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
+9 -2
View File
@@ -756,7 +756,8 @@
"tags": [
"tips"
],
"summary": "Tips CLAIMABLE by the caller. As with `received`, a public-only instance has\nno targeted-claimable inbox, so this is always empty (served 200, not 404).",
"summary": "Tips CLAIMABLE by the caller right now.",
"description": "A strict subset of `received`, filtered by exactly the conditions the claim\ncall itself enforces, so the inbox never offers a Claim the backend will then\nrefuse.",
"operationId": "get_claimable_social_tips",
"responses": {
"200": {
@@ -788,7 +789,8 @@
"tags": [
"tips"
],
"summary": "Tips RECEIVED by the caller. Public-only instances have no targeted-recipient\ninbox (public tips are claimed via share URL, never delivered to a user), so\nthis is always empty — served (200) rather than 404 so the client's inbox\npoll doesn't error on a targeted-only endpoint this instance doesn't serve.",
"summary": "Tips RECEIVED by the caller, newest first.",
"description": "Public tips never appear here: they are claimed by holding a share URL and\nhave no addressee to deliver to. An instance with targeted tips off has\nnothing to list and answers 200 with an empty list, so the client's inbox\npoll does not error on a backend that simply does not offer the feature.",
"operationId": "get_received_social_tips",
"responses": {
"200": {
@@ -3324,6 +3326,7 @@
"premium_relay",
"feed",
"tips",
"targeted_tips",
"xmr_subaddr_provisioning",
"utxo_multi_address"
],
@@ -3360,6 +3363,10 @@
"type": "boolean",
"description": "Fiat price feed."
},
"targeted_tips": {
"type": "boolean",
"description": "Tips addressed to a Smirk handle rather than to a share URL. A client\nmust not offer the targeted option unless this is true, because the\ncreate call refuses it and the send would fail after the user picked a\nrecipient and an amount."
},
"tips": {
"type": "boolean",
"description": "Tipping (parked)."