From 7a49f8d0e9dd2abe9f9e7718692d882055743626 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Mon, 31 Aug 2026 10:51:54 +0200 Subject: [PATCH] 01a0541f - Add hasPasskey to GET /view/:viewKey (#86) * Add hasPasskey to GET /view/:viewKey public profile Invite pages need to know whether the capability URL still has an unclaimed passkey before showing activation UI. * Format serializeViewProfile for Prettier CI lint requires the signature on one line. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- SPEC.md | 9 ++- docs/handbook/endpoints.md | 4 +- docs/handbook/functions.md | 6 +- src/__tests__/lib/auth/account-json.test.ts | 11 ++- src/__tests__/routes/view.test.ts | 74 ++++++++++++++++++++- src/lib/auth/account-json.ts | 8 ++- src/routes/view.ts | 3 +- 7 files changed, 101 insertions(+), 14 deletions(-) diff --git a/SPEC.md b/SPEC.md index f86e578d..52dd32fb 100644 --- a/SPEC.md +++ b/SPEC.md @@ -309,17 +309,22 @@ Param not matching `/^[0-9a-f]{64}$/` or an unknown key → **Response** `404`: { "error": "Not found" } ``` -**Response** `200` (four fields only; omits `id`, `linkingKey`, `role`, `viewKey`): +**Response** `200` (five fields only; omits `id`, `linkingKey`, `role`, `viewKey`): ```json { "name": null, "lightningAddress": null, "lightningAddressVerified": false, - "createdAt": 0 + "createdAt": 0, + "hasPasskey": false } ``` +`hasPasskey` is `true` when the account has at least one passkey credential, +otherwise `false`. Clients use it to show an activation banner only while the +profile is still unclaimed. + ### `POST /me/name` Set or replace the account display name. Body: diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index a05386dd..9b565456 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -212,9 +212,9 @@ ## Endpoint: GET /view/:viewKey -- **Purpose:** Public capability URL. Read-only profile card (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`). No auth. Not a session. +- **Purpose:** Public capability URL. Read-only profile card (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`, `hasPasskey`). `hasPasskey` is true when the account already has a passkey credential. No auth. Not a session. - **Errors:** 404 `{ "error": "Not found" }` when the param is not 64 lowercase hex or the key is unknown. -- **Used by:** Anyone with the link (owner copies `viewKey` from GET `/me`). +- **Used by:** Anyone with the link (owner copies `viewKey` from GET `/me`); invite page uses `hasPasskey` for the activation banner. - **Auth:** none. ## Endpoint: GET /messages diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 912df084..878c24ec 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -562,7 +562,7 @@ ## Function: viewRoutes -- **Purpose:** Hono sub-app for public `GET /:viewKey`. Param not 64 lowercase hex or unknown key → 404 `{ error: 'Not found' }`. Hit → `serializeViewProfile`. No auth; not a session. +- **Purpose:** Hono sub-app for public `GET /:viewKey`. Param not 64 lowercase hex or unknown key → 404 `{ error: 'Not found' }`. Hit → `store.accountHasPasskey(account.id)` then `serializeViewProfile(account, hasPasskey)`. No auth; not a session. - **Inputs:** `{ store: AuthStore }`. - **Returns / side effects:** Hono app mounted at `/view` so the public path is `GET /view/:viewKey`. - **Used by:** `createApp`. @@ -814,8 +814,8 @@ ## Function: serializeViewProfile -- **Purpose:** Public profile card for the capability URL. Four fields only (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`). Omits `id`, `linkingKey`, `role`, and `viewKey`. -- **Inputs:** `Account`. +- **Purpose:** Public profile card for the capability URL. Five fields (`name`, `lightningAddress`, `lightningAddressVerified`, `createdAt`, `hasPasskey`). Omits `id`, `linkingKey`, `role`, and `viewKey`. +- **Inputs:** `Account`, `hasPasskey: boolean`. - **Returns / side effects:** `ViewProfileResponse`. No I/O. - **Used by:** `viewRoutes`. diff --git a/src/__tests__/lib/auth/account-json.test.ts b/src/__tests__/lib/auth/account-json.test.ts index 23d93084..254561aa 100644 --- a/src/__tests__/lib/auth/account-json.test.ts +++ b/src/__tests__/lib/auth/account-json.test.ts @@ -61,18 +61,23 @@ describe('serializeOwnerAccount', () => { }); describe('serializeViewProfile', () => { - it('emits exactly four public profile fields', () => { - const json = serializeViewProfile(account); + it('emits exactly five public profile fields', () => { + const json = serializeViewProfile(account, false); expect(json).toEqual({ name: 'Ada', lightningAddress: 'ada@walletofsatoshi.com', lightningAddressVerified: false, createdAt: 1, + hasPasskey: false, }); expect(json).not.toHaveProperty('id'); expect(json).not.toHaveProperty('linkingKey'); expect(json).not.toHaveProperty('role'); expect(json).not.toHaveProperty('viewKey'); - expect(Object.keys(json)).toHaveLength(4); + expect(Object.keys(json)).toHaveLength(5); + }); + + it('passes through hasPasskey true', () => { + expect(serializeViewProfile(account, true).hasPasskey).toBe(true); }); }); diff --git a/src/__tests__/routes/view.test.ts b/src/__tests__/routes/view.test.ts index 2ca55556..1a64eb7f 100644 --- a/src/__tests__/routes/view.test.ts +++ b/src/__tests__/routes/view.test.ts @@ -34,7 +34,7 @@ describe('GET /view/:viewKey', () => { expect(await res.json()).toEqual({ error: 'Not found' }); }); - it('returns the four-field public profile without Authorization', async () => { + it('returns the five-field public profile without Authorization', async () => { const store = new InMemoryAuthStore(); await store.createAccount({ id: 'acc', @@ -56,6 +56,7 @@ describe('GET /view/:viewKey', () => { lightningAddress: 'ada@walletofsatoshi.com', lightningAddressVerified: true, createdAt: 1_000_000, + hasPasskey: false, }); const raw = JSON.stringify(body); expect(raw).not.toContain('id'); @@ -64,9 +65,80 @@ describe('GET /view/:viewKey', () => { expect(raw).not.toContain('viewKey'); expect(Object.keys(body).sort()).toEqual([ 'createdAt', + 'hasPasskey', 'lightningAddress', 'lightningAddressVerified', 'name', ]); }); + + it('sets hasPasskey true when this account has a credential', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + await store.createPasskeyCredential({ + credentialId: 'cred-acc', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc', + createdAt: 1, + }); + const res = await mount(store).request(`/view/${VIEW_KEY}`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + createdAt: 1_000_000, + hasPasskey: true, + }); + }); + + it('does not flip hasPasskey from another account credential', async () => { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'acc', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: VIEW_KEY, + createdAt: 1_000_000, + rulesAgreedAt: null, + }); + await store.createAccount({ + id: 'other', + linkingKey: null, + role: 'basis', + name: 'Other', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + viewKey: 'b'.repeat(64), + createdAt: 2, + rulesAgreedAt: null, + }); + await store.createPasskeyCredential({ + credentialId: 'cred-other', + publicKey: new Uint8Array([2]), + signCount: 0, + accountId: 'other', + createdAt: 2, + }); + const res = await mount(store).request(`/view/${VIEW_KEY}`); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ hasPasskey: false }); + }); }); diff --git a/src/lib/auth/account-json.ts b/src/lib/auth/account-json.ts index 9c7c4faa..25ee4cf6 100644 --- a/src/lib/auth/account-json.ts +++ b/src/lib/auth/account-json.ts @@ -55,6 +55,8 @@ export interface ViewProfileResponse { lightningAddressVerified: boolean; /** Creation time (epoch ms). */ createdAt: number; + /** True when the account has at least one passkey credential. */ + hasPasskey: boolean; } /** @@ -104,13 +106,15 @@ export function serializeOwnerAccount(account: Account): OwnerAccountResponse { * Omits `id`, `linkingKey`, `role`, and `viewKey`. * * @param account - Stored account. - * @returns Four public profile fields. + * @param hasPasskey - Whether the account already has a passkey credential. + * @returns Five public profile fields. */ -export function serializeViewProfile(account: Account): ViewProfileResponse { +export function serializeViewProfile(account: Account, hasPasskey: boolean): ViewProfileResponse { return { name: account.name, lightningAddress: account.lightningAddress, lightningAddressVerified: account.lightningAddressVerified, createdAt: account.createdAt, + hasPasskey, }; } diff --git a/src/routes/view.ts b/src/routes/view.ts index 28d27439..89b5e1e7 100644 --- a/src/routes/view.ts +++ b/src/routes/view.ts @@ -35,6 +35,7 @@ export function viewRoutes(deps: ViewRouteDeps): Hono { if (account === undefined) { return c.json({ error: 'Not found' }, 404); } - return c.json(serializeViewProfile(account), 200); + const hasPasskey = await deps.store.accountHasPasskey(account.id); + return c.json(serializeViewProfile(account, hasPasskey), 200); }); }