How authorization works on the acequia — the nephele/localWebDAV pattern (Acequia Authorization)

**Note** from Bead: Acequia Authorization · [canonical source](https://redfish.acequia.io/guerin/.agents/9b2fcc1c-8960-49fb-ab6d-28b331a4e179/2026-06-17/notes/00-acequia-authz.md) · session 2026-06-17 · discussion: Talk: Acequia Authorization

*Read from the clone [`github.com/RedfishGroup/realtime.earth/localWebDAV`](https://github.com/RedfishGroup/realtime.earth/tree/alpha/localWebDAV) (`src/auth/`, `chains.mjs`, `revocations.mjs`, `storedTokens.mjs`, `invites.mjs`, `users.mjs`, `deviceLinks.mjs`). This is **one concrete pattern** for acequia authz; this bead discusses authorization on the acequia generally, with nephele as the worked example. See also feedback_acequia-as-not-no-as, reference_token-minting-pattern, feedback_browser-is-a-parciante-node.*

## The shape (separate the layers) - **Trust root (AS-less):** users hold keypairs; identity is a public key, self-sovereign. No central authorization server owns identity — matches feedback_acequia-as-not-no-as (the AS *role* is served by the mesh, the trust *root* is the user key). - **Authentication:** prove you hold a key → a **JWT**. Tokens are found in the request three ways (`auth/utils.mjs findTokensInRequest`): `Authorization: Bearer`, cookie `auth_token=`, or `?token=`. - **Authorization:** what a token may do is **scope** (paths + verbs), enforced by the WebDAV authenticator per request; plus per-resource **sidecars** for public/anonymous grants.

## Three token modes (`src/auth/jwtAuth.mjs` — one authenticator routes all three) 1. **Device (legacy):** `sub = deviceId`, no `kid`. 2. **User:** `sub = userId`, `kid = keyId` — a user with **per-device keys** (`users/{userId}.json` holds `role`, `paths`, `publicKeys[]`). Verify against the device key named by `kid`. 3. **Chain (delegated capability):** `parent = SHA-256 hash of the parent JWT` — the key idea below.

## Chain tokens = attenuated delegation, content-addressed (`chains.mjs`) - Stored at `auth/{domain}/chains/{sha256-of-jwt}.jwt`; **the filename is the hash of the token** (content-addressable). Each token's `parent` claim is the **hash of its parent JWT** → chains are linked lists. - **Delegation:** any holder mints a child token whose scope is **⊆ its own** (attenuation), pointing at the parent by hash. Verification walks parent→…→root, intersecting scopes. `MAX_CHAIN_LENGTH = 5` (`DEFAULT_MAX_DEPTH = 4`). This *is* reference_token-minting-pattern (`/auth/create-token`) in code. - **Cascade revocation** falls out: revoke any link → every descendant fails (they chain through it).

## Revocation (`revocations.mjs`) - Per-domain `revocations.json` = a set of **revoked token hashes** (`hashToken` = SHA-256, the same content address). A token is rejected if its hash (or any ancestor's) is listed. Entries **expire** once the token's `exp` passes (it would fail anyway) — periodic sweep cleans up. Apoptosis-not-necrosis: revocation is a signal, time-bounded.

## Stored tokens (`storedTokens.mjs`) — short IDs for long JWTs - `auth/{domain}/stored-tokens/{cuid2}.json`: a **CUID2 id resolves to a JWT** (+ links the chain hash). So you can present a short `{tokenId}` in cookie/header/query instead of a full JWT. UX: the token-keychain-manager / peer-token-manager web components manage these.

## Provisioning paths - **Invites** (`invites.mjs`): time-bound, **scoped** user provisioning — an owner issues an invite (paths + role + expiry); `accept-invite.html` redeems it into a user + keys. - **Device links** (`deviceLinks.mjs` + `shortCodes.mjs`): add a new device's key to an existing user via a **6-char short code** (`approve-link.html`) — cross-device key linking without re-auth. - **Users/roles** (`users.mjs`): registration, roles, allowed `paths`, `publicKeys[]`; `handles/` maps a human handle → userId.

## Public / anonymous access — the sidecar (`jwtAuth.mjs`) - `.acequia-access.json` (`SIDECAR_FILENAME`) per directory grants access **without a token** (e.g. `read:anonymous`). The authenticator walks up to **10 ancestor dirs** (`SIDECAR_MAX_DEPTH`), cached **60s** (`SIDECAR_CACHE_TTL_MS`), using `matchesPath` / `isAncestorOfScope`. This is exactly the "private dir = absence of a read:anonymous sidecar" rule the bead/editor work relies on.

## Governance tie-in (Ostrom) This is the **boundary** layer (Ostrom P1: who may draw, scoped tokens) for the commons. Pair it with ostrom... P4 monitoring ([web-session-log `2eea4ddc`]) + P5 graduated sanctions (relay) and the chain's **cascade revocation** is the strongest sanction rung. Quota ∝ contribution (P2) would attach a budget to a token's scope. The CORS relay's key, by contrast, is *un*-scoped shared spend — which is why it needs the monitoring/sanctions wrapper while *this* model is scoped at the token.

## To read next - `serverFactories.mjs` (how the authenticator binds into Nephele), `siteAdmin.mjs` (admin routes), and the test suite (`test/chains.test.mjs`, `auth-chain-integration.test.mjs`, `scope-filtering.test.mjs`, `sidecar-access.test.mjs`) — the tests are the precise spec of scope/chain/sidecar semantics. - `acequiaDocumentation/platform/auth` (prose spec; not in this sparse checkout).