Website
21.gifts website handbook
This handbook is mandatory. Every HTML screen in this repository must have a ## Screen: section. CI (npm run handbook:check) fails otherwise.
There is no application src/ here (static HTML). Screens are /, /legal, /404.
The rendered handbook for website, app, and api is published at handbook.21.gifts (DEV: dev-handbook.21.gifts).
Screens
Screen: /
- URL:
https://21.gifts/(production) and the Cloudflare Pages develop alias. - What the user sees: Marketing landing: peer-to-peer Lightning gifts, NOSTR mention, CTA toward the app, Open Graph preview image.
- Actions: Read the pitch, follow links into the app (
app.21.gifts), the handbook (handbook.21.gifts), and the legal page. - Assets:
/favicon.ico,/apple-touch-icon.png,assets/favicon.svg,assets/og.png.
Screen: /legal
- URL:
/legal.html(sitemap and canonical use this path; there is no in-repo rewrite to/legal). - What the user sees: Imprint plus privacy (FADP), hosting, and app-data notes for 21.gifts.
- Actions: Read the legal text. Follow header links to the landing, the handbook (
handbook.21.gifts), and the app. - Assets: Same favicon and OG tags as the landing.
Screen: /404
- URL:
/404.html(static asset). This repo does not rewrite unknown paths onto404.html; that is hosting configuration. - What the user sees: Not-found page with a link home. Icons only (no Open Graph block).
- Actions: Navigate back to
/, or follow header links to the handbook (handbook.21.gifts) and the app. - Assets:
/favicon.ico, SVG icon, apple-touch-icon.
App
21.gifts app handbook
This handbook is mandatory. Every exported function, every UI screen, and every HTTP endpoint in this repository must have a section here. CI (npm run handbook:check) fails the PR if a heading is missing or the section is a stub.
- Screens:
## Screen: /path - Functions:
## Function: name - Endpoints:
## Endpoint: METHOD /path
Do not merge a PR that adds a screen, export, or endpoint without updating this handbook in the same PR. Undeclared gaps are rejected, not discussed.
See screens.md, functions.md, and endpoints.md.
Screens
Screen: /
- URL:
/— public landing placeholder (no auth gate). - What the user sees: Gift icon, wordmark
21.gifts, one-line pitch, “Coming soon”, links Donate (/donate) and Log in (/login). - Actions: Navigate to donate or login. There is no Lightning Address form on this route.
- Calls:
Homeonly (src/app/page.tsx). Auth UI lives on/login.
Screen: /login
- URL:
/login— LNURL-auth challenge. - What the user sees: Idle: page heading Log in to 21.gifts, card heading Sign in to 21.gifts, and button Log in with your Lightning wallet. After start: QR of the uppercase LNURL, primary Open Wallet of Satoshi, secondary Open default Lightning wallet, Copy login code, plus expiry/error/signed-in states.
- Actions: Scan the QR, tap WoS (
walletofsatoshi:lightning:LNURL1…/ Android Intent), tap genericlightning:, or copy the LNURL. The page polls/auth/sessionuntil authenticated, then stays on/loginand shows the signed-in card (role, linking key, Lightning Address form, log out). There is no client redirect to/. - Calls:
LoginCard(includesLightningAddressFormwhen signed in),useLnurlLogin,startLnurlAuth,pollSession,walletOfSatoshiHref,walletOfSatoshiIntentHref,uppercaseLnurl,QrCode,useAuthStore.
Screen: /donate
- URL:
/donate— guest LNURL-pay gift. No login required. - What the user sees: Heading Send a gift, Lightning Address field, sat amount (no comment field), Create invoice, then a QR and
lightning:invoice link. - Actions: Enter a LUD-16 address and amount, create an invoice, pay from any Lightning wallet.
- Calls:
DonateForm,resolveLightningAddress,requestDonateInvoice,satsToMsat,formatMsatAsSats,QrCode.
Functions
Function: DonateForm
- Purpose: Renders the guest donate form (Lightning Address and sat amount only; no comment) and, after success, the invoice QR.
- Inputs: Form state: address and whole-sat amount.
- Returns / side effects: React element. Side effects: HTTP to the api then GET the payee LNURL-pay callback.
- Used by: Screen
/donate.
Function: DonatePage
- Purpose: Next.js page for
/donate. - Inputs: None.
- Returns / side effects: The donate screen wrapped in the root layout.
- Used by: Route
/donate.
Function: GET
- Purpose: App-router handler for
GET /healthz(liveness). Documented here as the onlyexport function GETin this repo. - Inputs: None.
- Returns / side effects:
ResponseJSON{ status: 'ok' }with HTTP 200. - Used by: Container probes, e2e smoke.
Function: Home
- Purpose: Next.js page for
/. Placeholder landing: wordmark, pitch, Coming soon, Donate and Log in links. No session UI. - Inputs: None.
- Returns / side effects: The home screen element.
- Used by: Route
/.
Function: LightningAddressForm
- Purpose: Logged-in form to claim, verify, or unlink a LUD-16 Lightning Address.
- Inputs: Reads
useAuthStore. User input: address string, verification confirm. - Returns / side effects: React element or
nullwhen logged out. - Used by:
LoginCardsigned-in view on screen/login(not on/).
Function: LoginCard
- Purpose: LNURL-auth UI: hydrate session, start challenge, QR, WoS deep link, copy, poll, expiry, then signed-in view with
LightningAddressForm. - Inputs: Uses
useLnurlLoginanduseAuthStore. Rehydrates vialoadSession+fetchMe. - Returns / side effects: React element covering idle/waiting/expired/error/signed-in. Does not navigate away from
/login. - Used by: Screen
/login.
Function: LoginPage
- Purpose: Next.js page for
/login. - Inputs: None.
- Returns / side effects: Renders
LoginCard. - Used by: Route
/login.
Function: QrCode
- Purpose: SVG QR for a string (LNURL or bolt11).
- Inputs:
value(required), optionallabel. - Returns / side effects: React element.
- Used by:
LoginCardandDonateForm.
Function: RootLayout
- Purpose: Root HTML shell:
lang=en, global CSS, metadata (title, icons, Open Graph, Twitter). - Inputs:
childrenReact nodes. - Returns / side effects: The document wrapper for every route.
- Used by: All screens.
Function: clearSession
- Purpose: Removes the bearer token from
localStorage. - Inputs: None.
- Returns / side effects: void. No-op during SSR (
windowundefined). - Used by:
useAuthStore.clearAuthandLoginCardwhen session hydration gets 401.
Function: confirmLightningAddressVerification
- Purpose: POST
/me/lightning-address/verification/confirmwith the nonce after the 1-sat invoice is paid. - Inputs:
sessionToken,nonce. - Returns / side effects: Updated
Account, or throws on 4xx/5xx. - Used by:
LightningAddressForm.
Function: fetchMe
- Purpose: GET
/mewith the bearer session. - Inputs:
sessionToken. - Returns / side effects:
Accountornullon 401. - Used by:
LoginCardsession hydration.
Function: formatMsatAsSats
- Purpose: Formats millisatoshis as a sat string for the donate UI.
- Inputs:
msatnumber. - Returns / side effects: Decimal string in sats.
- Used by:
DonateFormamount-range error (minSendable–maxSendable).
Function: getApiUrl
- Purpose: Reads
NEXT_PUBLIC_API_URLvia the typed config accessor. - Inputs: None.
- Returns / side effects: Origin string. Throws if unset/empty (entrypoint must substitute).
- Used by: Every
src/lib/api.tscall.
Function: isAndroidUserAgent
- Purpose: Detects Android so the WoS CTA can use an Intent URL.
- Inputs:
userAgentstring. - Returns / side effects:
trueiff/Android/imatches. - Used by:
LoginCardQrView.
Function: loadSession
- Purpose: Reads the bearer token from
localStorage. - Inputs: None.
- Returns / side effects: Token string or
null. SSR-safe. - Used by:
LoginCardon mount.
Function: pollSession
- Purpose: GET
/auth/sessionwithX-Poll-Token. - Inputs:
pollToken. - Returns / side effects:
SessionResult(pending/authenticated/expired/used). - Used by:
useLnurlLogin.
Function: requestDonateInvoice
- Purpose: GET an LNURL-pay callback with
amountmillisatoshis and return the bolt11 string. - Inputs:
{ callback, amountMsat, fetchImpl? }. Does not resolve a Lightning Address. - Returns / side effects: bolt11
string, or throws. - Used by:
DonateForm.
Function: resolveLightningAddress
- Purpose: GET
/lightning-address?address=on the 21.gifts api. - Inputs:
address. - Returns / side effects: Resolved LNURL-pay metadata (callback, min/max).
- Used by:
DonateFormbefore paying.
Function: satsToMsat
- Purpose: Converts whole sats to millisatoshis.
- Inputs:
satsnumber. - Returns / side effects:
sats * 1000. - Used by:
DonateForm(converts sats before callingrequestDonateInvoice).
Function: saveSession
- Purpose: Writes the bearer token to
localStorage. - Inputs:
tokenstring. - Returns / side effects: void. SSR no-op.
- Used by:
useAuthStore.setAuth.
Function: setLightningAddress
- Purpose: POST
/me/lightning-address. - Inputs:
sessionToken,address. - Returns / side effects: Updated
Account. - Used by:
LightningAddressForm.
Function: startLightningAddressVerification
- Purpose: POST
/me/lightning-address/verification— api pays ~1 sat to the linked address. The nonce is not in the JSON; the user reads it from the wallet payment comment. - Inputs:
sessionToken. - Returns / side effects:
{ status: 'sent', expiresInSeconds, sats }. - Used by:
LightningAddressForm.
Function: startLnurlAuth
- Purpose: GET
/auth/lnurl— creates k1 + LNURL + poll token. - Inputs: None.
- Returns / side effects:
StartChallenge. - Used by:
useLnurlLogin.
Function: unlinkLightningAddress
- Purpose: DELETE
/me/lightning-address. - Inputs:
sessionToken. - Returns / side effects: Updated
Accountwith address cleared. - Used by:
LightningAddressForm.
Function: uppercaseLnurl
- Purpose: Uppercases a bech32 LNURL (LUD-01).
- Inputs:
lnurlstring. - Returns / side effects: Uppercase string.
- Used by: QR value,
lightning:href, WoS hrefs.
Function: useAuthStore
- Purpose: Zustand store for
session+account. Hydration is explicit (no module-initlocalStorage). - Inputs: Hook. Methods
setAuth,setAccount,clearAuth. - Returns / side effects: Auth state object.
- Used by:
LoginCard,useLnurlLogin,LightningAddressFormon/login(not/).
Function: useLnurlLogin
- Purpose: Hook: start LNURL-auth, poll until authenticated or expired. Returns
{ status, lnurl, start }— there is no separateerrorfield (errors are astatusof'error'). - Inputs: None.
- Returns / side effects:
UseLnurlLoginstatus machine. - Used by:
LoginCard.
Function: walletOfSatoshiHref
- Purpose: iOS/desktop WoS deep link.
- Inputs:
lnurl. - Returns / side effects:
walletofsatoshi:lightning:+ uppercase LNURL. - Used by:
LoginCardprimary CTA when not Android.
Function: walletOfSatoshiIntentHref
- Purpose: Android Chrome Intent pinning the WoS package.
- Inputs:
lnurl. - Returns / side effects:
intent:lightning:LNURL…#Intent;scheme=walletofsatoshi;package=com.livingroomofsatoshi.wallet;…;end. - Used by:
LoginCardprimary CTA on Android.
HTTP endpoints (Next.js route handlers)
Endpoint: GET /healthz
- Purpose: Liveness JSON
{ status: 'ok' }fromsrc/app/healthz/route.ts. - Errors: None if the process is up (always 200).
- Used by: Container probes and Playwright smoke.
- Auth: Public.
API
21.gifts api handbook
This handbook is mandatory. Every exported function/class and every HTTP endpoint must have a section. CI (bun run handbook:check / node scripts/check-handbook.mjs) fails the PR otherwise.
- Functions:
## Function: name - Endpoints:
## Endpoint: METHOD /path
This repo has no UI screens. UI screens live in 21gifts/app and 21gifts/website. A PR that adds an export or route without a handbook section is rejected.
Functions
Function: InMemoryAuthStore
- Purpose: Process-local AuthStore: challenges, accounts, sessions, verifications. Evicts expired challenges/sessions on write.
- Inputs: Constructor none. Methods take domain objects (
Challenge,Account,Session,AddressVerification). - Returns / side effects: Lookups return the object or
undefined. Writes are void. - Used by:
createAppdefault store; all auth/me routes.
Function: InMemoryLnAddressCache
- Purpose: TTL cache for successful LUD-16 metadata resolves.
- Inputs:
get(address, now),put(entry, now). TTL fromLN_ADDRESS_CACHE_TTL_MS. - Returns / side effects:
getreturnsCachedLnAddressornull. - Used by:
lightningAddressRoutes.
Function: UnconfiguredInvoicePayer
- Purpose: InvoicePayer that always fails — process boots without a payer so verification returns 503 until wired.
- Inputs:
isConfigured()is always false.payInvoice(bolt11)is the pay method. - Returns / side effects:
{ ok: false, reason: 'not_configured' }— it does not throw. - Used by: Default
createAppinvoicePayer.
Function: authRoutes
- Purpose: Hono sub-app for LNURL-auth.
- Inputs:
AuthRouteDeps: store, now, publicBaseUrl. - Returns / side effects: Hono app mounted at
/auth. - Used by:
createApp.
Function: bearerToken
- Purpose: Parses
Authorization: Bearer <token>. - Inputs: Header string or undefined.
- Returns / side effects: Token or
null. - Used by:
meRoutes.
Function: brandRoutes
- Purpose: Serves favicon.ico, favicon.svg, apple-touch-icon.png from
public/. - Inputs:
BrandRouteDeps.read. - Returns / side effects: Hono app with three GETs; 404 empty body if bytes missing.
- Used by:
createAppat/.
Function: claimSession
- Purpose: Consumes an authenticated challenge and issues a session.
- Inputs:
store,now,pollToken. - Returns / side effects:
SessionResultpending/authenticated/expired/used. - Used by:
GET /auth/session.
Function: completeCallback
- Purpose: Verifies wallet sig+key against k1, upserts account, marks challenge authenticated.
- Inputs:
store,now,{ k1, sig, key }. - Returns / side effects:
{ ok: true, accountId, firstLogin }or{ ok: false, reason }. - Used by:
GET /auth/lnurl/callback.
Function: confirmVerification
- Purpose: Checks the nonce the user read from the wallet payment comment (
21gifts <hex>), not a nonce returned by startVerification. - Inputs:
store,now,account,nonceRaw. - Returns / side effects: Success marks the address verified, or a
ConfirmVerificationCode. - Used by:
POST /me/lightning-address/verification/confirm.
Function: createApp
- Purpose: Wires CORS, requestLog, brand, health, info, auth, me, lightning-address.
- Inputs: Optional
AppDeps(store, clock, payer, fetch, cache, readBrand, origins, publicBaseUrl). - Returns / side effects: Hono app. Used by Bun.serve in
index.tsand by tests viaapp.request(). - Used by: Boot path and every HTTP test.
Function: encodeLnurl
- Purpose: bech32-encodes an HTTPS URL as
lnurl1…(LUD-01). - Inputs:
urlstring. - Returns / side effects: Bech32 LNURL.
- Used by:
startChallenge.
Function: healthRoute
- Purpose: Hono app: GET
/→{ status: 'ok', service, version }. - Inputs: None.
- Returns / side effects: Mounted at
/healthz. - Used by: Probes.
Function: infoRoute
- Purpose: Hono app: GET
/→ service name, version, description, repo. - Inputs: None.
- Returns / side effects: Mounted at
/info. - Used by: Service discovery.
Function: lightningAddressRoutes
- Purpose: Public LUD-16 resolve with cache.
- Inputs:
LightningAddressRouteDepscache, now, fetchImpl. - Returns / side effects: Hono GET
/. - Used by:
GET /lightning-address.
Function: logEvent
- Purpose: One JSON line on
console.warn(ts+event+ fields). Never log secrets. - Inputs:
eventstring, optionalLogFields. - Returns / side effects: void.
- Used by: Auth, me, lightning-address, requestLog.
Function: meRoutes
- Purpose: Authenticated account routes.
- Inputs:
MeRouteDepsstore, now, payer, fetchImpl. - Returns / side effects: Hono at
/me. - Used by:
createApp.
Function: normalizeLightningAddress
- Purpose: Lowercases and validates
local@domainLUD-16 shape. - Inputs:
rawstring. - Returns / side effects: Canonical address or
null. - Used by: me lightning-address POST and public resolve.
Function: normalizePublicBaseUrl
- Purpose: Trims trailing slash; rejects empty.
- Inputs: raw env string or undefined.
- Returns / side effects: Base URL or
null. Auth routes then respond HTTP 500Server auth is not configured. - Used by:
startChallengevia auth routes.
Function: parseBindAddr
- Purpose: Parses
host:portbind spec. - Inputs:
addrstring. - Returns / side effects:
{ host, port }. Throws on garbage. - Used by:
index.tsboot.
Function: randomHex
- Purpose: CSPRNG hex for k1 / poll tokens / session tokens / nonces.
- Inputs:
byteLength. - Returns / side effects: Lowercase hex.
- Used by: Auth challenge + session + verification.
Function: readPublicBrandFile
- Purpose: Reads
public/<name>relative to a root directory. - Inputs:
BrandFileNameand optionalroot(defaultprocess.cwd()). - Returns / side effects:
Uint8Arrayornullif missing. Does not change the process cwd. - Used by: Default
brandRoutesreader.
Function: requestLog
- Purpose: Hono middleware:
http.requestJSON after the handler. Skips/healthzand OPTIONS. Never logs the query string. - Inputs: None.
- Returns / side effects:
MiddlewareHandler. - Used by:
createApp.
Function: requestPayInvoice
- Purpose: LNURL-pay: fetch metadata, then GET the callback with
amountand optionalcommentquery params (LUD-06), return bolt11. - Inputs:
RequestPayInvoiceArgs. - Returns / side effects:
LnurlPayResult. - Used by: Verification payer path when a real InvoicePayer is wired; app donate uses the browser equivalent.
Function: resolveAllowedOrigins
- Purpose: CORS allow-list from
CORS_ALLOWED_ORIGINSor the built-in app origins. - Inputs:
envrecord. - Returns / side effects: string[] of origins.
- Used by:
createAppCORS.
Function: resolveBindAddr
- Purpose: BIND_ADDR from env with default
0.0.0.0:3000. - Inputs: optional override, env.
- Returns / side effects: Address string.
- Used by:
index.ts.
Function: resolveLnurlp
- Purpose: GET
https://domain/.well-known/lnurlp/localand parse metadata. - Inputs: address + fetchImpl.
- Returns / side effects: Callback URL + min/max sendable or error.
- Used by:
lightningAddressRoutes,requestPayInvoice.
Function: resolveSession
- Purpose: Looks up a bearer session; rejects expired.
- Inputs:
store,now,token. - Returns / side effects:
Accountornull. - Used by:
meRoutes.
Function: startChallenge
- Purpose: Mints k1, poll token, LNURL pointing at
{base}/auth/lnurl/callback. - Inputs:
store,now,baseUrl. - Returns / side effects:
StartChallengeResult. - Used by:
GET /auth/lnurl.
Function: startVerification
- Purpose: Pays a 1-sat LNURL-pay invoice to the linked address and stores a nonce.
- Inputs:
StartVerificationArgs(store, payer, fetch, accountId, now). - Returns / side effects: Sent result or a
StartVerificationCode(no address, payer down, …). - Used by:
POST /me/lightning-address/verification.
Function: verifyAuthSig
- Purpose: secp256k1 verify: DER sig of k1 by linkingKey.
- Inputs:
k1,sig,keyhex. - Returns / side effects:
trueiff the wallet signed this challenge. - Used by:
completeCallback.
HTTP endpoints
Endpoint: DELETE /me/lightning-address
- Purpose: Bearer required. Clears the account Lightning Address.
- Errors: 401 without session.
- Used by:
unlinkLightningAddressin the app. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /apple-touch-icon.png
- Purpose: PNG brand mark (apple-touch).
Cache-Control: public, max-age=86400. - Errors: 404 empty body when
public/apple-touch-icon.pngis missing. - Used by: iOS home-screen icon crawlers.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /auth/lnurl
- Purpose: Creates a login challenge. JSON: lnurl, k1, pollToken.
- Errors: HTTP 500
{ error: 'Server auth is not configured' }ifPUBLIC_BASE_URLis unset. - Used by: App
startLnurlAuth. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /auth/lnurl/callback
- Purpose: Wallet hits this with
k1,sig,key(LUD-04). Success body{ status: 'OK' }. Query is never written to http.request logs. - Errors: HTTP 200
{ status: 'ERROR', reason }on missing params or bad signature (LUD-04). - Used by: Wallet of Satoshi / any LNURL-auth wallet.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /auth/session
- Purpose: Header
X-Poll-Token. Returns pending until the callback succeeds, then{ status: 'authenticated', token, account }. - Errors: expired/used after TTL or reuse.
- Used by: App
pollSession. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /favicon.ico
- Purpose: Windows ICO (RGBA PNG-in-ICO) of the 21.gifts mark.
Content-Type: image/x-icon,Cache-Control: public, max-age=86400. - Errors: 404 empty body when
public/favicon.icois missing. - Used by: Browsers opening api.21.gifts.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /favicon.svg
- Purpose: SVG mark, orange 21 on black.
Content-Type: image/svg+xml,Cache-Control: public, max-age=86400. - Errors: 404 empty body when
public/favicon.svgis missing. - Used by: Modern browsers preferring SVG icons.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /healthz
- Purpose: Liveness.
{ status: 'ok', service, version }. Not logged as http.request. - Errors: Always 200 if the process is up.
- Used by: Orchestrators, e2e, Uptime checks.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /info
- Purpose: Service metadata (name, version, description, repo).
- Errors: 200 JSON.
- Used by: Humans and service catalogs.
- Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /lightning-address
- Purpose: Query
address=local@domain. Resolves LUD-16, cached 5 minutes on success. - Errors: 400 invalid, 502 unresolved.
- Used by: App donate
resolveLightningAddress. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: GET /me
- Purpose: Bearer session. Current account JSON (id, linkingKey prefix fields, lightning address, verified flag, role).
- Errors: 401 if missing/expired.
- Used by: App
fetchMe. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: POST /me/lightning-address
- Purpose: Body
{ address }. Stores unverified LUD-16 on the account. - Errors: 401/400.
- Used by: App
setLightningAddress. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: POST /me/lightning-address/verification
- Purpose: Triggers the 1-sat proof-of-control payment. JSON
{ status: 'sent', expiresInSeconds, sats }. The nonce is not returned to the client; it is only in the LUD-12 wallet comment. - Errors: 401
{ error: 'Unauthorized' }; 409{ error: 'No Lightning Address linked' }or{ error: 'Lightning Address already verified' }; 502{ error: 'Lightning Address did not accept the verification payment' }; 503{ error: 'Verification payments are not configured' }. - Used by: App
startLightningAddressVerification. - Auth: See Purpose — Bearer where stated, else public.
Endpoint: POST /me/lightning-address/verification/confirm
- Purpose: Body
{ nonce }. Marks the address verified when the invoice was paid. - Errors: 401
{ error: 'Unauthorized' }; 400{ error: 'Expected a JSON body with a "nonce" string' }or{ error: 'Incorrect verification code' }; 409{ error: 'No verification in progress' }or{ error: 'Verification expired' }. - Used by: App
confirmLightningAddressVerification. - Auth: See Purpose — Bearer where stated, else public.
Stand 4985b4f0b8c1