# Capability-Based Delegation for Acequia

> **Active Project** — design and implementation in progress.

**Status:** Phases 1-3.7 implemented, Phase 4+ in design
**Date:** 2025-02-25 (updated 2026-03-06)
**Scope:** Authentication, authorization, and delegation architecture evolution

---

## Summary

Evolve the localWebDAV auth system from server-mediated role-based access control toward self-sovereign, capability-based delegation. The current system has strong foundations — per-device asymmetric keys, endorsement tracking, scoped tokens, and time-bound invites — but delegation decisions are centralized on the server. This proposal outlines a path toward bearer-verifiable delegation chains where the server (or any Acequia peer) acts as a *verifier* of delegation decisions, not the *decider*.

---

## Current State

### What's Already Aligned

1. **Per-device asymmetric keys** — Users hold their own private keys; the server stores only public keys. No shared secrets, no password database. This is the correct foundation for self-sovereign identity.

2. **`endorsedBy` audit trail** — Key provenance is already tracked:
   - First key: `endorsedBy: null`
   - Device link: `endorsedBy: {signingKid}`
   - Invite: `endorsedBy: invite:{inviteId}`

3. **Stored tokens with path scoping** — Stored tokens carry `scope` with restricted `paths`/`writePaths`. This is path-based attenuation already in use.

4. **Invites as time-bound capabilities** — `maxUses`, `expiresAt`, scoped `paths`/`writePaths`, and per-user restrictions. These are capabilities with constraints.

### Where It Diverges

#### Delegation is server-mediated, not bearer-verifiable

All delegation flows require server participation:

- **Invites:** Owner calls `POST /auth/invites`, server stores it, acceptor calls `POST /auth/invites/accept`, server writes the user record.
- **Device links:** Existing device signs a device-link token, but the server validates and writes the new key entry.
- **Stored tokens:** Server stores the JWT and resolves token IDs to JWTs at request time.

The `endorsedBy` field is an audit trail on the server, not a verifiable chain a peer could validate independently.

#### Roles live on the server, not in the token

Users have `role: "owner" | "editor" | "viewer" | "pending"` stored in `users/{userId}.json`. Authorization checks read this file. JWT claims can further restrict, but base permissions are server-side state.

In a capability model, the token *is* the permission. There's no separate role lookup. The JWT payload says what you can do; the chain proves *why* you can do it.

#### No attenuation enforcement

The key principle of capability delegation: each link in the chain can only *narrow* the scope of the parent. The current system doesn't enforce this structurally because delegation doesn't produce chained tokens. An invite with `role: "editor"` and `paths: ["/docs"]` is a one-shot configuration, not a derived capability guaranteed to be a subset of the parent's scope.

#### Revocation is centralized

The current system uses `status: 'revoked'` fields checked on every request. The self-sovereign model favors short-lived tokens + non-renewal ("closing the headgate") as the primary revocation mechanism.

---

## Proposed Architecture

### Delegation Chains

In a decentralized system, there's no single authority minting all tokens. A node that *has* a capability can delegate a subset of it by issuing a new JWT. This creates a verifiable chain.

#### Chain anatomy

Alice has a root token granting her read/write on `/project/maps`:

```
JWT_0 (root)
  iss: resource-owner
  sub: alice
  scope: /project/maps [read, write]
  exp: 2026-03-01
```

Alice delegates read-only access to Bob:

```
JWT_1 (delegated)
  iss: alice
  sub: bob
  scope: /project/maps [read]          <- attenuated: subset of parent
  parent: hash(JWT_0)                   <- links to the authority chain
  exp: 2026-02-28                       <- cannot exceed parent's expiry
  max_depth: 3                          <- limits further re-delegation
  proof: alice's signature
```

Bob delegates further to Carol with even narrower scope, producing `JWT_2`. The chain is `JWT_0 -> JWT_1 -> JWT_2`.

#### Core principles

- **Attenuation only (no escalation).** Each link can only narrow scope — fewer permissions, shorter expiry, fewer resources. You can never delegate more than you have.
- **Verifiable without phoning home.** Any node can verify `JWT_2` by walking the chain: check signatures, confirm each child's scope is a subset of its parent's, confirm expiries are non-increasing.
- **Depth limits.** A `max_depth` field bounds chain length (3-5 hops) to limit verification cost and scope drift.

#### Token format sketch

```json
{
  "header": { "alg": "PS256", "kid": "<JWK thumbprint>" },
  "payload": {
    "iss": "did:acequia:alice",
    "sub": "did:acequia:bob",
    "aud": "/project/maps",
    "scope": ["read"],
    "parent": "sha256:ab3f...",
    "depth": 1,
    "max_depth": 4,
    "iat": 1740000000,
    "exp": 1740003600
  }
}
```

### The Keyring

A **keyring** holds identity keys and capability chains. Organized as a hybrid: canonical at-rest storage with capability attachment when binding to a resource or establishing a peer channel.

```
keyring/
  identity/                   <- signing keys (per-device)
    {deviceId}.jwk
  chains/                     <- delegation chains you hold
    {resource-hash}/
      root.jwt                <- original grant
      link-1.jwt              <- delegation step (alice -> bob)
      link-2.jwt              <- delegation step (bob -> carol)
  index                       <- path -> chain mapping for fast lookup
    /project/maps -> chain-abc
```

