diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84811217..31ee0954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/SPEC.md b/SPEC.md index a929e2e7..b8588fda 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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). --- @@ -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=` 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 | @@ -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). diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index e73a8088..fe2532db 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -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. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 630dc6fe..79db2652 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -385,17 +385,16 @@ ## Function: InMemoryAuthStore -- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey` / `listStaffAccountIds`). `listStaffAccountIds` returns ids at the moderator rank or above (`verified` and `basis` omitted, order unspecified). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `getAccountByLightningAddress` scans for a `lower(trim)` match and skips null addresses. `getAccountByPubkey` scans `#nostrKeys` for a case-insensitive hex match (`trim`; empty → `undefined`). `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase form starts with the prefix (prefix not trimmed). `updateAccountNameByLightningAddress` mutates only `name` on the matched account (`lower(trim)`); other fields stay unchanged; unknown address → `undefined`. `markWalletBackupSeen(accountId, now)` sets only `walletBackupSeenAt` when it is still null (other columns unchanged; unknown id → `undefined`; returns `{ account, wrote }` with `wrote` true only on that first write). `setAccountLocale` and `setAccountFiat` write only that field; `onlyIfUnset` returns the stored row with `wrote` false when it is already set, and writes when it is null or omitted; unknown id → `undefined`. `accountHasPasskey` is true when any credential maps to the account id. `getPasskeyCredentialForAccount` returns the newest credential for the account (`createdAt` desc, then `credentialId` desc) or `undefined`. `addSeedPasskeyCredential` inserts a second credential, sets `walletRequired` true, and does not delete or change `walletBackupSeenAt`. `replacePasskeyCredential` deletes the current row and inserts the new one (false when none exists or the new id belongs to another account). `createAccount` is a no-op when `viewKey` is already stored, a non-null `linkingKey` already exists, or `lightningAddress` (`lower(trim)`) belongs to another id. `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey`, non-null `linkingKey`, or `lightningAddress` owned by another id; it never writes `sessionRefused`, `walletRequired`, `walletBackupSeenAt`, `locale`, or `fiat` from the incoming object (the stored values stay). `tryCreateSession` writes only when the account exists and `sessionRefused` is not true. `setSessionRefused` writes only that flag. `claimProfileMessageId(accountId, expectedId, nextId)` sets only `profileMessageId` when `(stored ?? null) === (expectedId ?? null)` (`undefined` and `null` both match `expectedId === null`); does not touch viewKey/linkingKey indexes; returns true on win, false on unknown id or mismatch. `createAccount` / `updateAccount` with `isPlatform: true` call `#clearPlatformExcept` so every other account's `isPlatform` is false (at most one platform account). `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. `listNostrKeys` returns one row per stored account. Missing `#nostrKeys` entry → `pubkey` null, empty ciphertext, `kekId` 1, custody `custodial`, `createdAt` null. `insertPasskeyRenewAttempt` pushes one row and does not change the account. It stores `redactPasskeyRenewField` on `errorName` (80), `errorCode` (80), `message` (500), and `userAgent` (300), with `acknowledgedAt` null, and `sanitizePasskeyRenewDebug` on the public authenticator facts (invalid values become null). `latestUnacknowledgedPasskeyRenewErrorName` is that account's newest `outcome === 'failed'` and `acknowledgedAt === null` row's `errorName` (`createdAt` desc, then `id` desc), or null. `acknowledgePasskeyRenewFailures` sets `acknowledgedAt` only where `accountId` matches, `outcome === 'failed'`, and `acknowledgedAt === null`. `hasUnacknowledgedPasskeyRenewFailure` is true when a matching failed row still has `acknowledgedAt === null`. `hasAcknowledgedPasskeyRenewFailure` is true when a matching failed row has `acknowledgedAt !== null`. +- **Purpose:** Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (`getNostrPublicKey` / `getNostrSecret` / `setNostrKeyIfAbsent` / `listAccountIdsWithoutNostrKey` / `listStaffAccountIds`). `listStaffAccountIds` returns ids at the moderator rank or above (`verified` and `basis` omitted, order unspecified). Evicts expired challenges/sessions on write. Indexes `linkingKey` only when non-null. Maintains an O(1) `viewKey` index; `getAccountByViewKey` looks it up. `getAccountByLightningAddress` scans for a `lower(trim)` match and skips null addresses. `getAccountByPubkey` scans `#nostrKeys` for a case-insensitive hex match (`trim`; empty → `undefined`). `listIdsByPrefix(prefix)` returns at most two stored ids whose lowercase form starts with the prefix (prefix not trimmed). `listAccountsByUsernamePrefix` keeps accounts whose stored username starts with an already-normalised prefix (empty prefix means every non-blank username), orders by `lower(trim(username))` then `id`, and returns at most `limit` rows. `name` is the trimmed display name, or the stored username when that name is blank. `updateAccountNameByLightningAddress` mutates only `name` on the matched account (`lower(trim)`); other fields stay unchanged; unknown address → `undefined`. `markWalletBackupSeen(accountId, now)` sets only `walletBackupSeenAt` when it is still null (other columns unchanged; unknown id → `undefined`; returns `{ account, wrote }` with `wrote` true only on that first write). `setAccountLocale` and `setAccountFiat` write only that field; `onlyIfUnset` returns the stored row with `wrote` false when it is already set, and writes when it is null or omitted; unknown id → `undefined`. `accountHasPasskey` is true when any credential maps to the account id. `getPasskeyCredentialForAccount` returns the newest credential for the account (`createdAt` desc, then `credentialId` desc) or `undefined`. `addSeedPasskeyCredential` inserts a second credential, sets `walletRequired` true, and does not delete or change `walletBackupSeenAt`. `replacePasskeyCredential` deletes the current row and inserts the new one (false when none exists or the new id belongs to another account). `createAccount` is a no-op when `viewKey` is already stored, a non-null `linkingKey` already exists, or `lightningAddress` (`lower(trim)`) belongs to another id. `updateAccount` reindexes `viewKey` when it changes and refuses a `viewKey`, non-null `linkingKey`, or `lightningAddress` owned by another id; it never writes `sessionRefused`, `walletRequired`, `walletBackupSeenAt`, `locale`, or `fiat` from the incoming object (the stored values stay). `tryCreateSession` writes only when the account exists and `sessionRefused` is not true. `setSessionRefused` writes only that flag. `claimProfileMessageId(accountId, expectedId, nextId)` sets only `profileMessageId` when `(stored ?? null) === (expectedId ?? null)` (`undefined` and `null` both match `expectedId === null`); does not touch viewKey/linkingKey indexes; returns true on win, false on unknown id or mismatch. `createAccount` / `updateAccount` with `isPlatform: true` call `#clearPlatformExcept` so every other account's `isPlatform` is false (at most one platform account). `deleteAccount` drops the row and its linking-key and viewKey indexes. `listAccounts` returns every account oldest-first. `listNostrKeys` returns one row per stored account. Missing `#nostrKeys` entry → `pubkey` null, empty ciphertext, `kekId` 1, custody `custodial`, `createdAt` null. `insertPasskeyRenewAttempt` pushes one row and does not change the account. It stores `redactPasskeyRenewField` on `errorName` (80), `errorCode` (80), `message` (500), and `userAgent` (300), with `acknowledgedAt` null, and `sanitizePasskeyRenewDebug` on the public authenticator facts (invalid values become null). `latestUnacknowledgedPasskeyRenewErrorName` is that account's newest `outcome === 'failed'` and `acknowledgedAt === null` row's `errorName` (`createdAt` desc, then `id` desc), or null. `acknowledgePasskeyRenewFailures` sets `acknowledgedAt` only where `accountId` matches, `outcome === 'failed'`, and `acknowledgedAt === null`. `hasUnacknowledgedPasskeyRenewFailure` is true when a matching failed row still has `acknowledgedAt === null`. `hasAcknowledgedPasskeyRenewFailure` is true when a matching failed row has `acknowledgedAt !== null`. - **Inputs:** Constructor none. Methods take domain objects (`PasskeyChallenge`, `PasskeyCredential`, `Account`, `Session`, `AddressVerification`). `createAccount` is a no-op when a non-null `linkingKey` already exists, when `viewKey` is already stored, or when `lightningAddress` (`lower(trim)`) is taken. `updateAccount` refuses a `linkingKey` / `viewKey` / `lightningAddress` owned by another account and keeps the viewKey index consistent. `claimProfileMessageId(accountId, expectedId, nextId)` sets only `profileMessageId` when `(stored ?? null) === (expectedId ?? null)` (`undefined` and `null` both match `expectedId === null`); does not touch viewKey/linkingKey indexes; returns true on win, false on unknown id or mismatch. `updateAccountNameByLightningAddress(lightningAddress, name)` takes the address and new display name. `deleteAccount` drops the row and its linking-key and viewKey indexes. `createPasskeyCredential` returns false when this account already has a credential or the id is taken. `createFirstPasskeyCredential` returns false when this account already has a credential, the id is taken, the account is missing, or `sessionRefused`; on success it stores the credential and sets `walletRequired: true` in the same method. `updatePasskeyCredential` returns false unless `(newCount === 0 && stored === 0)` or `newCount > stored`; missing id is false; does not rebind `accountId` / `publicKey`. `updatePasskeyChallenge` returns false when the row is missing or already consumed. - **Returns / side effects:** Lookups return the object or `undefined`. Writes resolve when persisted. `listAccounts` returns `Account[]`. Operator dump lists: `listPasskeyCredentials`, `listSessions`, `listPasskeyChallenges`, `listAddressVerifications`, `listNostrKeys`. - **Used by:** `createApp` default store; all auth/me/debug/view routes. ## Function: PostgresAuthStore -- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `listNostrKeys` is `SELECT id, nostr_pubkey, nostr_nsec_ciphertext, nostr_kek_id, nostr_key_custody, nostr_key_created_at FROM account` with **no** `WHERE nostr_pubkey IS NOT NULL`; maps null pubkey; `kekId` `?? 1`; custody ternary `'user'` else `'custodial'`. `getAccountByViewKey` is `WHERE view_key = $1`. `getAccountByLightningAddress` is `WHERE lower(trim(lightning_address)) = lower(trim($1))` (null addresses do not match). `getAccountByPubkey` is `WHERE lower(nostr_pubkey) = lower(trim($1))`. `listIdsByPrefix` is `SELECT id::text AS id FROM account WHERE lower(id::text) LIKE $1 || '%' LIMIT 2` (prefix lowercased, not trimmed; at most two ids). `listStaffAccountIds` is `SELECT id FROM account WHERE role IN ('founder', 'moderator', 'initiator')`. `updateAccountNameByLightningAddress` is `UPDATE account SET name = $2 WHERE lower(trim(lightning_address)) = lower(trim($1)) RETURNING …` (other columns unchanged; empty `RETURNING` → `undefined`). `markWalletBackupSeen` is `UPDATE account SET wallet_backup_seen_at = to_timestamp($2::double precision / 1000.0) WHERE id = $1 AND wallet_backup_seen_at IS NULL RETURNING …`; empty `RETURNING` then `SELECT … WHERE id = $1`; returns `{ account, wrote }` or `undefined` (unknown id or unmappable `view_key`). `setAccountLocale` is `UPDATE account SET locale = $2 WHERE id = $1` plus `AND locale IS NULL` when `onlyIfUnset` is true, then the same empty-`RETURNING` `SELECT`; `setAccountFiat` is the same for `fiat`. Both return `{ account, wrote }` or `undefined`. `accountHasPasskey` is `SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, `getAccountByPubkey`, `updateAccountNameByLightningAddress`, `accountHasPasskey`, `getPasskeyCredentialForAccount` (`ORDER BY created_at DESC, credential_id COLLATE "C" DESC LIMIT 1`), `markWalletBackupSeen` then `replacePasskeyCredential`, `claimProfileMessageId`, `tryCreateSession`, and `setSessionRefused`. `replacePasskeyCredential` is one statement: `WITH deleted AS (DELETE … WHERE account_id = $4 RETURNING credential_id) INSERT … SELECT … WHERE EXISTS (SELECT 1 FROM deleted) RETURNING credential_id`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` / `getAccountByLightningAddress` / `getAccountByPubkey` / `updateAccountNameByLightningAddress` return undefined; `listAccounts` omits those rows) and sets `isPlatform` true only when `is_platform` is true. Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createPasskeyCredential` inserts only when that account has no credential yet (`WHERE NOT EXISTS`) and `ON CONFLICT (credential_id) DO NOTHING`; a duplicate credential id does not raise `23505`. There is no unique index on `account_id`. `createFirstPasskeyCredential` locks the account row (`FOR UPDATE` where `session_refused IS NOT TRUE` and `wallet_required IS NOT TRUE`, so a waiter sees the first claim's flag and inserts nothing), inserts only when that account has no credential (`WHERE NOT EXISTS`), then `UPDATE account SET wallet_required = TRUE` from the inserted row; empty `RETURNING` or unique_violation is false. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `claimProfileMessageId` is `UPDATE account SET profile_message_id = $3 WHERE id = $1 AND profile_message_id IS NOT DISTINCT FROM $2 RETURNING id` via `query` (not `execute`); true iff a row is returned. `updateAccount` writes the other columns including `amount_unit` (`$18`) and does not set `session_refused`, `wallet_required`, `wallet_backup_seen_at`, `locale`, or `fiat`. `tryCreateSession` is `INSERT … SELECT … WHERE session_refused IS NOT TRUE RETURNING token`. `setSessionRefused` is `UPDATE account SET session_refused = $2 WHERE id = $1 RETURNING …`. Before `createAccount` / `updateAccount` when `isPlatform === true`, `UPDATE account SET is_platform = false WHERE is_platform AND id <> $1` so at most one platform account remains (partial unique `account_is_platform_uidx`). INSERT/UPDATE write `is_platform`. `deleteAccount` is `DELETE FROM account WHERE id = $1`. Unique index on `lower(trim(lightning_address))` where the address is not null. `insertPasskeyRenewAttempt` calls `sanitizePasskeyRenewDebug`, then `INSERT INTO passkey_renew_attempt` with `$1`–`$10` (id, account, created_at, stage, outcome, error name, error code, http status, message, user agent) and `$11`–`$22` (`authenticator_attachment`, `transports`, `aaguid`, `prf_enabled`, `prf_present`, `extensions`, `authenticator_flags`, `public_key_algorithm`, `resident_key`, `hmac_secret`, `cred_protect`, `client_capabilities`) after `redactPasskeyRenewField` on error name (80), error code (80), message (500), and user agent (300). It does not update `account`. `acknowledgePasskeyRenewFailures` is `UPDATE passkey_renew_attempt SET acknowledged_at = to_timestamp($2::double precision / 1000.0) WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL`. `hasUnacknowledgedPasskeyRenewFailure` is `SELECT 1 AS exists FROM passkey_renew_attempt WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL LIMIT 1`. `hasAcknowledgedPasskeyRenewFailure` is the same select with `acknowledged_at IS NOT NULL`. `latestUnacknowledgedPasskeyRenewErrorName` is `SELECT error_name … WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 1`, or null when no row. +- **Purpose:** Durable AuthStore over Postgres (`SqlClient`). Same eviction-on-write semantics as the in-memory adapter, including passkey challenges, credentials, custodial Nostr key columns, and the `view_key` column. `listNostrKeys` is `SELECT id, nostr_pubkey, nostr_nsec_ciphertext, nostr_kek_id, nostr_key_custody, nostr_key_created_at FROM account` with **no** `WHERE nostr_pubkey IS NOT NULL`; maps null pubkey; `kekId` `?? 1`; custody ternary `'user'` else `'custodial'`. `getAccountByViewKey` is `WHERE view_key = $1`. `getAccountByLightningAddress` is `WHERE lower(trim(lightning_address)) = lower(trim($1))` (null addresses do not match). `getAccountByPubkey` is `WHERE lower(nostr_pubkey) = lower(trim($1))`. `listIdsByPrefix` is `SELECT id::text AS id FROM account WHERE lower(id::text) LIKE $1 || '%' LIMIT 2` (prefix lowercased, not trimmed; at most two ids). `listAccountsByUsernamePrefix` matches `lower(trim(username)) LIKE $1 ESCAPE '\'` after escaping `\`, `%`, and `_` in the prefix, orders by that expression then `id::text`, and returns at most `limit` rows. A blank display name falls back to the stored username. `listStaffAccountIds` is `SELECT id FROM account WHERE role IN ('founder', 'moderator', 'initiator')`. `updateAccountNameByLightningAddress` is `UPDATE account SET name = $2 WHERE lower(trim(lightning_address)) = lower(trim($1)) RETURNING …` (other columns unchanged; empty `RETURNING` → `undefined`). `markWalletBackupSeen` is `UPDATE account SET wallet_backup_seen_at = to_timestamp($2::double precision / 1000.0) WHERE id = $1 AND wallet_backup_seen_at IS NULL RETURNING …`; empty `RETURNING` then `SELECT … WHERE id = $1`; returns `{ account, wrote }` or `undefined` (unknown id or unmappable `view_key`). `setAccountLocale` is `UPDATE account SET locale = $2 WHERE id = $1` plus `AND locale IS NULL` when `onlyIfUnset` is true, then the same empty-`RETURNING` `SELECT`; `setAccountFiat` is the same for `fiat`. Both return `{ account, wrote }` or `undefined`. `accountHasPasskey` is `SELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, `getAccountByPubkey`, `updateAccountNameByLightningAddress`, `accountHasPasskey`, `getPasskeyCredentialForAccount` (`ORDER BY created_at DESC, credential_id COLLATE "C" DESC LIMIT 1`), `markWalletBackupSeen` then `replacePasskeyCredential`, `claimProfileMessageId`, `tryCreateSession`, and `setSessionRefused`. `replacePasskeyCredential` is one statement: `WITH deleted AS (DELETE … WHERE account_id = $4 RETURNING credential_id) INSERT … SELECT … WHERE EXISTS (SELECT 1 FROM deleted) RETURNING credential_id`. `mapAccount` skips null `view_key` (`getAccount` / `getAccountByViewKey` / `getAccountByLightningAddress` / `getAccountByPubkey` / `updateAccountNameByLightningAddress` return undefined; `listAccounts` omits those rows) and sets `isPlatform` true only when `is_platform` is true. Passkey `signCount` advances with an atomic `WHERE` (`0/0` or `new > stored`) `RETURNING`, not `GREATEST`; duplicate credential ids are `ON CONFLICT DO NOTHING`. `createPasskeyCredential` inserts only when that account has no credential yet (`WHERE NOT EXISTS`) and `ON CONFLICT (credential_id) DO NOTHING`; a duplicate credential id does not raise `23505`. There is no unique index on `account_id`. `createFirstPasskeyCredential` locks the account row (`FOR UPDATE` where `session_refused IS NOT TRUE` and `wallet_required IS NOT TRUE`, so a waiter sees the first claim's flag and inserts nothing), inserts only when that account has no credential (`WHERE NOT EXISTS`), then `UPDATE account SET wallet_required = TRUE` from the inserted row; empty `RETURNING` or unique_violation is false. `createAccount` INSERT unique_violation `23505` is a no-op. `updateAccount` refuses a `linkingKey` owned by another id (`UPDATE` matches no row; unique_violation `23505` is a no-op). `claimProfileMessageId` is `UPDATE account SET profile_message_id = $3 WHERE id = $1 AND profile_message_id IS NOT DISTINCT FROM $2 RETURNING id` via `query` (not `execute`); true iff a row is returned. `updateAccount` writes the other columns including `amount_unit` (`$18`) and does not set `session_refused`, `wallet_required`, `wallet_backup_seen_at`, `locale`, or `fiat`. `tryCreateSession` is `INSERT … SELECT … WHERE session_refused IS NOT TRUE RETURNING token`. `setSessionRefused` is `UPDATE account SET session_refused = $2 WHERE id = $1 RETURNING …`. Before `createAccount` / `updateAccount` when `isPlatform === true`, `UPDATE account SET is_platform = false WHERE is_platform AND id <> $1` so at most one platform account remains (partial unique `account_is_platform_uidx`). INSERT/UPDATE write `is_platform`. `deleteAccount` is `DELETE FROM account WHERE id = $1`. Unique index on `lower(trim(lightning_address))` where the address is not null. `insertPasskeyRenewAttempt` calls `sanitizePasskeyRenewDebug`, then `INSERT INTO passkey_renew_attempt` with `$1`–`$10` (id, account, created_at, stage, outcome, error name, error code, http status, message, user agent) and `$11`–`$22` (`authenticator_attachment`, `transports`, `aaguid`, `prf_enabled`, `prf_present`, `extensions`, `authenticator_flags`, `public_key_algorithm`, `resident_key`, `hmac_secret`, `cred_protect`, `client_capabilities`) after `redactPasskeyRenewField` on error name (80), error code (80), message (500), and user agent (300). It does not update `account`. `acknowledgePasskeyRenewFailures` is `UPDATE passkey_renew_attempt SET acknowledged_at = to_timestamp($2::double precision / 1000.0) WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL`. `hasUnacknowledgedPasskeyRenewFailure` is `SELECT 1 AS exists FROM passkey_renew_attempt WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL LIMIT 1`. `hasAcknowledgedPasskeyRenewFailure` is the same select with `acknowledged_at IS NOT NULL`. `latestUnacknowledgedPasskeyRenewErrorName` is `SELECT error_name … WHERE account_id = $1 AND outcome = 'failed' AND acknowledged_at IS NULL ORDER BY created_at DESC, id DESC LIMIT 1`, or null when no row. - **Inputs:** Constructor takes a `SqlClient`. Methods match `AuthStore` including `getAccountByViewKey`, `getAccountByLightningAddress`, `getAccountByPubkey`, `updateAccountNameByLightningAddress`, `accountHasPasskey`, `claimProfileMessageId`, `tryCreateSession`, `setSessionRefused`, and operator dump lists (`listPasskeyCredentials`, `listSessions`, `listPasskeyChallenges`, `listAddressVerifications`, `listNostrKeys`). `claimProfileMessageId` is `UPDATE account SET profile_message_id = $3 WHERE id = $1 AND profile_message_id IS NOT DISTINCT FROM $2 RETURNING id` via `query` (not `execute`); true iff a row is returned. `updateAccount` writes `amount_unit` (`$18`) and does not write `session_refused`, `wallet_required`, `wallet_backup_seen_at`, `locale`, or `fiat`. `setAccountLocale` and `setAccountFiat` are the only writers of those columns. - **Returns / side effects:** Parameter-bound SQL; maps snake_case rows to domain objects. - - **Used by:** `openAuthStore` when `DATABASE_URL` is set. ## Function: isUniqueViolation @@ -956,9 +955,9 @@ ## Function: createApp -- **Purpose:** Wires CORS (`allowHeaders` includes `Time-Zone`), requestLog, sundayRest, brand, health, info, auth, me, `/pictures`, `/banners`, `/view`, `/pay`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/diagnostics`, `/debug/diagnostics`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / reject-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice and `/messages/stats`), `GET /translate` (DeepL availability), `/members/:accountId`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/pos`, `/conversations`, `/notifications`, and invoices. +- **Purpose:** Wires CORS (`allowHeaders` includes `Time-Zone`), requestLog, sundayRest, brand, health, info, auth, me, `/pictures`, `/banners`, `/view`, `/pay`, lightning-address, `/debug/accounts`, `/debug/contacts`, `/debug/api-log`, `/diagnostics`, `/debug/diagnostics`, `/debug/db`, `/debug/external-pubkeys`, `/debug/messages`, `/debug/invoices`, `/debug/invoices/settle`, `/debug/zap-ingests`, `/debug/push-ping`, `/debug/trust-edges`, `/debug/dump`, `/trust-chain`, `/trust` (verify / propose-moderator / confirm-moderator / reject-moderator / appoint-moderator), `/funding` (apply / applications / trial / admit / reject), Web Push subscription routes, `/gifts`, `/gifts/stats`, `/messages` (incl. invoice and `/messages/stats`), `GET /translate` (DeepL availability), `/members/:accountId`, `GET /mentions`, `/.well-known` NIP-05 `nostr.json` (CORS `*`), `/contact`, `/pos`, `/conversations`, `/notifications`, and invoices. - **Inputs:** Optional `AppDeps` (store, clock, payer, fetch, cache, readBrand, origins, `debugToken`, giftStore, `giftRecorder`, `btcUsdRates`, `fiatRates`, `messageStore`, optional `translationStore` (default `InMemoryTranslationStore`; SQL boot injects `PostgresTranslationStore`), optional `conversationTranslationStore` (passed to `conversationRoutes.translationStore`; omitted so that factory constructs one `InMemoryTranslationStore`; SQL boot injects a second `PostgresTranslationStore` on `conversation_message_translation`, never the forum store), `contactStore`, optional `conversationStore` (default `InMemoryConversationStore`), optional `notificationStore` (default `InMemoryNotificationStore`), optional `apiLogStore` (default `InMemoryApiLogStore`), optional `diagnosticStore` (default `InMemoryDiagnosticStore`), optional `debugDbStore` (omitted on a memory boot; `GET /debug/db` then 503 after the token matches), `pushStore`, `trustStore`, optional `fundingStore` (default `InMemoryFundingStore`; also forwarded to `debugPaymentsRoutes`), optional `bannerStore` (default `InMemoryBannerStore`; SQL boot injects `PostgresBannerStore`; the About me photo is neither slot; mounted at `/pictures` and `/banners` and passed to the Nostr worker), optional `listDbChange`, `vapidPublicKey`, `nostrKek`, optional `nostrPublisher` (without `nostrKek` staff hide skips NIP-09), optional `env` (default `process.env`; relays / `PUBLIC_BASE_URL` / Cloudflare on `DELETE /messages/:id`; forwarded to `conversationRoutes`), spendApiToken, optional `mapPush` (default `resolveMapPush` on `env`, which stays off while `SHOP_PLACE_PUSH_ENABLED` is false even if both variables are set; a blank URL or token also sends nothing; the process still boots; forwarded to `messagesRoutes`), `spendPing` (default `resolveSpendPing(process.env, fetchImpl)`; unset/blank `SPEND_URL` or `SPEND_API_TOKEN` omits it; `POST /messages` still 200; daily/omitted kind body `{ address, messageId }`; `conversationRoutes` gets the same `spendPing`; moderator-group POST body `{ address, kind: "moderator", groupMessageId }` without `messageId`; forum `POST /messages` still two-arg daily ping; a verified account with any live top-level photo or video, including About me, also three-arg `'welcome'` even when the new row has no media; `spendPing` is also passed to `meRoutes` and, with `messages`, to `trustRoutes`; `fundingRoutes` receives the same optional `spendPing`), optional `postLimiter` (default a new `PostRateLimiter`; passed to `messagesRoutes`; boot shares one instance with the Nostr worker), invoiceStore, `webAuthnRpId`, `webAuthnRpName`, `passkeyCeremony`). `debugPaymentsRoutes` receives the same optional `spendPing`. Omitted `giftRecorder` → `invoiceRoutes` uses `NoopGiftRecorder`; omitted `messageStore` → `InMemoryMessageStore`; omitted `translationStore` → `InMemoryTranslationStore`; omitted `conversationTranslationStore` → `conversationRoutes` constructs one `InMemoryTranslationStore`; omitted `contactStore` → `InMemoryContactStore`; omitted `posStore` → `InMemoryPosStore`; omitted `conversationStore` → `InMemoryConversationStore`; omitted `notificationStore` → `InMemoryNotificationStore`; omitted `pushStore` → `InMemoryPushStore`; omitted `trustStore` → `InMemoryTrustStore`; omitted `fundingStore` → `InMemoryFundingStore`; omitted `apiLogStore` → `InMemoryApiLogStore`; omitted `diagnosticStore` → `InMemoryDiagnosticStore`; omitted/blank `vapidPublicKey` → push HTTP 503 after session; omitted `nostrKek` → unsigned forum + invoice 503; SQL boot injects `SqlGiftRecorder`, `PostgresMessageStore`, `PostgresTranslationStore`, a second `PostgresTranslationStore` on `conversation_message_translation`, `PostgresContactStore`, `PostgresPosStore`, `PostgresConversationStore`, `PostgresNotificationStore`, `PostgresPushStore`, `PostgresTrustStore`, `PostgresFundingStore`, `PostgresBannerStore`, `PostgresApiLogStore`, `PostgresDiagnosticStore`, `PostgresDebugDbStore`, and parsed KEK. `messagesRoutes`, `meRoutes`, `invoiceRoutes`, and `trustRoutes` receive `conversationStore`. `fundingRoutes`, `invoiceRoutes`, `messagesRoutes`, `conversationRoutes`, `meRoutes`, `membersRoutes`, and auth finish receive `fundingStore`. `contactRoutes` and `conversationRoutes` receive `pushStore` plus `notificationStore`. Mounts `notificationRoutes` at `/notifications`. Does not take a push sender (worker owns delivery). -- **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. +- **Returns / side effects:** Hono app. Default `btcUsdRates` is an empty `InMemoryBtcUsdStore`. Default `fiatRates` is an empty `InMemoryFiatStore`. `createApp` passes the same `fiatRates` object into `/gifts`, `/gifts/stats`, `/me`, `/members`, and `/view`, and the same `now` into `/mentions`. Used by Bun.serve in `index.ts` and by tests via `app.request()`. - **Used by:** Boot path and every HTTP test. ## Function: healthRoute @@ -2059,6 +2058,13 @@ - **Returns / side effects:** Hono app mounted at `/members`. Activity is 200 JSON or 503 `{ error: 'Gift stats are unavailable' }` on store throw or missing BTC-USD. Missing fiat never 503. Logs `members.get.failed`, `members.posts.failed`, `members.replies.failed`, or `account.activity.failed` on 503. Activity 503 logs `account.activity.failed` / `account.activity.fx_incomplete`. GET JSON includes `aboutMe` and `aboutMeHasPhoto`. - **Used by:** `createApp`. +## Function: mentionsRoutes + +- **Purpose:** Hono sub-app for `GET /mentions`. Bearer session plus `requireAction(forum.read)`. Optional `q` is a username prefix for `@` suggestions as soon as someone types `@` in a forum post. Empty `q` returns the first usernames. At most 20 rows, ordered by `lower(trim(username))` then `id`. Blank usernames are skipped. Each account is `{ id, username, name }`; `name` falls back to the stored username when the display name is null or blank. Does not change stored `@username` marks. +- **Inputs:** `{ auth: AuthStore, now: () => number }`. `createApp` passes its clock. Query `q` is optional. +- **Returns / side effects:** Hono app mounted at `/mentions`. 200 `{ accounts }` or 401 `{ error: 'Unauthorized' }` / 409 `missing_requirements` / 400 `{ error: 'Invalid query' }`. No logging. +- **Used by:** `createApp`. + ## Function: serializeViewProfile - **Purpose:** Public profile card for the capability URL. Ten fields (`name`, `username`, `location`, `lightningAddress`, `lightningAddressVerified`, `createdAt`, `hasPasskey`, `aboutMe`, `aboutMeHasPhoto`, `aboutMessageId`). `aboutMessageId` is the live profile-note id only when `aboutMe !== null`, else `null` (never `profileMessageId` under another name). Omits `id`, `linkingKey`, `role`, and `viewKey`. `username` is `string | null` (LUD-16 / NIP-05 local-part). `location` is `string | null` (never omitted, never `""`). @@ -2958,6 +2964,13 @@ Builds the operator-only external-pubkey inspection route. - **Returns / side effects:** `string[]`. No I/O. - **Used by:** `persistForumPost`. +## Function: mentionQueryPrefix + +- **Purpose:** Normalise the optional `q` on `GET /mentions` into a username prefix for `@` suggestions. Empty, whitespace, or a lone `@` become `""`. Otherwise trim, strip one leading `@`, and lowercase. Longer than 32 characters or outside `a-z0-9._-` with a leading letter or digit is `null` (the route maps that to 400 `{ error: 'Invalid query' }`). Does not store `@username` marks. +- **Inputs:** `string | undefined` query value. +- **Returns / side effects:** Prefix string, `""`, or `null`. No I/O. +- **Used by:** `mentionsRoutes`. + ## Function: notifyForumMentions - **Purpose:** One `forum_mention` notification per mentioned account except the author. Level `all` always, `active` only when `isActive`, `mentions` because the recipient is the mark. Push body uses that account's locale. Push url `/messages/`. diff --git a/e2e/functions.spec.ts b/e2e/functions.spec.ts index e1b329d9..4246f62a 100644 --- a/e2e/functions.spec.ts +++ b/e2e/functions.spec.ts @@ -253,6 +253,16 @@ test('Function: membersRoutes — GET /members/:accountId without bearer is 401' expect(res.status()).toBe(401); }); +test('Function: mentionQueryPrefix — GET /mentions without bearer is 401', async ({ request }) => { + const res = await request.get('/mentions'); + expect(res.status()).toBe(401); +}); + +test('Function: mentionsRoutes — GET /mentions without bearer is 401', async ({ request }) => { + const res = await request.get('/mentions'); + expect(res.status()).toBe(401); +}); + test('Function: requireAction — GET /messages without bearer is 401', async ({ request }) => { const res = await request.get('/messages'); expect(res.status()).toBe(401); diff --git a/src/__tests__/lib/auth/postgres-store.test.ts b/src/__tests__/lib/auth/postgres-store.test.ts index 8416570c..c52facd0 100644 --- a/src/__tests__/lib/auth/postgres-store.test.ts +++ b/src/__tests__/lib/auth/postgres-store.test.ts @@ -496,6 +496,58 @@ describe('PostgresAuthStore', () => { ).toBeUndefined(); }); + it('lists accounts by username prefix with escaped LIKE and mapped name', async () => { + const sql = new MockSql(); + sql.nextRows = [{ id: 'acc', username: 'A_b', name: ' Ada ' }]; + const store = new PostgresAuthStore(sql); + const found = await store.listAccountsByUsernamePrefix('a_b', 20); + const text = sql.queries[0]?.text ?? ''; + expect(found).toEqual([{ id: 'acc', username: 'A_b', name: 'Ada' }]); + expect(sql.queries[0]?.params).toEqual(['a\\_b%', 20]); + expect(text).toContain('LIKE'); + expect(text).toContain(`ESCAPE '\\'`); + expect(text).toContain('username IS NOT NULL'); + expect(text).toContain("trim(username) <> ''"); + expect(text).toContain('ORDER BY lower(trim(username)) ASC, id::text ASC'); + expect(text).toContain('LIMIT $2'); + expect(text).toContain('SELECT id::text AS id, username, name'); + expect(text).not.toMatch(/lightning/i); + expect(text).not.toMatch(/email/i); + expect(text).not.toMatch(/\brole\b/i); + expect(text).not.toMatch(/npub/i); + }); + + it('lists accounts by username prefix with an empty prefix as %', async () => { + const sql = new MockSql(); + sql.nextRows = []; + const store = new PostgresAuthStore(sql); + expect(await store.listAccountsByUsernamePrefix('', 20)).toEqual([]); + expect(sql.queries[0]?.params).toEqual(['%', 20]); + }); + + it('escapes % and backslash in the mention LIKE pattern', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.nextRows = []; + await store.listAccountsByUsernamePrefix('100%', 20); + expect(sql.queries[0]?.params[0]).toBe('100\\%%'); + await store.listAccountsByUsernamePrefix('a\\b', 20); + expect(sql.queries[1]?.params[0]).toBe('a\\\\b%'); + }); + + it('falls back to the stored username when name is null or blank', async () => { + const sql = new MockSql(); + const store = new PostgresAuthStore(sql); + sql.nextRows = [{ id: 'acc', username: 'cara', name: null }]; + expect(await store.listAccountsByUsernamePrefix('cara', 20)).toEqual([ + { id: 'acc', username: 'cara', name: 'cara' }, + ]); + sql.nextRows = [{ id: 'acc2', username: 'cara2', name: ' ' }]; + expect(await store.listAccountsByUsernamePrefix('cara', 20)).toEqual([ + { id: 'acc2', username: 'cara2', name: 'cara2' }, + ]); + }); + it('updateAccountNameByLightningAddress sets only name by lower(trim) address', async () => { const sql = new MockSql(); sql.nextRows = [ diff --git a/src/__tests__/lib/auth/store.test.ts b/src/__tests__/lib/auth/store.test.ts index 769538e0..404487f1 100644 --- a/src/__tests__/lib/auth/store.test.ts +++ b/src/__tests__/lib/auth/store.test.ts @@ -976,6 +976,197 @@ describe('InMemoryAuthStore', () => { expect(await store.getAccountByUsername('cara')).toBeUndefined(); }); + it('listAccountsByUsernamePrefix skips null, undefined, and blank usernames', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + name: 'Ada' as string | null, + location: null as string | null, + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'undef', + viewKey: '1'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'nully', + username: null, + viewKey: '2'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'blank', + username: ' ', + viewKey: '3'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'zed', + username: 'zed', + viewKey: '4'.repeat(64), + }); + expect(await store.listAccountsByUsernamePrefix('', 20)).toEqual([ + { id: 'zed', username: 'zed', name: 'Ada' }, + ]); + }); + + it('listAccountsByUsernamePrefix matches a prefix and misses others', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + name: 'Ada' as string | null, + location: null as string | null, + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'ada', + username: 'Ada', + viewKey: '1'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'bob', + username: 'bob', + viewKey: '2'.repeat(64), + }); + expect(await store.listAccountsByUsernamePrefix('ad', 20)).toEqual([ + { id: 'ada', username: 'Ada', name: 'Ada' }, + ]); + expect(await store.listAccountsByUsernamePrefix('zz', 20)).toEqual([]); + expect(await store.listAccountsByUsernamePrefix('', 20)).toEqual([ + { id: 'ada', username: 'Ada', name: 'Ada' }, + { id: 'bob', username: 'bob', name: 'Ada' }, + ]); + }); + + it('listAccountsByUsernamePrefix maps blank names and keeps a padded display name', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + location: null as string | null, + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'n', + name: null, + username: 'cara', + viewKey: '1'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'b', + name: ' ', + username: 'cara2', + viewKey: '2'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'p', + name: ' Ada Lovelace ', + username: 'Ada', + viewKey: '3'.repeat(64), + }); + expect(await store.listAccountsByUsernamePrefix('', 20)).toEqual([ + { id: 'p', username: 'Ada', name: 'Ada Lovelace' }, + { id: 'n', username: 'cara', name: 'cara' }, + { id: 'b', username: 'cara2', name: 'cara2' }, + ]); + }); + + it('listAccountsByUsernamePrefix orders by username then id and slices after sort', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + name: 'Ada' as string | null, + location: null as string | null, + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'a', + username: 'zed', + createdAt: 1, + viewKey: '1'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'z', + username: 'ada', + createdAt: 2, + viewKey: '2'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'm', + username: 'mia', + createdAt: 3, + viewKey: '3'.repeat(64), + }); + expect(await store.listAccountsByUsernamePrefix('', 20)).toEqual([ + { id: 'z', username: 'ada', name: 'Ada' }, + { id: 'm', username: 'mia', name: 'Ada' }, + { id: 'a', username: 'zed', name: 'Ada' }, + ]); + expect(await store.listAccountsByUsernamePrefix('', 2)).toEqual([ + { id: 'z', username: 'ada', name: 'Ada' }, + { id: 'm', username: 'mia', name: 'Ada' }, + ]); + }); + + it('listAccountsByUsernamePrefix treats underscore as a literal', async () => { + const store = new InMemoryAuthStore(); + const base = { + linkingKey: null as string | null, + role: 'basis' as const, + name: 'Ada' as string | null, + location: null as string | null, + lightningAddress: null as string | null, + lightningAddressVerified: false, + forumLawsDismissed: false, + createdAt: 1, + rulesAgreedAt: null as number | null, + }; + await store.createAccount({ + ...base, + id: 'axb', + username: 'axb', + viewKey: '1'.repeat(64), + }); + await store.createAccount({ + ...base, + id: 'a_b', + username: 'a_b', + viewKey: '2'.repeat(64), + }); + expect(await store.listAccountsByUsernamePrefix('a_', 20)).toEqual([ + { id: 'a_b', username: 'a_b', name: 'Ada' }, + ]); + }); + it('refuses createAccount and updateAccount when the lightningAddress is taken', async () => { const store = new InMemoryAuthStore(); const base = { diff --git a/src/__tests__/lib/mention-query.test.ts b/src/__tests__/lib/mention-query.test.ts new file mode 100644 index 00000000..a7a9b462 --- /dev/null +++ b/src/__tests__/lib/mention-query.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { mentionQueryPrefix } from '@/lib/mention-query'; + +describe('mentionQueryPrefix', () => { + it('returns an empty prefix for undefined, empty, and whitespace-only input', () => { + expect(mentionQueryPrefix(undefined)).toBe(''); + expect(mentionQueryPrefix('')).toBe(''); + expect(mentionQueryPrefix(' ')).toBe(''); + }); + + it('returns an empty prefix when the trimmed value is only @', () => { + expect(mentionQueryPrefix('@')).toBe(''); + expect(mentionQueryPrefix(' @ ')).toBe(''); + }); + + it('trims, strips one leading @, and lowercases a handle', () => { + expect(mentionQueryPrefix('@Ada')).toBe('ada'); + expect(mentionQueryPrefix(' @Ada ')).toBe('ada'); + expect(mentionQueryPrefix('Ada.B')).toBe('ada.b'); + expect(mentionQueryPrefix('a_b')).toBe('a_b'); + expect(mentionQueryPrefix('9names')).toBe('9names'); + expect(mentionQueryPrefix('A-b')).toBe('a-b'); + }); + + it('rejects a leading underscore, dot, hyphen, space, or leftover @', () => { + expect(mentionQueryPrefix('_ada')).toBeNull(); + expect(mentionQueryPrefix('.')).toBeNull(); + expect(mentionQueryPrefix('-ada')).toBeNull(); + expect(mentionQueryPrefix('ada bob')).toBeNull(); + expect(mentionQueryPrefix('@ Ada')).toBeNull(); + expect(mentionQueryPrefix('@@ada')).toBeNull(); + }); + + it('rejects 33 characters and accepts 32', () => { + expect(mentionQueryPrefix('a'.repeat(33))).toBeNull(); + expect(mentionQueryPrefix('a'.repeat(32))).toBe('a'.repeat(32)); + expect(mentionQueryPrefix(`@${'a'.repeat(32)}`)).toBe('a'.repeat(32)); + expect(mentionQueryPrefix(`@${'a'.repeat(33)}`)).toBeNull(); + }); +}); diff --git a/src/__tests__/routes/mentions.test.ts b/src/__tests__/routes/mentions.test.ts new file mode 100644 index 00000000..4db1dae5 --- /dev/null +++ b/src/__tests__/routes/mentions.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { SESSION_TTL_MS } from '@/lib/config'; +import { InMemoryAuthStore } from '@/lib/auth/store'; +import { mentionsRoutes } from '@/routes/mentions'; + +const AUTH = { authorization: 'Bearer tok' }; +const FROZEN = 1_000_000; +const frozenNow = (): number => FROZEN; + +function mount(auth: InMemoryAuthStore, now: () => number = frozenNow): Hono { + return new Hono().route('/mentions', mentionsRoutes({ auth, now })); +} + +function hexKey(tag: string): string { + const hex = [...tag].map((ch) => ch.charCodeAt(0).toString(16).padStart(2, '0')).join(''); + return (hex + '0'.repeat(64)).slice(0, 64); +} + +async function seededCaller( + overrides: { rulesAgreedAt?: number | null } = {}, +): Promise { + const store = new InMemoryAuthStore(); + await store.createAccount({ + id: 'caller', + linkingKey: null, + role: 'basis', + name: 'Caller', + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: 'a'.repeat(64), + createdAt: FROZEN, + rulesAgreedAt: overrides.rulesAgreedAt === undefined ? FROZEN : overrides.rulesAgreedAt, + }); + await store.createSession({ token: 'tok', accountId: 'caller', createdAt: FROZEN }); + return store; +} + +async function addAccount( + store: InMemoryAuthStore, + opts: { + id: string; + username?: string | null; + name?: string | null; + viewKey: string; + createdAt?: number; + }, +): Promise { + await store.createAccount({ + id: opts.id, + linkingKey: null, + role: 'basis', + name: opts.name === undefined ? 'Ada' : opts.name, + lightningAddress: null, + lightningAddressVerified: false, + forumLawsDismissed: false, + location: null, + viewKey: opts.viewKey, + createdAt: opts.createdAt ?? FROZEN, + rulesAgreedAt: FROZEN, + ...(opts.username !== undefined ? { username: opts.username } : {}), + }); +} + +describe('GET /mentions', () => { + it('returns 401 without an Authorization header', async () => { + const res = await mount(new InMemoryAuthStore()).request('/mentions'); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 401 with an invalid bearer', async () => { + const res = await mount(await seededCaller()).request('/mentions', { + headers: { authorization: 'Bearer nope' }, + }); + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('returns 409 when the caller lacks rules agreement', async () => { + const res = await mount(await seededCaller({ rulesAgreedAt: null })).request('/mentions', { + headers: AUTH, + }); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: 'missing_requirements', + missing: ['rules'], + }); + }); + + it('returns 400 for an invalid query', async () => { + const store = await seededCaller(); + for (const q of ['_ada', 'ada bob', '@ Ada', 'a'.repeat(33)]) { + const res = await mount(store).request(`/mentions?q=${encodeURIComponent(q)}`, { + headers: AUTH, + }); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid query' }); + } + }); + + it('expires the session on the injected clock', async () => { + const store = await seededCaller(); + const fresh = await mount(store, () => FROZEN + SESSION_TTL_MS).request('/mentions', { + headers: AUTH, + }); + expect(fresh.status).toBe(200); + const expired = await mount(store, () => FROZEN + SESSION_TTL_MS + 1).request('/mentions', { + headers: AUTH, + }); + expect(expired.status).toBe(401); + expect(await expired.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('caps an empty query at 20 rows in username order, not insertion or id order', async () => { + const store = await seededCaller(); + for (let n = 21; n >= 1; n -= 1) { + const label = String(n).padStart(2, '0'); + const id = n === 1 ? 'z-m01' : n === 21 ? 'a-m21' : `q-m${label}`; + await addAccount(store, { + id, + username: `m${label}`, + viewKey: hexKey(`m${label}`), + createdAt: 22 - n, + }); + } + const res = await mount(store).request('/mentions', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: { id: string; username: string; name: string }[]; + }; + expect(body.accounts).toHaveLength(20); + expect(body.accounts[0]?.username).toBe('m01'); + expect(body.accounts[19]?.username).toBe('m20'); + expect(body.accounts.map((row) => row.username)).not.toContain('m21'); + }); + + it('returns as, asia, aspen for prefix as and for @As', async () => { + const store = await seededCaller(); + for (const username of ['bob', 'aspen', 'asia', 'as', 'atlas']) { + await addAccount(store, { + id: `id-${username}`, + username, + viewKey: hexKey(username), + }); + } + for (const path of ['/mentions?q=as', '/mentions?q=@As']) { + const res = await mount(store).request(path, { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + accounts: { username: string }[]; + }; + expect(body.accounts.map((row) => row.username)).toEqual(['as', 'asia', 'aspen']); + } + }); + + it('treats underscore as a literal, not a wildcard', async () => { + const store = await seededCaller(); + for (const username of ['axb', 'a_b', 'a_c', 'ab']) { + await addAccount(store, { + id: `id-${username}`, + username, + viewKey: hexKey(username), + }); + } + const res = await mount(store).request('/mentions?q=a_', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accounts: { username: string }[] }; + expect(body.accounts.map((row) => row.username)).toEqual(['a_b', 'a_c']); + }); + + it('omits null, blank, and missing usernames', async () => { + const store = await seededCaller(); + await addAccount(store, { id: 'null-user', username: null, viewKey: hexKey('null') }); + await addAccount(store, { id: 'blank-user', username: 'keep', viewKey: hexKey('blank') }); + await store.updateAccount({ + ...(await store.getAccount('blank-user'))!, + username: ' ', + }); + await addAccount(store, { id: 'omit-user', viewKey: hexKey('omit') }); + await addAccount(store, { id: 'zed-user', username: 'zed', viewKey: hexKey('zed') }); + const res = await mount(store).request('/mentions', { headers: AUTH }); + expect(res.status).toBe(200); + const body = (await res.json()) as { accounts: { username: string }[] }; + expect(body.accounts.map((row) => row.username)).toEqual(['zed']); + }); + + it('falls back to the stored username when the display name is blank', async () => { + const store = await seededCaller(); + await addAccount(store, { + id: 'cara', + username: 'cara', + name: null, + viewKey: hexKey('cara'), + }); + await addAccount(store, { + id: 'cara2', + username: 'cara2', + name: ' ', + viewKey: hexKey('cara2'), + }); + await addAccount(store, { + id: 'ada', + username: 'Ada', + name: ' Ada Lovelace ', + viewKey: hexKey('Ada'), + }); + const empty = await mount(store).request('/mentions', { headers: AUTH }); + expect(empty.status).toBe(200); + const emptyBody = (await empty.json()) as { + accounts: { username: string; name: string }[]; + }; + expect(emptyBody.accounts).toEqual([ + { id: 'ada', username: 'Ada', name: 'Ada Lovelace' }, + { id: 'cara', username: 'cara', name: 'cara' }, + { id: 'cara2', username: 'cara2', name: 'cara2' }, + ]); + const prefixed = await mount(store).request('/mentions?q=ada', { headers: AUTH }); + expect(prefixed.status).toBe(200); + expect(await prefixed.json()).toEqual({ + accounts: [{ id: 'ada', username: 'Ada', name: 'Ada Lovelace' }], + }); + }); +}); diff --git a/src/lib/auth/postgres-store.ts b/src/lib/auth/postgres-store.ts index b651adb7..08a9c789 100644 --- a/src/lib/auth/postgres-store.ts +++ b/src/lib/auth/postgres-store.ts @@ -58,6 +58,11 @@ interface AccountRow { const ACCOUNT_SELECT_COLUMNS = `id, linking_key, role, name, lightning_address, lightning_address_verified, forum_laws_dismissed, view_key, created_at, rules_agreed_at, is_platform, name_skipped_at, lightning_address_skipped_at, profile_message_id, location, notification_level, username, session_refused, nostr_kek_id, nostr_key_custody, nostr_key_created_at, wallet_required, wallet_backup_seen_at, amount_unit, locale, fiat`; +/** LIKE prefix where `\`, `%`, and `_` are literals. */ +function mentionLikePattern(prefix: string): string { + return `${prefix.replace(/[\\%_]/g, (char) => `\\${char}`)}%`; +} + /** Row shape of `auth_session`. */ interface SessionRow { token: string; @@ -489,6 +494,29 @@ export class PostgresAuthStore implements AuthStore { return row === undefined ? undefined : mapAccount(row); } + async listAccountsByUsernamePrefix( + prefix: string, + limit: number, + ): Promise<{ id: string; username: string; name: string }[]> { + const rows = await this.#sql.query<{ + id: string; + username: string; + name: string | null; + }>( + `SELECT id::text AS id, username, name +FROM account +WHERE username IS NOT NULL AND trim(username) <> '' + AND lower(trim(username)) LIKE $1 ESCAPE '\\' +ORDER BY lower(trim(username)) ASC, id::text ASC +LIMIT $2`, + [mentionLikePattern(prefix), limit], + ); + return rows.map((row) => { + const name = row.name === null || row.name.trim() === '' ? row.username : row.name.trim(); + return { id: row.id, username: row.username, name }; + }); + } + async getAccountByPubkey(pubkey: string): Promise { const rows = await this.#sql.query( `SELECT ${ACCOUNT_SELECT_COLUMNS} diff --git a/src/lib/auth/store.ts b/src/lib/auth/store.ts index 416565d5..eba2ca70 100644 --- a/src/lib/auth/store.ts +++ b/src/lib/auth/store.ts @@ -457,6 +457,25 @@ export interface AuthStore { * (unique index in Postgres; in-memory create/update refuse a taken handle). */ getAccountByUsername(username: string): Promise; + /** + * Accounts whose username starts with `prefix`, for `@` suggestions. + * + * `prefix` is already `""` or a normalised prefix; do not re-validate, trim, + * or lowercase it. Empty prefix matches every account that has a non-blank + * username. At most `limit` rows. Order is `lower(trim(username))` + * ascending, then `id` ascending. Skip null, undefined, and blank + * (trim-empty) usernames. `username` in the result is the stored handle + * (not lowercased, not trimmed). `name` is the trimmed display name, or + * the stored username when the display name is null or blank (trim-empty). + * + * @param prefix - Already `""` or a normalised prefix. + * @param limit - Maximum rows to return. + * @returns Matching `{ id, username, name }` rows. + */ + listAccountsByUsernamePrefix( + prefix: string, + limit: number, + ): Promise<{ id: string; username: string; name: string }[]>; /** * Look up an account by custodial Nostr pubkey (case-insensitive hex). * Unique index in Postgres; in-memory scans `#nostrKeys`. @@ -987,6 +1006,34 @@ export class InMemoryAuthStore implements AuthStore { return undefined; } + async listAccountsByUsernamePrefix( + prefix: string, + limit: number, + ): Promise<{ id: string; username: string; name: string }[]> { + const rows: { key: string; id: string; username: string; name: string }[] = []; + for (const account of this.#accounts.values()) { + if (account.username === null || account.username === undefined) { + continue; + } + if (account.username.trim() === '') { + continue; + } + const lowered = account.username.trim().toLowerCase(); + if (prefix === '' || lowered.startsWith(prefix)) { + const trimmedName = (account.name ?? '').trim(); + rows.push({ + // NUL sorts before username characters so the id is the tie-break + key: `${lowered}\u0000${account.id}`, + id: account.id, + username: account.username, + name: trimmedName === '' ? account.username : trimmedName, + }); + } + } + rows.sort((a, b) => (a.key < b.key ? -1 : 1)); + return rows.slice(0, limit).map(({ id, username, name }) => ({ id, username, name })); + } + async getAccountByPubkey(pubkey: string): Promise { const needle = pubkey.trim().toLowerCase(); if (needle === '') { diff --git a/src/lib/mention-query.ts b/src/lib/mention-query.ts new file mode 100644 index 00000000..ad66b91d --- /dev/null +++ b/src/lib/mention-query.ts @@ -0,0 +1,34 @@ +/** + * Normalise a GET /mentions `q` value into a username prefix. + * + * Does not parse or store `@username` marks on a note. + */ + +const USERNAME_PREFIX = /^[a-z0-9][a-z0-9._-]{0,31}$/; + +/** + * Trim, strip one leading `@`, lowercase, and validate a mention prefix. + * + * @param raw - The `q` query string, or `undefined` when omitted. + * @returns A lowercase prefix (`""` when empty), or `null` when invalid. + */ +export function mentionQueryPrefix(raw: string | undefined): string | null { + if (raw === undefined || raw.trim() === '') { + return ''; + } + let value = raw.trim(); + if (value.startsWith('@')) { + value = value.slice(1); + } + value = value.toLowerCase(); + if (value === '') { + return ''; + } + if (value.length > 32) { + return null; + } + if (!USERNAME_PREFIX.test(value)) { + return null; + } + return value; +} diff --git a/src/routes/mentions.ts b/src/routes/mentions.ts new file mode 100644 index 00000000..88445710 --- /dev/null +++ b/src/routes/mentions.ts @@ -0,0 +1,83 @@ +import { Hono, type Context } from 'hono'; +import { resolveSession } from '@/lib/auth/service'; +import { MISSING_REQUIREMENTS_ERROR, requireAction } from '@/lib/auth/requirements'; +import type { Account, AuthStore } from '@/lib/auth/store'; +import { mentionQueryPrefix } from '@/lib/mention-query'; +import { bearerToken } from '@/routes/me'; + +/** + * `/mentions` — signed-in username prefix suggestions for `@` in a forum post. + */ + +/** Maximum accounts returned by `GET /mentions`. */ +export const MENTION_SUGGEST_LIMIT = 20; + +/** Collaborators the `/mentions` routes need. */ +interface MentionsRouteDeps { + /** Shared auth persistence port. */ + auth: AuthStore; + /** Clock returning epoch milliseconds. */ + now: () => number; +} + +/** Auth outcome. */ +type MentionsLoad = { ok: true; account: Account } | { ok: false; response: Response }; + +/** Resolve the account behind a request's bearer session, or `null`. */ +async function authedAccount( + deps: MentionsRouteDeps, + header: string | undefined, +): Promise { + const token = bearerToken(header); + if (token === null) { + return null; + } + return resolveSession(deps.auth, deps.now(), token); +} + +/** + * Require a signed-in caller with `forum.read`. + * + * @param deps - Auth store. + * @param c - Request. + * @returns The caller, or a 401/409 response. + */ +async function requireForumRead(deps: MentionsRouteDeps, c: Context): Promise { + const caller = await authedAccount(deps, c.req.header('authorization')); + if (caller === null) { + return { ok: false, response: c.json({ error: 'Unauthorized' }, 401) }; + } + const gate = requireAction(caller, 'forum.read'); + if (!gate.ok) { + return { + ok: false, + response: c.json({ error: MISSING_REQUIREMENTS_ERROR, missing: gate.missing }, 409), + }; + } + return { ok: true, account: caller }; +} + +/** + * Build the `/mentions` route group. + * + * Mounted at `/mentions` so the public path is `GET /mentions`. Username + * prefix suggestions for `@` in a forum post. Does not change how a sent + * post stores `@username` marks. + * + * @param deps - Auth store and clock. + * @returns A Hono app with mention prefix GET. + */ +export function mentionsRoutes(deps: MentionsRouteDeps): Hono { + return new Hono().get('/', async (c): Promise => { + const auth = await requireForumRead(deps, c); + if (!auth.ok) { + return auth.response; + } + const prefix = mentionQueryPrefix(c.req.query('q')); + if (prefix === null) { + return c.json({ error: 'Invalid query' }, 400); + } + const accounts = await deps.auth.listAccountsByUsernamePrefix(prefix, MENTION_SUGGEST_LIMIT); + return c.json({ accounts }, 200); + }); +} diff --git a/src/server.ts b/src/server.ts index 5cc3d344..892d4fef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,6 +12,7 @@ import { bannerRoutes } from '@/routes/banner'; import { pictureRoutes } from '@/routes/pictures'; import { InMemoryBannerStore, type BannerStore } from '@/lib/banner-store'; import { membersRoutes } from '@/routes/members'; +import { mentionsRoutes } from '@/routes/mentions'; import { linksRoutes } from '@/routes/links'; import { viewRoutes } from '@/routes/view'; import { lightningAddressRoutes } from '@/routes/lightning-address'; @@ -479,6 +480,7 @@ export function createApp(deps: AppDeps = {}): Hono { fiatRates, }), ); + app.route('/mentions', mentionsRoutes({ auth: store, now })); app.route('/links', linksRoutes({ messages: messageStore, accounts: store })); app.route( '/view',