Skip to content

Latest commit

 

History

History
1059 lines (906 loc) · 175 KB

File metadata and controls

1059 lines (906 loc) · 175 KB

21.gifts — Concept

Peer-to-peer donation platform. Direct human-to-human giving over Bitcoin Lightning, with NOSTR as the invisible communication substrate.

Status: draft, in active iteration. Last revised 2026-09-20.


Vision

Help people in difficult situations by enabling direct gifts from one human to another — without any organizational middleman taking a cut, gatekeeping, or politicizing the flow of help.

Bitcoin Lightning is the only payment rail for a gift. A shop till is the decided exception: USDT or USDC on the same OpenCryptoPay QR still leaves the shop with bitcoin on Spark, specified in docs/shop-spark-payment.md, not implemented. NOSTR is the only message rail. Both are plumbing — the user just sees a website where they can ask for help or send help.


Convictions

21.gifts is built on three convictions. They are the reason 21.gifts exists; the Core Principles below say how it is built.

1. Giving is a duty of every Christian

Giving is not an optional extra of Christian life. Jesus assumes it: “When you give to the needy” (Matthew 6:2) — when, not if. Whoever has material possessions, sees a brother or sister in need, and has no pity on them does not have the love of God in them (1 John 3:17). Faith by itself, if it is not accompanied by action, is dead (James 2:17). To neglect the hungry, the thirsty, the stranger, the naked, the sick, and the prisoner is to neglect Christ himself (Matthew 25:31–46). “And do not forget to do good and to share with others, for with such sacrifices God is pleased” (Hebrews 13:16).

The measure is free — “each of you should give what you have decided in your heart to give, not reluctantly or under compulsion, for God loves a cheerful giver” (2 Corinthians 9:7). The New Testament sets no rate. The tithe of the Law is never laid on the church; the duty is to give, and the amount is between the giver and God.

Giving is not only money. A smile, time, a coat, a visit are gifts too. 21.gifts is the path for the money gift: one person to another.

The signature verse of 21.gifts is Matthew 10:8: “Freely you have received; freely give.”

2. Direct giving, with no middleman, is the best and most beautiful way to give

A gift that goes straight to the receiver’s own address is the short path. Advantages:

  • The whole gift arrives: 21.gifts takes no cut, charges no fee, and adds no program overhead.
  • No charity, platform, or program sits between giver and receiver to skim, delay, or politicize the gift; the only party in the path is the wallet the receiver chose.
  • Giver and receiver stay people to each other, not a case file and a campaign.
  • Both keep their dignity: one asks, one gives, and they meet as people in the living room.
  • 21.gifts never holds the receiver’s Bitcoin. If 21.gifts disappeared tomorrow, the Wallet of Satoshi addresses would keep working.
  • Help moves at the speed of the payment, not of a committee.
  • The giver can see the person who asked — not an abstract cause.

This is Core Principle 2 (Truly P2P) in practice on the receiving side; the v1 sending-side compromise is documented under the transitional model below.

3. Bitcoin is the most effective money available today

Bitcoin is the rail because it is the strongest money 21.gifts can put in a person’s hand.

  • Censorship-resistant. Bitcoin itself cannot be told to refuse a gift that the sender and receiver have agreed. Capital controls, frozen bank accounts, war, a closed branch — none of them can stop the network.
  • Permissionless. No account application, no ID gate, no banking hours. A phone and a Wallet of Satoshi address are enough, also for people no bank will open a file for.
  • Borderless and always on. One network, every country; nights, weekends, bank holidays, and crises included. An international gift does not wait on correspondent banks or a money-transfer shop: it is sent like an email and arrives as Bitcoin, not as a form.
  • Cheap and fast at human scale. Lightning makes small gifts practical; an email-like address is enough to receive.
  • Hard cap. 21 million, so inflation cannot quietly tax people who already have little.
  • Bearer money. In a self-custodial wallet — Wallet of Satoshi offers one — nobody holds it for the receiver. 21.gifts never holds it in any case.

On the website we say “Bitcoin” and “Wallet of Satoshi”; Lightning, LNURL, and keys are plumbing and stay out of sight.

The public version of these convictions is /about in the app, in every catalog locale. This section is the full argument.


Core Principles

  1. Non-profit — the platform itself earns nothing beyond what it costs to operate
  2. Truly P2P — funds flow donor → receiver directly; the platform never custodies money
  3. Open protocol — anyone can build a client; the website is one reference implementation
  4. NOSTR-native, NOSTR-invisible — every message in the UI is also a NOSTR event, visible in Damus/Amethyst/etc., but the user is never asked about keys or relays
  5. Self-sovereign keys — Passkey + PRF derives the NOSTR key client-side; the server never sees raw key material
  6. Lightning Address mandatory — receivers must have a LUD-16 address; the platform never custodies receiver funds
  7. English canon, localized visitor UI — CONCEPT, identifiers, commits, handbook, and api payloads stay English. Visitor-facing app copy is localized (en, de, es, fil). Adding a fifth locale is out of scope unless Brand changes.
  8. Thin client, thick server — the browser holds only what must be client-side (keys, signing, wallet flow). Everything else — relay communication, indexing, discovery, LN-Address resolution, anti-abuse — lives in the backend API. The app bundle stays tiny.

v1 Transitional Model (decided 2026-07-05)

v1 ships with a deliberately simplified account and custody model so the platform can go live and be dogfooded. It deviates from Core Principle 2 on the donor's sending side (custodial LNDHub spending) and from Core Principle 5 for all v1 accounts (no client-side keys; each account's NOSTR identity is custodial, held and used for signing server-side — see "NOSTR in v1" below). Both deviations are transitional and will be replaced by a non-custodial setup. Receiving stays non-custodial (LUD-16 only, as before).

Roles

One exclusive account.role per account. Initiator has the same rank as moderator; founder stays strictly above. A higher rank can always do and see everything a lower rank can; equal ranks can do the same things. Permission text names the minimum rank only. Do not write "moderator or initiator" or „Moderator oder Initiator“. The single named exception is canEditDailyPayoutRoster: initiator and moderator share rank 2, so roleAtLeast cannot exclude moderators; true only for initiator and founder. New passkey accounts are Basis. verified is a moderator confirming this person in real life (forum badge), not Lightning-Address proof. POST /trust/verify must have confirmed the stored name. A funding-program grant is independent of that role: moderators review living-room posts against the three convictions (human decision). basis cannot apply. Spend pings and spend invoices require an admitted grant or a trial on today's UTC day. A moderator is proposed by an existing moderator and confirmed by a different staff member, or appointed directly by a founder. Those grants persist as trust edges (POST /trust/verify, POST /trust/propose-moderator, POST /trust/confirm-moderator, POST /trust/reject-moderator, POST /trust/appoint-moderator). GET /trust-chain requires a member Bearer session (any role) and returns founder seeds; ?around=<id> returns one hop of stored public edges with at most one incoming edge per subject (no inferred links). Operator PATCH /debug/accounts/:id can still set role and does not write trust edges; POST /debug/trust-edges backfills stored edges and DELETE /debug/trust-edges removes the latest stored (subjectId, kind) row (createdAt desc, then id desc), both without changing role.

Role Capabilities
Basis Log in, maintain a profile, receive gifts (default). No forum tag. Pays 1 sat to 21.gifts before posting or replying.
Verified Everything Basis can, plus a forum tag: a moderator physically met this person. Not Lightning-Address proof-of-control. May post and reply without a Bitcoin payment.
Moderator Everything Verified can, plus content moderation, the staff inbox, the closed Moderators group and the staff trust routes (verify a member, propose, confirm, or reject a moderator). Forum tag.
Initiator Rank 2, same rank as moderator. Forum tag.
Founder Everything Moderator can, plus appointing moderators directly. Forum tag.
Role Capabilities
--------- ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Basis Log in, maintain a profile, receive gifts (default). No forum tag.
Verified Everything Basis can, plus a forum tag: a moderator physically met this person. Not Lightning-Address proof-of-control. May reply without a Bitcoin payment.
Moderator Everything Verified can, plus content moderation, the staff inbox, the closed Moderators group and the staff trust routes (verify a member, propose, confirm, or reject a moderator). Forum tag.
Initiator Rank 2, same rank as moderator. Forum tag.
Founder Everything Moderator can, plus appointing moderators directly. Forum tag.

Becoming a donor is an upgrade available to every account, not a role of its own (see below). The forum shows a tag only for Verified, Moderator, Initiator, and Founder.

Login: passkey only

  • Passkey (WebAuthn) is the only login method. No email, no password, no LNURL-auth. The browser creates or asserts a discoverable credential; the api issues a bearer session immediately. Account identity is account.id.
  • Accepted trade-off: if the user loses the passkey and any platform sync, the account is unrecoverable. LNURL-auth was removed (2026-08-24); leftover account.linking_key values are historical and cannot log in.
  • WebAuthn RP ID is WEBAUTHN_RP_ID (21.gifts / dev.21.gifts / staging.21.gifts). Missing RP ID → passkey routes 500; the process still boots.
  • Operator provision / viewKey claim (2026-08-30): POST /debug/accounts can create accounts with name + Lightning Address and no passkey. The public viewKey URL is the invite. POST /auth/passkey/register/begin with { "viewKey" } binds a passkey to that row (name and address stay); living-room rules agreement remains a later /me step.

Donor upgrade (custodial, v1 only)

Any account can additionally become a donor and spend money:

  • Recurring paying uses an external spend worker with a lightning.space LNDHub wallet. This api does not store LNDHub credentials and does not pay.
  • The api issues recipient BOLT11 invoices (POST /invoices) and verifies the payment preimage (POST /invoices/proof). A matching proof records the outbound gift for public GET /gifts/stats.
  • This remains a v1 custody compromise (the worker can spend), documented because it contradicts the non-custodial target; that replacement retires it.

Receiver address verification

A receiver's Lightning Address is entered free-form on sign-up (a wrong address is self-punishing — gifts simply go elsewhere). Proof of control sets lightningAddressVerified (not the forum role Verified) via micro-payment: the api pays 1 sat (or the provider's minSendable if higher, capped at 10 sat) with a one-time nonce in the LNURL-pay comment (LUD-12; Wallet of Satoshi allows 255 characters); the user reads the nonce from the wallet's transaction history and enters it in the app (POST /me/lightning-address/verification + …/confirm). No LNDHub payer is wired yet — start returns 503 until one is injected; the process still boots. No LUD-21 dependency — WoS does not implement LNURL-verify.

Recurring gifts (v1 feature)

