atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add resident account hub

zzstoatzz (Jun 25, 2026, 5:07 PM -0500) d6f8a678 cb168582

+244 -367
+1 -1
AGENTS.md
··· 73 73 ``` 74 74 75 75 Also run `just smoke-permissioned` when touching `com.atproto.space.*`, 76 - permissioned-data storage, or the `/spaces` resident view. 76 + permissioned-data storage, or the `/account/spaces` resident view. 77 77 78 78 Use `just invite` for admin-minted invite codes. Assume `ZDS_ADMIN_TOKEN` is 79 79 set in the repo `.env`; source it before running the target, for example
+4 -3
README.md
··· 33 33 ## docs 34 34 35 35 - [architecture](docs/architecture.md) 36 - - [account security](docs/account-security.md) 36 + - [account management](docs/account-security.md) 37 37 - [comail](docs/comail.md) 38 38 - [development](docs/development.md) 39 39 - [operator guide](docs/operations.md) ··· 136 136 Revocation also invalidates active sessions created with that app password. 137 137 - Passkeys are optional account credentials for the OAuth login page. ZDS stores 138 138 credential IDs, public keys, counters, names, and last-use timestamps. 139 - - `/security` is the local account-security page for passkey and app-password 140 - management. 139 + - `/account` is the resident account hub. It includes passkey and app-password 140 + management, app access/session views, email/account status controls, and the 141 + experimental permissioned-space record browser. 141 142 - `com.atproto.space.*` permissioned-data routes are an experimental prototype 142 143 and operator gated with `ZDS_PERMISSIONED_DATA`. ZDS implements enough storage 143 144 and credential substrate for local experiments, but the upstream proposal is
+12 -6
docs/account-security.md
··· 43 43 44 44 ## management UI 45 45 46 - `/security` is the local account-security surface. It signs in with the account 47 - password, then lists passkeys and app passwords for that account. App passwords 48 - can be created and revoked from the page; the generated password is displayed 49 - once and is not recoverable later because only the hash is stored. The 50 - privileged-app-password checkbox is labeled as trusted-client behavior because 51 - ordinary clients should not request it casually. 46 + `/account` is the local resident account hub. Its security section, 47 + `/account/security`, signs in with the account password, then lists passkeys and 48 + app passwords for that account. App passwords can be created and revoked from 49 + the page; the generated password is displayed once and is not recoverable later 50 + because only the hash is stored. The privileged-app-password checkbox is labeled 51 + as trusted-client behavior because ordinary clients should not request it 52 + casually. 53 + 54 + The same hub also exposes app/OAuth sessions, direct API token families, 55 + resident email and account-status controls, and the experimental 56 + permissioned-space record browser. `/security` and `/passkeys` remain 57 + compatibility redirects to `/account/security`. 52 58 53 59 ## references 54 60
+4 -4
docs/architecture.md
··· 80 80 login is supported so an account can be chosen by the authenticator during the 81 81 OAuth flow. 82 82 83 - `/security` is the local management surface for passkeys and app passwords. It 84 - uses account-password authentication for management actions, matching the 85 - official PDS boundary that app passwords cannot mint or revoke other app 86 - passwords. 83 + `/account` is the resident management surface. Its security section, 84 + `/account/security`, manages passkeys and app passwords with account-password 85 + authentication, matching the official PDS boundary that app passwords cannot 86 + mint or revoke other app passwords. 87 87 88 88 PDS-owned `app.bsky.actor` preferences are handled locally because the official 89 89 PDS implements those routes. Appview reads and writes remain behind the generic
+5 -4
docs/operations.md
··· 202 202 203 203 ## account security 204 204 205 - The local security surface lives at `/security`. It is for account-owner 206 - management of passkeys and app passwords and requires the account password for 207 - management actions. App-password revocation also revokes active sessions that 208 - were created with that app password. 205 + The resident account surface lives at `/account`. The security section, 206 + `/account/security`, is for account-owner management of passkeys and app 207 + passwords and requires the account password for management actions. 208 + App-password revocation also revokes active sessions that were created with 209 + that app password. `/security` and `/passkeys` are compatibility redirects. 209 210 210 211 ## conformance 211 212
+5 -5
docs/passkeys.md
··· 23 23 If the login hint resolves to an account with saved passkeys, the OAuth page 24 24 offers passkey login first and keeps password login available. If no passkey is 25 25 saved for the account, password login is shown directly and points the user to 26 - `/security` after authentication. 26 + `/account/security` after authentication. 27 27 28 28 Discoverable passkey login is supported: the browser may return a credential 29 29 without the page first choosing an account. ZDS resolves the credential ID to ··· 32 32 33 33 ## management 34 34 35 - Passkeys are managed from `/security`, alongside app passwords. That page signs 36 - in with the account password, lists saved passkeys, and supports add, rename, 37 - and delete. Registration uses a browser WebAuthn prompt; ZDS stores only the 38 - credential ID, public key, counter, name, and timestamps. 35 + Passkeys are managed from `/account/security`, alongside app passwords. That 36 + page signs in with the account password, lists saved passkeys, and supports add, 37 + rename, and delete. Registration uses a browser WebAuthn prompt; ZDS stores only 38 + the credential ID, public key, counter, name, and timestamps. 39 39 40 40 Deleting a passkey removes it from ZDS. The browser, OS, or password manager 41 41 may still keep its copy, but it can no longer be used with this PDS.
+2 -2
docs/permissioned-data.md
··· 151 151 152 152 - HTTP dispatch and XRPC semantics live in `src/atproto/space.zig`. 153 153 - Experimental protocol helpers live in `src/internal/permissioned_data.zig`. 154 - - The resident private-record browser lives in 155 - `src/internal/private_spaces.zig`. 154 + - The resident private-record browser lives in the `/account/spaces` section 155 + rendered by `src/internal/account.zig`. 156 156 - OAuth scope parsing for `space:` scopes lives in `src/internal/scopes.zig`. 157 157 - Persistent state lives in permissioned-data tables in 158 158 `src/storage/store.zig`.
+1 -1
src/atproto/oauth.zig
··· 301 301 \\<button class="deny" name="deny" value="1" formnovalidate>deny</button> 302 302 \\<button>authorize</button> 303 303 \\</div> 304 - \\<p class="security-link">Want passkey sign-in next time? Add one from <a href="/security">security</a>.</p> 304 + \\<p class="security-link">Want passkey sign-in next time? Add one from <a href="/account/security">account security</a>.</p> 305 305 \\</div> 306 306 ); 307 307 }
+26 -11
src/http/router.zig
··· 8 8 api_docs, 9 9 api_openapi, 10 10 stats_page, 11 - private_spaces_page, 11 + account_page, 12 + spaces_redirect, 12 13 favicon, 13 14 og_image, 14 15 health, ··· 23 24 oauth_revoke, 24 25 oauth_passkey_options, 25 26 oauth_passkey_finish, 26 - passkeys_page, 27 - security_page, 27 + passkeys_redirect, 28 + security_redirect, 28 29 admin_sessions_page, 29 30 zds_list_account_sessions, 30 31 zds_list_admin_sessions, ··· 114 115 .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, 115 116 .{ .route = .stats_page, .method = "GET", .path = "/stats", .group = "zds", .auth = "public", .summary = "Operational health and route latency page." }, 116 117 .{ .route = .stats_page, .method = "HEAD", .path = "/stats", .group = "zds", .auth = "public", .summary = "Stats page probe without a response body." }, 117 - .{ .route = .private_spaces_page, .method = "GET", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Resident view for permissioned-space records." }, 118 - .{ .route = .private_spaces_page, .method = "HEAD", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Permissioned-space record view probe." }, 118 + .{ .route = .account_page, .method = "GET", .path = "/account", .group = "account", .auth = "resident", .summary = "Resident account control center." }, 119 + .{ .route = .account_page, .method = "GET", .path = "/account/security", .group = "account", .auth = "resident", .summary = "Resident passkey and app-password management." }, 120 + .{ .route = .account_page, .method = "GET", .path = "/account/sessions", .group = "account", .auth = "resident", .summary = "Resident app access and direct API token view." }, 121 + .{ .route = .account_page, .method = "GET", .path = "/account/apps", .group = "account", .auth = "resident", .summary = "Compatibility view for app access." }, 122 + .{ .route = .account_page, .method = "GET", .path = "/account/spaces", .group = "account", .auth = "resident", .summary = "Resident view for permissioned-space records." }, 123 + .{ .route = .account_page, .method = "GET", .path = "/account/manage", .group = "account", .auth = "resident", .summary = "Resident email and account status management." }, 124 + .{ .route = .account_page, .method = "GET", .path = "/account/about", .group = "account", .auth = "resident", .summary = "Resident account hosting explainer." }, 125 + .{ .route = .spaces_redirect, .method = "GET", .path = "/spaces", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/spaces." }, 126 + .{ .route = .spaces_redirect, .method = "HEAD", .path = "/spaces", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/spaces." }, 119 127 .{ .route = .root, .method = "GET", .path = "/", .group = "zds", .auth = "public", .summary = "Public landing page for the PDS." }, 120 128 .{ .route = .root, .method = "HEAD", .path = "/", .group = "zds", .auth = "public", .summary = "Landing page probe." }, 121 129 .{ .route = .favicon, .method = "GET", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon." }, ··· 137 145 .{ .route = .oauth_revoke, .method = "POST", .path = "/oauth/revoke", .group = "oauth", .auth = "client", .summary = "OAuth token revocation endpoint." }, 138 146 .{ .route = .oauth_passkey_options, .method = "POST", .path = "/oauth/passkey/options", .group = "oauth", .auth = "browser", .summary = "Passkey login challenge for OAuth flows." }, 139 147 .{ .route = .oauth_passkey_finish, .method = "POST", .path = "/oauth/passkey/finish", .group = "oauth", .auth = "browser", .summary = "Passkey login completion for OAuth flows." }, 140 - .{ .route = .security_page, .method = "GET", .path = "/security", .group = "account", .auth = "session", .summary = "Security settings page." }, 141 - .{ .route = .passkeys_page, .method = "GET", .path = "/passkeys", .group = "account", .auth = "session", .summary = "Compatibility redirect to security settings." }, 148 + .{ .route = .security_redirect, .method = "GET", .path = "/security", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/security." }, 149 + .{ .route = .passkeys_redirect, .method = "GET", .path = "/passkeys", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/security." }, 142 150 .{ .route = .admin_sessions_page, .method = "GET", .path = "/admin/sessions", .group = "zds", .auth = "admin", .summary = "Operator session inventory page." }, 143 151 .{ .route = .zds_list_account_sessions, .method = "GET", .path = "/xrpc/dev.zat.account.listSessions", .group = "zds", .auth = "bearer", .summary = "List active account sessions and OAuth grants for the signed-in account.", .params = &.{ "active", "limit" } }, 144 152 .{ .route = .zds_list_admin_sessions, .method = "GET", .path = "/xrpc/dev.zat.admin.listSessions", .group = "zds", .auth = "admin", .summary = "List active sessions and OAuth grants across accounts on this PDS.", .params = &.{ "active", "limit" } }, ··· 264 272 try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); 265 273 try std.testing.expectEqual(Route.stats_page, route(.GET, "/stats")); 266 274 try std.testing.expectEqual(Route.stats_page, route(.HEAD, "/stats")); 267 - try std.testing.expectEqual(Route.private_spaces_page, route(.GET, "/spaces")); 268 - try std.testing.expectEqual(Route.private_spaces_page, route(.HEAD, "/spaces")); 275 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account")); 276 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/security")); 277 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/sessions")); 278 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/apps")); 279 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/spaces")); 280 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/manage")); 281 + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/about")); 282 + try std.testing.expectEqual(Route.spaces_redirect, route(.GET, "/spaces")); 283 + try std.testing.expectEqual(Route.spaces_redirect, route(.HEAD, "/spaces")); 269 284 try std.testing.expectEqual(Route.favicon, route(.GET, "/favicon.svg")); 270 285 try std.testing.expectEqual(Route.favicon, route(.HEAD, "/favicon.svg")); 271 286 try std.testing.expectEqual(Route.og_image, route(.GET, "/og-image")); ··· 281 296 try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); 282 297 try std.testing.expectEqual(Route.oauth_passkey_options, route(.POST, "/oauth/passkey/options")); 283 298 try std.testing.expectEqual(Route.oauth_passkey_finish, route(.POST, "/oauth/passkey/finish")); 284 - try std.testing.expectEqual(Route.passkeys_page, route(.GET, "/passkeys")); 285 - try std.testing.expectEqual(Route.security_page, route(.GET, "/security")); 299 + try std.testing.expectEqual(Route.passkeys_redirect, route(.GET, "/passkeys")); 300 + try std.testing.expectEqual(Route.security_redirect, route(.GET, "/security")); 286 301 try std.testing.expectEqual(Route.admin_sessions_page, route(.GET, "/admin/sessions")); 287 302 try std.testing.expectEqual(Route.zds_list_account_sessions, route(.GET, "/xrpc/dev.zat.account.listSessions")); 288 303 try std.testing.expectEqual(Route.zds_list_admin_sessions, route(.GET, "/xrpc/dev.zat.admin.listSessions"));
+5 -4
src/http/server.zig
··· 12 12 const log = @import("../core/log.zig"); 13 13 const http_api = @import("api.zig"); 14 14 const docs = @import("docs.zig"); 15 + const account_page = @import("../internal/account.zig"); 15 16 const passkeys = @import("../internal/passkeys.zig"); 16 - const private_spaces = @import("../internal/private_spaces.zig"); 17 17 const landing = @import("landing/mod.zig"); 18 18 const router = @import("router.zig"); 19 19 const stats = @import("stats.zig"); ··· 77 77 .api_docs => try docs.serve(request), 78 78 .api_openapi => try docs.serveOpenApi(request), 79 79 .stats_page => try stats.serve(request), 80 - .private_spaces_page => try private_spaces.page(request), 80 + .account_page => try account_page.page(request), 81 + .spaces_redirect => try account_page.redirectToSpaces(request), 81 82 .favicon => try landing.serveFavicon(request), 82 83 .og_image => try landing.serveOgImage(request), 83 84 .health => try health(request), ··· 92 93 .oauth_revoke => try atproto_oauth.revoke(request), 93 94 .oauth_passkey_options => try passkeys.loginStart(request), 94 95 .oauth_passkey_finish => try passkeys.loginFinish(request), 95 - .passkeys_page => try passkeys.redirectToSecurity(request), 96 - .security_page => try passkeys.securityPage(request), 96 + .passkeys_redirect => try passkeys.redirectToSecurity(request), 97 + .security_redirect => try account_page.redirectToSecurity(request), 97 98 .admin_sessions_page => try passkeys.adminSessionsPage(request), 98 99 .zds_list_account_sessions => try atproto_server.listSessions(request), 99 100 .zds_list_admin_sessions => try atproto_server.adminListSessions(request),
+178
src/internal/account.zig
··· 1 + const std = @import("std"); 2 + const http_api = @import("../http/api.zig"); 3 + 4 + const http = std.http; 5 + 6 + pub fn page(request: *http_api.Request) !void { 7 + try http_api.respond(request, .ok, html, &html_headers); 8 + } 9 + 10 + pub fn redirectToSecurity(request: *http_api.Request) !void { 11 + try redirect(request, "/account/security"); 12 + } 13 + 14 + pub fn redirectToSpaces(request: *http_api.Request) !void { 15 + try redirect(request, "/account/spaces"); 16 + } 17 + 18 + fn redirect(request: *http_api.Request, location: []const u8) !void { 19 + const headers = [_]http.Header{ 20 + .{ .name = "location", .value = location }, 21 + .{ .name = "connection", .value = "close" }, 22 + }; 23 + try http_api.respond(request, .see_other, "", &headers); 24 + } 25 + 26 + const html_headers = [_]http.Header{ 27 + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 28 + .{ .name = "cache-control", .value = "no-store" }, 29 + .{ .name = "access-control-allow-origin", .value = "*" }, 30 + .{ .name = "access-control-allow-private-network", .value = "true" }, 31 + .{ .name = "connection", .value = "close" }, 32 + }; 33 + 34 + const html = 35 + \\<!doctype html> 36 + \\<html lang="en"> 37 + \\<head> 38 + \\<meta charset="utf-8"> 39 + \\<meta name="viewport" content="width=device-width, initial-scale=1"> 40 + \\<title>zds account</title> 41 + \\<meta name="description" content="Manage an account hosted on this ZDS PDS."> 42 + \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 43 + \\<style> 44 + \\:root{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#5b7159;--text:#f1f2ec;--muted:#c2bcaf;--accent:#8fb0ff;--accent-text:#061021;--green:#37c978;--field:#101410;--bad:#ffb4a8;--warn:#f4d35e;--focus:#f4d35e} 45 + \\@media (prefers-color-scheme:light){:root{--bg:#f6f4ed;--panel:#fffdf7;--line:#847866;--text:#161412;--muted:#4f493f;--accent:#1d4ed8;--accent-text:#fffdf7;--green:#17683a;--field:#fffaf1;--bad:#9f1d1d;--warn:#735f00;--focus:#5a3fc0;color-scheme:light}} 46 + \\*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 18% 0,color-mix(in srgb,var(--green) 18%,transparent),transparent 34%),var(--bg);color:var(--text);font:15px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace} 47 + \\.shell{width:min(100%,900px);margin:0 auto;padding:28px 16px 42px}a{color:inherit}.brand{display:inline-flex;margin-bottom:42px;text-decoration:none;font-weight:800}h1{margin:0;font-size:clamp(52px,15vw,112px);line-height:.88;letter-spacing:0}h2{margin:0;font-size:1.05rem}h3{margin:18px 0 7px;font-size:.95rem}p{color:var(--muted);margin:12px 0 0;max-width:68ch}strong{color:var(--text)}code{overflow-wrap:anywhere;color:var(--accent)} 48 + \\.panel{margin-top:28px;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} 49 + \\label{display:block;margin:14px 0 6px;font-weight:760}input,select{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}input:focus-visible,select:focus-visible,button:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button{border:1px solid var(--accent);border-radius:10px;background:var(--accent);color:var(--accent-text);font:inherit;font-weight:800;padding:12px;cursor:pointer}button.secondary{background:transparent;color:var(--text);border-color:var(--line)}button.danger{background:transparent;color:var(--bad);border-color:color-mix(in srgb,var(--bad) 55%,var(--line))}button:disabled{opacity:.62;cursor:not-allowed}.login button{width:100%;margin-top:22px} 50 + \\.status{min-height:1.5em;color:var(--muted)}.error{color:var(--bad)}.ok{color:var(--green)}.warn{color:var(--warn)}.hint{font-size:.92rem;color:var(--muted);margin:8px 0 0}.resident{display:none}.resident.on{display:block}.login.off{display:none} 51 + \\.account-card{display:grid;grid-template-columns:1.4fr repeat(3,minmax(0,1fr));gap:8px;margin-top:16px}.metric,.identity{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 74%,transparent);padding:11px}.metric span,.identity span{display:block;color:var(--muted);font-size:.78rem}.metric strong,.identity strong{display:block;margin-top:4px;font-size:1.2rem;overflow-wrap:anywhere}.identity strong{font-size:1rem} 52 + \\.nav{display:flex;gap:8px;overflow-x:auto;margin:18px 0 0;padding-bottom:2px}.nav a{display:inline-flex;text-decoration:none;border:1px solid var(--line);border-radius:999px;padding:8px 11px;color:var(--text);white-space:nowrap}.nav a[aria-current="page"]{background:var(--accent);border-color:var(--accent);color:var(--accent-text);font-weight:800} 53 + \\.view{display:none;border-top:1px solid var(--line);padding-top:18px;margin-top:18px}.view.on{display:block}.section{border-top:1px solid var(--line);padding-top:16px;margin-top:18px}.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.actions button{width:auto}.empty{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px} 54 + \\.credential-row,.session-card,.item{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:11px 12px;margin-top:9px}.credential-row{display:flex;align-items:center;gap:10px;justify-content:space-between}.credential-name,.who,.what,.item-title{font-weight:800;overflow-wrap:anywhere}.credential-meta,.did,.scope,.item-meta{display:block;margin-top:2px;color:var(--muted);font-size:.82rem;overflow-wrap:anywhere}.credential-actions{display:flex;gap:8px;flex:0 0 auto}.credential-actions button{padding:7px 10px} 55 + \\.session-card.active{border-color:color-mix(in srgb,var(--green) 44%,var(--line))}.session-card.inactive{opacity:.82}.card-top{display:flex;align-items:start;justify-content:space-between;gap:12px}.pill{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:3px 8px;font-size:.78rem;font-weight:800;white-space:nowrap}.pill.active{color:#061021;background:var(--green);border-color:var(--green)}.pill.inactive{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} 56 + \\.tabs{display:flex;gap:8px;overflow-x:auto;margin-top:14px;padding-bottom:2px}.tab{width:auto;margin:0;text-align:left;background:transparent;color:var(--text);border-color:var(--line);white-space:nowrap}.tab[aria-selected="true"]{color:var(--accent-text);background:var(--accent);border-color:var(--accent)}.item{width:100%;text-align:left;color:var(--text)}.item[aria-selected="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))} 57 + \\pre{margin:12px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,#000 24%,var(--field));padding:12px;color:var(--text);max-height:56vh;overflow:auto}.record-head{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;margin-top:14px}.record-head button{width:auto}.blob-chip{display:inline-flex;margin-top:8px;margin-right:6px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;color:var(--accent);overflow-wrap:anywhere}.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}.checkbox{display:flex;gap:8px;align-items:center;margin:10px 0 2px;color:var(--muted);font-weight:400}.checkbox input{width:auto} 58 + \\@media (max-width:760px){.account-card{grid-template-columns:1fr 1fr}.row,.grid,.card-grid{grid-template-columns:1fr}.actions button{width:100%}.record-head{display:block}.record-head button{width:100%;margin-top:8px}}@media (max-width:520px){.account-card{grid-template-columns:1fr}} 59 + \\</style> 60 + \\</head> 61 + \\<body> 62 + \\<main class="shell"> 63 + \\<a class="brand" href="/">zds</a> 64 + \\<h1>account</h1> 65 + \\<p>The resident control center for an account hosted on this PDS.</p> 66 + \\<section class="panel"> 67 + \\<form id="login-form" class="login"> 68 + \\<label for="identifier">account</label> 69 + \\<input id="identifier" name="identifier" autocomplete="username" required> 70 + \\<label for="password">password</label> 71 + \\<input id="password" name="password" type="password" autocomplete="current-password" required> 72 + \\<button>sign in</button> 73 + \\</form> 74 + \\<p id="status" class="status"></p> 75 + \\<section id="resident" class="resident" aria-live="polite"> 76 + \\<div class="account-card"> 77 + \\<div class="identity"><span>signed in as</span><strong id="account-handle">-</strong><code id="account-did"></code></div> 78 + \\<div class="metric"><span>status</span><strong id="account-status">-</strong></div> 79 + \\<div class="metric"><span>email</span><strong id="account-email-state">-</strong></div> 80 + \\<div class="metric"><span>spaces</span><strong id="space-count">0</strong></div> 81 + \\</div> 82 + \\<nav class="nav" aria-label="account sections"> 83 + \\<a href="/account" data-view="home">home</a> 84 + \\<a href="/account/security" data-view="security">security</a> 85 + \\<a href="/account/sessions" data-view="sessions">sessions</a> 86 + \\<a href="/account/spaces" data-view="spaces">spaces</a> 87 + \\<a href="/account/manage" data-view="manage">manage</a> 88 + \\<a href="/account/about" data-view="about">about</a> 89 + \\</nav> 90 + \\<section id="view-home" class="view"> 91 + \\<h2>home</h2> 92 + \\<p>This PDS stores your public repo, private permissioned-space records, blobs, credentials, and app access state. Use the sections above to manage what this server can control.</p> 93 + \\<div class="section"><h3>quick checks</h3><div id="home-checks"></div></div> 94 + \\</section> 95 + \\<section id="view-security" class="view"> 96 + \\<h2>security</h2> 97 + \\<section class="section"><h3>passkeys</h3><p class="hint">Use a passkey to sign in without typing this account password.</p><div id="passkey-items"></div><label for="friendly">new passkey name</label><input id="friendly" placeholder="this device"><button id="add-passkey" type="button" style="margin-top:12px">add passkey</button></section> 98 + \\<section class="section"><h3>app passwords</h3><p class="hint">Use an app password for clients that should not receive your main account password.</p><div id="app-password-items"></div><label for="app-password-name">new app password name</label><input id="app-password-name" placeholder="client or device name"><label class="checkbox"><input id="app-password-privileged" type="checkbox"> privileged app password</label><p class="hint">Most clients should leave this off.</p><button id="create-app-password" type="button" style="margin-top:12px">create app password</button><div id="generated-app-password" class="generated" aria-live="polite"></div></section> 99 + \\</section> 100 + \\<section id="view-sessions" class="view"> 101 + \\<h2>sessions</h2> 102 + \\<p class="hint" id="timezone"></p> 103 + \\<section class="section"><h3>app access</h3><p class="hint">OAuth app sign-ins. Active means the current refresh token has not been revoked.</p><div id="oauth-grant-items"></div></section> 104 + \\<section class="section"><h3>direct API tokens</h3><p class="hint">Token families from com.atproto.server.createSession, including app-password clients.</p><div id="session-items"></div></section> 105 + \\</section> 106 + \\<section id="view-spaces" class="view"> 107 + \\<h2>spaces</h2> 108 + \\<p>View private records in permissioned spaces for this account.</p> 109 + \\<div id="spaces" class="tabs" role="tablist" aria-label="permissioned spaces"></div> 110 + \\<section class="section"><h3>record set</h3><div class="grid"><div><label for="repo">writer repo</label><input id="repo" spellcheck="false"></div><div><label for="collection">collection, optional</label><input id="collection" placeholder="all collections" spellcheck="false"></div></div><div class="actions"><button id="load-records" type="button">load records</button><button id="next-records" class="secondary" type="button" disabled>next page</button></div><div id="records"></div></section> 111 + \\<section class="section"><h3>selected record</h3><div id="record-detail" class="empty">Choose a record.</div></section> 112 + \\</section> 113 + \\<section id="view-manage" class="view"> 114 + \\<h2>manage</h2> 115 + \\<section class="section"><h3>email</h3><p id="email-copy" class="hint"></p><div class="actions"><button id="verify-email-request" type="button">send verification email</button><button id="update-email-request" class="secondary" type="button">start email update</button></div><div class="grid"><div><label for="email-token">code</label><input id="email-token" autocomplete="one-time-code"></div><div><label for="new-email">new email, for update</label><input id="new-email" type="email"></div></div><div class="actions"><button id="verify-email-confirm" type="button">confirm current email</button><button id="update-email-confirm" class="secondary" type="button">confirm new email</button></div></section> 116 + \\<section class="section"><h3>account status</h3><p class="hint">Deactivation hides the account until it is reactivated. Takedowns and suspensions are operator actions, not resident controls.</p><div class="actions"><button id="activate-account" type="button">reactivate account</button><button id="deactivate-account" class="danger" type="button">deactivate account</button></div></section> 117 + \\</section> 118 + \\<section id="view-about" class="view"> 119 + \\<h2>about</h2> 120 + \\<p>ZDS is the PDS hosting this account. It stores repo data, blobs, account credentials, OAuth token state, and experimental permissioned-space data for residents on this server.</p> 121 + \\<p>This page is local to the PDS. It is not a Bluesky app view, and it only manages things this PDS is responsible for.</p> 122 + \\</section> 123 + \\</section> 124 + \\</section> 125 + \\</main> 126 + \\<script> 127 + \\const $=(id)=>document.getElementById(id); 128 + \\const status=$('status'),loginForm=$('login-form'),resident=$('resident'),generatedAppPassword=$('generated-app-password'); 129 + \\let accessJwt=null,account=null,spaces=[],selectedSpace=null,cursor=null; 130 + \\$('timezone').textContent=`times shown in ${Intl.DateTimeFormat().resolvedOptions().timeZone || 'your local timezone'}`; 131 + \\const esc=(s)=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 132 + \\const setStatus=(text,cls='')=>{status.className='status '+cls;status.textContent=text}; 133 + \\async function fail(r,msg){if(r.ok){const t=await r.text();return t?JSON.parse(t):{}}let body={};try{body=await r.json()}catch{}throw new Error(body.message||body.error||msg)} 134 + \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); 135 + \\async function xrpc(path,params={},options={}){const qs=new URLSearchParams(params);return fail(await authed(path+(qs.size?'?'+qs:''),options),'request failed')} 136 + \\function viewName(){const p=location.pathname; if(p.endsWith('/security'))return'security'; if(p.endsWith('/sessions')||p.endsWith('/apps'))return'sessions'; if(p.endsWith('/spaces'))return'spaces'; if(p.endsWith('/manage'))return'manage'; if(p.endsWith('/about'))return'about'; return'home'} 137 + \\function showView(name=viewName()){for(const el of document.querySelectorAll('.view'))el.classList.toggle('on',el.id==='view-'+name);for(const a of document.querySelectorAll('.nav a'))a.setAttribute('aria-current',a.dataset.view===name?'page':'false')} 138 + \\document.querySelector('.nav').addEventListener('click',e=>{const a=e.target.closest('a[data-view]');if(!a)return;e.preventDefault();history.pushState(null,'',a.href);showView(a.dataset.view)}); 139 + \\addEventListener('popstate',()=>showView()); 140 + \\function updateHeader(){ $('account-handle').textContent=account.handle; $('account-did').textContent=account.did; $('account-status').textContent=account.status||'active'; $('account-email-state').textContent=account.emailConfirmed?'verified':'needs verification'; $('email-copy').textContent=`Current email: ${account.email}${account.emailConfirmed?' (verified)':' (not verified)'}`; } 141 + \\function quickChecks(sessions){const rows=[['email',account.emailConfirmed?'verified':'needs verification',account.emailConfirmed?'active':'inactive'],['app access',`${(sessions.oauthGrants||[]).filter(g=>g.active).length} active`, 'active'],['direct API tokens',`${(sessions.sessions||[]).filter(s=>s.active).length} active`, 'active'],['permissioned spaces',`${spaces.length}`, spaces.length?'active':'inactive']];$('home-checks').innerHTML=rows.map(([a,b,c])=>`<article class="session-card ${c}"><div class="card-top"><div><div class="who">${esc(a)}</div><span class="did">${esc(b)}</span></div></div></article>`).join('')} 142 + \\const b64ToBuf=(v)=>{const b64=v.replace(/-/g,'+').replace(/_/g,'/');const bin=atob(b64+'==='.slice((b64.length+3)%4));const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}; 143 + \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); 144 + \\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}; 145 + \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 146 + \\const time=(value)=>{if(!value)return{short:'never',full:'never'};const d=typeof value==='number'?new Date(value*1000):new Date(value);return{short:d.toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}),full:d.toLocaleString([], {dateStyle:'full',timeStyle:'long'})}}; 147 + \\const stamp=(label,value)=>{const t=time(value);return`<div class="stamp"><span>${esc(label)}</span><time title="${esc(t.full)}">${esc(t.short)}</time></div>`}; 148 + \\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}}; 149 + \\function empty(root,text){root.textContent='';const p=document.createElement('div');p.className='empty';p.textContent=text;root.append(p)} 150 + \\function showPasskeys(items){const root=$('passkey-items');root.textContent='';if(!items.length)return empty(root,'No passkeys are saved for this account yet.');for(const item of items){const view=credentialRow(item.friendlyName||'unnamed passkey',`last used: ${time(item.lastUsed).short}`);const rename=document.createElement('button');rename.type='button';rename.className='secondary';rename.textContent='rename';rename.onclick=async()=>{const friendlyName=prompt('Passkey name',item.friendlyName||'');if(!friendlyName)return;await xrpc('/xrpc/com.atproto.server.updatePasskey',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id,friendlyName})});await loadAccountData()};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?'))return;await xrpc('/xrpc/com.atproto.server.deletePasskey',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:item.id})});await loadAccountData()};view.actions.append(rename,del);root.append(view.row)}} 151 + \\function showAppPasswords(items){const root=$('app-password-items');root.textContent='';if(!items.length)return empty(root,'No app passwords are active for this account.');for(const item of items){const view=credentialRow(item.name,`created ${item.createdAt}${item.privileged?' · privileged':''}`);const revoke=document.createElement('button');revoke.type='button';revoke.className='danger';revoke.textContent='revoke';revoke.onclick=async()=>{if(!confirm(`Revoke app password "${item.name}"?`))return;await xrpc('/xrpc/com.atproto.server.revokeAppPassword',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:item.name})});generatedAppPassword.classList.remove('on');generatedAppPassword.textContent='';await loadAccountData()};view.actions.append(revoke);root.append(view.row)}} 152 + \\const methodLabel=(s)=>s.appPasswordName?`app password: ${s.appPasswordName}`:s.authMethod==='password'?'account password token':s.authMethod==='app_password'?'app password token':s.authMethod==='app_password_privileged'?'privileged app password token':s.authMethod; 153 + \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; 154 + \\function card(item,isGrant){const active=item.active?'active':'inactive';const title=isGrant?item.clientId:methodLabel(item);const sub=isGrant?`${oauthAuthMethod(item)} authorization · ${item.scope}`:(item.controllerDid?`controller ${item.controllerDid}`:'com.atproto.server.createSession token family');const grid=isGrant?stamp('authorized',item.createdAt)+stamp('access token expires',item.expiresAt)+stamp('revoked',item.revokedAt):stamp('created',item.createdAt)+stamp('last used',item.lastUsedAt)+stamp('refresh token expires',item.refreshExpiresAt);return`<article class="session-card ${active}"><div class="card-top"><div><div class="who">${esc(title)}</div><span class="did">${esc(sub)}</span></div><span class="pill ${active}" title="${active==='active'?'can continue authenticating':'expired or revoked'}">${active}</span></div><div class="card-grid">${grid}</div></article>`} 155 + \\function showSessions(data){const grants=data.oauthGrants||[],sessions=data.sessions||[];$('oauth-grant-items').innerHTML=grants.length?grants.map(g=>card(g,true)).join(''):'<div class="empty">No OAuth app access rows.</div>';$('session-items').innerHTML=sessions.length?sessions.map(s=>card(s,false)).join(''):'<div class="empty">No direct API token rows.</div>'} 156 + \\function spaceParts(uri){const parts=String(uri).split('/');return {type:parts[3]||uri,skey:parts[4]||''}} 157 + \\function renderSpaces(){ $('space-count').textContent=spaces.length; const box=$('spaces'); box.innerHTML=''; if(!spaces.length){box.innerHTML='<div class="empty">No permissioned spaces found for this account.</div>';return} spaces.forEach((space,idx)=>{const p=spaceParts(space.uri);const btn=document.createElement('button');btn.type='button';btn.className='tab';btn.role='tab';btn.ariaSelected=idx===0?'true':'false';btn.textContent=p.type+' / '+p.skey;btn.onclick=()=>selectSpace(space.uri);box.appendChild(btn)});selectSpace(spaces[0].uri)} 158 + \\function selectSpace(uri){selectedSpace=uri;cursor=null;$('records').innerHTML='';$('record-detail').className='empty';$('record-detail').textContent='Choose a record.';for(const btn of $('spaces').querySelectorAll('.tab'))btn.ariaSelected=btn.textContent===spaceParts(uri).type+' / '+spaceParts(uri).skey?'true':'false';$('repo').value=account.did} 159 + \\function renderRecords(records,append=false){const box=$('records');if(!append)box.innerHTML='';if(!records.length&&!append)box.innerHTML='<div class="empty">No records for that repo.</div>';for(const rec of records){const btn=document.createElement('button');btn.type='button';btn.className='item';btn.innerHTML='<span class="item-title">'+esc(rec.collection)+' / '+esc(rec.rkey)+'</span><span class="item-meta">'+esc(rec.cid)+'</span>';btn.onclick=()=>openRecord(rec);box.appendChild(btn)}} 160 + \\async function loadRecords(append=false){if(!selectedSpace)throw new Error('choose a space first');const repo=$('repo').value.trim(),collection=$('collection').value.trim();if(!repo)throw new Error('enter a writer repo DID');setStatus('loading records...');const params={space:selectedSpace,repo,limit:'50'};if(collection)params.collection=collection;if(append&&cursor)params.cursor=cursor;const out=await xrpc('/xrpc/com.atproto.space.listRecords',params);cursor=out.cursor||null;$('next-records').disabled=!cursor;renderRecords(out.records||[],append);setStatus('records loaded','ok')} 161 + \\function blobChips(value){const out=[],seen=new Set();function walk(v){if(!v||typeof v!=='object')return;if(v.ref&&v.ref.$link&&!seen.has(v.ref.$link)){seen.add(v.ref.$link);out.push(v.ref.$link)}for(const child of Array.isArray(v)?v:Object.values(v))walk(child)}walk(value);return out.map(cid=>'<span class="blob-chip">blob '+esc(cid)+'</span>').join(' ')} 162 + \\async function openRecord(rec){const detail=$('record-detail');detail.className='';detail.innerHTML='<p class="hint">loading record...</p>';const out=await xrpc('/xrpc/com.atproto.space.getRecord',{space:selectedSpace,repo:$('repo').value.trim(),collection:rec.collection,rkey:rec.rkey});detail.innerHTML='<div class="record-head"><div><strong>'+esc(rec.collection)+' / '+esc(rec.rkey)+'</strong><span class="item-meta">'+esc(out.cid)+'</span></div><button class="secondary" type="button" id="copy-json">copy JSON</button></div>'+blobChips(out.value)+'<pre>'+esc(JSON.stringify(out.value,null,2))+'</pre>';$('copy-json').onclick=()=>navigator.clipboard.writeText(JSON.stringify(out.value,null,2))} 163 + \\async function loadAccountData(){setStatus('loading account...');const [session,passkeys,passwords,sessions,spaceOut]=await Promise.all([xrpc('/xrpc/com.atproto.server.getSession'),xrpc('/xrpc/com.atproto.server.listPasskeys'),xrpc('/xrpc/com.atproto.server.listAppPasswords'),xrpc('/xrpc/dev.zat.account.listSessions',{active:'false',limit:'200'}),xrpc('/xrpc/com.atproto.space.listSpaces',{limit:'100'}).catch(()=>({spaces:[]}))]);account=session;spaces=spaceOut.spaces||[];updateHeader();showPasskeys(passkeys.passkeys||[]);showAppPasswords(passwords.passwords||[]);showSessions(sessions);renderSpaces();quickChecks(sessions);setStatus('signed in','ok')} 164 + \\loginForm.addEventListener('submit',async(e)=>{e.preventDefault();try{setStatus('signing in...');const f=e.currentTarget;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;account=session;loginForm.classList.add('off');resident.classList.add('on');showView();await loadAccountData()}catch(err){setStatus(err.message||String(err),'error')}}) 165 + \\$('add-passkey').onclick=async()=>{try{setStatus('opening browser passkey prompt...');const friendlyName=$('friendly').value;const start=await xrpc('/xrpc/com.atproto.server.startPasskeyRegistration',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName})});const credential=await navigator.credentials.create(prepCreate(start.options));await xrpc('/xrpc/com.atproto.server.finishPasskeyRegistration',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({friendlyName,credential:serialize(credential)})});setStatus('passkey saved','ok');await loadAccountData()}catch(err){setStatus((err.name?err.name+': ':'')+(err.message||String(err)),'error')}} 166 + \\$('create-app-password').onclick=async()=>{try{const name=$('app-password-name').value.trim(),privileged=$('app-password-privileged').checked;if(!name)throw new Error('Choose a name for this app password.');setStatus('creating app password...');const created=await xrpc('/xrpc/com.atproto.server.createAppPassword',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,privileged})});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;$('app-password-name').value='';setStatus('app password created','ok');await loadAccountData()}catch(err){setStatus(err.message||String(err),'error')}} 167 + \\$('load-records').onclick=()=>loadRecords(false).catch(err=>setStatus(err.message,'error'));$('next-records').onclick=()=>loadRecords(true).catch(err=>setStatus(err.message,'error')); 168 + \\$('verify-email-request').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.requestEmailConfirmation',{}, {method:'POST'});setStatus('verification email sent','ok')}catch(err){setStatus(err.message,'error')}} 169 + \\$('verify-email-confirm').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.confirmEmail',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:account.email,token:$('email-token').value.trim()})});setStatus('email verified','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}} 170 + \\$('update-email-request').onclick=async()=>{try{const out=await xrpc('/xrpc/com.atproto.server.requestEmailUpdate',{}, {method:'POST'});setStatus(out.tokenRequired?'update code sent':'enter the new email and confirm','ok')}catch(err){setStatus(err.message,'error')}} 171 + \\$('update-email-confirm').onclick=async()=>{try{const body={email:$('new-email').value.trim()};if($('email-token').value.trim())body.token=$('email-token').value.trim();await xrpc('/xrpc/com.atproto.server.updateEmail',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});setStatus('email updated','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}} 172 + \\$('activate-account').onclick=async()=>{try{await xrpc('/xrpc/com.atproto.server.activateAccount',{}, {method:'POST'});setStatus('account reactivated','ok');await loadAccountData()}catch(err){setStatus(err.message,'error')}} 173 + \\$('deactivate-account').onclick=async()=>{try{if(!confirm('Deactivate this account? You can reactivate by signing back in.'))return;await xrpc('/xrpc/com.atproto.server.deactivateAccount',{}, {method:'POST'});setStatus('account deactivated','warn');await loadAccountData()}catch(err){setStatus(err.message,'error')}} 174 + \\showView(); 175 + \\</script> 176 + \\</body> 177 + \\</html> 178 + ;
+1 -143
src/internal/passkeys.zig
··· 43 43 44 44 pub fn redirectToSecurity(request: *http_api.Request) !void { 45 45 const headers = [_]http.Header{ 46 - .{ .name = "location", .value = "/security" }, 46 + .{ .name = "location", .value = "/account/security" }, 47 47 .{ .name = "connection", .value = "close" }, 48 48 }; 49 49 try http_api.respond(request, .see_other, "", &headers); 50 - } 51 - 52 - pub fn securityPage(request: *http_api.Request) !void { 53 - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 54 - defer arena.deinit(); 55 - const allocator = arena.allocator(); 56 - const public_url = config.publicUrl(); 57 - const public_host = try htmlEscape(allocator, displayHost(public_url)); 58 - const security_url = try htmlEscape(allocator, try std.fmt.allocPrint(allocator, "{s}/security", .{public_url})); 59 - const preview_image = try htmlEscape(allocator, try std.fmt.allocPrint(allocator, "{s}/og-image", .{public_url})); 60 - const body = try std.fmt.allocPrint(allocator, 61 - \\<!doctype html> 62 - \\<html lang="en"> 63 - \\<head> 64 - \\<meta charset="utf-8"> 65 - \\<meta name="viewport" content="width=device-width, initial-scale=1"> 66 - \\<title>zds security</title> 67 - \\<meta name="description" content="manage account security for accounts hosted on {s}"> 68 - \\<link rel="canonical" href="{s}"> 69 - \\<meta property="og:type" content="website"> 70 - \\<meta property="og:site_name" content="zds"> 71 - \\<meta property="og:title" content="zds security"> 72 - \\<meta property="og:description" content="manage account security for accounts hosted on {s}"> 73 - \\<meta property="og:url" content="{s}"> 74 - \\<meta property="og:image" content="{s}"> 75 - \\<meta property="og:image:width" content="1200"> 76 - \\<meta property="og:image:height" content="630"> 77 - \\<meta name="twitter:card" content="summary_large_image"> 78 - \\<meta name="twitter:title" content="zds security"> 79 - \\<meta name="twitter:description" content="manage account security for accounts hosted on {s}"> 80 - \\<meta name="twitter:image" content="{s}"> 81 - \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 82 - \\<style> 83 - \\:root{{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#263326;--text:#f1f2ec;--muted:#a7a299;--accent:#8fb0ff;--green:#37c978;--field:#101410;--bad:#ffb4a8}} 84 - \\@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}}}} 85 - \\*{{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:16px/1.55 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace}} 86 - \\.shell{{width:min(100%,760px);margin:0 auto;padding:28px 16px 42px}}a{{color:inherit}} 87 - \\.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:54ch}}strong{{color:var(--text)}} 88 - \\.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}} 89 - \\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}} 90 - \\button{{width:100%;margin-top:22px;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}} 91 - \\.status{{min-height:1.5em;color:var(--muted)}}.error{{color:var(--bad)}}.ok{{color:var(--green)}}code{{overflow-wrap:anywhere;color:var(--accent)}} 92 - \\.hint{{font-size:.92rem;color:var(--muted);margin:8px 0 0}} 93 - \\.account{{display:none;margin-top:18px}}.account.on{{display:block}}.login.off{{display:none}} 94 - \\.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}} 95 - \\.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)}} 96 - \\.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))}} 97 - \\.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)}} 98 - \\.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)}} 99 - \\</style> 100 - \\</head> 101 - \\<body> 102 - \\<main class="shell"> 103 - \\<a class="brand" href="/">zds</a> 104 - \\<h1>security</h1> 105 - \\<p>Sign in to manage passkeys and app passwords for an account hosted on <strong>{s}</strong>.</p> 106 - \\<section class="panel"> 107 - \\<form id="login-form" class="login"> 108 - \\<label for="identifier">account</label> 109 - \\<input id="identifier" name="identifier" autocomplete="username" required> 110 - \\<label for="password">password</label> 111 - \\<input id="password" name="password" type="password" autocomplete="current-password" required> 112 - \\<button>sign in</button> 113 - \\</form> 114 - \\<p id="status" class="status"></p> 115 - \\<section id="account" class="account" aria-live="polite"> 116 - \\<p id="account-label"></p> 117 - \\<section class="security-section"> 118 - \\<h2>passkeys</h2> 119 - \\<p class="hint">Use a passkey to sign in without typing this account password.</p> 120 - \\<div id="passkey-items"></div> 121 - \\<label for="friendly">new passkey name</label> 122 - \\<input id="friendly" name="friendlyName" placeholder="this device"> 123 - \\<button id="add-passkey" type="button">add passkey</button> 124 - \\<p class="hint">Removing a passkey here forgets it on this PDS; your browser or password manager may still keep its copy.</p> 125 - \\</section> 126 - \\<section class="security-section"> 127 - \\<h2>app passwords</h2> 128 - \\<p class="hint">Use an app password for clients that should not receive your main account password.</p> 129 - \\<div id="app-password-items"></div> 130 - \\<label for="app-password-name">new app password name</label> 131 - \\<input id="app-password-name" name="appPasswordName" placeholder="client or device name"> 132 - \\<label class="checkbox"><input id="app-password-privileged" type="checkbox"> privileged app password</label> 133 - \\<p class="field-help">Trusted clients only. Privileged app passwords can access sensitive account features; in Bluesky-compatible clients this includes chat transition APIs. Most clients should leave this off.</p> 134 - \\<button id="create-app-password" type="button">create app password</button> 135 - \\<div id="generated-app-password" class="generated" aria-live="polite"></div> 136 - \\</section> 137 - \\<section class="security-section"> 138 - \\<h2>direct API tokens</h2> 139 - \\<p class="hint">Active token families created by com.atproto.server.createSession. These are separate from OAuth app sign-ins.</p> 140 - \\<div id="session-items"></div> 141 - \\</section> 142 - \\<section class="security-section"> 143 - \\<h2>OAuth app sessions</h2> 144 - \\<p class="hint">App sign-ins issued by the OAuth flow. New rows show whether authorization used a passkey or password.</p> 145 - \\<div id="oauth-grant-items"></div> 146 - \\</section> 147 - \\</section> 148 - \\</section> 149 - \\</main> 150 - \\<script>{s}</script> 151 - \\</body> 152 - \\</html> 153 - , .{ 154 - public_host, 155 - security_url, 156 - public_host, 157 - security_url, 158 - preview_image, 159 - public_host, 160 - preview_image, 161 - public_host, 162 - securityScript, 163 - }); 164 - try respondHtml(request, .ok, body); 165 50 } 166 51 167 52 pub fn adminSessionsPage(request: *http_api.Request) !void { ··· 601 486 }; 602 487 return out.toOwnedSlice(allocator); 603 488 } 604 - 605 - const securityScript = 606 - \\const b64ToBuf=(v)=>{const b64=v.replace(/-/g,'+').replace(/_/g,'/');const bin=atob(b64+'==='.slice((b64.length+3)%4));const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}; 607 - \\const bufToB64=(buf)=>btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); 608 - \\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}; 609 - \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); 610 - \\let accessJwt=null,handle=null; 611 - \\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'); 612 - \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); 613 - \\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)}; 614 - \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used'; 615 - \\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}}; 616 - \\const empty=(root,text)=>{root.textContent='';const p=document.createElement('p');p.className='hint';p.textContent=text;root.append(p)}; 617 - \\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)}; 618 - \\const mono=(text)=>{const span=document.createElement('span');span.className='mono';span.textContent=text||'';return span}; 619 - \\const time=(value)=>value?new Date(value).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'never'; 620 - \\const methodLabel=(s)=>{if(s.appPasswordName)return`app password: ${s.appPasswordName}`;if(s.authMethod==='password')return'account password token';if(s.authMethod==='app_password')return'app password token';if(s.authMethod==='app_password_privileged')return'privileged app password token';return s.authMethod}; 621 - \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; 622 - \\const showSessions=(items)=>table(sessionRoot,['method','created','last used','refresh expires'],items,s=>[methodLabel(s),time(s.createdAt),time(s.lastUsedAt),time(s.refreshExpiresAt)]); 623 - \\const showOAuthGrants=(items)=>table(oauthGrantRoot,['client','authorized with','created','expires','scope'],items,g=>[mono(g.clientId),oauthAuthMethod(g),time(g.createdAt),time(g.expiresAt),mono(g.scope)]); 624 - \\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)}}; 625 - \\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)}}; 626 - \\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||[])}; 627 - \\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)}}); 628 - \\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))}}); 629 - \\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)}}); 630 - ; 631 489 632 490 const adminSessionsScript = 633 491 \\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');
-183
src/internal/private_spaces.zig
··· 1 - const std = @import("std"); 2 - const http_api = @import("../http/api.zig"); 3 - 4 - const http = std.http; 5 - 6 - pub fn page(request: *http_api.Request) !void { 7 - try http_api.respond(request, .ok, html, &html_headers); 8 - } 9 - 10 - const html_headers = [_]http.Header{ 11 - .{ .name = "content-type", .value = "text/html; charset=utf-8" }, 12 - .{ .name = "cache-control", .value = "no-store" }, 13 - .{ .name = "access-control-allow-origin", .value = "*" }, 14 - .{ .name = "access-control-allow-private-network", .value = "true" }, 15 - .{ .name = "connection", .value = "close" }, 16 - }; 17 - 18 - const html = 19 - \\<!doctype html> 20 - \\<html lang="en"> 21 - \\<head> 22 - \\<meta charset="utf-8"> 23 - \\<meta name="viewport" content="width=device-width, initial-scale=1"> 24 - \\<title>zds spaces</title> 25 - \\<meta name="description" content="View permissioned-space records hosted on this PDS."> 26 - \\<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 27 - \\<style> 28 - \\:root{color-scheme:dark;--bg:#070807;--panel:#101510;--line:#5b7159;--text:#f1f2ec;--muted:#c2bcaf;--accent:#8fb0ff;--accent-text:#061021;--green:#37c978;--field:#101410;--bad:#ffb4a8;--warn:#f4d35e;--focus:#f4d35e} 29 - \\@media (prefers-color-scheme:light){:root{--bg:#f6f4ed;--panel:#fffdf7;--line:#847866;--text:#161412;--muted:#4f493f;--accent:#1d4ed8;--accent-text:#fffdf7;--green:#17683a;--field:#fffaf1;--bad:#9f1d1d;--warn:#735f00;--focus:#5a3fc0;color-scheme:light}} 30 - \\*{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} 31 - \\.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)} 32 - \\.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} 33 - \\label{display:block;margin:15px 0 6px;font-weight:760}input,select{width:100%;font:inherit;color:var(--text);background:var(--field);border:1px solid var(--line);border-radius:9px;padding:12px}input:focus-visible,select:focus-visible,button:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button{border:1px solid var(--accent);border-radius:10px;background:var(--accent);color:var(--accent-text);font:inherit;font-weight:800;padding:12px;cursor:pointer}button.secondary{background:transparent;color:var(--text);border-color:var(--line)}button:disabled{opacity:.62;cursor:not-allowed} 34 - \\.status{min-height:1.5em;color:var(--muted)}.error{color:var(--bad)}.ok{color:var(--green)}.warn{color:var(--warn)}.hint{font-size:.92rem;color:var(--muted);margin:8px 0 0}.row{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:end}.row button{margin-top:0}.resident{display:none}.resident.on{display:block}.login.off{display:none} 35 - \\.summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-top:16px}.metric{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 74%,transparent);padding:11px}.metric span{display:block;color:var(--muted);font-size:.78rem}.metric strong{display:block;margin-top:4px;font-size:1.35rem} 36 - \\.tabs{display:flex;gap:8px;overflow-x:auto;margin-top:18px;padding-bottom:2px}.tab{width:auto;margin:0;text-align:left;background:transparent;color:var(--text);border-color:var(--line);white-space:nowrap}.tab[aria-selected="true"]{color:var(--accent-text);background:var(--accent);border-color:var(--accent)} 37 - \\.section{border-top:1px solid var(--line);padding-top:16px;margin-top:18px}.section h2{font-size:1rem;margin:0 0 8px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}.actions button{width:auto}.list{display:grid;gap:8px;margin-top:12px}.item{border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,var(--field) 76%,transparent);padding:11px;text-align:left;color:var(--text);width:100%}.item[aria-selected="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))}.item-title{font-weight:800;overflow-wrap:anywhere}.item-meta{display:block;color:var(--muted);font-size:.82rem;overflow-wrap:anywhere}.pill{display:inline-flex;border:1px solid var(--line);border-radius:999px;padding:2px 7px;font-size:.78rem;color:var(--muted);margin-left:6px}.empty{border:1px dashed var(--line);border-radius:9px;padding:14px;color:var(--muted);margin-top:10px} 38 - \\pre{margin:12px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:9px;background:color-mix(in srgb,#000 24%,var(--field));padding:12px;color:var(--text);max-height:56vh;overflow:auto}.record-head{display:flex;gap:10px;align-items:flex-start;justify-content:space-between;margin-top:14px}.record-head button{width:auto}.blob-chip{display:inline-flex;margin-top:8px;margin-right:6px;border:1px solid var(--line);border-radius:999px;padding:4px 8px;color:var(--accent);overflow-wrap:anywhere} 39 - \\@media (max-width:680px){.row,.grid,.summary{grid-template-columns:1fr}.actions button{width:100%}.record-head{display:block}.record-head button{width:100%;margin-top:8px}} 40 - \\</style> 41 - \\</head> 42 - \\<body> 43 - \\<main class="shell"> 44 - \\<a class="brand" href="/">zds</a> 45 - \\<h1>spaces</h1> 46 - \\<p>View private records in permissioned spaces for an account hosted on this PDS.</p> 47 - \\<section class="panel"> 48 - \\<form id="login-form" class="login"> 49 - \\<label for="identifier">account</label> 50 - \\<input id="identifier" name="identifier" autocomplete="username" required> 51 - \\<label for="password">account password or app password</label> 52 - \\<input id="password" name="password" type="password" autocomplete="current-password" required> 53 - \\<button style="width:100%;margin-top:22px">sign in</button> 54 - \\</form> 55 - \\<p id="status" class="status"></p> 56 - \\<section id="resident" class="resident" aria-live="polite"> 57 - \\<p id="account-label"></p> 58 - \\<div class="summary"> 59 - \\<div class="metric"><span>spaces</span><strong id="space-count">0</strong></div> 60 - \\<div class="metric"><span>records loaded</span><strong id="record-count">0</strong></div> 61 - \\</div> 62 - \\<div id="spaces" class="tabs" role="tablist" aria-label="permissioned spaces"></div> 63 - \\<section class="section"> 64 - \\<h2>record set</h2> 65 - \\<div class="grid"> 66 - \\<div> 67 - \\<label for="repo">writer repo</label> 68 - \\<input id="repo" spellcheck="false"> 69 - \\</div> 70 - \\<div> 71 - \\<label for="collection">collection, optional</label> 72 - \\<input id="collection" placeholder="all collections" spellcheck="false"> 73 - \\</div> 74 - \\</div> 75 - \\<div class="actions"> 76 - \\<button id="load-records" type="button">load records</button> 77 - \\<button id="next-records" class="secondary" type="button" disabled>next page</button> 78 - \\</div> 79 - \\<div id="records" class="list"></div> 80 - \\</section> 81 - \\<section class="section"> 82 - \\<h2>selected record</h2> 83 - \\<div id="record-detail" class="empty">Choose a record.</div> 84 - \\</section> 85 - \\</section> 86 - \\</section> 87 - \\</main> 88 - \\<script> 89 - \\let token=null, accountDid=null, accountHandle=null, selectedSpace=null, cursor=null; 90 - \\const $=(id)=>document.getElementById(id); 91 - \\const status=$('status'); 92 - \\function setStatus(text, cls=''){status.className='status '+cls;status.textContent=text} 93 - \\function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))} 94 - \\function spaceParts(uri){const parts=String(uri).split('/');return {type:parts[3]||uri,skey:parts[4]||''}} 95 - \\async function xrpc(path, params={}, options={}){ 96 - \\ const qs=new URLSearchParams(params); const url=path+(qs.size?'?'+qs:''); 97 - \\ const headers=Object.assign({}, options.headers||{}); if(token) headers.authorization='Bearer '+token; 98 - \\ const res=await fetch(url, Object.assign({}, options, {headers})); 99 - \\ const text=await res.text(); let body={}; try{body=text?JSON.parse(text):{}}catch{body={raw:text}} 100 - \\ if(!res.ok){const msg=body.error_description||body.message||body.error||text||res.statusText; throw new Error(msg)} 101 - \\ return body; 102 - \\} 103 - \\function renderSpaces(spaces){ 104 - \\ $('space-count').textContent=spaces.length; 105 - \\ const box=$('spaces'); box.innerHTML=''; 106 - \\ if(!spaces.length){box.innerHTML='<div class="empty">No permissioned spaces found for this account.</div>';return} 107 - \\ spaces.forEach((space,idx)=>{ 108 - \\ const p=spaceParts(space.uri); 109 - \\ const btn=document.createElement('button'); 110 - \\ btn.type='button'; btn.className='tab'; btn.role='tab'; btn.ariaSelected=idx===0?'true':'false'; 111 - \\ btn.textContent=p.type+' / '+p.skey; 112 - \\ btn.onclick=()=>selectSpace(space.uri); 113 - \\ box.appendChild(btn); 114 - \\ }); 115 - \\ selectSpace(spaces[0].uri); 116 - \\} 117 - \\async function selectSpace(uri){ 118 - \\ selectedSpace=uri; cursor=null; $('records').innerHTML=''; $('record-detail').className='empty'; $('record-detail').textContent='Choose a record.'; $('record-count').textContent='0'; 119 - \\ for(const btn of $('spaces').querySelectorAll('.tab')) btn.ariaSelected=btn.textContent===spaceParts(uri).type+' / '+spaceParts(uri).skey?'true':'false'; 120 - \\ $('repo').value=accountDid; 121 - \\} 122 - \\async function loadSpaces(){ 123 - \\ setStatus('loading spaces...'); 124 - \\ const out=await xrpc('/xrpc/com.atproto.space.listSpaces',{limit:'100'}); 125 - \\ renderSpaces(out.spaces||[]); 126 - \\ setStatus('signed in','ok'); 127 - \\} 128 - \\function renderRecords(records, append=false){ 129 - \\ const box=$('records'); if(!append) box.innerHTML=''; 130 - \\ if(!records.length && !append){box.innerHTML='<div class="empty">No records for that repo.</div>'} 131 - \\ for(const rec of records){ 132 - \\ const btn=document.createElement('button'); btn.type='button'; btn.className='item'; 133 - \\ btn.innerHTML='<span class="item-title">'+esc(rec.collection)+' / '+esc(rec.rkey)+'</span><span class="item-meta">'+esc(rec.cid)+'</span>'; 134 - \\ btn.onclick=()=>openRecord(rec); 135 - \\ box.appendChild(btn); 136 - \\ } 137 - \\ $('record-count').textContent=String(box.querySelectorAll('.item').length); 138 - \\} 139 - \\async function loadRecords(append=false){ 140 - \\ if(!selectedSpace){setStatus('choose a space first','error');return} 141 - \\ const repo=$('repo').value.trim(); const collection=$('collection').value.trim(); 142 - \\ if(!repo){setStatus('enter a writer repo DID','error');return} 143 - \\ setStatus('loading records...'); 144 - \\ const params={space:selectedSpace,repo,limit:'50'}; if(collection) params.collection=collection; if(append&&cursor) params.cursor=cursor; 145 - \\ const out=await xrpc('/xrpc/com.atproto.space.listRecords',params); 146 - \\ cursor=out.cursor||null; $('next-records').disabled=!cursor; 147 - \\ renderRecords(out.records||[], append); 148 - \\ setStatus('records loaded','ok'); 149 - \\} 150 - \\function blobChips(value){ 151 - \\ const out=[]; const seen=new Set(); 152 - \\ function walk(v){ 153 - \\ if(!v||typeof v!=='object') return; 154 - \\ if(v.ref&&v.ref.$link&&!seen.has(v.ref.$link)){seen.add(v.ref.$link);out.push(v.ref.$link)} 155 - \\ for(const child of Array.isArray(v)?v:Object.values(v)) walk(child); 156 - \\ } 157 - \\ walk(value); 158 - \\ return out.map(cid=>'<span class="blob-chip">blob '+esc(cid)+'</span>').join(' '); 159 - \\} 160 - \\async function openRecord(rec){ 161 - \\ for(const btn of $('records').querySelectorAll('.item')) btn.ariaSelected='false'; 162 - \\ const detail=$('record-detail'); detail.className=''; detail.innerHTML='<p class="hint">loading record...</p>'; 163 - \\ const repo=$('repo').value.trim(); 164 - \\ const out=await xrpc('/xrpc/com.atproto.space.getRecord',{space:selectedSpace,repo,collection:rec.collection,rkey:rec.rkey}); 165 - \\ detail.innerHTML='<div class="record-head"><div><strong>'+esc(rec.collection)+' / '+esc(rec.rkey)+'</strong><span class="item-meta">'+esc(out.cid)+'</span></div><button class="secondary" type="button" id="copy-json">copy JSON</button></div>'+blobChips(out.value)+'<pre>'+esc(JSON.stringify(out.value,null,2))+'</pre>'; 166 - \\ $('copy-json').onclick=()=>navigator.clipboard.writeText(JSON.stringify(out.value,null,2)); 167 - \\} 168 - \\$('login-form').onsubmit=async(ev)=>{ 169 - \\ ev.preventDefault(); setStatus('signing in...'); 170 - \\ try{ 171 - \\ const identifier=$('identifier').value.trim(); const password=$('password').value; 172 - \\ const out=await xrpc('/xrpc/com.atproto.server.createSession',{}, {method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({identifier,password})}); 173 - \\ token=out.accessJwt; accountDid=out.did; accountHandle=out.handle||identifier; 174 - \\ $('login-form').classList.add('off'); $('resident').classList.add('on'); $('account-label').innerHTML='Signed in as <strong>'+esc(accountHandle)+'</strong><br><code>'+esc(accountDid)+'</code>'; 175 - \\ await loadSpaces(); 176 - \\ }catch(err){setStatus(err.message,'error')} 177 - \\}; 178 - \\$('load-records').onclick=()=>loadRecords(false).catch(err=>setStatus(err.message,'error')); 179 - \\$('next-records').onclick=()=>loadRecords(true).catch(err=>setStatus(err.message,'error')); 180 - \\</script> 181 - \\</body> 182 - \\</html> 183 - ;