Skip to content
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ api/
│ │ ├── pictures.ts # GET/PUT /pictures/me; public GET /pictures/:accountId.jpg|.png|.webp (round profile photo; not the About me photo)
│ │ ├── banner.ts # GET/PUT /banners/me; public GET /banners/:accountId.jpg|.png|.webp (wide image; not the About me photo)
│ │ ├── members.ts # GET /members/:accountId (Bearer; live identity + profile note + counts + trust + fundingReviewedAt); GET /members/:accountId/activity; GET /members/:accountId/posts; GET /members/:accountId/replies
│ │ ├── mentions.ts # GET /mentions (Bearer, forum.read; username prefix suggestions, at most 20)
│ │ ├── links.ts # GET /links/:code (public; 8-hex prefix of a message or account id)
│ │ ├── view.ts # GET /view/:viewKey (public profile card); GET /view/:viewKey/about/photo; GET /view/:viewKey/activity
│ │ ├── lightning-address.ts # GET /lightning-address (public LUD-16 resolve)
Expand Down Expand Up @@ -78,6 +79,7 @@ api/
│ │ ├── location.ts # Profile location trim/validate (C0/DEL; empty clears)
│ │ ├── message.ts # Forum text/photo/video validate + public JSON (hasPhoto/hasVideo; no bytes)
│ │ ├── mention.ts # @username marks stored on a note at send time
│ │ ├── mention-query.ts # normalised @ prefix for GET /mentions (does not store marks)
│ │ ├── video.ts # Forum video magic-bytes, faststart, MEDIA_DIR, Range parse
│ │ ├── ocp-place.ts # First shop pin posted once to the OpenCryptoPay map
│ │ ├── nip05.ts # NIP-05 slugs, nostr.json names, kind:0 identifier
Expand Down Expand Up @@ -198,6 +200,7 @@ api/
│ │ ├── gift-store.test.ts
│ │ ├── message.test.ts
│ │ ├── mention.test.ts
│ │ ├── mention-query.test.ts
│ │ ├── mention-notify.test.ts
│ │ ├── funding-reviewed-by-name.test.ts
│ │ ├── push-mention.test.ts
Expand Down Expand Up @@ -264,6 +267,7 @@ api/
│ ├── me-about.test.ts
│ ├── activity.test.ts
│ ├── members.test.ts
│ ├── mentions.test.ts
│ ├── links.test.ts
│ ├── lightning-address.test.ts
│ ├── debug.test.ts
Expand Down
24 changes: 23 additions & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
> Product decisions live in [`CONCEPT.md`](./CONCEPT.md); this file owns
> request/response contracts for routes that exist in code today.

**Status**: living document. Last revised 2026-09-24 (`POST /conversations/:id/messages/:messageId/translate`; owner and view JSON include `aboutMessageId`; conversation rows include `lastMessageId`. 2026-09-23: `eligibleToday` does not require a grant until UTC 2026-09-30; funding-program grants independent of `account.role`; spend ping and `POST /invoices` require `eligibleToday`; verified top-level media also welcome-pings independent of `eligibleToday`; `GET /invoices/eligible`; `GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming edge per subject: the oldest eligible sibling (`createdAt` then `id`), skipping a non-chain oldest sibling so a later displayable contact can show; eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` and `moderator_reject` never; later appoint/confirm/propose do not replace the first eligible contact; staff may reject an open proposal (`POST /trust/reject-moderator`, append-only `moderator_reject`, role stays `verified`) and re-propose after reject (new `moderator_propose`; 409 while currently pending, any confirm/appoint, or a concurrent older open propose wins after insert); confirm/reject re-list after insert and undo when the other grant already closed; pending = latest propose/reject is propose, verified, no confirm/appoint; live-unique kinds are verify/confirm/appoint only; open proposal fans out in-app `moderator_proposal` plus Web Push to other staff until confirm, until reject when pending is then empty, or until appoint; GET `/notifications` keeps `moderator_appointed` and `moderator_proposal` (mark-read / read-all do not stamp the proposal); owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is unread among kept rows after the hidden filter (before the 200 cap), not `store.unreadCount()` and not the unfiltered matching unread of the newest 1000); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist; GET /messages omits name-copy profile notes and About me text stays).
**Status**: living document. Last revised 2026-09-28 (`GET /mentions` username prefix suggestions. GET /mentions returns at most 20 username-prefix suggestions for a signed-in forum reader. 2026-09-24: `POST /conversations/:id/messages/:messageId/translate`; owner and view JSON include `aboutMessageId`; conversation rows include `lastMessageId`. 2026-09-23: `eligibleToday` does not require a grant until UTC 2026-09-30; funding-program grants independent of `account.role`; spend ping and `POST /invoices` require `eligibleToday`; verified top-level media also welcome-pings independent of `eligibleToday`; `GET /invoices/eligible`; `GET /conversations` list/open rows include per-row `unreadMessageCount`; envelope `unreadCount` remains unread thread count; `GET /trust-chain` requires a member Bearer session; public graph uses at most one incoming edge per subject: the oldest eligible sibling (`createdAt` then `id`), skipping a non-chain oldest sibling so a later displayable contact can show; eligible `verify`, `moderator_appoint`, and `moderator_propose` only when the subject is a moderator; `moderator_confirm` and `moderator_reject` never; later appoint/confirm/propose do not replace the first eligible contact; staff may reject an open proposal (`POST /trust/reject-moderator`, append-only `moderator_reject`, role stays `verified`) and re-propose after reject (new `moderator_propose`; 409 while currently pending, any confirm/appoint, or a concurrent older open propose wins after insert); confirm/reject re-list after insert and undo when the other grant already closed; pending = latest propose/reject is propose, verified, no confirm/appoint; live-unique kinds are verify/confirm/appoint only; open proposal fans out in-app `moderator_proposal` plus Web Push to other staff until confirm, until reject when pending is then empty, or until appoint; GET `/notifications` keeps `moderator_appointed` and `moderator_proposal` (mark-read / read-all do not stamp the proposal); owner `notificationLevel` on GET `/me` and `POST /me/notification-level`; fan-out filters in-app and Web Push by `all` / `active` / `mentions`; GET `/notifications` applies the same filter to stored rows (`moderator_appointed` always stays; `unreadCount` is unread among kept rows after the hidden filter (before the 200 cap), not `store.unreadCount()` and not the unfiltered matching unread of the newest 1000); a zap that inserts a gift-reply fans out only `notifyZap`, not a second `forum_reply`; gift-reply row still lands in the thread; confirm/appoint notify the subject only with `moderator_appointed` and Web Push url `/welcome`; official platform account (`isPlatform`) never fans out living-room `forum_post` / `forum_reply` / `zap`; house daily gift-replies still persist; GET /messages omits name-copy profile notes and About me text stays).