This maps naturally to the filesystem-first philosophy. A user's keyring could live in the WebDAV namespace as a resource they control. The index uses a scope tree (trie over WebDAV paths) for fast lookup of which chains grant access to a given resource.

#### Indexing strategies

- **Claim-indexed map:** Parse claims on ingestion, index by resource + permission. O(1) lookup. Handle expiry via lazy eviction or TTL sweep.
- **Scope tree:** Hierarchical paths organized as a trie so a query for `/org/project` also surfaces tokens scoped to `/org`. Fits the "everything is a resource with a path" philosophy.
- **Capability attachment:** When binding a resource or opening a peer channel, resolve the relevant chain and attach it to the session. The keyring is at-rest storage; the attachment is the in-use index.

### Revocation: The Headgate Pattern

Short-lived delegated tokens (minutes to hours) + refresh from the delegator. If Alice wants to cut Bob off, she stops re-issuing. The capability withers naturally — like closing the headgate on an acequia lateral.

The existing explicit revocation (`status: 'revoked'`) remains as belt-and-suspenders for the server context, but should not be the only mechanism.

### The Acequia Metaphor

This maps to how acequias actually work:

| Acequia | System |
|---------|--------|
| **Acequia madre** (mother ditch) | Root authority — the original resource grant |
| **Mayordomo** | Owner who delegates allocations — attenuates the total flow |
| **Parciante** (irrigator) | Recipient of a scoped share, cannot exceed allocation |
| Sub-delegation to a neighbor | Further attenuation with shorter time window |
| **Closing the headgate** | Revocation via non-renewal of short-lived tokens |
| Tracing back to the water right | Walking the delegation chain to verify legitimacy |

---

## Delegation UX

Three models, not mutually exclusive:

### Explicit user involvement (current approach)

Owner manually creates invites with specific roles/paths. Owner explicitly manages users. Device linking requires intentional action.

- **Pro:** Clear accountability, user understands what's shared
- **Con:** Doesn't scale, every permission change needs owner action

### Policy-based automatic delegation

Rules like "anyone with an `@simtable.com` DID gets editor on `/shared`" or "any device endorsed by an owner-level key inherits viewer."

- **Pro:** Scales, reduces friction
- **Con:** Harder to audit, permissions may surprise people

### Capability-passing (peer-to-peer)

Alice gives Bob a scoped token directly. No server involved in the decision. Bob can further attenuate and pass to Carol.

- **Pro:** True decentralization, works offline, peer-to-peer
- **Con:** Harder to revoke, harder to answer "who has access right now?"

### Recommended hybrid

- **Cross-boundary delegation** (owner to new user, different organization): Explicit, user-involved. Owner creates a scoped capability and hands it over. This is the invite flow evolved into UCAN-style minting.
- **Within-boundary delegation** (same user across devices, same team within a project): Policy-driven. "All my devices get my full scope" is the device-link pattern already in place. "All editors on this subdomain can read `/shared`" is a policy, not per-user configuration.

---

## Prior Art and Reference Systems

| System | Delegation Model | UX Pattern |
|--------|-----------------|------------|
| **UCAN** (Fission/WNFS) | Chained JWTs with attenuation. Holder mints sub-tokens. Verification walks chain to root. | User-driven: explicitly create and share tokens |
| **Verifiable Credentials** (W3C) | Issuer to Holder to Verifier. Holder presents credentials from wallet. | Wallet-mediated: user chooses what to present |
| **ZCAP-LD** (Digital Bazaar) | Linked Data capabilities with delegation chains. | Programmatic: API-driven |
| **Object Capabilities** (ocap) | Possession of reference = permission. Unforgeable references. | Structural: capabilities acquired by receiving object references |
| **Macaroons** (Google) | Cookie-like tokens with caveats appended by anyone in the chain. | Transparent: each intermediary adds constraints |
| **SPIFFE/SPIRE** | Workload identity, automatic cert issuance based on policy. | Fully automatic: no human in the loop |

---

## Migration Path

### Phase 1: Enrich existing tokens — COMPLETE

- Added `parent`, `depth`, `max_depth` fields to JWT payloads for delegated tokens
- Three-mode detection in `jwtAuth.mjs`: chain (parent) → user (kid) → legacy device
- No breaking changes; existing tokens continue to work as chain-depth-0

**Implementation:** `src/chains.mjs` (chain engine), `src/auth/jwtAuth.mjs` (authenticateChainToken)

### Phase 2: Chain verification — COMPLETE

- Chain-walking verification: signature check at each link, scope subset validation (`isScopeSubset`), expiry attenuation
- Content-addressable storage at `/auth/{subdomain}/chains/{sha256}.jwt` — filename is SHA-256 of JWT
- Server resolves chains via `resolveAndVerify()` — walks parent hashes to root
- Revocation list at `/auth/{subdomain}/revocations.json` with expiry cleanup

**Implementation:** `src/chains.mjs` (29 unit tests), `src/revocations.mjs` (10 tests), `test/auth-chain-integration.test.mjs` (14 tests)

### Phase 3: Client-side minting — COMPLETE

