Skip to content

Redemption reflow: reconcile-on-touch, mule-side Google ack, drop /add_pro_payment + refund_requested - #4

Open
jagerman wants to merge 17 commits into
session-foundation:devfrom
jagerman:redemption-reflow
Open

Redemption reflow: reconcile-on-touch, mule-side Google ack, drop /add_pro_payment + refund_requested#4
jagerman wants to merge 17 commits into
session-foundation:devfrom
jagerman:redemption-reflow

Conversation

@jagerman

Copy link
Copy Markdown
Member

Summary

Reworks how Session Pro payments are redeemed and trims the wire surface around it.
Previously the client POSTed /add_pro_payment to redeem a purchase, and that handler
made a synchronous, un-timed Google acknowledgement call inside the redeem DB
transaction
— a blocking off-box call on the request path. This branch:

  • Makes redemption implicit ("reconcile-on-authenticated-touch"): the store notifies
    the backend out-of-band, and any master-key-signed request binds the account's unbound
    payments before it answers. /add_pro_payment is gone.
  • Moves the Google purchase-acknowledgement off the request path into the mule, via a
    needs_ack sweep, so no request ever blocks on a Google round-trip.
  • Retires refund_requested end-to-end.
  • Reconciles the docker/QA payment-minting from feat: add docker for qa + payment minting #3 onto the new model.

Redemption: reconcile-on-touch

  • reconcile_pending_payments(master_pkey) claims every unredeemed Google/Apple payment
    whose store account-id matches the key (Google obfuscatedAccountId = the pubkey; Apple
    appAccountToken = uuid_from_master_pk), links them to the user, and refreshes
    entitlement. It runs at the top of generate_pro_proof, get_pro_status, and
    get_payment_details, so a payment the mule registered binds to its owner on the owner's
    next request — whichever endpoint that is.
  • Renewals bind by store identifier, not the account-id UUID. The mule resolves the
    owner from the subscription-continuity linkage (payment_token / original_tx_id → a prior
    securely-bound payment) and redeems by that, never touching the appAccountToken UUID — so
    a (vanishingly unlikely) 122-bit UUID collision can't make a renewal bind a stranger's
    payment. The UUID match stays confined to the client path, where the caller is the owner.
  • Rangeproof (dev-house grants) now go through grant_rangeproof, a direct grant keyed
    on the master pubkey; the never-implemented fake-voucher UUID indirection is dropped.
  • /add_pro_payment, redeem_payment, verify_and_add_pro_payment, and the dead
    client-submitted payment_id ingress path are removed. payment_id survives only as an
    opaque output field on get_payment_details.

