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
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.<rpId>`. |
| `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. |
Expand Down
49 changes: 39 additions & 10 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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:**

Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
8 changes: 4 additions & 4 deletions docs/handbook/functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions e2e/http.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading
Loading