The worker credential — what authority a worker holds, and how it is bounded #
Companion to docs/workers-design.md, which describes what a Peek worker is and settles that a
worker reaches Peek data through the /mcp/* API on apps/server. This document answers the
question that design left open: what exactly does a worker hold, and what stops it doing more
than it declared?
The short version, stated up front because the rest is the argument for it:
Peek already has an object-capability system. It is
apps/desktop/main/tile-tokens.tsplusapps/desktop/main/tile-ipc-gate.ts, and it works the way capabilities work inside one address space: an unforgeable opaque reference, an authority table the holder never sees, and a gate that consults the table on every call. The remote half —mcp_grantsinapps/server/grants.js— has the same shape but a far poorer vocabulary: three fields, where the local side checks tables, topics and domains per call.So the work is not to adopt an object-capability scheme. It is to stop the remote half being a regression on the local half. That means the grant carries what the manifest already declares, the credential lives where feature code cannot reach it, and denied operations are absent rather than refused. None of that needs a token format, a signature, or a new dependency.
A signed, offline-attenuable token — a macaroon, a UCAN, a biscuit — is not recommended, for a specific reason rather than a dismissive one: those schemes buy attenuation and verification without contacting the issuer, and in this system every call already contacts the issuer. Paying their costs to buy something already free is the wrong trade.
Contents #
- What Peek already has
- The unforgeable reference
- Attenuation
- Delegation and revocation
- Removing ambient authority
- What this does to the generic-table question
- Prior art, and why none of it is adopted
- The staged path
- Where this costs more than it is worth
- Open questions
1. What Peek already has #
Two capability systems exist in this codebase. They are not the same system, and the difference between them is the whole problem.
1.1 The desktop: a capability table behind an opaque reference #
apps/desktop/main/tile-tokens.ts generateToken() mints tile- followed by 24 random bytes and
stores a TokenRecord — { tileId, tileEntryId, grant, createdAt, ownerWebContentsId } — in a
main-process Map. Its own comment states the property that matters: "Tiles never see the grant
directly."
That is the classic shape. The token is a reference; the authority is an object in a protected
space; the holder can exercise it but cannot read, copy, widen or forge it. The CapabilityGrant
it points at comes from apps/desktop/main/tile-manifest.ts
resolveCapabilities(tileId, requested, isBuiltin), a pure function that reduces what a manifest
asked for to what policy allows — denying pubsub.system and sync to non-builtins, denying
filesystem outright to non-builtins, recording every refusal in denied[].
apps/desktop/main/tile-ipc-gate.ts runPipeline() is the gate every tile:<domain>:<verb> frame
passes: payload schema (validatePayload()), token presence, token validity
(getGrantForToken()), sender-frame cross-check, declared required capabilities, lifecycle state
(a stateProbe against the FSM in tile-fsm.ts), and sender role (roleForGrant()). Rejections
go through emitGateRejected(), rate-limited to one publish per second per (tileId, reason).
Enforcement goes below the capability name, and this is the part worth carrying over. The manifest's declared tables, topics and domains are checked per call against actual arguments:
apps/desktop/main/tile-datastore-scope.tsvalidateTileDatastoreRequest(token, tables)→hasDatastoreTable()tests every table named in a request againstgrant.capabilities.datastore.tables, and separately against a coreisValidTable()allowlist so an injection-shaped name never reaches SQL. Everytile:datastore:*handler calls it.tile:pubsub:publishinapps/desktop/main/tile-ipc.tsmatches the topic againstgrant.capabilities.pubsub.topicswith a small glob matcher — exact, trailing*, leading*— with anisInfraTopic()carve-out routingcmd:*/noun:*through thecommandscapability instead.apps/desktop/main/tile-network-enforcement.tsisNetworkDomainAllowed(grant, domain)matchesgrant.capabilities.network.domainswith*.example.comsuffix wildcards.
Three weaknesses, named because a remote contract should not inherit them:
- The capability check is mostly not in the pipeline.
registerTileIpc()accepts adescriptor.requiredCapabilities, but of the 215 registration sites intile-ipc.tsonly four use it, all four forrules. Every other channel gates itself inside its own handler body. That works, but "is this channel checked?" becomes a per-handler audit rather than a table anyone can read, and a newly added handler is unprotected by default rather than protected by default. TILE_STRICTis off by default.apps/desktop/main/config.tsreads it from the environment;handleViolation()throws only when it is on, and otherwise logs underDEBUG. The per-argument checks still return their error either way, so the enforcement is real — but a violation is close to silent in a normal run.- A violation never escalates.
apps/desktop/main/tile-violations.tspublishCapabilityViolation()publishestile:capability-violationglobally, rate-limited to one per(tileId, capability, op)per five seconds. Nothing revokes the token, unloads the tile, or counts repeats. A tile probing for authority it does not have can probe indefinitely.
1.2 The server: three fields and a bearer string #
apps/server/grants.js createGrant(userId, label, scopeTag, readonly, profileId) mints a 32-byte
random key (generateApiKey()), stores only hashApiKey()'s SHA-256 of it, and returns the raw
key exactly once. resolveMcpGrant() looks a presented key up by hash, rejects a row with
revoked_at set, bumps last_seen_at, and returns the grant. apps/server/auth.js
createMcpAuthMiddleware() puts userId, scopeTag, readonly, profileId and grantId on the
request context, and every /mcp/* handler enforces from there.
The enforcement that exists is good, and this document is not arguing otherwise.
apps/server/mcp-items.js scopeClause() is a SQL WHERE fragment, not a post-filter, spliced
into every read before GROUP BY/LIMIT, so paging counts only in-scope rows. getItemForRoute()
resolves an id through resolveItemId() — itself scope-confined — then re-applies scopeClause()
in fetchItemRowById() as defence in depth. An out-of-scope id returns not_found,
indistinguishable from a genuine miss, so scope cannot be probed by comparing 404 to 200.
readonly is checked in exactly one place, readonlyRefused() in apps/server/index.js, as the
first line of each of the eight mutating routes, before the body is parsed.
What is missing is vocabulary and binding:
scopeTag+readonly+profileIdis the entire authorization language. No per-table, per-field, per-item-type or per-operation axis.typeis a query filter, not a capability.- Possession of the bearer string is the whole of the authorization. No expiry column, no
client binding, no nonce, no proof of possession.
mcp_grantsholdsid, user_id, label, key_hash, scope_tag, readonly, profile_id, created_at, last_seen_at, revoked_atand nothing else.
1.3 The comparison, which is the argument #
| tile token (desktop) | mcp_grants (server) |
|
|---|---|---|
| the reference | opaque, 24 random bytes, Map lookup |
opaque, 32 random bytes, hash lookup |
| where authority lives | main-process TokenRecord.grant |
an mcp_grants row |
| can the holder read its own authority? | no | no, but it is only three fields |
| vocabulary | tables, pubsub topics and scopes, network domains, window verbs and URL prefixes, shortcut keys, oauth providers, feature-source types | one tag, one boolean, one profile |
| checks below the capability name | yes — table, topic, domain, per call | none |
| bound to a holder | yes — ownerWebContentsId, trust-on-first-use |
no |
| lifetime | dies with the tile (revokeTokensForTile()) |
until an admin revokes |
| revocation | immediate, a Map delete |
immediate, a revoked_at write |
Read the middle rows and the conclusion is not "Peek should adopt object capabilities". It is that a worker, today, would hold a less capability-shaped credential than the same feature holds on the desktop — and rented compute is exactly the setting where that regression matters most.
2. The unforgeable reference #
Decision: an opaque random string, resolved server-side, exactly as both existing systems already do. Not a signed token.
Unforgeability comes from entropy plus a lookup, not from a signature. generateApiKey() is 32
bytes of crypto.randomBytes; generateToken() is 24. Neither can be guessed, neither carries
readable claims, and neither can be edited into a wider one because there is nothing in it to edit
— the authority is a row, and the string is only a name for it. That is the strongest property in
either system and it survives unchanged.
Two things must be added around it.
2.1 The credential lives in the host, never in feature code #
This is the single most important mechanic in this document, and the desktop already proves it.
In apps/desktop/main/tile-preload.cts, the tile token arrives through the BrowserWindow's
additionalArguments and is captured in the preload's closure as tileToken. Every method on
window.app attaches it. Feature code — background.js — never has a reference to it, and cannot
obtain one, because contextBridge.exposeInMainWorld hands the renderer a bridged object rather
than the closure.
The worker host must reproduce exactly this. The credential is read from the runtime's secret
store by the host module, held in a closure, attached by the host's implementation of
globalThis.app, and never placed on any object the feature module can reach. In
apps/desktop/features/mcp-server/store/remote-store.js this is already true and already
correct: openRemoteStore({ remoteUrl, credential }) captures credential as token and the
inner request() closure is the only thing that reads it. Its comment already commits to the
discipline — the token is "never logged, never included in an error message or a thrown stack" —
and the unexpectedStatus() and unreachable-server error paths honour it.
What this buys, stated precisely, because it is easy to overclaim: a worker running hostile code cannot exfiltrate its credential. It can still exercise every authority the credential carries, from inside the process, for as long as it runs. So the credential boundary bounds where the authority can be used, not how well it is used. Which is the correct division of labour: what a worker may do is the manifest's job (§3), whether the author is trustworthy is the install decision's job, and neither is a token format's job.
2.2 The reference should be bound to its holder #
The tile token is bound: TokenRecord.ownerWebContentsId, set by setTokenOwner() right after
new BrowserWindow(), with trust-on-first-use for <webview> guests whose id is not available at
mint time. runPipeline() compares it to event.sender.id and rejects a mismatch as
sender-mismatch. A stolen tile token is useless from another frame.
The grant credential has no equivalent, and that is the honest gap: it is a bearer string that works from anywhere, forever, for anyone who obtains it — a deploy log, a crash dump, a backup of the runtime's environment.
The primitive to close it already exists in this repo. apps/desktop/main/atproto-proxy.ts
createDpopProof() implements DPoP end to end with no library beyond node:crypto's
webcrypto.subtle: an ES256 (P-256) keypair, a dpop+jwt header carrying the public JWK, a
payload of { jti, htm, htu, iat, exp: iat + 120, nonce?, ath } where ath is
base64url(sha256(access token)) from computeAth(), and a full nonce-retry loop in
performAtprotoRequest() driven by shouldRetryWithNonce() off the server's DPoP-Nonce header.
apps/desktop/features/lex/atproto.js generateDpopKeyPair() generates the key.
So binding a worker credential to a key the worker holds is a port of working code, not a
research project: the worker generates a keypair at first boot, registers the public JWK against
its grant, and request() in remote-store.js — already the single chokepoint where the
Authorization header is set — signs each request. createMcpAuthMiddleware() verifies the proof
against the JWK stored on the grant row.
This is deliberately not in the first version (§8). It is named here so that "bearer string" is a recorded, dated decision with a known upgrade path, rather than an oversight.
One caveat to carry forward: the existing DPoP private key is generated extractable: true and
persisted as JWK into the feature_settings table, so its d component sits in plaintext SQLite.
That is acceptable for a key on the user's own machine and is not acceptable as the model for a
key on rented compute. A worker's key should be generated non-extractable in the runtime and never
persisted — which also means it is regenerated on cold start, so the registration step has to be
cheap and idempotent.
And it leaves a bootstrap the design should not pretend away: registering a fresh public key is itself an authorized call, so a worker still starts from something bearer-shaped held in the runtime's secret store. Binding therefore narrows the exposure from "the credential is replayable forever by anyone who reads it" to "the registration credential is replayable until it is rotated", which is a real improvement and not a complete answer. The complete answer is a long-lived registration credential that can only register a key, and a short-lived bound credential that can do everything else — which is the shape atproto itself uses (§7.5).
3. Attenuation #
Decision: the manifest is the source of the restriction, resolveCapabilities() is the
reduction, and the server enforces it from the grant row. The restriction is not carried in the
credential.
3.1 The manifest is already the right source #
It exists, authors already write it, it is already validated, and it already has a policy layer.
validateTileManifest() enforces the shape and the limits — MAX_TILES_PER_MANIFEST,
MAX_NETWORK_DOMAINS (50), MAX_DATASTORE_TABLES (20). resolveCapabilities() applies policy:
PRIVILEGED_CAPABILITIES holds filesystem, BUILTIN_ONLY_CAPABILITIES holds sync, and
pubsub.system is builtin-only. Both are pure functions over plain data, already covered by
apps/desktop/main/tile-manifest.test.ts.
Inventing a second declaration for workers would mean two vocabularies for one concept, drifting.
This repo has already paid that bill once: apps/server/schema.json is recorded in
handoff-mcp.md as "a stale duplicate of packages/schema/v1.json", with the note that "changing
one and not the other is how they diverged."
So resolveCapabilities() and the capability types move to a shared package — alongside
packages/schema and packages/sync, which is the pattern this repo already uses for exactly this
problem — and both the desktop loader and the server's mint path call the same function. If the
reduction lives in two places it will diverge, and the divergence will be silent and
security-relevant, which is the worst combination.
3.2 Enforced by the server, from the row #
mcp_grants gains one column: the resolved CapabilityGrant, stored as JSON, written at mint time
by running the shared resolveCapabilities() over the worker's manifest.
createMcpAuthMiddleware() sets it on the request context beside scopeTag and readonly, and
handlers check against it.
Why in the row rather than in the credential:
- The verifier is the issuer. Every
/mcp/*call reachesapps/server, which minted the grant and owns the database. A credential that carries its own restrictions exists so a verifier who is not the issuer can check them without asking. There is no such verifier here. - The database is consulted anyway.
resolveMcpGrant()already does a hash lookup on every request to checkrevoked_at. Reading one more column from a row already being read is free. A signed token would add signature verification and still need that lookup, because revocation cannot be read off a token. - Narrowing takes effect immediately. A manifest that removes a table produces a narrower grant at the next deploy, and the old authority is gone the moment the row is rewritten. With a carried restriction, previously-issued credentials keep their old, wider text until they expire.
3.3 What the checks look like #
The same shape the desktop already uses, moved behind HTTP — each a small pure predicate over the grant, mirroring an existing one:
| Desktop | Server equivalent |
|---|---|
hasDatastoreTable(grant, table) |
table check on any generic-table route (§6) |
pubsub topic glob in tile:pubsub:publish |
topic check on the worker pubsub channel |
isNetworkDomainAllowed(grant, domain) |
no server involvement — see below |
readonlyRefused(c) |
unchanged; already correct |
scopeClause(scopeTag, alias) |
unchanged; already correct |
Two honest gaps in that table:
networkcannot be enforced by the server, because the server is not in the path when a worker callsfetch().docs/workers-design.mdalready records this: the host must replace the global, orcapabilities.network.domainsis a disclosure rather than a control. This is the one capability whose enforcement genuinely sits on the untrusted side, and no credential design fixes it.createTablesis a dead declaration.DatastoreCapability.createTablesis parsed byvalidateTileManifest()and copied into the grant byresolveCapabilities(), and no handler anywhere consults it. It should be removed rather than ported, so the remote vocabulary does not start life with a field that means nothing.
3.4 Where the check belongs #
In the middleware chain, not in the handler bodies. The first weakness named in §1.1 is that the
desktop's checks live in 215 handlers of which four declare what they need, so a new handler is
unprotected until someone remembers. The server has the chance to do the opposite: a route declares
the capability it requires at registration, and a route that declares nothing is refused rather
than allowed. Hono middleware makes this cheap, and readonlyRefused() already proves per-route
gates work here — it just has to become declarative rather than a call someone might forget to
write.
4. Delegation and revocation #
4.1 Delegation: the server mints, nobody attenuates client-side #
Decision: a worker that wants to hand out a narrower capability asks the server for one. It cannot make one itself.
The mechanism is a new row with a parent. mcp_grants gains parent_grant_id; a
POST /mcp/grants/derive route takes a requested capability set, checks it is a subset of the
caller's own, and mints a child. The subset check is the same shared resolveCapabilities()
reduction from §3.1, applied with the caller's grant as the ceiling instead of the policy defaults.
Two things in docs/workers-design.md need this:
- An agent calling in.
call_workershould not hand the agent the worker's authority. What the agent gets is a child grant carrying only the command it invoked and the results of that command. - A worker calling a worker. Ruled out of scope in the design for good reasons — cycles, cold-start storms, unbounded fan-out — but if it ever arrives, the credential model should not be the thing that blocks it.
Client-side attenuation, which is the property macaroons and UCANs exist to provide, buys neither, because the party that would attenuate offline is talking to the issuer anyway.
4.2 Revocation is where a database wins outright #
This is the strongest argument in the document, and it is worth being blunt: the existing bearer grant already has the best revocation story available, and every token scheme in §7 is worse at it.
revokeGrant(userId, grantId) writes revoked_at and the credential is dead on the next request —
immediate, complete, no expiry to wait out, no revocation list to distribute, no cache to bust.
resolveMcpGrant() already checks it. The desktop mirror, revokeToken() /
revokeTokensForTile(), is a Map delete with the same property.
With parent_grant_id in place, revocation becomes transitive for free: revoking a grant revokes
its subtree with one recursive query. A scheme that attenuates offline cannot offer that, because
the issuer never learned the child existed.
Four triggers, each of which should be wired rather than assumed:
| Trigger | Action |
|---|---|
| worker uninstalled | revoke the grant and its subtree — the analogue of revokeTokensForTile() |
| manifest narrowed | rederive the grant from the new manifest; the row is rewritten, not edited, so removed authority is gone at the next deploy |
| manifest widened | mint nothing automatically — a widening is a fresh consent decision, matching how PRIVILEGED_CAPABILITIES already requires explicit approval |
| compromise | revoke; the subtree goes with it |
The second row is the one most likely to be got wrong. A grant must be derived from the manifest,
never edited in place, so the row and the manifest cannot disagree. describe() and
/mcp/session already exist to catch it when they do — docs/mcp-remote-backend-design.md §4.3
has startup refuse to serve when the on-disk config claims a scope the store disagrees with, and
the same check should cover the capability set.
4.3 What is still missing without expiry #
mcp_grants has no expires_at. For a credential held by a long-lived install that is defensible;
for one sitting in a rented runtime's environment it is weak, because the window between a leak and
someone noticing is unbounded. Adding an expiry with a refresh is cheap and is listed in §8's second
stage. It is not first-version work only because revocation already covers the case where someone
knows.
5. Removing ambient authority #
Four concrete changes, each making "the credential is the authority" true rather than aspirational.
5.1 openStore() returns only what the grant permits #
Today PeekStore in apps/desktop/features/mcp-server/store/types.js is a flat interface of
seventeen methods, and every holder of a store object can call all of them. A worker granted only
items can still call listTags(); a readonly worker can still call createItem() and gets a 403
back. That is ambient authority one layer below the credential — the object confers more than the
grant does, and only the far end says no.
Decision: openStore() builds the object from the grant. A method the grant does not permit is
absent, not a rejecting stub.
This is the decision docs/workers-design.md makes about api.window and api.shortcuts being
undefined rather than stubbed, applied one layer down and for the same reason: a TypeError at
the call site is a better failure than a plausible negative answer the caller carries onward. It
also makes the authority legible — a worker can enumerate what it holds by looking at the object
it was handed.
The mechanism already exists. describe() calls GET /mcp/session, which returns what the server
believes about the grant. Adding the capability object to that response is a few lines in
apps/server/index.js, and it becomes the input openStore() builds from. One ordering
consequence: describe() stops being an optional consistency check and becomes a required
bootstrap call, because nothing else can tell the client what to construct.
5.2 StoreConfig.scopeTag and readonly become hints, explicitly #
StoreConfig in types.js carries scopeTag and readonly as client configuration. They must
never be read as authorization — docs/mcp-remote-backend-design.md §4.2 already argues this — but
the typedef does not say so, and an implementer reading only the types would not know. Two changes:
document them as fail-fast hints whose only job is to disagree loudly with /mcp/session, and have
openRemoteStore() refuse to open on a disagreement rather than silently preferring one.
5.3 The /mcp/* vocabulary grows past three fields #
createMcpAuthMiddleware() sets scopeTag, readonly, profileId, grantId, userId. It gains
capabilities. Routes that today check nothing beyond scope gain a declared requirement (§3.4).
This is the bulk of the actual work, and it is ordinary route work rather than cryptography.
5.4 createGrant() stops taking a fixed five-tuple #
createGrant(userId, label, scopeTag, readonly, profileId) hard-codes the whole authorization
vocabulary into its signature. It becomes createGrant(userId, label, { capabilities, parentGrantId, expiresAt }), with scopeTag and readonly as members of the capability object
rather than peers of it. resolveMcpGrant() returns that object; listGrants() surfaces it, so an
admin sees what a grant can actually do rather than three fields that no longer describe it.
One thing that must not change: the separation of credential namespaces apps/server/grants.js
already enforces. Its comment explains it — a device key and a grant key never resolve against the
same table, "so one credential can never serve both the sync routes and /mcp/*", because "a
device is unscoped by nature". A worker credential is a third namespace and should stay one. The
reasoning that keeps a phone's sync key from being a whole-store MCP key is exactly the reasoning
that keeps a worker's grant from being either.
6. What this does to the generic-table question #
docs/workers-design.md left this fork: add capability-scoped table routes to /mcp/*, or declare
a worker's datastore items-and-tags-only and accept that features keeping state elsewhere cannot be
workers.
The fork was mis-framed, and looking at the schema dissolves most of it.
api.datastore.getRow / setRow / getTable do not create tables. apps/desktop/main/datastore.ts
declares a fixed schema — items, item_tags, item_events, item_visits, item_groups,
item_group_members, tags, content, blobs, settings, feature_settings, scripts_data,
feeds, themes, rules, manifest_cache, features_history, lazy_wake_shortcuts,
lazy_wake_declared, migrations — and hasDatastoreTable() checks a requested name against both
the grant's list and a core isValidTable() allowlist. There is no bring-your-own-table facility;
createTables is the dead flag from §3.3.
So "generic table access" means "reach one of a fixed set of shared tables", and that splits into two questions with different answers.
Per-worker private state does not want a table at all. feature_settings(id, featureId, key, value, updatedAt), with a unique index on (featureId, key), is already the per-feature key-value
store, reached through api.settings and api.extensions.getSettings. It is confined by
construction: the row key is the feature id, so a grant naming one worker cannot address
another's rows, and no capability list is needed to make that true. This is the cleanest
object-capability shape in the codebase and it already exists. The work is to port the table to
apps/server — whose settings table is a flat global (key, value) holding datastore_version,
not a per-feature one — and expose it as a route whose feature id comes from the grant and never
from the request.
Shared tables are a per-table decision the grant can already express. rules, themes,
item_groups and the rest hold state with real cross-feature meaning. Each one a worker needs gets
a server-side equivalent and a route gated on capabilities.datastore.tables — the same predicate
hasDatastoreTable() already implements — added one at a time, when a feature needs one.
So the security objection to the "add table routes" branch disappears, because the manifest can
name exactly which tables and the grant can carry that. What remains is a plain schema-design cost:
apps/server has five tables (items, tags, item_tags, item_events, settings) against the
desktop's twenty, and every one a worker needs is a schema addition, a sync decision, and a row in
docs/sync-coverage-gaps.md. Real work, but ordinary work, and incremental instead of a gate on
the whole worker design.
Revised answer to the fork: neither branch. A first worker gets items, tags and events, which
exist, plus per-worker private state through a ported feature_settings, which is small,
self-confining, and unblocks the common case. Shared tables arrive individually, on demand, gated
on the declaration the manifest already carries.
7. Prior art, and why none of it is adopted #
Everything below was fetched on 2026-08-18. Two provenance caveats, recorded because a security argument that cites a source it did not read is worse than one that admits the gap:
erights.orgwas unreachable (connection refused on every path). The object-capability claims here come from a mirror of "Capability Myths Demolished" (Miller, Yee, Shapiro, dated 2003-01-27) atpapers.agoric.com/assets/pdf/papers/capability-myths-demolished.pdf, read through a text-extraction proxy, plus the Wikipedia articles on the object-capability model and ambient authority. Treat the wording as high-confidence rather than byte-exact.biscuitsec.org's apex domain was unreachable and its revocation page returned 403. Biscuit claims come fromdoc.biscuitsec.organdSPECIFICATIONS.mdingithub.com/biscuit-auth/biscuit. Its revocation-list distribution story is therefore unverified, not absent.
7.1 What the object-capability model actually asks for #
Ambient authority is defined as "authority that is exercised, but not selected, by its user" — a request that succeeds by naming a resource, with nothing accompanying it that says which permission authorized it. The connectivity rule is "an object A can send a message to B only if object A holds a reference to B", summarised as "only connectivity begets connectivity."
Two things follow that are directly useful here.
Peek's desktop model already satisfies the definition. A tile cannot name a table it was not
granted, because hasDatastoreTable() consults the grant behind the token rather than the tile's
own claim about itself. The authority is designated by the reference, not by the resource's name.
And the literature's own answer to revocation is exactly what a grant row is. The paper is
explicit that "capabilities themselves are not literally revocable"; revocable access is built by
indirection — a forwarding facet handed to the holder, backed by a revoking facet the grantor
keeps, so severing the second kills the first while the holder still possesses it. A credential
string that means nothing without a live row in mcp_grants is that pattern: the string is the
forwarder, revokeGrant() is the revoker. Peek is not choosing convenience over the model here; it
is implementing the model's own recommended construction with a database.
One correction to a common shorthand: the paper distinguishes object capabilities from access control lists with seven properties, not three — the extraction surfaced them as no designation without authority, dynamic subject creation, subject-aggregated authority management, no ambient authority, composability of authorities, access-controlled delegation channels, and dynamic resource creation. The letter-to-name pairing came through a PDF proxy and should be checked against the original before anyone quotes it as a checklist.
7.2 Macaroons #
"Macaroons: Cookies with Contextual Caveats for Decentralized Authorization in the Cloud",
Birgisson, Politz, Erlingsson, Taly, Vrable and Lentczner, NDSS 2014
(research.google/pubs/macaroons-cookies-with-contextual-caveats-for-decentralized-authorization-in-the-cloud/).
Mechanism: "nested, chained MACs (e.g., HMACs)". Each caveat appends a predicate and re-MACs, with
"the signature… the MAC of the pair vId :: cId, using the existing signature as the MAC key", which
github.com/rescrv/libmacaroons summarises as making it "really easy to add a caveat, but
impossible to remove a caveat". Attenuation needs nobody: "new, more restricted authorization
credentials can be directly derived from macaroons, without proxying requests to the target
service, or re-minting new credentials from it."
The two facts that decide it for Peek:
- Verification is issuer-only anyway. "Only the target service that originally mints a macaroon can check its validity. Specifically, the verification of an HMAC-based macaroon requires knowledge of its root key." So a macaroon does not buy third-party verification — it buys offline attenuation against a verifier who is still the issuer. Peek's verifier is the issuer, and Peek has no party that wants to attenuate without asking.
- Revocation is left to the caller. The paper enumerates four approaches rather than providing
one: "(i) using very short-lived credentials, (ii) allowing the addition of freshness
constraints, (iii) relying on external, authoritative state (such as revocation lists, or epoch
counters), and (iv) splitting credentials".
libmacaroons's README does not discuss revocation at all. Option (iii) is a database, which Peek already has and already consults.
7.3 UCAN #
github.com/ucan-wg/spec, v1.0.0. DAG-CBOR envelopes with iss / aud / sub / cmd / args /
nonce / exp, signed with DID keys: "A UCAN is signed by the sender (the iss field DID) and can
only be created by an agent in possession of the relevant private key." Delegation chains validate
locally, narrowing only — "Every unique delegated capability MUST have equal or narrower
capabilities from their delegator."
Revocation is a separate specification (github.com/ucan-wg/revocation, v1.0.0-rc.1) built on
append-only revocation stores keyed by canonical CID, deliberately partition-tolerant and
gossip-propagated: a server "MAY have an endpoint that lists the revoked UCANs by canonical CID", a
CRDT filesystem "SHOULD maintain the revocation store directly at a well-known path", and
revocations may arrive before the delegation they revoke. It also fixes that "Revocations MUST NOT
be reversible" — an erroneous revocation is repaired by issuing a fresh delegation, not by undoing.
For a single-user personal store served by one server, this is a large amount of distributed-systems
machinery to reproduce what revoked_at already does synchronously and reversibly.
One genuinely relevant idea to borrow without the format: the command-hierarchy check —
delegation[n+1].cmd must extend delegation[n].cmd — is the subset test §4.1 needs, and Peek can
run it against rows. (Provenance note: that formulation was found in the ucan-wg/rs-ucan
implementation's design notes rather than in the canonical spec README.)
7.4 Biscuit and SPKI/SDSI #
Biscuit (doc.biscuitsec.org, github.com/biscuit-auth/biscuit SPECIFICATIONS.md) is
"an authorization token that is signed with public key cryptography (like JWT)", an append-only
Ed25519 block chain carrying Datalog checks, with "offline attenuation (like Macaroons)" and
genuine third-party verification — "any verifier for the token needs only to know the corresponding
public key". Revocation is a revocation_id(<index>, <byte array>) fact per block that a verifier
compares against externally supplied revocation lists; the fetched specification defines the
identifier and not how such lists are distributed. Biscuit is the most attractive of the three on
technical merit and remains the wrong fit for the same reason as the others: its headline features
are offline attenuation and public-key verification, and Peek needs neither.
SPKI/SDSI, RFC 2693 (September 1999,
rfc-editor.org/rfc/rfc2693), is worth a paragraph because its framing is the right one and its
mechanism is not. It is authorization-first rather than identity-first — "the application needs to
know if the keyholder is authorized for some access. This is the primary job of a certificate" —
reducing credentials to a five-tuple of issuer, subject, a delegation boolean, an authorization
S-expression, and validity dates. Delegation is that single boolean, not a narrowing algebra.
Revocation is timed CRLs with non-intersecting validity ranges. The RFC describes itself as giving
"the theory behind SPKI certificates… without going into technical detail", and notes that "the
world is not going to switch to SPKI certificates overnight". Peek's mcp_grants row, extended per
§3 and §4, is a five-tuple of the same kind with the delegation boolean replaced by a parent
pointer — which is the useful half of the idea without the certificate format.
7.5 atproto, which is already in this repo #
Worth a section of its own because the dependency is already present and the primitives are already written.
DPoP is implemented. apps/desktop/main/atproto-proxy.ts createDpopProof() builds and signs
a dpop+jwt with ES256 (P-256) via node:crypto's webcrypto.subtle — no jose, no
jsonwebtoken, neither of which is a dependency anywhere in the repo — carrying
{ jti, htm, htu, iat, exp: iat + 120, nonce?, ath }, with computeAth() producing the
access-token hash and shouldRetryWithNonce() driving the server-nonce retry loop in
performAtprotoRequest(). apps/desktop/features/lex/atproto.js does full PKCE (S256) plus a
Pushed Authorization Request on top of it. This is the §2.2 binding primitive, already working.
atproto's own permission vocabulary is close to Peek's manifest. atproto.com/specs/permission
types permissions by resource — repo, rpc, blob, account, identity, and a meta-resource
include — and states that "each resource type has a defined set of parameters that can attenuate
the permission": repo takes a required collection list and an optional action subset of
create/update/delete; rpc takes a required lxm list of method NSIDs and an aud service
reference. That is per-resource-type, per-operation attenuation with a positional required
parameter — structurally the same idea as datastore: { tables: [...] } and
window: { create, query, manage, urls }, and worth reading before extending Peek's own vocabulary,
since the shape has been thought about carefully by people solving the same problem.
And atproto's answer to inter-service authority is the one this document recommends.
atproto.com/specs/xrpc documents inter-service authentication as a JWT signed with "the account's
signing key (the same used to sign repo commits)", bound by an lxm field — "short for 'lexicon
method'… Indicates the endpoint that this token authorizes" — and required "for all authenticated
XRPC endpoint requests". The lexicon
com/atproto/server/getServiceAuth.json adds a required aud audience DID and an optional exp
that "defaults to 60 seconds". The spec's own note on why: "Should be a short time window, as
revocation is not implemented. 60 seconds is a good token lifetime."
Read that alongside §4.2. atproto, having built the signed-token machinery, chose server-issued, narrowly-bound, short-lived tokens rather than client-side attenuation — and had to accept "no revocation" as the cost. Peek is in the strictly better position of already having revocation, and should not trade it away.
The lxm idea itself is worth stealing for §4.1's derived grants: a child grant minted for an
agent's call_worker should name the single command it authorizes, and expire in seconds.
7.6 The decision, in one table #
| needs a verifier who is not the issuer | needs offline attenuation | needs delegation without the issuer | revocation | |
|---|---|---|---|---|
| Peek's actual requirements | no | no | no | must be instant, certain, reversible |
| Macaroons | no (issuer-only by construction) | yes | yes | external — four suggested approaches |
| UCAN | yes | yes | yes | a separate spec, gossiped stores, irreversible |
| Biscuit | yes | yes | yes | identifier defined, distribution unverified |
| SPKI/SDSI | yes | partly | boolean only | timed CRLs |
| a grant row | — | — | — | one UPDATE, effective on the next request |
Every row but the last buys properties Peek does not need, and every one of them is worse at the single property Peek does need.
One thing worth conceding. Signed tokens are strictly better in a world where apps/server is
not the only verifier — a second Peek backend, a federated instance, a worker runtime that wants to
check authority before making a request. None of those exist. If one arrives, the migration is real
work, and §3.2's decision is the one that would have to be revisited. That is a known, dated
tradeoff rather than an oversight.
8. The staged path #
8.1 What a first worker genuinely needs to be safe #
All of it is rows, closures and route guards. No cryptography, no new dependency, no token format.
- The host holds the credential; feature code never sees it. (§2.1) The pattern is
tile-preload.cts's closure overtileTokenandopenRemoteStore()'s closure overcredential. This is the highest-value item and it is nearly free, because both existing implementations already do it — the requirement is that the worker host not undo it. - The grant row carries the resolved manifest capabilities. (§3.2) One JSON column on
mcp_grants, written at mint time by the sharedresolveCapabilities(), put on the request context bycreateMcpAuthMiddleware(). resolveCapabilities()and the capability types move to a shared package. (§3.1) Without this, the desktop and the server reductions drift, exactly asapps/server/schema.jsondrifted frompackages/schema/v1.json.openStore()returns only the permitted methods. (§5.1) Requires the capability object onGET /mcp/session.- Routes declare the capability they require, and an undeclared route is refused. (§3.4)
- Grant lifetime is tied to the install — minted on install, revoked on uninstall, rederived on a manifest change. (§4.2)
- One grant per worker. Never shared between workers, never a device key, never the user key.
The namespace separation
grants.jsalready enforces extends to a third namespace.
Everything above is a precondition for running anyone's feature code remotely, which
docs/workers-design.md settles as the destination: third-party features can be workers, gated on
the publisher verification in apps/desktop/main/atproto-verify.ts. The first version runs bundled
features only, and during that stage items 1, 2, 4 and 6 carry most of the value, because they are
what makes a mistake in a trusted feature bounded rather than total.
8.2 Second stage, once a first worker runs #
- Holder binding via the DPoP primitive already in
atproto-proxy.ts(§2.2), with the registration-credential caveat that section records. - Expiry and refresh on
mcp_grants(§4.3). - Derived grants —
parent_grant_id, a subset check,POST /mcp/grants/derive— which is what makescall_workersafe to expose to an agent (§4.1). - Short-lived, single-command child grants for agent invocation, taking the
lxmidea from atproto's inter-service auth (§7.5). - Violation escalation, which the desktop also lacks (§1.1): count violations per grant and revoke on a threshold, rather than publishing an event nobody reads.
8.3 Probably never #
- Macaroons, UCANs, biscuits, or any offline-attenuable signed token, unless a verifier appears that is not the issuer (§7.6).
- Per-field authorization.
scopeTagplus per-table plus readonly is already finer than anything Peek's data model distinguishes. - A policy language. Biscuit's Datalog is the right tool for a system with many resource kinds and many principals. Peek has one user.
9. Where this costs more than it is worth #
Stated plainly, because a design that only lists benefits has not been thought about.
The sequencing risk this section used to raise is closed. The open question was whether the server-side vocabulary of §5.3 defends against a threat anyone had accepted: if only features shipped in the app could ever be workers, the code holding the credential is code the same people wrote, and a capability system defends against in-house bugs rather than against intent. Third-party features can be workers, so the vocabulary is earned and the question of building it against a hypothetical threat does not arise.
What remains is a staging cost rather than a design risk. The first version runs bundled features
only, so the vocabulary is built before the population it defends against exists, and §8.1 item 4
— a wrong method call surfacing as a TypeError rather than a silent 403 — is most of what it buys
in the interim.
Per-argument enforcement has a real ongoing cost. The desktop's version is 215 handlers with
their checks written by hand, and §1.1 records what that produced: four handlers declaring their
requirements and the rest self-policing. Repeating the pattern server-side means every new /mcp/*
route is a place to forget a check. §3.4's declarative middleware is the mitigation and it is not
free either.
Shared-package extraction is real, but not because of Electron coupling.
apps/desktop/main/tile-manifest.ts imports only node:fs and node:path, both used by
parseManifestFile() alone — nothing in the tile layer touches Electron, and that is a property
the design keeps by intent rather than by accident: a tile's manifest describes what it may do,
and the host enforcing it is meant to be replaceable, which is exactly what a worker runtime
depends on. The real obstacles are the module system split (apps/desktop is TypeScript ESM;
apps/server declares "type": "commonjs" and sits outside the root workspaces list) and the
deployment boundary — apps/server is subtree-split and deployed standalone, so it can't import
packages/ at runtime and carries its own copy instead (see the comment atop apps/server/db.js).
The precedent already in this repo, packages/schema/v1.json projected into
apps/server/schema.json by packages/schema/codegen.js and guarded by
packages/schema/check-freshness.js, projects data; the capability model is logic, and
closing that gap is the extraction: packages/capabilities holds the model as plain JavaScript
with hand-written index.d.ts types (apps/desktop/main/tile-manifest.ts re-exports it
unchanged), and packages/capabilities/codegen.js generates the checked-in CommonJS
apps/server/capabilities.js the same way the schema package generates its own projection.
And the thing none of this fixes: a worker misusing what it legitimately holds. A feed worker
granted network: { domains: ["*"] } and read access to items can post the user's items anywhere
it likes, and every check in this document passes. Narrowing domains helps only if authors
narrow it, and feeds today declares ["*"]. The defences against that are provenance — which
this repo already has and which is stronger than expected, since
apps/desktop/main/atproto-verify.ts verifies a publisher's commit signature against the key that
was active at the commit's rev via the PLC audit log, walks the MST to the record, and re-hashes
every block, with "no unverified fallback" — plus the install-time consent decision, plus
revocation. A credential design contributes the third of those three and should not be sold as
contributing the first two.
10. Open questions #
-
Does the manifest need a worker-specific capability vocabulary at all, or is the tile one enough? §3.1 assumes reuse. But several capabilities are meaningless remotely (
window,shortcuts,izui) and at least one remote concern has no local equivalent — how much a worker may spend, in requests or wall time. Settled by: writing the manifest for one real worker and seeing what is missing. -
What is the ceiling for a derived grant, exactly? §4.1 says a child must be a subset of its parent, computed by the shared reduction. "Subset" is obvious for
tablesandtopicsand unclear forscopeTag— is a child scoped towork:projecta subset of a parent scoped towork?scopeClause()already treats<scope>:-namespaced child tags as in scope, which suggests yes, but that is a read-path convention, not a stated rule about authority. Settled by: writing the subset predicate and its tests, at which point the ambiguity becomes unavoidable. -
Where does the worker's registration credential live, and who can read it? §2.2 narrows bearer exposure without eliminating it, and §8.1 leaves the credential in the runtime's secret store. Cloudflare secrets bindings and celld's operator environment have different properties, and
docs/worker-runtime-portability.mdmakes both adapters behind one host contract, where reading the credential is a named operation (readCredential) whose implementation is the platform's secret storage — a 0600 file on a bare Node process, a secret binding on Cloudflare. Settled by: deciding whether a worker's grant is per-deployment or per-install — which is really the question of whether a hosted Peek runs everyone's workers in one account. -
Should the desktop adopt the fixes this document proposes for the server? §1.1's three weaknesses — checks in handlers rather than the pipeline,
TILE_STRICToff by default, no escalation — are desktop problems that a worker design has no business fixing, but they are also the reason the server should not copy the pattern. Settled by: a separate decision, on the desktop's own timeline. Named here so the divergence is deliberate rather than accidental. -
Does
feature_settingssync? §6 makes a portedfeature_settingsthe answer for per-worker private state, which means a worker's state has to reach the desktop like anything else.docs/sync-coverage-gaps.mdis the inventory and does not currently cover it. Settled by: the same policy call that owns every other"sync": falsedecision. -
What happens to a running worker when its grant is revoked mid-flight? §4.2 makes revocation effective on the next request, which is correct for the credential and says nothing about the instance. A worker mid-alarm-handler will start getting 401s from a store it opened successfully.
remote-store.jscurrently treats a non-transport HTTP error as a thrown tool error, which is probably right, but nothing tells the runtime to stop rescheduling the worker. Settled by: deciding whether revocation also cancels the schedule, which is a runtime-control-plane question rather than a credential one.