Google ack → mule

  • New needs_ack column on google_play_payment_details (migration 001). A fresh,
    not-yet-acknowledged purchase sets it; the mule's pull loop sweeps and acks such purchases
    then clears the flag — so the ack survives a mule crash between recording the payment and
    completing it.
  • The Google Play API client gets a 15s socket timeout (nothing the mule does is critical; a
    failed call is simply retried before long).
  • The mule moved to providers/google_play/mule.py to make it explicitly Google-only. (If we need mules for other payment providers in the future, uwsgi is just fine managing multiple mules -- and one mule per provider means one blocked mule doesn't affect other providers).

Drop refund_requested

  • /set_payment_refund_requested and refund_requested_ts are removed from the wire
    (request + all responses). Clients that want to surface a pending refund carry the
    timestamp in their own account config.
  • payments.refund_requested_at / users.refund_requested_at dropped from the baseline and
    via migration 002 (DROP … IF EXISTS).
  • The unknown_payment error code is gone (its only source was /add_pro_payment).

Reconcile #3 (docker/QA minting)

#3 landed the docker/QA harness against the pre-reflow API. Rewired onto the new model:
minting.mint_payment redeems via a new redeem_minted_payment helper instead of the
removed redeem_payment; the CLI voucher command mints then
build_current_entitlement_proof; and the redeem=false path is reconcile-on-touch, not a
client /add_pro_payment call. No behavioural change to the docker setup itself.

Migrations

  • 001_google_needs_ack.sql — add needs_ack + partial index.
  • 002_drop_refund_requested.sql — drop the refund_requested columns (IF EXISTS; no-op on
    a fresh DB).

Both are idempotent and ledger-tracked.

Client / wire impact (heads-up for libsession + clients)

  • Removed endpoints: /add_pro_payment, /set_payment_refund_requested.
  • Removed response field: refund_requested_ts (everywhere it appeared).
  • Removed error code: unknown_payment — a not-yet-propagated purchase is now just a
    normal "not active yet" answer, retried.
  • New client flow: after a purchase, call generate_pro_proof (or get_pro_status) and
    retry until it reflects the entitlement; there is no submit step.
  • Authoritative layouts in docs/pro-wire-protocol.md.

jagerman and others added 17 commits July 28, 2026 13:46
These constants are the same names (but longer) as the endpoints
themselves and are used only once.  Having them as constants is neither
useful nor saving, and makes it harder to find at a quick search the
handler for an endpoint.
…moved

4d2218c inlined the FLASK_ROUTE_* constants out of server.py but left test.py still
referencing server.FLASK_ROUTE_*, so four tests failed with AttributeError. Inline the route
strings in test.py to match (black re-collapsed a few calls that no longer need wrapping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
googleapiclient's default httplib2 transport has no socket timeout, so a hung Google request
blocks indefinitely. In the mule that's the whole (single-threaded) Pub/Sub pull loop wedged --
and unlike the request workers, the mule has no harakiri leash. Build the androidpublisher
client over an AuthorizedHttp with httplib2.Http(timeout=15) so a hung fetch or acknowledge
fails fast and the mule retries; nothing the mule does is time-critical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An explicit, durable flag for "this Google purchase still owes an acknowledgement to Google."
The mule sets it TRUE when registering a fresh, not-yet-acknowledged purchase, and a sweep in
its pull loop acks such purchases and clears the flag. Keeping the obligation as a column
(rather than inferring it from notification bookkeeping) lets it survive a mule crash between
committing the payment and completing the ack. Existing rows default FALSE (already acked by the
old redeem-time path). Partial index for the sweep's `WHERE needs_ack` scan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Google Play purchase-acknowledge (the "confirm within 3 days or it's auto-refunded" step)
used to happen synchronously in the client redeem path. Move it into the mule, decoupled from
notification handling so an ack is never sent for a payment the DB hasn't durably recorded and is
reliably retried after a crash.

Notification handling only records the obligation: add_unredeemed_payment gains a needs_ack flag,
and the Google PURCHASED/RENEWED paths set it from Google's acknowledgement_state (TRUE only for a
fresh, not-yet-acknowledged purchase). A new sweep, _sweep_pending_acks, is the SOLE acker: the
pull loop runs it once per iteration, right before blocking on the next pull, so it uniformly
covers fresh purchases, failed-ack retries, and crash/startup leftovers with no special startup
path.

It acks each token with an outstanding obligation and clears the flag. Because Google 400s an
already-acknowledged purchase with no cleanly-identifiable error, on ANY ack failure it consults
the authoritative acknowledgement_state (one fetch) and clears the flag iff Google already
considers it acked (the acked-then-crashed case), else leaves it set to retry. Best-effort -- it
never raises, so it can't break the pull loop.

backend: google_payment_tokens_needing_ack + google_clear_needs_ack helpers.
test: test_google_ack_sweep covers the clean-ack, already-acked-via-fetch, and genuine-failure paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-only)

The maintenance mule's only job is the Google Pub/Sub subscriber, so put it inside the Google
provider package and name it accordingly. This makes the Google-specificity explicit and sets up a
per-provider pattern: another provider needing a background consumer gets its OWN mule (uWSGI
supports any number), so a stall in one provider's subscriber can't hold up another's.

- git mv mule.py -> providers/google_play/mule.py; its imports become relative (from . import api,
  notifications). grpcio stays deferred inside notifications.thread_entry_point, so importing the
  module/package doesn't pull gRPC's fork-hostile background threads into the uWSGI master.
