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
1 change: 1 addition & 0 deletions CONCEPT.md
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ repository — they're intentionally not part of this project's scope.
| 2026-08-29 | Zap ingest and invoice `relays` always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on `NOSTR_PUBLISH_PUBLIC`. |
| 2026-08-29 | Zap-receipt sats UPDATE qualifies `message.sats` so Postgres can apply it. |
| 2026-08-30 | Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` → process still boots; push HTTP 503. Subscriptions bind to `account.id`. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag `forum`); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English `{ type, title, body, url, tag }`. |
| 2026-09-09 | `forum.post` requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). `POST /messages` 409 `missing_requirements` includes `lightning-address` when it is factually missing. `ensureProfileMessage` no-ops without LN; linking LN after a name creates the profile note. `contact.post`, `forum.read`, and `forum.pay` unchanged; existing message rows are not deleted. |

---

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ api/
│ │ ├── account-json.ts # Public account JSON (no nsec)
│ │ ├── account-setup.ts # Next owner setup step + factual missing fields
│ │ ├── requirements.ts # Action→fields gates (`requireAction`)
│ │ ├── profile-message.ts # First-name profile forum note (`ensureProfileMessage`)
│ │ ├── profile-message.ts # Profile forum note when name + LN set (`ensureProfileMessage`)
│ │ ├── hex.ts # CSPRNG hex tokens
│ │ ├── passkey.ts # WebAuthn register/authenticate domain logic
│ │ ├── service.ts # Session issuance and bearer resolution
Expand Down
19 changes: 10 additions & 9 deletions FLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,14 @@ any pending verification (`SPEC.md`).

### Identity copy — **Shipped** (name) + **Sketch** (photo / story)

Receiver name is stored on the account (`POST /me/name`). The first persisted
non-empty name also creates exactly one top-level profile forum note; rename
does not create a second note or change its text. Other members read live
identity plus that note via `GET /members/:accountId` (Bearer; rules required).
Photo and story beyond that note stay custodial `kind:0` metadata signed
server-side (`about` is the profile-note text when present, else `21.gifts`).
**Do not invent** `POST /me/profile`.
Receiver name is stored on the account (`POST /me/name`). The profile forum
note is created when a non-blank name and a non-blank Lightning Address are
present (`POST /me/name` no-ops without LN; `POST /me/lightning-address`
creates it). Rename does not create a second note or change its text. Other
members read live identity plus that note via `GET /members/:accountId`
(Bearer; rules required). Photo and story beyond that note stay custodial
`kind:0` metadata signed server-side (`about` is the profile-note text when
present, else `21.gifts`). **Do not invent** `POST /me/profile`.

### View-key link — **Shipped**

Expand Down Expand Up @@ -156,8 +157,8 @@ HTTP that exists today is only the spend-worker invoice pair above (`SPEC.md`).

Public comment / encouragement is a v1 surface. The composer POSTs
`{ text }` and/or `{ photo: { contentType, data } }` to `POST /messages`
(requires rules + name; Lightning Address is not required to post — missing
requirements are **409** `missing_requirements`);
(requires rules + name + Lightning Address — missing requirements are
**409** `missing_requirements`);
the public thread is listed via `GET /messages` (requires rules; newest first, name
snapshotted at post, `sats`, `payable`, `hasPhoto`, and live author `role`
— never photo bytes). Bytes are public `GET /messages/:id/photo` (Nostr `imeta`). The shipped UI
Expand Down
45 changes: 26 additions & 19 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Public base URLs used in examples:
| GET | `/me` | `Authorization: Bearer` | Account (`setup` + factual `missing`) |
| GET | `/view/:viewKey` | none | Public profile card by view key |
| POST | `/me/setup/skip` | Bearer | Skip name or Lightning Address wizard step |
| POST | `/me/name` | Bearer | Set/replace display name (first name creates profile note) |
| POST | `/me/name` | Bearer | Set/replace display name (profile note when name + LN are both set) |
| POST | `/me/forum-laws-dismissed` | Bearer | Dismiss welcome-forum living-room laws |
| POST | `/me/rules-agreement` | Bearer | Record living-room rules agreement |
| POST | `/me/lightning-address` | Bearer | Link/replace after live LNURL resolve + NIP-57 mint probe |
Expand All @@ -80,7 +80,7 @@ Public base URLs used in examples:
| POST | `/me/lightning-address/verification/confirm` | Bearer | Confirm nonce from wallet history |
| GET | `/members/:accountId` | Bearer | Live member identity + profile note |
| GET | `/messages` | Bearer | List top-level forum notes (+ `replyCount`); 409 if rules missing |
| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name missing; LN not required to post |
| POST | `/messages` | Bearer | Post text/photo; 409 if rules/name/Lightning Address missing |
| GET | `/messages/:id` | none | Public single-note JSON |
| GET | `/messages/:id/replies` | Bearer | Oldest-first replies for a parent note |
| GET | `/messages/:id/photo` | none | Fetch forum message photo bytes |
Expand Down Expand Up @@ -388,10 +388,13 @@ control / DEL character (`charCode < 32` or `=== 127`) → **Response** `400`:
```