Donors can configure fixed USD amounts to a list of recipients. This api does not pay. Invoice HTTP (POST /invoices / POST /invoices/proof) is unchanged: the api issues BOLT11 invoices (LNURL-pay to the recipient) and verifies the payment preimage. A matching proof records the outbound gift for public GET /gifts/stats. An external worker holds lightning.space LNDHub credentials and pays when the recipient posts a top-level forum note (ping from this api); replies do not pay. Payout semantics on that worker are fail-closed: ambiguous outcomes quarantined as "uncertain", balance preflight before the first payment, and a per-donor cap. Recurring donor UI and an in-process scheduler are not HTTP yet. Do not invent /me/recurring.

NOSTR in v1

Passkey begin options request PRF so the client can derive a recovery phrase; API authentication and Nostr custody do not use it, so v1 runs NOSTR fully custodially (decided 2026-07-05, restated 2026-09-21, resolves Open Question #9): on sign-up the api generates a NOSTR keypair for the account, stores the nsec encrypted at rest, and signs that account's events server-side with the account's own key. Every profile, campaign, and comment therefore appears on the public NOSTR network under the user's own npub — attribution stays per-user, and external clients (Damus, Amethyst, …) see ordinary per-identity events. User-owned keys (Passkey + PRF → NIP-06) arrive with the non-custodial phase; the custodial-to-user-owned migration path is the remaining open part of Open Question #9.


Architecture

Identity & Keys

Post-v1 target architecture. v1 login is still a server-issued session after passkey. Begin options request WebAuthn PRF so the client can derive a recovery phrase in tab memory; PRF is not used for API authentication or server-side Nostr custody — see "v1 Transitional Model" above. Everything in this section describes the non-custodial phase that replaces that custody.

The full key flow, end-to-end:

WebAuthn Passkey (PRF extension)
        │
        ▼  prf.eval.first(SHA-256("21gifts-nostr-v1"))
   PRF output (32 bytes, deterministic from Secure Enclave)
        │
        ▼  HKDF-SHA256(salt="21gifts-seed-derivation", info="mnemonic-v1")
   128 bits of entropy
        │
        ▼  BIP-39
   12-word mnemonic
        │
        ▼  BIP-39 seed → BIP-32 master
   BIP-32 derivation at m/44'/1237'/0'/0/0   (NIP-06 path, 1237 = NOSTR slip-44)
        │
        ▼
   32-byte secp256k1 private key (NOSTR nsec)
        │
        ▼  schnorr_pubkey
   NOSTR npub

Key design choices — directly modeled after the zkCoins app passkey module:

  • PRF salt is cached — SHA-256("21gifts-nostr-v1") computed once per session and reused so all PRF evaluations yield the same deterministic output
  • Domain-separated HKDF — different info tags for different uses (mnemonic derivation, AES key derivation, future expansion). Same PRF output, different outputs by purpose.
  • Versioning — DERIVATION_VERSION = "v1" stored alongside the credential. Future versions can derive in parallel for migration.
  • Hard-fail on missing PRF — no silent fallback to a weaker scheme. If the authenticator doesn't expose PRF, the user is told to use a supported device.
  • Address in cleartext for the locked view — public NOSTR pubkey stored unencrypted so the locked UI can display "this is your wallet" without requiring authentication.

Why NIP-06 (BIP-39 mnemonic in the middle) instead of PRF → HKDF → nsec directly?

  • User-readable 12-word backup (familiar to anyone who has used a Bitcoin wallet)
  • Cross-client compatibility — Damus, Amethyst, and all NOSTR clients that implement NIP-06 can import the same mnemonic and recover the same identity
  • Future-proof — the same mnemonic can derive other keys (LN, BTC) later if the scope grows, without breaking the existing identity
  • Matches the architecture of related projects in the same stack, minimizing mental overhead

Recovery paths:

  1. Platform Passkey sync — iCloud Keychain, Google Password Manager, 1Password, Bitwarden, hardware authenticator with sync
  2. Optional explicit 12-word backup, shown once on sign-up, never sent to the server

Putting the same account on a new phone does two things only: a new passkey on that account, and the same 12-word backup. Mein Konto absichern is optional. The account works without it. An owner who continues is told what it does, then chooses two people. Both are required. If a moderator verified the owner, that person is suggested as person 1 and can be replaced. 21.gifts does not hold a share. Specified in docs/social-recovery.md. Not implemented. Restoring those words does not move bitcoin. The same words are the Spark wallet seed in docs/shop-spark-payment.md, which is also not implemented.

The server never holds the nsec. All NOSTR signing happens in the browser.

Browser support for WebAuthn PRF (as of 2026)

Platform / Authenticator PRF Support
iOS / macOS Safari 18+ ✅
Chrome on macOS/iOS ✅
Edge (Chromium) ✅
Android Chrome 132+ ✅
1Password 8+ ✅
Bitwarden ✅
YubiKey 5 (firmware 5.7+) ✅
Firefox partial — lagging behind

Unsupported-tail handling is deferred with Open Question #1 to the non-custodial phase (this table, like the rest of this section, is post-v1).

Donations

  • Receiver profile must include a Lightning Address (LUD-16)
  • The api resolves and caches LUD-16 metadata server-side, with health checks
  • Donor flow in the browser: click Donate → app reads cached LN-Address from api → browser fetches LNURL-pay callback → invoice → pay (browser ↔ wallet provider directly, the api is not in the payment path)
  • In this browser flow the api never sees the invoice, the amount, the payer, or the funds
  • v1 addition (see "v1 Transitional Model"): recurring gifts are paid by an external spend worker via lightning.space LNDHub. This api only fetches the invoice and verifies the preimage — it is not in the LNDHub pay path. The browser flow above remains for guests and one-off gifts.
  • Optional: NIP-57 Zap receipts published to NOSTR for transparent acknowledgements

Shop till (concept)

The shop QR stays the OpenCryptoPay link. A customer who pays USDT or USDC through that same QR leaves bitcoin on the shop's own Breez Spark wallet. The shop does not hold USDT or USDC. The 12-word seed that wallet uses is the phrase the phone already derives and never sends. Specified in docs/shop-spark-payment.md. Not implemented.

Communication

Every "message" the user writes in the UI is a NOSTR event. (v1 note: NOSTR is fully custodial in v1 — the api holds one keypair per account and signs events server-side with the account's own key, see "NOSTR in v1". The table below applies to v1 for the surfaces v1 ships — profile metadata, campaign post, public comment, and the custodial PN channel on /conversations. Zap-receipt / leaderboard rows stay deferred, see MVP scope. The client-side-signing flow beneath it is target state.)

UI surface NOSTR primitive
Profile metadata (name, photo, story) kind:0 (NIP-01 metadata)
Receiver profile / campaign post kind:1 (text note), tagged with campaign metadata
Public comment / encouragement top-level kind:1 (frozen t=bitcoin / t=21gifts / r=https://21.gifts; no e/p/q; Damus-visible #bitcoin #21gifts in kind:1 content; pending fan-out is not reset to stamp hashtags or photo URLs)
Private message donor ↔ receiver kind:14 (NIP-17 sealed DM, modern) or kind:4 (legacy)
Donation acknowledgement kind:9735 Zap receipt (when NIP-57 enabled)

Flow — the app does not talk to NOSTR relays directly. It talks to the backend API, which acts as the user's edge to the network:

app  ──signed event──→  api  ──fan-out──→  relays (public NOSTR network)
                                          (Damus, Amethyst, etc. observe)

app  ←──indexed feed──  api  ←──subscribe──  relays
  • The app signs every event client-side with the PRF-derived key
  • The app POSTs the signed event to the api
  • The api verifies the signature, applies anti-abuse filters, then fans out to the configured relay set
  • For reading, the api maintains an indexed view aggregated from the relay set and exposes simple REST/GraphQL endpoints — the app fetches one paginated resource, not raw relay traffic
  • Default relay set is configured server-side; users can opt into a "raw mode" later (deferred) where the app talks to relays directly with the same key
  • Target-state private DMs pass through the api as opaque encrypted payloads (client-side nsec). v1 is custodial: the api unwraps NIP-17 / decrypts kind:4 with the account nsec for the /conversations PN channel.

Public member forum (v1)

The /messages thread is a forum / messenger group, not a social-media feed. Visitors read it top-to-bottom like a group chat: oldest notes at the top, newest at the bottom, composer under the newest note. A new post is inserted at the bottom. GET /messages still returns the latest 200 notes newest-first so the window is "what is recent"; every client reverses that array for display.

Trust & Verification

Protocol level: completely open. Anyone publishes. Trust emerges from NOSTR reputation (who follows / vouches for whom).

Website level: stricter, to protect donors from obvious scams:

  • External NOSTR identities are shown only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol stays open. This applies retroactively both ways — a reply stored before its author's first zap stays invisible until that zap is recorded, and becomes visible immediately once it is recorded.
  • NIP-05 verification (optional, badged)
  • Profile completeness (story, photo, LN-Address resolves successfully)
  • Community vouching (other NOSTR identities sign off)
  • No KYC, no government ID

(v1 note: NIP-05 badging and NOSTR-identity vouching are post-v1 — v1 NOSTR identities are custodial, server-held. Proof of Lightning-Address control is the account flag lightningAddressVerified via micro-payment nonce, see "Receiver address verification". That flag is not the forum role Verified, which means a moderator physically met the person (account.role, via POST /trust/verify or an edge-less operator PATCH /debug/accounts/:id).)

The website is not a gatekeeper — it's a curator with transparent rules. If a receiver doesn't meet website requirements, they can still use a different client on the same protocol.

Backend (api)

Central to the architecture from day one. Holds the project's canonical documentation, schema, and protocol. The app is just one client of this api; other clients (mobile apps, third-party reference implementations) can target the same endpoints later.

Responsibilities:

  • NOSTR fan-out — accept signed events from clients and verify their signatures (target state — in v1 events originate and are signed server-side, see the v1 additions below), publish to the configured relay set
  • NOSTR aggregation / indexing — subscribe to relays, index events, expose paginated read endpoints for the app
  • LN-Address resolution + caching — LUD-16 endpoints get cached server-side with health checks; the app fetches a single normalized response
  • Discovery — recent campaigns, ordering, eventual categories / search
  • Anti-abuse signals — rate-limiting, spam scoring, suspicious-pattern detection at the edge
  • v1 additions (see "v1 Transitional Model"): passkey register/authenticate and sessions; spend-worker invoice HTTP (POST /invoices, POST /invoices/proof); receiver address verification via micro-payment nonce; custodial per-account NOSTR identities (nsec encrypted at rest) with server-side event signing

Non-responsibilities (stay client-side; target state — the v1 additions above temporarily move key generation/custody and event signing server-side; recurring paying stays in the external spend worker):

  • Passkey ceremonies, PRF evaluation, key derivation
  • Event signing (the api never sees the nsec)
  • Guest Donate LNURL-pay flow (browser → wallet provider directly). Recurring spend-worker invoices are the exception (POST /invoices).
  • Client-side decryption of NIP-17 sealed DMs (v1 custodial unwrap is on the api for /conversations)

The api lives in its own repository (21gifts/api) and is the canonical home for project-level documentation, including this concept document. The app repo (21gifts/app) only carries frontend-specific docs.

Durability: Durable Postgres writes are also appended to db_change with at / op / before / after. Secret columns token, challenge, nostr_nsec_ciphertext, nonce, and view_key are stored as SHA-256 hex in that JSON; other columns including name stay plaintext.

Storage (client-side)

Post-v1 target architecture (like "Identity & Keys" above). The api never stores a mnemonic or PRF output. The v1 session is a server-issued token bound to account.id after passkey authentication. The client may hold a PRF-derived 12-word phrase in tab memory only.

IndexedDB, two object stores:

Store Contents
credentials Passkey metadata (credential ID, derivation version, creation timestamp)
keystore Encrypted secret material (encrypted mnemonic / encrypted nsec); plus the NOSTR npub in cleartext for the locked view

Encryption: AES-GCM 256, with two key-derivation paths:

  • Passkey path — HKDF-SHA256 from PRF output, salt "21gifts-encryption", info "aes-key-v1"
  • Password path — PBKDF2-SHA256 from user password, 100,000 iterations, 16-byte random salt persisted with the ciphertext

MVP Scope (v1)

In — app:

  • Sign-in via passkey (WebAuthn discoverable credential; session bound to account.id)
  • Receiver profile UI: name, photo, story, Lightning Address (+ lightningAddressVerified via micro-payment nonce; not the forum role Verified)
  • Public campaign feed (rendered from api response)
  • Donate button → LNURL-pay (browser flow, works without an account)
  • Recurring gifts: configure USD amounts per recipient (paid by the external spend worker when the recipient posts a top-level note, not on a daily timer; replies do not pay)
  • Public comment composer (POST to api; signed server-side with the account's custodial key)
  • Moderation actions on campaigns/comments (Moderator role)

In — api:

  • Passkey endpoints: register/authenticate/replace begin and finish, session issuance; POST /me/wallet-backup-seen
  • Spend-worker invoice HTTP: POST /invoices / POST /invoices/proof (paying and LNDHub stay in the external worker)
  • Receiver address verification: micro-payment with one-time nonce in the LUD-12 comment
  • Custodial NOSTR identities: per-account keypair generated on sign-up, nsec stored encrypted at rest, events signed server-side
  • NOSTR fan-out to a default relay set
  • Subscribe to relays + index kind:0, kind:1 events
  • Read endpoints: feed, profile, replies-to-event, recent campaigns
  • LN-Address (LUD-16) resolution + cache + health check
  • Basic anti-abuse: rate-limit per account, malformed-input rejection
  • Moderation: hide/unhide content endpoints (Moderator role); staff POST /trust/verify, confirm-moderator, appoint-moderator write role + edge; POST /trust/propose-moderator writes the propose edge only; POST /trust/reject-moderator writes append-only moderator_reject (role unchanged); PATCH /debug/accounts/:id may still set role and does not write edges
  • USD → sats conversion for recurring-gift amounts via an exchange-rate source (fail-closed on a missing or implausible rate; paying stays in the spend worker)
  • Custodial PN channel on GET/POST /conversations (NIP-17 + kind:4; official platform account; Account.isPlatform; moderator_group is a closed HTTP group for moderators with no Nostr)
  • Forum replies (replyCount, GET /messages/:id/replies) and public GET /messages/:id
  • NIP-57 mint probe before linking a Lightning Address (POST /me/lightning-address and operator POST /debug/accounts unless NIP57_PROBE=0)

Out, deferred:

  • Passkey + PRF → NOSTR identity (NIP-06) — moves to the non-custodial phase
  • Client-side event signing (v1 signs server-side with custodial keys)
  • Any second login method or account recovery (no email, no backup auth — accepted risk, see "v1 Transitional Model")
  • Linking multiple LNURL-auth wallets to one account
  • Non-custodial donor spending (replaces the v1 spend worker)
  • Non-custodial client-side DMs (v1 ships a custodial PN channel on /conversations: NIP-17 + kind:4, official platform account; moderator_group is a closed HTTP group for moderators with no Nostr)
  • NIP-57 Zap receipts / leaderboards
  • NIP-05 verification badge
  • Native mobile app
  • Categories / filters / search
  • Smart matching / recommendations
  • Advanced anti-abuse (spam scoring, ML)
  • Pinned relay
  • "Raw mode" where the app talks to relays directly

Open Questions

  1. PRF fallback — what happens if the user's browser doesn't support PRF? Restated 2026-09-21: v1 begin options request PRF so the client can derive a recovery phrase. Missing PRF aborts register before finish. API authentication still does not consume PRF; server-side Nostr keys stay custodial until the non-custodial phase.
  2. Verification rigor beyond the external-identity floor — the website now shows an external NOSTR identity only after a verified zap of at least 1 sat, while the protocol remains open. Which additional reputation or platform-level checks, if any, belong above that fixed floor?
  3. Relay strategy — public relays only, or run a pinned relay for the platform? Resolved 2026-05-25: 21.gifts uses the shared nostr.space relay (strfry) maintained as part of the wider NOSTR-space infrastructure. No relay operation is in 21.gifts' scope.
  4. Funding the platform — hosting, domain, dev work need someone to pay. Options: rounding-up donations, optional tip on every flow, sponsor, founder funds. Must align with "non-profit" principle.
  5. Legal exposure — gift law vs. fundraising law per jurisdiction. Liability if a receiver turns out to be a scammer? Clear "this is a gift, not a contract" disclaimers probably essential.
  6. Discovery UX — how do donors find receivers? Random? Curated front page? Categories (medical, education, refugee, etc.)? Time-sensitive urgency?
  7. Anti-abuse — scammers, fake stories, AI-generated profiles. How to detect without becoming a centralized gatekeeper?
  8. Sybil resistance — one person, many profiles? NOSTR Web of Trust helps but isn't bulletproof.
  9. Platform-signed NOSTR events in v1 — one platform key for everything, or one derived key per account? How is authorship attributed? Resolved 2026-07-05: v1 NOSTR is fully custodial — one keypair per account, generated server-side on sign-up, nsec encrypted at rest, events signed with the account's own key, so public attribution is per-user. Still open: the migration path from custodial to user-owned keys in the non-custodial phase (key hand-over/export vs. fresh identity plus republish).

Tech Stack

App (21gifts/app) — thin frontend client

Goal: smallest viable browser bundle. Only what must run client-side.

Layer Choice Rationale
Framework Next.js 15 (App Router) SSR, standalone Docker output, broad ecosystem
Language TypeScript (strict mode) Type safety
Styling Tailwind CSS only No CSS files, no styled-components
State Zustand Minimal boilerplate, encrypted IndexedDB persistence
WebAuthn / PRF navigator.credentials.* directly, no external library Smallest surface
Crypto primitives Web Crypto API (HKDF, PBKDF2, AES-GCM, SHA-256) Native, audited, no dependency cost
secp256k1 / Schnorr @noble/curves Pure TypeScript, audited, no WASM needed
BIP-32 / BIP-39 @scure/bip32, @scure/bip39 Pure TypeScript, NIP-06-compatible
NOSTR event helpers nostr-tools (encoding + signing only; no relay code) App uses it for event construction and NIP-19 bech32, not for relay I/O
Lightning light-bolt11-decoder for invoice decoding LUD-16 metadata comes pre-resolved from api
Schema validation zod API response validation
Icons lucide-react Minimal icon set
Test Vitest (unit), Playwright (e2e) Standard
Lint next lint + Prettier Standard

The WebAuthn/PRF, BIP-32/39, and client-side signing rows describe the non-custodial phase. v1 already requests PRF so the app can show a recovery phrase in tab memory; API auth and server-side Nostr keys do not use it. The rest of the app crypto surface is passkey login and LNURL-pay.

Dependency philosophy: stay minimal. Target ~12 runtime dependencies. The app does UI plus — in the non-custodial phase — crypto + signing; everything else (relay I/O, indexing, discovery, anti-abuse, LN-Address resolution) is the api's job.

Backend (21gifts/api) — central service

The workload is I/O-bound (HTTP, WebSocket, JSON) — not CPU-bound. The language choice optimizes for iteration speed, dependency sharing with the app, and operational simplicity.

Layer Choice Rationale
Runtime Bun ≥ 1.3 Fast TS execution, built-in package manager, native HTTP server, small image
Language TypeScript (strict mode) Same language as app → shared types, mental-model symmetry
Framework Hono TypeScript-first, runs natively on Bun, tiny surface, ergonomic test ergonomics
Validation zod Same as app; shared schemas down the line
NOSTR client nostr-tools (subscriptions, encoding, signature verification; v1 additionally: key generation + server-side event signing for custodial identities) Same lib as the app; one mental model
Lightning LUD-16 JSON resolution via fetch; LNURL-pay invoice fetch + light-bolt11-decoder for spend-worker invoices; LNDHub pay stays in the external worker LN node not required
Storage TBD (Postgres for relational; potentially Redis for relay-event cache) Decision deferred until indexer surface stabilizes
Relay endpoint Shared wss://relay.nostr.space (PRD), wss://dev-relay.nostr.space (DEV) Operated as separate infrastructure; configured via env var
Test Vitest + @vitest/coverage-v8 Explicit coverage.thresholds: { lines, branches, functions, statements: 100 }
Lint ESLint (flat config) + Prettier + eslint-plugin-tsdoc TSDoc on every exported function enforced

Quality bar: 100% coverage on every function (lines, branches, functions, statements). Unreachable defensive code is exempted via v8 ignore markers with a one-line written reason — never to silence the gate. CI is red until thresholds are met.


Repositories

GitHub organization: 21gifts (created 2026-05-25).

Repo Purpose Status
21gifts/api Backend service + canonical project docs (this file, ROADMAP, SPEC, etc.) Created 2026-05-25
21gifts/app Web frontend client (21.gifts) — thin, only frontend-specific docs Created 2026-07-05
21gifts/docs Public developer documentation site (docs.21.gifts) Later
21gifts/landing-page Whitepaper / manifest landing page Later
21gifts/marketing (private) Brand assets, launch material Later

Where docs live:

  • 21gifts/api — CONCEPT.md (this file), SPEC.md, FLOWS.md (UI-journey sketch), future ROADMAP.md, protocol decisions, schema, architecture diagrams. The api is the brain of the system, so it owns the canonical project specification.
  • 21gifts/app — README.md (short, points at api repo for protocol), CONTRIBUTING.md (frontend-specific: dev setup, component conventions, styling, testing). Nothing protocol-level.

Per-repo conventions:

  • develop is the default branch
  • main is the production branch
  • Feature branch → PR → merge to develop
  • main is protected; updates flow via an auto-generated Release PR (develop → main)
  • Every repo has README.md, CONTRIBUTING.md, SECURITY.md, LICENSE
  • Strict linting (Prettier + ESLint)
  • No console.log in committed code
  • Commit messages in English, concise, describe what changed

Brand

  • Domain: 21.gifts (secured 2026-05-25). The 21 is a Bitcoin reference (21M cap); .gifts semantically captures the intent — these are gifts, not donations, not transactions
  • Tone: warm, direct, dignified. Not charity-speak ("the needy"; quoted Scripture excepted), not techbro-speak ("disrupting philanthropy"). People helping people, with the best money humans have ever had.
  • Origin: 21.gifts comes from Christian faith. The three Convictions above are the foundation. The public copy is /about in the app: it states the convictions and quotes the verses. It does not explain who is welcome: no inclusion slogan, and no FAQ entry asking whether the project is Christian.
  • Signature verse: Matthew 10:8 — "Freely you have received; freely give." It is the gift principle of the living-room rules (/rules in the app) in one line: a gift has no price. Shown quietly in the marketing footer; not on the hero.
  • Visual: minimal, photo-driven, large typography. Receiver photos and stories are the hero. Tech is invisible. No cross, fish, or second typeface as brand marks; Bitcoin orange stays the accent.
  • Language: English for CONCEPT, code, commits, handbook, and api payloads. Visitor-facing app copy is localized (en, de, es, fil).

Docker

  • Docker Hub organization: 21gifts (created 2026-05-25)
  • Image names match the repo: 21gifts/app, 21gifts/api
  • Tag convention per image:
    • :beta — built from develop, deployed to DEV
    • :staging — built from staging, deployed to staging
    • :latest — built from main, deployed to PRD. :latest is an independent rebuild from main, not a retag of :staging
  • One image, multiple environments — for the app, build-time placeholders for NEXT_PUBLIC_* variables are replaced at container start by an entrypoint.sh with runtime values; the api reads its config purely from environment variables at startup. Same image runs DEV and PRD without rebuild.

CI / CD (per product repo)

Five GitHub Actions workflows, identical structure for app and api:

Workflow Trigger Action
ci.yaml PR, push to develop Lint + build + test (required for merge)
deploy-dev.yaml push to develop Docker build → push :beta → notify infra repo
deploy-staging.yaml push to staging Docker build → push :staging → notify infra repo
deploy-prd.yaml push to main Docker build → push :latest → notify infra repo
auto-release-pr.yaml push to develop or staging Auto-create staging → develop and develop → main only. Never staging → main.

Pre-push local checks:

  • app: npm run lint && npm run build && npm test
  • api: bun install --frozen-lockfile && bun run typecheck && bun run lint && bun run test:coverage && bun run build

CI red is unacceptable; it's caught locally.

Testing rule: new code on the activated surface (features actually shipped) must hit 100% line/branch/statement/function coverage. Feature-gated code (behind a build-time flag or a server-side capability gate) is excluded — gated code does not need coverage as long as the gate stays off in production builds.

Image-build → deploy hand-off: the product repo's deploy-*.yaml workflow pushes the image to Docker Hub and sends a repository_dispatch event to a separate infrastructure repository (private, not part of this project's scope). That repo handles the actual host-level deploy, secrets, DNS, and reverse-proxy routing.


Hosting & Operations

Three environments per service. Staging is the environment for experimental testing. A change that is good there is released to develop first. main receives changes only from develop. Staging is never released directly to main. Feature pull requests always target develop, not staging and not main. Developers rebase staging onto develop regularly, because those pull requests land on develop and do not update staging:

Service Env Source branch Image tag Public URL
app DEV develop :beta dev.21.gifts
app PRD main :latest 21.gifts
api DEV develop :beta dev-api.21.gifts
api PRD main :latest api.21.gifts
app staging staging :staging staging.21.gifts
api staging staging :staging staging-api.21.gifts

app.21.gifts / dev-app.21.gifts remain transitional aliases for the app container. staging-app.21.gifts redirects to the apex staging.21.gifts. Passkey RP ID is the apex (21.gifts / dev.21.gifts / staging.21.gifts), not the api hostname.

Subdomain convention: dash, not dot (e.g., dev-api.21.gifts rather than dev.api.21.gifts). staging.api.21.gifts is not a name. This keeps every subdomain at exactly one level deep, which sidesteps the multi-level wildcard certificate problem on Cloudflare.

Public routing: behind a reverse proxy / tunnel that terminates TLS and forwards to the container's port. Specific host names, secret stores, monitoring hooks, and deploy mechanics live in the operator's separate infrastructure repository — they're intentionally not part of this project's scope.


Decisions Log

Date Decision
2026-10-07 POST /trust/verify requires confirmedName. The string must equal the stored account.name exactly. A null or trim-empty stored name is 409 Conflict, as is any other mismatch, including the idempotent repeat. No edge and no role change. Other trust POSTs still take only accountId.
2026-10-02 Staging is the experimental test environment. A change that is good there is released to develop first. main receives changes only from develop. A release from staging directly to main is not allowed. Feature pull requests always target develop, not staging and not main. Developers rebase staging onto develop regularly, because those pull requests land on develop and do not update staging. This supersedes the release-direction sentence of 2026-10-01.
2026-10-02 POST /funding/apply writes no grant while paused. A verified or higher caller gets 403 { error: 'Applications are paused' }, except joey-rosima, vincent, and jewel-bacolbas. Those three, and every caller when applicationsPaused is false, still get 400 { error: 'About me is required' }, { error: 'About me photo is required' }, or { error: 'Location is required' }, 409 { error: 'Conflict' }, 200 { funding } (logs funding.applied), or 503 { error: 'Funding is unavailable' }. basis is 403 Forbidden. Trial, admit, reject, and the staff queue stay unchanged. The pause is not a shop-transaction count. Production omits the flag.
2026-10-01 Three environments per service. Branch staging publishes image tag :staging. The app public URL is staging.21.gifts. The api public URL is staging-api.21.gifts. staging-app.21.gifts redirects to the apex. staging.api.21.gifts is not a name. Release pull requests stay develop → main. :latest is an independent rebuild from main, not a retag of :staging. The passkey RP ID includes staging.21.gifts.
2026-09-25 A moderator can set, replace, or clear the map pin on an existing live top-level shop note (#21GiftsShop) via PATCH /messages/:id/place. Columns stay place_lat / place_lng / place_label. No Nostr republish, no text change, no notification.
2026-09-24 Signed-in language and fiat are stored on the account (locale, fiat, both nullable). Null means not defined yet: the app writes the resolved value once (onlyIfUnset). A stored value always wins over Accept-Language and the cookies. An explicit control updates the stored value. Signed-out visitors stay on the cookie and Accept-Language and write nothing.
2026-05-25 Domain 21.gifts registered (premium .gifts TLD on Identity Digital)
2026-05-25 GitHub organization 21gifts created
2026-05-25 Docker Hub organization 21gifts created
2026-05-25 Tech stack: Next.js 15 + TS strict + Tailwind + Zustand, mirroring the zkCoins-app pattern
2026-05-25 Passkey + PRF + NIP-06 derivation chosen as the key model (over PRF → HKDF → nsec direct path)
2026-05-25 No external WebAuthn library — navigator.credentials.* directly
2026-05-25 Lightning Address (LUD-16) mandatory for receivers; platform never custodies funds
2026-05-25 English-only product (no i18n in v1)
2026-05-25 Hard-fail on PRF-unsupported authenticators (no silent fallback)
2026-05-25 Multi-repo architecture: api, app, docs (later), landing-page (later), marketing (private, later)
2026-05-25 Thin-client / thick-server: app holds only keys+signing+UI; everything else (relay I/O, indexing, discovery, LN-Address resolution, anti-abuse) lives in api
2026-05-25 Backend service is named api and is built from day one — not deferred
2026-05-25 Canonical project documentation (CONCEPT, ROADMAP, SPEC) lives in 21gifts/api; the app repo carries only frontend-specific docs
2026-05-25 Backend stack: TypeScript + Bun + Hono + Vitest (revised from Rust + Axum). Workload is I/O-bound, not CPU-bound; language symmetry with the app wins
2026-05-25 NOSTR relay: shared nostr.space infra (wss://relay.nostr.space / wss://dev-relay.nostr.space). 21.gifts is a client, not an operator. Closes OQ #3
2026-05-25 Hard 100% coverage gate (lines + branches + functions + statements) enforced via vitest.config.ts thresholds; CI red until met
2026-05-25 TSDoc on every exported symbol, enforced via eslint-plugin-tsdoc
2026-07-05 v1 account model: Basis (login + receive, default for every account) and Moderator (content moderation); donor is an upgrade, not a role
2026-07-05 v1 login: LNURL-auth (LUD-04) only — no email, no password, no passkey; linkingKey = account identifier; lockout risk explicitly accepted; auth callback host pinned to api.21.gifts / dev-api.21.gifts
2026-08-22 Auth callback host (wallet linkingKey domain) moved to the public apex 21.gifts / dev.21.gifts. Supersedes the 2026-07-05 pin to api.21.gifts. App public URL is the apex; app.21.gifts stays a transitional alias. In-memory accounts from the old host do not survive.
2026-07-05 v1 donor spending: custodial via deposited lndhub:// export, restricted to lightning.space wallets; explicit transitional deviation from Core Principles 2/5, replaced by a non-custodial setup later
2026-07-05 Recurring daily gifts are a v1 feature: server-side scheduler in the api with fail-closed payout semantics
2026-07-05 Receiver verification: micro-payment with one-time nonce in the LUD-12 comment (WoS-compatible, min 1 sat); no LUD-21 dependency (WoS lacks it)
2026-07-05 Research recorded: WoS has no official API; WoS supports LNURL-auth (Classic since 2023, Self-Custody since app v3.2.5 / 2026-02-04)
2026-07-05 v1 NOSTR events are platform-signed (users hold no keys until the non-custodial phase) — attribution details open in OQ #9
2026-07-05 Revised same day: v1 NOSTR is fully custodial — one keypair per account, generated server-side, nsec encrypted at rest, events signed with the account's own key. Supersedes the platform-signed row above; resolves OQ #9 (migration path to user-owned keys stays open)
2026-08-15 CORS on the api allows DELETE so the browser app can unlink a Lightning Address; SPEC.md added as the HTTP contract home
2026-08-15 Receiver address verification endpoints: POST /me/lightning-address/verification and …/confirm; api pays 1 sat (or provider minSendable ≤ 10 sat) with a LUD-12 comment nonce; 503 until an invoice payer is wired (process still boots)
2026-08-15 Core UI journeys sketched in FLOWS.md (sign-in, profile, donate, recurring gifts, message). Implemented screens cite SPEC.md only; donate / recurring / message remain CONCEPT sketches with no HTTP
2026-08-15 Public GET /lightning-address resolves LUD-16 metadata (callback, min/max sendable, optional commentAllowed) with a 5-minute in-memory cache; the process still boots with no extra env. Gift invoices stay browser-side.
2026-08-23 Spend-worker invoice HTTP: POST /invoices fetches a recipient BOLT11 via LNURL-pay; POST /invoices/proof accepts the payment preimage. Paying is the external spend worker via lightning.space LNDHub — this api does not store LNDHub credentials or pay. SPEND_API_TOKEN optional (503 until set). Supersedes the 2026-07-05 in-api scheduler/LNDHub-pay decision and the 2026-08-15 “gift invoices stay browser-side” note for the spend-worker path.
2026-08-24 v1 login is passkey only; LNURL-auth (LUD-04) endpoints, QR login, and /auth/session poll removed. linkingKey remains a nullable historical column. LNURL-pay (donate / invoices / address verification) is unchanged. Supersedes the 2026-07-05 LNURL-auth-only login decision.
2026-08-24 Matching POST /invoices/proof inserts an outbound gift row when DATABASE_URL is set so GET /gifts/stats includes spend-worker payments. Insert failure logs gifts.record_failed and still returns 200. Memory boots keep a no-op recorder.
2026-08-24 Public GET /gifts?day=YYYY-MM-DD lists each outbound gift on that UTC day (time, recipient, sats/BTC/USD at that day's close). No invoices. Empty day is 200.
2026-08-28 v1 public comments ship as custodial HTTP GET/POST /messages (name snapshot, text, timestamp); kind:1 relay fan-out remains unwired.
2026-08-29 Member-forum posts are top-level kind:1 notes (not replies). GET/POST /messages include sats and payable. POST /messages/:id/invoice is a NIP-57 zap. Guest Send-a-gift is removed from the app. Worker fans out when NOSTR_PUBLISH=1.
2026-08-29 Worker indexes validated kind:9735 zap receipts onto message.sats (durable nostr_zap_receipt, LNURL provider pubkey + bolt11 amount). Kind:1 EVENT frames are published as JSON objects so relays can ACK.
2026-08-29 POST /me/lightning-address live-resolves LUD-16 and requires NIP-57 zap metadata before save (no migration of existing rows). Invoice limiter on POST /messages/:id/invoice runs only after auth, amount, payable, and KEK checks so early 400/404/401/503 do not burn quota.
2026-08-29 Forum display roles on exclusive account.role: basis | verified | moderator | founder. New passkey accounts stay basis. verified = moderator physically met the person (not lightningAddressVerified). GET/POST /messages always include live author role (missing author → basis). Operator assignment via PATCH /debug/accounts/:id (DEBUG_TOKEN).
2026-08-29 Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). GET /messages remains the latest-200 window newest-first; clients reverse for display.
2026-08-29 Zap ingest and invoice relays always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on NOSTR_PUBLISH_PUBLIC.
2026-08-29 Zap-receipt sats UPDATE qualifies message.sats so Postgres can apply it.
2026-08-30 Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY → process still boots; push HTTP 503. Subscriptions bind to account.id. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag forum); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English { type, title, body, url, tag }.
2026-09-09 forum.post requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). POST /messages 409 missing_requirements includes lightning-address when it is factually missing. ensureProfileMessage no-ops without LN; linking LN after a name creates the profile note. contact.post, forum.read, and forum.pay unchanged; existing message rows are not deleted.
2026-09-11 Forum UI/API lists only 21.gifts-author replies (account_id IS NOT NULL); replyCount and GET /messages/:id/replies omit unknown-npub children. The worker no longer persists inbound kind:1 replies whose pubkey is not a 21.gifts account. Existing Damus-only reply rows stay in storage but are omitted from lists/replyCount. Public GET /messages/:id of a Damus-only reply is 404; top-level Damus-only notes stay 200. No migration or soft-hide backfill.
2026-09-12 A 21.gifts-author forum reply writes a Notifications row for the parent author and enqueues one targeted Web Push (/notifications, tag forum_reply:<parentId>). The booted process always has those stores (memory or Postgres). It does not copy into the member↔member inbox. Top-level notes still broadcast. Self-replies and Damus-only parents do not notify. Failure does not fail POST /messages.
2026-09-12 Operator DEBUG_TOKEN debug reads every persisted forum row (live, soft-hidden, and replies) via GET /debug/messages and GET /debug/messages/:id; hidden JPEG/PNG/WebP bytes via GET /debug/messages/:id/photo. Soft-hide remains a public-API filter only; public GET /messages hide behaviour is unchanged.
2026-09-12 Public gift stats/day also return historical CHF/EUR/PHP (USD × Frankfurter ECB; missing fiat is null, not 503).
2026-09-13 Three convictions are canonical (CONCEPT "Convictions"): giving is a duty of every Christian; direct giving with no middleman is the best and most beautiful way; Bitcoin is the most effective money available today. Public copy is /about in the app: states the convictions, quotes the verses, no inclusion slogan. Matthew 10:8 unchanged. Principle 7: visitor UI localized (en, de, es, fil). Supersedes the 2026-05-25 English-only decision.
2026-09-14 Spend-worker payouts are ping-triggered from a new top-level POST /messages; replies do not pay. Unset SPEND_URL or SPEND_API_TOKEN skips the ping; the process still boots.
2026-09-14 Living-room post, reply, and zap notify every bell subscriber (accounts with ≥1 push_subscription) except the actor/payer. In-app kinds forum_post / forum_reply / zap. Push URLs /notifications; tags forum_post:<id>, forum_reply:<replyId>, zap:<id>. Damus-only parents still fan out. Self-reply skips only the actor. Missing pushStore is a no-op. Unique remains (recipient, type, reply_id). Supersedes the 2026-09-12 parent-author-only reply notify.
2026-09-15 In-app living-room notifications go to every account except the actor/payer. Web Push still goes only to bell subscribers (push_subscription). Missing pushStore no longer drops in-app rows when auth is set. Supersedes the 2026-09-14 in-app-only-via-subscription rule.
2026-09-16 A zap that inserts a gift-reply fans out only notifyZap (one in-app row + one Web Push), not a second forum_reply. Gift-reply row still lands in the thread. Supersedes the 2026-09-14/15 dual fan-out for that path.
2026-09-16 Stored per-account notificationLevel (all / active / mentions). Fan-out filters in-app rows and Web Push by that level (POST /me/notification-level; default all). GET /notifications lists stored rows unfiltered, including moderator_appointed. Supersedes the 2026-09-15 every-account in-app notify.
2026-09-20 GET /notifications applies the owner's notificationLevel to stored rows (same wantsNotification rules as write-time fan-out). unreadCount is matching unread. moderator_appointed always stays. Supersedes the 2026-09-16 unfiltered GET list.
2026-10-01 Level mentions is only a reply or zap on the recipient's own note, or an @username mark for that recipient. A staff or platform actor does not satisfy mentions. moderator_appointed and moderator_proposal are unchanged. Supersedes the staff half of the 2026-09-16 and 2026-09-20 mentions rule.
2026-09-12 Trust edges persist who verified whom and who proposed/confirmed/appointed a moderator. Public graph JSON is stored nodes+edges only (no synthetic links; moderator_propose is omitted). Staff POST /trust/verify, confirm-moderator, and appoint-moderator write role + edge; POST /trust/propose-moderator writes the propose edge only. Operator POST /debug/trust-edges backfills edges without changing role. PATCH /debug/accounts/:id still sets role only and does not write trust edges.
2026-09-13 Public GET /trust-chain returns founder seeds only. GET /trust-chain?around=<id> returns that chain member plus one hop of stored public edges so a thousand-person chain is loaded by click, not dumped on first paint. Supersedes the 2026-09-12 GET form (stored graph, never a first-paint dump).
2026-09-16 GET /trust-chain requires a member Bearer session (any role, including basis). Missing or invalid Bearer is 401 { "error": "Unauthorized" }. Neighborhood shape is unchanged: founder seeds on the bare GET; ?around=<id> one hop of stored public edges (moderator_propose omitted). Supersedes the 2026-09-13 public GET form.
2026-09-17 Public Trust Chain credits the proposer, not the confirmer. Projected kinds are stored verify / moderator_propose (only once the subject is a moderator) / moderator_appoint. moderator_confirm is omitted. A pending propose (subject still verified) stays private and is not a hop neighbor. Operator DELETE /debug/trust-edges removes one stored (subjectId, kind) row without changing role. Supersedes the 2026-09-12 public-graph kinds (moderator_propose omitted) and the 2026-09-16 neighborhood kinds parenthetical.
2026-09-17 Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (createdAt then id). Eligible: verify, moderator_appoint, and moderator_propose only when the subject is a moderator. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still verified) stays private. Supersedes the 2026-09-17 kind-priority projection (moderator_propose if moderator, else verify, else moderator_appoint).
2026-09-16 confirm/appoint notify only the subject (moderator_appointed); Web Push url /welcome, tag moderator_appointed:<subjectId>; not a living-room fan-out; unique (recipient, type, reply_id) with reply_id = subject id; missing stores no-op; failure does not fail the trust POST.
2026-09-17 Staff Bearer GET /trust/proposals lists pending moderator_propose (verified subject, no confirm/appoint). Session GET /trust-chain still omits a pending propose; once the subject is a moderator, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling.
2026-09-30 eligibleToday does not require a grant until UTC 2026-10-10 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-24 row (was UTC 2026-09-30).
2026-10-01 eligibleToday requires a funding grant from UTC 2026-10-01 (FUNDING_REQUIRED_FROM_UTC), not from UTC 2026-10-10. Before that UTC day, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-30 row (was UTC 2026-10-10). Six named accounts with no grant row get owner funding.dailyPayoutStoppedNotice: true; any stored grant status clears it. Admit and trial still ping today's live top-level photo or video.
2026-09-24 eligibleToday does not require a grant until UTC 2026-09-30 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-21 row (was UTC 2026-09-25).
2026-09-21 eligibleToday does not require a grant until UTC 2026-09-25 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Softens the 2026-09-20 spend/invoice grant cutover.
2026-09-20 Funding-program grants (funding_grant) are independent of account.role. verified remains a real-life meeting (forum badge). Moderators review posts against the three convictions. basis cannot apply; owner JSON funding is null. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and POST /invoices require eligibleToday; GET /invoices/eligible?address= returns { eligible }.
2026-09-17 Inbox last-read is per (account, conversation). GET /conversations adds per-row unread and list unreadCount; POST /conversations/:id/read stamps last-read. Does not copy DMs into Notifications.
2026-09-17 Inbound private messages enqueue Web Push (type: conversation, url /messages?c=<id>, tag conversation:<id>) to bell subscribers only. No in-app Notification rows for DMs. Every outbox unreadCount (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest.
2026-09-17 A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on message.photo (Damus /photo.jpg unchanged). Extras 1–9 live in message_extra_photo and are served at /messages/:id/photo/1.jpg … /photo/9.webp. Public JSON includes photoCount (0–10). POST accepts photos[] (max 10) and still accepts singular photo. Video stays exclusive (poster = photo 0, no extras).
2026-09-18 External NOSTR identities become visible on the website only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol remains open. The zap creates an external gift-reply on an eligible top-level note and permanently entitles that pubkey's kind:1 replies. Staff hiding an external row blocks the pubkey and hides its other live rows. Public JSON marks these rows with via: "nostr" and never exposes the pubkey. Supersedes the 2026-09-11 rule that all unknown-pubkey replies are omitted.
2026-09-20 Roles are a strict hierarchy founder > moderator > verified > basis; every permission is a minimum role (roleAtLeast), and text names only that minimum role. The closed Moderators group follows the same rule.
2026-09-20 Staff DELETE /messages/:id still only soft-hides on 21.gifts, then best-effort publishes NIP-09 kind: 5 (author nsec, durability relay plus Damus/Primal/nos.lol, not gated on NOSTR_PUBLISH*) and purges cached public photo/video URLs at Cloudflare when CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set. Failure still 204. Debug restore does not undelete Nostr. Supersedes the 2026-09-12 “soft-hide remains a public-API filter only” note.
2026-09-20 Staff GET of a soft-hidden forum note returns who hid it and when. Hide retracts in-app notifications for the note and its direct children. GET /notifications drops leftover hidden forum_post / forum_reply rows (zap still checks only the parent because replyId is a receipt UUID).
2026-09-20 Official platform account (isPlatform) never fans out living-room forum_post / forum_reply / zap (in-app or Web Push). House daily gift-replies still persist. Supersedes the 2026-09-14/15/16 living-room fan-out for the platform actor only.
2026-09-20 Moderators-group Web Push (type: conversation) opens /moderate/group (the staff-room thread). Member DM push stays /messages?c=<id>; forum/zap stay /notifications. Tag remains conversation:<id>. No in-app Notification rows; GET /conversations still omits moderator_group. Supersedes only the staff-room URL in the 2026-09-17 conversation-push row.
2026-09-20 A paid moderator stipend appears in the closed Moderators group as a house ("21.gifts") message carrying the paid sats, after the triggering group message, at payment time. Spend pings { address, kind: "moderator", groupMessageId }; proof attaches that row. A missing or mismatched group reference never blocks the payout.
2026-09-20 Passkey authenticate/register finish and operator debug session mint refuse an account whose stored sessionRefused flag is true: HTTP 403 with the wrong-account error and no bearer. GET /me with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with PATCH /debug/accounts/:id. The account row is not deleted. Supersedes the same-day listed-id copy of this row.
2026-09-20 A paid moderator stipend in the closed Moderators group carries a durable gift_for_message_id / public giftFor link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row.
2026-09-21 GET /conversations (and other public conversation list rows) include per-row unreadMessageCount (inbound messages strictly after last-read; 0 when none). unread stays unreadMessageCount > 0. Envelope unreadCount remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not.
2026-09-20 Staff may reject an open moderator proposal. Reject is append-only moderator_reject (role stays verified). Re-propose after reject inserts a new moderator_propose (history kept). Pending = latest propose/reject is propose, subject still verified, no confirm/appoint. Unique live kinds are verify/confirm/appoint only (propose/reject may repeat). An open proposal fans out in-app moderator_proposal plus Web Push to other staff until confirm/reject; mark-read does not dismiss it. Supersedes the 2026-09-17 pending-propose list row (pending was any propose without confirm/appoint).
2026-09-21 Optional GET /messages?hashtag= token filter on live top-level text (name without #; token match; combines with mode/limit/cursor). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later.
2026-09-21 Basis accounts pay 1 sat to 21.gifts before they can post or reply (GET /messages/compose-target then invoice the platform profile note). Unpaid POST /messages is 403 for anyone below verified, including the parent author. Verified, moderator, and founder stay unpaid-write exempt. Extra gifts on someone else’s note still pay that author. The worker always queries that profile note’s event id even after it ages out of listLatest. Supersedes the Roles table line that only mentioned unpaid replies for Verified.
2026-09-21 JPEG/PNG/WebP stills (max 10, photo-only send) on every private conversation kind (Direct, Contact, Damus, Moderators group). Bytes stay on authenticated GET photo routes (Cache-Control: private, no-store). Photo-bearing rows skip Nostr (nostrPublishState: skipped); text-only Direct/Contact/Damus stay pending. Spend ping stays moderator_group only.
2026-09-21 Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings.
2026-09-21 Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh moderator_proposal rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings.
2026-09-21 Optional GET /messages?hashtag= token filter on live top-level text (name without #; token match; combines with mode/limit/cursor). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later.
2026-09-21 Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh moderator_proposal rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. Supersedes the 2026-09-17 “one incoming kind” / first-contact-wins row.
2026-09-24 Initiator shares the moderator rank; permissions still name the minimum rank only. Supersedes the 2026-09-20 strict total order founder > moderator > verified > basis.
Date Decision
---------- ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
2026-05-25 Domain 21.gifts registered (premium .gifts TLD on Identity Digital)
2026-05-25 GitHub organization 21gifts created
2026-05-25 Docker Hub organization 21gifts created
2026-05-25 Tech stack: Next.js 15 + TS strict + Tailwind + Zustand, mirroring the zkCoins-app pattern
2026-05-25 Passkey + PRF + NIP-06 derivation chosen as the key model (over PRF → HKDF → nsec direct path)
2026-05-25 No external WebAuthn library — navigator.credentials.* directly
2026-05-25 Lightning Address (LUD-16) mandatory for receivers; platform never custodies funds
2026-05-25 English-only product (no i18n in v1)
2026-05-25 Hard-fail on PRF-unsupported authenticators (no silent fallback)
2026-05-25 Multi-repo architecture: api, app, docs (later), landing-page (later), marketing (private, later)
2026-05-25 Thin-client / thick-server: app holds only keys+signing+UI; everything else (relay I/O, indexing, discovery, LN-Address resolution, anti-abuse) lives in api
2026-05-25 Backend service is named api and is built from day one — not deferred
2026-05-25 Canonical project documentation (CONCEPT, ROADMAP, SPEC) lives in 21gifts/api; the app repo carries only frontend-specific docs
2026-05-25 Backend stack: TypeScript + Bun + Hono + Vitest (revised from Rust + Axum). Workload is I/O-bound, not CPU-bound; language symmetry with the app wins
2026-05-25 NOSTR relay: shared nostr.space infra (wss://relay.nostr.space / wss://dev-relay.nostr.space). 21.gifts is a client, not an operator. Closes OQ #3
2026-05-25 Hard 100% coverage gate (lines + branches + functions + statements) enforced via vitest.config.ts thresholds; CI red until met
2026-05-25 TSDoc on every exported symbol, enforced via eslint-plugin-tsdoc
2026-07-05 v1 account model: Basis (login + receive, default for every account) and Moderator (content moderation); donor is an upgrade, not a role
2026-07-05 v1 login: LNURL-auth (LUD-04) only — no email, no password, no passkey; linkingKey = account identifier; lockout risk explicitly accepted; auth callback host pinned to api.21.gifts / dev-api.21.gifts
2026-08-22 Auth callback host (wallet linkingKey domain) moved to the public apex 21.gifts / dev.21.gifts. Supersedes the 2026-07-05 pin to api.21.gifts. App public URL is the apex; app.21.gifts stays a transitional alias. In-memory accounts from the old host do not survive.
2026-07-05 v1 donor spending: custodial via deposited lndhub:// export, restricted to lightning.space wallets; explicit transitional deviation from Core Principles 2/5, replaced by a non-custodial setup later
2026-07-05 Recurring daily gifts are a v1 feature: server-side scheduler in the api with fail-closed payout semantics
2026-07-05 Receiver verification: micro-payment with one-time nonce in the LUD-12 comment (WoS-compatible, min 1 sat); no LUD-21 dependency (WoS lacks it)
2026-07-05 Research recorded: WoS has no official API; WoS supports LNURL-auth (Classic since 2023, Self-Custody since app v3.2.5 / 2026-02-04)
2026-07-05 v1 NOSTR events are platform-signed (users hold no keys until the non-custodial phase) — attribution details open in OQ #9
2026-07-05 Revised same day: v1 NOSTR is fully custodial — one keypair per account, generated server-side, nsec encrypted at rest, events signed with the account's own key. Supersedes the platform-signed row above; resolves OQ #9 (migration path to user-owned keys stays open)
2026-08-15 CORS on the api allows DELETE so the browser app can unlink a Lightning Address; SPEC.md added as the HTTP contract home
2026-08-15 Receiver address verification endpoints: POST /me/lightning-address/verification and …/confirm; api pays 1 sat (or provider minSendable ≤ 10 sat) with a LUD-12 comment nonce; 503 until an invoice payer is wired (process still boots)
2026-08-15 Core UI journeys sketched in FLOWS.md (sign-in, profile, donate, recurring gifts, message). Implemented screens cite SPEC.md only; donate / recurring / message remain CONCEPT sketches with no HTTP
2026-08-15 Public GET /lightning-address resolves LUD-16 metadata (callback, min/max sendable, optional commentAllowed) with a 5-minute in-memory cache; the process still boots with no extra env. Gift invoices stay browser-side.
2026-08-23 Spend-worker invoice HTTP: POST /invoices fetches a recipient BOLT11 via LNURL-pay; POST /invoices/proof accepts the payment preimage. Paying is the external spend worker via lightning.space LNDHub — this api does not store LNDHub credentials or pay. SPEND_API_TOKEN optional (503 until set). Supersedes the 2026-07-05 in-api scheduler/LNDHub-pay decision and the 2026-08-15 “gift invoices stay browser-side” note for the spend-worker path.
2026-08-24 v1 login is passkey only; LNURL-auth (LUD-04) endpoints, QR login, and /auth/session poll removed. linkingKey remains a nullable historical column. LNURL-pay (donate / invoices / address verification) is unchanged. Supersedes the 2026-07-05 LNURL-auth-only login decision.
2026-08-24 Matching POST /invoices/proof inserts an outbound gift row when DATABASE_URL is set so GET /gifts/stats includes spend-worker payments. Insert failure logs gifts.record_failed and still returns 200. Memory boots keep a no-op recorder.
2026-08-24 Public GET /gifts?day=YYYY-MM-DD lists each outbound gift on that UTC day (time, recipient, sats/BTC/USD at that day's close). No invoices. Empty day is 200.
2026-08-28 v1 public comments ship as custodial HTTP GET/POST /messages (name snapshot, text, timestamp); kind:1 relay fan-out remains unwired.
2026-08-29 Member-forum posts are top-level kind:1 notes (not replies). GET/POST /messages include sats and payable. POST /messages/:id/invoice is a NIP-57 zap. Guest Send-a-gift is removed from the app. Worker fans out when NOSTR_PUBLISH=1.
2026-08-29 Worker indexes validated kind:9735 zap receipts onto message.sats (durable nostr_zap_receipt, LNURL provider pubkey + bolt11 amount). Kind:1 EVENT frames are published as JSON objects so relays can ACK.
2026-08-29 POST /me/lightning-address live-resolves LUD-16 and requires NIP-57 zap metadata before save (no migration of existing rows). Invoice limiter on POST /messages/:id/invoice runs only after auth, amount, payable, and KEK checks so early 400/404/401/503 do not burn quota.
2026-08-29 Forum display roles on exclusive account.role: basis | verified | moderator | founder. New passkey accounts stay basis. verified = moderator physically met the person (not lightningAddressVerified). GET/POST /messages always include live author role (missing author → basis). Operator assignment via PATCH /debug/accounts/:id (DEBUG_TOKEN).
2026-08-29 Public member forum UX is a messenger-group thread (oldest top, newest bottom above the composer). GET /messages remains the latest-200 window newest-first; clients reverse for display.
2026-08-29 Zap ingest and invoice relays always include the public list (space plus Damus / Primal / nos.lol); kind:1 public write stays gated on NOSTR_PUBLISH_PUBLIC.
2026-08-29 Zap-receipt sats UPDATE qualifies message.sats so Postgres can apply it.
2026-08-30 Web Push is self-hosted VAPID in this api (no third-party push SDK). Missing VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY → process still boots; push HTTP 503. Subscriptions bind to account.id. Outbox worker sends. Events: forum posts notify every other subscribed account (collapse tag forum); a newly indexed zap notifies the note author. iOS v1 is Home Screen (A2HS). Payloads are English { type, title, body, url, tag }.
2026-09-09 forum.post requires a non-blank Lightning Address in addition to rules + name (skip timestamps still do not satisfy). POST /messages 409 missing_requirements includes lightning-address when it is factually missing. ensureProfileMessage no-ops without LN; linking LN after a name creates the profile note. contact.post, forum.read, and forum.pay unchanged; existing message rows are not deleted.
2026-09-11 Forum UI/API lists only 21.gifts-author replies (account_id IS NOT NULL); replyCount and GET /messages/:id/replies omit unknown-npub children. The worker no longer persists inbound kind:1 replies whose pubkey is not a 21.gifts account. Existing Damus-only reply rows stay in storage but are omitted from lists/replyCount. Public GET /messages/:id of a Damus-only reply is 404; top-level Damus-only notes stay 200. No migration or soft-hide backfill.
2026-09-12 A 21.gifts-author forum reply writes a Notifications row for the parent author and enqueues one targeted Web Push (/notifications, tag forum_reply:<parentId>). The booted process always has those stores (memory or Postgres). It does not copy into the member↔member inbox. Top-level notes still broadcast. Self-replies and Damus-only parents do not notify. Failure does not fail POST /messages.
2026-09-12 Operator DEBUG_TOKEN debug reads every persisted forum row (live, soft-hidden, and replies) via GET /debug/messages and GET /debug/messages/:id; hidden JPEG/PNG/WebP bytes via GET /debug/messages/:id/photo. Soft-hide remains a public-API filter only; public GET /messages hide behaviour is unchanged.
2026-09-12 Public gift stats/day also return historical CHF/EUR/PHP (USD × Frankfurter ECB; missing fiat is null, not 503).
2026-09-13 Three convictions are canonical (CONCEPT "Convictions"): giving is a duty of every Christian; direct giving with no middleman is the best and most beautiful way; Bitcoin is the most effective money available today. Public copy is /about in the app: states the convictions, quotes the verses, no inclusion slogan. Matthew 10:8 unchanged. Principle 7: visitor UI localized (en, de, es, fil). Supersedes the 2026-05-25 English-only decision.
2026-09-14 Spend-worker payouts are ping-triggered from a new top-level POST /messages; replies do not pay. Unset SPEND_URL or SPEND_API_TOKEN skips the ping; the process still boots.
2026-09-14 Living-room post, reply, and zap notify every bell subscriber (accounts with ≥1 push_subscription) except the actor/payer. In-app kinds forum_post / forum_reply / zap. Push URLs /notifications; tags forum_post:<id>, forum_reply:<replyId>, zap:<id>. Damus-only parents still fan out. Self-reply skips only the actor. Missing pushStore is a no-op. Unique remains (recipient, type, reply_id). Supersedes the 2026-09-12 parent-author-only reply notify.
2026-09-15 In-app living-room notifications go to every account except the actor/payer. Web Push still goes only to bell subscribers (push_subscription). Missing pushStore no longer drops in-app rows when auth is set. Supersedes the 2026-09-14 in-app-only-via-subscription rule.
2026-09-16 A zap that inserts a gift-reply fans out only notifyZap (one in-app row + one Web Push), not a second forum_reply. Gift-reply row still lands in the thread. Supersedes the 2026-09-14/15 dual fan-out for that path.
2026-09-16 Stored per-account notificationLevel (all / active / mentions). Fan-out filters in-app rows and Web Push by that level (POST /me/notification-level; default all). GET /notifications lists stored rows unfiltered, including moderator_appointed. Supersedes the 2026-09-15 every-account in-app notify.
2026-09-20 GET /notifications applies the owner's notificationLevel to stored rows (same wantsNotification rules as write-time fan-out). unreadCount is matching unread. moderator_appointed always stays. Supersedes the 2026-09-16 unfiltered GET list.
2026-10-01 Level mentions is only a reply or zap on the recipient's own note, or an @username mark for that recipient. A staff or platform actor does not satisfy mentions. moderator_appointed and moderator_proposal are unchanged. Supersedes the staff half of the 2026-09-16 and 2026-09-20 mentions rule.
2026-09-12 Trust edges persist who verified whom and who proposed/confirmed/appointed a moderator. Public graph JSON is stored nodes+edges only (no synthetic links; moderator_propose is omitted). Staff POST /trust/verify, confirm-moderator, and appoint-moderator write role + edge; POST /trust/propose-moderator writes the propose edge only. Operator POST /debug/trust-edges backfills edges without changing role. PATCH /debug/accounts/:id still sets role only and does not write trust edges.
2026-09-13 Public GET /trust-chain returns founder seeds only. GET /trust-chain?around=<id> returns that chain member plus one hop of stored public edges so a thousand-person chain is loaded by click, not dumped on first paint. Supersedes the 2026-09-12 GET form (stored graph, never a first-paint dump).
2026-09-16 GET /trust-chain requires a member Bearer session (any role, including basis). Missing or invalid Bearer is 401 { "error": "Unauthorized" }. Neighborhood shape is unchanged: founder seeds on the bare GET; ?around=<id> one hop of stored public edges (moderator_propose omitted). Supersedes the 2026-09-13 public GET form.
2026-09-17 Public Trust Chain credits the proposer, not the confirmer. Projected kinds are stored verify / moderator_propose (only once the subject is a moderator) / moderator_appoint. moderator_confirm is omitted. A pending propose (subject still verified) stays private and is not a hop neighbor. Operator DELETE /debug/trust-edges removes one stored (subjectId, kind) row without changing role. Supersedes the 2026-09-12 public-graph kinds (moderator_propose omitted) and the 2026-09-16 neighborhood kinds parenthetical.
2026-09-17 Public Trust Chain picks one incoming kind per subject: the oldest eligible sibling (createdAt then id). Eligible: verify, moderator_appoint, and moderator_propose only when the subject is a moderator. First contact wins; later appoint, confirm, or propose do not replace it. A pending propose (subject still verified) stays private. Supersedes the 2026-09-17 kind-priority projection (moderator_propose if moderator, else verify, else moderator_appoint).
2026-09-16 confirm/appoint notify only the subject (moderator_appointed); Web Push url /welcome, tag moderator_appointed:<subjectId>; not a living-room fan-out; unique (recipient, type, reply_id) with reply_id = subject id; missing stores no-op; failure does not fail the trust POST.
2026-09-17 Staff Bearer GET /trust/proposals lists pending moderator_propose (verified subject, no confirm/appoint). Session GET /trust-chain still omits a pending propose; once the subject is a moderator, that propose is eligible as the public incoming edge only when it is the oldest eligible sibling.
2026-09-30 eligibleToday does not require a grant until UTC 2026-10-10 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-24 row (was UTC 2026-09-30).
2026-10-01 eligibleToday requires a funding grant from UTC 2026-10-01 (FUNDING_REQUIRED_FROM_UTC), not from UTC 2026-10-10. Before that UTC day, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-30 row (was UTC 2026-10-10). Six named accounts with no grant row get owner funding.dailyPayoutStoppedNotice: true; any stored grant status clears it. Admit and trial still ping today's live top-level photo or video.
2026-09-24 eligibleToday does not require a grant until UTC 2026-09-30 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Supersedes the date in the 2026-09-21 row (was UTC 2026-09-25).
2026-09-21 eligibleToday does not require a grant until UTC 2026-09-25 (FUNDING_REQUIRED_FROM_UTC). Until then, non-basis accounts stay eligible (passkey and living-room post still gate issue). From that day the 2026-09-20 grant matrix applies. Softens the 2026-09-20 spend/invoice grant cutover.
2026-09-20 Funding-program grants (funding_grant) are independent of account.role. verified remains a real-life meeting (forum badge). Moderators review posts against the three convictions. basis cannot apply; owner JSON funding is null. Status none → pending → trial (eligible only on that UTC day) or admitted (recurring) or rejected (may re-apply). Expired trial is effective pending (lazy persist). Spend ping and POST /invoices require eligibleToday; GET /invoices/eligible?address= returns { eligible }.
2026-09-17 Inbox last-read is per (account, conversation). GET /conversations adds per-row unread and list unreadCount; POST /conversations/:id/read stamps last-read. Does not copy DMs into Notifications.
2026-09-17 Inbound private messages enqueue Web Push (type: conversation, url /messages?c=<id>, tag conversation:<id>) to bell subscribers only. No in-app Notification rows for DMs. Every outbox unreadCount (forum, zap, conversation) is notification unread plus listed inbox unread. Push failure does not fail HTTP 200 or Nostr ingest.
2026-09-17 A living-room note may carry up to 10 JPEG/PNG/WebP stills. Photo 0 stays on message.photo (Damus /photo.jpg unchanged). Extras 1–9 live in message_extra_photo and are served at /messages/:id/photo/1.jpg … /photo/9.webp. Public JSON includes photoCount (0–10). POST accepts photos[] (max 10) and still accepts singular photo. Video stays exclusive (poster = photo 0, no extras).
2026-09-18 External NOSTR identities become visible on the website only after a verified zap of at least 1 sat on a 21.gifts forum note; the protocol remains open. The zap creates an external gift-reply on an eligible top-level note and permanently entitles that pubkey's kind:1 replies. Staff hiding an external row blocks the pubkey and hides its other live rows. Public JSON marks these rows with via: "nostr" and never exposes the pubkey. Supersedes the 2026-09-11 rule that all unknown-pubkey replies are omitted.
2026-09-20 Roles are a strict hierarchy founder > moderator > verified > basis; every permission is a minimum role (roleAtLeast), and text names only that minimum role. The closed Moderators group follows the same rule.
2026-09-20 Staff DELETE /messages/:id still only soft-hides on 21.gifts, then best-effort publishes NIP-09 kind: 5 (author nsec, durability relay plus Damus/Primal/nos.lol, not gated on NOSTR_PUBLISH*) and purges cached public photo/video URLs at Cloudflare when CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set. Failure still 204. Debug restore does not undelete Nostr. Supersedes the 2026-09-12 “soft-hide remains a public-API filter only” note.
2026-09-20 Staff GET of a soft-hidden forum note returns who hid it and when. Hide retracts in-app notifications for the note and its direct children. GET /notifications drops leftover hidden forum_post / forum_reply rows (zap still checks only the parent because replyId is a receipt UUID).
2026-09-20 Official platform account (isPlatform) never fans out living-room forum_post / forum_reply / zap (in-app or Web Push). House daily gift-replies still persist. Supersedes the 2026-09-14/15/16 living-room fan-out for the platform actor only.
2026-09-20 Moderators-group Web Push (type: conversation) opens /moderate/group (the staff-room thread). Member DM push stays /messages?c=<id>; forum/zap stay /notifications. Tag remains conversation:<id>. No in-app Notification rows; GET /conversations still omits moderator_group. Supersedes only the staff-room URL in the 2026-09-17 conversation-push row.
2026-09-20 A paid moderator stipend appears in the closed Moderators group as a house ("21.gifts") message carrying the paid sats, after the triggering group message, at payment time. Spend pings { address, kind: "moderator", groupMessageId }; proof attaches that row. A missing or mismatched group reference never blocks the payout.
2026-09-20 Passkey authenticate/register finish and operator debug session mint refuse an account whose stored sessionRefused flag is true: HTTP 403 with the wrong-account error and no bearer. GET /me with an already-minted token for that row is the same 403 so the client can sign the visitor out. Other authenticated routes treat that token as missing (401). Operators set the flag with PATCH /debug/accounts/:id. The account row is not deleted. Supersedes the same-day listed-id copy of this row.
2026-09-20 A paid moderator stipend in the closed Moderators group carries a durable gift_for_message_id / public giftFor link to the group message that triggered it, so the app can render the stipend row attached under that message. Absent or null on every other conversation row.
2026-09-21 GET /conversations (and other public conversation list rows) include per-row unreadMessageCount (inbound messages strictly after last-read; 0 when none). unread stays unreadMessageCount > 0. Envelope unreadCount remains the number of listed unread threads (menu/PWA badge). Gift-only inbound counts; outbound does not.
2026-09-20 Staff may reject an open moderator proposal. Reject is append-only moderator_reject (role stays verified). Re-propose after reject inserts a new moderator_propose (history kept). Pending = latest propose/reject is propose, subject still verified, no confirm/appoint. Unique live kinds are verify/confirm/appoint only (propose/reject may repeat). An 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; mark-read does not dismiss it. Supersedes the 2026-09-17 pending-propose list row (pending was any propose without confirm/appoint).
2026-09-21 Optional GET /messages?hashtag= token filter on live top-level text (name without #; token match; combines with mode/limit/cursor). No new entity, table, or index. A shops page of 20 is 20 matching notes, not 20 mixed notes filtered later.
2026-09-21 Propose/confirm/reject re-list after insert so a concurrent older propose, a concurrent reject, or a concurrent confirm/appoint cannot leave two live outcomes. Confirm and reject compare the pending propose by edge id (same-actor same-ms re-propose is a different row). After propose notify, re-list and drop or refresh moderator_proposal rows when that insert is no longer pending. The public graph projects at most one incoming edge per subject (winning id), skipping non-chain oldest siblings. Supersedes the 2026-09-17 “one incoming kind” / first-contact-wins row.
2026-09-22 Payments store USD/CHF/EUR/PHP at payment time. A USD stipend keeps the USD amount that was sent.
2026-09-22 A top-level forum note may store an optional place pin (latitude, longitude, label at most 80 characters). Replies cannot. Public JSON includes place only when set. GET /messages/places lists live top-level pins for a map. The label is not a kind:1 hashtag and is not the profile location.
2026-09-24 Initiator shares the moderator rank; permissions still name the minimum rank only. Supersedes the 2026-09-20 strict total order founder > moderator > verified > basis.
2026-09-29 Social recovery of the user-held seed is specified in docs/social-recovery.md and is not implemented. Guardians hold SLIP-39 shares of the frozen mnemonic-v1 entropy. They do not hold the passkey. The api must not be the source of the public keys those shares are encrypted to. Custodial nsecs are out of scope.
2026-09-30 Mein Konto absichern is optional. The account works without it. An owner who continues is told what social recovery does, then chooses two people. Both are required to open the same account on a new device and restore the same 12 seed words. If a moderator verified the owner, that person is suggested as person 1 and can be replaced. 21.gifts does not hold a share. Specified in docs/social-recovery.md. Not implemented. Supersedes the 2026-09-29 social-recovery row.
2026-10-01 A shop's OpenCryptoPay QR stays the same link. USDT or USDC paid through it settles as bitcoin on the shop's own Breez Spark wallet. The shop does not hold USDT or USDC. The till is paid only when those sats have arrived. The wallet seed is the 12-word phrase the app already derives (mnemonicFromPrfFirst); the api already records that seed passkey (POST /auth/passkey/seed/begin and /finish). Specified in docs/shop-spark-payment.md. Not implemented. Today's Lightning pay link is unchanged.
2026-10-01 Opening a forum note, translating it, or opening Notifications stamps the matching unread in-app rows and tells the account's other devices to close those banners. POST /notifications/read-by-message stamps unread forum_post, forum_reply, forum_mention, and zap whose parent or reply id is that note. Read-all and a freshly stamped single read do the same for the rows they mark. moderator_proposal stays unread and is not dismissed. Private-message pushes are not dismissed.
2026-10-02 One forum note notifies each recipient once. The marked account keeps only forum_mention; everyone else keeps the single forum_post or forum_reply. POST /messages excludes mention account ids from the post/reply fan-out. GET /notifications drops and deletes a stored generic row when the scan also has forum_mention for the same replyId. Zaps stay a separate event. Supersedes the extra mention-on-top-of-post fan-out.

Next Steps

  1. Create 21gifts/api repo skeleton — done 2026-05-25: TS + Bun + Hono + Vitest, 100% coverage on /healthz and /info, this CONCEPT.md committed as the canonical home
  2. Create 21gifts/app repo skeleton (Next.js 15 + TS strict + Tailwind + Zustand) — done 2026-07-05: public repo exists
  3. Port the passkey + PRF + key-derivation primitives from the reference app — restated 2026-09-21: v1 begin options request PRF for client phrase export; API auth and Nostr custody do not use it
  4. Define the v1 api surface (passkey login, donor wallets, recurring-gift scheduler, address verification, custodial NOSTR identities + server-side event signing, feed, LN-Address resolver) — SPEC.md in the api repo — done 2026-08-15: implemented HTTP surface documented in SPEC.md; remaining CONCEPT capabilities listed there as not implemented
  5. Wire up the four CI/CD workflows on both repos and Docker Hub publishing — done 2026-07-05: ci, deploy-dev, deploy-prd, and auto-release-pr exist on api and app
  6. Validate LNURL-auth end-to-end with real wallets — cancelled 2026-08-24: LNURL-auth login was removed; login is passkey-only
  7. Sketch core UI flows: sign-in → profile → donate → recurring gifts → message — done 2026-08-15: FLOWS.md sketches the five journeys and labels each Shipped vs Sketch; HTTP stays in SPEC.md
  8. Choose initial NOSTR relay set — done 2026-05-25: shared nostr.space relay (see Decisions Log 2026-05-25 / Open Question #3)
  9. First public DEV deploy — done 2026-07-05: public DEV URLs are live (dev-api.21.gifts, dev-app.21.gifts)
  10. Iterate MVP, dogfood early

This document is the canonical source for product-level decisions on 21.gifts. Hosting, secrets, DNS, and any other operator-specific details are out of scope and live in the operator's separate infrastructure repository.