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: 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