---

Expand Down Expand Up @@ -127,6 +127,7 @@ Public base URLs used in examples:
| GET | `/members/:accountId/activity` | Bearer | Same given/received payload as `/me/activity` for that member |
| GET | `/members/:accountId/posts` | Bearer | Live member top-level notes (latest 200) |
| GET | `/members/:accountId/replies` | Bearer | Live member replies (latest 200) |
| GET | `/mentions` | Bearer | Username prefix suggestions (`q` empty = first 20 alphabetical; otherwise starts-with). Does not store `@` marks |
| GET | `/trust-chain` | Bearer | Founder seeds (empty edges); `?around=<id>` one hop of stored public edges |
| POST | `/trust/verify` | Bearer (moderator+) | Staff: confirm a person in real life (`verified`) |
| POST | `/trust/propose-moderator` | Bearer (moderator+) | Staff: propose a verified member as moderator |
Expand Down Expand Up @@ -893,6 +894,27 @@ same JSON as `GET /me/activity` for **that** member. 503 `{ "error": "Gift
stats are unavailable" }` when the gift store throws or a gift day lacks
BTC-USD.

### `GET /mentions`

Signed-in username prefix suggestions. Bearer session with `forum.read`.
Missing or invalid Bearer → **Response** `401` `{ "error": "Unauthorized" }`.
`forum.read` not yet allowed → **Response** `409` `{ "error": "missing_requirements", "missing": [...] }`.

Query `q` is optional. Omitted, empty, or whitespace, including a lone `@`
after trim, is the first page. Otherwise trim, strip one leading `@`, and
lowercase. The result must match `^[a-z0-9][a-z0-9._-]{0,31}$`. Anything else
→ **Response** `400` `{ "error": "Invalid query" }`.

**Response** `200`:

```json
{ "accounts": [{ "id": "…", "username": "ada", "name": "Ada" }] }
```

At most 20 rows, ordered by `lower(trim(username))` then `id`. Blank
usernames are skipped. `name` is the trimmed display name, or the stored
username when that name is blank. Does not store `@username` marks.

### `GET /trust-chain`

Stored trust graph. Bearer session required (any role, including basis).
Expand Down
7 changes: 7 additions & 0 deletions docs/handbook/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
- **Used by:** Lightning wallets paying `username@21.gifts`; app proxies this from the site apex.
- **Auth:** none.

## Endpoint: GET /mentions

- **Purpose:** Signed-in username prefix suggestions for `@` in a forum post or reply. Query `q` is optional. Omitted, empty, whitespace, or a lone `@` after trim is the first 20 handles in alphabetical order. Otherwise the value is trimmed, one leading `@` is stripped, and the rest is lowercased. That remainder must be a username prefix (`^[a-z0-9][a-z0-9._-]{0,31}$`). Each row is `{ id, username, name }` where `name` is the trimmed display name, or the stored username when that name is blank. At most 20 rows. Does not parse or store `@username` marks on a note.
- **Errors:** 401 `{ error: 'Unauthorized' }` without a usable bearer session; 409 `{ error: 'missing_requirements', missing }` when `forum.read` is not allowed yet; 400 `{ error: 'Invalid query' }` when the remainder after trim and one leading `@` is not a username prefix.
- **Used by:** The forum composer suggestion list (`GET /forum/mentions` on the app, which proxies here).
- **Auth:** Bearer session with `forum.read`.

## Endpoint: GET /pay/:username

- **Purpose:** Public, unauthenticated pay-link card for a member. Normalises `:username`, loads the account, and returns `name`, `username`, `minSats`, `maxSats`, and `charge` from the linked Lightning Address LNURL-pay metadata. `name` is the trimmed display name, or the normalised username when the display name is blank. No charge → `charge: null` and the wallet sat range. Unexpired pending → both bounds equal that amount and `charge` is `{ amountSats, expiresAt }` only. A bad wallet window is still 502 before any pin. Does not return the callback, the Lightning Address, or provider metadata. No spend token.
Expand Down
Loading
Loading