Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/handbook/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.

Expand Down
11 changes: 8 additions & 3 deletions src/__tests__/lib/auth/account-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
74 changes: 73 additions & 1 deletion src/__tests__/routes/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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');
Expand All @@ -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 });
});
});
8 changes: 6 additions & 2 deletions src/lib/auth/account-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
};
}
3 changes: 2 additions & 1 deletion src/routes/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Loading