Success → **Response** `200` with the updated account (same shape as
`GET /me`). The stored value is trimmed. Names are not unique. The first
persisted non-empty name also creates exactly one top-level profile forum
note and stores `profileMessageId` (not on owner JSON). Rename does not
create a second note and does not change the note text.
`GET /me`). The stored value is trimmed. Names are not unique. When a
non-blank Lightning Address is already linked, the first persisted
non-empty name also creates exactly one top-level profile forum note and
stores `profileMessageId` (not on owner JSON). Without a Lightning
Address the name is stored and no profile note is inserted (linking the
address later creates it). Rename does not create a second note and does
not change the note text.

### `POST /me/forum-laws-dismissed`

Expand Down Expand Up @@ -481,8 +484,10 @@ Another account already owns the address (including a unique-index race)

Success → **Response** `200` with the updated account (same shape as
`GET /me`). `lightningAddressVerified` is always reset to `false`, and any
pending verification for the account is cleared. There is no proof-of-control
in this step — use `POST /me/lightning-address/verification` for that.
pending verification for the account is cleared. After the address is
stored, `ensureProfileMessage` runs so a non-blank display name that was
set earlier gets its profile forum note. There is no proof-of-control in
this step — use `POST /me/lightning-address/verification` for that.

### `DELETE /me/lightning-address`

Expand Down Expand Up @@ -1485,15 +1490,15 @@ is not in the store, or a parent that is itself a reply (`parentId` not
null) → **404** `{ "error": "Not found" }`. Multipart video posts do not
accept `inReplyTo` (they are always top-level).

After auth, `requireAction(account, 'forum.post')` requires rules agreement
and a non-blank display name (Lightning Address is **not** required to post).
The api stores a **name snapshot** (trimmed account name at post time),
normalised text (possibly `""` for photo-only), optional JPEG/PNG/WebP bytes
(≤ 1 MiB; MIME from magic bytes), `parentId` (null for top-level notes), and a
timestamp. Text longer than **500** after trim, or with disallowed C0/DEL
controls, is rejected. Newlines (`\n`, `\r`) are allowed. The **200** body
is the public message object itself (not wrapped in `{ messages }`),
including `sats`, `payable`, `hasPhoto`, `hasVideo`, and
After auth, `requireAction(account, 'forum.post')` requires rules agreement,
a non-blank display name, and a non-blank Lightning Address (skip timestamps
do not satisfy). The api stores a **name snapshot** (trimmed account name at
post time), normalised text (possibly `""` for photo-only), optional
JPEG/PNG/WebP bytes (≤ 1 MiB; MIME from magic bytes), `parentId` (null for
top-level notes), and a timestamp. Text longer than **500** after trim, or
with disallowed C0/DEL controls, is rejected. Newlines (`\n`, `\r`) are
allowed. The **200** body is the public message object itself (not wrapped
in `{ messages }`), including `sats`, `payable`, `hasPhoto`, `hasVideo`, and
`videoContentType`. May include `accountId` (21gifts author id). No
`replyCount`, and no photo or video bytes in the JSON. `sats` is 0 and
`payable` is false until the worker signs the note (and stays false without
Expand All @@ -1513,10 +1518,12 @@ Missing/invalid/expired bearer → **Response** `401`:
Missing required fields → **Response** `409`:

```json
{ "error": "missing_requirements", "missing": ["rules", "name"] }
{ "error": "missing_requirements", "missing": ["rules", "name", "lightning-address"] }
```

(`missing` is never empty; order is `rules`, then `name`.)
(`missing` is never empty; order is `rules`, then `name`, then
`lightning-address`. A named, rules-agreed account with null LN yields
`["lightning-address"]` only.)

Body is not JSON with `text` and/or `photo` → **Response** `400`:

Expand Down
8 changes: 4 additions & 4 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,8 @@

## Endpoint: POST /messages

- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name; Lightning Address is not required). JSON `{ text?, photo?: { contentType, data }, inReplyTo? }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates may enqueue push; replies do not.
- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules and/or name are missing (order `rules`, then `name`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`).
- **Purpose:** Bearer required. After auth, `requireAction(account, 'forum.post')` (needs rules + name + Lightning Address; skip timestamps do not satisfy). JSON `{ text?, photo?: { contentType, data }, inReplyTo? }` (base64 JPEG/PNG/WebP ≤ 1 MiB) or `multipart/form-data` with `text`, `video` (MP4/WebM/MOV ≤ 32 MiB), and optional JPEG/PNG/WebP `poster`. Optional `inReplyTo` is a **top-level** parent message UUID (sets `parentId` for a one-level NIP-10 reply; JSON only). Text-only stays valid; photo-only or video-only allowed; at least one of non-empty trimmed text, photo, or video required. Name snapshot. 200 is the public message including `sats`, `payable`, `hasPhoto`, `hasVideo`, `videoContentType`, the session account's live `role`, and `accountId` (not wrapped). New notes have `sats` 0 and `payable` false until signed (and stay `payable` false without author LN). Top-level creates may enqueue push; replies do not.
- **Errors:** 401 Unauthorized; 409 `{ error: 'missing_requirements', missing: [...] }` when rules, name, and/or Lightning Address are missing (order `rules`, then `name`, then `lightning-address`); 400 Expected a JSON body with text and/or photo; 400 Text must be 1–500 characters; 400 Text must be 1–500 characters or include a photo; 400 Text must be 1–500 characters or include a photo or video; 400 Photo must be a JPEG, PNG, or WebP under 1 MiB; 400 Poster must be a JPEG, PNG, or WebP under 1 MiB; 400 Video must be an MP4, WebM, or MOV under 32 MiB; 404 `{ error: 'Not found' }` when `inReplyTo` is present but not a UUID, the parent is missing, or the parent is itself a reply (`parentId !== null`); 429 Too many messages (`Retry-After: 10`); 503 Messages are unavailable (`messages.create.failed`).
- **Used by:** App forum composer and reply composer.
- **Auth:** `Authorization: Bearer` session.

