- Purpose: Pure list of outbound gifts that fall on one UTC calendar day, with BTC/USD at that day's close.
- Inputs:
day(YYYY-MM-DD),readonly GiftRow[](other days ignored),ReadonlyMapof UTC day → USD-per-BTC. Empty matching set needs no rates. - Returns / side effects:
GiftDay(giftssorted bypaidAtthenrecipient). ThrowsError('fx.rate.missing')when a listed gift has no rate. No I/O. - Used by:
giftsRoutes.
- Purpose: Pure aggregation of outbound gifts into the public stats JSON (UTC daily series with gap days, months with gap months, recipients) including BTC strings and historical USD from per-gift day rates.
- Inputs:
readonly GiftRow[](paidAt,amountSats,recipientWosUser) andReadonlyMap<string, string>of UTC day → USD-per-BTC. Empty rows need no rates. - Returns / side effects:
GiftStatswithtotalBtc,totalUsd,fx, and BTC/USD on series/buckets. ThrowsError('fx.rate.missing')when a gift day has no rate. Gap days and gap months are zero sats/BTC/USD without a rate. No I/O. - Used by:
giftsStatsRoutes.
- Purpose: Filter outbound gift rows to one Wallet of Satoshi handle (case-insensitive). Used by
GET /gifts/stats?recipient=so stats reflect that handle's gifts only. - Inputs:
readonly GiftRow[]andrecipientstring. Trimsrecipient; whenindexOf('@') > 0compares the local-part before@, otherwise the whole trimmed string. Empty after trim matches nothing — never "all gifts". - Returns / side effects: Matching
GiftRow[]in input order, or[]. No I/O. - Used by:
giftsStatsRoutes.
- Purpose: Hono sub-app for
GET /gifts?day=YYYY-MM-DD. Invalid/missingday→ 400. Empty day → 200 without Coinbase. Gifts present →ensureDays([day]); missing rate → 503. - Inputs:
{ store: GiftStore; rates?: BtcUsdRateBook; now?: () => number }(defaults: emptyInMemoryBtcUsdStore,Date.now). - Returns / side effects: Hono app mounted at
/gifts. Logsgifts.day.fx_incompleteorgifts.day.failedon 503 paths. - Used by:
createApp.
- Purpose: Hono sub-app for
GET /gifts/stats. Optional?recipient=filters viagiftsForRecipientbefore aggregation. Empty selection (no gifts, or unknown handle) → empty stats 200 without Coinbase. OtherwiseensureDaysfor unique selected gift days; missing rate → 503. - Inputs:
{ store: GiftStore; rates?: BtcUsdRateBook; now?: () => number }(defaults: emptyInMemoryBtcUsdStore,Date.now). Queryrecipientis optional (missing/blank = unfiltered). - Returns / side effects: Hono app mounted at
/gifts/stats. Logsgifts.stats.fx_incompleteorgifts.stats.failedon 503 paths. - Used by:
createApp.
- Purpose: Validate a UTC calendar day string
YYYY-MM-DD(rejects2026-02-31and non-shape input). - Inputs: Candidate
daystring. - Returns / side effects:
trueonly for a real UTC date. No I/O. - Used by:
giftsRoutes.
- Purpose: UTC calendar day
YYYY-MM-DDfrom aDate(toISOStringslice). - Inputs:
paidAtinstant. - Returns / side effects: Day string. No I/O.
- Used by:
buildGiftDay,giftsRoutes.
- Purpose: Format non-negative integer sats as an eight-decimal BTC string.
- Inputs:
satsnumber (non-negative integer). - Returns / side effects: e.g.
"0.00001000". Throws on invalid sats. No I/O. - Used by:
buildGiftStats.
- Purpose: Parse a USD-per-BTC decimal string into an 8-decimal scaled
bigint. - Inputs: Rate string (e.g.
"95000.12"). Extra fractional digits round half-up. - Returns / side effects:
rate * 10^8asbigint. Throws if invalid or<= 0. No I/O. - Used by:
satsToUsdCents.
- Purpose: Convert sats to USD cents at a USD-per-BTC rate using BigInt half-up (
sats * usd_scaled_8 / 10^14). - Inputs: Non-negative integer
satsand rate string. - Returns / side effects: Integer cents. Throws on bad sats/rate or if rounded cents exceed
Number.MAX_SAFE_INTEGER. No I/O. - Used by:
buildGiftStats.
- Purpose: Format non-negative integer cents as a two-decimal dollar string.
- Inputs:
centsnumber (non-negative integer). - Returns / side effects: e.g.
"1234.56". Throws on invalid cents. No I/O. - Used by:
buildGiftStats.
- Purpose: Resolve the Coinbase (or override) candles HTTP URL from env.
- Inputs:
NodeJS.ProcessEnv(BTC_USD_CANDLES_URL). - Returns / side effects: Trimmed override or
DEFAULT_BTC_USD_CANDLES_URLwhen unset/blank. No I/O. - Used by:
openBootStores.
- Purpose: Parse Coinbase candles JSON (
[time, low, high, open, close, volume]) into{ day, usdPerBtc }rows. - Inputs: Parsed JSON body (must be an array).
- Returns / side effects: Close rows; skips bad shape / non-positive close. Throws if body is not an array. No I/O.
- Used by:
fetchDailyCloses.
- Purpose: HTTP GET daily BTC-USD closes for an inclusive UTC day range (chunks of 300 days,
User-Agent: 21.gifts-api, AbortSignal timeout). - Inputs:
{ fetchImpl, url, fromDay, toDay, timeoutMs? }(timeoutMsdefault 8000). - Returns / side effects:
CandleClose[]. Throws on invalid range, non-OK HTTP, or invalid JSON. - Used by:
PostgresBtcUsdStore.ensureDays.
- Purpose: Applies
BTC_USD_DAILY_SCHEMA_SQL(CREATE TABLE IF NOT EXISTS btc_usd_daily). - Inputs:
SqlClient. - Returns / side effects: Void; idempotent DDL execute.
- Used by:
openBootStoreswhen SQL opens.
- Purpose: Applies
MESSAGE_SCHEMA_SQLin order (CREATE TABLE IF NOT EXISTS messagewith nullablephoto/photo_content_type, newest-first index, additiveALTER … ADD COLUMN IF NOT EXISTSfor existing databases includingvideo_content_type(MIME in Postgres; video bytes on disk underMEDIA_DIR, not bytea),parent_id uuid REFERENCES message (id),author_pubkey text, thenALTER TABLE message ALTER COLUMN account_id DROP NOT NULLandCREATE INDEX IF NOT EXISTS message_parent_id_idx ON message (parent_id, created_at ASC, id ASC), thenmessage_invoiceandnostr_zap_ingestwithout FKs plusALTER TABLE message_invoice ADD COLUMN IF NOT EXISTS lnurl_response jsonband theircreated_at/message_idandreceipt_idindexes). Aftermessageexists, addsaccount_profile_message_id_fkey(ON DELETE SET NULL) and unique partial indexaccount_profile_message_uidx. - Inputs:
SqlClient. - Returns / side effects: Void; idempotent DDL execute matching
docs/schema/message.sql. - Used by:
openBootStoreswhen SQL opens.
- Purpose: Applies
CONTACT_SCHEMA_SQLin order (CREATE TABLE IF NOT EXISTS contactplus the newest-first index). - Inputs:
SqlClient. - Returns / side effects: Void; idempotent DDL execute matching
docs/schema/contact.sql. - Used by:
openBootStoreswhen SQL opens.
- Purpose: Applies
CONVERSATION_SCHEMA_SQLin order (conversation+conversation_messagetables and unique indexes).db_changeattach runs later and covers the new public tables. - Inputs:
SqlClient. - Returns / side effects: Void; idempotent DDL matching
docs/schema/conversation.sql. - Used by:
openBootStoreswhen SQL opens, aftermigrateContactSchemaand beforemigrateDbChangeSchema.
- Purpose: Applies
PUSH_SCHEMA_SQLin order (CREATE TABLE IF NOT EXISTSforpush_subscriptionandpush_outboxwithdelivered_endpoints, supporting indexes, thenALTER TABLE … ADD COLUMN IF NOT EXISTS delivered_endpoints). - Inputs:
SqlClientalready opened by boot. - Returns / side effects: Void; idempotent DDL matching
docs/schema/push.sql. Does not attachdb_changetriggers (that runs later viamigrateDbChangeSchema). - Used by:
openBootStoreswhen SQL opens, aftermigrateConversationSchemaand beforemigrateDbChangeSchema.
- Purpose: Applies
DB_CHANGE_SCHEMA_SQLin order so durable Postgres row changes are append-logged indb_changevia AFTER INSERT/UPDATE/DELETE triggers (not from application store methods). - Inputs:
SqlClient. - Returns / side effects: Void; idempotent SQL matching
docs/schema/db_change.sql(pgcrypto, table, redact/log/immutable functions, triggers, attach loop). The immutability-guardDOdrops the append-only trigger once, hashesview_keyvalues that still match a liveaccount.view_key, leaves non-matches unchanged, then recreates the trigger. - Used by:
openBootStoreswhen SQL opens, immediately aftermigratePushSchema.
- Purpose: Ordered idempotent SQL that creates the append-only
db_changelog, secret-redacting helpers, immutability guard (including a one-time liveview_keyrewrite in that sameDO), and per-tabletrg_db_changetriggers on every public table exceptdb_change. - Inputs: None (readonly string array constant).
- Returns / side effects: Statement texts only; executed by
migrateDbChangeSchema. Secretstoken,challenge,nostr_nsec_ciphertext,nonce,view_key,endpoint,p256dh,auth, anddelivered_endpointsbecome SHA-256 hex in logged JSON; other columns includingnamestay plaintext. The guardDOhashes JSONview_keythat still equals a liveaccount.view_keyand leaves other rows unchanged. - Used by:
migrateDbChangeSchema; documented mirror indocs/schema/db_change.sql.
- Purpose: In-memory
BtcUsdRateBookseeded at construction; never HTTP. - Inputs: Optional
ReadonlyMaporRecordof day → rate.ensureDays(days, nowMs)returns the seed subset for valid requested days. - Returns / side effects: Map of available rates; missing days omitted. No network.
- Used by:
createApp/giftsStatsRoutesdefaults; memoryopenBootStores.
- Purpose: Durable
BtcUsdRateBookover Postgres: SELECT requested days; fetch+upsert gaps, stale UTC-today (fetched_atolder than 1h), and after-midnight finalize of an intraday print; skip candle days not requested; still-missing omitted (no throw). - Inputs: Constructor
{ sql, fetchImpl, candlesUrl, source? }.ensureDays(days, nowMs). - Returns / side effects: Day → rate map; still-missing days omitted (no throw). Writes
btc_usd_daily. - Used by:
openBootStoreswhen SQL opens.
- Purpose: Durable
MessageStoreover Postgres (messagetable plusmessage_invoiceandnostr_zap_ingest).deleteByIdremoves zap receipts, invoices, child replies, and the row in one parameterised data-modifying CTEquery, then unlinks on-disk videos from the returned rows.listLatestis top-level only (WHERE parent_id IS NULL) with subqueryreplyCount(direct children), selecting Nostr columns plus(photo IS NOT NULL) AS has_photoand never thephotobytea column (HTTP window newest-first; product UX is a messenger group — clients reverse);listRepliesis oldest-first (WHERE parent_id = $1,created_at ASC, id ASC);listPublishedEventIdsreturns non-null top-levelevent_ids newest-first for inbound reply REQ;createinserts optional photo bytes and optionalvideo_content_type(disk write viawriteForumVideo;removeForumVideounlink on INSERT failure);getPhotoloads bytes by id;getById;getByEventId(WHERE event_id);claimUnsigned/claimUnpublishedlease rows (claimed_until <= nowis expired; unsigned requirespending+ nullevent_id);listPendingSignedreturns pending rows whose kind:1 lackst=bitcoin(created_at ASC, id ASC);clearSignedEventnullsevent_id/nostr_event/claimed_untilonly whilependingandevent_idstill matches the listed id and no child reply exists (NOT EXISTS);listSignedMissingPhotoreturns published top-level rows (parent_id IS NULL) with a photo whose kind:1 content lacks/messages/:id/photo.plus an image extension (sats = 0, pending excluded so fan-out is not starved, video rows /video_content_typeexcluded so posters are not treated as missing photos, parents with children skipped viaNOT EXISTS,created_at ASC, id ASC);listSignedMissingVideoreturns published top-level rows (parent_id IS NULL) withvideo_content_typeset whose kind:1 content lacks/messages/:id/video.(sats = 0, pending excluded, parents with children skipped viaNOT EXISTS,created_at ASC, id ASC);listSignedMissingHashtagsreturns published unpaid top-level rows (parent_id IS NULL, parents with children skipped viaNOT EXISTS) whose kind:1 content lacks a#bitcoinor#21giftstoken (next character must not be[A-Za-z0-9_];sats = 0, pending excluded so fan-out is not starved, includes null / non-string content,created_at ASC, id ASC);resetSignedEventnullsevent_id/nostr_event/claimed_until, parkspending, and clears the epoch only whenevent_idstill matches,satsis 0, and no child reply exists (NOT EXISTS);updateSignedEvent(false onevent_idcollision);updatePublishState;addSats;recordZapReceipt(one statement:INSERT nostr_zap_receipt ON CONFLICT DO NOTHINGplusUPDATE message.sats);recordInvoiceAttempt/listInvoiceAttempts(each attempt includeslnurlResponse: raw LNURL callback JSON object or null);recordZapIngest/listZapIngests. - Inputs: Constructor takes a shared boot
SqlClient(already migrated). - Returns / side effects: Parameter-bound SQL; maps snake_case rows to
MessageRow/ForumPhoto/ invoice and ingest rows. Claim usesFOR UPDATE SKIP LOCKED. Errors propagate to the route (503) except invoice/ingest persist failures which are caught by callers. - Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: Durable
ContactStoreover Postgres (contacttable).listLatestis newest-first with a limit;createinserts the row. - Inputs: Constructor takes a shared boot
SqlClient(already migrated). - Returns / side effects: Parameter-bound SQL; maps snake_case rows to
ContactRow. Errors propagate to the route (503). - Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: Durable
ConversationStoreover Postgres (conversation+conversation_message). Open-or-create per counterpart kind, list visible threads, append messages, claim unsigned/unpublished wraps, uniqueevent_id.openMemberPlatformupdatesaccount_bwhen an existing member→platform thread points at a different platform id.retargetMemberPlatformbulk-updatesaccount_bon everymember_platformrow whoseaccount_ais not the new platform id. - Inputs: Constructor takes a shared boot
SqlClient(already migrated). - Returns / side effects: Parameter-bound SQL; maps snake_case rows to
ConversationThread/ConversationMessageRow. Unique violations on open/append are swallowed as idempotent. Errors otherwise propagate to the route (503). - Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: Boot helper:
SELECT min/max(paid_at)for outbound gifts, thenensureDaysfor every UTC day from min through max. - Inputs:
SqlClient,BtcUsdRateBook,nowMs. - Returns / side effects: Void. No-op when no outbound gifts. Does not catch — boot logs failures.
- Used by:
openBootStores.
- Purpose: Process-local AuthStore: passkey challenges/credentials, accounts, sessions, verifications, and custodial Nostr keys (
getNostrPublicKey/getNostrSecret/setNostrKeyIfAbsent/listAccountIdsWithoutNostrKey). Evicts expired challenges/sessions on write. IndexeslinkingKeyonly when non-null. Maintains an O(1)viewKeyindex;getAccountByViewKeylooks it up.getAccountByLightningAddressscans for alower(trim)match and skips null addresses.updateAccountNameByLightningAddressmutates onlynameon the matched account (lower(trim)); other fields stay unchanged; unknown address →undefined.accountHasPasskeyis true when any credential maps to the account id.createAccountis a no-op whenviewKeyis already stored, a non-nulllinkingKeyalready exists, orlightningAddress(lower(trim)) belongs to another id.updateAccountreindexesviewKeywhen it changes and refuses aviewKey, non-nulllinkingKey, orlightningAddressowned by another id.createAccount/updateAccountwithisPlatform: truecall#clearPlatformExceptso every other account'sisPlatformis false (at most one platform account).deleteAccountdrops the row and its linking-key and viewKey indexes.listAccountsreturns every account oldest-first. - Inputs: Constructor none. Methods take domain objects (
PasskeyChallenge,PasskeyCredential,Account,Session,AddressVerification).createAccountis a no-op when a non-nulllinkingKeyalready exists, whenviewKeyis already stored, or whenlightningAddress(lower(trim)) is taken.updateAccountrefuses alinkingKey/viewKey/lightningAddressowned by another account and keeps the viewKey index consistent.updateAccountNameByLightningAddress(lightningAddress, name)takes the address and new display name.deleteAccountdrops the row and its linking-key and viewKey indexes.createPasskeyCredentialreturns false when this account already has a credential or the id is taken.createFirstPasskeyCredentialreturns false when this account already has a credential or the id is taken.updatePasskeyCredentialreturns false unless(newCount === 0 && stored === 0)ornewCount > stored; missing id is false; does not rebindaccountId/publicKey.updatePasskeyChallengereturns false when the row is missing or already consumed. - Returns / side effects: Lookups return the object or
undefined. Writes resolve when persisted.listAccountsreturnsAccount[]. - Used by:
createAppdefault store; all auth/me/debug/view routes.
- 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 theview_keycolumn.getAccountByViewKeyisWHERE view_key = $1.getAccountByLightningAddressisWHERE lower(trim(lightning_address)) = lower(trim($1))(null addresses do not match).updateAccountNameByLightningAddressisUPDATE account SET name = $2 WHERE lower(trim(lightning_address)) = lower(trim($1)) RETURNING …(other columns unchanged; emptyRETURNING→undefined).accountHasPasskeyisSELECT 1 FROM passkey_credential WHERE account_id = $1 LIMIT 1.mapAccountskips nullview_key(getAccount/getAccountByViewKey/getAccountByLightningAddress/updateAccountNameByLightningAddressreturn undefined;listAccountsomits those rows) and setsisPlatformtrue only whenis_platformis true. PasskeysignCountadvances with an atomicWHERE(0/0ornew > stored)RETURNING, notGREATEST; duplicate credential ids areON CONFLICT DO NOTHING.createPasskeyCredentialalso returns false on unique_violation23505forpasskey_credential_account_uidx(one credential per account).createFirstPasskeyCredentialinserts only when the account has no credential (WHERE NOT EXISTSplus uniqueaccount_id); unique_violation is false.createAccountINSERT unique_violation23505is a no-op.updateAccountrefuses alinkingKeyowned by another id (UPDATEmatches no row; unique_violation23505is a no-op). BeforecreateAccount/updateAccountwhenisPlatform === true,UPDATE account SET is_platform = false WHERE is_platform AND id <> $1so at most one platform account remains (partial uniqueaccount_is_platform_uidx). INSERT/UPDATE writeis_platform.deleteAccountisDELETE FROM account WHERE id = $1. Unique index onlower(trim(lightning_address))where the address is not null. - Inputs: Constructor takes a
SqlClient. Methods matchAuthStoreincludinggetAccountByViewKey,getAccountByLightningAddress,updateAccountNameByLightningAddress, andaccountHasPasskey. - Returns / side effects: Parameter-bound SQL; maps snake_case rows to domain objects.
- Used by:
openAuthStorewhenDATABASE_URLis set.
- Purpose: Applies
AUTH_SCHEMA_SQLin order (CREATE TABLE IF NOT EXISTSplusALTERbackfills for existing databases). - Inputs:
SqlClient. - Returns / side effects: Void; creates
account,auth_session,address_verification,passkey_challenge,passkey_credential; drops leftoverauth_challenge; backfillsaccount.name/ nullablelinking_key; addsnostr_pubkey/ nsec ciphertext / kek id / custody plus unique index and CHECK; addsview_keyALTER, uuid-concat backfill, and unique index; adds nullablerules_agreed_at; unique indexaccount_lightning_address_uidxonlower(trim(lightning_address))where not null; unique indexpasskey_credential_account_uidxonaccount_id; addsis_platform boolean NOT NULL DEFAULT falseand unique indexaccount_is_platform_uidxon(is_platform) WHERE is_platform; adds nullablename_skipped_at,lightning_address_skipped_at, andprofile_message_id uuid(no FK tomessagehere — message migrates later). - Used by:
openAuthStore.
- Purpose: Chooses in-memory vs Postgres AuthStore from
DATABASE_URL. - Inputs: URL or blank/undefined;
createClientfactory required when the URL is set (boot supplies Bun SQL; tests inject a mock). - Returns / side effects:
InMemoryAuthStoreif unset; otherwise migrate thenPostgresAuthStore. Throws if the URL is set without a factory. - Used by:
openBootStores.
- Purpose: Shared
DATABASE_URLwiring: oneSqlClientfor durable auth, FX table,QueryGiftStore,SqlGiftRecorder,PostgresBtcUsdStore,migrateMessageSchema,PostgresMessageStore,migrateContactSchema,PostgresContactStore,migrateConversationSchema,PostgresConversationStore,migratePushSchema,PostgresPushStore,migrateDbChangeSchema, and parsedNOSTR_NSEC_KEK; or in-memory auth,giftStore/giftRecorder/messageStore/contactStore/conversationStore/pushStoreundefined,nostrKekundefined, and emptyInMemoryBtcUsdStorewhen unset. - Inputs:
databaseUrl; optionalcreateClient(required when URL set); optionalfx: { fetchImpl, candlesUrl, now }so tests avoid the network (candlesUrldefaults viaresolveCandlesUrl(process.env)). SQL path readsprocess.env.NOSTR_NSEC_KEK. - Returns / side effects:
{ authStore, giftStore, giftRecorder, btcUsdRates, messageStore, contactStore, conversationStore, pushStore, nostrKek }. Migratesbtc_usd_daily,message,contact,conversation(viamigrateConversationSchema),push_subscription/push_outbox(viamigratePushSchema), thendb_changeafter auth migrate; best-effortfillRatesForGiftRangelogsgifts.fx.boot_fill.failedand does not throw. Throws if the URL is set without a factory, or if the SQL path has a missing/malformed KEK. SQL path returnsSqlGiftRecorder,PostgresMessageStore,PostgresContactStore,PostgresConversationStore, andPostgresPushStore; memory path returnsgiftRecorder/messageStore/contactStore/conversationStore/pushStore/nostrKekundefined and skips migrates includingmigrateConversationSchema/migratePushSchema/migrateDbChangeSchema. - Used by:
src/index.tsboot.
- Purpose: Constant-time compare of
DEBUG_TOKENagainstAuthorization: Bearer. - Inputs: Configured token (non-empty) and raw header or
undefined. - Returns / side effects:
trueonly on an exact Bearer match (trim on the presented token). - Used by:
debugRoutes,debugContactsRoutes,debugMessagesRoutes,debugPaymentsRoutes,debugPushRoutes.
- Purpose: Sort key for
listAccounts: oldercreatedAtfirst, thenidascending. - Inputs: Two
Accountvalues. - Returns / side effects: Negative / positive / 0.
- Used by:
InMemoryAuthStore.listAccounts.
- Purpose: Operator listing, provisioning, role assignment, Lightning Address unlink, official platform-flag retarget, and minting a member bearer via
POST /:id/session. - Inputs:
DebugRouteDeps: store, optional debugToken, requiredfetchImpl(NIP-57 mint probe on new POST addresses), optionalconversationStore(PATCH platform: truecallsretargetMemberPlatform), optionalmessageStoreandpushStore(POST provision callsensureProfileMessagewhenmessageStoreis set), optionalnowfor minted debug sessions. - Returns / side effects: Hono app (
GET /,POST /,PATCH /:id,POST /:id/session). Shared 503 if token unset; 401 if bearer mismatches. GET 200{ accounts }viaserializeDebugAccount(includesisPlatform; noviewKey) logsdebug.accounts.listedwith count. POST body{ accounts: [{ name, lightningAddress }] }→ 400 invalid body (including C0/DEL names or non-LUD-16 addresses after the shape check; no row is written); probes all new addresses first (probeNip57Mint) unlessNIP57_PROBE=0(Playwright e2e skip; production must not set this); anynot_zap/unreachableis 400 and no new address in that request is saved; name-only updates run only after every probe has passed; 500{ error: 'Could not save the account' }when create does not persist the address, the name-only update matches no row, or the name-only update returns a row whosenameis not the requested name; creates by Lightning Address, or for an existing address updates onlynameviaupdateAccountNameByLightningAddress; whenmessageStoreis set, POST then callsensureProfileMessage(optionalpushStore) (keepsviewKey/role/ other columns); returns{ accounts: [{ name, lightningAddress, viewKey, created }] }; logsdebug.accounts.provisionedwith created/updated counts (never viewKeys or the token). PATCH body{ role }and/or{ lightningAddress: null }and/or{ platform: true|false }→ 400 unknown/missing; 404 missing account; 200serializeDebugAccountof the updated row (includesisPlatform; noviewKey); unlink alsodeleteVerificationand logsdebug.accounts.lightning_address.cleared; role changes logdebug.accounts.role_setwith account id and role;platform: trueuniquely retargets (store clears any otherisPlatform), points every member→platform thread at the new account viaretargetMemberPlatformwhenconversationStoreis set, and logsdebug.accounts.platform_set. Never logs the token or the previous address. - Used by:
createAppat/debug/accounts.
- Purpose: Operator listing of private in-app contacts (includes
accountId). - Inputs:
DebugContactsRouteDeps: contact store, optional debugToken. - Returns / side effects: Hono app. 503 if token unset; 401 if bearer mismatches; 200
{ contacts }newest-first (cap 200); 503 on store throw (contact.list.failed). Logsdebug.contacts.listedwith count, never the token. - Used by:
createAppat/debug/contacts.
- Purpose: Operator restore of a missing forum-video file for an already-existing message with
hasVideo(raw body underMEDIA_DIR; no new message id, no DB create). - Inputs:
DebugMessagesRouteDeps: message store, optional debugToken. - Returns / side effects: Hono app exposing
PUT /:id/video. 503 if token unset/blank; 401 if bearer mismatches (before the body is read); 404 for non-UUID or unknown id; 409 when the row has no video or the decoded MIME extension does not match the stored type; 400 for empty/oversize/unrecognized body; 204 afterwriteForumVideo; 503{ error: 'Messages are unavailable' }whengetByIdorwriteForumVideothrows (debug.messages.video.put_failed). Logsdebug.messages.video.putwithmessageIdandbytes, never the token or raw bytes. - Used by:
createAppat/debug/messages.
- Purpose: Operator listing of forum invoice attempts (
message_invoice) and kind:9735 ingest decisions (nostr_zap_ingest). - Inputs:
DebugPaymentsRouteDeps: message store, optional debugToken. - Returns / side effects: Hono app. 503 if token unset; 401 if bearer mismatches; 200
{ invoices }onGET /invoices(each row serializeslnurlResponseas the raw LNURL callback JSON object or null, pluspr/isNip57Invoice/ description fields; never nsec) and{ ingests }onGET /zap-ingests, newest-first (cap 200). Store throws → 503{ error: 'Messages are unavailable' }anddebug.invoices.list_failed/debug.zap_ingests.list_failed. Logsdebug.invoices.listed/debug.zap_ingests.listedwith count, never the token or nsec. - Used by:
createAppat/debug.
- Purpose: Decode BOLT11 payment hash, amount, plaintext description, description_hash, and expiry for operator debug (does not change
decodeBolt11). - Inputs: BOLT11 string; optional decoder inject for tests.
- Returns / side effects:
InspectedBolt11ornullwhen malformed / zero-amount. - Used by:
POST /messages/:id/invoicefor the NIP-57 gate (reject before returningpr) and when persisting ok /not_zapattempts.
- Purpose: True when
descriptionHashequalssha256(utf8(zapRequestJson)). - Inputs: description hash (or null) and zap request JSON string (or null).
- Returns / side effects: boolean.
- Used by:
POST /messages/:id/invoicefor the NIP-57 gate (reject before returningpr).
- Purpose: Resolve self-hosted Web Push VAPID credentials from an environment slice without failing boot when keys are missing or unusable.
- Inputs:
envrecord (VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY, optionalVAPID_SUBJECT). - Returns / side effects:
{ publicKey, privateKey, subject }when both keys decode (URL-safe base64) to 65-byte uncompressed P-256 public and 32-byte private, andsubjectishttps:ormailto:(defaulthttps://21.gifts). Otherwisenull. Never logs the private key.src/index.tsstill try/catchesWebPushSenderconstruction so a library throw cannot kill listen. - Used by:
createApp(public key for HTTP),src/index.ts(sender + worker gate).
- Purpose: No-op
PushSenderused when VAPID env is missing so the process still boots and HTTP can return 503 without attempting delivery. - Inputs: Constructor none.
send(sub, payload)ignores arguments. - Returns / side effects:
isConfigured()is alwaysfalse;sendresolves{ ok: false, reason: 'not_configured' }and never callsweb-push. - Used by:
src/index.tswhenresolveVapidConfigreturnsnull.
- Purpose: VAPID Web Push delivery via the
web-pushpackage to one browser subscription endpoint. - Inputs: Constructor takes resolved
VapidConfig.send(sub, payload)takes aPushSubscriptionRecordand a JSON string body. - Returns / side effects:
isConfigured()istrue. Maps HTTP 404/410 togone, other errors tofail, success to{ ok: true }. Optional ASCIItopicfrom payloadtag(max 32). TTL 86400. - Used by:
src/index.tswhen VAPID resolves; drained byrunPushWorkerTick.
- Purpose: Process-local
PushStorefor Web Push subscriptions and the outbox. Default empty so the process boots without a database. - Inputs: Constructor none. Methods match
PushStore(upsertSubscriptionkeeps originalcreatedAton endpoint conflict;claimPendingleases oldest pending;markFailedfails at 8 attempts;recordDeliveredunions unique endpoint URLs onto the outbox row). - Returns / side effects: Caller-owned copies including
deliveredEndpointsslices; mutating results does not change the store. No I/O. - Used by:
createAppdefaultpushStore; memorysrc/index.tswhen boot omits SQL push.
- Purpose: Durable
PushStoreover Postgres (push_subscription,push_outbox). Same port semantics as the in-memory adapter, including claim leases, attempt counting, andrecordDeliveredfor successful endpoint URLs. - Inputs: Constructor takes a shared boot
SqlClient(already migrated viamigratePushSchema). - Returns / side effects: Parameter-bound SQL; maps snake_case rows to domain objects including
delivered_endpointsJSON. Errors propagate to callers. - Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: Enqueue one forum notification per account that has at least one subscription, never for the message author.
- Inputs:
PushStore,authorId,messageId,nowMs. Payload frombuildForumPushPayload. - Returns / side effects: One pending
type: 'forum'outbox row per other subscriber account. Does not send HTTP push itself. - Used by:
messagesRoutesafter a successfulPOST /messagescreate.
- Purpose: Enqueue one zap notification for the note author when they have at least one push subscription.
- Inputs:
PushStore,authorId,messageId,nowMs. Payload frombuildZapPushPayload(messageId). - Returns / side effects: Zero or one pending
type: 'zap'outbox row. No-op when the author has no subscriptions. - Used by: Zap ingest in
indexOpenZapReceiptswhenindexZapReceiptnewly indexed a receipt.
- Purpose: Enqueue a single operator test notification for one account when it has a subscription.
- Inputs:
PushStore,accountId,nowMs. Uses a fixed zap-typed debug payload (tag: 'debug'). - Returns / side effects:
0or1(rows enqueued). Does not deliver; the push worker drains the outbox. - Used by:
debugPushRoutes(POST /debug/push-ping).
- Purpose: Claim a batch of pending outbox rows and deliver each payload to every subscription for the recipient account.
- Inputs:
PushWorkerDeps(store,sender,now). Batch size and lease from module constants. - Returns / side effects: No-op when
sender.isConfigured()is false. Records successful endpoints viarecordDeliveredand does not resend them on retry; deletes gone subscriptions without recording them;markFailedon fail after recording successes;markSentwhen remaining sends succeed / all gone / no subs left to try. - Used by:
startPushWorkerinterval; unit tests.
- Purpose: Start a periodic
setIntervalthat runsrunPushWorkerTickuntil stopped. - Inputs:
PushWorkerDepsand optionalintervalMs(defaultPUSH_WORKER_INTERVAL_MS= 2s). - Returns / side effects:
{ stop }clears the interval. Does not throw on tick failures inside the timer callback. - Used by:
src/index.tswhen VAPID resolves.
- Purpose: Validate a browser PushSubscription JSON body into stored endpoint/key fields.
- Inputs: Unknown request body expecting
{ endpoint, keys: { p256dh, auth } }. - Returns / side effects: Parsed fields, or
nullwhen invalid (blank endpoint, bad url-safe base64 keys, non-https endpoint except localhost http). - Used by:
pushRoutesPOST /me/push-subscriptions.
- Purpose: Shared English forum notification payload (
type: 'forum', collapse tagforum, url/welcome). - Inputs: None.
- Returns / side effects:
PushPayloadobject; callersJSON.stringifybefore enqueue/send. - Used by:
enqueueForumPushes.
- Purpose: English zap notification payload for a note author (
type: 'zap', tagzap:<messageId>, url/welcome). - Inputs:
messageIdstring used only intag. - Returns / side effects:
PushPayloadobject; callersJSON.stringifybefore enqueue/send. - Used by:
enqueueZapPush.
- Purpose: Member Web Push HTTP: public VAPID key plus subscription upsert/delete for the signed-in account.
- Inputs:
PushRouteDeps(authStore,pushStore,now, optionalvapidPublicKey). - Returns / side effects: Hono app with full path literals
/push/vapid-publicand/me/push-subscriptions. Session 401 before unconfigured 503. - Used by:
createAppmounted at/.
- Purpose: Operator debug ping that enqueues a test Web Push for one account via
DEBUG_TOKEN(not an end-user session). Body{ accountId }. Returns{ enqueued }(0or1). - Inputs:
DebugPushRouteDeps(authStore,pushStore,now,debugToken,vapidPublicKey). - Returns / side effects: Hono app
POST /mounted at/debug/push-ping. Debug 503/401 before JSON; then unconfigured 503; unknown account 404. CallsenqueueDebugPush. - Used by:
createApp.
- Purpose: Process-local
MessageStorefor the public member forum. Default empty so the process boots without a database. Photos live in a private map, not on listed rows. Same port as Postgres:getById,deleteById(row, direct replies, photos, invoices, zap receipt ids, on-disk videos),getByEventId,listLatest(top-level only,parentIdnull, each row hasreplyCount),listReplies(oldest-first for a parent),listPublishedEventIds(non-null top-leveleventIds newest-first), claim/sign/publish (claimUnsignedis pending + nulleventId; lease expires atclaimedUntil),listPendingSigned(pending, not=bitcoin, oldest-first),clearSignedEvent(pending andeventIdstill matchesexpectedEventIdand the note has no child replies, then nullseventId/nostrEvent/claimedUntil),listSignedMissingPhoto(top-level only, no children, published + photo, kind:1 content lacks/messages/:id/photo.plus extension, oldest-first,sats === 0, pending excluded, video rows excluded so posters are not treated as missing photos),listSignedMissingVideo(top-level only, no children, published + video MIME, kind:1 content lacks/messages/:id/video., oldest-first,sats === 0, pending excluded),listSignedMissingHashtags(top-level only, no children, published unpaid, kind:1 content lacks a#bitcoinor#21giftstoken, oldest-first,sats === 0, pending excluded so fan-out is not starved),resetSignedEvent(nullseventId/nostrEvent/claimedUntil, parkspending, no-op unlesseventIdstill matches,satsis 0, and the note has no child replies),addSats,recordZapReceipt(duplicate receipt id does not add sats; ids are released ondeleteByIdso the same receipt can be recorded again),recordInvoiceAttempt/listInvoiceAttempts(each attempt includeslnurlResponseobject or null),recordZapIngest/listZapIngests;updateSignedEventreturns false on duplicateeventId. Store/HTTP order is newest-first; product UX is a messenger group (clients reverse). - Inputs: Optional seed
MessageRow[](copied;hasPhotodefaults false).listLatest(limit)is top-level only (parentId === null) withreplyCount, sorts newestcreatedAtthenidDESC and caps atlimit.listReplies(parentId, limit?)is oldest-first (default 200).listPublishedEventIds(limit)is newest-first non-null top-leveleventIds.create(row, photo?, video?)appends a copy;getPhoto(id)returns a photo copy or null. - Returns / side effects: Promise of row/photo copies; mutating results does not change the store. Listed objects never expose bytes. When
videois set,createawaitswriteForumVideo(disk underMEDIA_DIR); if that write throws, the row is never pushed (no unlink). - Used by:
createAppdefaultmessageStore.
- Purpose: Process-local
ContactStorefor the private in-app mailbox. Default empty so the process boots without a database. - Inputs: Optional seed
ContactRow[](copied).listLatest(limit)sorts newestcreatedAtthenidDESC and caps atlimit.create(row)appends a copy. - Returns / side effects: Promise of row copies; mutating results does not change the store. No I/O.
- Used by:
createAppdefaultcontactStore.
- Purpose: Process-local
ConversationStorefor member↔member, member↔platform, and member↔Damus threads. Default empty so the process boots without a database. - Inputs: Optional seed threads and messages (copied). Open helpers are idempotent per unique counterpart.
openMemberPlatformupdatesaccountBwhen the stored platform id differs.retargetMemberPlatformpoints every member→platform thread at the new official account except rows whose member is that account.listVisibleis newestlastMessageAtthenidDESC. - Returns / side effects: Promise of copies; mutating results does not change the store. Duplicate
eventIdappend returns the existing row. No I/O. - Used by:
createAppdefaultconversationStore.
- Purpose: TTL cache for successful LUD-16 metadata resolves.
- Inputs:
get(address, now),put(entry, now). TTL fromLN_ADDRESS_CACHE_TTL_MS. - Returns / side effects:
getreturnsCachedLnAddressornull. - Used by:
lightningAddressRoutes.
- Purpose: Process-local GiftStore seeded at construction. Default empty so the process boots without a database.
- Inputs: Optional
GiftRow[].listOutbound()copies and sorts bypaidAt. - Returns / side effects: Promise of rows. Does not mutate the seed array.
- Used by:
createAppdefaultgiftStore.
- Purpose: Maps a SQL
giftrow (paid_at,amount_sats,recipient_wos_user) onto aGiftRow. - Inputs:
GiftQueryRow(Date or string timestamp; numeric/string/bigint sats). - Returns / side effects:
{ paidAt, amountSats, recipientWosUser }. No I/O. - Used by: Production
QueryGiftStorequery inopenBootStores.
- Purpose: GiftStore that delegates
listOutboundto an injected query (Postgres in production). - Inputs:
() => Promise<GiftRow[]>. - Returns / side effects: The query result. Errors propagate to the route (503).
- Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: InvoicePayer that always fails — process boots without a payer so verification returns 503 until wired.
- Inputs:
isConfigured()is always false.payInvoice(bolt11)is the pay method. - Returns / side effects:
{ ok: false, reason: 'not_configured' }— it does not throw. - Used by: Default
createAppinvoicePayer.
- Purpose: Timing-safe compare of the spend-worker Bearer token to
SPEND_API_TOKEN. - Inputs: Configured token (may be unset) and the raw
Authorizationheader. - Returns / side effects:
unconfigured|unauthorized|ok. Does not throw on length mismatch. - Used by:
invoiceRoutes.
- Purpose: Read payment hash and millisat amount from a BOLT11 string via
light-bolt11-decoder. - Inputs:
prstring; optional test decoder. - Returns / side effects:
{ paymentHash, amountMsat }ornullon any decode failure. - Used by:
invoiceRoutesafter LNURL-pay returnspr.
- Purpose: Process-local store of gift invoices issued for the spend worker.
- Inputs:
put,get(id),markPaid(id, preimage, now),sweep(now). - Returns / side effects: Lookups return the row or
undefined.sweepdrops unpaid rows after expiry plus one extra TTL (409 tombstone window); paid rows stay for proof idempotency. Restart clears the map. - Used by: Default
createAppinvoiceStore;invoiceRoutes.
- Purpose: Hono sub-app for spend-worker passkey eligibility (
GET /passkey), invoice issue (POST /), and preimage proof (POST /proof). Issue refuses addresses without a passkey-backed account (403 before LNURL). - Inputs:
InvoiceRouteDeps: spend token, invoicestore,authStore(account + passkey lookup), clock, fetch, optionalgiftRecorder(defaultNoopGiftRecorder). - Returns / side effects: Hono app mounted at
/invoices.GET /passkeyreturns{ hasPasskey }(200 even when false). A matching proof (including the same-preimage idempotent 200) callsrecordOutbound. Insert failures loggifts.record_failedand still return 200. - Used by:
createApp.
- Purpose:
GiftRecorderthat ignores the row — used whenDATABASE_URLis unset so proof still returns 200. - Inputs:
recordOutbound(record)with aGiftRecord. - Returns / side effects: Resolves immediately. No SQL.
- Used by:
invoiceRoutesdefault whengiftRecorderis omitted.
- Purpose: Persist a proven outbound gift into Postgres
giftforGET /giftsandGET /gifts/stats. - Inputs: Shared boot
SqlClient.recordOutboundinsertspaid_at, sats, recipient handle, BOLT11pr, description,source_wallet. - Returns / side effects:
INSERT … ON CONFLICT (lightning_invoice) DO NOTHING. Errors propagate to the route, which logs and still returns 200. - Used by:
openBootStoreswhenDATABASE_URLis set.
- Purpose: Stats handle from a Lightning Address: local-part before
@, or the whole string if there is no@. - Inputs: Normalised
local@domain(or a bare handle). - Returns / side effects:
recipient_wos_userstring. No I/O. - Used by:
invoiceRouteswhen recording a proven gift.
- Purpose: 16 random bytes as 32 lowercase hex characters.
- Inputs: None (uses
crypto.getRandomValues). - Returns / side effects: Unguessable invoice id string.
- Used by:
POST /invoices.
- Purpose: Accept a 32-byte hex string (any case, trimmed).
- Inputs: Raw hex string.
- Returns / side effects: Lowercase 64-char hex or
null. - Used by:
preimageMatchesHash.
- Purpose: Lightning proof-of-payment:
sha256(preimage)equals the invoice payment hash. - Inputs: Preimage hex and payment-hash hex.
- Returns / side effects:
trueonly on a 32-byte match. - Used by:
POST /invoices/proof.
- Purpose: LNURL-pay fetch for gift amounts: no 10-sat cap, comment optional, amount not raised to minSendable.
- Inputs: Normalised address, amountMsat, optional comment, fetchImpl.
- Returns / side effects:
{ ok: true, pr }or{ ok: false, reason: 'unreachable' }. - Used by:
POST /invoices.
- Purpose: Hono sub-app for passkey register and authenticate. Register begin accepts an optional
{ viewKey }to claim a provisioned account; empty begin still mints a pending new account. Passes optionalnostrKek/nostrKeygeninto finish so new logins get a custodial nsec. - Inputs:
AuthRouteDeps: store, now, allowedOrigins, webAuthnRpId, webAuthnRpName, passkeyCeremony, optionalnostrKekandnostrKeygen. - Returns / side effects: Hono app mounted at
/auth. Begin with viewKey maps claim errors to 404/409; unwraps{ challengeId, options }on success. - Used by:
createApp.
- Purpose: Parses
Authorization: Bearer <token>. - Inputs: Header string or undefined.
- Returns / side effects: Token or
null. - Used by:
meRoutes,messagesRoutes.
- Purpose: Serves favicon.ico, favicon.svg, apple-touch-icon.png from
public/. - Inputs:
BrandRouteDeps.read. - Returns / side effects: Hono app with three GETs; 404 empty body if bytes missing.
- Used by:
createAppat/.
- Purpose: Checks the nonce the user read from the wallet payment comment (
21gifts <hex>), not a nonce returned by startVerification. - Inputs:
store,now,account,nonceRaw. - Returns / side effects: Success marks the address verified, or a
ConfirmVerificationCode. - Used by:
POST /me/lightning-address/verification/confirm.
- Purpose: Wires CORS, requestLog, brand, health, info, auth, me,
/view, lightning-address,/debug/accounts,/debug/contacts,/debug/messages,/debug/invoices,/debug/zap-ingests,/debug/push-ping, Web Push subscription routes,/gifts,/gifts/stats,/messages(incl. invoice),/members/:accountId,/.well-knownNIP-05nostr.json(CORS*),/contact,/conversations, and invoices. - Inputs: Optional
AppDeps(store, clock, payer, fetch, cache, readBrand, origins,debugToken, giftStore,giftRecorder,btcUsdRates,messageStore,contactStore, optionalconversationStore(defaultInMemoryConversationStore),pushStore,vapidPublicKey,nostrKek, spendApiToken, invoiceStore,webAuthnRpId,webAuthnRpName,passkeyCeremony). OmittedgiftRecorder→invoiceRoutesusesNoopGiftRecorder; omittedmessageStore→InMemoryMessageStore; omittedcontactStore→InMemoryContactStore; omittedconversationStore→InMemoryConversationStore; omittedpushStore→InMemoryPushStore; omitted/blankvapidPublicKey→ push HTTP 503 after session; omittednostrKek→ unsigned forum + invoice 503; SQL boot injectsSqlGiftRecorder,PostgresMessageStore,PostgresContactStore,PostgresConversationStore,PostgresPushStore, and parsed KEK. Does not take a push sender (worker owns delivery). - Returns / side effects: Hono app. Default
btcUsdRatesis an emptyInMemoryBtcUsdStore. Used by Bun.serve inindex.tsand by tests viaapp.request(). - Used by: Boot path and every HTTP test.
- Purpose: Hono app: GET
/→{ status: 'ok', service, version }. - Inputs: None.
- Returns / side effects: Mounted at
/healthz. - Used by: Probes.
- Purpose: Hono app: GET
/→ service name, version, description, repo. - Inputs: None.
- Returns / side effects: Mounted at
/info. - Used by: Service discovery.
- Purpose: Public LUD-16 resolve with cache.
- Inputs:
LightningAddressRouteDepscache, now, fetchImpl. - Returns / side effects: Hono GET
/. - Used by:
GET /lightning-address.
- Purpose: One JSON line on
console.warn(ts+event+ fields). Never log secrets. - Inputs:
eventstring, optionalLogFields. - Returns / side effects: void.
- Used by: Auth, me, lightning-address, requestLog.
- Purpose: Authenticated account routes (
GET /,POST /setup/skip, name withensureProfileMessage, forum-laws dismiss, living-room rules agreement, Lightning Address link with live LNURL resolve + zap metadata check then NIP-57 mint probeprobeNip57Mint, verification). Unlink clearslightningAddressSkippedAt.POST /lightning-addressreturns 409{ error: 'Lightning Address is already in use' }when another account owns the address. - Inputs:
MeRouteDepsstore,messages, now, payer, fetchImpl, optionalpushStore, optionalnostrKek(required to sign the mint probe). - Returns / side effects: Hono at
/me. Owner JSON includessetup+missing. SuccessfulPOST /lightning-addressneeds zap metadata (allowsNostr+ non-emptynostrPubkey) plus KEK +ensureAccountNostrKey+ probeok. Probenot_zap→ 400{ error: LIGHTNING_ADDRESS_NOT_ZAP }; probeunreachable(and missing zap metadata) → 400{ error: 'Lightning Address could not be resolved' }; missing/malformed KEK or key ensure failure → 503 with the same resolve string (account unchanged). Logsaccount.setup.skippedwith{ accountId, step }. - Used by:
createApp.
- Purpose: Hono sub-app for public
GET /:viewKey. Param not 64 lowercase hex or unknown key → 404{ error: 'Not found' }. Hit →store.accountHasPasskey(account.id)thenserializeViewProfile(account, hasPasskey). No auth; not a session. - Inputs:
{ store: AuthStore }. - Returns / side effects: Hono app mounted at
/viewso the public path isGET /view/:viewKey. - Used by:
createApp.
- Purpose: Hono sub-app for the public member forum. After Bearer auth,
requireActiongatesGET /(forum.read→ rules),POST /(forum.post→ rules + name; LN not required), andPOST /:id/invoice(forum.pay→ payer rules only). BearerGET /lists top-level notes only newest-first (cap 200,hasPhoto,hasVideo,videoContentType,sats,payable, liverole,replyCount); missing-filehasVideorows are deleted (messages.video.dropped);POST /creates text/photo/video; publicGET /:idstays unauthenticated withoutaccountId; BearerGET /:id/replies; photo/video byte routes; invoice returns{ pr, amountSats }only for NIP-57 invoices (author LN / unsigned stay 400 resource errors, never 409lightning-addressfor the payer). OptionalpushStoreenqueues on top-level create. - Inputs:
MessagesRouteDeps: messagestore, sharedauthStore,now, optionalnostrKek,fetchImpl,postLimiter,invoiceLimiter, optionalpushStore. - Returns / side effects: Hono app mounted at
/messages. 401 without session on list/create/replies/invoice; 409{ error: 'missing_requirements', missing }when action gates fail; 400 on bad body / invalid text / bad media / unpaid note / author's-wallet / LNURL failures; 404 for badinReplyTo/ missing rows; 429 rate limits; 503 on store/KEK/sign failure. Signed-in list/replies/create may includeaccountId; publicGET /:idnever includes it. - Used by:
createApp.
- Purpose: Hono sub-app for the private in-app contact mailbox:
POST /only (no member GET). After auth,requireAction(account, 'contact.post')(rules + name). After the platform account exists, persists the contact row first, then opens/appends the member→platform conversation thread. Conversation append failure logsconversations.contact_sync.failedand still 200. - Inputs:
ContactRouteDeps: contactstore,conversationStore, sharedauthStore,now. - Returns / side effects: Hono app mounted at
/contact. 401 without session; 409{ error: 'missing_requirements', missing }when rules/name are missing; 400 on bad body / invalid text; 503{ error: 'Platform account is not configured' }when noisPlatformaccount (no writes); 503 Contact is unavailable on contact-store failure (contact.create.failed). Public JSON omitsaccountId. - Used by:
createApp.
- Purpose: Hono sub-app for the signed-in PN channel:
GET /lists visible threads;POST /opens a thread from{ forumMessageId };GET /:idlists messages oldest-first;POST /:idappends{ text }. Staff (founder/moderator) see all platform threads and reply as the platform nsec. - Inputs:
ConversationRouteDeps: conversationstore, sharedauthStore, forummessageStore,now. - Returns / side effects: Hono app mounted at
/conversations. 401 without session; 400 on bad body / self-PM / missing name / invalid text; 404 when not allowed; 503 Conversations are unavailable. Public JSON omitsaccountId, event ids, and npubs (Damus-onlynamemay be a truncated npub). - Used by:
createApp.
- Purpose: Trim and validate an account display name (1–80 characters, no C0/DEL controls).
- Inputs:
rawstring. - Returns / side effects: Trimmed name or
null. - Used by:
POST /me/name.
- Purpose: Trim and validate forum message text. Empty/whitespace becomes
''(valid for photo-only or video-only posts). Over-long (after trim, longer thanmaxLength) or disallowed C0/DEL still reject; newlines\n/\rallowed. - Inputs:
rawstring; optionalmaxLength(defaultMESSAGE_MAX_LENGTH500). Inbound Nostr worker passesMESSAGE_INBOUND_REPLY_MAX_LENGTH(8192) for Damus kind:1 replies and NIP-17/kind:4 plaintext. - Returns / side effects: Trimmed text (possibly empty) or
null. No I/O. - Used by:
POST /messages,POST /contact,POST /conversations/:id,runNostrWorkerTickinbound indexing.
- Purpose: Detect JPEG / PNG / WebP from magic bytes for forum photo storage.
- Inputs: Raw
Uint8Arraycandidate bytes. - Returns / side effects:
'image/jpeg' | 'image/png' | 'image/webp', ornullfor empty/SVG/GIF/HEIC/unrecognized. No I/O. - Used by:
decodeForumPhoto.
- Purpose: Decode a base64 forum photo, enforce the 1 MiB cap, and set MIME from magic bytes (declared
contentTypeis ignored). - Inputs: Declared
contentTypestring (non-authoritative) and standard base64data. - Returns / side effects:
{ contentType, bytes }with a copiedUint8Array, ornullon invalid base64, empty, oversize, or unrecognized magic. No I/O. - Used by:
POST /messages.
- Purpose: Project a stored forum row to its public JSON shape including zap totals, payability,
hasPhoto,hasVideo,videoContentType, live author role, optionalreplyCount, and optionalaccountId. Callers that serve list/GET/replies delete ahasVideorow when the file is missing or empty on disk (forumVideoFilePresent) so no empty note remains. - Inputs:
MessageRow(includesaccountId; never photo/video bytes),payableboolean, optionalrole(AccountRole; omitted for Damus-only authors), optionalreplyCount(top-levelGET /messageslist rows), and optionalincludeAccountId(signed-in list/replies/create pass true; public GET omits). - Returns / side effects:
{ id, name, text, createdAt, sats, payable, hasPhoto, hasVideo, videoContentType }with ISO-8601createdAt;videoContentTypeis null whenhasVideois false;roleomitted when undefined;replyCountomitted when undefined;accountIdset only whenincludeAccountIdis true androw.accountId !== null(Damus-only and public GET omit it); never photo/video bytes. No I/O. - Used by:
messagesRoutes.
- Purpose: Project a stored thread to its public list JSON shape.
- Inputs:
ConversationThreadwith resolvedname/lastText. - Returns / side effects:
{ id, name, lastText, lastAt }. Omits account ids, event ids, npubs. No I/O. - Used by:
conversationRoutes.
- Purpose: Project a stored conversation message to its public JSON shape.
- Inputs:
ConversationMessageRow. - Returns / side effects:
{ id, name, text, createdAt }. Omits account ids and event ids. No I/O. - Used by:
conversationRoutes.
- Purpose: Unsigned/pending defaults for a locally persisted conversation message.
- Inputs: none.
- Returns / side effects:
{ eventId: null, nostrPublishState: 'pending', nostrEvent: null, claimedUntil: null }. - Used by:
contactRoutes,conversationRoutes.
- Purpose: Wrap plaintext as a NIP-17 kind:1059 gift wrap (rumor kind:14) using
nostr-tools. - Inputs: sender 32-byte secret, recipient hex pubkey, text.
- Returns / side effects: Signed kind:1059 event. Never logs the secret.
- Used by: Nostr worker outbound DMs.
- Purpose: Unwrap a NIP-17 kind:1059 wrap to sender pubkey, plaintext, and rumor
created_at. - Inputs: wrap event, recipient 32-byte secret.
- Returns / side effects:
{ senderPubkey, text, createdAt }ornullon failure / non-kind-14 rumor / missing rumorcreated_at.createdAtis the rumor unix time (not the wrap). Never logs the secret. - Used by: Nostr worker inbound DMs.
- Purpose: NIP-04 encrypt plaintext for a legacy kind:4 DM.
- Inputs: sender secret, recipient hex pubkey, text.
- Returns / side effects: Ciphertext string. Never logs the secret.
- Used by: Tests; inbound path uses
decryptKind4.
- Purpose: NIP-04 decrypt kind:4 content.
- Inputs: recipient secret, sender hex pubkey, ciphertext.
- Returns / side effects: Plaintext or
nullon failure. Never logs the secret. - Used by: Nostr worker inbound kind:4 DMs.
- Purpose: Project a stored contact row to its public JSON shape.
- Inputs:
ContactRow(includesaccountId). - Returns / side effects:
{ id, name, text, createdAt }with ISO-8601createdAt;accountIdomitted. No I/O. - Used by:
contactRoutes.
- Purpose: Project a stored contact row to its operator debug JSON shape.
- Inputs:
ContactRow. - Returns / side effects:
{ id, accountId, name, text, createdAt }with ISO-8601createdAt. No I/O. - Used by:
debugContactsRoutes.
- Purpose: Trims and validates
local@domainLUD-16 shape. Case is preserved. - Inputs:
rawstring. - Returns / side effects: Trimmed address or
null. - Used by: me lightning-address POST, public resolve, GET /invoices/passkey, and POST /invoices.
- Purpose: Parses
host:portbind spec. - Inputs:
addrstring. - Returns / side effects:
{ host, port }. Throws on garbage. - Used by:
index.tsboot.
- Purpose: CSPRNG hex for session tokens, passkey challenge ids, and verification nonces.
- Inputs:
byteLength. - Returns / side effects: Lowercase hex.
- Used by:
issueSession, passkey begin, verification nonce.
- Purpose: Reads
public/<name>relative to a root directory. - Inputs:
BrandFileNameand optionalroot(defaultprocess.cwd()). - Returns / side effects:
Uint8Arrayornullif missing. Does not change the process cwd. - Used by: Default
brandRoutesreader.
- Purpose: Hono middleware:
http.requestJSON after the handler. Skips/healthzand OPTIONS. Never logs the query string. Path is passed throughrequestLogPathso/view/<segment>is redacted. - Inputs: None.
- Returns / side effects:
MiddlewareHandler. - Used by:
createApp.
- Purpose: Redact the first
/view/<segment>to/view/:viewKeyso request logs never print the durable capability secret. Trailing slashes and extra segments keep the suffix./viewalone and unrelated routes are unchanged. - Inputs: Path string without the query string.
- Returns / side effects: Redacted or original string. No I/O.
- Used by:
requestLog.
- Purpose: LNURL-pay: fetch metadata, then GET the callback with
amountand optionalcommentquery params (LUD-06), return bolt11. - Inputs:
RequestPayInvoiceArgs. - Returns / side effects:
LnurlPayResult. - Used by: Verification payer path when a real InvoicePayer is wired; app donate uses the browser equivalent.
- Purpose: CORS allow-list from
CORS_ALLOWED_ORIGINSor the built-in apex, transitional app-subdomain, and localhost origins. - Inputs:
envrecord. - Returns / side effects: string[] of origins.
- Used by:
createAppCORS.
- Purpose: BIND_ADDR from env with default
0.0.0.0:3000. - Inputs: optional override, env.
- Returns / side effects: Address string.
- Used by:
index.ts.
- Purpose: GET
https://domain/.well-known/lnurlp/localand parse metadata. - Inputs: address + fetchImpl.
- Returns / side effects: Callback URL, min/max sendable, optional NIP-57
allowsNostr/nostrPubkey, or error. - Used by:
lightningAddressRoutes,POST /me/lightning-address(meRoutes),requestPayInvoice,requestGiftInvoice,requestZapInvoice.
- Purpose: Looks up a bearer session; rejects expired.
- Inputs:
store,now,token. - Returns / side effects:
Accountornull. - Used by:
meRoutes.
- Purpose: Pays a 1-sat LNURL-pay invoice to the linked address and stores a nonce.
- Inputs:
StartVerificationArgs(store, payer, fetch, accountId, now). - Returns / side effects: Sent result or a
StartVerificationCode(no address, payer down, …). - Used by:
POST /me/lightning-address/verification.
- Purpose: Reads the WebAuthn credential
idfrom an untyped finish body. - Inputs: Unknown
credentialJSON. - Returns / side effects: Non-empty string id, or
null. - Used by:
finishPasskeyAuthentication.
- Purpose: Filters CORS origins to those whose hostname equals the RP ID, or
app.<rpId>(no general subdomain suffix). - Inputs:
rpId,allowedOrigins. - Returns / side effects: Matching origin strings; invalid URLs dropped.
- Used by:
resolveWebAuthnConfig.
- Purpose: Verifies a discoverable-credential assertion, CAS-updates signCount, issues a session only when the CAS succeeds. Optional
nostrbest-effort backfills a missing nsec. - Inputs: store, ceremony, config, now, Origin, challengeId, credential, optional
nostr. - Returns / side effects:
{ ok: true, value: { token, account } }or{ ok: false, error }. CAS failure is{ ok: false, error: 'Invalid passkey' }. - Used by:
POST /auth/passkey/authenticate/finish.
- Purpose: Verifies an attestation and issues a session. When the challenge account id already exists (claim path), binds the credential to that provisioned row without
createAccountand neverdeleteAccounton failure. When the account is new, creates alinkingKey: nullaccount plus credential; optionalnostrmints a custodial nsec (rollback on keygen failure) and a duplicate credential id rolls the new account back. - Inputs: store, ceremony, config, now, Origin, challengeId, credential, optional
nostr. - Returns / side effects:
{ ok: true, value: { token, account } }or{ ok: false, error }. Claim-path credential race →{ ok: false, error: 'Invalid passkey' }with the provisioned account left intact. Nostr keygen failure on claim is best-effort (same as authenticate): session still issues. - Used by:
POST /auth/passkey/register/finish.
- Purpose: Mints a bearer session token for an already-authenticated account.
- Inputs:
store,now,account. - Returns / side effects:
{ token, account }; writes the session row. - Used by: passkey finish paths and
POST /debug/accounts/:id/session.
- Purpose: Trims
WEBAUTHN_RP_ID; missing/blank/unknown isnull(only21.gifts/dev.21.gifts/localhost; fail closed on passkey routes). - Inputs: Raw env string or
undefined. - Returns / side effects: Trimmed RP ID or
null. - Used by:
resolveWebAuthnConfig.
- Purpose: Builds RP ID, RP name, and expected origins for passkey ceremonies.
- Inputs: env slice (
WEBAUTHN_RP_ID, optionalWEBAUTHN_RP_NAME) and CORS origins. - Returns / side effects:
WebAuthnRuntimeConfigornullwhen unconfigured. - Used by:
authRoutespasskey handlers.
- Purpose: Production
PasskeyCeremonywrapping@simplewebauthn/server(residentKey + userVerification required). - Inputs: Generate/verify methods take RP/user fields or browser JSON plus stored credential material.
- Returns / side effects: Options JSON + challenge, or
{ ok: false, reason }on verify failure. - Used by:
createAppdefaultpasskeyCeremony.
- Purpose: Mints discoverable-credential request options (
allowCredentialsempty). - Inputs: store, ceremony, config, now.
- Returns / side effects:
{ challengeId, options }; persists a passkey challenge. - Used by:
POST /auth/passkey/authenticate/begin.
- Purpose: Mints WebAuthn creation options and a pending account UUID (row created only on finish). Display name is always
21.gifts. - Inputs: store, ceremony, config, now.
- Returns / side effects:
{ challengeId, options }; persists a passkey challenge. - Used by:
POST /auth/passkey/register/beginwhen the body has no stringviewKey.
- Purpose: Mints WebAuthn creation options for an existing operator-provisioned account identified by
viewKey. Uses the stored account id andaccount.name(or21.giftswhen null) as the WebAuthn user entity. - Inputs: store, ceremony, config, now, viewKey.
- Returns / side effects:
{ ok: true, value: { challengeId, options } }or{ ok: false, error }(This profile could not be found./This profile already has a passkey). Persists a register challenge bound to the existing account id. - Used by:
POST /auth/passkey/register/beginwhen the body includes a stringviewKey.
- Purpose: Next owner wizard step from stored account fields. Skip timestamps count as completing that step. The api is the source of truth; clients only route.
- Inputs:
Account. - Returns / side effects:
'name'when name is null/blank andnameSkippedAtis unset, else'lightning-address'when Lightning Address is null/blank andlightningAddressSkippedAtis unset, else'rules'whenrulesAgreedAtis null, elsenull. No I/O. - Used by:
serializeOwnerAccount.
- Purpose: Factually unset account fields for action gates. Skip timestamps do not clear a field from this list.
- Inputs:
Account. - Returns / side effects:
AccountMissingField[]in ordername,lightning-address,rules(only those that are null/blank or rules unset). No I/O. - Used by:
serializeOwnerAccount,requireAction.
- Purpose: Declare which account fields an action needs before it may proceed.
- Inputs:
AccountAction(forum.read|forum.post|contact.post|forum.pay). - Returns / side effects: Readonly list in 409 order:
forum.read→rules;forum.post/contact.post→rules,name;forum.pay→rules. No I/O. - Used by:
requireAction.
- Purpose: Gate a signed-in action on factual account fields (skip does not satisfy). Filters
accountMissingto the action's needs, preservingactionRequirementsorder. - Inputs:
Account,AccountAction. - Returns / side effects:
{ ok: true }or{ ok: false, missing }(never empty). No I/O. Routes respond 409{ error: 'missing_requirements', missing }whenokis false. - Used by:
messagesRoutes,contactRoutes,membersRoutes.
- Purpose: Ensure a named account has exactly one top-level profile forum note. First non-blank name inserts one message (kind:1 pipeline defaults, frozen tags only) and stores
profileMessageId. Rename is idempotent and does not change note text. Recreates when the stored id is missing. Rolls back the insert ifupdateAccountfails or a later write wins the live pointer. OptionalpushStoreenqueues forum pushes for a new note. - Inputs:
{ auth, messages, account, now, pushStore? }. - Returns / side effects: The account (possibly with
profileMessageIdset). May insert a message and update the account; may delete an orphaned insert on update failure, a vanished row, or a laterprofileMessageIdwinner. - Used by:
meRoutes(POST /me/name),debugRoutesprovision, Nostr worker backfill.
- Purpose: Project an account to the nine-field dump without
viewKeyorisPlatform(no Nostr fields). - Inputs:
Account. - Returns / side effects: Nine public fields (
id,linkingKey,role,name,lightningAddress,lightningAddressVerified,forumLawsDismissed,createdAt,rulesAgreedAt). No I/O. No Nostr key material. - Used by:
serializeOwnerAccount(member/me) andserializeDebugAccount.
- Purpose: Operator account JSON: the nine public fields plus
isPlatform. Never used by memberGET /me. - Inputs:
Account. - Returns / side effects:
DebugAccountResponse.isPlatformis true only when the stored flag is true. NoviewKey. No I/O. - Used by:
GET /debug/accountsandPATCH /debug/accounts/:id.
- Purpose: Owner JSON for authenticated account responses: the nine public fields plus
viewKey,setup, andmissing, so the owner can copy the capability URL and the client can route onboarding and action gates. Used byGET /me,/mewrites includingPOST /me/rules-agreementandPOST /me/setup/skip, and passkey finish — never by the debug listing. Does not exposeprofileMessageId. - Inputs:
Account. - Returns / side effects:
OwnerAccountResponse(twelve fields includingsetupandmissing). No I/O. - Used by:
meRoutes,authRoutes.
- Purpose: Hono sub-app for
GET /members/:accountId. Bearer +requireAction(forum.read); UUID path; live identity plus optionalprofileMessageviaserializeMessage. - Inputs:
MembersRouteDeps(authStore,messageStore,now). - Returns / side effects: Hono app mounted at
/members. Logsmembers.get.failedon 503. - Used by:
createApp.
- Purpose: Public profile card for the capability URL. Five fields (
name,lightningAddress,lightningAddressVerified,createdAt,hasPasskey). Omitsid,linkingKey,role, andviewKey. - Inputs:
Account,hasPasskey: boolean. - Returns / side effects:
ViewProfileResponse. No I/O. - Used by:
viewRoutes.
- Purpose: Parse
NOSTR_NSEC_KEKas 32-byte AES key (64 lowercase hex). - Inputs: Env string or
undefined. - Returns / side effects:
Uint8Arrayor throw. - Used by:
openBootStores.
- Purpose: Decode lowercase hex.
- Inputs: Even-length hex string.
- Returns / side effects: Bytes or throw.
- Used by: Tests and KEK helpers.
- Purpose: Encode bytes as lowercase hex.
- Inputs:
Uint8Array. - Returns / side effects: Hex string.
- Used by: Tests.
- Purpose: Derive NIP-01 hex pubkey.
- Inputs: 32-byte secret.
- Returns / side effects: 64-char hex.
- Used by:
ensureAccountNostrKey.
- Purpose: AES-256-GCM envelope for a 32-byte nsec.
- Inputs: secret, kek, accountId, optional kekId.
- Returns / side effects: Envelope bytes.
- Used by:
ensureAccountNostrKey.
- Purpose: Decrypt a v1 envelope (
kek_id=1only). - Inputs: envelope, kek, accountId.
- Returns / side effects: 32-byte secret.
- Used by:
signEventForAccount.
- Purpose: Overwrite a secret buffer with zeros.
- Inputs:
Uint8Array. - Returns / side effects: In-place fill.
- Used by:
ensureAccountNostrKey,signEventForAccount.
- Purpose: Generate and store a custodial keypair if missing (CAS).
- Inputs: AuthStore, accountId, kek, optional keygen.
- Returns / side effects: Hex pubkey. Logs
nostr.keygen. - Used by: Worker, authenticate-finish.
- Purpose: Build a
NostrKeyRecordfor register-finish. - Inputs: accountId, kek, optional keygen.
- Returns / side effects: Record for
setNostrKeyIfAbsent. - Used by:
finishPasskeyRegistration.
- Purpose: Copy frozen kind:1 tags.
- Inputs: none.
- Returns / side effects:
[["t","bitcoin"],["t","21gifts"],["r","https://21.gifts"]]. - Used by:
buildKind1Event.
- Purpose: Case-insensitive check that kind:1 content already contains
#nameas a hashtag token (next character must not be[A-Za-z0-9_]; the#prefix distinguishes#21giftsfromhttps://21.gifts). - Inputs: content string, hashtag name without
#. - Returns / side effects: True when the token is present; otherwise false.
- Used by:
kind1ContentWithHashtags.
- Purpose: Append any missing Damus-visible
#bitcoin/#21giftstokens to Nostr kind:1 content (forum DBtextstays unchanged). Empty →"#bitcoin #21gifts"; non-empty strips trailing newlines then appends\n\n+ missing tags in fixed order; a tag is present whenkind1HasHashtagmatches (#bitcoinersis not#bitcoin). - Inputs: content string.
- Returns / side effects: content with missing hashtags appended.
- Used by:
buildKind1Event;listSignedMissingHashtags(in-memory helper).
- Purpose: Absolute
GET /messages/:id/photo.jpg(or.png/.webp) URL for kind:1 content andimeta. The extension matches the stored MIME so Damus treats the URL as an image, not a website. - Inputs: API origin, message id, optional MIME (default JPEG).
- Returns / side effects: URL string.
- Used by: Worker sign path.
- Purpose: Unsigned kind:1 for a forum line (top-level or NIP-10 reply). Optional media (
Kind1Photo: image or video MIME) appends the public URL to content and a NIP-92imetatag (url,m, optionaldim, optionalsize, optionalimagefromposterUrl). Always ensures Damus-visible#bitcoin/#21giftsviakind1ContentWithHashtags, appending only missing tokens (forum rowtextis not modified). WhenreplyTois set, adds NIP-10e(root + reply) andptags after the frozen tags (and optionalimeta); top-level notes never gete/p/q. - Inputs: content, unix created_at, optional
{ url, mime, posterUrl?, dim?, size? }(Kind1Photo), optionalreplyTo?: Kind1ReplyTo(noteEventId,spaceRelay,noteAuthorPubkey). - Returns / side effects: Unsigned fields (
kind,content,tags,created_at). - Used by: Worker sign path.
- Purpose: Kind:0 JSON without extra whitespace (
name,display_name,website,picture,about, optionallud16, optionalnip05). - Inputs: name, lightningAddress or null, optional nip05 or null, optional
about(default'21.gifts'; worker passes profile-note text when present). - Returns / side effects: JSON string;
pictureis always the 21.gifts icon;aboutis the fourth argument;lud16only when address set;nip05only when a public identifier is passed. - Used by:
buildKind0Event, workerpublishProfiles.
- Purpose: Unsigned replaceable kind:0, including optional
nip05and optionalabout. - Inputs: name, lightningAddress, unix created_at, optional nip05, optional about (default
'21.gifts'). - Returns / side effects: Unsigned fields.
- Used by: Worker
publishProfiles.
- Purpose: Unsigned NIP-65 relay list.
- Inputs: relay URLs, unix created_at.
- Returns / side effects: Unsigned fields.
- Used by: Worker
publishRelayLists.
- Purpose: Unsigned kind:9734 used only to probe whether a Lightning Address mints a NIP-57 invoice.
- Inputs: recipient pubkey, amount msat, relay URLs.
- Returns / side effects: Unsigned event template (
p/amount/relaysonly). - Used by:
probeNip57Mint.
- Purpose: Request a throwaway zap invoice and accept the address only when
description_hashmatches the signed 9734 JSON. - Inputs: LUD-16 address, signer pubkey, sign helper, fetch, optional env.
- Returns / side effects:
'ok' | 'not_zap' | 'unreachable'. Never pays. Never writesmessage_invoice. - Used by:
POST /me/lightning-address,POST /debug/accounts.
- Purpose: Short npub-style label for Damus authors without a 21.gifts account.
- Inputs: hex pubkey.
- Returns / side effects: Truncated display string.
- Used by: Inbound forum replies; conversation display names (
GET /conversationsDamus-only counterparts).
- Purpose: Decrypt nsec,
finalizeEvent, zeroize. - Inputs: store, accountId, kek, unsigned template.
- Returns / side effects: Signed event. Never logs the secret.
- Used by: Worker,
POST /messages/:id/invoice.
- Purpose:
NOSTR_PUBLISH === "1". - Inputs: env slice.
- Returns / side effects: boolean.
- Used by:
resolveWriteSet.
- Purpose:
NOSTR_PUBLISH_PUBLIC === "1". - Inputs: env slice.
- Returns / side effects: boolean.
- Used by:
resolveWriteSet.
- Purpose: Durability relay URL.
- Inputs: env slice.
- Returns / side effects: Trimmed
NOSTR_RELAY_SPACE, elseNOSTR_RELAY_URL, else PRD default. - Used by:
resolveWriteSet,resolveZapRelays.
- Purpose: Public write relay list.
- Inputs: env slice.
- Returns / side effects: Split
NOSTR_RELAY_PUBLICor default three. - Used by:
resolveWriteSet,resolveZapRelays.
- Purpose: Combine flags + URLs for one worker tick.
- Inputs: env slice.
- Returns / side effects:
{ spaceUrl, publicUrls, publishEnabled, publicEnabled }. - Used by: Worker publish (
runNostrWorkerTick/publishProfiles/publishRelayLists/publishBatch).
- Purpose: Space URL plus public URLs when public write is on.
- Inputs: resolved write set.
- Returns / side effects: URL list for EVENT fan-out.
- Used by: Worker publish.
- Purpose: HTTP origin for kind:1 photo URLs. Maps
https://21.gifts→https://api.21.giftsandhttps://dev.21.gifts→https://dev-api.21.gifts; otherwise the trimmedPUBLIC_BASE_URL. - Inputs: env slice.
- Returns / side effects: Origin without trailing slash, or empty.
- Used by: Worker sign path.
- Purpose: Relays for zap receipt ingest and kind:9734 invoice
relaystags (space plus public list, independent ofNOSTR_PUBLISH_PUBLIC). - Inputs: env slice.
- Returns / side effects: Space URL first, then unique
resolveRelayPublicentries. - Used by:
runNostrWorkerTickingest;POST /messages/:id/invoice.
- Purpose: UTC
YYYY-MM-DDfrom epoch ms. - Inputs: nowMs.
- Returns / side effects: Day key.
- Used by:
PostRateLimiter.
- Purpose: In-process post caps (1/10s, 6/h, 20/UTC-day).
- Inputs:
allow(accountId, nowMs). - Returns / side effects: boolean; idle eviction 48h.
- Used by:
POST /messages.
- Purpose: In-process invoice caps (1/10s, 20/h).
- Inputs:
allow(accountId, nowMs). - Returns / side effects: boolean.
- Used by:
POST /messages/:id/invoice.
- Purpose: Test fake that records EVENT publishes.
- Inputs: event, urls, timeout.
- Returns / side effects: ACK list;
okflag. - Used by: Worker tests.
- Purpose: Test fake
NostrQuerierthat records REQ calls and returns configured events. - Inputs:
query(filter, urls, timeoutMs); tests setevents. - Returns / side effects: Copied event list; fills
calls. - Used by: Worker unit tests.
- Purpose: Coerce stored/wire signed events (object, JSON string, double-encoded jsonb string) into a plain object so EVENT frames never send a string payload.
- Inputs: Unknown value.
- Returns / side effects: Shallow-copied object or
null(arrays, primitives, invalid JSON). - Used by:
WebsocketNostrPublisher.publishOne;mapMessageRow.
- Purpose: Production
NostrPublisherthat opens one WebSocket per relay URL, runsnormalizeSignedEventso the EVENT second element is an object, sends["EVENT", event], and waits for a matching["OK", id, true|false](or timeout/error) before closing. - Inputs: Optional
WebSocketFactory(defaultnew WebSocket(url));publish(event, urls, timeoutMs). - Returns / side effects: One
RelayAckper URL in input order; never leaves sockets open after settle. Injectable factory keeps unit tests off the network. - Used by: Process entry
src/index.tswhen KEK + durable message store present.
- Purpose: Production
NostrQuerier: one WebSocket per URL, send["REQ", subId, filter], collect EVENT object payloads (id, pubkey, kind, tags, plus content/created_at/sig when present), stop on EOSE/timeout, CLOSE and close socket. Factory throw / error / timeout contribute no events. Dedup by id; first URL in the list wins. - Inputs: Optional
WebSocketFactory;query(filter, urls, timeoutMs). - Returns / side effects:
NostrEventFrame[]; never throws; no live subscription past the call. - Used by:
src/index.tsworker wiring.
- Purpose: Whether the space relay ACK'd OK.
- Inputs: acks, spaceUrl.
- Returns / side effects: boolean.
- Used by: Worker.
- Purpose: Whether a non-space relay ACK'd OK.
- Inputs: acks, spaceUrl.
- Returns / side effects: boolean.
- Used by: Worker.
- Purpose: Sign unsigned rows; fan out when
NOSTR_PUBLISH=1. Space-only ACK is terminalpublished/space. WithNOSTR_PUBLISH_PUBLIC=1, space-only parkspendinguntil a public ACK. Pending kind:1 JSON withoutt=bitcoinis dropped and re-signed, then unsigned rows are signed. After that, published unpaid notes missing a photo URL, a video URL, or Damus#bitcoin/#21giftsin content are reset for the next tick (PUBLIC_BASE_URLset for media URLs; video posters are not treated as missing photos;profileMessageIdrows are skipped so a name note is not rewritten with those hashtags). Pending rows EVENT as-is so a reset cannot renew the 60s sign lease. Zapped rows keepeventId. An empty API base skips photo/video-URL resign. Sign looks up photo bytes even whenhasPhotois stale. Each tick runsbackfillProfileMessagesfor named accounts missing a profile note. When publishing, also fans out kind:0 profiles (name/display_name/picture/ optionalnip05,aboutfrom the profile-note text or21.gifts) and NIP-65 kind:10002 relay lists. Kind:1 photo/video posts include the public media URL andimeta. Each tick queries zap relays (space plus the public list, even whenNOSTR_PUBLISH_PUBLICis off) for kind:9735 and indexes validated receipts ontosats, even whenNOSTR_PUBLISHis off. Each tick also runssignConversationBatch(NIP-17 wraps when a conversation store is present) and, whenNOSTR_PUBLISH=1,publishConversationBatch. After zap ingest,indexInboundForumReplies(REQ kind:1#eour published note ids; persist Damus/member replies even when publish is off) andindexInboundDirectMessages(REQ kind:1059 / kind:4 to member and platform pubkeys when a conversation store is present). - Kind:0 cache: Unchanged content is not resent for the life of the AuthStore instance. After the live account row is read, the worker stores a reservation object and treats only that object as owner after each await. A nack or throw deletes the reservation only when it is still that object; the last issued
created_atwatermark is kept so a retry in the same second still increments. Kind:0created_atismax(wall clock, last issued + 1)so an in-flight older profile cannot win a same-second replaceable-event tie. - Kind:0 batch: At most
WORKER_BATCHkeyed attempts run per tick, including nacks. With public fan-out on, a space-only ACK is a nack and the profile is retried. - Inputs: worker deps.
- Returns / side effects: Store updates; logs
nostr.sign.failed/nostr.publish.*/nostr.profile.ok/nostr.profile.nack/nostr.relays.ok/nostr.relays.nack/nostr.dm.sign.failed/nostr.dm.publish.*. Event-id collision retries once withcreated_at + 1. - Used by:
startNostrWorker.
- Purpose: Interval handle around
runNostrWorkerTick. - Inputs: deps, intervalMs.
- Returns / side effects:
{ stop }. - Used by: Process entry
src/index.tswhen KEK + message store present.
- Purpose: Unsigned kind:9734 for a forum event.
- Inputs: recipient pubkey, event id, amountMsat, relays.
- Returns / side effects: EventTemplate.
- Used by:
POST /messages/:id/invoice.
- Purpose: Validate provider pubkey (case-insensitive hex) and add sats once per receipt id. Callers verify the Nostr signature first. Persists a
nostr_zap_ingestrow (indexed, orrejectedwith reasonpubkey/amount/duplicate); store throw logsnostr.zap.ingest.record_failedand does not change the boolean result. - Inputs: store, messageId, receipt, providerPubkey, amountSats; optional receiptEvent / noteEventId for debug rows.
- Returns / side effects: boolean; logs indexed/rejected; records ingest.
- Used by:
indexOpenZapReceipts(worker tick).
- Purpose: Each worker tick, query zap relays for kind:9735 on recent notes (chunks of 20 event ids), verify the Nostr signature, validate provider pubkey via LNURL (module TTL cache, lowercased), bolt11 amount, e-tag, and index via
indexZapReceipt. Persists every ingest decision (indexed/rejectedwith reason). One throwing receipt does not skip the rest of the tick. A newly indexed receipt enqueues a zap push whenpushStoreis set (push.enqueue.failedon throw, ingest continues). - Inputs: store, auth, querier, urls, timeoutMs, now, fetchImpl; optional
verifyReceipt(default: nostr-toolsverifyEvent); optionalpushStore. - Returns / side effects: void; logs
nostr.zap.rejected/indexed; records ingest rows; never logs full bolt11. - Used by:
runNostrWorkerTick.
- Purpose: LNURL-pay callback with
nostr=(notcomment=). Captures raw LNURL callback JSON so callers can persist it on invoice-attempt rows. Never pays. - Inputs: address, amountMsat, zapRequestJson, fetchImpl.
- Returns / side effects: Every path includes
lnurlResponse(Record<string, unknown>when the callback body was a JSON object, elsenull). Success:{ ok: true, pr, amountSats, lnurlResponse }. Failure:{ ok: false, reason: 'noZap' | 'unreachable', lnurlResponse }—noZapwhenallowsNostris not true ornostrPubkeyis missing;unreachableon resolve/amount/callback/schema failure (lnurlResponseis the raw object when a JSON body was received, otherwisenull). - Used by:
POST /messages/:id/invoice,probeNip57Mint.
- Purpose: Unsigned/pending defaults for a new forum row.
- Inputs: none.
- Returns / side effects: Column defaults including
sats: 0,parentId: null,authorPubkey: null, plus unsigned/pending Nostr columns (eventId/nostrEvent/claimedUntil/nostrFirstAttemptAt/nostrPublishEpochnull,nostrPublishState: 'pending',nostrAttempts: 0). - Used by:
POST /messages, stores.
- Purpose: Unique NIP-05 local-part; first slug wins, collisions append account-id hex.
- Inputs: name, account id, taken set.
- Returns / side effects: local-part string.
- Used by:
nip05Identifier,listNip05Entries.
- Purpose: NIP-05
names+relaysmap forGET /.well-known/nostr.json. - Inputs: auth store, env, optional name filter.
- Returns / side effects: JSON body.
- Used by:
wellKnownRoutes.
- Purpose: Size + magic-byte check for MP4/WebM/MOV (32 MiB cap). MP4/MOV bytes are passed through
faststartIsoBmff(moovbeforemdatonly when remux succeeds; abort cases keep the original bytes). - Inputs: raw bytes.
- Returns / side effects:
{ contentType, bytes }or null. - Used by:
POST /messagesmultipart.
- Purpose:
ftyp/ WebM magic → MIME. - Inputs: bytes.
- Returns / side effects: MIME or null.
- Used by:
decodeForumVideo.
- Purpose: Rearrange ISO-BMFF so
moovprecedesmdat(qt-faststart), patchingstco/co64chunk offsets. Aborts to the originalbytesreference (no remux) when already faststart (moovalready beforemdat), truncated / invalid box tree, truncated or oversizedstco/co64tables, top-levelmoof,cmov, not exactly one top-levelmoovand one top-levelmdat, missingstco/co64(no chunk-offset box visited), orstcooverflow (uint32 chunk offset would exceed0xffffffff). - Inputs: container bytes.
- Returns / side effects: Same-length remuxed copy with
moovbeforemdatand patchedstco/co64, or the originalbytesreference on abort. - Used by:
decodeForumVideo;readForumVideoBytes.
- Purpose: Damus path extension for a video MIME.
- Inputs: MIME.
- Returns / side effects:
mp4/webm/mov. - Used by: public video URLs.
- Purpose: Absolute
GET /messages/:id/video.mp4(or.webm/.mov) URL. - Inputs: API origin, message id, MIME.
- Returns / side effects: URL string.
- Used by: Worker sign path.
- Purpose: Integer width/height from the first non-zero
tkhd(16.16 fixed) undermoov/trak. - Inputs: ISO-BMFF bytes.
- Returns / side effects:
{ width, height }or null. - Used by: Worker kind:1 video
imetadim.
- Purpose: Named accounts with pubkeys, oldest first, unique locals.
- Inputs: auth store.
- Returns / side effects:
Nip05Entry[]. - Used by:
buildNostrJson.
- Purpose: Hostname from
PUBLIC_BASE_URL; null for loopback/IP. - Inputs: env.
- Returns / side effects: hostname or null.
- Used by: kind:0
nip05.
- Purpose:
local@domainfor one account matchingnostr.json. - Inputs: account, named accounts oldest-first, domain.
- Returns / side effects: identifier string.
- Used by: Worker kind:0.
- Purpose: Display name →
a-z0-9-local-part (userif empty). - Inputs: name.
- Returns / side effects: slug.
- Used by:
allocateNip05Local.
- Purpose: Parse
bytes=start-endfor 200 / 206 / 416 responses (RFC 7233). - Inputs: header, file size.
- Returns / side effects:
{ type: 'full' }|{ type: 'partial'; start; end }|{ type: 'unsatisfiable' }. - Used by:
GET /messages/:id/video.*.
- Purpose: Read video bytes from disk, remux with
faststartIsoBmff, and rewrite the file when boxes move (heal-on-read for clips stored before faststart). Heal writes a sibling temp file named withcrypto.randomUUID()in the same directory aspath, thenrenames that temp ontopath. - Inputs: absolute path; optional
iodisk ops (tests). - Returns / side effects: Bytes to serve. On write/rename failure the original file is left in place and the remuxed buffer is still returned.
- Used by:
GET /messages/:id/video.*.
- Purpose: Best-effort unlink of a stored video file.
- Inputs: message id, MIME, env.
- Returns / side effects: void.
- Used by: tests; create rollback.
- Purpose: Trimmed
MEDIA_DIRfor forum video files on disk; no temp fallback. - Inputs: env (defaults to
process.env). - Returns / side effects: Trimmed path. Throws
Error(MEDIA_DIR must be a non-empty path) when missing, not a string, or blank after trim. Boot calls it before stores /Bun.serve. - Used by: video read/write;
index.tsboot.
- Purpose:
{dir}/{id}.{ext}on disk. - Inputs: dir, id, MIME.
- Returns / side effects: path.
- Used by: write/read/serve.
- Purpose: True when the stored video file exists, is a regular file, and is non-empty.
- Inputs: media dir, message id, MIME or
null, optionalstatinject. - Returns / side effects:
falsewhen MIME is null, the path is missing (ENOENT), not a file, or size 0; non-ENOENTstaterrors propagate (callers must not delete the row). Stats disk. - Used by:
messagesRouteslist, public GET, and replies.
- Purpose: Hono
GET /nostr.json(CORS*). - Inputs: auth store, env.
- Returns / side effects: Hono app mounted at
/.well-known. - Used by:
createApp.
- Purpose: Persist video bytes under
MEDIA_DIR(caller should already faststart MP4/MOV viadecodeForumVideo). - Inputs: message id, video, env.
- Returns / side effects: mkdir, write UUID sibling temp,
renameonto the public path so readers never see a partial file. - Used by:
MessageStore.create;debugMessagesRoutes.