feat(tips): targeted tips, off until an operator asks for one

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.
This commit is contained in:
2026-09-17 19:34:01 -04:00
parent 7592034546
commit c653dec341
6 changed files with 286 additions and 49 deletions
@@ -0,0 +1,37 @@
-- Targeted social tips: a tip addressed to a Smirk user rather than to whoever
-- holds a share URL.
--
-- The v3 port shipped public-only and the table says so: there is no recipient
-- anywhere, so a targeted tip had no one to be addressed to and the create path
-- rejected it outright. This adds the missing column and the two constraints
-- that keep each kind of tip well formed.
--
-- Additive and reversible in practice: every existing row is public, so the new
-- column is NULL for all of them and the new CHECK is satisfied by is_public.
-- Nothing is dropped, rewritten or backfilled.
ALTER TABLE social_tips
ADD COLUMN IF NOT EXISTS recipient_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
-- A targeted tip must name its recipient. The mirror of public_tip_has_hash:
-- between them, every row carries whichever claim path it actually uses, and a
-- row that carries neither cannot be inserted.
ALTER TABLE social_tips
DROP CONSTRAINT IF EXISTS targeted_tip_has_recipient;
ALTER TABLE social_tips
ADD CONSTRAINT targeted_tip_has_recipient
CHECK (is_public = TRUE OR recipient_user_id IS NOT NULL);
-- A targeted tip's ciphertext is sealed to the recipient's key and there is no
-- URL fragment, so encrypted_key is the only way to claim it. Without this a
-- draft could be created that is permanently unclaimable while looking healthy.
ALTER TABLE social_tips
DROP CONSTRAINT IF EXISTS targeted_tip_has_encrypted_key;
ALTER TABLE social_tips
ADD CONSTRAINT targeted_tip_has_encrypted_key
CHECK (is_public = TRUE OR encrypted_key IS NOT NULL);
-- The inbox query: a recipient's tips, newest first.
CREATE INDEX IF NOT EXISTS idx_social_tips_recipient
ON social_tips (recipient_user_id, created_at DESC)
WHERE recipient_user_id IS NOT NULL;