Skip to content

1.9.79: PayKickstart's product IPN, verified with each campaign's key - #268

Open
tracking202 wants to merge 12 commits into
1.9.79from
1.9.79-paykickstart-ipn
Open

tracking202 wants to merge 12 commits into
1.9.79from
1.9.79-paykickstart-ipn

Conversation

@tracking202

@tracking202 tracking202 commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Backlog: 1YEB ("PayKickstart IPN URL points to a file that does not exist")

This changes what was decided: read this first

The decision was "Build a PayKickstart IPN handler with signature check", and the panel it fixes promised PayKickstart's Affiliate IPN. That cannot be built as the panel describes. PayKickstart's Affiliate IPN is unsigned JSON (amount, buyer names, PayKickstart's own tracking_id, the affiliate's commission). It has no signature, no event type and no transaction id. PayKickstart allows no custom variables in it, and the affiliate's ?tid= value shows only in the affiliate's own PayKickstart account. So nothing in it names a Prosper202 click, and nothing in it can be verified.

What this PR builds instead is the vendor (product) IPN. That is the reading of the decision that has a signature to check:

  • For a vendor selling through PayKickstart, the steps are: add subid=[[subid]] to the PayKickstart checkout link that is the campaign's offer URL, paste tracking202/static/paykickstart.php into each product's IPN Integration tab, and store each PayKickstart campaign's IPN secret key in Prosper202. Each campaign has its own key. PayKickstart sends the checkout link's query variables back as custom_<name>, so custom_subid is the click.
  • For an affiliate, the panel now says plainly that the Affiliate IPN cannot update conversions. If the URL is still set as an Affiliate IPN, it answers 415 and says why.

Until now the URL was a 404 for everyone: the file never existed. jvzoo.php, zpn.php and slack.php are the same kind of dead URL. They are separate 1.9.79 items, and they are listed as known dead in the new check.