- Browser-compatible `hashToken()` using `crypto.subtle.digest('SHA-256')` — matches server's `crypto.createHash('sha256')`
- Client mints root + scoped child tokens via `acequia.chains.createChainToken()`
- Attenuation enforcement: child exp ≤ parent exp, child scope ⊆ parent scope
- IndexedDB wallet (`acequia-chains` localforage instance) stores token metadata
- Dashboard transitioned from stored tokens to chain workflow

**Implementation:** `acequia2/auth/chains.js` (client), `public/dashboard.html` (UI), `test/chain-dashboard-flow.test.mjs` (8 tests)

### Phase 3.5: Client-side verification — COMPLETE

- `resolveAndVerify(authUrl, leafJwt, options)` — browser-side full chain resolution and cryptographic verification
- `resolveChain(authUrl, leafJwt)` — walks parent hashes, fetches from server via `GET /auth/chains/{hash}`
- `verifyChain(chain, { resolvePublicKey, isRevoked })` — verify signatures, scope attenuation, expiry, depth at each link
- Pluggable key resolvers: `createCombinedKeyResolver(authUrl)` (local + server), `createServerKeyResolver(authUrl)`, `createLocalKeyResolver()`
- Revocation checking: `createWalletRevocationChecker()` — checks IndexedDB wallet
- BrowserDAV now performs full cryptographic chain verification with graceful fallback to scope-only decoding

**Implementation:** `acequia2/auth/chains.js` (client verification), `acequia/{sub}/BrowserDAV/main.js` (integration)

### Phase 3.6: Sidecar public access + dotfile filtering — COMPLETE

Implemented `.acequia-access.json` sidecar files for per-directory anonymous read access, plus write-gated dotfile filtering. Both features implemented in parallel on both servers:

**Sidecar access (both servers):**
- Walk-up-ancestors to find nearest sidecar file (nearest-wins inheritance)
- Support `read: "anonymous"`, `recursive: true`, `denyPatterns` (glob-style file exclusion)
- Server: 60s TTL cache, child directory scanning for filtered ancestor navigation
- BrowserDAV: reads from File System Access API handles
- Anonymous users get `writePaths: []` → feeds into dotfile filtering

**Dotfile filtering (both servers):**
- Files starting with `.` hidden from users without write access to that path
- `DOTFILE_READER_ALLOWLIST`: `['.well-known', '.ai']` — standards-mandated exceptions
- Server: `ScopedFileSystemAdapter.isAuthorized()` + `DirectoryJsonPlugin` member filtering
- BrowserDAV: pre-check in `handleWebDAVRequest` before WebDAV dispatch

**Implementation:** `src/auth/jwtAuth.mjs` (server sidecar), `src/serverFactories.mjs` + `src/plugins/directoryJson.mjs` (server dotfile), `acequia/{sub}/BrowserDAV/main.js` (BrowserDAV sidecar + dotfile)

### Phase 3.7: File browser UI — COMPLETE

Built `<acequia-file-browser>` — a framework-agnostic web component for browsing WebDAV filesystems with access rule visualization and editing:

- Tree + list dual-pane navigation via WebDAV PROPFIND
- Access indicator badges (public/private/denied) resolved from `.acequia-access.json` sidecars
- Visual sidecar editor for creating/editing access rules
- CRUD: upload, create folder, rename, delete, download
- CSS custom properties theming (`--fb-*` variables, dark default, light override for BrowserDAV)
- Integrated in both server dashboard (owner tab) and BrowserDAV (embedded section)
- Owner bypass token: per-session `crypto.randomUUID()` for BrowserDAV file browser sidecar editing

**Implementation:** `public/components/acequia-file-browser/` (7 files), `public/dashboard.html` + `dashboard.js`, `acequia/{sub}/BrowserDAV/index.html` + `main.js`

### Phase 4: Peer verification — IN DESIGN

- Acequia mesh peers verify chains locally during WebRTC handshake
- Chain bundling as part of data channel establishment
- Peer public key discovery via WebDAV namespace (`/peers/{did}/pubkey`)

### Phase 5: Policy engine — IN DESIGN

- Declarative policy rules for automatic within-boundary delegation
- Policy documents stored in WebDAV namespace, themselves governed by capabilities

---

## Design Decisions

### 1. Chain Storage — Hybrid: server-side in `/auth/`, client-side in IndexedDB

**Context:** The server currently stores all auth data as JSON files in `/auth/{subdomain}/`. The client stores keys and IDs in IndexedDB via `localforage` (store name `'acequia'`). The service worker shares the same IndexedDB instance.

**Decision:** Server and client each maintain the chains they need.

- **Server-side:** Chains received during request verification are stored in the existing `/auth/{subdomain}/` filesystem, extending the current pattern for users, invites, and stored tokens. The `/auth/` directory is already outside the WebDAV content namespace, so there's no chicken-and-egg problem (needing a chain to access the chain).
- **Client-side:** Each node's keyring lives in IndexedDB via `localforage`. Chains the client holds (both those it received and those it minted as delegations) are cached locally for fast presentation during requests and WebRTC handshakes.
- **Canonical vs. cache:** The server's copy is the canonical record for server-verified requests. The client's copy is canonical for peer-to-peer verification. Neither is subordinate — both are valid verification contexts.

