diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2283bf8..4c4abaf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ api/ │ │ ├── push.ts # GET /push/vapid-public; POST/DELETE /me/push-subscriptions │ │ ├── stats.ts # GET /gifts/stats (public gift totals) │ │ ├── gifts.ts # GET /gifts?day= (public per-day gift list) -│ │ ├── invoices.ts # POST /invoices, POST /invoices/proof (spend worker) +│ │ ├── invoices.ts # GET /invoices/passkey, POST /invoices, POST /invoices/proof (spend worker) │ │ ├── messages.ts # GET/POST /messages, public GET /messages/:id, GET /messages/:id/replies, GET /messages/:id/photo, GET /messages/:id/video.*, POST /messages/:id/invoice │ │ ├── well-known.ts # GET /.well-known/nostr.json (NIP-05) │ │ ├── contact.ts # POST /contact (private mailbox + platform thread) @@ -404,7 +404,7 @@ Currently: | `WEBAUTHN_RP_ID` | _(none — required for passkey)_ | WebAuthn RP ID (`21.gifts` / `dev.21.gifts` / `localhost`). Passkey routes return `500` until it is set; the process still boots. Not a secret. | | `WEBAUTHN_RP_NAME` | `21.gifts` | Human-readable RP name. | | `CORS_ALLOWED_ORIGINS` | built-in apex / app aliases / localhost | Comma-separated browser origins. Passkey finish keeps those whose hostname is the RP ID or `app.`. | -| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `POST /invoices` / `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | +| `SPEND_API_TOKEN` | _(none — optional)_ | Bearer for spend-worker `GET /invoices/passkey`, `POST /invoices`, and `POST /invoices/proof`. Unset/blank → **503**; the process still boots. | | `BTC_USD_CANDLES_URL` | Coinbase Exchange BTC-USD candles URL | Optional override for daily close fetch used by `GET /gifts` and `GET /gifts/stats`. Blank/unset → default Coinbase URL; the process still boots. | | `NOSTR_NSEC_KEK` | _(required with `DATABASE_URL`)_ | 32-byte hex AES-GCM KEK for custodial nsec. With `DATABASE_URL`, missing or malformed KEK **throws at boot**. Memory boots omit it. | | `NOSTR_PUBLISH` | _(unset → sign only)_ | Set to `1` to fan out signed kind:1 notes, replaceable kind:0 profiles, and NIP-65 kind:10002 relay lists over WebSockets. Unchanged kind:0 / kind:10002 content is skipped for the life of the AuthStore instance. Other values do not publish. | diff --git a/SPEC.md b/SPEC.md index ae74f5f..88ad55e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -33,12 +33,14 @@ verification payment requires an injected invoice payer; the default `GET /lightning-address` resolves LUD-16 metadata with an in-memory cache; it does not fetch or pay invoices. -Spend-worker invoice routes (`POST /invoices`, `POST /invoices/proof`) fetch a -BOLT11 via LNURL-pay and accept a preimage proof. They require -`SPEND_API_TOKEN`; when it is unset the routes return **503** and the process -still boots. This service does not pay invoices (no LNDHub client). A matching -proof inserts an outbound row into `gift` when `DATABASE_URL` is set (no-op -without it) so `GET /gifts/stats` and `GET /gifts?day=` include the payment. Insert failure logs +Spend-worker invoice routes (`GET /invoices/passkey`, `POST /invoices`, +`POST /invoices/proof`) check passkey eligibility, fetch a BOLT11 via +LNURL-pay, and accept a preimage proof. Issue requires a passkey-backed +account for the address. They require `SPEND_API_TOKEN`; when it is unset the +routes return **503** and the process still boots. This service does not pay +invoices (no LNDHub client). A matching proof inserts an outbound row into +`gift` when `DATABASE_URL` is set (no-op without it) so `GET /gifts/stats` and +`GET /gifts?day=` include the payment. Insert failure logs `gifts.record_failed` and still returns **200**. CORS allows the configured origins (`CORS_ALLOWED_ORIGINS`, or the default @@ -104,7 +106,8 @@ Public base URLs used in examples: | POST | `/debug/push-ping` | Bearer `DEBUG_TOKEN` | Enqueue a test push for one account | | GET | `/gifts` | none | Outbound gifts for one UTC day (`?day=`) | | GET | `/gifts/stats` | none | Aggregated outbound gift statistics | -| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay) | +| GET | `/invoices/passkey` | Bearer `SPEND_API_TOKEN` | Whether a Lightning Address has a passkey-backed account | +| POST | `/invoices` | Bearer `SPEND_API_TOKEN` | Fetch a recipient BOLT11 (LNURL-pay; passkey required) | | POST | `/invoices/proof` | Bearer `SPEND_API_TOKEN` | Accept payment preimage as proof | ### `GET /healthz` @@ -1275,11 +1278,30 @@ call. Rates are ensured only for the selected gifts' UTC days. { "error": "Gift stats are unavailable" } ``` +### `GET /invoices/passkey` + +Spend-worker eligibility check. Query `address=name@domain.tld`. Same +`SPEND_API_TOKEN` Bearer as `POST /invoices` (503 unconfigured / 401 +unauthorized). + +Missing or invalid Lightning Address → **400** +`{ "error": "Not a valid Lightning Address (expected name@domain)" }`. + +Success is always **200** (never 404 for an unknown address): + +```json +{ "hasPasskey": true } +``` + +or `{ "hasPasskey": false }` when there is no account for the address or the +account has no passkey credential. + ### `POST /invoices` -Spend-worker invoice fetch. The api resolves LUD-16, GETs the LNURL-pay -callback, decodes the BOLT11, and stores `{ id, pr, paymentHash }` in memory. -It does not pay. +Spend-worker invoice fetch. After address and amount validation, the api +requires a 21.gifts account for `address` that already has a passkey +credential. It then resolves LUD-16, GETs the LNURL-pay callback, decodes +the BOLT11, and stores `{ id, pr, paymentHash }` in memory. It does not pay. **Body:** @@ -1307,6 +1329,13 @@ Bad JSON, `amountMsat` outside `1000..10000000000`, or `comment` longer than Invalid Lightning Address → **400** `{ "error": "Not a valid Lightning Address (expected name@domain)" }`. +No account for the address, or the account has no passkey credential → +**403** (before any LNURL fetch; no invoice is stored): + +```json +{ "error": "Passkey required" } +``` + LNURL-pay failure, decode failure, or invoice amount mismatch → **502**: ```json diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 512b117..8494990 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -196,10 +196,17 @@ - **Used by:** Humans and service catalogs. - **Auth:** See Purpose — Bearer where stated, else public. +## Endpoint: GET /invoices/passkey + +- **Purpose:** Spend-worker only. Query `address=local@domain`. Returns `{ hasPasskey: boolean }` so spend can filter before preflight. Fail closed: unknown address or account without a passkey credential → `hasPasskey: false` (always HTTP 200 on success; never 404). +- **Errors:** 503 if the token env is unset; 401 wrong/missing Bearer; 400 missing or invalid Lightning Address (`Not a valid Lightning Address (expected name@domain)`). +- **Used by:** the external spend worker before issuing a gift invoice. +- **Auth:** `Authorization: Bearer` matching `SPEND_API_TOKEN`. + ## Endpoint: POST /invoices -- **Purpose:** Spend-worker only. Bearer `SPEND_API_TOKEN`. Body `{ address, amountMsat, comment? }` (`comment` max 255). Resolves LUD-16, fetches a BOLT11 via LNURL-pay, decodes hash/amount, stores the invoice in memory. -- **Errors:** 503 if the token env is unset; 401 wrong/missing Bearer; 400 bad JSON/address/amount/`comment` longer than 255; 502 provider did not issue a matching invoice. +- **Purpose:** Spend-worker only. Bearer `SPEND_API_TOKEN`. Body `{ address, amountMsat, comment? }` (`comment` max 255). Requires a 21.gifts account for `address` that already has a passkey credential. Then resolves LUD-16, fetches a BOLT11 via LNURL-pay, decodes hash/amount, stores the invoice in memory. +- **Errors:** 503 if the token env is unset; 401 wrong/missing Bearer; 400 bad JSON/address/amount/`comment` longer than 255; 403 `{ error: 'Passkey required' }` when there is no account or the account has no passkey (before LNURL); 502 provider did not issue a matching invoice. - **Used by:** the external spend worker before paying via lightning.space. - **Auth:** `Authorization: Bearer` matching `SPEND_API_TOKEN`. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index ffc103e..c1123de 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -464,9 +464,9 @@ ## Function: invoiceRoutes -- **Purpose:** Hono sub-app for spend-worker invoice issue and preimage proof. -- **Inputs:** `InvoiceRouteDeps`: spend token, store, clock, fetch, optional `giftRecorder` (default `NoopGiftRecorder`). -- **Returns / side effects:** Hono app mounted at `/invoices`. A matching proof (including the same-preimage idempotent 200) calls `recordOutbound`. Insert failures log `gifts.record_failed` and still return 200. +- **Purpose:** Hono sub-app for spend-worker passkey eligibility (`GET /passkey`), invoice issue (`POST /`), and preimage proof (`POST /proof`). Issue refuses addresses without a passkey-backed account (403 before LNURL). +- **Inputs:** `InvoiceRouteDeps`: spend token, invoice `store`, `authStore` (account + passkey lookup), clock, fetch, optional `giftRecorder` (default `NoopGiftRecorder`). +- **Returns / side effects:** Hono app mounted at `/invoices`. `GET /passkey` returns `{ hasPasskey }` (200 even when false). A matching proof (including the same-preimage idempotent 200) calls `recordOutbound`. Insert failures log `gifts.record_failed` and still return 200. - **Used by:** `createApp`. ## Function: NoopGiftRecorder @@ -719,7 +719,7 @@ - **Purpose:** Trims and validates `local@domain` LUD-16 shape. Case is preserved. - **Inputs:** `raw` string. - **Returns / side effects:** Trimmed address or `null`. -- **Used by:** me lightning-address POST, public resolve, and POST /invoices. +- **Used by:** me lightning-address POST, public resolve, GET /invoices/passkey, and POST /invoices. ## Function: parseBindAddr diff --git a/e2e/http.spec.ts b/e2e/http.spec.ts index cef0a9a..42131bc 100644 --- a/e2e/http.spec.ts +++ b/e2e/http.spec.ts @@ -384,6 +384,11 @@ test('POST /auth/passkey/authenticate/finish without body is 400', async ({ requ expect(res.status()).toBe(400); }); +test('GET /invoices/passkey unconfigured is 503', async ({ request }) => { + const res = await request.get('/invoices/passkey'); + expect(res.status()).toBe(503); +}); + test('POST /invoices unconfigured is 503', async ({ request }) => { const res = await request.post('/invoices', { data: { address: 'alice@walletofsatoshi.com', amountMsat: 1000 }, diff --git a/src/__tests__/routes/invoices.test.ts b/src/__tests__/routes/invoices.test.ts index 5e56bca..10afa88 100644 --- a/src/__tests__/routes/invoices.test.ts +++ b/src/__tests__/routes/invoices.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GIFT_INVOICE_MAX_MSAT } from '@/lib/config'; +import { InMemoryAuthStore } from '@/lib/auth/store'; import { InMemoryInvoiceStore, type GiftInvoice } from '@/lib/invoice-store'; import { createApp } from '@/server'; import { decodeBolt11 } from '@/lib/bolt11'; @@ -59,6 +60,114 @@ function parsedEvents(warn: ReturnType): Array JSON.parse(arg) as Record); } +/** + * Seed an account for `address` with a passkey credential so POST /invoices + * can reach LNURL / 200. + */ +async function seedPasskeyAccount( + authStore: InMemoryAuthStore, + address: string = ADDRESS, +): Promise { + await authStore.createAccount({ + id: 'acc-alice', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: address, + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + await authStore.createPasskeyCredential({ + credentialId: 'cred-alice', + publicKey: new Uint8Array([1]), + signCount: 0, + accountId: 'acc-alice', + createdAt: 1, + }); +} + +describe('GET /invoices/passkey', () => { + it('returns 503 when the spend token is not configured', async () => { + const res = await createApp({ spendApiToken: '' }).request( + `/invoices/passkey?address=${encodeURIComponent(ADDRESS)}`, + ); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ error: 'Spend invoices are not configured' }); + }); + + it('returns 401 when the bearer is missing', async () => { + const res = await createApp({ spendApiToken: TOKEN }).request( + `/invoices/passkey?address=${encodeURIComponent(ADDRESS)}`, + ); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 400 when address is missing', async () => { + const res = await createApp({ spendApiToken: TOKEN }).request('/invoices/passkey', auth()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Not a valid Lightning Address (expected name@domain)', + }); + }); + + it('returns 400 on a bad Lightning Address', async () => { + const res = await createApp({ spendApiToken: TOKEN }).request( + '/invoices/passkey?address=nope', + auth(), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: 'Not a valid Lightning Address (expected name@domain)', + }); + }); + + it('returns hasPasskey false for an unknown address', async () => { + const res = await createApp({ spendApiToken: TOKEN }).request( + `/invoices/passkey?address=${encodeURIComponent(ADDRESS)}`, + auth(), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ hasPasskey: false }); + }); + + it('returns hasPasskey false for an account without a credential', async () => { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount({ + id: 'acc-alice', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: ADDRESS, + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + const res = await createApp({ spendApiToken: TOKEN, authStore }).request( + `/invoices/passkey?address=${encodeURIComponent(ADDRESS)}`, + auth(), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ hasPasskey: false }); + }); + + it('returns hasPasskey true when the account has a credential', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const res = await createApp({ spendApiToken: TOKEN, authStore }).request( + `/invoices/passkey?address=${encodeURIComponent(ADDRESS)}`, + auth(), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ hasPasskey: true }); + }); +}); + describe('POST /invoices', () => { let warn: ReturnType; @@ -135,9 +244,48 @@ describe('POST /invoices', () => { expect(res.status).toBe(400); }); + it('returns 403 when there is no account for the address', async () => { + const res = await createApp({ spendApiToken: TOKEN, fetchImpl: happyFetch() }).request( + '/invoices', + auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Passkey required' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'invoice.passkey_required')).toBe(true); + }); + + it('returns 403 when the account has no passkey credential', async () => { + const authStore = new InMemoryAuthStore(); + await authStore.createAccount({ + id: 'acc-alice', + linkingKey: null, + role: 'basis', + name: 'Ada', + lightningAddress: ADDRESS, + lightningAddressVerified: true, + forumLawsDismissed: false, + viewKey: 'a'.repeat(64), + createdAt: 1, + rulesAgreedAt: null, + }); + const res = await createApp({ + spendApiToken: TOKEN, + authStore, + fetchImpl: happyFetch(), + }).request( + '/invoices', + auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), + ); + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: 'Passkey required' }); + expect(parsedEvents(warn).some((e) => e['event'] === 'invoice.passkey_required')).toBe(true); + }); + it('returns 502 when LNURL-pay cannot issue an invoice', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); const fetchImpl: FetchFn = async () => jsonResponse({}, 500); - const res = await createApp({ spendApiToken: TOKEN, fetchImpl }).request( + const res = await createApp({ spendApiToken: TOKEN, authStore, fetchImpl }).request( '/invoices', auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), ); @@ -146,8 +294,14 @@ describe('POST /invoices', () => { }); it('returns 502 when bolt11 decode fails', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); mockedDecode.mockReturnValue(null); - const res = await createApp({ spendApiToken: TOKEN, fetchImpl: happyFetch() }).request( + const res = await createApp({ + spendApiToken: TOKEN, + authStore, + fetchImpl: happyFetch(), + }).request( '/invoices', auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), ); @@ -155,8 +309,14 @@ describe('POST /invoices', () => { }); it('returns 502 when the invoice amount does not match', async () => { + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); mockedDecode.mockReturnValue({ paymentHash: HASH, amountMsat: 999 }); - const res = await createApp({ spendApiToken: TOKEN, fetchImpl: happyFetch() }).request( + const res = await createApp({ + spendApiToken: TOKEN, + authStore, + fetchImpl: happyFetch(), + }).request( '/invoices', auth({ method: 'POST', body: JSON.stringify({ address: ADDRESS, amountMsat: 1000 }) }), ); @@ -164,7 +324,13 @@ describe('POST /invoices', () => { }); it('returns 200 with id, pr, paymentHash, amountMsat', async () => { - const res = await createApp({ spendApiToken: TOKEN, fetchImpl: happyFetch() }).request( + const authStore = new InMemoryAuthStore(); + await seedPasskeyAccount(authStore); + const res = await createApp({ + spendApiToken: TOKEN, + authStore, + fetchImpl: happyFetch(), + }).request( '/invoices', auth({ method: 'POST', diff --git a/src/routes/invoices.ts b/src/routes/invoices.ts index 0272f6f..1e89fb7 100644 --- a/src/routes/invoices.ts +++ b/src/routes/invoices.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono'; import { z } from 'zod'; +import type { AuthStore } from '@/lib/auth/store'; import { decodeBolt11 } from '@/lib/bolt11'; import { GIFT_INVOICE_MAX_MSAT, GIFT_INVOICE_MIN_MSAT, GIFT_INVOICE_TTL_MS } from '@/lib/config'; import { requestGiftInvoice } from '@/lib/gift-invoice'; @@ -16,8 +17,9 @@ import { import { logEvent } from '@/lib/log'; /** - * Spend-worker invoice routes: fetch a recipient BOLT11 via LNURL-pay, then - * accept the payment preimage as proof. The api does not pay. + * Spend-worker invoice routes: check passkey eligibility, fetch a recipient + * BOLT11 via LNURL-pay, then accept the payment preimage as proof. The api + * does not pay. */ /** Collaborators the invoice routes need. */ @@ -26,6 +28,11 @@ export interface InvoiceRouteDeps { spendApiToken: string | undefined; /** Issued-invoice store. */ store: InvoiceStore; + /** + * Auth store for Lightning Address → account and passkey credential lookup. + * Distinct from {@link InvoiceStore} (`store`). + */ + authStore: Pick; /** Clock, epoch milliseconds. */ now: () => number; /** Injected fetch for LNURL-pay. */ @@ -70,10 +77,26 @@ function authGate( return null; } +/** + * Whether a normalised Lightning Address belongs to an account that already + * has a passkey credential. Missing account → false (fail closed). + * + * @param authStore - Account and credential lookup. + * @param address - Normalised `local@domain`. + * @returns `true` only when both account and credential exist. + */ +async function addressHasPasskey( + authStore: InvoiceRouteDeps['authStore'], + address: string, +): Promise { + const account = await authStore.getAccountByLightningAddress(address); + return account !== undefined && (await authStore.accountHasPasskey(account.id)); +} + /** * Build the `/invoices` route group. * - * @param deps - Token, store, clock, fetch, optional gift recorder. + * @param deps - Token, invoice store, auth store, clock, fetch, optional gift recorder. * @returns Hono app mounted at `/invoices`. */ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { @@ -96,6 +119,23 @@ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { } return new Hono() + .get('/passkey', async (c) => { + const denied = authGate( + checkSpendAuth(deps.spendApiToken, c.req.header('Authorization')), + (body, status) => c.json(body, status), + ); + if (denied !== null) { + return denied; + } + + const address = normalizeLightningAddress(c.req.query('address') ?? ''); + if (address === null) { + return c.json({ error: 'Not a valid Lightning Address (expected name@domain)' }, 400); + } + + const hasPasskey = await addressHasPasskey(deps.authStore, address); + return c.json({ hasPasskey }, 200); + }) .post('/', async (c) => { const denied = authGate( checkSpendAuth(deps.spendApiToken, c.req.header('Authorization')), @@ -126,6 +166,12 @@ export function invoiceRoutes(deps: InvoiceRouteDeps): Hono { return c.json({ error: 'Expected a JSON body with address and amountMsat' }, 400); } + const hasPasskey = await addressHasPasskey(deps.authStore, address); + if (!hasPasskey) { + logEvent('invoice.passkey_required', { address }); + return c.json({ error: 'Passkey required' }, 403); + } + const fetchArgs: { address: string; amountMsat: number; diff --git a/src/server.ts b/src/server.ts index 13e7f90..2dd36a8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -102,7 +102,8 @@ export interface AppDeps { passkeyCeremony?: PasskeyCeremony; /** * Spend-worker shared secret (default: `process.env.SPEND_API_TOKEN`). - * Unset → `POST /invoices` returns 503. + * Unset → `GET /invoices/passkey`, `POST /invoices`, and + * `POST /invoices/proof` return 503. */ spendApiToken?: string; /** Gift invoices issued for the spend worker (default: in-memory). */ @@ -311,6 +312,7 @@ export function createApp(deps: AppDeps = {}): Hono { invoiceRoutes({ spendApiToken, store: invoiceStore, + authStore: store, now, fetchImpl, ...(giftRecorder === undefined ? {} : { giftRecorder }),