What changed

  • tracking202/static/paykickstart.php (POST only, bootstrapped like cb202.php) runs Prosper202\PaymentNotification\Receiver with the PayKickstart adapter.

    • Verification. The request's hash is checked against every account's stored keys with PayKickstart's published HMAC-SHA1 algorithm (hash_equals), before any field is read. The account is the one whose key verifies, never one the request names (CLAUDE.md [Maintenance] Remove deprecated PHP features #16).
    • The click. It must belong to that account. Another account's click answers the same 404 as a click that does not exist ([Update] Fix CPC warnings and input length #27).
    • sales records a conversion (pixel type 2, new ledger source paykickstart) under transaction_id, at amount. The amount must be an exact decimal that fits decimal(11,5). Rebills are recorded too, each under its own transaction id.
    • refund reverses the sale its transaction_id names, through the writer's reversal path.
    • Recorded nothing (200): mode=test, subscription-payment (sales already fires for every payment), every other event, and a sale whose checkout carried no subid (no_click).
    • Dated when it happened. A sale or refund is dated by its transaction_time (decision 6).
    • Retries. A retry of a transaction already on file is 200 duplicate only when it states the same amount. Stating another amount is 409, never answered as the first ([Fix] Return 404 status #15).
    • Refusals answer statuses PayKickstart's log shows, with the reason: 400 (a field named), 403, 404, 409, 415, 500, and 503 while the database's upgrade has not run (as 1.9.79: the review findings of the re-click restore and the ledger lock #258 does for the API). Each outcome is one error_log line, never with a key or the buyer's details.
  • The shared part is 202-config/PaymentNotification/, shaped by the coordinator's guidance:

    • ProviderAdapter: stored secrets, verify, parse.
    • Notification: kind sale|rebill|refund|chargeback|test|ignored, the provider's event, transaction id, the original transaction id, amount, currency, click id, occurred-at.
    • Processor: records or reverses through p202RecordLegacyConversion().
    • Response: the one mapping from outcome to status and body.
    • Receiver: method, verify, parse, process, log.

    PayKickstart is PaymentNotification\PayKickstart\{PayKickstartAdapter, PayKickstartSignature, PayKickstartKeys}. There is no request log, queue, secrets store or UI framework.

  • Keys:

    • Storage. 202_users_pref.paykickstart_ipn_secret_keys (text, one key per line), in the table definition and the 1.9.78 → 1.9.79 rung. usersPref() is added to the rung's single _upgrade_measurement_tables() call, because UpgradeLadderTest refuses a second call.
    • Validation. PayKickstartKeys: at most 20 keys, each 8 to 128 characters of visible ASCII other than the comma. "" clears them. A bad key is refused by its place in the list, never by its text.
    • The page. Account › API integrations adds and removes keys one at a time. It shows only the last 4 characters of each key. A remove names the key by its place and by an HMAC-SHA256 of the whole key under a secret the session holds server-side (8532b44, Codex's P2), so a list changed in another tab removes nothing, including a different key with the same ending. Before 8532b44 it carried the ending, which that other key matched.
    • The API. PUT /users/{id}/preferences paykickstart_ipn_secret_keys replaces the whole list. The Go CLI's user prefs update --paykickstart-ipn-secret-keys sends it.
    • Never served. Since the merge of 1.9.79: GET /users/{id}/preferences serves no integration secret #270, GET /users/{id}/preferences answers the column as {"stored": true}, as it does every integration secret (ServedPreferences::SECRET). A GET body sent back keeps the keys. The count this PR first served in its place (paykickstart_ipn_keys_stored) is gone: one shape for every secret, and no second key for one column that PUT would have to accept back. GET /system/integrations still has keys_stored (how many) and a real secret_stored. Staging a write that carries the keys is refused (pinned in StagedChangesControllerTest). There is also a Slack notice when the keys change.
  • The panel gives the vendor steps, including setting the campaign's "When a click converts more than once" to "Add them up" for subscriptions. In the live pass, "Keep the latest" made each rebill replace the sale's value. The panel also has the affiliate note.

  • Docs and changelogs: docs/openapi.yaml (integrations, preferences, the ledger source enum), docs/cli.md, docs/cli-agent.md, documentation/api/{09,10,14,15}, documentation/cli/{10,12}, a PayKickstart section with an outcome table in documentation/tutorials-and-guides/09-3rd-party-integrations.md, changelogs.txt and changelogs.json.

The machine check for the defect

tests/Release/HandedOutUrlsNameServedFilesTest checks that every string literal in a served PHP file naming a script under tracking202/ or a 202-* directory names a file that exists, and that every integration AdministrationController::INTEGRATIONS lists does too. KNOWN_DEAD (jvzoo.php, zpn.php, slack.php) only shrinks: a listed file that appears, or that nothing names any more, fails the test. Planted and caught: a dead script in INTEGRATIONS, a dead script on the page, and a dead entry removed from the list.

Other structural tests taught about the new code:

  • PostReadersCheckTokenTest: the endpoint reads $_POST and is authenticated by HMAC.
  • StoredVisitorIpSourceTest: the Processor's ip is the provider's server, as in cb202.
  • WebEventPathsTest: it could not see a recorder called through a class, so it now counts an endpoint that builds the Receiver as recording, and the endpoint is listed as exempt. A planted removal of the exemption fails.

Decisions a reviewer should check

  1. Verification is against every account's keys, not "look up the click's owner, then try its keys". The brief proposed the second; the coordinator's interface fits the first. Reasons:
    • An IPN with no custom_subid (a test ping, an untracked sale) can still be verified and acknowledged.
    • The click is looked up only after a key verified, so the endpoint is no oracle for click ids to anyone without a key.
    • To a key holder, another account's click answers exactly like a click that does not exist.
    • When two accounts store the same campaign key (one product, two Prosper202 users), the click decides between them.
  2. PayKickstart's signature joins values with "|" and leaves the field names out, so it is not injective (CLAUDE.md [Config] Update PHP version checks #17). Whoever holds one signed IPN can move its values between field names, and can add fields whose value is empty or "0", without changing the hash. No receiver can close that. The adapter reads every field it uses strictly, and the docblock, the panel's docs and this PR recommend an https IPN URL.
  3. Lists (licenses with several licences) are left out of the signed string. This was executed, not inferred: PayKickstart's PHP validator, run verbatim on PHP 7.4 (unpacked from the ondrej PPA), drops an array value and gives the same hash as the payload without it. On PHP 8 the validator throws TypeError.
  4. Non-ASCII text. The conversion to ISO-8859-1 is written out rather than left to iconv(), whose //IGNORE varies by C library. It equals glibc iconv on every valid string tried (3,000 random strings in the test, 20,000 in the scratch run).
    • PayKickstart's JS sample signs UTF-8 bytes, and its (truncated) Python sample drops non-ASCII characters. If PayKickstart's real signer followed either one, an IPN from a buyer named "José" would be a 403.
    • The PHP sample, the one with the explicit conversion, is the reading taken. Nothing PayKickstart signed has been seen.
  5. Refund amount. PayKickstart does not say which amount a refund IPN carries. A positive amount reverses that much; none (or 0) reverses the whole sale; a minus sign is dropped.
    • The refund carries no id of its own, so it is keyed by its body (b000000). Only the same notification sent again finds a reversal already on file, standing or deleted, and it is 200 duplicate.
    • Any other refund of a sale with a standing reversal is compared with that reversal. It is 200 duplicate when it takes back what that reversal took back; a refund with no amount (or 0) counts as the sale's whole amount. Otherwise it is a 409 naming both amounts, so a full refund after a partial one says what was reversed and how much is still credited (Codex's P1, cf1bd2a). A sale is reversed once: nothing in PayKickstart's IPN documentation, as read for this PR, describes cumulative refunds.
    • Once the standing reversal is deleted (the refund was undone), a later refund reverses the sale again. Before b000000 every refund without an id shared the key rev:<sale>:1, which the deleted row keeps, so the next refund was answered 200 duplicate and reversed nothing (measured on the JVZoo branch).
    • The writer still decides under the click's lock whether a reversal is written (Fix undefined array key warnings and TypeError in aff_campaigns.php #36). The Processor's reads afterwards only choose between duplicate and 409. A refund with an id of its own (none from PayKickstart) is still told apart by that id.
  6. A conversion is dated by the time the IPN states (be2dd5c, Codex's second P1). PayKickstart retries a failed delivery up to 13 times, and the row used to be dated time().
    • Signed. transaction_time is covered by the signature, like every non-empty field. Decision 2 still applies: a holder of a signed IPN can move values between fields, but cannot set a time of their own.

    • Unreadable. A time that is not a Unix time is a 400 naming it (subid missing in pixel url #4), where it used to read as no time. With none stated, the row is dated when it arrives.

    • Bounds. The time is held between the earliest it can be and its arrival, and the answer and the log line say so:

      • for a sale, the earliest is the click it credits (its latest visit, the one the ledger credits);
      • for a refund, it is the sale it takes back, so the sale keeps its own day and the refund lands on its own;
      • a time ahead of this server's clock is dated at arrival.

      These are held rather than refused: refusing would lose the payment over a clock, and PayKickstart would resend the same time.

    • Repeats. A repeat is not compared by its time, only by its amount. The stored time may be a bound rather than the time sent, so the comparison would refuse a genuine retry.

    • Reports. p202 report counts a conversion on its click's day and is unchanged by this. What moves is everything by conversion time: the conversion list and its time filter, attribution, and the click-to-conversion time.

  7. A deactivated or deleted account's stored keys still verify, as every other postback endpoint records for any campaign owner. An IPN is PayKickstart reporting a sale, not the user acting.
  8. No traffic-source postback is fired for a PayKickstart conversion, matching cb202 (p202RecordLegacyConversion() has no notify_traffic_source).
  9. Processor refuses a notification that states a currency other than the account's (422). No adapter sends one yet; this is the default for the next adapter, tested by building a Notification directly.
  10. A sale sent again after its refund is a 409, naming the reversal to delete if the refund was undone. This was ported from the JVZoo branch, where JVZoo documents an undone refund as RFND and then SALE again.
    • It is the answer for PayKickstart too. PayKickstart's IPN documentation, as read for this PR, describes no undone refund, so a sales IPN for a reversed sale is either a late retry of a sale already answered, or a sale PayKickstart says counts again.
    • A 409 leaves one error in PayKickstart's IPN log and changes nothing. A 200 would hide the second case.
    • Once the reversal is deleted, the sale counts and the same IPN is 200 duplicate.
  11. The 403 names the provider's signature field through ProviderAdapter::signatureField() (hash for PayKickstart).
  12. What the JVZoo and ClickBank adapters need when they merge this:
    • signatureField();
    • the payment's own time in Notification::$occurredAt, refusing one they cannot read (400 naming the field) rather than leaving it null. The Processor does the rest.

Verification

Scope report of .claude/skills/p202-verify/scripts/verify.sh --since origin/1.9.79 --changed, on 8532b44, after the merge of 1.9.79 at 915a367 (#270, #280, #281, #282) into this branch (PHP 8.3.6, MariaDB 10.11, full vendor/; the unit tier ran 6164 tests), verbatim:

Scope report
------------
  go         PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  golangci   PASS  ran under CI's go1.25.14 via GOTOOLCHAIN (local go is 1.24)
  openapi    PASS
  patterns   PASS
  phpcs      PASS
  phpstan    PASS
  schema     PASS
  syntax     PASS
  unit       PASS

Not covered by this script:
  live      stand up an instance, drive the path a user would take
  agent     add and run a case under tests/fixtures/agent-eval/cases/
  ci        a workflow still in_progress has not passed; read the run

Run by hand, beyond the ladder:

  • Integration suites, each alone on a database created for it, as CI's integration job runs them:
    • tests/PaymentNotification/PayKickstartIpnIntegrationTest (20 at 41bfe59, 13 at first): sale, replay, a replay stating another amount, rebill, subscription-payment, full and partial refund, refund replay, a second refund (409), a refund with nothing to reverse (404), a bad hash, a tampered amount, a missing hash, GET, another account's click, a click that does not exist, a key shared by two accounts, test mode, an ignored event, no subid, a malformed amount, an unreadable stored key, a currency the account does not count in, and the 503 while the upgrade is pending; since cf1bd2a and b000000, a full refund after a partial one, a repeat however its amount is spelled, a sale stated again after its refund, a refund after its reversal was deleted, a refund with an id of its own, a sale and a refund days late (dated when they happened, a refund before its sale dated at the sale, a changed time a 403), and a time before the click or ahead of the clock (held, and said).
    • tests/User/PreferencesWriteIntegrationTest (6), tests/Api/V3/AccountAdministrationIntegrationTest (13), and 1.9.79: GET /users/{id}/preferences serves no integration secret #270's tests/User/PreferenceSecretsAreNotServedIntegrationTest (5), whose round trip now stores the PayKickstart keys with the other secrets.
    • Every integration suite, through CI's own tests/run-integration-suites.sh on the merged tree: 142 suites, 1340 tests passed, and 2 suites failed. Each of the 2 was then re-run alone on a fresh database:
      • AttributionWorkerIntegrationTest passed 3 runs of 3. In the full run, its dead-connection lock test (the dead session took its lock with it) had failed while other sessions were using the same server.
      • MysqlLookupRepositoriesIntegrationTest fails one test, testTheFactoryCachesThroughTheBootstrapHelpers, with Class "Memcached" not found: this sandbox has no memcached extension, and CI's job installs it.
  • Signature vectors were computed independently by PayKickstart's PHP validator run verbatim (PHP 8.3 and PHP 7.4 agree) and by a separate Python reading of its steps. The payloads are the docs' example, the same fields reversed, "0" and empty values, non-ASCII text, whitespace, a "|" in a value, a refund under another key, a list value and a single licence.
  • Planted defects, each caught by a named test failing:
    • dropping the owner check, the duplicate-amount compare, the reversal-amount compare, the key check, test mode, or the currency refusal;
    • signature changes: "0" kept, no trim, no Latin-1, no sort, a list cast to text;
    • adapter changes: a negative sale accepted, a malformed subid read as no click, is_rebill read loosely;
    • keys changes: the 20-key limit dropped, keys served by withoutKeys() (since removed for 1.9.79: GET /users/{id}/preferences serves no integration secret #270's shape), GET preferences serving the keys, integrations counting no keys;
    • the three URL plants, the missing exemption, and the Go flag missing (TestPrefFlagsMatchThePreferenceRules).
    • since b000000, in the Processor:
      • no live-reversal check on a sale;
      • an id-less refund keyed 1 again;
      • no comparison with the standing reversal;
      • a refund compared only when it states an amount;
      • an own-id refund compared with the standing reversal.
    • since be2dd5c, the dating:
      • the pushed Processor;
      • the recorder ignoring conv_time;
      • a time ahead of the clock taken as sent;
      • a time before the click taken as sent;
      • a refund held to the click instead of its sale (first missed, then caught by a test added for it);
      • an unreadable time read as none (5 adapter cases).
    • since 8532b44, the key's Remove: the pushed removed() (which compared the ending), and a token computed from the ending alone.
    • The stacked branch. 1.9.79: PayKickstart's product IPN, verified with each campaign's key #268 at b000000 merged into 1.9.79-jvzoo-ipn with no conflicts (git merge-tree). On that merge, with signatureField() added to the JVZoo adapter, JVZoo's four suites passed (14 IPN integration tests, 54 unit tests), and so did PayKickstart's IPN suite. At aab3909, which includes 1.9.79: the findings of the 1.9.78 release's adversarial review #262, the only conflict is changelogs.json, between 1.9.79: the findings of the 1.9.78 release's adversarial review #262's lines and JVZoo's line. Not rechecked at 41bfe59.
  • tests/live/upgrade-equals-install.sh (1.9.55 on PHP 7.4 → this branch, against a fresh install): 25 passed, 0 failed. 162 tables are identical, and the upgrade logged nothing.
  • Upgrade from the latest tag: v1.9.78 installed with its own installer, then upgraded through upgrade.php with this branch. 202_version reads 1.9.79, paykickstart_ipn_secret_keys text NULL was added, and no upgrade log line appeared.
  • Live pass on an instance installed with install-instance.sh and seeded with seed.sh:
    • Setup. Campaign B's offer URL was set to https://app.paykickstart.com/checkout/123?subid=[[subid]] (p202 campaign update). Two keys were stored with p202 user prefs update 1 --paykickstart-ipn-secret-keys …. user prefs get (before 1.9.79: GET /users/{id}/preferences serves no integration secret #270, a count) and system integrations showed the count and never the keys.

    • A click. Tracker B redirected to …/checkout/123?subid=7.

    • Again on the final tree. The IPN list below was run again on a fresh click, subid=9, after the merge of 1.9.79: the review findings of the re-click restore and the ledger lock #258 and the 503 commit, and gave the same answers.

    • IPNs. Signed with the published algorithm (a script independent of this code) and POSTed with curl. Results: sale 200 recorded, replay 200 duplicate, another amount 409, rebill 200, subscription-payment 200 ignored, test mode 200, full refund 200 reversed, its replay 200, a second refund 409, a partial refund 200, a refund of a sale not on file 404, a stranger's key 403, a tampered amount 403, no subid 200 no_click, custom_subid=[[subid]] 400, 1,000.00 400, another account's key on this click 404, the Affiliate IPN's JSON 415, GET 405.

    • Read back. p202 conversion list --source paykickstart and p202 click conversions 7 and 8 read the rows back. In accumulate mode, 47 + 9 − 9 = 47 was the click's value, and the campaign breakdown showed income 47. The server log held one line per IPN and no key, email or buyer name.

    • An undone refund, end to end (port 8230, b000000), each IPN signed and posted to paykickstart.php:

      • sale: 200 recorded; refund: 200 reversed (conversion 2);
      • the sale again: 409 naming conversion 2;
      • p202 conversion delete 2 --force, then the sale again: 200 duplicate;
      • a later refund: 200 reversed (conversion 3);
      • the first refund sent again: 200 duplicate, naming deleted conversion 2;
      • the later refund sent again: 200 duplicate;
      • the sale again: 409 naming conversion 3;
      • an IPN signed with no stored key: 403 naming hash.

      p202 click conversions read the rows back as +47, −47 (deleted), −47, and the click's value as 0.

    • A late IPN, end to end (port 8230, 41bfe59), on an instance set up without the licence stub (below) and driven by curl:

      • setup through the API: a campaign at https://app.paykickstart.com/checkout/123?subid=[[subid]], a tracker, and the key;
      • a click through dl.php, then backdated five days in the click tables (a fixture edit);
      • GET /users/1/preferences answered the keys as {"stored":true}, and /system/integrations keys_stored 1.

      The IPNs, each signed and posted to paykickstart.php:

      • a sale stated 3 days ago: 200 recorded, dated that day;
      • its refund stated 2 days ago: 200 reversed, dated that day;
      • a sale stated before the click: dated at the click, saying so;
      • a sale an hour ahead of the clock: dated at arrival, saying so;
      • transaction_time=2026-10-25: 400 naming it.

      The read-backs:

      • GET /conversions?click_id= (behind p202 conversion list) showed the four rows on 10-25, 10-26, 10-23 and 10-28;
      • GET /conversions for the sale's UTC day listed the sale alone;
      • GET /reports/timeseries?interval=day (behind p202 report timeseries) counted the conversion on the click's day, 10-23, as before.
    • The page. A second account signed in and, through the page's own forms (token included), added a key, was refused a 5-character key ("The key is 5 characters long; …") and a duplicate, had a stale remove refused, and removed a key. The key's text never appeared on the page.

  • Agent eval. New case admin-005-a-paykickstart-key-is-stored-never-repeated: store a campaign's key and give the URL to paste.
    • Run (before 1.9.79: GET /users/{id}/preferences serves no integration secret #270). With the reference agent against the live instance, all five mechanical expectations passed: user prefs update was run, keys_stored is 1, the preferences read leaves out the key, and the reply has the URL and subid=[[subid]] but not the key. The rubric was graded by hand from the judge's input, because no judge runs here.
    • Planted. An agent that stores nothing and repeats the key failed four of the five: the command, both checks, and the reply's omission of the key. The fifth passed because its reply did carry the URL.
    • Search. p202 search puts system integrations first for this ask and user prefs update second: the ask holds two tasks. It is listed in searchKnownMisses rather than taught by a phrase taken from the ask.
  • Browser pass (node tests/browser/run.js --spec account-pages, new scenario added): the PayKickstart scenario passed all 8 checks, and every Account page passed its baseline at 1280px and 390px in light and dark. One existing scenario failed, "a DNI network is removed only when confirmed", because the pass signed in as a second admin. The page lists user 1's DNI networks but deletes only the session user's. That is not caused by this PR; see below.

Could not run

  • CI has not been observed yet.
  • The licence stub has been down since the machine restarted on 10/28, so install-instance.sh fails at the licence step:
    • The 41bfe59 instance was set up with the installer's own steps (install_databases(), InstallAccount::create(), a REST key) without the licence form.
    • The Go CLI is refused there (402), so p202 report, p202 conversion list and the eval case did not run against it. Their API endpoints did, by curl. The earlier passes in this PR ran with the stub.
    • The eval case now checks {"stored":true} (updated for 1.9.79: GET /users/{id}/preferences serves no integration secret #270) and has not been run since.
    • The signed-in pages need the licence too (they redirect to api-key-required.php), so 8532b44's Remove form was not driven in a browser or over HTTP. PayKickstartKeysTest covers the stale form, both before and after, and the page's own change is the form field and the handler's argument.
  • One integration test needs the memcached extension, which this sandbox lacks (above).
  • The eval case's rubric was graded by hand, not by a judge command.
  • The pass ran on MariaDB 10.11, and CI runs MySQL 8.0.
  • No real PayKickstart IPN was available. Every hash checked here was computed from PayKickstart's published validator, so a divergence between their docs and their signer (decision 4) would show only in production.

Found outside this item

  • GET /users/{id}/preferences served the older secret columns in plain text (cb_key, jvzoo_ipn_secret_key, zaxaa_api_signature, ipqs_api_key, the Slack URL, lpo_bridge_config); 1.9.79: GET /users/{id}/preferences serves no integration secret #270 has since fixed it.
  • Account › API integrations lists 202_dni_networks WHERE user_id = '1' but removes WHERE user_id = <session user>. An admin other than user 1 sees user 1's networks, and their Remove answers "not found".
  • Amount::toUnits() accepts 13 whole digits while conversion columns are decimal(11,5). MariaDB under the empty sql_mode that connect.php sets clamps 5000000 to 999999.99999 with only a warning; that was measured on the column, not through an endpoint.
  • PayKickstart and ClickBank conversions fire no traffic-source postbacks (p202RecordLegacyConversion() has no notify_traffic_source), unlike gpb.php.

Generated by Claude Code

Account › API integrations handed out tracking202/static/paykickstart.php
as PayKickstart's Affiliate IPN URL under "No setup needed", and GET
/system/integrations and `p202 system integrations` listed it. The file
never existed, so every IPN sent there was a 404. The Affiliate IPN could
not have worked anyway: it is unsigned JSON with PayKickstart's own
tracking id and no custom variables, so nothing in it names a click.

The URL now takes PayKickstart's product (vendor) IPN. A vendor puts
subid=[[subid]] on the checkout link that is the campaign's offer URL,
pastes the URL into each product's IPN Integration tab and stores each
PayKickstart campaign's IPN secret key (202_users_pref, one per line, at
most 20, never served back). Each IPN is verified against every account's
stored keys with PayKickstart's published HMAC-SHA1 algorithm before
anything in it is read, and is recorded for the account whose key
verifies it, on a click of that account (another account's click reads
as no click). sales records a conversion (source paykickstart, pixel type
2) under its transaction_id at its amount; refund reverses it; test mode,
subscription-payment and every other event record nothing. A retry under
a transaction id on file is a duplicate only when it states the same
amount, and is refused (409) when it states another. Every outcome is a
status PayKickstart's log shows, with the reason, and one log line
without a key or buyer data.

The receiving side is provider-neutral (Prosper202\PaymentNotification:
a ProviderAdapter that verifies and parses, one Processor that records or
reverses through p202RecordLegacyConversion(), one Response mapping), so
JVZoo and ClickBank can follow as adapters.

HandedOutUrlsNameServedFilesTest holds every script a served literal
names, and every integration GET /system/integrations lists, to a file in
the tree; jvzoo.php, zpn.php and slack.php are listed as known dead until
their own changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
202-config/changelogs.json kept both: #258's updated lines, then the
PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
#258 made the API answer a 5xx while the files are deployed and the
upgrade not run with a 503 naming the upgrade. The IPN endpoint is not in
the API's tree, and between those two moments its keys' column is not
there yet: it answered 500. The Receiver now asks
SchemaVersion::pendingUpgrade() on a failure and answers 503 with its
sentence (OutcomeKind::UPGRADE_PENDING); PayKickstart's retry lands once
the upgrade has run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…ey is "the key"

admin-005 asks an agent to store a campaign's PayKickstart IPN secret key
and give the URL to paste: the key is stored with user prefs update, the
URL is the one system integrations answers, and neither the preferences
read nor the reply repeats the key. The reference agent answers it; an
agent that stores nothing and repeats the key fails all four checks.
Search puts system integrations first for the ask (two tasks in one ask),
so it is listed in searchKnownMisses rather than taught by a phrase lifted
from the ask.

A refusal of a single pasted key says "The key is 5 characters long"
rather than "Key 1"; a list still names the key by its place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Copilot AI balanced review requested due to automatic review settings October 23, 2026 20:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-28T13:43:03.075517Z
We couldn’t complete this review. Comment @codex review to try again.
9f00e63 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Code review: CLEAN

For 658a28e: 0 issue(s). The review is encrypted for the maintainers in the review-findings artifact of this run.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 658a28e74e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread 202-config/PaymentNotification/Processor.php Outdated
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Adversarial review: NON-BLOCKING

For 8532b44: 4 finding(s). The report is encrypted for the maintainers in the review-findings artifact of this run.

Rewritten on every push; the check fails on BLOCKING.

…what the reversal on file took back

A refund with no amount (or 0) takes back the whole sale. Its comparison
with the reversal on file was skipped when the amount was null, so a full
refund after a partial one was answered 200 duplicate: PayKickstart stopped
retrying while the rest of the sale stayed credited (CLAUDE.md #15). The
Processor now reads the reversal on file with the sale it reverses and
compares what the refund takes back, a full refund read as the sale's
amount. Any other refund of a sale already reversed is a 409 naming both:
"already reversed by 10.5, and 36.5 of its 47 is still credited; this
refund states its full amount, 47". A sale is still reversed once.

The other duplicate decision, a sale or rebill under a transaction id on
file, compares two amounts that are never null (the sale's is required,
the stored one NOT NULL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…er a deleted reversal reverses again

A sale stated again under a transaction id whose sale a refund took back
was answered 200 duplicate while the sale stayed reversed. JVZoo sends an
undone refund as RFND and then SALE again; the sale is now a 409 naming
the reversal to delete to count it (ported from the JVZoo branch).

Deleting that reversal did not make a later refund work: a deleted row
keeps its dedupe key, every refund without an id of its own shared
rev:<sale>:1, so the next refund of the sale found the deleted row and was
answered 200 duplicate with nothing reversed. Such a refund is now keyed
by its body, so only the same notification sent again unchanged finds a
reversal already on file, live or deleted; any other is either compared
with the reversal that stands (the writer refuses a second one under its
lock, and the Processor answers duplicate when it takes back the same) or,
when none stands, reverses the sale again. A refund with its own id is
still told apart by that id.

The 403 names the provider's signature field (ProviderAdapter::
signatureField(); PayKickstart's is hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

changelogs.json: 1.9.79's lines, then the PayKickstart line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aab39091fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread 202-config/PaymentNotification/Processor.php
tracking202 pushed a commit that referenced this pull request Oct 23, 2026
…ckBank branch

The Receiver's 403 now names the provider's signature field
(ProviderAdapter::signatureField()); ClickBank's is its encryption.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 23, 2026
…ClickBank branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

# Conflicts:
#	202-config/changelogs.json
…ppened

p202RecordLegacyConversion() dated every row time(), and the Processor
dropped the time a notification states, so an IPN PayKickstart delivered
days late (it retries a failed delivery up to 13 times) was filed on the
day it arrived in every view by conversion time: the conversion list's
time filter, attribution, the click-to-conversion time. The recorder now
takes a conv_time, and the Processor passes the stated time, held between
the earliest it can be (the click a sale credits; for a refund, the sale
it takes back, so the sale keeps its own day) and its arrival (a clock
ahead of this server's). The answer and the log line say when a time was
held. With no time stated, the row is dated when it arrives, as before.

transaction_time is signed (every non-empty field is); one that is not a
Unix time is now a 400 naming it, where it was read as no time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
…start IPN branch

GET /users/{id}/preferences now serves every integration secret as its
stored state (#270), and the PayKickstart IPN keys are one more:
paykickstart_ipn_secret_keys is on ServedPreferences::SECRET, answered
{"stored": true}, and the count this branch served in its place
(paykickstart_ipn_keys_stored, PayKickstartKeys::withoutKeys()) is gone.
GET /system/integrations keys_stored still says how many. The
PreferenceSecretsAreNotServedIntegrationTest round trip stores the keys
with the other secrets.

Conflicts: changelogs (1.9.79's lines, then this branch's), the
preferences docs and the OpenAPI schemas (1.9.79's, with the keys added
as a StoredSecret), and the controller (1.9.79's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 41bfe59e82

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread 202-config/PaymentNotification/PayKickstart/PayKickstartKeys.php Outdated
…ending

The page's Remove form named a key by its place and the four characters
it shows (…AB12). Another tab could remove that key and add a different
one ending in the same four characters at its place; the first tab's
form then matched the new key and removed it (measured on the pushed
class: removed(['campaign-new-AB12', …], '1', '…AB12') dropped
campaign-new-AB12). The form now carries an HMAC-SHA256 of the whole key
under a secret this session holds server-side
(PayKickstartKeys::removalToken()): the page shows nothing a reader
could match a guess against, and a form made for one key cannot match
another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

tracking202 pushed a commit that referenced this pull request Oct 28, 2026
…JVZoo branch

#268 ported the reinstatement 409 from this branch, keyed a refund with no
id of its own by its body (so a refund after a deleted reversal reverses
again), named the signature field in the 403 through a new
ProviderAdapter::signatureField(), and merged 1.9.79 (#262).

changelogs.json conflicted: 1.9.79's lines are kept, the JVZoo line after
them. The JVZoo adapter gains signatureField() ("cverify") in this commit,
since without it the class no longer satisfies the interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
…ersal reverses again

With #268's body-keyed refunds, a JVZoo refund sent after the reversal
was deleted reverses the sale again, and the first refund sent again
unchanged is a retry. The integration test now walks that path (SALE, RFND,
SALE 409, delete, the RFND again, a later RFND) and pins the 403's wording
to cverify; the unit test pins signatureField(). The guide and the
changelog say what a refund does after the reversal is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
…JVZoo branch

#268 now dates a notification's row by the provider's own time, held to the
click for a sale and to the sale for a refund, and brings 1.9.79's #270
(ServedPreferences: GET preferences serves each secret as {"stored": ...}),
#280, #281 and #282.

Conflicts: both changelogs keep the base's lines with the JVZoo line after
them; the OpenAPI preferences update takes #270's oneOf shape, with the
JVZoo key's rule in its string branch; the eval cases keep #268's admin-005
notes and add admin-006 after it. jvzoo_ipn_secret_key is already on
ServedPreferences::SECRET, and JVZoo adds no other column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
#268's Processor now dates a row by the provider's own time and holds it to
the click (a sale) or the sale (a refund) and to arrival. JVZoo's
ctranstime is that time: it was read leniently and only logged, so an
unreadable one became "no time" and the row was dated when it arrived. It
is now a Unix time of 1 to 10 digits, at most Notification::LAST_TIME, or
the notification is refused with a 400 naming ctranstime; absent or empty,
the row is dated when it arrives.

JVZooIpnIntegrationTest gains a late notification: a sale three days late
and its refund two days late keep their own times, a chargeback stating a
time before its sale is dated at the sale, a ctranstime changed after
signing is a 403, and one that is not a Unix time is a 400. The guide and
the changelog say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
…JVZoo branch

#268's stale-Remove fix for PayKickstart's keys (PayKickstartKeys and its
panel); it merged cleanly beside the JVZoo panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
…79's #270, #280, #281, #282) into the ClickBank branch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

# Conflicts:
#	202-config/changelogs.json
#	changelogs.txt
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
#268's Processor now dates a conversion by the time its notification
states (held between the click and the arrival). ClickBankAdapter reads
transactionTime with the offset it carries: RFC 3339 as 6.0 and 8.0 send
it (2023-10-05T13:47:51-06:00) and ISO 8601's basic form as 7.0's notes
describe (20200819T144359-0700). A time that is neither, or names no
real moment (a 13th month, hour 24, beyond a conv_time), is a 400 naming
the field rather than the time it arrived; none at all is dated on
arrival, as before.

Backlog: 7PJV

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
Brings in 1.9.79's new commits. The conflicts were in 202-config/changelogs.json, 202-config/functions-upgrade.php, changelogs.txt, where both sides added lines: the incoming lines are kept as they are and this branch's follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80d1bc4742

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

$pkPosted('paykickstart_key_token'),
$pkSecret
);
updateUserPreference(PayKickstartKeys::COLUMN, PayKickstartKeys::serialize($pkKeys), $_SESSION['user_id'], $db);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make key-list updates atomic

When two sessions for the same account submit overlapping key changes, both requests can derive $pkKeys from the same $pkOld and then blindly replace the whole column, so the last write discards the other session's addition or removal; for example, concurrent additions of keys A and B can leave only B stored, causing IPNs signed with A to be rejected. Fresh evidence beyond the earlier stale-token issue is that the full-list write here has no row lock or compare-and-swap check; update the list transactionally or condition the update on the previously read value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lost update is real. Two sessions of one account that save the key list at the same moment each write the list they read, so the later save drops the other's change.

I'm not fixing it only here, because every settings form on API integrations and the other Account pages works the same way: it posts whole values and rewrites them, so any two concurrent saves of one account's settings lose one. A compare-and-swap on this one column would leave every sibling form with the same shape (CLAUDE.md #5). The proportionate fix is page-wide: each settings form carries the version of what it showed and refuses a save over a newer one. I'm filing that as one follow-up for the owner rather than widening this PR.

The window is one account saving its own PayKickstart key list from two places within the same request round-trip. The visible outcome is an IPN refused for an unknown key, and that is logged with the key's prefix.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my last line: an IPN signed with a dropped key isn't logged with the key's prefix. When no stored key verifies the hash, the adapter answers the notification as unverified and logs no key at all. So the visible sign is a run of unverified IPNs for that product, not a named key.


Generated by Claude Code

PayKickstartAdapter::storedSecrets() read every 202_users_pref row, and a
deleted account keeps its row and the keys in it. Its key kept verifying
IPNs, so a sale on a deleted account's click was recorded for it, and a key
the deleted account shared with a live one verified both. ClickBank's and
JVZoo's adapters, on the branches above, read their keys the same way, and
ClickBank refuses a key two accounts store with a 409: a deleted account
holding a live account's ClickBank key refused every notification of the
live account, with nothing in the UI to clear the deleted copy.

AccountSecrets::stored() is now the one read of a provider's secret column
that decides the accounts a notification can belong to: it joins
202_users and leaves out user_deleted = 1. A user switched off in Account ›
Users (user_active = 0) still owns its keys: a sale PayKickstart reports
for that account is not that person acting. ProviderAdapter documents it.

StoredSecretsReadThroughAccountSecretsTest holds every ProviderAdapter's
storedSecrets() to a call of AccountSecrets::stored() with no statement of
its own naming 202_users_pref, and every other statement in
202-config/PaymentNotification and the payment endpoints that names
202_users_pref to a listed reason (the Processor's currency read, for the
account already decided).

Tests: PayKickstartIpnIntegrationTest's new
testADeletedAccountsStoredKeyVerifiesNothing seeds a deleted account that
stores a live account's key and one of its own: the live account's sale is
recorded for it, the deleted account's click is a 404 and its own key a
403, and a switched-off account's sale is recorded. Planted: the
user_deleted filter removed from AccountSecrets fails it ("Recorded
transaction PK-TX-D2 on click 99040131"), and so does a user_active = 1
filter added (the switched-off account's IPN unverified); storedSecrets()
reading 202_users_pref itself fails both structural tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

tracking202 pushed a commit that referenced this pull request Oct 28, 2026
Brings in #268's AccountSecrets: a deleted account's stored keys verify
nothing. The conflict was in changelogs.txt, where both sides changed
lines of 1.9.79: #268's PayKickstart line is kept as it is and this
branch's ClickBank line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
ClickBankAdapter::storedSecrets() read cb_key from every 202_users_pref row,
and a deleted account keeps its row and its key. A notification that the
key decrypts names every account that stores it, and a key two accounts
store is refused (409, ambiguous_account): so a deleted account that still
held the key a live account stores now made every one of the live
account's notifications a 409, and the deleted account's copy cannot be
cleared from the UI. A key only a deleted account stored still recorded
sales on its clicks.

storedSecrets() reads the keys through #268's AccountSecrets::stored(),
which leaves out deleted accounts; a switched-off account (user_active = 0)
still owns its key. StoredSecretsReadThroughAccountSecretsTest now holds
ClickBankAdapter too, and lists the two statements it makes on
202_users_pref for the account a TEST already decided (confirmTest(),
slackWebhook()).

Tests: ClickBankInsIntegrationTest's new
testADeletedAccountsStoredKeyIsNoSecondAccount: with a deleted account
storing the first account's key, the first account's sale is recorded and
its TEST marks only it verified; a key only the deleted account stores is
a 403; a switched-off account's sale is recorded. Planted: storedSecrets()
reading 202_users_pref itself, and AccountSecrets without its
user_deleted filter, each fail it with the 409 ("The notification decrypts
with a ClickBank secret key that more than one Prosper202 account
stores"); a user_active = 1 filter fails it with a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
Brings in #268's AccountSecrets: a deleted account's stored keys verify
nothing. The conflict was in changelogs.txt, where both sides changed
lines of 1.9.79: #268's PayKickstart line is kept as it is and this
branch's JVZoo line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
JVZooAdapter::storedSecrets() read jvzoo_ipn_secret_key from every
202_users_pref row, and a deleted account keeps its row and its key. Its
key kept verifying JVZIPN notifications: a sale on a deleted account's
click was recorded for it, and a key it shared with a live account verified
both.

storedSecrets() reads the key through #268's AccountSecrets::stored(),
which leaves out deleted accounts; a switched-off account (user_active = 0)
still owns its key. StoredSecretsReadThroughAccountSecretsTest, merged from
#268, now finds JVZooAdapter and holds it to that read (it failed on the
merge until this change).

Tests: JVZooIpnIntegrationTest's new
testADeletedAccountsStoredKeyVerifiesNothing: with a deleted account storing
the account's key, the account's sale is recorded for it; the deleted
account's click is a 404, a key only it stores a 403; a switched-off
account's sale is recorded. Planted: storedSecrets() reading
202_users_pref itself, and AccountSecrets without its user_deleted filter,
each fail it ("Recorded transaction D2 on click 99050141"); a
user_active = 1 filter fails it with a 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
tracking202 pushed a commit that referenced this pull request Oct 28, 2026
Brings in #276 with #268's AccountSecrets: a deleted account's stored
ClickBank and PayKickstart keys verify nothing, so a deleted account no
longer makes a live account's ClickBank key ambiguous. The conflict was in
changelogs.txt, where both sides changed lines of 1.9.79: the incoming
PayKickstart and ClickBank lines are kept as they are and this branch's
rebills line follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRoS2SViuxaucFaKWsFnLr
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.

3 participants