From 3e97f2942670780aa0f16fdfa626a12a2a77a75f Mon Sep 17 00:00:00 2001 From: Levi Wilkerson Date: Thu, 15 Oct 2026 01:23:33 -0400 Subject: [PATCH] feat: account/info for reading your own account --- AGENTS.md | 5 ++ src/accounts/accounts.test.ts | 92 +++++++++++++++++++++++++++++++++-- src/accounts/contracts.ts | 14 ++++-- src/accounts/openapi.ts | 70 ++++++++++++++++++++------ src/accounts/route.ts | 91 +++++++++++++++++++++++++++------- src/accounts/store.ts | 45 ++++++++++++++++- src/e2e/accounts.e2e.test.ts | 79 ++++++++++++++++++++++++++++++ 7 files changed, 356 insertions(+), 40 deletions(-) create mode 100644 src/e2e/accounts.e2e.test.ts diff --git a/AGENTS.md b/AGENTS.md index 3249b16..8f4c9cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -405,6 +405,11 @@ Code in `src/accounts/`, schema in `src/accounts/schema.ts` (Drizzle on D1). - `POST /accounts/v1/account/register`: self-signed under the `ww-account/v1` context; idempotent per key. Counts against the Buddies register edge tier and the per-caller daily quota. +- `POST /accounts/v1/account/info`: the same self-signed envelope (op + `account/info`), read-only. Returns the key's own account (`accountId`, + `accountPub`, `status`, `createdAt`, `inboxIds`, and `statusChanges` + without their reasons) for the app's developer tools; an unregistered key + is 404 `unknown_account`. Counts against the Buddies read edge tier. - `inbox/register` takes an optional `account: { accountId, signature }`, the account key's signature over the inbox id and owner key. The link (`account_inboxes`) is written only after the inbox accepted the diff --git a/src/accounts/accounts.test.ts b/src/accounts/accounts.test.ts index 1ec84bd..c88e260 100644 --- a/src/accounts/accounts.test.ts +++ b/src/accounts/accounts.test.ts @@ -11,7 +11,11 @@ import { type Harness, } from '../test/buddies' import { signedMessage } from '../buddies/envelope' -import { accountSignedMessage, inboxLinkMessage } from './contracts' +import { + type AccountsOp, + accountSignedMessage, + inboxLinkMessage, +} from './contracts' import { createAccountsRoutes } from './route' import { createAccountsAdminRoutes } from './admin' @@ -71,12 +75,13 @@ const post = async ( const accountEnvelope = async ( payload: Record, key: SigningKey, - message = accountSignedMessage + message = accountSignedMessage, + op: AccountsOp = 'account/register' ) => { const bytes = encoder.encode(JSON.stringify(payload)) return { p: b64u(bytes), - s: b64u(await key.sign(message('account/register', bytes))), + s: b64u(await key.sign(message(op, bytes))), } } @@ -213,6 +218,87 @@ describe('account/register', () => { }) }) +const accountInfo = async ( + key: SigningKey, + payload: Record = {}, + op: AccountsOp = 'account/info' +) => + post( + accountsApp as never, + '/account/info', + await accountEnvelope( + { accountPub: key.publicKey, ts: Date.now(), nonce: randomId(), ...payload }, + key, + accountSignedMessage, + op + ) + ) + +describe('account/info', () => { + it('shows the account, its inboxes, and its status changes', async () => { + const { account, owner } = await linkedOwner() + vi.setSystemTime(START + 60_000) + await setStatus(account.accountId, 'suspended') + vi.setSystemTime(START + 120_000) + await setStatus(account.accountId, 'active', 'appeal') + expect(await accountInfo(account.key)).toEqual({ + status: 200, + body: { + ok: true, + accountId: account.accountId, + accountPub: account.key.publicKey, + status: 'active', + createdAt: START, + inboxIds: [owner.inboxId], + statusChanges: [ + { status: 'suspended', createdAt: START + 60_000 }, + { status: 'active', createdAt: START + 120_000 }, + ], + }, + }) + }) + + it('never creates an account', async () => { + const key = await SigningKey.generate() + expect(await accountInfo(key)).toMatchObject({ + status: 404, + body: { code: 'unknown_account' }, + }) + expect((await registerAccount(key)).status).toBe(200) + expect((await accountInfo(key)).body.inboxIds).toEqual([]) + }) + + it('needs the account key’s signature for account/info', async () => { + const { key } = await newAccount() + const other = await SigningKey.generate() + const forged = await post( + accountsApp as never, + '/account/info', + await accountEnvelope( + { accountPub: key.publicKey, ts: Date.now(), nonce: randomId() }, + other, + accountSignedMessage, + 'account/info' + ) + ) + expect(forged.body.code).toBe('bad_signature') + // A register signature can't be replayed as a read. + const crossOp = await accountInfo(key, {}, 'account/register') + expect(crossOp.body.code).toBe('bad_signature') + }) + + it('refuses a stale timestamp, and is off without the database', async () => { + const { key } = await newAccount() + const stale = await accountInfo(key, { ts: Date.now() - 10 * 60_000 }) + expect(stale).toMatchObject({ status: 401, body: { code: 'stale' } }) + h = await createHarness({ accountsDb: false }) + expect(await accountInfo(key)).toMatchObject({ + status: 503, + body: { code: 'disabled' }, + }) + }) +}) + describe('linking an inbox', () => { it('links on inbox/register with a proof, and says so later without one', async () => { const { account, owner } = await linkedOwner() diff --git a/src/accounts/contracts.ts b/src/accounts/contracts.ts index 15a77b1..3ce848d 100644 --- a/src/accounts/contracts.ts +++ b/src/accounts/contracts.ts @@ -15,7 +15,7 @@ import { * Buddies one or the other way round. */ -export const ACCOUNTS_OPS = ['account/register'] as const +export const ACCOUNTS_OPS = ['account/register', 'account/info'] as const export type AccountsOp = (typeof ACCOUNTS_OPS)[number] const SIGNING_CONTEXT = 'ww-account/v1\n' @@ -49,7 +49,8 @@ export const inboxLinkMessage = ( `${SIGNING_CONTEXT}inbox/link\n${accountId}\n${inboxId}\n${ownerPub}` ) -export interface AccountRegisterPayload { +/** `account/register` and `account/info` both name the signing key. */ +export interface AccountKeyPayload { accountPub: string ts: number nonce: string @@ -65,10 +66,13 @@ const publicKey = (value: unknown): string | null => { return bytes && bytes.length === 32 ? bytesToBase64Url(bytes) : null } -/** Validates `account/register`'s payload; null means `bad_request`. */ -export const parseAccountRegister = ( +/** + * Validates `account/register`'s or `account/info`'s payload; null means + * `bad_request`. + */ +export const parseAccountKeyPayload = ( payload: unknown -): AccountRegisterPayload | null => { +): AccountKeyPayload | null => { if (!isPlainObject(payload)) return null const accountPub = publicKey(payload.accountPub) const ts = payload.ts diff --git a/src/accounts/openapi.ts b/src/accounts/openapi.ts index 9e99ac2..ae0e4c9 100644 --- a/src/accounts/openapi.ts +++ b/src/accounts/openapi.ts @@ -14,13 +14,8 @@ import { ACCOUNT_STATUS_REASONS, ACCOUNT_STATUSES } from './schema' const accountId = buddiesId('The account id') const status = z.enum(ACCOUNT_STATUSES) -export const accountRegisterDocs = describeRoute({ - tags: ['Accounts'], - summary: 'Register an account', - description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`. - -Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`, - requestBody: signedBody( +const keySignedBody = (op: string) => + signedBody( z.object({ accountPub: b64u( 'Ed25519 public key of the account signing key, 32 bytes' @@ -31,19 +26,31 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra }), nonce: buddiesId('A fresh random nonce'), }), - 'A signed envelope; `s` is the account key’s signature over `ww-account/v1\\naccount/register\\n` followed by `p`’s bytes.' + `A signed envelope; \`s\` is the account key’s signature over \`ww-account/v1\\n${op}\\n\` followed by \`p\`’s bytes.` + ) + +const signatureErrors = { + 400: errorResponse('Malformed envelope or payload.', ['bad_request']), + 401: errorResponse( + 'Bad signature, or `ts` too far from the server clock. `stale` carries `serverTime` (epoch ms) and a `Date` header.', + ['bad_signature', 'stale'], + staleErrorSchema ), +} + +export const accountRegisterDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Register an account', + description: `Returns the account for a signing key, creating it on first use. Self-signed by the account key under the \`ww-account/v1\` context, and idempotent: the same key always gets the same \`accountId\`. + +Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registrations per caller per rolling day, shared in kind with \`inbox/register\`. Answers \`disabled\` on a worker without the accounts database.`, + requestBody: keySignedBody('account/register'), responses: { 200: jsonResponse( 'The account for this key.', z.object({ ok: z.literal(true), accountId, status }) ), - 400: errorResponse('Malformed envelope or payload.', ['bad_request']), - 401: errorResponse( - 'Bad signature, or `ts` too far from the server clock. `stale` carries `serverTime` (epoch ms) and a `Date` header.', - ['bad_signature', 'stale'], - staleErrorSchema - ), + ...signatureErrors, 429: errorResponse('Over the per-caller rate or daily quota.', [ 'rate_limited', ]), @@ -52,6 +59,41 @@ Limits: the Buddies register tier (60 a minute per caller IP) and 2,000 registra }, }) +const epochMs = (description: string) => + z.number().int().meta({ description: `${description}, epoch milliseconds.` }) + +export const accountInfoDocs = describeRoute({ + tags: ['Accounts'], + summary: 'Read your account', + description: `What the accounts database holds for the signing key’s own account, for the app’s developer tools: its id, status, creation time, linked inboxes, and status changes (without their reasons). Self-signed by the account key under the \`ww-account/v1\` context, so only the key’s holder can read it. Read-only: it never creates an account. + +Limits: the Buddies read tier (600 a minute per caller IP). Answers \`disabled\` on a worker without the accounts database.`, + requestBody: keySignedBody('account/info'), + responses: { + 200: jsonResponse( + 'The account for this key.', + z.object({ + ok: z.literal(true), + accountId, + accountPub: b64u('The account signing key’s public key'), + status, + createdAt: epochMs('When the account was registered'), + inboxIds: z.array(buddiesId('An inbox linked to the account')), + statusChanges: z.array( + z.object({ status, createdAt: epochMs('When it changed') }) + ), + }) + ), + ...signatureErrors, + 404: errorResponse('No account has this key; register first.', [ + 'unknown_account', + ]), + 429: errorResponse('Over the per-caller rate.', ['rate_limited']), + 500: errorResponse('Unexpected failure.', ['internal']), + 503: errorResponse('Accounts are off on this worker.', ['disabled']), + }, +}) + const adminErrors = { 400: errorResponse('Invalid JSON or field.', ['bad_request']), 404: errorResponse( diff --git a/src/accounts/route.ts b/src/accounts/route.ts index 91f88ba..c732bac 100644 --- a/src/accounts/route.ts +++ b/src/accounts/route.ts @@ -18,9 +18,14 @@ import { EDGE_RETRY_AFTER_SECONDS, logLimitHit, } from '../buddies/limits' -import { accountSignedMessage, parseAccountRegister } from './contracts' -import { accountsDb, registerAccount } from './store' -import { accountRegisterDocs } from './openapi' +import { + type AccountKeyPayload, + type AccountsOp, + accountSignedMessage, + parseAccountKeyPayload, +} from './contracts' +import { accountsDb, findAccountInfo, registerAccount } from './store' +import { accountInfoDocs, accountRegisterDocs } from './openapi' const randomAccountId = (): string => bytesToBase64Url(crypto.getRandomValues(new Uint8Array(16))) @@ -58,6 +63,33 @@ const refuseOverQuota = async ( } } +/** + * The payload of a self-signed account op, after the envelope, its signature + * by the named key, and its timestamp check out; otherwise the refusal. + */ +const verifiedKeyPayload = async ( + c: AppContext, + op: AccountsOp +): Promise<{ payload: AccountKeyPayload } | { refusal: Response }> => { + const text = await readEnvelopeText(c.req.raw) + const envelope = text == null ? null : parseEnvelope(text) + const payload = envelope ? parseAccountKeyPayload(envelope.payload) : null + if (!envelope || !payload) + return { refusal: buddiesErrorResponse('bad_request') } + const signature = decodeSignature(envelope.signature) + if (!signature) return { refusal: buddiesErrorResponse('bad_signature') } + if (Math.abs(Date.now() - payload.ts) > BUDDIES_LIMITS.timestampSkewMs) + return { refusal: buddiesErrorResponse('stale') } + const valid = await verifyEd25519( + payload.accountPub, + accountSignedMessage(op, envelope.payloadBytes), + signature + ) + return valid + ? { payload } + : { refusal: buddiesErrorResponse('bad_signature') } +} + /** * `POST /accounts/v1/account/register`: the account for a signing key, created * on first use. Self-signed, like `inbox/register`, and idempotent: the same @@ -75,20 +107,9 @@ const handleRegister = async (c: AppContext): Promise => { retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS, }) - const text = await readEnvelopeText(c.req.raw) - const envelope = text == null ? null : parseEnvelope(text) - const payload = envelope ? parseAccountRegister(envelope.payload) : null - if (!envelope || !payload) return buddiesErrorResponse('bad_request') - const signature = decodeSignature(envelope.signature) - if (!signature) return buddiesErrorResponse('bad_signature') - if (Math.abs(Date.now() - payload.ts) > BUDDIES_LIMITS.timestampSkewMs) - return buddiesErrorResponse('stale') - const valid = await verifyEd25519( - payload.accountPub, - accountSignedMessage('account/register', envelope.payloadBytes), - signature - ) - if (!valid) return buddiesErrorResponse('bad_signature') + const verified = await verifiedKeyPayload(c, 'account/register') + if ('refusal' in verified) return verified.refusal + const { payload } = verified const refused = await refuseOverQuota(c.env, callerKey) if (refused) return refused @@ -106,9 +127,45 @@ const handleRegister = async (c: AppContext): Promise => { } } +/** + * `POST /accounts/v1/account/info`: what the database holds for the signing + * key's own account, for the app's developer tools. Read-only and self-signed, + * so only the account key's holder can read it; it never creates an account + * (an unknown key is 404 `unknown_account`). Counts against the read edge tier. + * + * Privacy: nothing here logs or reports the body, the key, or the account id. + */ +const handleInfo = async (c: AppContext): Promise => { + try { + if (!c.env.ACCOUNTS_DB) return buddiesErrorResponse('disabled') + const callerKey = buddiesCallerKey(c.req.header('CF-Connecting-IP')) + if (!(await allowBuddiesCaller(c.env, 'inbox/sync', callerKey))) + return buddiesErrorResponse('rate_limited', { + retryAfterSeconds: EDGE_RETRY_AFTER_SECONDS, + }) + + const verified = await verifiedKeyPayload(c, 'account/info') + if ('refusal' in verified) return verified.refusal + const { payload } = verified + + const info = await findAccountInfo( + accountsDb(c.env.ACCOUNTS_DB), + payload.accountPub + ) + if (!info) return buddiesErrorResponse('unknown_account') + const { id, ...rest } = info + return c.json({ ok: true, accountId: id, ...rest }) + } catch (error) { + console.error('accounts: request failed', { op: 'account/info' }) + Sentry.captureException(error) + return buddiesInternalError() + } +} + /** Mounted at `/accounts/v1` by the Worker entry point. */ export const createAccountsRoutes = () => { const routes = new Hono<{ Bindings: Environment }>() routes.post('/account/register', accountRegisterDocs, handleRegister) + routes.post('/account/info', accountInfoDocs, handleInfo) return routes } diff --git a/src/accounts/store.ts b/src/accounts/store.ts index 8fb5733..54b2a8b 100644 --- a/src/accounts/store.ts +++ b/src/accounts/store.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { asc, eq } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/d1' import { accountInboxes, @@ -122,3 +122,46 @@ export const setAccountStatus = async ( db.insert(accountStatusEvents).values({ accountId, status, reason }), ]) } + +/** + * What `account/info` shows the account's own devices: its row, the inboxes + * it owns, and when its status changed. Status reasons stay with moderation. + */ +export interface AccountInfo extends AccountRecord { + createdAt: number + inboxIds: string[] + statusChanges: { status: AccountStatus; createdAt: number }[] +} + +export const findAccountInfo = async ( + db: AccountsDb, + accountPub: string +): Promise => { + const account = await db + .select({ ...accountColumns, createdAt: accounts.createdAt }) + .from(accounts) + .where(eq(accounts.accountPub, accountPub)) + .get() + if (!account) return null + const [inboxIds, statusChanges] = await Promise.all([ + accountInboxIds(db, account.id), + db + .select({ + status: accountStatusEvents.status, + createdAt: accountStatusEvents.createdAt, + }) + .from(accountStatusEvents) + .where(eq(accountStatusEvents.accountId, account.id)) + .orderBy(asc(accountStatusEvents.id)) + .all(), + ]) + return { + ...account, + createdAt: account.createdAt.getTime(), + inboxIds, + statusChanges: statusChanges.map((change) => ({ + status: change.status, + createdAt: change.createdAt.getTime(), + })), + } +} diff --git a/src/e2e/accounts.e2e.test.ts b/src/e2e/accounts.e2e.test.ts new file mode 100644 index 0000000..8935e8e --- /dev/null +++ b/src/e2e/accounts.e2e.test.ts @@ -0,0 +1,79 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { accountSignedMessage, type AccountsOp } from '../accounts/contracts' +import { SigningKey, b64u, randomId } from '../test/buddiesClient' +import { ADMIN_TOKEN, http, writeTranscript } from '../test/e2e' + +/** Anonymous accounts: register, then read the account back with account/info. */ + +afterAll(() => { + writeTranscript('accounts') +}) + +const encoder = new TextEncoder() + +const send = async (op: AccountsOp, key: SigningKey, signer = key) => { + const bytes = encoder.encode( + JSON.stringify({ accountPub: key.publicKey, ts: Date.now(), nonce: randomId() }) + ) + return http('POST', `/accounts/v1/${op}`, { + body: { + p: b64u(bytes), + s: b64u(await signer.sign(accountSignedMessage(op, bytes))), + }, + }) +} + +describe('POST /accounts/v1/account/info', () => { + it('404s for a key that never registered, without creating it', async () => { + const key = await SigningKey.generate() + const res = await send('account/info', key) + expect(res.status).toBe(404) + expect(res.body).toMatchObject({ ok: false, code: 'unknown_account' }) + }) + + it('reads back the account register created', async () => { + const key = await SigningKey.generate() + const registered = await send('account/register', key) + expect(registered.status).toBe(200) + const { accountId } = registered.body as { accountId: string } + + const res = await send('account/info', key) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + ok: true, + accountId, + accountPub: key.publicKey, + status: 'active', + createdAt: expect.any(Number), + inboxIds: [], + statusChanges: [], + }) + }) + + it('refuses another key’s signature', async () => { + const key = await SigningKey.generate() + await send('account/register', key) + const res = await send('account/info', key, await SigningKey.generate()) + expect(res.status).toBe(401) + expect(res.body).toMatchObject({ code: 'bad_signature' }) + }) + + it.skipIf(!ADMIN_TOKEN)('shows a suspension made through admin', async () => { + const key = await SigningKey.generate() + const { accountId } = (await send('account/register', key)).body as { + accountId: string + } + const suspended = await http('POST', '/admin/accounts/status', { + headers: { 'x-ww-admin-token': ADMIN_TOKEN ?? '' }, + body: { accountId, status: 'suspended', reason: 'admin' }, + }) + expect(suspended.status).toBe(200) + + const res = await send('account/info', key) + expect(res.body).toMatchObject({ + status: 'suspended', + statusChanges: [{ status: 'suspended', createdAt: expect.any(Number) }], + }) + expect(JSON.stringify(res.body)).not.toContain('admin') + }) +})