**Storage layout (server) — as implemented:**

```
/auth/{subdomain}/
  chains/
    {sha256}.jwt              <- individual JWT, content-addressable by hash
  revocations.json            <- revoked token hashes with expiry
  ... existing users/, handles/, invites/, stored-tokens/
```

Chains are linked lists, not bundles. Each JWT's `parent` claim contains the SHA-256 hash of its parent JWT. Verification walks the chain by loading each parent from the store.

**Storage layout (client IndexedDB) — as implemented:**

```
acequia-chains (localforage instance):
  {leafHash}: { leafHash, rootHash, leafJwt, name, note, scope, createdAt, expiresAt, status }
acequia (existing localforage instance):
  ... deviceId, userId, keys, userKeys
```

**Why not WebDAV namespace:** Storing chains as user-visible WebDAV resources (e.g., `/keyring/chains/...`) is appealing for the filesystem-first philosophy, but auth infrastructure shouldn't depend on the content layer it protects. The `/auth/` directory already establishes this separation.

### 2. DID Scheme — Defer; use CUID2 + JWK thumbprints now, namespace as `did:acequia` later

**Context:** The system currently uses CUID2 for user/device IDs and RFC 7638 JWK Thumbprints for key IDs (`kid`). No DID patterns exist in the codebase.

**Decision:** Don't adopt DIDs yet. The chain verification algorithm needs public keys, not DIDs — and JWK thumbprints already serve as self-certifying key identifiers.

**Rationale:**

- `did:key` is deterministic from the public key but changes when keys rotate, doesn't map to the handle system, and is long/opaque.
- `did:web` resolves via HTTPS (`did:web:stigmergic.realtime.earth:users:alice`) — readable and uses existing infrastructure, but ties identity to DNS, undermining self-sovereignty.
- `did:acequia` would be a thin namespace wrapper around CUID2 (`did:acequia:{cuid2}`) with resolution via `/auth/{subdomain}/users/{cuid2}.json`. Adds semantic clarity for interop without changing the underlying system.

**When to revisit:** When external system interop becomes real (federation with other Acequia instances, Verifiable Credentials exchange, or identity portability across subdomains). At that point, wrap existing CUID2 identifiers as `did:acequia:{cuid2}` — a non-breaking addition.

**Token format note:** The token format sketch in this document uses `did:acequia:alice` for illustration. In implementation, the `iss` and `sub` fields will continue to use raw CUID2 user IDs until the DID namespace is adopted.

### 3. Offline Delegation Limits — max_depth of 3

**Context:** No delegation chains exist yet. WebRTC peers currently piggyback auth headers on proxied requests but don't verify chains locally. PS256 signature verification costs ~1ms per hop on modern hardware.

**Decision:** Default `max_depth` of 3 (depths 0, 1, 2).

| Depth | Role | Example |
|-------|------|---------|
| 0 | Root grant | Resource owner issues capability to Alice |
| 1 | First delegation | Alice delegates subset to Bob |
| 2 | Second delegation | Bob delegates subset to Carol |

**Rationale:**

- Three hops covers realistic scenarios: owner to team lead to contributor.
- Maps to the acequia: mayordomo delegates to parciantes; a parciante might lend to a neighbor, but the neighbor doesn't re-lend — they go talk to the mayordomo.
- The compute cost is negligible (3ms worst case). The real constraint is trust attenuation — each hop dilutes the original intent.
- Individual delegators can tighten further: an owner minting a token with `max_depth: 1` means "use this, but don't re-delegate."

**If someone needs deeper:** They go back to a connected node with a shorter chain to get a fresh root-anchored grant. This is by design — it forces periodic reconnection with the authority, keeping the "headgate" relationship alive.

### 4. Revocation Signaling — Tiered TTL + revocation list at well-known path

**Context:** The current system uses explicit `status: 'revoked'` fields checked server-side on every request. Tokens default to 30-day expiry or no expiry. There is no token refresh mechanism.

**Decision:** Combine two complementary strategies:

#### Tiered TTL (the headgate)

Different token depths get different lifetimes:

| Depth | TTL | Revocation |
|-------|-----|------------|
| 0 (root grant) | 30 days (current default) | Server-side `status: 'revoked'` check (existing) |
| 1 (first delegation) | 1-4 hours | Non-renewal by delegator |
| 2 (second delegation) | 15-60 minutes | Non-renewal by delegator |