Expand Down Expand Up @@ -359,7 +359,7 @@

## Endpoint: POST /me/lightning-address

- **Purpose:** Body `{ address }`. Live-resolves LUD-16 well-known metadata, requires zap support (`allowsNostr` + non-empty `nostrPubkey`), then runs a NIP-57 mint probe (`probeNip57Mint` with the account's custodial key). On `ok`, stores the address unverified on the account.
- **Purpose:** Body `{ address }`. Live-resolves LUD-16 well-known metadata, requires zap support (`allowsNostr` + non-empty `nostrPubkey`), then runs a NIP-57 mint probe (`probeNip57Mint` with the account's custodial key). On `ok`, stores the address unverified on the account, then runs `ensureProfileMessage` so a non-blank display name already set gets its profile forum note.
- **Errors:** 401 Unauthorized; 400 Expected a JSON body with an "address" string; 400 Not a valid Lightning Address (expected name@domain); 400 Lightning Address could not be resolved (unreachable well-known / missing zap metadata / unreachable probe; account unchanged); 400 `{ error: LIGHTNING_ADDRESS_NOT_ZAP }` when the mint probe returns `not_zap` (account unchanged); 503 `{ error: 'Lightning Address could not be resolved' }` when `NOSTR_NSEC_KEK` / `nostrKek` is missing or key ensure fails; 409 Lightning Address is already in use (another account owns it, including a unique-index race).
- **Used by:** App `setLightningAddress`.
- **Auth:** See Purpose — Bearer where stated, else public.
Expand Down Expand Up @@ -387,7 +387,7 @@

## Endpoint: POST /me/name

- **Purpose:** Bearer required. Body `{ name }`. Stores the trimmed display name on the account (1–80 characters, no C0/DEL control characters). The first persisted non-empty name also creates exactly one top-level profile forum note (`ensureProfileMessage`) and stores `profileMessageId` (not exposed on owner JSON). Rename does not create a second note and does not change the note text.
- **Purpose:** Bearer required. Body `{ name }`. Stores the trimmed display name on the account (1–80 characters, no C0/DEL control characters). When a non-blank Lightning Address is already linked, the first persisted non-empty name also creates exactly one top-level profile forum note (`ensureProfileMessage`) and stores `profileMessageId` (not exposed on owner JSON); without LN the name is stored and no note is inserted. Rename does not create a second note and does not change the note text.
- **Errors:** 401 without session; 400 if the body is not `{ name: string }` or the name fails validation.
- **Used by:** App `setName`.
- **Auth:** See Purpose — Bearer where stated, else public.
Expand Down
Loading
Loading