- vassal ini: `mule = mule:run` -> `mule = providers.google_play.mule:run`.
- Fix a stale comment (main.py and the ini) that claimed the periodic DB prune runs in the mule; it
  runs on worker 1 via an @Timer (a mule can't register uWSGI signals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ter key

The first piece of the redeem/proof reflow. reconcile_pending_payments(tx, master_pkey, redeemed_at)
redeems every unredeemed, unrevoked Google/Apple payment bound to the master key -- Google's
obfuscatedAccountId is the pubkey verbatim, Apple's appAccountToken is uuid_from_master_pk(pubkey),
so holding the key IS the claim (no client-supplied token needed) -- links them to the user, and
refreshes entitlement. Claim-all (a key can accumulate several unredeemed payments across renewals),
and a no-match touches nothing: crucially it creates NO user row for a key with no payments, so a
signed-but-payment-less status probe can't spawn users.

This is the reconcile step every master-key-authenticated endpoint will run up front (generate_pro_proof,
get_pro_status, get_payment_details), replacing the client-driven /add_pro_payment redeem. Additive for
now -- nothing calls it yet; wiring + retiring the old path follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire reconcile_pending_payments into the proof path: a client's proof request now first claims any
payment the mule has registered for its master key but that isn't yet redeemed, then builds the proof
from the resulting entitlement. So the post-purchase flow collapses to "client calls generate_pro_proof"
— no separate /add_pro_payment redeem — and if nothing has landed yet the existing "no Pro" answer
stands and the client retries. A no-op when there's nothing new, so already-redeemed keys are unaffected.

Additive: /add_pro_payment still exists (now redundant for Google/Apple); retiring it + the CLI
Rangeproof grant + the old redeem_payment/unknown_payment path follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The auto-redeem inside add_unredeemed_payment (bind a renewal to the master key found from the prior
payment) went through redeem_payment with a rebuilt payment_tx. A renewal carries the same store
account-id as the original (obfuscatedAccountId / appAccountToken are stable across a subscription),
so reconcile_pending_payments binds it by that account-id just like any other pending payment -- and
claim-all harmlessly sweeps any other stragglers for the key too. Drops redeem_payment to a single
remaining caller (the retiring add_pro_payment), and unifies every redeem path on reconcile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… voucher

The Rangeproof CLI generated a throwaway uuid "order id", created an unredeemed payment, then
redeemed it through add_pro_payment -- pretending a voucher existed. There is no voucher system, so
replace that two-step with grant_rangeproof(master_pkey, plan, expires_at, ...): it creates a payment
already redeemed and linked to the key and returns the proof. order_id stays as an internal unique row
id (a real client-claimable voucher is future work).

cmd_voucher now just calls grant_rangeproof (and loads the signing key once, up front). This removes
the CLI's dependency on add_pro_payment, leaving redeem_payment/add_pro_payment with only the retiring
client route as callers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The renewal auto-redeem resolves the owner's FULL master key directly from the store's
subscription-continuity linkage (Google payment_token / Apple original_tx_id -> a prior, securely-bound
payment). Having the full key, there's no reason to route the actual bind back through the
master-key-derived account-id (reconcile) -- especially the Apple appAccountToken, which is only the
first 16 bytes of the key. Doing so needlessly made binding a renewal depend on it carrying the
account-id, and worse, let a mule-side renewal claim-by-UUID: on a (vanishingly unlikely) 122-bit
appAccountToken collision that could bind a stranger's payment.

Replace the reconcile call in the auto-redeem with _redeem_payment_for_user, which redeems THIS payment
by its own store identifier and links it to the resolved user -- never touching the account-id. The
account-id match now stays confined to the client request path (generate_pro_proof / status / details),
where the caller is the owner and "the new payer is the next to claim their own UUID" holds. Renewals
whose original isn't found (never redeemed) still fall back to that client path, same as new payments.

test: a payment binds to a user by its identifier even when its stored account-id is a different key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the redeem/proof reflow. The client no longer submits a payment to redeem it; every
master-key-authenticated endpoint reconciles pending payments up front, so a payment the mule
registered binds to its owner on the owner's next request, whichever endpoint that is.

server: delete the /add_pro_payment route + handler; call reconcile_pending_payments at the top of
get_pro_status and get_payment_details (generate_pro_proof already did). A status/details check right
after purchase now reflects the just-arrived payment.

backend: delete verify_and_add_pro_payment, add_pro_payment (with its synchronous Google fetch+ack
block), redeem_payment (the token/tx-id matching + the whole unknown_payment / idempotent-replay
machinery), and the now-dead RedeemPayment/RedeemPaymentStatus/make_add_pro_payment_message/
_add_pro_payment_user_tx_log_label_safe. reconcile_pending_payments (bind by account-id) + the
renewal direct-bind + grant_rangeproof are the only redeem paths now.

test: verify_and_add_pro_payment callers now redeem via a _redeem_and_prove helper (reconcile +
generate_pro_proof); the binding-rejection test asserts account-id reconcile (attacker claims 0, owner
claims 1); the notification/apple flows redeem via reconcile and assert the binding; the end-to-end
flow test drops the explicit redeem (status auto-redeems) and proves via generate_pro_proof. Net
-659 lines. (The set_payment_refund_requested nuke is still deferred, as agreed.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re spec

Track the redemption reflow (18fa6b9) and the refund-requested removal in the
authoritative wire spec, which should have moved with the code:

- §3: remove the add_pro_payment (ProAddPayment___) and
  set_payment_refund_requested (ProSetRefundReq_) signed requests; document
  that redemption is implicit (the store notifies the backend out-of-band and
  any master-signed request binds the account's unbound payments), and split
  the read endpoints into their own §3.2/§3.3.
- payment_id: no longer a signed input; it survives only as an opaque output
  field on get_payment_details items. Drop the §3.5 client-construction grammar
  and the provider_code/payment_id signed-message examples in §1.1 (the cursor
  `before` is now the sole variable-length string field).
- Remove every refund_requested_ts reference (§1 status note, the signed-
  timestamp list, get_pro_status and payment-item result shapes) and the now-
  unreachable unknown_payment error_code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "a refund has been requested (but not yet granted)" timestamp is removed
across the whole stack; clients carry it in their own account config instead.
This tracks the wire-spec removal in 8aedfd1.

- server.py: delete the /set_payment_refund_requested route+handler and drop
  refund_requested_ts from get_pro_status and the payment-item shape.
- backend.py: remove make_set_payment_refund_requested_message,
  set_refund_requested, the SET_PAYMENT_REFUND_REQUESTED_DOMAIN, the
  refund_requested_at columns from PAYMENTS/USERS reads, the struct fields
  (LookupUserExpiry.{,best_}refund_requested*, Payment/UserRow.refund_requested_at),
  the _lookup_user_expiry aggregation, the two users UPDATEs, the three payment
  INSERT defaults, and the refunds_initiated report column.
- providers/app_store.py: drop the REFUND_DECLINED branch whose only action was
  clearing the flag; REFUND_DECLINED is already the catch-all no-op below it.
- schema: drop refund_requested_at from the baseline (000) and add 002 to drop
  it (IF EXISTS) from databases that predate the edit.
- test.py: remove the /set_payment_refund_requested section of the flow test.

Also drops two symbols orphaned by the earlier /add_pro_payment retirement and
now absent from the spec: the ADD_PRO_PAYMENT_DOMAIN prefix and the
unknown_payment error slug (it had no remaining raise site).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etirement

With /add_pro_payment and /set_payment_refund_requested gone, nothing ingests a
client-supplied payment_id anymore: payment_id is now purely an output field on
get_payment_details items (wire spec §5.2). Drop the ingress machinery that no
longer has a caller and whose comments still described a client-signed input:

- backend.py: remove UserPaymentTransaction.payment_id, payment_id_from_user_tx,
  and apply_payment_id_to_tx. encode_payment_id / GOOGLE_PAYMENT_ID_DELIMITER
  stay — payment_id_from_payment_row still uses them for the egress encoding.
- Repoint the payment_id doc cross-references from the removed §3.5 to §5.2
  (and the ts-nonce note from the renumbered §3.4 to §1.1), matching 8aedfd1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion reflow

PR session-foundation#3's minting path was written against the pre-reflow backend API — it called
backend.redeem_payment and built a UserPaymentTransaction with a payment_id
field, both of which the reflow removed, and its redeem=False story pointed at
the deleted /add_pro_payment route. Rewire it onto the reconcile-on-touch model:

- backend.py: add redeem_minted_payment(tx, master_pkey, payment_tx, redeemed_at)
  — redeems exactly one just-minted payment by its own store identifier, creating
  the user + generation, for all three providers (incl. Rangeproof, which has no
  account-id for reconcile_pending_payments to match). It's the minting-path
  sibling of reconcile_pending_payments (client-facing, account-id) and
  _redeem_payment_for_user (renewal, existing user only).
- minting.py: mint_payment's redeem step now calls redeem_minted_payment instead
  of the deleted redeem_payment; drop the removed UserPaymentTransaction.payment_id
  kwarg. redeem=False for Google/Apple is now reconcile-on-touch (the client's next
  authenticated request binds it), not a client /add_pro_payment call.
- cli.py voucher: resolved the rebase conflict (PR session-foundation#3's minting-based command vs
  the reflow's grant_rangeproof rewrite) to mint_payment(redeem=True) +
  build_current_entitlement_proof; refresh the now-inaccurate provider_dry_run
  comments (the reflow moved Google's ack to the mule, so a redeem never reaches a
  store).
- dev_routes.py / readme.md: correct the redeem=False description to
  reconcile-on-touch.

Full suite green (29), mypy + black clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_google_ack_sweep's flag() helper did db.query_one(...)[0], indexing an
Optional row — mypy (*.py) flags "Value of type Any | None is not indexable".
Pre-existing (not from this rebase); use db.query_scalar, the helper added for
exactly this guaranteed-single-row case. `mypy *.py` now clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant