Per-user connections
A plugin that talks to a service each user runs or owns — their own gateway, instance or account — declares a connection type. Every user then adds their own connection in Settings → Secrets → Connections: an address, the fields your type asks for, and keys that go to the vault. Nothing is written in timon.config.json.
Requires apiVersion: 7.
Declare a type
"timon": {
"id": "acme-gateway",
"apiVersion": 7,
"permissions": ["connections:register", "connections:users:read", "http:outbound"]
}
export function apply(ctx) {
ctx.connections.registerType({
name: 'gateway', version: 1,
label: 'Acme gateway',
configSchema: z.object({ workspace: z.string().optional() }).strict(),
fields: [{ key: 'workspace', label: 'Workspace' }],
secrets: [{ name: 'apiKey', label: 'API key', required: true }],
// Optional: what the "Test" button does. `fetch` stays on this connection's address.
async test({ connection, secret, fetch }) {
const response = await fetch(new URL('health', connection.url), {
headers: { authorization: `Bearer ${await secret('apiKey')}` },
});
return { ok: response.ok };
},
});
}
The type id is qualified: acme-gateway:gateway@1. It disappears when the plugin unloads; the connections users created stay.
Use the connection of one account
const [connection] = await ctx.connections.forUser(ownerUserId);
const key = await ctx.connections.userSecret(ownerUserId, connection.id, 'apiKey');
const response = await ctx.fetch(new URL('v1/runs', connection.url), {
headers: { authorization: `Bearer ${key}` },
});
forUserreturns the connections of that account for your plugin, with the names of their secrets, never the values. Inside a request, it only accepts the signed-in caller.userSecretreturns a key only for a connection that is approved and enabled.ctx.fetchreaches the address of an approved connection even when it is not in the operator'spluginPolicy.http.allow.
Approval and network rules
A new connection waits for an administrator, who approves it in the same Settings pane. An operator can let users approve their own with pluginPolicy.http.connectionApproval: "owner". Changing a connection's address, or its local network flag, asks again.
Timon checks the address before each request and each redirect:
- link-local and cloud metadata addresses, multicast and
0.0.0.0are never reachable; - loopback, private network and Tailscale addresses only when the user ticked This service is on my machine or local network;
- HTTPS is required, except towards loopback and Tailscale addresses.
A refused address rejects with an error that names the rule, never a key. A connection address has no query string or fragment: keys go in the secrets. Only a signed-in person adds, changes, approves or removes a connection; an agent's token cannot.
When your plugin is removed
timon plugin remove keeps the connections but sends them back to approval and deletes their keys. After a reinstall, forUser returns them with approval: "pending" and no secret names, ctx.fetch refuses their addresses, and userSecret returns undefined until an administrator approves them again and each owner re-enters the key. Handle a connection with no key: say so to the user rather than sending an empty credential. A plugin updated in place keeps its connections. The same happens without that command when a different package takes your plugin's id, before the new package runs: a different npm name, or an unsigned copy of a signed package. The signed release of a package installed unsigned keeps its connections, like any update.
Your own form
Without anything else, Settings builds a form from fields and secrets. To replace it, your browser half registers in the keyed slot settings.connection.form, under the type id, and reads useConnectionForm() from @timon/ui:
import { useConnectionForm } from '@timon/ui'
function GatewayForm() {
const form = useConnectionForm() // { type, mode, initial, approvalRequired, submit(input), cancel() }
const initial = form?.mode === 'edit' ? form.initial : null
// … your fields, prefilled from `initial` when editing, then:
// await form.submit({ label, url, secrets: apiKey ? { apiKey } : {} })
}
export function apply(ctx) {
return ctx.slots.register({ slot: 'settings.connection.form', key: `${ctx.id}:gateway@1` }, GatewayForm)
}
Declare settings.connection.form in client.slots. Only the plugin that declared the type is shown there.
The same form adds and edits a connection:
modeiscreateoredit. A form that ignores it keeps working as a create form, and an older Timon does not set it: read a missingmodeascreate.initialis the connection being edited,nullwhen creating. It carries the names of its secrets (initial.secrets), never their values: leave the secret fields empty and tell the user that empty keeps the current key.- On an edit,
submitsends only what differs frominitial; a secret left empty or absent keeps the stored one. approvalRequiredsays whether saving waits for an administrator. When it is true, warn before saving that a new address or a change of local network asks for approval again.