···11+# ZDS `/oauth/par` rejecting plyr.fm's permission-set includes → login broken on pds.zat.dev
22+33+**From:** plyr.fm side investigation (2026-06-09 ~01:20 UTC)
44+**Symptom:** signing into plyr.fm staging as a `pds.zat.dev` account (e.g. `bufo.uk`,
55+`did:plc:b64lsctzqnzpv6vd4ry3qktw`) fails. Accounts on *other* PDSes (e.g.
66+`zzstoatzz.io` on `pds.zzstoatzz.io`) sign in fine. So this is specific to ZDS,
77+and it is **new** — `bufo.uk` signed in successfully at 23:34 UTC, then broke.
88+Something changed ZDS-side since then (a redeploy?).
99+1010+## Where it fails
1111+1212+plyr's OAuth client (`atproto_oauth`) dies at the **PAR step** of
1313+`start_authorization`, *after* successfully fetching ZDS's `.well-known`
1414+metadata (both 200) and *before* a usable PAR response. On plyr's side the
1515+exception arrived with an empty `str()` (we just shipped a fix to log the real
1616+type/traceback — next failed login will show it precisely in our logfire).
1717+1818+## The ZDS-side lead (your logs)
1919+2020+`fly logs -a zds-pds` shows, for plyr's client:
2121+2222+```
2323+error oauth validate include failed scope=include:fm.plyr.stg.privateMedia err=InvalidPermissionSet
2424+error oauth authorize scope_html failed client=https://api-stg.plyr.fm/oauth-client-metadata.json \
2525+ scope=atproto blob:*/* include:fm.plyr.stg.authFullApp include:fm.plyr.stg.privateMedia \
2626+ err=InvalidPermissionSet
2727+```
2828+2929+So ZDS is rejecting plyr's permission-set `include:` scopes with
3030+**`InvalidPermissionSet`**. plyr's *current* (post-revert) login requests only:
3131+3232+```
3333+atproto blob:*/* include:fm.plyr.stg.authFullApp
3434+```
3535+3636+If ZDS now fails to resolve **`include:fm.plyr.stg.authFullApp`** the same way it
3737+failed `privateMedia`, PAR aborts and login dies.
3838+3939+## What to check
4040+4141+1. Resolve `include:fm.plyr.stg.authFullApp` (and `include:fm.plyr.stg.privateMedia`)
4242+ from ZDS right now — why `InvalidPermissionSet`? The permission-set records are
4343+ published as `com.atproto.lexicon.schema` on the **plyr.fm** account
4444+ `did:plc:vs3hnzq2daqbszxlysywzy54` (PDS `chalciporus.us-west.host.bsky.network`),
4545+ resolved via DNS `_lexicon.stg.plyr.fm` TXT → that DID. Confirm ZDS can still
4646+ fetch + parse those records (DNS, DID resolution, getRecord, `buildExpandedScope`).
4747+ This worked earlier today, so suspect a regression in the last ZDS deploy.
4848+4949+2. `/oauth/par` returns **HTTP 500** on bad input rather than a clean 4xx OAuth
5050+ error. Example (my probe, missing client assertion):
5151+ `error oauth client_auth rejected missing_assertion_type` → `failed to serve
5252+ /oauth/par: MissingFormField` → 500. A 500 (especially with an empty/aborted
5353+ body) surfaces on the client as an opaque `httpx.RemoteProtocolError`. Returning
5454+ a proper `{"error": "...", "error_description": "..."}` 4xx would make failures
5555+ debuggable on both sides.
5656+5757+## Please ignore in the logs
5858+5959+My manual `/oauth/par` probes around **01:19–01:20 UTC** (`client=test`, and
6060+`client=...plyr...` *without* a client assertion) are mine, not plyr's — they
6161+lack the confidential-client assertion and are not representative. plyr always
6262+sends `client_assertion` (private_key_jwt).
6363+6464+## To reproduce faithfully
6565+6666+Have a `pds.zat.dev` account sign into `https://stg.plyr.fm` and watch the
6767+`/oauth/par` handler + permission-set resolution for
6868+`client=https://api-stg.plyr.fm/oauth-client-metadata.json`.
+24
bench/README.md
···57575858Each run uses temporary SQLite and blobstore state.
59596060+## permissioned data
6161+6262+Permissioned-data benchmarks are separate from the public repo/appview/sync
6363+benchmarks. The goal is not broad coverage yet; it is to catch obviously bad
6464+access patterns introduced by the experimental `com.atproto.space.*` storage
6565+model.
6666+6767+When adding the first permissioned-data probe, keep it out of `all` until the
6868+protocol shape settles. A useful first pass should seed one self-owned space,
6969+one member repo, and one small blob, then measure these paths as distinct units
7070+of work:
7171+7272+- `createSpace` with owner auto-membership
7373+- `addMember` / `removeMember` and member LtHash/oplog updates
7474+- `createRecord` or `applyWrites` into a space member repo
7575+- `getRecord` by `(space, repo, collection, rkey)`
7676+- `listRecords`, limit 50, index-only response shape
7777+- ranged `getBlob`
7878+- `getRepoOplog` and `getMemberOplog` catch-up reads
7979+8080+Comparison against Daniel's permissioned-data branch should live in this
8181+section once we have a repeatable way to run that PDS locally. Until then, keep
8282+ZDS numbers as local trend data and compare only equivalent operations.
8383+6084## methodology
61856286- Build ZDS with `-Doptimize=ReleaseFast`.
+22
docs/benchmarking.md
···9595resolution. Larger records and index-only list probes are tracked as separate
9696comparison gaps instead of being folded into the current table.
97979898+## permissioned data
9999+100100+Permissioned-data measurements should stay separate from the existing repo,
101101+blob, sync, and HTTP route matrices. The experimental `com.atproto.space.*`
102102+surface has its own storage model, actor-local space state, LtHash-backed
103103+member/record state, oplogs, credentials, and ranged blob reads, so mixing it
104104+into the public repo tables would hide the actual access pattern.
105105+106106+The first benchmark slice should be small and diagnostic rather than exhaustive:
107107+108108+- seed one self-owned private space and one member repo.
109109+- measure `createSpace`, member add/remove, record write, record read,
110110+ `listRecords`, ranged `getBlob`, and oplog catch-up separately.
111111+- report p50/p95/p99/max for the concurrent paths, but keep write, read, blob,
112112+ and oplog rows separate.
113113+- compare to Daniel's permissioned-data branch only after we can run that PDS
114114+ locally with the same seeded work.
115115+116116+The question for this slice is simply whether any route has an obviously bad
117117+query shape, full-table scan, unnecessary blob materialization, or avoidable
118118+global lock hold. It does not need to become a giant protocol benchmark suite.
119119+98120## HTTP route path
99121100122`just bench http` starts a local ReleaseFast ZDS server and measures a seeded
+10-3
docs/operations.md
···3636state. Treat the numbers as local trend data; compare runs on the same machine
3737and optimize against the shape of the curve, not a single absolute value.
38383939-See [bench/README.md](../bench/README.md) for the Tranquil comparison target.
3939+Keep experimental permissioned-data probes separate from this baseline. Their
4040+first job is to catch obviously bad access patterns in `com.atproto.space.*`,
4141+not to expand the normal benchmark gate.
4242+4343+See [bench/README.md](../bench/README.md) for the Tranquil comparison target
4444+and the permissioned-data benchmark notes.
40454146## local server
4247···7580 creation. `describeServer` reflects this value.
7681- `ZDS_PERMISSIONED_DATA`: set to `true` to opt into experimental
7782 `com.atproto.space.*` permissioned-data routes. Default: disabled. The
7878- current route slice is gated and non-mutating until space storage is
7979- implemented.
8383+ current implementation is intended to track the upstream experimental
8484+ permissioned-data branch, including space lifecycle, membership, private
8585+ records, grants, credentials, oplogs, ranged blob reads, and notify routes.
8686+ Use `just smoke-permissioned` for the experimental smoke lane.
8087- Passkeys do not require a separate deployment secret. WebAuthn RP ID is
8188 derived from the public URL host.
8289
+62-21
docs/permissioned-data.md
···1111```
12121313Until that flag is enabled, ZDS should reject permissioned-data routes as
1414-unimplemented and should not create or mutate space tables.
1414+unimplemented and should not create or mutate permissioned-space rows.
15151616-The first implementation slice wires the flag and route gate only. Even when
1717-enabled, the space methods return `MethodNotImplemented` until storage and
1818-semantics are implemented.
1616+When enabled, ZDS should expose the full experimental
1717+`com.atproto.space.*` surface described by the current permissioned-data
1818+lexicons. Downstream apps should treat the namespace as experimental protocol
1919+support, not as a plyr.fm-specific private-media slice.
19202021## references
2122···56575758## storage shape
58595959-The reference branch uses space-scoped tables:
6060+The reference branch stores these tables inside each actor store:
60616162- `space`: URI, owner/member flags, app access policy, creation/deletion
6263 timestamps
···6970- `space_member_oplog`: incremental membership changes by `(space, rev, idx)`
7071- `space_credential_recipient`: registered services to notify for writes
71727272-ZDS currently has one public repo namespace per DID: `records`, `repo_blocks`,
7373+ZDS has one public repo namespace per DID: `records`, `repo_blocks`,
7374`commits`, `seq_events`, `blobs`, plus account/auth state. Permissioned data
7474-should not be squeezed into those public repo tables. It needs a new
7575-space-scoped storage module, probably `src/atproto/space.zig` plus space
7676-storage helpers in `src/storage/store.zig` or a split-out storage module once
7777-the shape is clear.
7575+must not be squeezed into those public repo tables. It uses space-scoped
7676+storage in `src/storage/store.zig` and route behavior in
7777+`src/atproto/space.zig`.
7878+7979+Because ZDS stores many actor repos in one SQLite database instead of one
8080+actor-store per DID, it must preserve the same actor-local semantics explicitly:
8181+8282+- canonical space config lives in `permissioned_spaces`, keyed by space URI
8383+- actor-local owner/member/deleted state lives in
8484+ `permissioned_space_actor_state`, keyed by `(space, actor_did)`
8585+- member-list state remains space-owner state
8686+- record state and oplogs carry the member `repo_did` explicitly
8787+8888+Do not collapse actor-local space state back into a single global row. The same
8989+space URI can legitimately have a different local view for different actors on
9090+the same ZDS process.
78917992## Zat first
8093···103116Do not edit or patch the sibling `zat` repo from ZDS without stopping and
104117making the proposed Zat change explicit.
105118106106-## first milestone
119119+## implementation milestones
107120108108-Implement in this order:
121121+Current implementation notes:
109122110110-1. Add the feature flag and route gating, still with no functional space
111111- storage.
112112-2. Add a storage-only space state model and tests: create space, add/remove
113113- member, write/delete record, update record/member oplogs.
114114-3. Add LtHash/set commitment support, using Zat if it exists by then or a small
115115- internal module if it does not.
116116-4. Expose the smallest route slice: `createSpace`, `addMember`,
117117- `createRecord`, `listRecords`, `getRecord`, `getRepoState`.
118118-5. Add grants, space credentials, member/repo oplog sync, and notify fanout.
123123+1. Permissioned data remains behind `ZDS_PERMISSIONED_DATA`.
124124+2. Spaces, members, member/repo LtHash state, record oplogs, member oplogs,
125125+ grants, owner-signed space credentials, credential-gated reads, ranged blob
126126+ reads, and best-effort notify fanout live in ZDS.
127127+3. The current branch is still experimental because the upstream proposal is
128128+ experimental. Do not narrow future work to one downstream app's needs unless
129129+ the protocol shape itself narrows.
119130120131Each step should keep permissioned data dark unless `ZDS_PERMISSIONED_DATA` is
121132enabled.
133133+134134+## code boundaries
135135+136136+Permissioned data is experimental code and should stay segmented:
137137+138138+- HTTP dispatch and XRPC semantics live in `src/atproto/space.zig`.
139139+- Experimental protocol primitives live in
140140+ `src/internal/permissioned_data.zig`.
141141+- OAuth scope parsing for `space:` scopes lives in `src/internal/scopes.zig`.
142142+- Persistent state lives in permissioned-data tables in
143143+ `src/storage/store.zig`.
144144+- API-reference surfacing is marked experimental through the `space` router
145145+ group and `x-zds-experimental`.
146146+147147+Normal repo writes, sync events, account auth, and appview proxy behavior should
148148+not learn permissioned-data special cases unless the protocol requires a shared
149149+primitive. Prefer a permissioned-data helper over scattering conditionals into
150150+stable PDS code.
151151+152152+## checks
153153+154154+Run both smoke lanes when touching this area:
155155+156156+```sh
157157+just test
158158+just smoke
159159+just smoke-permissioned
160160+git diff --check
161161+zig zen
162162+```
+4
justfile
···88smoke:
99 tools/smoke.sh
10101111+# run the experimental permissioned-data endpoint smoke test
1212+smoke-permissioned:
1313+ tools/smoke-permissioned.sh
1414+1115# mint an invite code with the configured admin token
1216invite pds="https://pds.zat.dev" use_count="1" for_account="":
1317 #!/usr/bin/env bash
···51515252fn endpointDescription(endpoint: Endpoint) []const u8 {
5353 if (model.isExperimental(endpoint)) {
5454- return "Experimental permissioned-data method gated by ZDS_PERMISSIONED_DATA. Request and response schemas are intentionally sparse in this MVP.";
5454+ return "Experimental permissioned-data method gated by ZDS_PERMISSIONED_DATA.";
5555 }
5656- return "Generated from the ZDS endpoint inventory. Request and response schemas are intentionally sparse in this MVP.";
5656+ return "Generated from the ZDS endpoint inventory.";
5757}
+102-2
src/internal/passkeys.zig
···9494 \\.security-section{{margin-top:22px;border-top:1px solid var(--line);padding-top:16px}}.security-section h2{{font-size:1rem;margin:0 0 6px}}.security-section p{{margin-top:0}}
9595 \\.credential-row{{display:flex;align-items:center;gap:10px;justify-content:space-between;border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin:8px 0;background:color-mix(in srgb,var(--field) 76%,transparent)}}
9696 \\.credential-name{{font-weight:760;overflow:hidden;text-overflow:ellipsis}}.credential-meta{{display:block;color:var(--muted);font-size:.82rem;font-weight:400}}.credential-actions{{display:flex;gap:8px;flex:0 0 auto}}.credential-row button{{width:auto;margin:0;padding:7px 10px;background:transparent;color:var(--text);border-color:var(--line)}}.credential-row button.danger{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,var(--line))}}
9797+ \\.table-wrap{{margin-top:8px;overflow:auto;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent)}}table{{width:100%;min-width:680px;border-collapse:collapse}}th,td{{padding:9px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}}th{{color:var(--muted);font-weight:500;font-size:.78rem}}td{{font-size:.88rem}}tr:last-child td{{border-bottom:0}}.mono{{overflow-wrap:anywhere}}.pill{{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}}
9798 \\.generated{{display:none;margin-top:14px;border:1px solid color-mix(in srgb,var(--green) 42%,var(--line));border-radius:9px;padding:12px;background:color-mix(in srgb,var(--green) 9%,transparent)}}.generated.on{{display:block}}.generated code{{display:block;margin-top:6px;font-size:1.1rem;color:var(--text);overflow-wrap:anywhere}}.inline{{display:flex;gap:10px;align-items:center}}.inline input{{flex:1}}.checkbox{{display:flex;gap:8px;align-items:center;margin:10px 0 2px;color:var(--muted);font-weight:400}}.checkbox input{{width:auto}}.field-help{{margin:0 0 12px 24px;font-size:.86rem;color:var(--muted)}}
9899 \\</style>
99100 \\</head>
···133134 \\<button id="create-app-password" type="button">create app password</button>
134135 \\<div id="generated-app-password" class="generated" aria-live="polite"></div>
135136 \\</section>
137137+ \\<section class="security-section">
138138+ \\<h2>legacy API sessions</h2>
139139+ \\<p class="hint">Active com.atproto.server sessions for this account, including account password and app-password tokens.</p>
140140+ \\<div id="session-items"></div>
141141+ \\</section>
142142+ \\<section class="security-section">
143143+ \\<h2>OAuth app grants</h2>
144144+ \\<p class="hint">Active ATProto OAuth grants issued to applications. Passkey OAuth sign-ins appear here.</p>
145145+ \\<div id="oauth-grant-items"></div>
146146+ \\</section>
136147 \\</section>
137148 \\</section>
138149 \\</main>
···150161 public_host,
151162 securityScript,
152163 });
164164+ try respondHtml(request, .ok, body);
165165+}
166166+167167+pub fn adminSessionsPage(request: *http_api.Request) !void {
168168+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
169169+ defer arena.deinit();
170170+ const allocator = arena.allocator();
171171+ const public_url = config.publicUrl();
172172+ const public_host = try htmlEscape(allocator, displayHost(public_url));
173173+ const body = try std.fmt.allocPrint(allocator,
174174+ \\<!doctype html>
175175+ \\<html lang="en">
176176+ \\<head>
177177+ \\<meta charset="utf-8">
178178+ \\<meta name="viewport" content="width=device-width, initial-scale=1">
179179+ \\<title>zds sessions</title>
180180+ \\<link rel="icon" href="/favicon.svg" type="image/svg+xml">
181181+ \\<style>
182182+ \\:root{{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8}}
183183+ \\@media (prefers-color-scheme:light){{:root{{--bg:#f6f4ed;--panel:#fffdf7;--line:#d8d0c0;--text:#161412;--muted:#625c53;--accent:#315dcb;--green:#1b7340;--field:#fffaf1;--bad:#9f1d1d;color-scheme:light}}}}
184184+ \\*{{box-sizing:border-box}}body{{margin:0;min-height:100vh;background:radial-gradient(circle at 18% 0,color-mix(in srgb,var(--green) 20%,transparent),transparent 34%),var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}}
185185+ \\.shell{{width:min(100%,780px);margin:0 auto;padding:28px 16px 42px}}a{{color:inherit}}.brand{{display:inline-flex;margin-bottom:58px;text-decoration:none;font-weight:800}}h1{{margin:0;font-size:clamp(46px,16vw,96px);line-height:.88;letter-spacing:0}}p{{color:var(--muted);margin:14px 0 0;max-width:60ch}}strong{{color:var(--text)}}
186186+ \\.panel{{margin-top:30px;border:1px solid var(--line);border-radius:10px;background:linear-gradient(135deg,color-mix(in srgb,var(--green) 10%,transparent),transparent 46%),linear-gradient(315deg,color-mix(in srgb,var(--accent) 8%,transparent),transparent 44%),var(--panel);padding:18px}}
187187+ \\label{{display:block;margin:15px 0 6px;font-weight:760}}input{{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}}button{{width:100%;margin-top:16px;border:1px solid color-mix(in srgb,var(--accent) 60%,var(--line));border-radius:10px;background:var(--accent);color:#061021;font:inherit;font-weight:800;padding:12px;cursor:pointer}}
188188+ \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.toolbar{{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:14px}}.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}}.timezone{{font-size:.82rem;color:var(--muted);margin-top:4px}}.chooser{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:18px}}.choice{{width:auto;margin:0;text-align:left;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 70%,transparent);color:var(--text);padding:11px;cursor:pointer}}.choice[aria-pressed="true"]{{border-color:color-mix(in srgb,var(--accent) 72%,var(--line));background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 17%,transparent),color-mix(in srgb,var(--field) 78%,transparent))}}.choice span{{display:block;color:var(--muted);font-size:.78rem;font-weight:500}}.choice strong{{display:block;margin-top:4px;font-size:1.35rem}}.filters{{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}}.filter{{width:auto;margin:0;padding:7px 10px;border-radius:999px;background:transparent;color:var(--muted);border-color:var(--line)}}.filter[aria-pressed="true"]{{color:#061021;background:var(--accent);border-color:var(--accent)}}.section{{border-top:1px solid var(--line);padding-top:16px;margin-top:18px}}.section-head{{display:flex;gap:12px;align-items:end;justify-content:space-between}}.section h2{{font-size:1rem;margin:0}}.list{{display:grid;gap:9px;margin-top:10px}}.session-card{{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:11px 12px}}.session-card.usable{{border-color:color-mix(in srgb,var(--green) 44%,var(--line))}}.session-card.ended{{opacity:.82}}.card-top{{display:flex;align-items:start;justify-content:space-between;gap:12px}}.who,.what{{font-weight:800;overflow-wrap:anywhere}}.did,.scope{{display:block;margin-top:2px;color:var(--muted);font-size:.82rem;overflow-wrap:anywhere}}.pill{{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:3px 8px;font-size:.78rem;font-weight:800;white-space:nowrap}}.pill.usable{{color:#061021;background:var(--green);border-color:var(--green)}}.pill.ended{{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 55%,var(--line));background:color-mix(in srgb,var(--bad) 7%,transparent)}}.card-grid{{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px}}.stamp{{border-top:1px solid color-mix(in srgb,var(--line) 70%,transparent);padding-top:7px;color:var(--text)}}.stamp span{{display:block;color:var(--muted);font-size:.72rem}}.pager{{display:flex;align-items:center;gap:8px;justify-content:flex-end;color:var(--muted);font-size:.82rem}}.pager button{{width:auto;margin:0;padding:6px 9px;background:transparent;color:var(--text);border-color:var(--line)}}.empty{{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px}}@media (max-width:680px){{.chooser{{grid-template-columns:1fr}}.card-grid{{grid-template-columns:1fr}}.section-head{{display:block}}.pager{{justify-content:flex-start;margin-top:8px}}}}
189189+ \\</style>
190190+ \\</head>
191191+ \\<body>
192192+ \\<main class="shell">
193193+ \\<a class="brand" href="/">zds</a>
194194+ \\<h1>sessions</h1>
195195+ \\<p>Operator view for app authorizations and legacy API sessions across accounts hosted on <strong>{s}</strong>.</p>
196196+ \\<section class="panel">
197197+ \\<form id="admin-form">
198198+ \\<label for="token">admin token</label>
199199+ \\<input id="token" name="token" type="password" autocomplete="off" required>
200200+ \\<button>load sessions</button>
201201+ \\</form>
202202+ \\<p id="status" class="status"></p>
203203+ \\<p id="timezone" class="timezone"></p>
204204+ \\<div class="chooser" id="kind-chooser" hidden>
205205+ \\<button class="choice" type="button" data-kind="grants" aria-pressed="true"><span>OAuth app grants</span><strong id="grant-count">0</strong></button>
206206+ \\<button class="choice" type="button" data-kind="sessions" aria-pressed="false"><span>legacy API sessions</span><strong id="session-count">0</strong></button>
207207+ \\</div>
208208+ \\<div class="filters" id="filters" hidden>
209209+ \\<button class="filter" type="button" data-filter="usable" aria-pressed="true">usable now</button>
210210+ \\<button class="filter" type="button" data-filter="ended" aria-pressed="false">ended</button>
211211+ \\<button class="filter" type="button" data-filter="all" aria-pressed="false">all</button>
212212+ \\</div>
213213+ \\<section class="section">
214214+ \\<div class="section-head"><div><h2 id="list-title">OAuth app grants</h2>
215215+ \\<p id="list-hint" class="hint">Apps authorized through ATProto OAuth. Usable means the grant has not expired or been revoked.</p>
216216+ \\</div><div id="pager" class="pager"></div></div>
217217+ \\<div id="items"></div>
218218+ \\</section>
219219+ \\</section>
220220+ \\</main>
221221+ \\<script>{s}</script>
222222+ \\</body>
223223+ \\</html>
224224+ , .{ public_host, adminSessionsScript });
153225 try respondHtml(request, .ok, body);
154226}
155227···536608 \\const prepCreate=(o)=>{const p=o.publicKey;p.challenge=b64ToBuf(p.challenge);p.user.id=b64ToBuf(p.user.id);p.excludeCredentials=(p.excludeCredentials||[]).map(c=>({...c,id:b64ToBuf(c.id)}));return o};
537609 \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}});
538610 \\let accessJwt=null,handle=null;
539539- \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),appPasswordRoot=document.querySelector('#app-password-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'),generatedAppPassword=document.querySelector('#generated-app-password');
611611+ \\const status=document.querySelector('#status'),accountBox=document.querySelector('#account'),itemsRoot=document.querySelector('#passkey-items'),appPasswordRoot=document.querySelector('#app-password-items'),sessionRoot=document.querySelector('#session-items'),oauthGrantRoot=document.querySelector('#oauth-grant-items'),loginForm=document.querySelector('#login-form'),accountLabel=document.querySelector('#account-label'),generatedAppPassword=document.querySelector('#generated-app-password');
540612 \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}});
541613 \\const fail=async(r,msg)=>{if(r.ok)return r.json();let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)};
542614 \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used';
543615 \\const credentialRow=(name,meta)=>{const row=document.createElement('div');row.className='credential-row';const label=document.createElement('span');label.className='credential-name';label.textContent=name;const small=document.createElement('span');small.className='credential-meta';small.textContent=meta;label.append(small);const actions=document.createElement('span');actions.className='credential-actions';row.append(label,actions);return{row,actions}};
616616+ \\const empty=(root,text)=>{root.textContent='';const p=document.createElement('p');p.className='hint';p.textContent=text;root.append(p)};
617617+ \\const table=(root,cols,items,row)=>{root.textContent='';if(!items.length)return empty(root,'No active entries.');const wrap=document.createElement('div');wrap.className='table-wrap';const t=document.createElement('table');const thead=document.createElement('thead');const tr=document.createElement('tr');for(const col of cols){const th=document.createElement('th');th.textContent=col;tr.append(th)}thead.append(tr);const tbody=document.createElement('tbody');for(const item of items){const values=row(item);const tr=document.createElement('tr');for(const value of values){const td=document.createElement('td');if(value&&value.nodeType)td.append(value);else td.textContent=value??'';tr.append(td)}tbody.append(tr)}t.append(thead,tbody);wrap.append(t);root.append(wrap)};
618618+ \\const mono=(text)=>{const span=document.createElement('span');span.className='mono';span.textContent=text||'';return span};
619619+ \\const time=(value)=>value?new Date(value).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'never';
620620+ \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password session';if(s.authMethod==='app_password')return'app password session';if(s.authMethod==='app_password_privileged')return'privileged app password session';return s.authMethod};
621621+ \\const showSessions=(items)=>table(sessionRoot,['method','created','last used','refresh expires'],items,s=>[methodLabel(s),time(s.createdAt),time(s.lastUsedAt),time(s.refreshExpiresAt)]);
622622+ \\const showOAuthGrants=(items)=>table(oauthGrantRoot,['client','created','expires','scope'],items,g=>[mono(g.clientId),time(g.createdAt),time(g.expiresAt),mono(g.scope)]);
544623 \\const showPasskeys=(items)=>{itemsRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No passkeys are saved for this account yet.';itemsRoot.append(p);return}for(const item of items){const view=credentialRow(item.friendlyName||'unnamed passkey',`last used: ${when(item.lastUsed)}`);const rename=document.createElement('button');rename.type='button';rename.textContent='rename';rename.onclick=async()=>{const name=prompt('Passkey name',item.friendlyName||'');if(!name)return;await fail(await authed('/xrpc/com.atproto.server.updatePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id,friendlyName:name})}),'rename failed');await load()};const del=document.createElement('button');del.type='button';del.className='danger';del.textContent='delete';del.onclick=async()=>{if(!confirm('Delete this passkey from this PDS? Your browser or password manager may still keep its copy.'))return;await fail(await authed('/xrpc/com.atproto.server.deletePasskey',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id})}),'delete failed');await load()};view.actions.append(rename,del);itemsRoot.append(view.row)}};
545624 \\const showAppPasswords=(items)=>{appPasswordRoot.textContent='';if(!items.length){const p=document.createElement('p');p.className='hint';p.textContent='No app passwords are active for this account.';appPasswordRoot.append(p);return}for(const item of items){const meta=`created ${item.createdAt}${item.privileged?' · privileged':''}`;const view=credentialRow(item.name,meta);const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='revoke';revoke.onclick=async()=>{if(!confirm(`Revoke app password "${item.name}"? Existing sessions created with it will be revoked too.`))return;await fail(await authed('/xrpc/com.atproto.server.revokeAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:item.name})}),'revoke failed');generatedAppPassword.classList.remove('on');generatedAppPassword.textContent='';await load()};view.actions.append(revoke);appPasswordRoot.append(view.row)}};
546546- \\const load=async()=>{const [passkeys,passwords]=await Promise.all([fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys'),fail(await authed('/xrpc/com.atproto.server.listAppPasswords'),'failed to load app passwords')]);showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[])};
625625+ \\const load=async()=>{const [passkeys,passwords,sessions]=await Promise.all([fail(await authed('/xrpc/com.atproto.server.listPasskeys'),'failed to load passkeys'),fail(await authed('/xrpc/com.atproto.server.listAppPasswords'),'failed to load app passwords'),fail(await authed('/xrpc/dev.zat.account.listSessions'),'failed to load sessions')]);showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[]);showSessions(sessions.sessions||[]);showOAuthGrants(sessions.oauthGrants||[])};
547626 \\loginForm.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{const f=e.currentTarget;status.textContent='signing in...';const session=await fail(await fetch('/xrpc/com.atproto.server.createSession',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier:f.identifier.value,password:f.password.value})}),'sign in failed');accessJwt=session.accessJwt;handle=session.handle;loginForm.classList.add('off');accountBox.classList.add('on');accountLabel.innerHTML=`signed in as <strong>${handle}</strong>`;status.textContent='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}});
548627 \\document.querySelector('#add-passkey').addEventListener('click',async()=>{status.className='status';try{const friendlyName=document.querySelector('#friendly').value;status.textContent='opening browser passkey prompt...';const start=await fail(await authed('/xrpc/com.atproto.server.startPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName})}),'registration failed');const credential=await navigator.credentials.create(prepCreate(start.options));await fail(await authed('/xrpc/com.atproto.server.finishPasskeyRegistration',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName,credential:serialize(credential)})}),'registration failed');status.className='status ok';status.textContent='Passkey saved.';await load()}catch(err){status.className='status error';status.textContent=(err.name?err.name+': ':'')+(err.message||String(err))}});
549628 \\document.querySelector('#create-app-password').addEventListener('click',async()=>{status.className='status';try{const name=document.querySelector('#app-password-name').value.trim();const privileged=document.querySelector('#app-password-privileged').checked;if(!name)throw new Error('Choose a name for this app password.');status.textContent='creating app password...';const created=await fail(await authed('/xrpc/com.atproto.server.createAppPassword',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,privileged})}),'create failed');generatedAppPassword.classList.add('on');generatedAppPassword.innerHTML='Copy this app password now. It will not be shown again.<code></code>';generatedAppPassword.querySelector('code').textContent=created.password;status.className='status ok';status.textContent='App password created.';document.querySelector('#app-password-name').value='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}});
629629+;
630630+631631+const adminSessionsScript =
632632+ \\const status=document.querySelector('#status'),form=document.querySelector('#admin-form'),chooser=document.querySelector('#kind-chooser'),filters=document.querySelector('#filters'),itemsRoot=document.querySelector('#items'),pagerRoot=document.querySelector('#pager'),title=document.querySelector('#list-title'),hint=document.querySelector('#list-hint'),timezone=document.querySelector('#timezone'),grantCount=document.querySelector('#grant-count'),sessionCount=document.querySelector('#session-count');
633633+ \\const pageSize=8;
634634+ \\let sessions=[],grants=[],kind='grants',filter='usable',page=0;
635635+ \\timezone.textContent=`times shown in ${Intl.DateTimeFormat().resolvedOptions().timeZone || 'your local timezone'}`;
636636+ \\const fail=async(r,msg)=>{if(r.ok)return r.json();let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)};
637637+ \\const empty=(text)=>{itemsRoot.textContent='';pagerRoot.textContent='';const p=document.createElement('div');p.className='empty';p.textContent=text;itemsRoot.append(p)};
638638+ \\const time=(value)=>{if(!value)return{short:'never',full:'never'};const d=new Date(value);return{short:d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}),full:d.toLocaleString([], {dateStyle:'full',timeStyle:'long'})}};
639639+ \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password session';if(s.authMethod==='app_password')return'app password session';if(s.authMethod==='app_password_privileged')return'privileged app password session';return s.authMethod};
640640+ \\const shownItems=()=>{const source=kind==='grants'?grants:sessions;if(filter==='all')return source;if(filter==='usable')return source.filter(item=>item.active);return source.filter(item=>!item.active)};
641641+ \\const pill=(usable)=>{const span=document.createElement('span');span.className=`pill ${usable?'usable':'ended'}`;span.textContent=usable?'usable now':'ended';span.title=usable?'Not revoked and not expired.':'Expired or revoked.';return span};
642642+ \\const stamp=(label,value)=>{const div=document.createElement('div');div.className='stamp';const span=document.createElement('span');span.textContent=label;const t=time(value);const body=document.createElement('time');body.textContent=t.short;body.title=t.full;body.dateTime=value||'';div.append(span,body);return div};
643643+ \\const card=(item)=>{const isGrant=kind==='grants';const el=document.createElement('article');el.className=`session-card ${item.active?'usable':'ended'}`;const top=document.createElement('div');top.className='card-top';const main=document.createElement('div');const who=document.createElement('div');who.className='who';who.textContent=item.handle||item.did||'unknown account';const sub=document.createElement('span');sub.className='did';sub.textContent=item.did||'';main.append(who,sub);top.append(main,pill(item.active));const what=document.createElement('div');what.className='what';what.textContent=isGrant?item.clientId:methodLabel(item);const scope=document.createElement('span');scope.className='scope';scope.textContent=isGrant?item.scope:(item.controllerDid?`controller ${item.controllerDid}`:'legacy com.atproto.server session');what.append(scope);const grid=document.createElement('div');grid.className='card-grid';if(isGrant){grid.append(stamp('authorized',item.createdAt),stamp('grant expires',item.expiresAt),stamp('revoked',item.revokedAt))}else{grid.append(stamp('created',item.createdAt),stamp('last used',item.lastUsedAt),stamp('refresh token expires',item.refreshExpiresAt))}el.append(top,what,grid);return el};
644644+ \\const renderPager=(total)=>{pagerRoot.textContent='';if(total<=pageSize)return;const pages=Math.ceil(total/pageSize);const prev=document.createElement('button');prev.type='button';prev.textContent='prev';prev.disabled=page===0;prev.onclick=()=>{page--;render()};const label=document.createElement('span');label.textContent=`${page+1}/${pages}`;const next=document.createElement('button');next.type='button';next.textContent='next';next.disabled=page>=pages-1;next.onclick=()=>{page++;render()};pagerRoot.append(prev,label,next)};
645645+ \\const renderControls=()=>{grantCount.textContent=`${grants.filter(g=>g.active).length} usable / ${grants.length} total`;sessionCount.textContent=`${sessions.filter(s=>s.active).length} usable / ${sessions.length} total`;for(const b of chooser.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.kind===kind));for(const b of filters.querySelectorAll('button'))b.setAttribute('aria-pressed',String(b.dataset.filter===filter))};
646646+ \\const render=()=>{renderControls();const isGrant=kind==='grants';title.textContent=isGrant?'OAuth app grants':'legacy API sessions';hint.textContent=isGrant?'Apps authorized through ATProto OAuth. Usable means the grant has not expired or been revoked.':'Tokens from com.atproto.server.createSession, including account password and app-password sessions. Passkey sign-in for OAuth appears under OAuth app grants.';const items=shownItems();if(!items.length)return empty(filter==='usable'?'Nothing usable right now.':filter==='ended'?'No ended entries.':'No entries.');const maxPage=Math.max(0,Math.ceil(items.length/pageSize)-1);if(page>maxPage)page=maxPage;itemsRoot.textContent='';const list=document.createElement('div');list.className='list';for(const item of items.slice(page*pageSize,page*pageSize+pageSize))list.append(card(item));itemsRoot.append(list);renderPager(items.length)};
647647+ \\chooser.addEventListener('click',(e)=>{const b=e.target.closest('button[data-kind]');if(!b)return;kind=b.dataset.kind;page=0;render()});
648648+ \\filters.addEventListener('click',(e)=>{const b=e.target.closest('button[data-filter]');if(!b)return;filter=b.dataset.filter;page=0;render()});
649649+ \\form.addEventListener('submit',async(e)=>{e.preventDefault();status.className='status';try{status.textContent='loading authorizations...';const token=form.token.value.trim();const data=await fail(await fetch('/xrpc/dev.zat.admin.listSessions?active=false&limit=500',{headers:{authorization:`Bearer ${token}`}}),'failed to load authorizations');sessions=data.sessions||[];grants=data.oauthGrants||[];chooser.hidden=false;filters.hidden=false;kind='grants';filter='usable';page=0;render();status.textContent=''}catch(err){status.className='status error';status.textContent=err.message||String(err)}});
550650;
551651552652test "validates webauthn credential key through tangled dependency" {