A targeted tip is addressed to a Smirk handle rather than to whoever holds a
share URL. The v3 port shipped public-only and the schema said so: there was
no recipient column anywhere, so create_social_tip rejected is_public=false
outright and the two inbox endpoints returned an empty list unconditionally.
This is the missing half. The migration adds recipient_user_id plus two
CHECKs that keep each kind of tip well formed: a targeted tip must name its
recipient, and must carry the ciphertext, because with no URL fragment that
ciphertext is the only way to claim it. Without the second one a draft could
be created that looks healthy and is permanently unclaimable. Both are
satisfied by every existing row, which is public, so nothing is backfilled
or rewritten.
is_public is derived from the recipient in the INSERT rather than passed
alongside it. Two independent sources for "who may claim this" is how a tip
ends up addressed to one person and claimable by anyone.
Claiming was already guarded by is_public = TRUE, so no targeted tip could
be claimed at all; that becomes "public, or the caller is the recipient".
The claimable query repeats exactly the conditions the claim call enforces,
so the inbox cannot show a Claim button the backend then refuses.
It is off by default and separately from FEATURE_TIPS, because it is a
distinct surface: an inbox, and a claim authorised by identity rather than
by holding a secret. It also needs the migration to have run, so an instance
must opt in rather than acquire it by upgrading. The capability is
advertised only when tipping is also on, since a targeted tip is still a
tip, and recipient resolution checks the flag before anything else: a
backend that cannot serve this must refuse, never quietly fall back to
creating a public tip from a targeted request, which would hand a tip meant
for one person to anyone with the URL.
The config fixture moves to Config::test_default so a test of what a flag
does can live next to the flag.
A v0.3.0 wallet registers two different Grin keys in sequence. Bootstrap
stores the Smirk-derived public key, which is what /auth/website/verify
checks a Grin signature against, and a deferred effect then registers the
canonical grin-wallet slatepack address. These are separate derivations,
not two encodings of one key, and both wrote (user_id, 'grin', 'primary').
The second silently replaced the first, so signing in to the website with
Grin could not match any account.
Route the value to its own row instead. A slatepack address is bech32 under
the grin/tgrin HRP and a hex public key cannot start with those letters,
since none of them are hex digits, so the two are separable by construction
rather than by guess. That matters because it fixes wallets that are already
shipped: they keep posting what they post today and land in the right row.
lookup_username picked with an unqualified find on the asset, which returns
an arbitrary row once an asset has two. It now selects by key_type, and for
Grin prefers the slatepack address, falling back to a primary row only while
that row still holds a pre-split address. A sender is never handed a public
key where an address belongs.
The migration is additive: existing bech32 values are copied into the new
row and the primary row is left for the next unlock to rewrite. Nothing is
deleted, so a user who never unlocks again keeps a working address.
The published privacy policy states that IP addresses are retained only as a
salted one-way hash for rate limiting. That was true of the login-event path
but not of sessions.ip_address or audit_logs.ip_address, both of which stored
a raw client IP tied to user_id on every login.
Drop both columns and all writer/model code, so the schema enforces the policy
rather than relying on convention. The hashed rate-limit path (login_events,
restore_attempts) is unchanged and remains the only IP retention.
Also drops the `wallets` table, which was created in the initial schema and
never read or written: a latent user_id -> address / view_key store.
The operator forensic trail (admin_audit_logs, admin_sessions) deliberately
keeps its raw IP. Privileged-action forensics is a different threat model and
is not covered by the user-facing claim.
Stage 1 of the operator-editable config layer (additive; no behavior change yet):
- migration operator_settings: one MAC(ADMIN_KEY_INTEGRITY_SECRET) JSONB row per
section, FK to admin_keys, audited on write. Missing section = env-only.
- OperatorSettingRow model.
- SettingsOverlay + section overlays (landing/retention/restore) with secrets
EXCLUDED BY CONSTRUCTION; Config::apply_overlay merges onto the env-derived Config
then re-runs the SAME Config::validate boot uses (a bad edit yields the identical
error, nothing persists, the instance cannot be bricked).
- runtime_class() single source of truth (RuntimeSafe hot-swap vs RestartRequired).
- operator_settings storage: canonical-JSON MAC (stable across JSONB key reorder),
load_settings_overlay (fail-closed per section -> env fallback), put_setting_audited
(upsert + admin_audit append in one tx).
- sqlx json feature.
cargo check clean; 4 new unit tests pass. Next stages: ArcSwap AppState + ~59
read-site migration, GET/PUT /admin/config (+ restart_apply_mode), remaining sections.
Grin as a new asset arm in the tips machinery (no grin-lws change, no custodial
grin_transactions/grin_outputs port):
- GrinClient::get_outputs([commit]) (funding: present+height=confirmed) +
get_kernel(excess) (sweep spend-height); reached via state.chains.grin.
- create-gating (supported_tip_asset grin, validate 66-hex commitment,
confirmations_for_asset grin=10), grin_commitment column (migration).
- confirmation worker grin arm: get_outputs present -> confs; amount AUTO-VERIFY
with warn (voucher hides amount; not a theft vector; matches legacy).
- sweep reconciler grin arm: get_outputs-absent + get_kernel height at depth 10 ->
settle; absence-without-kernel -> no settle (recoverable, never missettle);
reorg revert only on commitment reappearing; probe error never settles/reverts.
- 5 grin tests + pure-helper unit tests; 251 lib + 24 tips green.
Amount-verification note: the voucher's bulletproof rewind nonce is random +
untransmitted, so it can't be rewound today; a clean non-custodial fast-follow is
to have the client send the per-voucher rewind nonce and verify via rangeproof
rewind (nonce reveals amount only, not spend authority).
A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.
- Admission: a new `premium-post` write policy gates on kind x membership —
premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
DM-relay-list) -> free; registered + general -> premium required; external ->
gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
extends premium_until in ONE transaction (a mid-flight failure keeps the paid
invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
expiry processor floors apply on the premium path too (its common case is
require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
expiry, feature gate). OpenAPI + migration updated.
Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
Optional REGISTRATION_REQUIRE_PAYMENT gate: a new wallet must settle an
invoice on an external, non-custodial payment processor before it can
register. Pull model — the grant reads the processor's authenticated API as
the source of truth, so there is no inbound webhook to secure and a settled
invoice cannot be forged over HTTP. Returning wallets and self-hosting bypass
it, exactly like the invite and PoW gates.
- infra/payment: a pluggable PaymentProvider seam (neutral Invoice /
InvoiceStatus / InvoiceRequest) with a BTCPay-compatible first adapter that
also serves this project's xmrcheckout/wowcheckout apps + the BTCPay Monero
plugin, so "operator picks any coin" costs one adapter. A future ETH/native
adapter (BTCPay serves neither) implements the same trait. Only `Settled`
grants (unknown status fails safe); the API key rides in a redacting Secret
and hostile responses are size-capped.
- payment_invoices: invoice<->pubkey_hash binding + atomic single-use consume
(UPDATE ... WHERE consumed_at IS NULL AND pubkey_hash=$ RETURNING), so one
invoice grants at most one registration and never a different identity.
- POST /auth/payment-invoice mints + binds; enforce_payment (bind -> processor
`Settled` -> atomic consume) runs beside enforce_invite; payment_invoice_id
on the register request.
- Fail-closed config: https-in-prod, positive-decimal price, >= 1 confirmation
(bars a 0-conf settled-then-double-spent free registration), bounded expiry.
/capabilities advertises payment_required + price; OpenAPI + .env.example.
Tests: adapter status/speedPolicy mapping + config validation (unit), and L1
binding + atomic single-use (concurrent-consume-one-wins, mis-bind rejected).
Reviewed adversarially across payment-integrity, leak/oracle, and config/DoS
lenses; the confirmed findings (0-conf, expiry bound, Settled-means-paid-in-full
adapter contract, rotation-branch bypass guard) are addressed here.
First of the operator-configurable registration gates (PoW-priced restore and
pay-to-register follow). A NEW wallet must present a valid invite code when
REGISTRATION_REQUIRE_INVITE is on; returning wallets and self-hosting bypass it,
and the active gates are advertised via /capabilities so the wallet shapes
onboarding.
- migration + db: invite_codes table storing only sha256(code) (128-bit codes,
no pepper needed). Redemption is an atomic single-statement claim
(UPDATE ... WHERE used_at IS NULL RETURNING) — single-use even under concurrent
submission (L1 test fires 8 concurrent claims; exactly one wins).
- core::invite: generate (base58, 128-bit) + sha256 hash.
- enforce_invite in extension_register, mirroring enforce_pow (returning bypass;
ONE literal error for not-found/used/expired — never an oracle).
- RegistrationConfig.require_invite + RegistrationCapability in /capabilities.
- smirk-admin mint-invite --count/--label (prints raw codes once; stores hashes).
- L1 tests (single-use + concurrency race), .env.example, OpenAPI (drift clean).
The last operator subsystem: a user who no longer trusts the backend can reach in
and delete their data. Action-bound and two-phase.
- migration 20260626000003: erasure_requests (user_id ON DELETE SET NULL so the
completed tombstone survives the user delete; one-live-request-per-user partial
unique index; due index).
- Proof model (§5.2): every action is a fresh NIP-98 signed action (purpose
erasure_request/confirm/cancel/export) bound to a single-use challenge nonce +
canonical descriptor + (confirm/cancel) the explicit erasure_id. A session
token is NEVER sufficient. Erasure targets only the users.id the proof's key
resolves to (via linked nostr_pubkey); leaked identifiers are never selectors.
Nonce consumed AFTER signature checks (forged sig can't burn a victim's nonce).
- Public routes (proof-gated): POST /account/erasure/challenge, /account/erasure
(request; ABSENT-user path returns identical shape — no account-existence
oracle), /account/erasure/:id/confirm (revokes live sessions), DELETE
/account/erasure/:id (cancel during grace), POST /account/export (see-before-
delete; omits view keys; audited).
- Execution (§5.7): a background sweeper claims confirmed-past-grace rows
(FOR UPDATE SKIP LOCKED) and runs ONE fail-closed tx: purge login_events
(the origin/asset/time quasi-identifier; default) or anonymize; scrub audit
PII; DELETE users (schema cascades wallets/sessions/keys/owned slatepacks,
SET NULL on counterparty/audit/login links); tombstone; hash-chained
account_erasure_completed audit.
- config: ERASURE_GRACE_PERIOD_HOURS (72h default); validate() requires the
integrity secret + PUBLIC_API_URL when erasure is enabled.
- 1 L1 test: export -> request -> confirm -> execute -> account gone, plus the
absent-user constant-shape path.
Scope (v1, documented): live primary. No at-rest view key exists to crypto-shred
(stateless-forward), so backup residue is bounded by retention-window docs, not
crypto-shred (non-v1 per decisions ledger). Deferred: LWS de-registration queue +
read-back probe (operators run their own LWS), audit cross-reference scrub on
OTHER users' rows, restore_attempt salted-rotated fingerprint, retention sweepers,
NIP-17 DM warning, in-flight-value blockers (no swaps/tips in the clean core),
hard per-day export rate cap, /admin/erasure/stuck observability. Routes are off
the public OpenAPI for now (stable contract; annotate later).
No trust-on-first-use: bootstrap mode is an explicit, MAC-protected latch
(server_config singleton), NOT inferred from "no admin keys". The latch MAC
(ADMIN_KEY_INTEGRITY_SECRET over setup_state|bootstrap_completed_at) means a
DB-write attacker who restores a pre-bootstrap backup or flips the state to
re-open TOFU produces a mismatch the boot path detects and fails closed on.
- migration 20260626000002: server_config (id=1 singleton; setup_state +
bootstrap_completed_at + integrity_mac).
- db::server_config: read_setup_state -> {Fresh,Uninitialized,Locked,Tampered}
(MAC-verified); has_any_users; init_server_config (adoption: a live upgrade
latches `locked` so it never exposes a setup window; a truly empty DB begins
uninitialized); bootstrap_admin (insert the FIRST active admin AND latch
locked + hash-chained audit, ONE tx; refuses if an active admin already
exists); reset_setup (shell recovery; recomputes MAC, preserves admin keys).
- main boot (admin enabled): adopt/await-bootstrap, and FAIL CLOSED on a tampered
latch.
- smirk-admin: `setup --pubkey` (the supported headless bootstrap) + `reset-setup
--i-understand`.
- 1 L1 test: Fresh -> locked(valid) -> wrong-secret Tampered -> raw-flip Tampered
-> reset Uninitialized -> bootstrap refused while an active admin exists.
Deferred (documented): the HTTP /setup/* wizard. Per the spec the CLI / `docker
exec` is the SUPPORTED bootstrap (pure-PaaS without shell is explicitly
unsupported), so the loopback TTY wizard is a later convenience, not load-bearing.
A separate, low-volume audit trail for admin/setup/erasure actions, hash-chained
so a DB-write attacker cannot silently rewrite history. Kept distinct from the
high-volume user audit_logs (which keeps its enum action) — the chain applies
only where the privacy-maximalist mandate requires it.
- migration 20260626000001: admin_audit_logs (free-text action, actor_kind,
actor_pubkey_prefix [prefix only, no social graph], target, details, real-peer
ip, prev_hash, row_hash; monotonic seq).
- db::admin_audit: append_admin_audit(&mut PgConnection) so the audit row commits
in the SAME transaction as the state change it records (fail-closed, not a
best-effort side effect); record_admin_audit (own tx) for standalone audits;
verify_admin_audit_chain (boot/doctor walk — detects edit/delete/reorder/broken
linkage); list_admin_audit (panel). row_hash = HMAC(integrity_secret,
prev_hash || canonical(row)); appends serialize on a tx advisory lock so
seq/prev_hash cannot race.
- AdminAuditLog/NewAdminAudit models; 1 L1 test (link + verify + tamper-detect +
restore).
The admin authorization data model. The server stores only public keys; an admin
is authorized by presence in admin_keys, and each row carries an HMAC
(ADMIN_KEY_INTEGRITY_SECRET, separate from DATABASE_URL) over
id|pubkey|scope|created_at|activated_at|revoked_at. Because the MAC covers the
revocation/activation state, a DB-write attacker who clears revoked_at (un-revoke)
or revives a key produces a mismatch the guard rejects — the MAC proves a pubkey
is *currently* authorized, not merely once.
- migration 20260625000002: admin_keys (partial unique index = one active row per
pubkey; soft revoke keeps history) + admin_sessions (separate table/secret from
user sessions; cascade-on-key-delete).
- core::crypto::admin_mac: compute/verify over a JSON-canonical field set,
timestamps as microsecond epochs; constant-time compare; KATs bind
revoked/activated state.
- db::admin_keys: create / get_active (re-verifies MAC, fail-closed + logs tamper)
/ list / count_live / activate / revoke. Every covered-field write recomputes
the MAC centrally; timestamps truncated to microseconds so the signed value
round-trips; revoke/activate are FOR UPDATE transactions.
- 5 L1 tests incl. the load-bearing raw-SQL un-revoke -> MAC mismatch -> rejected.
The shared server-nonce store every signed action consumes (admin login,
first-run setup, self-service erasure). DB-backed so consumption is atomic and
correct under concurrent requests and a future load-balanced fleet:
DELETE FROM challenges WHERE nonce=$1 AND purpose=$2 AND expires_at > NOW()
RETURNING subject
Winning the race returns one row; zero rows = replay/expired/wrong-purpose =>
reject (no TOCTOU). issue_challenge mints a 256-bit nonce bound to a purpose and
optional subject; consume_challenge is the atomic single-use step that pairs with
verify_signed_action (already built, §0.1). Periodic expiry sweep bounds the
table; the table holds no secret and is documented as backup-excluded
(ephemeral, links pubkey->time).
migration 20260625000001; ConsumedChallenge exported; 3 L1 tests (consume-once /
replay-rejected, wrong-purpose rejected, expired rejected).
Final wallet-handler slice. An async store-and-forward mailbox for interactive
Grin transfers between two registered Smirk users, over the grin_slatepacks db.
Reworked clean from the legacy relay — NOT a port.
Endpoints (under /api/v1/wallet/grin/relay, JWT-gated):
- POST create {recipient_user_id, slate_id, slatepack, amount_nanogrin}
- GET pending -> the caller's inbox
- POST get {slate_id} -> poll a relay's state (sender/recipient)
- POST respond {slate_id, response_slatepack} (recipient)
- POST finalize {slate_id, tx_hash} (sender; RECORDS the txid only)
- POST cancel {slate_id} (sender or recipient)
Non-custodial: stores only opaque (encrypted) slatepacks + lifecycle status;
never holds keys, finalizes, or broadcasts. The wallet broadcasts locally (via
/wallet/grin/broadcast) and reports the txid here — diverging from the legacy
server-side post_tx (see docs/private/OWN_BACKEND_CONVERGENCE.md).
Controls + hardening (incl. fixes from the adversarial review workflow):
- Per-action authz: caller must be the slate's sender/recipient; a non-party
gets 404 (no existence oracle).
- slate_id is UNIQUE (+ validated as a UUID) and mutations key on the
authorized row's PRIMARY KEY, not slate_id — so a duplicate slate_id can't
brick a relay (fetch_optional-on-2-rows 500) or let a slate_id-keyed UPDATE
touch another row. create maps the unique violation to 409.
- State transitions are ATOMIC: the status + expiry preconditions live in the
UPDATE WHERE clause (not a Rust read-check-write), so respond-twice and
cancel-races-finalize cannot occur; a non-qualifying transition -> 409.
- TTL enforced at the action boundary (respond/finalize require expires_at >
now) + a periodic expire_old_slatepacks sweep in main.rs (relay-enabled only).
- Recipient must be a registered user; slatepack sizes capped (64 KiB).
- Behind FEATURE_GRIN_RELAY (default on).
6 paths + schemas + grin_relay tag in openapi (drift-clean). 4 L1 integration
tests against live Postgres: full lifecycle, unregistered-recipient 400, auth
gate, non-party/non-recipient 404s.
models/db.rs: User keyed by pubkey_hash/nostr_pubkey/username (pubkey_hash and
seed_fingerprint peppered at rest); Wallet, Session (platform=extension|web|nostr),
UserKey, GrinSlatepack, AuditLog. Dropped all telegram/discord/social-identity
columns and every tip/social/swap struct (swaps land as their own feature unit).
migrations/20260622000001_initial_schema: consolidated clean schema; enum types;
UNIQUE on identity columns; FK ON DELETE (CASCADE/SET NULL) for self-service
erasure; indexes on every lookup column. PostgreSQL 13+.