From d6f8a6783265f0cdacacbe8acb81b486c4caca18 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 25 Jun 2026 17:07:38 -0500 Subject: [PATCH] Add resident account hub --- AGENTS.md | 2 +- README.md | 7 +- docs/account-security.md | 18 ++-- docs/architecture.md | 8 +- docs/operations.md | 9 +- docs/passkeys.md | 10 +- docs/permissioned-data.md | 4 +- src/atproto/oauth.zig | 2 +- src/http/router.zig | 37 +++++-- src/http/server.zig | 9 +- src/internal/account.zig | 178 +++++++++++++++++++++++++++++++ src/internal/passkeys.zig | 144 +------------------------ src/internal/private_spaces.zig | 183 -------------------------------- 13 files changed, 244 insertions(+), 367 deletions(-) create mode 100644 src/internal/account.zig delete mode 100644 src/internal/private_spaces.zig diff --git a/AGENTS.md b/AGENTS.md index 8fdb7e4..7890998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ zig zen ``` Also run `just smoke-permissioned` when touching `com.atproto.space.*`, -permissioned-data storage, or the `/spaces` resident view. +permissioned-data storage, or the `/account/spaces` resident view. Use `just invite` for admin-minted invite codes. Assume `ZDS_ADMIN_TOKEN` is set in the repo `.env`; source it before running the target, for example diff --git a/README.md b/README.md index 73c7493..97232b9 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ decision rules. ## docs - [architecture](docs/architecture.md) -- [account security](docs/account-security.md) +- [account management](docs/account-security.md) - [comail](docs/comail.md) - [development](docs/development.md) - [operator guide](docs/operations.md) @@ -136,8 +136,9 @@ just docker-publish-release v0.1.1 Revocation also invalidates active sessions created with that app password. - Passkeys are optional account credentials for the OAuth login page. ZDS stores credential IDs, public keys, counters, names, and last-use timestamps. -- `/security` is the local account-security page for passkey and app-password - management. +- `/account` is the resident account hub. It includes passkey and app-password + management, app access/session views, email/account status controls, and the + experimental permissioned-space record browser. - `com.atproto.space.*` permissioned-data routes are an experimental prototype and operator gated with `ZDS_PERMISSIONED_DATA`. ZDS implements enough storage and credential substrate for local experiments, but the upstream proposal is diff --git a/docs/account-security.md b/docs/account-security.md index 469882d..d102d29 100644 --- a/docs/account-security.md +++ b/docs/account-security.md @@ -43,12 +43,18 @@ ZDS stores privileged app passwords with `transition:generic` and ## management UI -`/security` is the local account-security surface. It signs in with the account -password, then lists passkeys and app passwords for that account. App passwords -can be created and revoked from the page; the generated password is displayed -once and is not recoverable later because only the hash is stored. The -privileged-app-password checkbox is labeled as trusted-client behavior because -ordinary clients should not request it casually. +`/account` is the local resident account hub. Its security section, +`/account/security`, signs in with the account password, then lists passkeys and +app passwords for that account. App passwords can be created and revoked from +the page; the generated password is displayed once and is not recoverable later +because only the hash is stored. The privileged-app-password checkbox is labeled +as trusted-client behavior because ordinary clients should not request it +casually. + +The same hub also exposes app/OAuth sessions, direct API token families, +resident email and account-status controls, and the experimental +permissioned-space record browser. `/security` and `/passkeys` remain +compatibility redirects to `/account/security`. ## references diff --git a/docs/architecture.md b/docs/architecture.md index 4693d01..71f63c9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,10 +80,10 @@ by the local `webauthn` dependency and stored in SQLite. Discoverable passkey login is supported so an account can be chosen by the authenticator during the OAuth flow. -`/security` is the local management surface for passkeys and app passwords. It -uses account-password authentication for management actions, matching the -official PDS boundary that app passwords cannot mint or revoke other app -passwords. +`/account` is the resident management surface. Its security section, +`/account/security`, manages passkeys and app passwords with account-password +authentication, matching the official PDS boundary that app passwords cannot +mint or revoke other app passwords. PDS-owned `app.bsky.actor` preferences are handled locally because the official PDS implements those routes. Appview reads and writes remain behind the generic diff --git a/docs/operations.md b/docs/operations.md index 4791a5e..28a0f64 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -202,10 +202,11 @@ storage, and writable SQLite storage. ## account security -The local security surface lives at `/security`. It is for account-owner -management of passkeys and app passwords and requires the account password for -management actions. App-password revocation also revokes active sessions that -were created with that app password. +The resident account surface lives at `/account`. The security section, +`/account/security`, is for account-owner management of passkeys and app +passwords and requires the account password for management actions. +App-password revocation also revokes active sessions that were created with +that app password. `/security` and `/passkeys` are compatibility redirects. ## conformance diff --git a/docs/passkeys.md b/docs/passkeys.md index 31426d1..037b2f2 100644 --- a/docs/passkeys.md +++ b/docs/passkeys.md @@ -23,7 +23,7 @@ authenticator can identify the account by credential ID. If the login hint resolves to an account with saved passkeys, the OAuth page offers passkey login first and keeps password login available. If no passkey is saved for the account, password login is shown directly and points the user to -`/security` after authentication. +`/account/security` after authentication. Discoverable passkey login is supported: the browser may return a credential without the page first choosing an account. ZDS resolves the credential ID to @@ -32,10 +32,10 @@ authorizes the OAuth request. ## management -Passkeys are managed from `/security`, alongside app passwords. That page signs -in with the account password, lists saved passkeys, and supports add, rename, -and delete. Registration uses a browser WebAuthn prompt; ZDS stores only the -credential ID, public key, counter, name, and timestamps. +Passkeys are managed from `/account/security`, alongside app passwords. That +page signs in with the account password, lists saved passkeys, and supports add, +rename, and delete. Registration uses a browser WebAuthn prompt; ZDS stores only +the credential ID, public key, counter, name, and timestamps. Deleting a passkey removes it from ZDS. The browser, OS, or password manager may still keep its copy, but it can no longer be used with this PDS. diff --git a/docs/permissioned-data.md b/docs/permissioned-data.md index bf23764..169ccc0 100644 --- a/docs/permissioned-data.md +++ b/docs/permissioned-data.md @@ -151,8 +151,8 @@ Permissioned data is experimental code and should stay segmented: - HTTP dispatch and XRPC semantics live in `src/atproto/space.zig`. - Experimental protocol helpers live in `src/internal/permissioned_data.zig`. -- The resident private-record browser lives in - `src/internal/private_spaces.zig`. +- The resident private-record browser lives in the `/account/spaces` section + rendered by `src/internal/account.zig`. - OAuth scope parsing for `space:` scopes lives in `src/internal/scopes.zig`. - Persistent state lives in permissioned-data tables in `src/storage/store.zig`. diff --git a/src/atproto/oauth.zig b/src/atproto/oauth.zig index eeec9fc..44cda88 100644 --- a/src/atproto/oauth.zig +++ b/src/atproto/oauth.zig @@ -301,7 +301,7 @@ fn authMethodHtml(allocator: std.mem.Allocator, passkey_available: bool) ![]cons \\ \\ \\ - \\ + \\ \\ ); } diff --git a/src/http/router.zig b/src/http/router.zig index 4cd707c..983c4d3 100644 --- a/src/http/router.zig +++ b/src/http/router.zig @@ -8,7 +8,8 @@ pub const Route = enum { api_docs, api_openapi, stats_page, - private_spaces_page, + account_page, + spaces_redirect, favicon, og_image, health, @@ -23,8 +24,8 @@ pub const Route = enum { oauth_revoke, oauth_passkey_options, oauth_passkey_finish, - passkeys_page, - security_page, + passkeys_redirect, + security_redirect, admin_sessions_page, zds_list_account_sessions, zds_list_admin_sessions, @@ -114,8 +115,15 @@ pub const endpoints = [_]Endpoint{ .{ .route = .api_openapi, .method = "HEAD", .path = "/api/openapi.json", .group = "zds", .auth = "public", .summary = "OpenAPI export probe without a response body." }, .{ .route = .stats_page, .method = "GET", .path = "/stats", .group = "zds", .auth = "public", .summary = "Operational health and route latency page." }, .{ .route = .stats_page, .method = "HEAD", .path = "/stats", .group = "zds", .auth = "public", .summary = "Stats page probe without a response body." }, - .{ .route = .private_spaces_page, .method = "GET", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Resident view for permissioned-space records." }, - .{ .route = .private_spaces_page, .method = "HEAD", .path = "/spaces", .group = "zds", .auth = "resident", .summary = "Permissioned-space record view probe." }, + .{ .route = .account_page, .method = "GET", .path = "/account", .group = "account", .auth = "resident", .summary = "Resident account control center." }, + .{ .route = .account_page, .method = "GET", .path = "/account/security", .group = "account", .auth = "resident", .summary = "Resident passkey and app-password management." }, + .{ .route = .account_page, .method = "GET", .path = "/account/sessions", .group = "account", .auth = "resident", .summary = "Resident app access and direct API token view." }, + .{ .route = .account_page, .method = "GET", .path = "/account/apps", .group = "account", .auth = "resident", .summary = "Compatibility view for app access." }, + .{ .route = .account_page, .method = "GET", .path = "/account/spaces", .group = "account", .auth = "resident", .summary = "Resident view for permissioned-space records." }, + .{ .route = .account_page, .method = "GET", .path = "/account/manage", .group = "account", .auth = "resident", .summary = "Resident email and account status management." }, + .{ .route = .account_page, .method = "GET", .path = "/account/about", .group = "account", .auth = "resident", .summary = "Resident account hosting explainer." }, + .{ .route = .spaces_redirect, .method = "GET", .path = "/spaces", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/spaces." }, + .{ .route = .spaces_redirect, .method = "HEAD", .path = "/spaces", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/spaces." }, .{ .route = .root, .method = "GET", .path = "/", .group = "zds", .auth = "public", .summary = "Public landing page for the PDS." }, .{ .route = .root, .method = "HEAD", .path = "/", .group = "zds", .auth = "public", .summary = "Landing page probe." }, .{ .route = .favicon, .method = "GET", .path = "/favicon.svg", .group = "zds", .auth = "public", .summary = "SVG favicon." }, @@ -137,8 +145,8 @@ pub const endpoints = [_]Endpoint{ .{ .route = .oauth_revoke, .method = "POST", .path = "/oauth/revoke", .group = "oauth", .auth = "client", .summary = "OAuth token revocation endpoint." }, .{ .route = .oauth_passkey_options, .method = "POST", .path = "/oauth/passkey/options", .group = "oauth", .auth = "browser", .summary = "Passkey login challenge for OAuth flows." }, .{ .route = .oauth_passkey_finish, .method = "POST", .path = "/oauth/passkey/finish", .group = "oauth", .auth = "browser", .summary = "Passkey login completion for OAuth flows." }, - .{ .route = .security_page, .method = "GET", .path = "/security", .group = "account", .auth = "session", .summary = "Security settings page." }, - .{ .route = .passkeys_page, .method = "GET", .path = "/passkeys", .group = "account", .auth = "session", .summary = "Compatibility redirect to security settings." }, + .{ .route = .security_redirect, .method = "GET", .path = "/security", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/security." }, + .{ .route = .passkeys_redirect, .method = "GET", .path = "/passkeys", .group = "account", .auth = "resident", .summary = "Compatibility redirect to /account/security." }, .{ .route = .admin_sessions_page, .method = "GET", .path = "/admin/sessions", .group = "zds", .auth = "admin", .summary = "Operator session inventory page." }, .{ .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" } }, .{ .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,8 +272,15 @@ test "routes pds probes" { try std.testing.expectEqual(Route.api_openapi, route(.HEAD, "/api/openapi.json")); try std.testing.expectEqual(Route.stats_page, route(.GET, "/stats")); try std.testing.expectEqual(Route.stats_page, route(.HEAD, "/stats")); - try std.testing.expectEqual(Route.private_spaces_page, route(.GET, "/spaces")); - try std.testing.expectEqual(Route.private_spaces_page, route(.HEAD, "/spaces")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/security")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/sessions")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/apps")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/spaces")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/manage")); + try std.testing.expectEqual(Route.account_page, route(.GET, "/account/about")); + try std.testing.expectEqual(Route.spaces_redirect, route(.GET, "/spaces")); + try std.testing.expectEqual(Route.spaces_redirect, route(.HEAD, "/spaces")); try std.testing.expectEqual(Route.favicon, route(.GET, "/favicon.svg")); try std.testing.expectEqual(Route.favicon, route(.HEAD, "/favicon.svg")); try std.testing.expectEqual(Route.og_image, route(.GET, "/og-image")); @@ -281,8 +296,8 @@ test "routes pds probes" { try std.testing.expectEqual(Route.oauth_token, route(.POST, "/oauth/token")); try std.testing.expectEqual(Route.oauth_passkey_options, route(.POST, "/oauth/passkey/options")); try std.testing.expectEqual(Route.oauth_passkey_finish, route(.POST, "/oauth/passkey/finish")); - try std.testing.expectEqual(Route.passkeys_page, route(.GET, "/passkeys")); - try std.testing.expectEqual(Route.security_page, route(.GET, "/security")); + try std.testing.expectEqual(Route.passkeys_redirect, route(.GET, "/passkeys")); + try std.testing.expectEqual(Route.security_redirect, route(.GET, "/security")); try std.testing.expectEqual(Route.admin_sessions_page, route(.GET, "/admin/sessions")); try std.testing.expectEqual(Route.zds_list_account_sessions, route(.GET, "/xrpc/dev.zat.account.listSessions")); try std.testing.expectEqual(Route.zds_list_admin_sessions, route(.GET, "/xrpc/dev.zat.admin.listSessions")); diff --git a/src/http/server.zig b/src/http/server.zig index 29f5ae1..e5db030 100644 --- a/src/http/server.zig +++ b/src/http/server.zig @@ -12,8 +12,8 @@ const httpz = @import("httpz"); const log = @import("../core/log.zig"); const http_api = @import("api.zig"); const docs = @import("docs.zig"); +const account_page = @import("../internal/account.zig"); const passkeys = @import("../internal/passkeys.zig"); -const private_spaces = @import("../internal/private_spaces.zig"); const landing = @import("landing/mod.zig"); const router = @import("router.zig"); const stats = @import("stats.zig"); @@ -77,7 +77,8 @@ const App = struct { .api_docs => try docs.serve(request), .api_openapi => try docs.serveOpenApi(request), .stats_page => try stats.serve(request), - .private_spaces_page => try private_spaces.page(request), + .account_page => try account_page.page(request), + .spaces_redirect => try account_page.redirectToSpaces(request), .favicon => try landing.serveFavicon(request), .og_image => try landing.serveOgImage(request), .health => try health(request), @@ -92,8 +93,8 @@ const App = struct { .oauth_revoke => try atproto_oauth.revoke(request), .oauth_passkey_options => try passkeys.loginStart(request), .oauth_passkey_finish => try passkeys.loginFinish(request), - .passkeys_page => try passkeys.redirectToSecurity(request), - .security_page => try passkeys.securityPage(request), + .passkeys_redirect => try passkeys.redirectToSecurity(request), + .security_redirect => try account_page.redirectToSecurity(request), .admin_sessions_page => try passkeys.adminSessionsPage(request), .zds_list_account_sessions => try atproto_server.listSessions(request), .zds_list_admin_sessions => try atproto_server.adminListSessions(request), diff --git a/src/internal/account.zig b/src/internal/account.zig new file mode 100644 index 0000000..e980921 --- /dev/null +++ b/src/internal/account.zig @@ -0,0 +1,178 @@ +const std = @import("std"); +const http_api = @import("../http/api.zig"); + +const http = std.http; + +pub fn page(request: *http_api.Request) !void { + try http_api.respond(request, .ok, html, &html_headers); +} + +pub fn redirectToSecurity(request: *http_api.Request) !void { + try redirect(request, "/account/security"); +} + +pub fn redirectToSpaces(request: *http_api.Request) !void { + try redirect(request, "/account/spaces"); +} + +fn redirect(request: *http_api.Request, location: []const u8) !void { + const headers = [_]http.Header{ + .{ .name = "location", .value = location }, + .{ .name = "connection", .value = "close" }, + }; + try http_api.respond(request, .see_other, "", &headers); +} + +const html_headers = [_]http.Header{ + .{ .name = "content-type", .value = "text/html; charset=utf-8" }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "access-control-allow-origin", .value = "*" }, + .{ .name = "access-control-allow-private-network", .value = "true" }, + .{ .name = "connection", .value = "close" }, +}; + +const html = + \\ + \\ + \\ + \\ + \\ + \\zds account + \\ + \\ + \\ + \\ + \\ + \\
+ \\zds + \\

account

+ \\

The resident control center for an account hosted on this PDS.

+ \\
+ \\ + \\

+ \\
+ \\ + \\ + \\
+ \\

home

+ \\

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.

+ \\

quick checks

+ \\
+ \\
+ \\

security

+ \\

passkeys

Use a passkey to sign in without typing this account password.

+ \\

app passwords

Use an app password for clients that should not receive your main account password.

Most clients should leave this off.

+ \\
+ \\
+ \\

sessions

+ \\

+ \\

app access

OAuth app sign-ins. Active means the current refresh token has not been revoked.

+ \\

direct API tokens

Token families from com.atproto.server.createSession, including app-password clients.

+ \\
+ \\
+ \\

spaces

+ \\

View private records in permissioned spaces for this account.

+ \\
+ \\

record set

+ \\

selected record

Choose a record.
+ \\
+ \\
+ \\

manage

+ \\

email

+ \\

account status

Deactivation hides the account until it is reactivated. Takedowns and suspensions are operator actions, not resident controls.

+ \\
+ \\
+ \\

about

+ \\

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.

+ \\

This page is local to the PDS. It is not a Bluesky app view, and it only manages things this PDS is responsible for.

+ \\
+ \\
+ \\
+ \\
+ \\ + \\ + \\ +; diff --git a/src/internal/passkeys.zig b/src/internal/passkeys.zig index 0eb5759..6897f9b 100644 --- a/src/internal/passkeys.zig +++ b/src/internal/passkeys.zig @@ -43,127 +43,12 @@ pub fn verifyAssertionSignature(credential_public_key: []const u8, signature_der pub fn redirectToSecurity(request: *http_api.Request) !void { const headers = [_]http.Header{ - .{ .name = "location", .value = "/security" }, + .{ .name = "location", .value = "/account/security" }, .{ .name = "connection", .value = "close" }, }; try http_api.respond(request, .see_other, "", &headers); } -pub fn securityPage(request: *http_api.Request) !void { - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer arena.deinit(); - const allocator = arena.allocator(); - const public_url = config.publicUrl(); - const public_host = try htmlEscape(allocator, displayHost(public_url)); - const security_url = try htmlEscape(allocator, try std.fmt.allocPrint(allocator, "{s}/security", .{public_url})); - const preview_image = try htmlEscape(allocator, try std.fmt.allocPrint(allocator, "{s}/og-image", .{public_url})); - const body = try std.fmt.allocPrint(allocator, - \\ - \\ - \\ - \\ - \\ - \\zds security - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\
- \\zds - \\

security

- \\

Sign in to manage passkeys and app passwords for an account hosted on {s}.

- \\
- \\ - \\

- \\ - \\
- \\
- \\ - \\ - \\ - , .{ - public_host, - security_url, - public_host, - security_url, - preview_image, - public_host, - preview_image, - public_host, - securityScript, - }); - try respondHtml(request, .ok, body); -} - pub fn adminSessionsPage(request: *http_api.Request) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -602,33 +487,6 @@ fn htmlEscape(allocator: std.mem.Allocator, value: []const u8) ![]const u8 { return out.toOwnedSlice(allocator); } -const securityScript = - \\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;ibtoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); - \\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}; - \\const serialize=(c)=>({id:c.id,rawId:bufToB64(c.rawId),type:c.type,response:{clientDataJSON:bufToB64(c.response.clientDataJSON),attestationObject:bufToB64(c.response.attestationObject)}}); - \\let accessJwt=null,handle=null; - \\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'); - \\const authed=(url,opts={})=>fetch(url,{...opts,headers:{...(opts.headers||{}),authorization:`Bearer ${accessJwt}`}}); - \\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)}; - \\const when=(seconds)=>seconds?new Date(seconds*1000).toLocaleString():'never used'; - \\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}}; - \\const empty=(root,text)=>{root.textContent='';const p=document.createElement('p');p.className='hint';p.textContent=text;root.append(p)}; - \\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)}; - \\const mono=(text)=>{const span=document.createElement('span');span.className='mono';span.textContent=text||'';return span}; - \\const time=(value)=>value?new Date(value).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'never'; - \\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}; - \\const oauthAuthMethod=(g)=>g.authMethod==='passkey'?'passkey':g.authMethod==='password'?'password':'unknown method'; - \\const showSessions=(items)=>table(sessionRoot,['method','created','last used','refresh expires'],items,s=>[methodLabel(s),time(s.createdAt),time(s.lastUsedAt),time(s.refreshExpiresAt)]); - \\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)]); - \\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)}}; - \\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)}}; - \\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||[])}; - \\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 ${handle}`;status.textContent='';await load()}catch(err){status.className='status error';status.textContent=err.message||String(err)}}); - \\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))}}); - \\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.';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)}}); -; - const adminSessionsScript = \\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'); \\const pageSize=8; diff --git a/src/internal/private_spaces.zig b/src/internal/private_spaces.zig deleted file mode 100644 index 22fe428..0000000 --- a/src/internal/private_spaces.zig +++ /dev/null @@ -1,183 +0,0 @@ -const std = @import("std"); -const http_api = @import("../http/api.zig"); - -const http = std.http; - -pub fn page(request: *http_api.Request) !void { - try http_api.respond(request, .ok, html, &html_headers); -} - -const html_headers = [_]http.Header{ - .{ .name = "content-type", .value = "text/html; charset=utf-8" }, - .{ .name = "cache-control", .value = "no-store" }, - .{ .name = "access-control-allow-origin", .value = "*" }, - .{ .name = "access-control-allow-private-network", .value = "true" }, - .{ .name = "connection", .value = "close" }, -}; - -const html = - \\ - \\ - \\ - \\ - \\ - \\zds spaces - \\ - \\ - \\ - \\ - \\ - \\
- \\zds - \\

spaces

- \\

View private records in permissioned spaces for an account hosted on this PDS.

- \\
- \\ - \\

- \\
- \\

- \\
- \\
spaces0
- \\
records loaded0
- \\
- \\
- \\
- \\

record set

- \\
- \\
- \\ - \\ - \\
- \\
- \\ - \\ - \\
- \\
- \\
- \\ - \\ - \\
- \\
- \\
- \\
- \\

selected record

- \\
Choose a record.
- \\
- \\
- \\
- \\
- \\ - \\ - \\ -; -- 2.51.2