Root grants work like they do today — long-lived, server-verified, explicitly revocable. Delegated tokens are inherently riskier (trusting someone else's judgment) so they're shorter-lived. When Alice wants to cut Bob off, she stops refreshing his token. The capability withers naturally.

This preserves the current UX for direct grants while making delegation safe without centralized revocation infrastructure.

#### Revocation list at well-known path

Each subdomain publishes a revocation list for immediate-effect revocation of any token depth:

```
/auth/{subdomain}/revocations.json
```

**Format:**

```json
{
  "revoked": [
    {
      "tokenHash": "sha256:ab3f...",
      "revokedAt": "2026-02-25T10:00:00Z",
      "reason": "user-removed",
      "expiresFromList": "2026-03-27T10:00:00Z"
    }
  ],
  "updatedAt": "2026-02-25T10:00:00Z"
}
```

**Behavior:**

- Server writes to this file when explicit revocation occurs (key revoked, user removed, stored token revoked).
- Peers check this list periodically or on first use of a chain from a given subdomain.
- Entries expire from the list once the revoked token's `exp` has passed (the token would fail verification anyway).
- The list is small — only tokens revoked before their natural expiry need to appear.
- Fits the filesystem model: atomic JSON writes, same pattern as all other `/auth/` data.

**Why both:** TTL-based non-renewal handles the common case (graceful access reduction, the "closing the headgate" pattern). The revocation list handles the urgent case (compromised key, fired employee, immediate cutoff). Belt and suspenders, with each mechanism handling what it's naturally good at.

### 5. Backward Compatibility — Three-mode detection, phase out legacy

**Context:** The system already runs dual-mode authentication. Legacy device tokens (no `kid` claim) and user tokens (`kid` present) are distinguished by a single `if (decToken.kid)` branch in `jwtAuth.mjs`.

**Decision:** Add chain-verified as a third mode. Phase out legacy device tokens on a defined timeline.

**Detection logic:**

```javascript
if (decToken.parent) {
  // Chain-verified path (new) — walk the delegation chain
  return await this.authenticateChainToken(req, resp, decToken, chainBundle)
} else if (decToken.kid) {
  // User token path (current) — verify against stored public key
  return await this.authenticateUserToken(req, resp, decToken)
} else {
  // Legacy device token path — verify against device file
  return await this.authenticateDeviceToken(req, resp, decToken)
}
```

**Key insight:** A standalone user token (no `parent` claim) is simply a chain of depth 0. It continues to work exactly as it does today. The chain-verified path is purely additive — no existing tokens break.

**Phase-out timeline:**

| Mode | Status | Plan |
|------|--------|------|
| Legacy device tokens | Deprecated | Stop accepting after one release cycle. Remove code path. Existing subdomains with `authMode: 'legacy'` must migrate to `authMode: 'user'`. |
| User tokens (no chain) | Current | Supported indefinitely. These are depth-0 chain tokens by definition. |
| Chain tokens | New | Introduced in Phase 2 of migration. Coexists with user tokens. |

**Migration tooling:** Provide a one-time migration script for legacy subdomains (convert device records to user records, set `authMode: 'user'`). The existing `PATCH /auth/subdomain/auth-mode` endpoint already supports this transition.

---

## Owner as Root Token Authority

**Principle:** Each WebDAV server (node or browser) has an owner whose keys are the ultimate root of all delegation chains. The server is a peer in the Acequia network — a canonical verifier for its namespace, not a privileged controller.

**Current state:** Root tokens are self-signed by any registered user. The chain engine verifies the signer is registered with appropriate role, but doesn't structurally require chains to trace to the subdomain owner. An editor can create a root token that anchors a chain — their scope is derived from their user record's `paths`/`writePaths`.

**Proposed constraint:** `resolveAndVerify()` should optionally enforce that the chain root's `sub` is the subdomain owner (recorded in `subdomain.json` as `createdBy`). This makes the owner the acequia madre — all water rights (capabilities) flow from them.

**Why this matters for apps:** When a browser-based app (BrowserDAV, Camera, SandTableController) provides services, the app instance's owner is the root authority for permissions on that instance's resources. A Camera app sharing a video stream delegates read access via chains rooted in the camera owner's keys. This is the same model whether the "server" is a Node process or a browser tab.

**Open question:** Should non-owner users be able to create root chains at all? Or should they only receive delegated chains from the owner? The current system allows editors to create chains scoped to their own permissions — this is useful for "share my access with my other device" but conceptually breaks the single-root-authority model.

---

## App Permission Model

**Context:** Acequia apps (BrowserDAV, Chat, Camera, etc.) originally had no per-request authorization. With delegation chains implemented server-side (Phases 1-3) and now integrated into BrowserDAV as a pilot, the pattern for how browser-based apps participate in the delegation model is established.

### Implemented flow (BrowserDAV pilot)

1. **App registers with Acequia** — declares capabilities (e.g., `['webdav']`) and registers routes
2. **App owner creates root chain** — via the Permissions UI, the person running the app creates scoped tokens using `acequia.chains.createChainToken()`
3. **Token is shared** — owner copies the JWT or token URL and shares with the recipient
4. **Request arrives with chain token** — the `serverProxiedRequestHandler` receives the request; the `Authorization: Bearer <jwt>` header or `?token=<jwt>` query param carries the token
5. **App decodes scope** — `acequia.chains.decodeTokenScope(jwt)` extracts the `paths`/`writePaths` from the JWT payload
6. **App enforces scope** — `acequia.chains.checkScopeAccess(method, path, scope)` checks the request method and path against the token's scope. Write methods require both `writePaths` and `paths` coverage.
7. **401/403 on failure** — missing token → 401, insufficient scope → 403

### What's implemented (BrowserDAV as reference)

**Client-side chain verification** (`acequia2/auth/chains.js`):
- `resolveChain(authUrl, leafJwt)` — walks parent hashes, fetches from server via `GET /auth/chains/{hash}`
- `verifyChain(chain, { resolvePublicKey, isRevoked })` — full cryptographic verification of each link
- `resolveAndVerify(authUrl, leafJwt, options)` — convenience wrapper combining resolve + verify
- `createCombinedKeyResolver(authUrl)` — tries local keys first, then fetches from server
- `createServerKeyResolver(authUrl)` — fetches public keys from `GET /auth/users/{userId}/keys/{kid}/public`
- `createLocalKeyResolver()` — resolves against current user's local key pair
- `createWalletRevocationChecker()` — checks IndexedDB wallet for revocation status
- `matchesPath(requestPath, allowedPaths)` — glob matching: exact, `*`, `/prefix/*`
- `isScopeSubset(childScope, parentScope)` — enforces attenuation
- `checkScopeAccess(method, requestPath, scope)` — method-aware access check
- `decodeTokenScope(jwt)` — extract scope without full chain verification

**Permission UI** (`BrowserDAV/index.html` + `main.js`):
- Auth toggle (on/off, persisted in localStorage)
- Create Token form: name, note, expiry, permission level (Full/Read Only/Custom)
- Custom mode: per-directory checkboxes for read and write, plus manual path patterns
- Token list: all wallet entries with scope badges, status, copy actions, revoke/delete
- Success dialog: token ID, JWT, and WebDAV URL with token for easy sharing

**Token lifecycle:**
- Created via `createChainToken()` → stored on server + IndexedDB wallet
- Listed via `listWalletTokens()` from IndexedDB
- Revoked via `revokeWalletToken()` locally + `DELETE /auth/chains/{hash}` on server
- Deleted via `removeWalletToken()` from IndexedDB wallet

### Client-side verification vs. server-side

The node server verifies chains using `src/chains.mjs` with Node's `crypto` module. The browser app will verify using `acequia2/auth/chains.js` with `crypto.subtle`. The algorithms are identical:

| Step | Node (`chains.mjs`) | Browser (`chains.js`) | Status |
|------|---------------------|----------------------|--------|
| Hash | `crypto.createHash('sha256')` | `crypto.subtle.digest('SHA-256')` | Done |
| Sign | `jose.SignJWT` | `jose.SignJWT` | Done |
| Verify | `jose.jwtVerify` | `jose.jwtVerify` | Done |
| Scope check | `isScopeSubset()` | `isScopeSubset()` | Done |
| Path match | `matchesPath()` | `matchesPath()` | Done |
| Access check | `canAccess()`/`canWrite()` | `checkScopeAccess()` | Done |
| Chain walk | `resolveAndVerify()` | `resolveAndVerify()` | Done |
| Revocation check | checks `revocations.json` | checks IndexedDB wallet | Done |
| Key resolution | loads from user record file | `createCombinedKeyResolver()` — local + server fetch | Done |

**Key resolution strategy:** The browser module uses pluggable key resolvers. `createCombinedKeyResolver(authUrl)` tries the current user's local keys first (fast, no network), then falls back to fetching from the server at `GET /auth/users/{userId}/keys/{kid}/public`. This public endpoint returns the JWK for any registered user's key, enabling cross-user chain verification.

**Revocation:** The browser checks the local IndexedDB wallet for revocation status via `createWalletRevocationChecker()`. This covers tokens the owner created and revoked. For server-side revocation lists (covering tokens revoked by other users or the admin), a future enhancement would add a `fetchRevocationList()` that checks `GET /auth/revocations` (currently requires owner auth — may need a public revocation-check endpoint).

**Graceful degradation:** BrowserDAV's auth middleware attempts full chain verification first (`resolveAndVerify`). If that fails (network error, server unreachable), it falls back to scope-only decoding (`decodeTokenScope` + `checkScopeAccess`). This means the app works offline for tokens with embedded scope, while providing full cryptographic verification when the auth server is reachable.

### Uniform permission feel

The goal — now achieved — is that BrowserDAV (browser WebDAV) and localWebDAV (node WebDAV) feel the same from a permissions perspective:
- Same chain token format (2-token chains, PS256 signatures) ✓
- Same scope semantics (`paths`, `writePaths`, glob matching) ✓
- Same verification algorithm (full chain resolution + scope checking) ✓
- Same UI patterns for creating/managing tokens ✓
- Different storage backends (IndexedDB vs filesystem) but same content-addressable naming ��
- Full chain resolution with pluggable key resolvers ✓
- Same `.acequia-access.json` sidecar format for public access ✓
- Same dotfile filtering logic (`DOTFILE_READER_ALLOWLIST`, write-gated visibility) ✓
- Same `<acequia-file-browser>` component for browsing files ✓
- Same CUID2 `leafId` short token IDs ✓

---

## Plan9 Auth Namespace Overlay

**Concept:** The `/auth` namespace is not a single directory on a single server — it's a distributed namespace where different nodes overlay their own auth state. Like Plan9's `bind` and `mount`, each peer contributes a slice of the namespace.

### How it maps

| Plan9 | Acequia Auth |
|-------|-------------|
| `bind /dev/cons /mnt/term` | Device registers its public key at `/auth/devices/{deviceId}` |
| Union mount (`bind -a`) | Multiple nodes can serve different parts of `/auth` |
| Per-process namespace | Each peer's service worker has its own route table |
| `9P` protocol | WebDAV + Acequia route proxying |

### Concrete example: device public key resolution

When verifying a chain token, the verifier needs the signer's public key. Currently:
- **Node server:** Reads `/auth/{subdomain}/users/{userId}.json` from local filesystem
- **Browser peer:** Would need to fetch the same data, either from the node server or from IndexedDB if replicated

In a Plan9 model:
- `/auth/devices/{deviceId}` is served by whichever node *owns* that device
- The service worker's route table maps auth resource requests to the appropriate peer
- Chain tokens are content-addressable — `/auth/chains/{hash}` resolves the same JWT regardless of which node serves it
- Revocation lists at `/auth/{subdomain}/revocations.json` are fetched from the canonical server (the node) but could be cached/replicated by peers

### What's already in place

The service worker (`sw.js`) already implements a Plan9-like VFS at `/acq/`:
- Client-side mount points in IndexedDB (`acequiaMounts`)
- Longest-prefix matching for path resolution
- Transparent rewriting to different backend servers
- WebDAV method dispatch (GET, PUT, MKCOL)

Extending this to `/auth/` would mean:
- Auth resources are mountable like any other VFS path
- A peer could mount another peer's auth namespace to resolve their public keys
- Chain tokens could be fetched from the nearest peer that has a copy

### Incremental path

1. **Now:** Auth data lives on the node server's filesystem. Browser apps fetch from the server.
2. **Next:** Browser apps cache auth data in IndexedDB. Chain tokens are resolved locally when available, with server fallback.
3. **Later:** Auth namespace is a union mount — peers contribute their own slices, service worker routes to the appropriate source.

---

## Invites in the Chain Model

**Current:** Invites are server-stored CUID2-keyed JSON records. They provision user accounts (register public key, assign role/paths). They are a user-provisioning mechanism, not a bearer access mechanism.

**Two paths forward:**

### Option A: Invites become chain tokens

The invite IS a delegation chain with special semantics:
- Owner creates a chain with `scope: { invite: true, role: 'editor', paths: ['/docs/*'] }`
- The chain token is shareable (URL, QR code) — bearer-verifiable
- Accepting means: recipient registers their public key, endorsed by the chain
- The recipient's ongoing access is a new chain delegated from the owner's root
- Unifies invites and delegation under one mechanism

**Pro:** Single token format, no separate invite system, bearer-verifiable invites work offline
**Con:** Invites have unique semantics (maxUses, targetUsers, mode:merge/replace) that don't map cleanly to chain scope

### Option B: Invites stay separate

Invites remain a provisioning mechanism. After acceptance, the user receives a delegation chain for ongoing access. Two systems, two purposes:
- Invites: "Join this subdomain with these permissions"
- Chains: "Access these resources with this scope"

**Pro:** Clean separation of concerns, existing invite system works well
**Con:** Two token systems to maintain, invites can't be verified offline

**Recommendation:** Start with Option B (keep invites separate) and revisit after BrowserDAV pilot reveals whether the chain model can naturally absorb invite semantics.

---

## Key Lifecycle & Credential Hygiene

*Informed by Zoom's 2023 deprecation of JWT apps in favor of Server-to-Server OAuth — a case study in migrating from static, broadly-scoped credentials to scoped, short-lived, rotatable ones.*

### Where Acequia already aligns

The delegation chain model gets several things right by construction:

- **Scoped tokens** — chains carry explicit `paths`/`writePaths`, enforced at every link via `isScopeSubset()`
- **Short-lived credentials** — tiered TTL (Design Decision #4) means delegated tokens expire in minutes to hours
- **Revocation** — both headgate (non-renewal) and explicit (revocation list) mechanisms exist
- **Audit trail** — `endorsedBy` provenance, content-addressable chain storage, and parent-hash linking provide full delegation history
- **Blast radius containment** — attenuation-only delegation means a compromised child token can never exceed its parent's scope

### Gap 1: Key rotation

**Problem:** There is no mechanism to retire a user's signing key and rotate to a new one. A user's public keys are stored in `users/{userId}.json` as a `publicKeys[]` array, but once a key is registered, it remains valid indefinitely. If a key is compromised, the only option is to delete it — there's no workflow to re-sign active chains with a new key.

**Why it matters:** Zoom's JWT deprecation was partly driven by the impossibility of rotating static API keys without breaking all integrations. Acequia's per-device asymmetric keys are better than shared secrets, but without rotation:
- A compromised device key remains valid until manually removed
- All chains rooted in that key must be individually revoked
- There's no "roll forward" — only "tear down and rebuild"

**Proposed approach:**
1. Add `keyStatus` field to public key entries: `active | rotating | retired`
2. During rotation, both old and new keys are `active` for a grace period
3. Chain tokens signed by the old key continue to verify during grace period
4. After grace period, old key moves to `retired` — new chains must use the new key
5. Provide a `rotateKey` endpoint that registers the new key and initiates the transition
6. Consider a batch re-signing tool: walk all chains rooted in the old key and re-issue with the new key (owner action)

### Gap 2: Creation-time scope enforcement

**Problem:** The server doesn't enforce scope constraints when a chain token is *created* — only when it's *verified* during a request. A user with `paths: ["/docs"]` can mint a token claiming `paths: ["/"]`. The token will fail at verification time, but the invalid token gets stored in the chain store and wallet.

**Why it matters:** Zoom's S2S OAuth enforces scopes at token creation time — you can only request scopes your app has been granted. This prevents "optimistic" tokens that claim more than they should, reducing confusion and potential for misuse. In Acequia:
- Invalid-scope tokens waste storage and create confusing wallet entries
- A user might share a token that *looks* valid but will fail on use
- No feedback loop at creation time to tell the user "you can't grant this"

**Proposed approach:**
1. `POST /auth/chains` should validate the new token's scope against the signer's effective scope
2. For root tokens (no parent): check signer's user record `paths`/`writePaths`
3. For delegated tokens (has parent): verify child scope ⊆ parent scope before storing
4. Return 403 with a clear error message indicating which scope entries exceed the signer's authority
5. Client-side: `createChainToken()` should pre-validate scope before sending to server

### Gap 3: Legacy device token deprecation path

**Problem:** Legacy device tokens (no `kid`, no `parent`) are Acequia's equivalent of Zoom's deprecated JWT apps — broadly-scoped, long-lived credentials with no key rotation, no chain verification, and no scope attenuation. Design Decision #5 describes a phase-out timeline ("stop accepting after one release cycle") but no concrete migration tooling or deadline exists.

**Why it matters:** Zoom gave developers 12 months notice and built migration guides, SDK updates, and compatibility tools. Acequia's legacy tokens are currently detected by the absence of claims — a fragile heuristic. Every month they remain active:
- The three-mode detection code in `jwtAuth.mjs` stays complex
- No scope enforcement exists for legacy paths (full subdomain access)
- No audit trail (no `kid` means no key attribution)
- Legacy subdomains with `authMode: 'legacy'` have no incentive to migrate

**Proposed approach:**
1. **Audit:** Identify all subdomains still using `authMode: 'legacy'` (scan `/auth/*/subdomain.json`)
2. **Migration script:** Convert device records to user records with appropriate scoping (described in Design Decision #5 but not yet built)
3. **Deprecation warnings:** Log warnings on every legacy token verification, including the subdomain and device ID
4. **Hard deadline:** Set a date after which legacy tokens return 401 with a migration URL in the response body
5. **Compatibility endpoint:** `GET /auth/migrate-device?deviceId=xxx` returns instructions and a one-time migration token

---

## Open Questions

1. **Refresh protocol:** What's the concrete mechanism for a delegated token holder to request a refresh from their delegator? Direct peer request over the Acequia mesh? Polling a well-known path? This needs design before Phase 4.
2. **Cross-subdomain delegation:** Can an owner of subdomain A delegate access to a resource on subdomain B? This implies cross-subdomain chain verification and shared trust roots. Defer or design now?
3. **Policy language:** What does the declarative policy format look like in Phase 5? JSON rules? A DSL? How expressive does it need to be?
4. **Isomorphic verification:** ~~Should chain verification be a single isomorphic module?~~ *Resolved: parallel implementations with the same algorithm. The browser module (`acequia2/auth/chains.js`) and server module (`src/chains.mjs`) use different crypto APIs (crypto.subtle vs Node crypto) but identical verification logic. Shared test vectors would still be valuable.*
5. **Non-owner root chains:** Should editors/viewers be able to create root chains (anchoring delegation at their own scope), or should all chains trace to the subdomain owner? *Current BrowserDAV implementation allows the app runner (whoever is in the browser tab) to create tokens — effectively treating them as the owner of their shared directories.*
6. **App identity:** When a BrowserDAV instance verifies chains, what is its identity? Is it the user running the browser tab? The group it belongs to? How does its auth namespace relate to the node server's? *Partially answered: the app's identity is the user's Acequia identity (userId + deviceId + instanceId). Its auth namespace is separate from the node server's — tokens created in BrowserDAV are stored on the discovery server's `/auth/chains/` but scoped to the user's key pair.*
7. **~~Full chain verification in browser:~~** *Resolved: `resolveAndVerify()` ported to `acequia2/auth/chains.js` with pluggable key resolvers and revocation checking. BrowserDAV now performs full cryptographic chain verification with graceful fallback to scope-only decoding.*
8. **Token sharing UX:** Current sharing is manual (copy JWT/URL). Should Acequia apps have a built-in mechanism for token exchange between peers — e.g., a WebRTC-based token handshake where the owner's app sends the token directly to the recipient's app?
9. **~~Public/anonymous access mechanism:~~** *Resolved: `.acequia-access.json` sidecar files with nearest-wins inheritance, recursive flag, deny patterns. Implemented in both localWebDAV (`jwtAuth.mjs`) and BrowserDAV (`main.js`). See `documents/projects/public-access.md`.*
10. **~~Dotfile exposure:~~** *Resolved: Write-gated visibility — dotfiles hidden from users without write access. `DOTFILE_READER_ALLOWLIST` for standards-mandated exceptions. Implemented in `ScopedFileSystemAdapter`, `DirectoryJsonPlugin`, and BrowserDAV.*
