diff --git a/Cargo.lock b/Cargo.lock index a72d141..863f211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2139,8 +2139,8 @@ dependencies = [ [[package]] name = "paykit-lib" -version = "0.1.0-rc48" -source = "git+https://github.com/pubky/paykit-rs.git?tag=v0.1.0-rc48#9b56a0eacd6874137370fa79ec0f40b809140809" +version = "0.1.0-rc56" +source = "git+https://github.com/pubky/paykit-rs.git?tag=v0.1.0-rc56#24162ebbcc703251d8038f2e176d1f4cfb117c6a" dependencies = [ "anyhow", "base64 0.22.1", @@ -2148,6 +2148,7 @@ dependencies = [ "chrono", "pubky", "pubky-noise", + "reqwest", "serde", "serde_json", "thiserror", @@ -2428,9 +2429,9 @@ dependencies = [ [[package]] name = "pubky-noise" -version = "0.1.0-rc7" +version = "0.1.0-rc8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0fcffa2792342caf099107604fe1bd4c99a5f92fd3584306b678cc0ad744da" +checksum = "d506939a2d151814267bc94a1e70f6406d98bda0322da33a014a249edd307eea" dependencies = [ "curve25519-dalek 5.0.0", "ed25519-dalek", @@ -3544,7 +3545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/Cargo.toml b/Cargo.toml index 65bdca0..f4f1f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,10 +28,10 @@ clap = { version = "4", features = ["derive"] } locks-core = { path = "locks-core" } locks-service = { path = "locks-service" } mime = "0.3" -paykit-lib = { git = "https://github.com/pubky/paykit-rs.git", tag = "v0.1.0-rc48" } +paykit-lib = { git = "https://github.com/pubky/paykit-rs.git", tag = "v0.1.0-rc56" } pubky = { version = "0.11.0", git = "https://github.com/pubky/pubky-homeserver.git", rev = "99a2fb12f0ba4d3f7d9ff4b7b7b0740dabb18d03", features = ["json"] } pubky-common = { version = "0.11.0", git = "https://github.com/pubky/pubky-homeserver.git", rev = "99a2fb12f0ba4d3f7d9ff4b7b7b0740dabb18d03" } -pubky-noise = "=0.1.0-rc7" +pubky-noise = "=0.1.0-rc8" qrcode = { version = "0.14", default-features = false, features = ["svg"] } pkarr = "8.0.0" percent-encoding = "2" diff --git a/README.md b/README.md index 6290438..d05c435 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ docker compose --file compose.paykit-local-demo.yaml up -d --build 3. In production, use the production Bitkit QR/deep-link path presented by Paykit. When using the local CLI authentication fallback, run `npm --prefix examples/js-sdk ...` commands from the repository host. Do not wrap `authenticate` or `authenticate-paykit` in `docker compose exec`; those wrappers load private role state on the host and bridge only the bounded native-helper request into the demo container. The helper is supplied only by the Paykit local-demo image/runtime stage, not the normal production package/runtime. Follow the manual bearer-URL log retrieval and retention guidance in the example README. -Paykit Server uses release tag `v0.1.0-rc5` and is built against the current Locks worktree so both services use the same protocol paths. This release provides the signed lifecycle, setup-status, and Noise connection-status APIs required by Locks. No sibling Paykit or Pubky checkout is required. The local Paykit Server worktree override remains available by exporting an absolute `PAYKIT_SERVER_CONTEXT` path before running Compose. Other external build contexts remain anonymously reachable and version-tagged: Pubky Testnet uses `pubky/pubky-homeserver` `v0.11.0`, and Paykit libraries use `v0.1.0-rc48`. The full Paykit demo adds Paykit Server at . The reader remains at in every local flow. Payment remains a manual operator action. +The local demo temporarily selects the reviewed Paykit Server development commit encoded once in `compose.paykit-local-demo.yaml` and Paykit Rust release tag `v0.1.0-rc56`; contract checks derive the provisional server revision from that canonical Compose build context pending an immutable Paykit Server release. That provisional server commit does not yet compile against the current stacked Locks API, so the Compose path is not a runtime-green rejection demo until the upstream contract is updated and repinned. No sibling Paykit or Pubky checkout is required for source resolution. An absolute local `PAYKIT_SERVER_CONTEXT` override remains available for coordinated development. Once the gate is cleared, the full Paykit demo adds Paykit Server at and keeps the reader at . Payment remains a manual operator action. For the helper-free loopback browser demo against deployed staging Locks and Paykit services: @@ -518,7 +518,7 @@ The `bundle_id` must be cryptographically random and treated as a bearer secret. `paykit-payment` v1 submissions are single-proof only: do not mix payment and non-payment proofs in the same bundle. After rate limiting and current canonical lock/reader preflight, the Lock Server checks the permanent lifecycle identity `{ creator, bundle_id }`. Changed submitted proof material conflicts without calling Paykit. New payment submissions require Paykit configuration and call the signed idempotent invoice endpoint with `{ bundle_id, lock_resource, reader }`. Exact persisted replays return the existing lifecycle without calling Paykit. Connection observation is a separate read-only lookup bound to the persisted payment task. -The worker checks payment through a signed canonical `{ creator, bundle_id }` request to `POST /transactions/status`. Valid `undetected`, `detected`, and `confirmed` responses are evaluated against amount matching and the configured confirmation threshold. Transport, timeout, HTTP (including `404` or authorization), and response-decoding failures all durably return the task to pending for retry; v1 has no terminal Paykit payment failure. Responses never include invoice data, payment status internals, raw proof material, or an internal task ID. +The worker checks payment through a signed canonical `{ creator, bundle_id }` request to `POST /payment-requests/status`. Paykit returns separate closed `request_state` and `payment_state` axes plus factual confirmations, amount matching, `invoice_created_at`, and `payment_deadline`. Locks applies its configured confirmation threshold; it never infers expiry from its local clock. Rejected, canceled, proposal-expired, and payment-deadline-expired attempts become `expired` with a typed `terminal_reason`, no failure message, no entitlement, and no retry of that task. Transport failures, timeouts, response-body read failures, and non-`200` responses remain no-entitlement and pending for durable retry; `409 Conflict` is preserved as an operator-visible conflict. A `200` response with malformed JSON, missing or unknown fields, unknown states, an oversized body, or invalid or misordered timestamps fails closed: the task becomes `failed` with no entitlement. Both `failed` and `expired` are terminal for that Bundle ID; another attempt requires a new Bundle ID. Responses never include invoice data, payment internals, raw proof material, or an internal task ID. ### 4.3. Verified proof bundle / entitlement record diff --git a/compose.paykit-local-demo.yaml b/compose.paykit-local-demo.yaml index fdc95d7..357c995 100644 --- a/compose.paykit-local-demo.yaml +++ b/compose.paykit-local-demo.yaml @@ -281,11 +281,11 @@ services: paykit-server: image: pubky-locks-paykit-server:local build: - context: "${PAYKIT_SERVER_CONTEXT:-https://github.com/pubky/paykit-server.git#v0.1.0-rc5}" + context: "${PAYKIT_SERVER_CONTEXT:-https://github.com/pubky/paykit-server.git#44ed37886122a201b2d5f73c9578ecabb48f72bd}" dockerfile: Dockerfile.local additional_contexts: - paykit-lib: "https://github.com/pubky/paykit-rs.git#v0.1.0-rc48:paykit-lib" - paykit-sdk: "https://github.com/pubky/paykit-rs.git#v0.1.0-rc48:paykit-sdk" + paykit-lib: "https://github.com/pubky/paykit-rs.git#v0.1.0-rc56:paykit-lib" + paykit-sdk: "https://github.com/pubky/paykit-rs.git#v0.1.0-rc56:paykit-sdk" locks: . depends_on: diff --git a/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md b/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md index 1a256e1..67f3c7b 100644 --- a/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md +++ b/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md @@ -73,7 +73,7 @@ For connection observation, the Reader sends its existing `{ creator, bundle_id ### Status request and access policy -Locks sends RFC 8785 canonical JSON to `POST /transactions/status`: +Locks sends RFC 8785 canonical JSON to `POST /payment-requests/status`: ```json { @@ -82,15 +82,9 @@ Locks sends RFC 8785 canonical JSON to `POST /transactions/status`: } ``` -The status body uses the same `X-Paykit-Signature` authentication as invoice creation. The only v1 factual statuses are: +The status body uses the same `X-Paykit-Signature` authentication as invoice creation. It is closed and keeps canonical axes separate: `request_state` is `proposed`, `proposal_expired`, `accepted`, `rejected`, `canceled`, `proof_submitted`, or `active_recurring`; `payment_state` is `undetected`, `detected`, `confirmed`, or `expired`. The response also contains non-negative `confirmations`, `amount_matched`, `invoice_created_at`, and `payment_deadline`. Paykit reports those facts; Locks alone applies `minimum_confirmations` and decides access. -- `undetected`; -- `detected`; and -- `confirmed`. - -The response also contains non-negative `confirmations` and `amount_matched`. Paykit reports those facts; Locks alone applies `minimum_confirmations` and decides whether access is satisfied. - -V1 Payment Request terms have no separate proposal TTL. Paykit Server applies an application payment deadline and returns `invoice_created_at` plus `payment_deadline` from invoice creation; Locks validates that closed response but does not use those timestamps for verification policy. The transaction-status boundary above remains factual (`undetected`, `detected`, `confirmed`) and has no terminal payment-failure value. Paykit's separate Payment Request lifecycle may expose `expired`; that is distinct from proposal expiry and this transaction-observation contract. Every status-call transport, timeout, HTTP, authentication/authorization, protocol, and decoding failure leaves verification pending and schedules durable retry. This includes `404` and malformed successful responses. +`rejected`, `canceled`, `proposal_expired`, and accepted plus payment `expired` terminalize the current Locks attempt as `expired` with the corresponding typed reason. They issue no entitlement and do not retry the same task, even when confirmed payment facts exist. Transport failures, timeouts, response-body read failures, and non-`200` responses remain no-entitlement and pending for durable retry; `409 Conflict` is operator-visible rather than reclassified as payment rejection. A `200` response with malformed JSON, missing or unknown fields, unknown states, an oversized body, or invalid or misordered timestamps is a permanent contract failure: Locks terminalizes the attempt as `failed` with a viewer-safe failure message and no entitlement. Both `failed` and `expired` require a new Bundle ID for another attempt. Locks validates timestamp syntax and requires `payment_deadline > invoice_created_at`, but does not compare either timestamp to its local clock. ### Runtime boundary @@ -108,14 +102,14 @@ V1 Payment Request terms have no separate proposal TTL. Paykit Server applies an - Payment transport and asset policy remain inside Paykit. - Creator-scoped invoice identity supports tenant isolation and Bundle ID reuse across creators. - Durable idempotency makes ambiguous and concurrent invoice submission recoverable. -- Status failures cannot incorrectly become permanent payment denials. +- Retryable status availability failures cannot incorrectly become permanent payment denials. ### Negative and risks - Both services must implement the same canonical-body signing contract. - Paykit must parse the public Locks payment criterion and therefore depends on its versioned shape. - Exact submission replay intentionally performs current canonical lock and reader preflight before returning persisted lifecycle state. -- Unpaid invoices and pending Locks tasks have no protocol expiry in v1 and therefore require operational retention policy outside the payment-status contract. +- Terminal payment attempts require a fresh Bundle ID for retry; payment evidence remains owned and queryable by Paykit. ## Rejected alternatives @@ -123,7 +117,8 @@ V1 Payment Request terms have no separate proposal TTL. Paykit Server applies an - **Use globally unique Bundle IDs:** rejected because the durable identity is creator-scoped. - **Trust caller-supplied payment terms:** rejected because terms come from the canonical Lock Resource. - **Use unsigned or bundle-only status lookup:** rejected because it is unauthenticated and ambiguous across creators. -- **Terminalize status transport/protocol failures:** rejected because those failures are not payment facts. +- **Terminalize status transport or availability failures:** rejected because those failures are not payment facts. +- **Retry malformed or unsupported status contracts:** rejected because retrying the same invalid contract under the same Bundle ID cannot establish entitlement safely. - **Store wallet or xpub material in Locks:** rejected because Paykit owns payment transport and derivation. ## Related records diff --git a/docs/API.md b/docs/API.md index 03a990b..29fab51 100644 --- a/docs/API.md +++ b/docs/API.md @@ -596,7 +596,9 @@ This lookup is independent from verification lifecycle. `connected` does not mea Locks limits this public outbound proxy independently from proof submission. Default admission is 60 requests per 60 seconds for each `(client IP, creator, bundle_id)` plus 16 concurrent outbound Paykit status requests process-wide. Fixed-window rejection includes `Retry-After`; either limit returns `429 rate_limited` before another Paykit request is sent. -Paykit status verification is worker-owned. The Lock Server sends canonical JSON `{ "creator": "pubky...", "bundle_id": "..." }` to `POST /transactions/status`. `X-Paykit-Signature` signs `b"paykit-http-signature-v1\0" + uppercase_method + b"\0" + exact_query_free_path + b"\0" + exact_canonical_body`; body-only signatures are unsupported. Valid response statuses are `undetected`, `detected`, and `confirmed`. Transport failures, timeouts, every non-2xx response (including `404` and authentication/authorization failures), and malformed success bodies are durably rescheduled as pending and are not retried again before the worker poll interval elapses. V1 has no terminal Paykit payment-failure status. +Paykit status verification is worker-owned. The Lock Server sends canonical JSON `{ "creator": "pubky...", "bundle_id": "..." }` to `POST /payment-requests/status`. `X-Paykit-Signature` signs `b"paykit-http-signature-v1\0" + uppercase_method + b"\0" + exact_query_free_path + b"\0" + exact_canonical_body`; body-only signatures are unsupported. The closed response keeps `request_state` (`proposed`, `proposal_expired`, `accepted`, `rejected`, `canceled`, `proof_submitted`, `active_recurring`) separate from `payment_state` (`undetected`, `detected`, `confirmed`, `expired`) and includes factual `confirmations`, `amount_matched`, `invoice_created_at`, and `payment_deadline`. Locks validates timestamp syntax and strict ordering but never infers expiry from its local clock. + +Rejected, canceled, proposal-expired, and payment-expired attempts return lifecycle `status: "expired"` with `terminal_reason` equal to `payment_request_rejected`, `payment_request_canceled`, `proposal_expired`, or `payment_deadline_expired`. Such responses have `failure_message: null`, issue no entitlement, and are not retried. Transport failures, timeouts, response-body read failures, and every non-`200` response remain no-entitlement and retryable; `409 Conflict` is preserved as an operator-visible conflict. A `200` response with malformed JSON, missing or unknown fields, an unknown state, an oversized body, or invalid or misordered timestamps is a permanent contract failure: the worker transitions the attempt to `failed` with a viewer-safe `failure_message` and no entitlement. `failed` remains distinct from payment-lifecycle `expired`, but both are terminal for that Bundle ID; another attempt requires a new Bundle ID. Rate limiting, when enabled, returns `429 rate_limited` with the stable error envelope. diff --git a/docs/DOMAIN_MODEL.md b/docs/DOMAIN_MODEL.md index 2e906ea..b5b5a78 100644 --- a/docs/DOMAIN_MODEL.md +++ b/docs/DOMAIN_MODEL.md @@ -340,6 +340,7 @@ Fields: - `started_at: Option` - `completed_at: Option` - `failure_message: Option` +- `terminal_reason: Option` Invariants: @@ -355,11 +356,11 @@ Invariants: - Task records store operational lifecycle state only; they do not store `VerificationResult` because successful verification evidence lives in `VerifiedProofBundle`. - Public lifecycle responses replace internal `task_id` with `creator` and `bundle_id`, keep status/timestamp/failure fields, and must not expose submitted proof material, raw credentials, entitlement evidence, or worker claim metadata. - `{ creator, bundle_id }` is a permanent one-attempt lifecycle identity. After current canonical preflight, re-submitting the exact same submitted proof bundle returns the existing lifecycle state without creating new work or another Paykit invoice; different proof material for the same identity is a conflict. -- Paykit status lookup uses a signed `{ creator, bundle_id }` request. Any status-call transport, HTTP, authentication/authorization, protocol, or decoding failure returns the task to pending for durable retry; v1 has no terminal Paykit payment-failure status. +- Paykit lifecycle lookup uses a signed `{ creator, bundle_id }` request to `/payment-requests/status`. Request and payment states remain separate. Rejection, cancellation, proposal expiry, and payment deadline expiry terminalize the current attempt as `expired` with a typed reason, no failure message, no entitlement, and no retry. Transport failures, timeouts, response-body read failures, and non-`200` responses remain no-entitlement and pending for durable retry; `409 Conflict` remains operator-visible. Malformed JSON, missing or unknown fields, unknown states, oversized bodies, and invalid or misordered timestamps in a `200` response are permanent contract failures that terminalize the attempt as `failed` with no entitlement. - Retrying after `failed` or `expired` requires a new Bundle ID. -- Allowed transitions are `pending -> in_progress`, `pending -> expired`, `in_progress -> completed`, `in_progress -> failed`, and `in_progress -> expired`. +- Allowed transitions are `pending -> in_progress`, `in_progress -> completed`, `in_progress -> failed`, and `in_progress -> expired`. - `completed`, `failed`, and `expired` are terminal states; retention cleanup deletes task records rather than transitioning terminal tasks. -- `failed` tasks require a non-empty failure message; other statuses must not carry failure messages. +- `failed` tasks require a non-empty viewer-safe failure message and no terminal reason. `expired` tasks require one closed terminal reason and no failure message. Other statuses carry neither. - Transition methods validate current-state timestamp/failure-message invariants before applying a transition. ### VerifiedProofBundle Aggregate diff --git a/docs/LOCAL_OPERATOR_DEMO.md b/docs/LOCAL_OPERATOR_DEMO.md index 75c25af..f087fcb 100644 --- a/docs/LOCAL_OPERATOR_DEMO.md +++ b/docs/LOCAL_OPERATOR_DEMO.md @@ -15,7 +15,7 @@ Creator publishing is authenticated. The removed unauthenticated local/dev creat ## Paykit Compose local demonstration -The repository's browser-facing Paykit demonstration is a separate operator path from the manual single-server walkthrough below. Its local-only definition is `compose.paykit-local-demo.yaml`. It composes PostgreSQL, Bitcoin regtest, Fulcrum, Pubky testnet v0.11, Locks, Paykit Server, and the creator and reader browser demos. External source builds use anonymous public Git contexts, so no sibling repository checkout is required. Paykit Server uses release tag `v0.1.0-rc5` and is built against the current Locks worktree so both services use the same protocol paths and Paykit provides the required signed lifecycle, setup-status, and Noise connection-status APIs; Paykit library contexts use `v0.1.0-rc48`. An absolute local `PAYKIT_SERVER_CONTEXT` remains available as an optional coordinated-development override. +The repository's browser-facing Paykit demonstration is a separate operator path from the manual single-server walkthrough below. Its local-only definition is `compose.paykit-local-demo.yaml`. It composes PostgreSQL, Bitcoin regtest, Fulcrum, Pubky testnet v0.11, Locks, Paykit Server, and the creator and reader browser demos. External source builds use anonymous public Git contexts, so no sibling repository checkout is required. The rejection-lifecycle branch provisionally selects the reviewed Paykit Server commit encoded once in that Compose file and Paykit Rust release `v0.1.0-rc56`; an immutable Paykit Server release remains a pre-merge deployment gate. The provisional server commit does not yet compile against the current stacked Locks API, so this Compose path is not runtime-green and must not be presented as a working rejection demo until that upstream contract is updated and repinned. The `app.locks` path change does not migrate old demo resources or grants. Republish old locks and reauthorize creator identities for the new public and private namespaces. If disposable demo database state still references `locks.app`, finish or discard that state before restarting on this version; do not delete persistent volumes as a routine upgrade step. diff --git a/docs/RUNTIME.md b/docs/RUNTIME.md index 80f1e81..d770f7d 100644 --- a/docs/RUNTIME.md +++ b/docs/RUNTIME.md @@ -149,7 +149,7 @@ server_url = "http://127.0.0.1:3001" minimum_confirmations = 0 ``` -`server_url` must be a canonical exact HTTP(S) origin without credentials, path, query, fragment, or trailing slash. Endpoint paths are appended only after this configuration boundary. For a new `{ creator, bundle_id }` payment lifecycle identity, the Lock Server calls `POST /invoices` during `POST /proof-bundles` before creating a verification task. Exact persisted payment-submission replay returns the existing lifecycle without calling Paykit. Browser connection-state lookup calls Locks `POST /paykit-connection-state-lookups`; Locks derives the accepted task binding and calls Paykit `POST /connections/status`. Workers call `POST /transactions/status` while completing pending payment verification tasks. Every Paykit request body is canonical JSON. `X-Paykit-Signature` signs `b"paykit-http-signature-v1\0" + uppercase_method + b"\0" + exact_query_free_path + b"\0" + exact_raw_body` with the existing Lock Server keypair; body-only signatures are not supported. Therefore `credentials.lock_server_secret_key` must use the `keypair-seed:` format when `[paykit]` is configured. +`server_url` must be a canonical exact HTTP(S) origin without credentials, path, query, fragment, or trailing slash. Endpoint paths are appended only after this configuration boundary. For a new `{ creator, bundle_id }` payment lifecycle identity, the Lock Server calls `POST /invoices` during `POST /proof-bundles` before creating a verification task. Exact persisted payment-submission replay returns the existing lifecycle without calling Paykit. Browser connection-state lookup calls Locks `POST /paykit-connection-state-lookups`; Locks derives the accepted task binding and calls Paykit `POST /connections/status`. Workers call `POST /payment-requests/status` while completing pending payment verification tasks. Every Paykit request body is canonical JSON. `X-Paykit-Signature` signs `b"paykit-http-signature-v1\0" + uppercase_method + b"\0" + exact_query_free_path + b"\0" + exact_raw_body` with the existing Lock Server keypair; body-only signatures are not supported. Therefore `credentials.lock_server_secret_key` must use the `keypair-seed:` format when `[paykit]` is configured. Public connection-state lookups have independent process-local admission control. Defaults are 60 requests per 60-second window for each `(client IP, creator, bundle_id)`, a process-wide token bucket of 50 requests per second with burst 50, 16 concurrent outbound Paykit status requests, and at most 10,000 retained windows across the process: @@ -167,9 +167,9 @@ Fixed-window rejection includes its remaining window in `Retry-After`; token-buc Paykit HTTP connections have a 5-second connect timeout and every request has a 20-second whole-request timeout. Redirects are never followed for any signed Paykit request: `paykit.server_url` must name the final origin, and every `3xx` is handled as a non-success response. Invoice timeouts fail submission with `paykit_invoice_creation_failed`; connection-state timeouts fail only that read with `paykit_connection_state_timeout`; payment-status timeouts remain pending/retryable. When `[paykit]` and the in-process worker are both enabled, `worker.claim_timeout_seconds` must be greater than 20 so a Paykit request cannot outlive the worker claim lease. External worker deployments must preserve the same timeout/lease relationship operationally. -Every claimed verification task receives a fresh opaque claim token. Retry, completion, and failure transitions require the exact token, worker ID, `in_progress` state, and an unexpired lease, so a stale process cannot write after the same worker ID reclaims the task. Pubky entitlement publication cannot be atomic with the Postgres transition: a stale worker may publish a valid entitlement but cannot persist terminal task state. After any publication error, Locks reads the entitlement back; the current owner recovers only when the stored entitlement decision matches in every field except verifier-owned `verified_at` timestamps. A missing or mismatched entitlement preserves the failure. The Pubky adapter remains check-then-put, so claim fencing does not make concurrent homeserver writes atomic; it only fences Postgres task state. +Every claimed verification task receives a fresh opaque claim token. Retry, completion, and failure transitions require the exact token, worker ID, `in_progress` state, and an unexpired lease, so a stale process cannot write after the same worker ID reclaims the task. Pubky entitlement publication cannot be atomic with the Postgres transition: a stale worker may publish a valid entitlement but cannot persist terminal task state. After any publication error, Locks reads the entitlement back; the current owner recovers only when the stored entitlement is exactly equal to the persisted publication payload, including `verified_at`. A missing or mismatched entitlement preserves the failure. The Pubky adapter remains check-then-put, so claim fencing does not make concurrent homeserver writes atomic; it only fences Postgres task state. -`minimum_confirmations = 0` accepts a Paykit status of `detected` or `confirmed` when `amount_matched = true`. Values above zero require `status = "confirmed"` and at least that many confirmations. `undetected`, insufficient confirmations, or `amount_matched = false` keep the task pending/retryable. +`minimum_confirmations = 0` accepts Paykit `payment_state` `detected` or `confirmed` when `amount_matched = true` and request state remains access-eligible. Values above zero require `payment_state = "confirmed"` and at least that many confirmations. `undetected`, insufficient confirmations, or `amount_matched = false` keep the task pending/retryable. Request rejection, cancellation, proposal expiry, or accepted-payment deadline expiry terminalizes the current task as `expired` with a typed reason and no entitlement, even when factual payment evidence is confirmed. Locks does not derive expiry from local time. Transport failures, timeouts, response-body read failures, and every non-`200` response remain no-entitlement and pending/retryable; a typed `409 Conflict` remains operator-visible. A `200` response with malformed JSON, missing or unknown fields, unknown states, an oversized body, or invalid or misordered timestamps fails closed as `failed` with no entitlement. Both `failed` and `expired` require a fresh Bundle ID for another attempt. Omitting `[paykit]` prevents creation of new payment lifecycle identities and connection-state lookup. Existing exact payment-submission replays still return their persisted lifecycle. Non-payment verifier flows continue to run. New `paykit-payment` submissions return `422 paykit_not_configured`. Staging deployments should omit `[paykit]` until a Paykit Server is deployed and reachable for that environment. @@ -184,6 +184,8 @@ Postgres is private runtime storage for verification tasks, task claiming, acces Migration `0010_reset_prototype_runtime_state` performs the approved one-time destructive reset for the coordinated Paykit Server `0.1.0-rc5` and Locks Server `0.1.0-rc7` prototype deployment. It clears Locks-owned verification tasks, access credentials, creator authority, pending connect flows, frontend session codes, and frontend sessions. SQLx records the migration once; later starts preserve post-upgrade state. It does not drop the schema or migration ledger, does not touch the separate Paykit database, and does not delete Pubky-hosted content locks, guarded resources, Lock Service Pointers, or verified proof bundles. Stop every Paykit Server and Locks Server instance, verify both configured databases are dedicated disposable databases, stage both revisions, then start one instance of each service and let both migrations complete. Do not deploy or start either revision alone. Reacquire creator authority, frontend sessions, and Paykit Creator setup after startup. No production data migration is claimed. +Migration `0011_verification_terminal_reason` performs the approved staging-only reset of `verification_tasks`, then adds the closed terminal-reason column and lifecycle constraint. Scope is limited to incompatible Locks verification-task rows; access credentials, creator authority, frontend sessions, unrelated Locks tables, the Paykit database, and Pubky-hosted data are untouched. Existing staging attempts must be resubmitted with fresh Bundle IDs after the coordinated cutover. + Creator-granted session material is encrypted before storage. The server-side encryption key comes from an env var named by config: ```toml @@ -249,7 +251,7 @@ poll_interval_ms = 250 `dev-static` verification is registered only in `environment = "development"`. `paykit-payment` verification is registered when `[paykit]` is configured, regardless of environment. Staging/production completion uses the worker path, not the dev-only `POST /verification-task-completions` route. -For `paykit-payment`, every Paykit status-call failure schedules a normal pending retry and is logged as retry telemetry rather than a verification failure. This includes network errors, timeouts, all non-2xx responses (including `404` and authentication/authorization failures), and malformed success bodies. V1 has no terminal Paykit payment-failure status. +For `paykit-payment`, valid request rejection/cancellation and proposal/payment expiry persist an `expired` task with typed `terminal_reason`, no failure message, no entitlement, and no retry. Retryable availability failures schedule a normal pending retry and are logged as retry telemetry rather than verification failure. This includes network and response-body read errors, timeouts, and non-`200` responses such as authentication/authorization failures; `409 Conflict` remains operator-visible and is not converted into payment rejection. Malformed JSON, missing or unknown fields, unknown states, oversized bodies, and invalid or misordered timestamps in a `200` response are permanent contract failures: the worker persists `failed` with a viewer-safe failure message, issues no entitlement, and does not retry that Bundle ID. Scheduled retries and crash recovery are separate mechanisms. Expected retryable results explicitly release the current claim and set `next_attempt_at`. If a worker crashes while a task is `in_progress`, another worker may reclaim it only after `claim_expires_at`; only the worker that still owns an active claim may schedule its retry. diff --git a/docs/SDK_PAYKIT_VIEWER.md b/docs/SDK_PAYKIT_VIEWER.md index 64aba52..82682fe 100644 --- a/docs/SDK_PAYKIT_VIEWER.md +++ b/docs/SDK_PAYKIT_VIEWER.md @@ -1,6 +1,6 @@ # Paykit-backed viewer flow -Locks' signed Paykit lifecycle integration adds no new `locks-sdk` or JS/WASM export. SDK consumers compose existing public viewer calls; Lock Server owns invoice creation, payment observation, and verification. +Locks' signed Paykit lifecycle integration adds `VerificationTaskLifecycleResponse.terminal_reason` and the public Rust `VerificationTerminalReason` enum. JS/WASM consumers receive the wire string on the existing lifecycle object; no new JS class export is needed. Lock Server owns invoice creation, payment observation, and verification. Runnable browser example: [`examples/js-sdk/paykit-viewer-flow.js`](../examples/js-sdk/paykit-viewer-flow.js). Its smoke test executes lifecycle handling with public generated-package method names and verifies credential placement. @@ -93,18 +93,64 @@ Lifecycle response: "submitted_at": "2026-09-29T12:00:00Z", "started_at": null, "completed_at": null, - "failure_message": null + "failure_message": null, + "terminal_reason": null } ``` Handle lifecycle states as closed vocabulary: -| Status | Consumer action | +| Status | Required fields | Consumer action | +| --- | --- | --- | +| `pending` | `started_at`, `completed_at`, `failure_message`, and `terminal_reason` are `null` | Wait, then call `lookupVerificationTask` again. | +| `in_progress` | `started_at` is set; `completed_at`, `failure_message`, and `terminal_reason` are `null` | Wait, then call `lookupVerificationTask` again. | +| `completed` | `started_at` and `completed_at` are set; `failure_message` and `terminal_reason` are `null` | Call `issueAccessCredential`, then proxy-read an authorized relative path. | +| `failed` | `started_at` and `completed_at` are set; `failure_message` is non-empty; `terminal_reason` is `null` | Stop polling. Do not issue a credential. | +| `expired` | `started_at` and `completed_at` are set; `failure_message` is `null`; `terminal_reason` is set | Stop polling. Do not issue a credential. | +| anything else or any invalid tuple | Fail closed; client and server contract are incompatible. | + +`expired` identifies a normal terminal payment-request outcome, unlike `failed`, which carries a safe verification failure message. Its exact `terminal_reason` wire values are: + +| Wire value | Meaning | | --- | --- | -| `pending`, `in_progress` | Wait, then call `lookupVerificationTask` again. | -| `completed` | Call `issueAccessCredential`, then proxy-read an authorized relative path. | -| `failed`, `expired` | Terminal failure. Do not issue a credential. | -| anything else | Fail closed; client and server contract are incompatible. | +| `payment_request_rejected` | Reader rejected payment request. | +| `payment_request_canceled` | Payment request was canceled. | +| `proposal_expired` | Payment proposal expired before acceptance. | +| `payment_deadline_expired` | Accepted payment request reached its payment deadline. | + +All four outcomes are terminal: stop browser/Rust polling, issue no access credential, and grant no entitlement. To retry, submit a new attempt with a fresh Bundle ID and payment request; replaying same Bundle ID returns same terminal lifecycle. + +JS/WASM methods reject unknown statuses, unknown terminal reasons, extra private fields, and invalid tuples. Keep application handling closed too: + +```js +function nextAction(lifecycle) { + switch (lifecycle.status) { + case 'pending': + case 'in_progress': + return 'poll'; + case 'completed': + return 'issue-credential'; + case 'failed': + throw new Error(`verification failed: ${lifecycle.failure_message}`); + case 'expired': + switch (lifecycle.terminal_reason) { + case 'payment_request_rejected': + case 'payment_request_canceled': + case 'proposal_expired': + case 'payment_deadline_expired': + throw new Error( + `${lifecycle.terminal_reason}; retry with a fresh Bundle ID and payment request`, + ); + default: + throw new Error('invalid verification terminal reason'); + } + default: + throw new Error('invalid verification lifecycle status'); + } +} +``` + +Migration note: lifecycle JSON now includes `terminal_reason` on every response (`null` unless status is `expired`). Consumers with exact object comparisons, JSON schemas, TypeScript interfaces, or destructuring assumptions for older payloads must accept this nullable field before deploying against this server version. Connection state is independent from verification lifecycle: @@ -118,7 +164,7 @@ Connection state is independent from verification lifecycle: A timeout or error from `lookupPaykitConnectionState` must not delay or stop authoritative lifecycle polling. Never replay `submitProofBundle` merely to refresh connection state. -Access credential is returned exactly once. Keep it out of URLs, JSON bodies, logs, and durable analytics. Pass it only to `proxyReadGuardedResource` or `proxyReadGuardedResourceResponse`; SDK places it in `Authorization: Bearer `. +Access credential is returned exactly once. Keep it out of URLs, JSON bodies, logs, and durable analytics. Pass it only to `proxyReadGuardedResource` or `proxyReadGuardedResourceResponse`; SDK places it in the `Authorization` bearer header. ## Rust request planner @@ -128,14 +174,15 @@ Rust `locks-sdk` builds canonical requests and parses closed responses. It does use locks_core::verification::SubmittedProofBundle; use locks_sdk::{ Result, SdkViewerRequest, VerificationTaskHandleRequest, - VerificationTaskStatus, ViewerLocks, + VerificationTaskStatus, VerificationTerminalReason, ViewerLocks, }; use serde_json::Value; enum NextRequest { Poll(SdkViewerRequest), IssueCredential(SdkViewerRequest), - Terminal, + Failed(String), + Expired(VerificationTerminalReason), } fn plan_after_submit( @@ -157,14 +204,28 @@ fn plan_after_submit( VerificationTaskStatus::Completed => { NextRequest::IssueCredential(viewer.issue_access_credential(handle)) } - VerificationTaskStatus::Failed | VerificationTaskStatus::Expired => { - NextRequest::Terminal + VerificationTaskStatus::Failed => { + NextRequest::Failed(lifecycle.failure_message.expect("validated failed response")) + } + VerificationTaskStatus::Expired => { + NextRequest::Expired( + lifecycle.terminal_reason.expect("validated expired response"), + ) } }; Ok((submit_request, next)) } + +fn expired_message(reason: VerificationTerminalReason) -> &'static str { + match reason { + VerificationTerminalReason::PaymentRequestRejected => "payment request rejected", + VerificationTerminalReason::PaymentRequestCanceled => "payment request canceled", + VerificationTerminalReason::ProposalExpired => "proposal expired", + VerificationTerminalReason::PaymentDeadlineExpired => "payment deadline expired", + } +} ``` -Send returned request through caller transport. Parse each later lifecycle response with `parse_lifecycle_response`; parse credential response with `parse_access_credential_response`, then pass only its `credential` field to `proxy_read_guarded_resource`. +Send returned request through caller transport. Parse each later lifecycle response with `parse_lifecycle_response`; it rejects unknown/invalid response shapes. Parse credential response with `parse_access_credential_response`, then pass only its `credential` field to `proxy_read_guarded_resource`. `Failed` and `Expired` are terminal and must not issue a credential; a retry needs a fresh Bundle ID and payment request. Compile-enforced public consumer contract: [`locks-sdk/tests/paykit_viewer_flow.rs`](../locks-sdk/tests/paykit_viewer_flow.rs). It verifies exact request bodies/routes, every lifecycle and connection state, invalid-state rejection, and bearer-only credential placement. diff --git a/docs/THESAURUS.md b/docs/THESAURUS.md index 9f748d3..92630dc 100644 --- a/docs/THESAURUS.md +++ b/docs/THESAURUS.md @@ -264,7 +264,7 @@ Repository/workspace structure, protocol payload ownership, and code-boundary la - **Related terms**: Submitted Proof Bundle, Paykit Payment Verifier, Content Viewer ### Paykit Server -- **Definition**: Standalone payment service configured under Lock Server `[paykit]`; Locks calls `POST /invoices` and `POST /transactions/status` with Lock-Server-signed requests. +- **Definition**: Standalone payment service configured under Lock Server `[paykit]`; Locks calls `POST /invoices`, `POST /payment-requests/status`, and `POST /connections/status` with Lock-Server-signed requests. - **NOT**: Lock Server private runtime state, Locks-owned access decision logic, or creator-owned Pubky data. - **Synonyms to AVOID**: payment backend, wallet server, invoice server - **Related terms**: Paykit Payment Verifier, Lock Server, Reader Public Key diff --git a/examples/js-sdk/README.md b/examples/js-sdk/README.md index a8c4032..5fe8605 100644 --- a/examples/js-sdk/README.md +++ b/examples/js-sdk/README.md @@ -212,10 +212,14 @@ npm --prefix examples/js-sdk run authenticate-paykit -- --role content-creator Do not wrap these commands in `docker compose exec`. The host wrappers load private role state locally and bridge only bounded helper input into the relevant container. -The Paykit Server build uses release tag `v0.1.0-rc5`, the active Locks checkout, -Paykit Rust `v0.1.0-rc48`, and Pubky Homeserver `v0.11.0`. This release provides the signed -lifecycle, setup-status, and Noise connection-status APIs required by Locks. No sibling -repository checkout is required. +The local demo temporarily selects the reviewed Paykit Server development commit encoded +once in `compose.paykit-local-demo.yaml`, the active Locks checkout, Paykit Rust +`v0.1.0-rc56`, and Pubky Homeserver `v0.11.0`. Contract checks derive the provisional +server revision from that canonical Compose build context pending an immutable release. +That server commit does not yet compile against +the current stacked Locks API, so this Compose path is not a runtime-green rejection +demo until the upstream contract is updated and repinned. No sibling repository checkout +is required for source resolution. For coordinated Paykit Server work, select an explicit absolute local worktree without changing the committed public default: diff --git a/examples/js-sdk/paykit-viewer-flow.js b/examples/js-sdk/paykit-viewer-flow.js index 38f744c..5be0696 100644 --- a/examples/js-sdk/paykit-viewer-flow.js +++ b/examples/js-sdk/paykit-viewer-flow.js @@ -12,6 +12,13 @@ const CONNECTION_STATES = new Set([ 'blocked', ]); +const TERMINAL_REASONS = new Set([ + 'payment_request_rejected', + 'payment_request_canceled', + 'proposal_expired', + 'payment_deadline_expired', +]); + /** * Complete a Paykit-backed viewer flow using only public JS/WASM SDK exports. * Caller must generate and durably store bundleId before invoking this helper. @@ -88,7 +95,7 @@ export async function runPaykitViewerFlow({ let connectionLookupInFlight = false; while (true) { - const status = lifecycle?.status; + const status = validateLifecycle(lifecycle); if (status === 'pending' || status === 'in_progress') { if (observeConnection && !connectionLookupInFlight) { connectionLookupInFlight = true; @@ -111,11 +118,15 @@ export async function runPaykitViewerFlow({ lifecycle = await viewer.lookupVerificationTask(handle); continue; } - if (status === 'failed' || status === 'expired') { - throw new Error(`Paykit verification ${status}`); + if (status === 'failed') { + throw new Error( + `Paykit verification failed: ${lifecycle.failure_message}; no access credential was issued; start a new attempt with a fresh Bundle ID and payment request`, + ); } - if (status !== 'completed') { - throw new Error(`unknown verification status: ${String(status)}`); + if (status === 'expired') { + throw new Error( + `Paykit verification expired: ${lifecycle.terminal_reason}; no access credential was issued; start a new attempt with a fresh Bundle ID and payment request`, + ); } const issued = await viewer.issueAccessCredential(handle); @@ -131,6 +142,31 @@ export async function runPaykitViewerFlow({ } } +function validateLifecycle(lifecycle) { + const status = lifecycle?.status; + const started = lifecycle?.started_at != null; + const completed = lifecycle?.completed_at != null; + const failure = lifecycle?.failure_message; + const terminalReason = lifecycle?.terminal_reason; + const valid = ( + (status === 'pending' + && !started && !completed && failure === null && terminalReason === null) + || (status === 'in_progress' + && started && !completed && failure === null && terminalReason === null) + || (status === 'completed' + && started && completed && failure === null && terminalReason === null) + || (status === 'failed' + && started && completed && typeof failure === 'string' && failure.trim() !== '' + && terminalReason === null) + || (status === 'expired' + && started && completed && failure === null && TERMINAL_REASONS.has(terminalReason)) + ); + if (!valid) { + throw new Error(`invalid verification lifecycle response: ${String(status)}`); + } + return status; +} + function parseConnectionState(response) { const state = response?.state; if (!CONNECTION_STATES.has(state)) { diff --git a/examples/js-sdk/reader-app.js b/examples/js-sdk/reader-app.js index b31ed32..29a03d3 100644 --- a/examples/js-sdk/reader-app.js +++ b/examples/js-sdk/reader-app.js @@ -24,7 +24,11 @@ import { createPaykitDataCheckController, } from './reader-staging-paykit.js'; import { buildPersistedReaderState, restorePersistedReaderState } from './reader-persistence.js'; -import { describeReaderLoadState, validateContentLockResource } from './reader-load-state.js'; +import { + describeReaderLoadState, + paymentLifecycleTerminalError, + validateContentLockResource, +} from './reader-load-state.js'; const STATE_KEY = 'pubky-locks-reader-demo.state'; @@ -526,8 +530,9 @@ async function pollPaymentLifecycle(handle = currentPaymentHandle()) { await delay(1_000); continue; } - if (status === 'failed' || status === 'expired' || classification === 'failed') { - throw new Error(`payment verification ended with status ${status}`); + const terminalError = paymentLifecycleTerminalError(lifecycle); + if (terminalError || classification === 'failed') { + throw new Error(terminalError ?? `payment verification ended with status ${status}`); } if (classification === 'completed') { await postClientLog('info', 'reader-payment-poll-completed', handleDetails(handle)); @@ -728,7 +733,10 @@ function render() { el.proofStatus.textContent = 'Submitting proof bundle...'; el.proofStatus.className = 'muted'; } else if (state.lifecycle) { - el.proofStatus.textContent = `Proof submitted. Status: ${state.lifecycle.status}`; + const terminalReason = state.lifecycle.terminal_reason; + el.proofStatus.textContent = terminalReason + ? `Proof submitted. Status: ${state.lifecycle.status} (${terminalReason}); retry with a new Bundle ID.` + : `Proof submitted. Status: ${state.lifecycle.status}`; el.proofStatus.className = ['failed', 'expired'].includes(state.lifecycle.status) ? 'error' : 'ok'; } else { el.proofStatus.textContent = state.loaded ? 'Ready to submit proof bundle.' : 'Waiting for loaded lock.'; diff --git a/examples/js-sdk/reader-load-state.js b/examples/js-sdk/reader-load-state.js index ddf77e4..978fc81 100644 --- a/examples/js-sdk/reader-load-state.js +++ b/examples/js-sdk/reader-load-state.js @@ -17,3 +17,13 @@ export function describeReaderLoadState({ loadingLock, loaded, resource, loadErr if (resource) return { message: 'Ready to load content lock.', className: 'muted' }; return { message: 'Paste a content lock resource.', className: 'muted' }; } + +export function paymentLifecycleTerminalError(lifecycle) { + const status = lifecycle?.status; + if (status !== 'failed' && status !== 'expired') return null; + + const terminalReason = lifecycle?.terminal_reason; + return terminalReason + ? `payment verification ended with status ${status}: ${terminalReason}; retry with a new Bundle ID` + : `payment verification ended with status ${status}`; +} diff --git a/examples/js-sdk/scripts/check-paykit-setup-contract.mjs b/examples/js-sdk/scripts/check-paykit-setup-contract.mjs index 79a7d16..4033c4f 100644 --- a/examples/js-sdk/scripts/check-paykit-setup-contract.mjs +++ b/examples/js-sdk/scripts/check-paykit-setup-contract.mjs @@ -1,20 +1,11 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -const PAYKIT_SERVER_REMOTE = 'https://github.com/pubky/paykit-server.git'; -const PAYKIT_SERVER_REF = 'v0.1.0-rc5'; -const RELEASE_REF = `refs/tags/${PAYKIT_SERVER_REF}`; -const MAX_GIT_OUTPUT_BYTES = 64 * 1024; +import { PAYKIT_SERVER_REVISION } from './lib/paykit-server-source.mjs'; + const MAX_SOURCE_BYTES = 256 * 1024; const TIMEOUT_MS = 30_000; -export function parsePaykitReleaseRevision(output) { - const match = /^([0-9a-f]{40})\trefs\/tags\/v0\.1\.0-rc5\n?$/u.exec(output); - if (!match) throw new Error('Paykit Server returned an invalid release revision'); - return match[1]; -} - export function validatePaykitSetupStatusSources({ setupStatusSource, connectionStatusSource, @@ -35,36 +26,16 @@ export function validatePaykitSetupStatusSources({ } export async function checkPaykitSetupContract({ - run = runGit, fetchSource = fetchBoundedText, } = {}) { - const revisionResult = run([ - 'ls-remote', - PAYKIT_SERVER_REMOTE, - RELEASE_REF, - ]); - if (revisionResult.error || revisionResult.status !== 0 || revisionResult.signal) { - throw new Error('Could not resolve Paykit Server release'); - } - const revision = parsePaykitReleaseRevision(revisionResult.stdout ?? ''); - const sourceBase = `https://raw.githubusercontent.com/pubky/paykit-server/${revision}`; + const sourceBase = `https://raw.githubusercontent.com/pubky/paykit-server/${PAYKIT_SERVER_REVISION}`; const [setupStatusSource, connectionStatusSource, serverSource] = await Promise.all([ fetchSource(`${sourceBase}/paykit-server/src/http/setup_status.rs`), fetchSource(`${sourceBase}/paykit-server/src/http/connection_status.rs`), fetchSource(`${sourceBase}/paykit-server/src/server.rs`), ]); validatePaykitSetupStatusSources({ setupStatusSource, connectionStatusSource, serverSource }); - return revision; -} - -function runGit(args) { - return spawnSync('git', args, { - shell: false, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - timeout: TIMEOUT_MS, - maxBuffer: MAX_GIT_OUTPUT_BYTES, - }); + return PAYKIT_SERVER_REVISION; } async function fetchBoundedText(url) { diff --git a/examples/js-sdk/scripts/lib/paykit-server-source.mjs b/examples/js-sdk/scripts/lib/paykit-server-source.mjs new file mode 100644 index 0000000..1c69ba9 --- /dev/null +++ b/examples/js-sdk/scripts/lib/paykit-server-source.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs'; + +const compose = readFileSync( + new URL('../../../../compose.paykit-local-demo.yaml', import.meta.url), + 'utf8', +); +const source = compose.match( + /context: "\$\{PAYKIT_SERVER_CONTEXT:-(https:\/\/github\.com\/pubky\/paykit-server\.git#([0-9a-f]{40}))\}"/, +); +if (!source) throw new Error('Compose is missing the canonical Paykit Server source'); + +export const DEFAULT_PAYKIT_SERVER_CONTEXT = source[1]; +export const PAYKIT_SERVER_REVISION = source[2]; \ No newline at end of file diff --git a/examples/js-sdk/scripts/smoke-paykit-compose.mjs b/examples/js-sdk/scripts/smoke-paykit-compose.mjs index a20fd2f..70257d9 100644 --- a/examples/js-sdk/scripts/smoke-paykit-compose.mjs +++ b/examples/js-sdk/scripts/smoke-paykit-compose.mjs @@ -18,19 +18,21 @@ import { import { resolveCreatorStaticPath } from './lib/creator-static-path.mjs'; import { publishCreatorProfile } from './publish-creator-profile.mjs'; import { - parsePaykitReleaseRevision, readBoundedResponseText, validatePaykitSetupStatusSources, } from './check-paykit-setup-contract.mjs'; +import { + DEFAULT_PAYKIT_SERVER_CONTEXT, + PAYKIT_SERVER_REVISION, +} from './lib/paykit-server-source.mjs'; const lockServerPubky = 'pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo'; const creatorPubky = 'pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy'; -const paykitReleaseRevision = '31c77c99bf73fe6e377c842b582632a832950022'; +assert.match(PAYKIT_SERVER_REVISION, /^[0-9a-f]{40}$/); assert.equal( - parsePaykitReleaseRevision(`${paykitReleaseRevision}\trefs/tags/v0.1.0-rc5\n`), - paykitReleaseRevision, + DEFAULT_PAYKIT_SERVER_CONTEXT, + `https://github.com/pubky/paykit-server.git#${PAYKIT_SERVER_REVISION}`, ); -assert.throws(() => parsePaykitReleaseRevision('not-a-revision\n'), /release revision/); assert.doesNotThrow(() => validatePaykitSetupStatusSources({ setupStatusSource: '.route("/setup/status", post(status))', connectionStatusSource: '.route("/connections/status", post(status))', @@ -347,6 +349,7 @@ const bitcoinBootstrap = await readFile(join(repoRoot, 'docker/bitcoin-bootstrap const resetScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/reset-paykit-demo.mjs'), 'utf8'); const validateScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/validate-paykit-compose.mjs'), 'utf8'); const accountScript = await readFile(join(repoRoot, 'examples/js-sdk/scripts/generate-paykit-account-tpub.mjs'), 'utf8'); +const paykitServerSource = await readFile(join(repoRoot, 'examples/js-sdk/scripts/lib/paykit-server-source.mjs'), 'utf8'); const packageJson = JSON.parse(await readFile(join(repoRoot, 'examples/js-sdk/package.json'), 'utf8')); const bootstrapMode = (await stat(join(repoRoot, 'docker/bitcoin-bootstrap.sh'))).mode; assert.notEqual(bootstrapMode & 0o111, 0, 'Bitcoin bootstrap script must be executable'); @@ -385,9 +388,9 @@ for (const required of [ 'node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a5968bbbdef8d058f9dff4', 'additional_contexts:', 'PUBKY_HOMESERVER_REF: v0.11.0', - 'https://github.com/pubky/paykit-server.git#v0.1.0-rc5', - 'https://github.com/pubky/paykit-rs.git#v0.1.0-rc48:paykit-lib', - 'https://github.com/pubky/paykit-rs.git#v0.1.0-rc48:paykit-sdk', + DEFAULT_PAYKIT_SERVER_CONTEXT, + 'https://github.com/pubky/paykit-rs.git#v0.1.0-rc56:paykit-lib', + 'https://github.com/pubky/paykit-rs.git#v0.1.0-rc56:paykit-sdk', 'locks: .', '127.0.0.1:${LOCKS_PAYKIT_PORT:-3001}:3001', '127.0.0.1:${LOCKS_READER_DEMO_PORT:-8088}:8088', @@ -421,6 +424,11 @@ for (const required of [ ]) { assert.ok(compose.includes(required), `Compose missing ${required}`); } +assert.doesNotMatch( + paykitServerSource, + /[0-9a-f]{40}/, + 'Paykit Server source helper must derive the revision from Compose instead of duplicating it', +); assert.ok(!compose.includes('env_file:'), 'Compose must bootstrap before loading generated environments'); assert.ok(!compose.includes('chown -R 1000:1000 .local\n'), 'bootstrap must not rewrite ownership of the complete local state tree'); assert.equal( @@ -531,8 +539,8 @@ assert.equal( 'node scripts/check-paykit-setup-contract.mjs', ); assert.ok( - validateScript.includes("PAYKIT_SERVER_REF = 'v0.1.0-rc5'"), - 'Compose validation must enforce the Paykit Server release ref', + validateScript.includes('DEFAULT_PAYKIT_SERVER_CONTEXT'), + 'Compose validation must enforce the centralized Paykit Server source', ); assert.ok( validateScript.includes("additional_contexts?.locks\n !== repoRoot"), diff --git a/examples/js-sdk/scripts/test-reader-load-state.mjs b/examples/js-sdk/scripts/test-reader-load-state.mjs index 89c5c13..0d10cdf 100644 --- a/examples/js-sdk/scripts/test-reader-load-state.mjs +++ b/examples/js-sdk/scripts/test-reader-load-state.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { describeReaderLoadState, + paymentLifecycleTerminalError, validateContentLockResource, } from '../reader-load-state.js'; @@ -30,4 +31,16 @@ assert.deepEqual( { message: 'Old namespace rejected.', className: 'error' }, ); +assert.equal( + paymentLifecycleTerminalError({ + status: 'expired', + terminal_reason: 'payment_request_rejected', + }), + 'payment verification ended with status expired: payment_request_rejected; retry with a new Bundle ID', +); +assert.equal( + paymentLifecycleTerminalError({ status: 'pending', terminal_reason: null }), + null, +); + console.log('reader load state tests passed'); diff --git a/examples/js-sdk/scripts/validate-paykit-compose.mjs b/examples/js-sdk/scripts/validate-paykit-compose.mjs index 833c7cd..024aed3 100644 --- a/examples/js-sdk/scripts/validate-paykit-compose.mjs +++ b/examples/js-sdk/scripts/validate-paykit-compose.mjs @@ -3,11 +3,11 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { repoRoot } from './lib/paths.mjs'; +import { DEFAULT_PAYKIT_SERVER_CONTEXT } from './lib/paykit-server-source.mjs'; const MAX_MODEL_BYTES = 2 * 1024 * 1024; const COMPOSE_FILE = 'compose.paykit-local-demo.yaml'; -const PAYKIT_SERVER_REF = 'v0.1.0-rc5'; -const DEFAULT_PAYKIT_SERVER_CONTEXT = `https://github.com/pubky/paykit-server.git#${PAYKIT_SERVER_REF}`; + const REQUIRED_SERVICES = [ 'postgres', 'paykit-postgres', diff --git a/locks-e2e/tests/creator_publishing_http.rs b/locks-e2e/tests/creator_publishing_http.rs index c41bec7..1924fca 100644 --- a/locks-e2e/tests/creator_publishing_http.rs +++ b/locks-e2e/tests/creator_publishing_http.rs @@ -817,7 +817,7 @@ impl FakePaykitServer { let app = Router::new() .route("/invoices", post(fake_invoice_handler)) .route("/connections/status", post(fake_connection_status_handler)) - .route("/transactions/status", post(fake_status_handler)) + .route("/payment-requests/status", post(fake_status_handler)) .with_state(Arc::clone(&state)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let server_url = format!("http://{}", listener.local_addr().unwrap()); @@ -933,7 +933,10 @@ async fn fake_status_handler( .get("X-Paykit-Signature") .map(|value| value.to_str().unwrap().to_owned()); Json(json!({ - "status": "detected", + "request_state": "accepted", + "payment_state": "detected", + "invoice_created_at": "2026-09-25T11:00:00Z", + "payment_deadline": "2026-09-25T12:00:00Z", "confirmations": 0, "amount_matched": true, })) diff --git a/locks-sdk/bindings/js/scripts/smoke-examples.mjs b/locks-sdk/bindings/js/scripts/smoke-examples.mjs index 8b34d7e..6c1b704 100644 --- a/locks-sdk/bindings/js/scripts/smoke-examples.mjs +++ b/locks-sdk/bindings/js/scripts/smoke-examples.mjs @@ -25,6 +25,7 @@ const files = { paykitSetup: join(examplesDir, 'paykit-setup.js'), readerHtml: join(examplesDir, 'reader.html'), readerApp: join(examplesDir, 'reader-app.js'), + readerLoadState: join(examplesDir, 'reader-load-state.js'), readerFlow: join(examplesDir, 'reader-flow.js'), paykitViewerFlow: join(examplesDir, 'paykit-viewer-flow.js'), readerPersistence: join(examplesDir, 'reader-persistence.js'), @@ -61,22 +62,24 @@ const texts = Object.fromEntries( const required = { rootReadme: [ - 'Paykit Server uses release tag `v0.1.0-rc5`', - 'and is built against the current Locks worktree', - 'The local Paykit Server worktree override remains available', + 'Paykit Server development commit encoded once in `compose.paykit-local-demo.yaml`', + 'Paykit Rust release tag `v0.1.0-rc56`', + 'does not yet compile against the current stacked Locks API', + 'An absolute local `PAYKIT_SERVER_CONTEXT` override remains available', 'production Bitkit QR/deep-link path', 'local-demo image/runtime stage', ], localOperatorDemo: [ - 'release tag `v0.1.0-rc5`', - 'built against the current Locks worktree', - 'An absolute local `PAYKIT_SERVER_CONTEXT` remains available', + 'Paykit Rust release `v0.1.0-rc56`', + 'does not yet compile against the current stacked Locks API', + 'must not be presented as a working rejection demo', 'production Bitkit QR/deep-link path', 'local-demo image/runtime stage', ], readme: [ - 'release tag', - '`v0.1.0-rc5`', + 'reviewed Paykit Server development commit encoded', + 'canonical Compose build context', + '`v0.1.0-rc56`, and Pubky Homeserver', 'For coordinated Paykit Server work', 'npm --prefix examples/js-sdk install', 'npm --prefix examples/js-sdk run init-config', @@ -132,18 +135,29 @@ const required = { 'SDK_PAYKIT_VIEWER.md', ], paykitViewerDocs: [ - 'adds no new `locks-sdk` or JS/WASM export', + 'public Rust `VerificationTerminalReason` enum', + 'no new JS class export', + '`VerificationTaskLifecycleResponse.terminal_reason`', + '`VerificationTerminalReason`', + '`payment_request_rejected`', + '`payment_request_canceled`', + '`proposal_expired`', + '`payment_deadline_expired`', + 'fresh Bundle ID and payment request', + 'Migration note', + '| `pending` |', + '| `in_progress` |', + '| `failed` |', + '| `expired` |', 'const bundleId = BundleId.generate().toString();', 'await persistBundleId(bundleId);', - '`pending`, `in_progress`', - '`failed`, `expired`', '`none`', '`handshake`', '`connected`', '`recovery_required`', '`blocked`', 'must not delay or stop authoritative lifecycle polling', - 'Authorization: Bearer ', + '`Authorization` bearer header', 'locks-sdk/tests/paykit_viewer_flow.rs', ], packageJson: [ @@ -369,10 +383,20 @@ const required = { 'viewer.issueAccessCredential', 'viewer.proxyReadGuardedResourceResponse', "status === 'pending' || status === 'in_progress'", - "status === 'failed' || status === 'expired'", + "status === 'failed'", + "status === 'expired'", + 'payment_request_rejected', + 'payment_request_canceled', + 'proposal_expired', + 'payment_deadline_expired', + 'fresh Bundle ID and payment request', ], readerStagingPaykit: ['validateExternalReaderPubky', 'checkExternalReaderPaykitData', 'distinct Bitkit identities', "state: 'present'", "state: 'absent'", "state: 'unavailable'"], readerPersistence: ['buildPersistedReaderState', 'restorePersistedReaderState', "'resource'", "'loaded'"], + readerLoadState: [ + "status !== 'failed' && status !== 'expired'", + 'retry with a new Bundle ID', + ], readerApp: [ "from './reader-flow.js'", 'pubky-locks-reader-demo.state', @@ -380,7 +404,7 @@ const required = { 'reader-submit-proof-started', 'pollPaymentLifecycle', "status === 'in_progress'", - "status === 'expired'", + 'paymentLifecycleTerminalError', 'reader-complete-verification-started', 'reader-complete-verification-conflict-looking-up', 'reader-issue-credential-started', @@ -1165,12 +1189,23 @@ assert.equal(classifyPaymentLifecycle({ status: 'failed' }), 'failed'); assert.equal(classifyPaymentLifecycle({ status: 'expired' }), 'failed'); assert.throws(() => classifyPaymentLifecycle({ status: 'unknown' }), /unknown lifecycle status/); +const lifecycleResponse = (status, overrides = {}) => { + const terminal = ['completed', 'failed', 'expired'].includes(status); + return { + status, + started_at: status === 'pending' ? null : '2026-09-29T12:00:01Z', + completed_at: terminal ? '2026-09-29T12:00:02Z' : null, + failure_message: status === 'failed' ? 'verification failed' : null, + terminal_reason: status === 'expired' ? 'payment_request_rejected' : null, + ...overrides, + }; +}; const documentedLifecycle = [ - { status: 'in_progress' }, - { status: 'pending' }, - { status: 'in_progress' }, - { status: 'pending' }, - { status: 'completed' }, + lifecycleResponse('in_progress'), + lifecycleResponse('pending'), + lifecycleResponse('in_progress'), + lifecycleResponse('pending'), + lifecycleResponse('completed'), ]; const documentedConnectionStates = ['none', 'handshake', 'connected', 'recovery_required', 'blocked']; const observedConnectionStates = []; @@ -1178,7 +1213,7 @@ const viewerCalls = []; const documentedViewer = { submitProofBundle: async (proof) => { viewerCalls.push(['submit', proof]); - return { status: 'pending' }; + return lifecycleResponse('pending'); }, lookupVerificationTask: async (handle) => { viewerCalls.push(['lookup', handle]); @@ -1215,6 +1250,8 @@ const documentedResult = await runPaykitViewerFlow({ await Promise.resolve(); assert.deepEqual(observedConnectionStates, ['none', 'handshake', 'connected', 'recovery_required', 'blocked']); assert.equal(documentedResult.lifecycle.status, 'completed'); +assert.equal(Object.hasOwn(documentedResult.lifecycle, 'credential'), false); +assert.equal(Object.hasOwn(documentedResult.lifecycle, 'task_id'), false); assert.equal(documentedResult.credentialExpiresAt, '2026-09-29T12:15:00Z'); assert.equal(await documentedResult.response.text(), 'unlocked'); assert.deepEqual(viewerCalls.at(0), ['submit', documentedProof]); @@ -1222,26 +1259,79 @@ assert.deepEqual(viewerCalls.at(-2), ['issue', documentedHandle]); assert.deepEqual(viewerCalls.at(-1), ['read', 'secret-access-credential', 'primary.txt']); assert.equal(JSON.stringify(viewerCalls.slice(0, -1)).includes('secret-access-credential'), false); -for (const status of ['failed', 'expired', 'future']) { +for (const terminalReason of [ + 'payment_request_rejected', + 'payment_request_canceled', + 'proposal_expired', + 'payment_deadline_expired', +]) { + const rejectionCalls = []; + const rejectionConnectionCalls = []; + await assert.rejects( + runPaykitViewerFlow({ + viewer: { + submitProofBundle: async () => { + rejectionCalls.push('submit'); + return lifecycleResponse('pending'); + }, + lookupVerificationTask: async () => { + rejectionCalls.push('lookup'); + return lifecycleResponse('expired', { terminal_reason: terminalReason }); + }, + lookupPaykitConnectionState: async () => { + rejectionConnectionCalls.push('connection'); + return { state: 'none' }; + }, + issueAccessCredential: async () => { rejectionCalls.push('issue'); }, + proxyReadGuardedResourceResponse: async () => { rejectionCalls.push('read'); }, + }, + handle: documentedHandle, + submittedProofBundle: documentedProof, + guardedPath: 'primary.txt', + wait: () => Promise.resolve(), + }), + new RegExp(`${terminalReason}.*fresh Bundle ID.*payment request`), + ); + assert.deepEqual(rejectionCalls, ['submit', 'lookup']); + assert.deepEqual(rejectionConnectionCalls, ['connection']); +} + +await assert.rejects( + runPaykitViewerFlow({ + viewer: { + submitProofBundle: async () => lifecycleResponse('failed'), + }, + handle: documentedHandle, + submittedProofBundle: documentedProof, + guardedPath: 'primary.txt', + }), + /verification failed.*fresh Bundle ID.*payment request/, +); + +for (const invalidLifecycle of [ + lifecycleResponse('expired', { terminal_reason: null }), + lifecycleResponse('failed', { terminal_reason: 'payment_request_rejected' }), + lifecycleResponse('expired', { terminal_reason: 'future_reason' }), + lifecycleResponse('future'), +]) { await assert.rejects( runPaykitViewerFlow({ viewer: { - submitProofBundle: async () => ({ status }), - lookupPaykitConnectionState: async () => ({ state: 'none' }), + submitProofBundle: async () => invalidLifecycle, }, handle: documentedHandle, submittedProofBundle: documentedProof, guardedPath: 'primary.txt', }), - status === 'future' ? /unknown verification status/ : new RegExp(`verification ${status}`), + /invalid verification lifecycle response|unknown verification status/, ); } let observedConnectionError; const connectionFailureResult = await runPaykitViewerFlow({ viewer: { - submitProofBundle: async () => ({ status: 'pending' }), - lookupVerificationTask: async () => ({ status: 'completed' }), + submitProofBundle: async () => lifecycleResponse('pending'), + lookupVerificationTask: async () => lifecycleResponse('completed'), lookupPaykitConnectionState: async () => { throw new Error('connection lookup unavailable'); }, issueAccessCredential: async () => ({ credential: 'secret-access-credential', @@ -1262,7 +1352,7 @@ assert.equal(observedConnectionError, 'connection lookup unavailable'); await assert.rejects( runPaykitViewerFlow({ viewer: { - submitProofBundle: async () => ({ status: 'pending' }), + submitProofBundle: async () => lifecycleResponse('pending'), lookupPaykitConnectionState: async () => ({ state: 'none' }), }, handle: documentedHandle, @@ -1277,7 +1367,7 @@ const resumedAfterExhaustion = await runPaykitViewerFlow({ viewer: { lookupVerificationTask: async (handle) => { resumedCalls.push(['lookup', handle]); - return { status: 'completed' }; + return lifecycleResponse('completed'); }, issueAccessCredential: async (handle) => { resumedCalls.push(['issue', handle]); diff --git a/locks-sdk/bindings/js/src/viewer.rs b/locks-sdk/bindings/js/src/viewer.rs index 71d0f2c..db8f5a3 100644 --- a/locks-sdk/bindings/js/src/viewer.rs +++ b/locks-sdk/bindings/js/src/viewer.rs @@ -808,11 +808,25 @@ mod tests { "submitted_at": "2026-06-01T12:00:00Z", "started_at": "2026-06-01T12:00:01Z", "completed_at": "2026-06-01T12:00:02Z", - "failure_message": null + "failure_message": null, + "terminal_reason": null })) .unwrap(); assert_eq!(lifecycle["status"], "completed"); + let expired = validate_lifecycle_response_for_tests(json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-06-01T12:00:00Z", + "started_at": "2026-06-01T12:00:01Z", + "completed_at": "2026-06-01T12:00:02Z", + "failure_message": null, + "terminal_reason": "payment_deadline_expired" + })) + .unwrap(); + assert_eq!(expired["terminal_reason"], "payment_deadline_expired"); + let credential = validate_access_credential_response_for_tests(json!({ "credential": "raw-access-credential", "expires_at": "2026-06-01T12:15:00Z" @@ -830,7 +844,8 @@ mod tests { "submitted_at": "2026-06-01T12:00:00Z", "started_at": null, "completed_at": null, - "failure_message": null + "failure_message": null, + "terminal_reason": null }); assert_eq!( validate_submit_proof_bundle_response_for_tests(response.clone()).unwrap(), diff --git a/locks-sdk/src/lib.rs b/locks-sdk/src/lib.rs index e8a4d33..f47555c 100644 --- a/locks-sdk/src/lib.rs +++ b/locks-sdk/src/lib.rs @@ -23,5 +23,6 @@ pub use session::LocksSession; pub use viewer::{ AccessCredentialResponse, PaykitConnectionState, PaykitConnectionStateResponse, ReadLockedResourceRequest, SdkViewerRequest, VerificationTaskHandleRequest, - VerificationTaskLifecycleResponse, VerificationTaskStatus, ViewerLocks, + VerificationTaskLifecycleResponse, VerificationTaskStatus, VerificationTerminalReason, + ViewerLocks, }; diff --git a/locks-sdk/src/viewer.rs b/locks-sdk/src/viewer.rs index df9797f..65bf3e1 100644 --- a/locks-sdk/src/viewer.rs +++ b/locks-sdk/src/viewer.rs @@ -44,6 +44,15 @@ pub enum VerificationTaskStatus { Expired, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum VerificationTerminalReason { + PaymentRequestRejected, + PaymentRequestCanceled, + ProposalExpired, + PaymentDeadlineExpired, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaykitConnectionState { @@ -67,6 +76,7 @@ pub struct VerificationTaskLifecycleResponse { #[serde(with = "time::serde::rfc3339::option")] pub completed_at: Option, pub failure_message: Option, + pub terminal_reason: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -152,13 +162,16 @@ impl ViewerLocks { } pub fn parse_lifecycle_response(value: Value) -> Result { - serde_json::from_value(value).map_err(|err| LocksSdkError::InvalidResponse(err.to_string())) + let response: VerificationTaskLifecycleResponse = serde_json::from_value(value) + .map_err(|err| LocksSdkError::InvalidResponse(err.to_string()))?; + validate_lifecycle_response(&response)?; + Ok(response) } pub fn parse_submit_proof_bundle_response( value: Value, ) -> Result { - serde_json::from_value(value).map_err(|err| LocksSdkError::InvalidResponse(err.to_string())) + Self::parse_lifecycle_response(value) } pub fn parse_paykit_connection_state_response( @@ -187,6 +200,51 @@ impl Default for ViewerLocks { } } +fn validate_lifecycle_response(response: &VerificationTaskLifecycleResponse) -> Result<()> { + let valid = match response.status { + VerificationTaskStatus::Pending => { + response.started_at.is_none() + && response.completed_at.is_none() + && response.failure_message.is_none() + && response.terminal_reason.is_none() + } + VerificationTaskStatus::InProgress => { + response.started_at.is_some() + && response.completed_at.is_none() + && response.failure_message.is_none() + && response.terminal_reason.is_none() + } + VerificationTaskStatus::Completed => { + response.started_at.is_some() + && response.completed_at.is_some() + && response.failure_message.is_none() + && response.terminal_reason.is_none() + } + VerificationTaskStatus::Failed => { + response.started_at.is_some() + && response.completed_at.is_some() + && response + .failure_message + .as_deref() + .is_some_and(|message| !message.trim().is_empty()) + && response.terminal_reason.is_none() + } + VerificationTaskStatus::Expired => { + response.started_at.is_some() + && response.completed_at.is_some() + && response.failure_message.is_none() + && response.terminal_reason.is_some() + } + }; + if valid { + Ok(()) + } else { + Err(LocksSdkError::InvalidResponse( + "invalid verification task lifecycle field combination".to_owned(), + )) + } +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -318,7 +376,8 @@ mod tests { "submitted_at": "2026-06-01T12:00:00Z", "started_at": "2026-06-01T12:00:01Z", "completed_at": "2026-06-01T12:00:02Z", - "failure_message": null + "failure_message": null, + "terminal_reason": null })) .unwrap(); @@ -328,6 +387,51 @@ mod tests { assert!(response.failure_message.is_none()); } + #[test] + fn lifecycle_response_parses_terminal_reason_and_rejects_invalid_status_tuple() { + let response = ViewerLocks::parse_lifecycle_response(json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-06-01T12:00:00Z", + "started_at": "2026-06-01T12:00:01Z", + "completed_at": "2026-06-01T12:00:02Z", + "failure_message": null, + "terminal_reason": "payment_request_rejected" + })) + .unwrap(); + + assert_eq!( + response.terminal_reason, + Some(VerificationTerminalReason::PaymentRequestRejected) + ); + + for invalid in [ + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-06-01T12:00:00Z", + "started_at": "2026-06-01T12:00:01Z", + "completed_at": "2026-06-01T12:00:02Z", + "failure_message": null, + "terminal_reason": null + }), + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "failed", + "submitted_at": "2026-06-01T12:00:00Z", + "started_at": "2026-06-01T12:00:01Z", + "completed_at": "2026-06-01T12:00:02Z", + "failure_message": "verification failed", + "terminal_reason": "payment_request_rejected" + }), + ] { + assert!(ViewerLocks::parse_lifecycle_response(invalid).is_err()); + } + } + #[test] fn paykit_connection_state_response_parses_closed_vocabulary() { for (wire, expected) in [ @@ -356,6 +460,7 @@ mod tests { "started_at": null, "completed_at": null, "failure_message": null, + "terminal_reason": null, "connection_state": "connected" })) .is_err() diff --git a/locks-sdk/tests/paykit_viewer_flow.rs b/locks-sdk/tests/paykit_viewer_flow.rs index 0f5029d..e341355 100644 --- a/locks-sdk/tests/paykit_viewer_flow.rs +++ b/locks-sdk/tests/paykit_viewer_flow.rs @@ -5,7 +5,8 @@ use locks_core::{ verification::SubmittedProofBundle, }; use locks_sdk::{ - PaykitConnectionState, VerificationTaskHandleRequest, VerificationTaskStatus, ViewerLocks, + PaykitConnectionState, VerificationTaskHandleRequest, VerificationTaskStatus, + VerificationTerminalReason, ViewerLocks, }; use serde_json::{Value, json}; @@ -109,6 +110,104 @@ fn public_api_parses_every_documented_lifecycle_and_connection_state() { ); } +#[test] +fn public_api_parses_every_terminal_reason_and_valid_terminal_tuple() { + for (wire, expected) in [ + ( + "payment_request_rejected", + VerificationTerminalReason::PaymentRequestRejected, + ), + ( + "payment_request_canceled", + VerificationTerminalReason::PaymentRequestCanceled, + ), + ( + "proposal_expired", + VerificationTerminalReason::ProposalExpired, + ), + ( + "payment_deadline_expired", + VerificationTerminalReason::PaymentDeadlineExpired, + ), + ] { + let response = ViewerLocks::parse_lifecycle_response(json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-09-29T12:00:00Z", + "started_at": "2026-09-29T12:00:01Z", + "completed_at": "2026-09-29T12:00:02Z", + "failure_message": null, + "terminal_reason": wire + })) + .unwrap(); + assert_eq!(response.terminal_reason, Some(expected)); + } + + let completed = ViewerLocks::parse_lifecycle_response(lifecycle_json("completed")).unwrap(); + assert_eq!(completed.status, VerificationTaskStatus::Completed); + assert!(completed.failure_message.is_none()); + assert!(completed.terminal_reason.is_none()); + + let failed = ViewerLocks::parse_lifecycle_response(lifecycle_json("failed")).unwrap(); + assert_eq!(failed.status, VerificationTaskStatus::Failed); + assert_eq!( + failed.failure_message.as_deref(), + Some("verification failed") + ); + assert!(failed.terminal_reason.is_none()); +} + +#[test] +fn public_api_rejects_invalid_terminal_tuples_and_private_fields() { + for invalid in [ + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-09-29T12:00:00Z", + "started_at": "2026-09-29T12:00:01Z", + "completed_at": "2026-09-29T12:00:02Z", + "failure_message": null, + "terminal_reason": null + }), + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "failed", + "submitted_at": "2026-09-29T12:00:00Z", + "started_at": "2026-09-29T12:00:01Z", + "completed_at": "2026-09-29T12:00:02Z", + "failure_message": "verification failed", + "terminal_reason": "payment_request_rejected" + }), + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "expired", + "submitted_at": "2026-09-29T12:00:00Z", + "started_at": "2026-09-29T12:00:01Z", + "completed_at": "2026-09-29T12:00:02Z", + "failure_message": null, + "terminal_reason": "future_reason" + }), + json!({ + "creator": CREATOR, + "bundle_id": BUNDLE_ID, + "status": "pending", + "submitted_at": "2026-09-29T12:00:00Z", + "started_at": null, + "completed_at": null, + "failure_message": null, + "terminal_reason": null, + "task_id": "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d10", + "credential": "secret-access-credential" + }), + ] { + assert!(ViewerLocks::parse_lifecycle_response(invalid).is_err()); + } +} + #[test] fn public_api_places_access_credential_only_in_bearer_header() { let viewer = ViewerLocks::new(); @@ -138,13 +237,42 @@ fn handle() -> VerificationTaskHandleRequest { } fn lifecycle_json(status: &str) -> Value { + let (started_at, completed_at, failure_message, terminal_reason) = match status { + "pending" => (Value::Null, Value::Null, Value::Null, Value::Null), + "in_progress" => ( + json!("2026-09-29T12:00:01Z"), + Value::Null, + Value::Null, + Value::Null, + ), + "completed" => ( + json!("2026-09-29T12:00:01Z"), + json!("2026-09-29T12:00:02Z"), + Value::Null, + Value::Null, + ), + "failed" => ( + json!("2026-09-29T12:00:01Z"), + json!("2026-09-29T12:00:02Z"), + json!("verification failed"), + Value::Null, + ), + "expired" => ( + json!("2026-09-29T12:00:01Z"), + json!("2026-09-29T12:00:02Z"), + Value::Null, + json!("payment_request_rejected"), + ), + _ => (Value::Null, Value::Null, Value::Null, Value::Null), + }; json!({ "creator": CREATOR, "bundle_id": BUNDLE_ID, "status": status, "submitted_at": "2026-09-29T12:00:00Z", - "started_at": null, - "completed_at": null, - "failure_message": null + "started_at": started_at, + "completed_at": completed_at, + "failure_message": failure_message, + "terminal_reason": terminal_reason }) } diff --git a/locks-server/src/api/dtos.rs b/locks-server/src/api/dtos.rs index c3efad0..5f6215e 100644 --- a/locks-server/src/api/dtos.rs +++ b/locks-server/src/api/dtos.rs @@ -7,7 +7,7 @@ use locks_core::lock_policy::{ContentLock, GuardedResource}; use locks_core::lock_service_pointer::LockServicePointer; use locks_core::verification::SubmittedProofBundle; use locks_service::application::models::{ - AccessCredential, FrontendSessionCode, VerificationTaskStatus, + AccessCredential, FrontendSessionCode, VerificationTaskStatus, VerificationTerminalReason, }; use locks_service::application::use_cases::exchange_frontend_session_code::{ ExchangeFrontendSessionCodeRequest, ExchangeFrontendSessionCodeResponse, @@ -89,6 +89,8 @@ pub struct VerificationTaskLifecycleHttpResponse { #[serde(with = "time::serde::rfc3339::option")] pub completed_at: Option, pub failure_message: Option, + #[serde(serialize_with = "serialize_optional_terminal_reason")] + pub terminal_reason: Option, } impl From for VerificationTaskLifecycleHttpResponse { @@ -101,6 +103,7 @@ impl From for VerificationTaskLifecycleHttpRespon started_at: view.started_at, completed_at: view.completed_at, failure_message: view.failure_message, + terminal_reason: view.terminal_reason, } } } @@ -203,13 +206,28 @@ where { serializer.serialize_str(match status { VerificationTaskStatus::Pending => "pending", - VerificationTaskStatus::InProgress => "in_progress", + VerificationTaskStatus::InProgress | VerificationTaskStatus::PublishingEntitlement => { + "in_progress" + } VerificationTaskStatus::Completed => "completed", VerificationTaskStatus::Failed => "failed", VerificationTaskStatus::Expired => "expired", }) } +fn serialize_optional_terminal_reason( + reason: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match reason { + Some(reason) => serializer.serialize_some(reason.as_str()), + None => serializer.serialize_none(), + } +} + fn serialize_access_credential( credential: &AccessCredential, serializer: S, @@ -273,6 +291,7 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, }); let json = serde_json::to_value(response).unwrap(); @@ -308,6 +327,7 @@ mod tests { started_at: Some(datetime!(2026-05-29 12:01:00 UTC)), completed_at: Some(datetime!(2026-05-29 12:02:00 UTC)), failure_message: None, + terminal_reason: None, }); let json = serde_json::to_value(response).unwrap(); @@ -324,6 +344,31 @@ mod tests { assert_no_keys(&json, &["task_id", "credential", "credential_issuance"]); } + #[test] + fn expired_verification_task_response_serializes_typed_terminal_reason() { + let response = VerificationTaskLifecycleHttpResponse::from(VerificationTaskLifecycleView { + creator: CreatorPubky::from_str( + "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + ) + .unwrap(), + bundle_id: BundleId::from_str(BUNDLE_ID).unwrap(), + status: VerificationTaskStatus::Expired, + submitted_at: datetime!(2026-05-29 12:00:00 UTC), + started_at: Some(datetime!(2026-05-29 12:01:00 UTC)), + completed_at: Some(datetime!(2026-05-29 12:02:00 UTC)), + failure_message: None, + terminal_reason: Some( + locks_service::application::models::VerificationTerminalReason::ProposalExpired, + ), + }); + + let json = serde_json::to_value(response).unwrap(); + + assert_eq!(json["status"], "expired"); + assert_eq!(json["terminal_reason"], "proposal_expired"); + assert_eq!(json["failure_message"], Value::Null); + } + #[test] fn verification_task_handle_request_accepts_creator_and_bundle_id_only() { let request: VerificationTaskHandleHttpRequest = serde_json::from_value(json!({ diff --git a/locks-server/src/api/errors.rs b/locks-server/src/api/errors.rs index 4ad70bc..eb1b231 100644 --- a/locks-server/src/api/errors.rs +++ b/locks-server/src/api/errors.rs @@ -225,6 +225,13 @@ impl From for ApiError { ApiErrorCode::TaskStateConflict, "verification task state conflict", ), + ApplicationError::PaykitPaymentStatusConflict => Self::new( + ApiErrorCode::TaskStateConflict, + "paykit payment status conflict", + ), + ApplicationError::PaykitPaymentStatusInvalidResponse => { + Self::new(ApiErrorCode::InternalError, "internal server error") + } ApplicationError::UnsupportedVerifierType { .. } => Self::new( ApiErrorCode::UnsupportedVerifierType, "unsupported verifier type", @@ -577,4 +584,14 @@ mod tests { assert_eq!(api_error.status_code(), StatusCode::CONFLICT); assert_eq!(api_error.error_response().error.code, "task_state_conflict"); } + + #[test] + fn paykit_payment_status_conflict_remains_operator_visible() { + let api_error = ApiError::from(ApplicationError::PaykitPaymentStatusConflict); + + assert_eq!(api_error.status_code(), StatusCode::CONFLICT); + let response = api_error.error_response(); + assert_eq!(response.error.code, "task_state_conflict"); + assert_eq!(response.error.message, "paykit payment status conflict"); + } } diff --git a/locks-server/src/paykit_http_client.rs b/locks-server/src/paykit_http_client.rs index 48548b7..726ae6a 100644 --- a/locks-server/src/paykit_http_client.rs +++ b/locks-server/src/paykit_http_client.rs @@ -5,8 +5,9 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use locks_core::ids::{BundleId, CreatorPubky}; use locks_service::infrastructure::verifiers::paykit_payment::{ - PaykitPaymentStatus, PaykitPaymentStatusClient, PaykitPaymentStatusError, - PaykitPaymentStatusKind, + PaykitPaymentRequestState as ServicePaymentRequestState, + PaykitPaymentState as ServicePaymentState, PaykitPaymentStatus, PaykitPaymentStatusClient, + PaykitPaymentStatusError, }; use pubky_common::crypto::Keypair; use reqwest::StatusCode; @@ -23,6 +24,7 @@ const SIGNATURE_HEADER: &str = "X-Paykit-Signature"; const SIGNATURE_DOMAIN: &[u8] = b"paykit-http-signature-v1\0"; const INVOICE_BODY_LIMIT: usize = 1_024; const CONNECTION_STATUS_BODY_LIMIT: usize = 1_024; +const PAYMENT_STATUS_BODY_LIMIT: usize = 2_048; #[derive(Debug, thiserror::Error)] pub enum PaykitClientError { @@ -59,6 +61,14 @@ pub enum PaykitClientError { ConnectionStatusBodyTooLarge, #[error("Paykit status response was invalid: {0}")] InvalidStatusResponse(reqwest::Error), + #[error("Paykit payment-status response body was invalid: {0}")] + InvalidPaymentStatusResponse(reqwest::Error), + #[error("Paykit payment-status response JSON was invalid: {0}")] + InvalidPaymentStatusJson(serde_json::Error), + #[error("Paykit payment-status response exceeded the allowed size")] + StatusBodyTooLarge, + #[error("Paykit payment-status timestamps were invalid")] + InvalidStatusTimestamps, } impl PaykitClientError { @@ -67,6 +77,7 @@ impl PaykitClientError { Self::Http(source) | Self::InvalidInvoiceResponse(source) | Self::InvalidConnectionStatusResponse(source) + | Self::InvalidPaymentStatusResponse(source) | Self::InvalidStatusResponse(source) => source.is_timeout(), Self::InvalidServerUrl | Self::SigningSeedRead(_) @@ -78,6 +89,9 @@ impl PaykitClientError { | Self::InvoiceBodyTooLarge | Self::InvalidConnectionStatusJson(_) | Self::ConnectionStatusBodyTooLarge + | Self::InvalidPaymentStatusJson(_) + | Self::StatusBodyTooLarge + | Self::InvalidStatusTimestamps | Self::NonSuccess { .. } => false, } } @@ -168,16 +182,35 @@ pub trait PaykitSetupStatusProvider: Send + Sync { } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PaykitTransactionStatusKind { +#[serde(rename_all = "snake_case")] +pub enum PaykitPaymentRequestState { + Proposed, + ProposalExpired, + Accepted, + Rejected, + Canceled, + ProofSubmitted, + ActiveRecurring, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaykitPaymentState { Undetected, Detected, Confirmed, + Expired, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct PaykitTransactionStatus { - pub status: PaykitTransactionStatusKind, +#[serde(deny_unknown_fields)] +pub struct PaykitPaymentRequestStatus { + pub request_state: PaykitPaymentRequestState, + pub payment_state: PaykitPaymentState, + #[serde(with = "time::serde::rfc3339")] + pub invoice_created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub payment_deadline: OffsetDateTime, pub confirmations: u32, pub amount_matched: bool, } @@ -287,23 +320,20 @@ impl PaykitHttpClient { parse_connection_status_response(response).await } - pub async fn transaction_status( + pub async fn payment_request_status( &self, request: &PaykitStatusRequest, - ) -> Result { - let response = self.signed_post("transactions/status", request).await?; + ) -> Result { + let response = self.signed_post("payment-requests/status", request).await?; - if !response.status().is_success() { + if response.status() != StatusCode::OK { return Err(PaykitClientError::NonSuccess { - operation: "transaction status", + operation: "payment request status", status: response.status(), }); } - response - .json::() - .await - .map_err(PaykitClientError::InvalidStatusResponse) + parse_payment_request_status_response(response).await } pub async fn setup_status( @@ -397,14 +427,44 @@ async fn parse_connection_status_response( serde_json::from_slice(&body).map_err(PaykitClientError::InvalidConnectionStatusJson) } +async fn parse_payment_request_status_response( + mut response: reqwest::Response, +) -> Result { + if response + .content_length() + .is_some_and(|length| length > PAYMENT_STATUS_BODY_LIMIT as u64) + { + return Err(PaykitClientError::StatusBodyTooLarge); + } + + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(PaykitClientError::InvalidPaymentStatusResponse)? + { + if body.len().saturating_add(chunk.len()) > PAYMENT_STATUS_BODY_LIMIT { + return Err(PaykitClientError::StatusBodyTooLarge); + } + body.extend_from_slice(&chunk); + } + + let status: PaykitPaymentRequestStatus = + serde_json::from_slice(&body).map_err(PaykitClientError::InvalidPaymentStatusJson)?; + if status.payment_deadline <= status.invoice_created_at { + return Err(PaykitClientError::InvalidStatusTimestamps); + } + Ok(status) +} + #[async_trait] impl PaykitPaymentStatusClient for PaykitHttpClient { - async fn transaction_status( + async fn payment_request_status( &self, creator: &CreatorPubky, bundle_id: &BundleId, ) -> Result { - let status = PaykitHttpClient::transaction_status( + let status = PaykitHttpClient::payment_request_status( self, &PaykitStatusRequest { creator: creator.to_string(), @@ -412,13 +472,46 @@ impl PaykitPaymentStatusClient for PaykitHttpClient { }, ) .await - .map_err(|_| PaykitPaymentStatusError)?; + .map_err(|error| match error { + PaykitClientError::NonSuccess { + status: StatusCode::CONFLICT, + .. + } => PaykitPaymentStatusError::Conflict, + PaykitClientError::InvalidStatusResponse(_) => { + PaykitPaymentStatusError::InvalidResponse + } + PaykitClientError::InvalidPaymentStatusResponse(_) => { + PaykitPaymentStatusError::Unavailable + } + PaykitClientError::InvalidPaymentStatusJson(_) + | PaykitClientError::StatusBodyTooLarge + | PaykitClientError::InvalidStatusTimestamps => { + PaykitPaymentStatusError::InvalidResponse + } + _ => PaykitPaymentStatusError::Unavailable, + })?; Ok(PaykitPaymentStatus { - status: match status.status { - PaykitTransactionStatusKind::Undetected => PaykitPaymentStatusKind::Undetected, - PaykitTransactionStatusKind::Detected => PaykitPaymentStatusKind::Detected, - PaykitTransactionStatusKind::Confirmed => PaykitPaymentStatusKind::Confirmed, + request_state: match status.request_state { + PaykitPaymentRequestState::Proposed => ServicePaymentRequestState::Proposed, + PaykitPaymentRequestState::ProposalExpired => { + ServicePaymentRequestState::ProposalExpired + } + PaykitPaymentRequestState::Accepted => ServicePaymentRequestState::Accepted, + PaykitPaymentRequestState::Rejected => ServicePaymentRequestState::Rejected, + PaykitPaymentRequestState::Canceled => ServicePaymentRequestState::Canceled, + PaykitPaymentRequestState::ProofSubmitted => { + ServicePaymentRequestState::ProofSubmitted + } + PaykitPaymentRequestState::ActiveRecurring => { + ServicePaymentRequestState::ActiveRecurring + } + }, + payment_state: match status.payment_state { + PaykitPaymentState::Undetected => ServicePaymentState::Undetected, + PaykitPaymentState::Detected => ServicePaymentState::Detected, + PaykitPaymentState::Confirmed => ServicePaymentState::Confirmed, + PaykitPaymentState::Expired => ServicePaymentState::Expired, }, confirmations: status.confirmations, amount_matched: status.amount_matched, @@ -520,6 +613,7 @@ mod tests { use locks_core::ids::LockServerPubky; use serde_json::json; use tempfile::tempdir; + use time::macros::datetime; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; @@ -572,8 +666,8 @@ mod tests { "https://paykit.example/invoices" ); assert_eq!( - client.endpoint("transactions/status").as_str(), - "https://paykit.example/transactions/status" + client.endpoint("payment-requests/status").as_str(), + "https://paykit.example/payment-requests/status" ); assert_eq!( client.endpoint("connections/status").as_str(), @@ -814,7 +908,7 @@ mod tests { } #[tokio::test] - async fn transaction_status_posts_signed_composite_identity_and_parses_status_response() { + async fn payment_request_status_posts_signed_identity_and_parses_closed_projection() { let captured = CapturedRequests::default(); let server_url = spawn_test_server(captured.clone()).await; let keypair = Keypair::from_secret(&[9_u8; 32]); @@ -827,20 +921,26 @@ mod tests { }; let expected_body = canonical_body_bytes(&status_request).unwrap(); let expected_signature = - sign_request(&keypair, "POST", "/transactions/status", &expected_body); + sign_request(&keypair, "POST", "/payment-requests/status", &expected_body); - let status = client.transaction_status(&status_request).await.unwrap(); + let status = client + .payment_request_status(&status_request) + .await + .unwrap(); assert_eq!( status, - PaykitTransactionStatus { - status: PaykitTransactionStatusKind::Detected, + PaykitPaymentRequestStatus { + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Detected, + invoice_created_at: datetime!(2026-09-25 11:00:00 UTC), + payment_deadline: datetime!(2026-09-25 12:00:00 UTC), confirmations: 0, amount_matched: true, } ); let request = captured.single(); - assert_eq!(request.path, "/transactions/status"); + assert_eq!(request.path, "/payment-requests/status"); assert_eq!( String::from_utf8(request.body).unwrap(), format!("{{\"bundle_id\":\"{BUNDLE_ID}\",\"creator\":\"{CREATOR}\"}}") @@ -864,7 +964,7 @@ mod tests { } #[tokio::test] - async fn transaction_status_times_out_when_paykit_does_not_respond() { + async fn payment_request_status_times_out_when_paykit_does_not_respond() { let server_url = spawn_hanging_test_server().await; let client = PaykitHttpClient::from_parts( &server_url, @@ -874,7 +974,7 @@ mod tests { .unwrap(); let error = client - .transaction_status(&PaykitStatusRequest { + .payment_request_status(&PaykitStatusRequest { creator: CREATOR.to_owned(), bundle_id: BUNDLE_ID.to_owned(), }) @@ -885,12 +985,50 @@ mod tests { } #[tokio::test] - async fn status_not_found_and_invalid_success_body_use_retryable_client_error() { - for (status, body) in [ - (axum::http::StatusCode::NOT_FOUND, "not found"), - (axum::http::StatusCode::OK, "not-json"), + async fn payment_request_status_response_body_timeout_is_retryable() { + let server_url = spawn_stalled_payment_status_body().await; + let client = PaykitHttpClient::from_parts( + &server_url, + bounded_http_client(Duration::from_secs(1), Duration::from_millis(500)).unwrap(), + Keypair::from_secret(&[9_u8; 32]), + ) + .unwrap(); + + assert_eq!( + PaykitPaymentStatusClient::payment_request_status( + &client, + &CreatorPubky::from_str(CREATOR).unwrap(), + &BundleId::from_str(BUNDLE_ID).unwrap(), + ) + .await, + Err(PaykitPaymentStatusError::Unavailable) + ); + } + + #[tokio::test] + async fn status_not_found_is_retryable_but_invalid_success_bodies_fail_closed() { + let server_url = spawn_configured_status_server(StatusCode::NOT_FOUND, "not found").await; + let client = PaykitHttpClient::from_parts( + &server_url, + reqwest::Client::new(), + Keypair::from_secret(&[9_u8; 32]), + ) + .unwrap(); + assert_eq!( + PaykitPaymentStatusClient::payment_request_status( + &client, + &CreatorPubky::from_str(CREATOR).unwrap(), + &BundleId::from_str(BUNDLE_ID).unwrap(), + ) + .await, + Err(PaykitPaymentStatusError::Unavailable) + ); + + for body in [ + "not-json", + "{\"request_state\":\"future_state\",\"payment_state\":\"undetected\",\"invoice_created_at\":\"2026-09-25T11:00:00Z\",\"payment_deadline\":\"2026-09-25T12:00:00Z\",\"confirmations\":0,\"amount_matched\":false}", ] { - let server_url = spawn_configured_status_server(status, body).await; + let server_url = spawn_configured_status_server(StatusCode::OK, body).await; let client = PaykitHttpClient::from_parts( &server_url, reqwest::Client::new(), @@ -898,7 +1036,7 @@ mod tests { ) .unwrap(); - let error = PaykitPaymentStatusClient::transaction_status( + let error = PaykitPaymentStatusClient::payment_request_status( &client, &CreatorPubky::from_str(CREATOR).unwrap(), &BundleId::from_str(BUNDLE_ID).unwrap(), @@ -906,7 +1044,59 @@ mod tests { .await .unwrap_err(); - assert_eq!(error, PaykitPaymentStatusError); + assert_eq!(error, PaykitPaymentStatusError::InvalidResponse); + } + } + + #[tokio::test] + async fn payment_request_status_rejects_equal_or_reversed_timestamps() { + for payment_deadline in ["2026-09-25T11:00:00Z", "2026-09-25T10:59:59Z"] { + let body = Box::leak( + format!( + "{{\"request_state\":\"accepted\",\"payment_state\":\"undetected\",\"invoice_created_at\":\"2026-09-25T11:00:00Z\",\"payment_deadline\":\"{payment_deadline}\",\"confirmations\":0,\"amount_matched\":false}}" + ) + .into_boxed_str(), + ); + let server_url = spawn_configured_status_server(StatusCode::OK, body).await; + let client = PaykitHttpClient::from_parts( + &server_url, + reqwest::Client::new(), + Keypair::from_secret(&[9_u8; 32]), + ) + .unwrap(); + + assert_eq!( + PaykitPaymentStatusClient::payment_request_status( + &client, + &CreatorPubky::from_str(CREATOR).unwrap(), + &BundleId::from_str(BUNDLE_ID).unwrap(), + ) + .await, + Err(PaykitPaymentStatusError::InvalidResponse) + ); + } + } + + #[tokio::test] + async fn payment_request_status_rejects_oversized_declared_and_chunked_bodies() { + for chunked in [false, true] { + let server_url = spawn_oversized_body(chunked, PAYMENT_STATUS_BODY_LIMIT).await; + let client = PaykitHttpClient::from_parts( + &server_url, + reqwest::Client::new(), + Keypair::from_secret(&[9_u8; 32]), + ) + .unwrap(); + + assert_eq!( + PaykitPaymentStatusClient::payment_request_status( + &client, + &CreatorPubky::from_str(CREATOR).unwrap(), + &BundleId::from_str(BUNDLE_ID).unwrap(), + ) + .await, + Err(PaykitPaymentStatusError::InvalidResponse) + ); } } @@ -943,7 +1133,7 @@ mod tests { async fn spawn_test_server(captured: CapturedRequests) -> String { let app = Router::new() .route("/invoices", post(capture_invoice)) - .route("/transactions/status", post(capture_status)) + .route("/payment-requests/status", post(capture_status)) .with_state(captured); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -956,7 +1146,7 @@ mod tests { async fn spawn_hanging_test_server() -> String { let app = Router::new() .route("/invoices", post(hang)) - .route("/transactions/status", post(hang)); + .route("/payment-requests/status", post(hang)); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { @@ -997,6 +1187,22 @@ mod tests { format!("http://{address}") } + async fn spawn_stalled_payment_status_body() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 200\r\n\r\n{\"request_state\":\"accepted\",", + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; + }); + format!("http://{address}") + } + async fn assert_oversized_connection_status_body_is_rejected(chunked: bool) { let server_url = spawn_oversized_body(chunked, CONNECTION_STATUS_BODY_LIMIT).await; let client = PaykitHttpClient::from_parts( @@ -1131,7 +1337,7 @@ mod tests { body: &'static str, ) -> String { let app = Router::new() - .route("/transactions/status", post(configured_status)) + .route("/payment-requests/status", post(configured_status)) .with_state(ConfiguredStatusResponse { status, body }); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -1207,14 +1413,17 @@ mod tests { body: Bytes, ) -> impl IntoResponse { captured.push(CapturedRequest { - path: "/transactions/status".to_owned(), + path: "/payment-requests/status".to_owned(), signature: headers .get(SIGNATURE_HEADER) .map(|value| value.to_str().unwrap().to_owned()), body: body.to_vec(), }); Json(json!({ - "status": "detected", + "request_state": "accepted", + "payment_state": "detected", + "invoice_created_at": "2026-09-25T11:00:00Z", + "payment_deadline": "2026-09-25T12:00:00Z", "confirmations": 0, "amount_matched": true, })) diff --git a/locks-server/src/worker.rs b/locks-server/src/worker.rs index ceafd33..d92ee02 100644 --- a/locks-server/src/worker.rs +++ b/locks-server/src/worker.rs @@ -138,7 +138,17 @@ impl<'a> VerificationWorker<'a> { info!(%task_id, status = ?completed.status, "completed verification task"); Ok(WorkerTick::Completed(task_id)) } - Err(ApplicationError::VerificationPending) => { + Err( + error @ (ApplicationError::VerificationPending + | ApplicationError::PaykitPaymentStatusConflict), + ) => { + if matches!(error, ApplicationError::PaykitPaymentStatusConflict) { + error!( + %task_id, + worker_id = %self.worker_id, + "paykit payment status conflict; scheduling retry" + ); + } let retry_scheduled_at = self.clock.now(); let next_attempt_at = retry_scheduled_at + retry_delay(); let Some(_) = self @@ -232,12 +242,11 @@ mod tests { AccessPolicy, CONTENT_LOCK_VERSION, ContentLock, Criterion, GuardedResource, LockLogic, LockServerConfig, VerifierType, }; - use locks_core::verification::{ - CriterionVerificationResult, Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle, - }; + use locks_core::verification::{Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle}; use locks_service::application::errors::ApplicationError; use locks_service::application::models::{ - CriterionVerificationRequest, VerificationTaskRecord, VerificationTaskStatus, + CriterionVerificationOutcome, CriterionVerificationRequest, VerificationTaskRecord, + VerificationTaskStatus, }; use locks_service::application::ports::{ ContentLockRepository, CriterionVerifier, EntitlementRepository, VerificationTaskRepository, @@ -334,6 +343,68 @@ mod tests { assert_eq!(worker.run_once().await.unwrap(), WorkerTick::Idle); } + #[tokio::test] + async fn worker_schedules_operator_visible_paykit_conflict_without_terminalizing() { + let fixture = WorkerFixture::new(content_lock(true)).await; + fixture.seed_task().await; + let verifier = ConflictVerifier; + let worker = fixture.worker_with_verifier(&verifier); + + assert_eq!( + worker.run_once().await.unwrap(), + WorkerTick::RetryScheduled(task_id()) + ); + let stored = fixture + .tasks + .get_verification_task(&task_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, VerificationTaskStatus::Pending); + assert_eq!(stored.completed_at, None); + assert_eq!(stored.failure_message, None); + assert!( + fixture + .entitlements + .get_verified_proof_bundle(&creator(), &bundle_id()) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn worker_fails_closed_on_invalid_paykit_status_response() { + let fixture = WorkerFixture::new(content_lock(true)).await; + fixture.seed_task().await; + let verifier = InvalidPaykitStatusVerifier; + let worker = fixture.worker_with_verifier(&verifier); + + assert_eq!( + worker.run_once().await.unwrap(), + WorkerTick::Failed(task_id()) + ); + let stored = fixture + .tasks + .get_verification_task(&task_id()) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, VerificationTaskStatus::Failed); + assert_eq!( + stored.failure_message, + Some("verification failed".to_owned()) + ); + assert!( + fixture + .entitlements + .get_verified_proof_bundle(&creator(), &bundle_id()) + .await + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn worker_without_dev_static_registration_fails_dev_static_tasks() { let fixture = WorkerFixture::new(content_lock(true)).await; @@ -534,14 +605,38 @@ mod tests { async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result { + ) -> Result { if !self.returned_pending.swap(true, Ordering::SeqCst) { - return Err(ApplicationError::VerificationPending); + return Ok(CriterionVerificationOutcome::Pending); } DevStaticVerifier.verify(request).await } } + struct ConflictVerifier; + + #[async_trait] + impl CriterionVerifier for ConflictVerifier { + async fn verify( + &self, + _request: CriterionVerificationRequest, + ) -> Result { + Err(ApplicationError::PaykitPaymentStatusConflict) + } + } + + struct InvalidPaykitStatusVerifier; + + #[async_trait] + impl CriterionVerifier for InvalidPaykitStatusVerifier { + async fn verify( + &self, + _request: CriterionVerificationRequest, + ) -> Result { + Err(ApplicationError::PaykitPaymentStatusInvalidResponse) + } + } + fn task_for(content_lock: &ContentLock, payload: serde_json::Value) -> VerificationTaskRecord { VerificationTaskRecord { task_id: task_id(), @@ -552,6 +647,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } diff --git a/locks-service/migrations/0011_verification_terminal_reason.sql b/locks-service/migrations/0011_verification_terminal_reason.sql new file mode 100644 index 0000000..1e669bf --- /dev/null +++ b/locks-service/migrations/0011_verification_terminal_reason.sql @@ -0,0 +1,21 @@ +-- Staging-only lifecycle pivot: existing verification attempts use the retired +-- expired-without-reason contract and cannot be represented safely. +TRUNCATE TABLE verification_tasks; + +ALTER TABLE verification_tasks + ADD COLUMN terminal_reason TEXT; + +ALTER TABLE verification_tasks + ADD CONSTRAINT verification_tasks_terminal_reason_check CHECK ( + (status = 'expired' + AND terminal_reason IN ( + 'payment_request_rejected', + 'payment_request_canceled', + 'proposal_expired', + 'payment_deadline_expired' + ) + AND completed_at IS NOT NULL + AND failure_message IS NULL) + OR + (status <> 'expired' AND terminal_reason IS NULL) + ); diff --git a/locks-service/migrations/0012_entitlement_publication_intent.sql b/locks-service/migrations/0012_entitlement_publication_intent.sql new file mode 100644 index 0000000..ef67acc --- /dev/null +++ b/locks-service/migrations/0012_entitlement_publication_intent.sql @@ -0,0 +1,98 @@ +ALTER TABLE verification_tasks + ADD COLUMN entitlement_to_publish JSONB; + +ALTER TABLE verification_tasks + DROP CONSTRAINT verification_tasks_status_check; + +ALTER TABLE verification_tasks + DROP CONSTRAINT verification_tasks_terminal_reason_check; + +ALTER TABLE verification_tasks + ADD CONSTRAINT verification_tasks_status_check CHECK ( + status IN ( + 'pending', + 'in_progress', + 'publishing_entitlement', + 'completed', + 'failed', + 'expired' + ) + ); + +ALTER TABLE verification_tasks + ADD CONSTRAINT verification_tasks_state_check CHECK ( + CASE status + WHEN 'pending' THEN + started_at IS NULL + AND completed_at IS NULL + AND failure_message IS NULL + AND terminal_reason IS NULL + AND entitlement_to_publish IS NULL + AND claimed_by IS NULL + AND claim_token IS NULL + AND claim_expires_at IS NULL + WHEN 'in_progress' THEN + started_at IS NOT NULL + AND completed_at IS NULL + AND failure_message IS NULL + AND terminal_reason IS NULL + AND entitlement_to_publish IS NULL + AND ( + (claimed_by IS NULL AND claim_token IS NULL AND claim_expires_at IS NULL) + OR + (claimed_by IS NOT NULL AND claim_token IS NOT NULL AND claim_expires_at IS NOT NULL) + ) + WHEN 'publishing_entitlement' THEN + started_at IS NOT NULL + AND completed_at IS NULL + AND failure_message IS NULL + AND terminal_reason IS NULL + AND entitlement_to_publish IS NOT NULL + AND ( + (claimed_by IS NULL AND claim_token IS NULL AND claim_expires_at IS NULL) + OR + (claimed_by IS NOT NULL AND claim_token IS NOT NULL AND claim_expires_at IS NOT NULL) + ) + WHEN 'completed' THEN + started_at IS NOT NULL + AND completed_at IS NOT NULL + AND failure_message IS NULL + AND terminal_reason IS NULL + AND entitlement_to_publish IS NOT NULL + AND claimed_by IS NULL + AND claim_token IS NULL + AND claim_expires_at IS NULL + WHEN 'failed' THEN + started_at IS NOT NULL + AND completed_at IS NOT NULL + AND NULLIF(BTRIM(failure_message), '') IS NOT NULL + AND terminal_reason IS NULL + AND entitlement_to_publish IS NULL + AND claimed_by IS NULL + AND claim_token IS NULL + AND claim_expires_at IS NULL + WHEN 'expired' THEN + started_at IS NOT NULL + AND completed_at IS NOT NULL + AND failure_message IS NULL + AND terminal_reason IS NOT NULL + AND terminal_reason IN ( + 'payment_request_rejected', + 'payment_request_canceled', + 'proposal_expired', + 'payment_deadline_expired' + ) + AND entitlement_to_publish IS NULL + AND claimed_by IS NULL + AND claim_token IS NULL + AND claim_expires_at IS NULL + ELSE FALSE + END + ); + +DROP INDEX verification_tasks_expired_claim_idx; + +CREATE INDEX verification_tasks_expired_claim_idx +ON verification_tasks (claim_expires_at) +WHERE status IN ('in_progress', 'publishing_entitlement') + AND claim_expires_at IS NOT NULL; diff --git a/locks-service/src/application/errors.rs b/locks-service/src/application/errors.rs index 748fcb2..9494c84 100644 --- a/locks-service/src/application/errors.rs +++ b/locks-service/src/application/errors.rs @@ -33,6 +33,12 @@ pub enum ApplicationError { /// Criterion verifier is not terminal yet and should be retried later. #[error("verification pending")] VerificationPending, + /// Paykit reported a durable payment-status identity conflict requiring operator attention. + #[error("paykit payment status conflict")] + PaykitPaymentStatusConflict, + /// Paykit returned a malformed or unsupported payment-status response. + #[error("invalid paykit payment status response")] + PaykitPaymentStatusInvalidResponse, /// Submitted payment proof does not match its canonical content lock criterion. #[error("invalid paykit payment submission")] InvalidPaykitPaymentSubmission, diff --git a/locks-service/src/application/models/mod.rs b/locks-service/src/application/models/mod.rs index b820487..3822f93 100644 --- a/locks-service/src/application/models/mod.rs +++ b/locks-service/src/application/models/mod.rs @@ -17,9 +17,13 @@ mod tests { use serde_json::json; use time::macros::datetime; - use locks_core::ids::{BundleId, CreatorPubky, PubkyLockResource, TaskId}; + use locks_core::ids::{BundleId, CreatorPubky, LockServerPubky, PubkyLockResource, TaskId}; use locks_core::lock_policy::VerifierType; - use locks_core::verification::{Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle}; + use locks_core::verification::{ + CriterionVerificationResult, EntitlementLifetime, Proof, SUBMITTED_PROOF_BUNDLE_VERSION, + SubmittedProofBundle, VERIFIED_PROOF_BUNDLE_VERSION, VerificationResult, + VerifiedProofBundle, + }; use super::{ AccessCredential, AccessCredentialLookupKey, AccessCredentialPolicy, @@ -27,7 +31,7 @@ mod tests { CreatorConnectAuthorizationUrl, CreatorConnectFlowId, DEFAULT_ACCESS_CREDENTIAL_TTL_SECONDS, FrontendSessionCode, FrontendSessionCodeRecord, FrontendSessionRecord, FrontendSessionToken, PendingCreatorConnectFlowRecord, - VerificationTaskRecord, VerificationTaskStatus, + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, }; use crate::application::errors::ApplicationError; @@ -251,18 +255,30 @@ mod tests { } #[test] - fn in_progress_task_transitions_to_completed_and_sets_completed_at() { + fn entitlement_publication_intent_precedes_completion_and_preserves_exact_payload() { let task = in_progress_task(); let completed_at = datetime!(2026-05-29 12:03:00 UTC); + let entitlement = entitlement(); - let transitioned = task - .transition_to(VerificationTaskStatus::Completed, completed_at, None) + let publishing = task + .begin_entitlement_publication(entitlement.clone()) + .unwrap(); + assert_eq!( + publishing.status, + VerificationTaskStatus::PublishingEntitlement + ); + assert_eq!(publishing.entitlement_to_publish, Some(entitlement)); + assert_eq!(publishing.completed_at, None); + + let transitioned = publishing + .complete_entitlement_publication(completed_at) .unwrap(); assert_eq!(transitioned.status, VerificationTaskStatus::Completed); assert_eq!(transitioned.started_at, task.started_at); assert_eq!(transitioned.completed_at, Some(completed_at)); assert_eq!(transitioned.failure_message, None); + assert!(transitioned.entitlement_to_publish.is_some()); } #[test] @@ -287,25 +303,23 @@ mod tests { } #[test] - fn pending_or_in_progress_task_can_expire_without_failure_message() { + fn in_progress_task_expires_with_typed_reason_without_failure_message() { let expired_at = datetime!(2026-05-29 12:04:00 UTC); - - let from_pending = pending_task() - .transition_to(VerificationTaskStatus::Expired, expired_at, None) - .unwrap(); let from_in_progress = in_progress_task() - .transition_to(VerificationTaskStatus::Expired, expired_at, None) + .expire( + VerificationTerminalReason::PaymentRequestRejected, + expired_at, + ) .unwrap(); - assert_eq!(from_pending.status, VerificationTaskStatus::Expired); - assert_eq!(from_pending.started_at, None); - assert_eq!(from_pending.completed_at, Some(expired_at)); - assert_eq!(from_pending.failure_message, None); - assert_eq!(from_in_progress.status, VerificationTaskStatus::Expired); assert!(from_in_progress.started_at.is_some()); assert_eq!(from_in_progress.completed_at, Some(expired_at)); assert_eq!(from_in_progress.failure_message, None); + assert_eq!( + from_in_progress.terminal_reason, + Some(VerificationTerminalReason::PaymentRequestRejected) + ); } #[test] @@ -349,7 +363,9 @@ mod tests { #[test] fn non_failed_transitions_reject_failure_message() { - let task = in_progress_task(); + let task = in_progress_task() + .begin_entitlement_publication(entitlement()) + .unwrap(); let error = task .transition_to( @@ -397,6 +413,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } @@ -413,6 +431,7 @@ mod tests { status: VerificationTaskStatus::Completed, started_at: Some(datetime!(2026-05-29 12:01:00 UTC)), completed_at: Some(datetime!(2026-05-29 12:03:00 UTC)), + entitlement_to_publish: Some(entitlement()), ..pending_task() } } @@ -433,4 +452,26 @@ mod tests { }], } } + + fn entitlement() -> VerifiedProofBundle { + let submitted = submitted_proof_bundle(); + VerifiedProofBundle { + version: VERIFIED_PROOF_BUNDLE_VERSION, + bundle_id: submitted.bundle_id, + pubky_lock_resource: submitted.pubky_lock_resource, + verification_result: VerificationResult { + criteria: vec![CriterionVerificationResult { + criterion_id: "criterion-1".to_owned(), + satisfied: true, + verified_at: datetime!(2026-05-29 12:02:00 UTC), + verified_by: LockServerPubky::from_str( + "pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo", + ) + .unwrap(), + verifier_type: VerifierType::DevStatic, + }], + }, + entitlement_lifetime: EntitlementLifetime::Unbounded, + } + } } diff --git a/locks-service/src/application/models/verification.rs b/locks-service/src/application/models/verification.rs index 3451409..2f21f8a 100644 --- a/locks-service/src/application/models/verification.rs +++ b/locks-service/src/application/models/verification.rs @@ -2,7 +2,7 @@ use time::OffsetDateTime; use locks_core::ids::{BundleId, CreatorPubky, LockId, LockServerPubky, TaskId}; use locks_core::lock_policy::Criterion; -use locks_core::verification::{Proof, SubmittedProofBundle}; +use locks_core::verification::{Proof, SubmittedProofBundle, VerifiedProofBundle}; use crate::application::errors::ApplicationError; @@ -13,6 +13,8 @@ pub enum VerificationTaskStatus { Pending, /// Verification is currently running. InProgress, + /// Exact entitlement payload won the lifecycle race and awaits verified publication. + PublishingEntitlement, /// Verification succeeded and entitlement storage can be read. Completed, /// Verification failed and no entitlement should be created. @@ -21,6 +23,44 @@ pub enum VerificationTaskStatus { Expired, } +/// Canonical reason a Paykit-backed verification attempt ended without entitlement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerificationTerminalReason { + PaymentRequestRejected, + PaymentRequestCanceled, + ProposalExpired, + PaymentDeadlineExpired, +} + +impl VerificationTerminalReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::PaymentRequestRejected => "payment_request_rejected", + Self::PaymentRequestCanceled => "payment_request_canceled", + Self::ProposalExpired => "proposal_expired", + Self::PaymentDeadlineExpired => "payment_deadline_expired", + } + } + + pub fn from_storage_value(value: &str) -> Option { + match value { + "payment_request_rejected" => Some(Self::PaymentRequestRejected), + "payment_request_canceled" => Some(Self::PaymentRequestCanceled), + "proposal_expired" => Some(Self::ProposalExpired), + "payment_deadline_expired" => Some(Self::PaymentDeadlineExpired), + _ => None, + } + } +} + +/// Closed criterion-verifier decision used by completion orchestration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CriterionVerificationOutcome { + Pending, + Satisfied(locks_core::verification::CriterionVerificationResult), + TerminalUnsatisfied(VerificationTerminalReason), +} + /// Persisted service-layer verification task state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct VerificationTaskRecord { @@ -40,6 +80,10 @@ pub struct VerificationTaskRecord { pub completed_at: Option, /// Non-empty failure detail for failed tasks only. pub failure_message: Option, + /// Typed no-entitlement reason for expired Paykit attempts only. + pub terminal_reason: Option, + /// Exact durable payload replayed while entitlement publication is pending. + pub entitlement_to_publish: Option, } /// Worker claim carrying the lease incarnation token required for fenced writes. @@ -75,10 +119,17 @@ impl VerificationTaskRecord { transitioned.started_at = None; transitioned.completed_at = None; transitioned.failure_message = None; + transitioned.terminal_reason = None; + transitioned.entitlement_to_publish = None; } VerificationTaskStatus::InProgress => { transitioned.started_at = Some(at); } + VerificationTaskStatus::PublishingEntitlement => { + return Err(ApplicationError::InvalidVerificationTaskState { + message: "publishing transitions require an entitlement payload".to_owned(), + }); + } VerificationTaskStatus::Completed => { transitioned.completed_at = Some(at); } @@ -87,24 +138,71 @@ impl VerificationTaskRecord { transitioned.failure_message = trimmed_failure_message; } VerificationTaskStatus::Expired => { - transitioned.completed_at = Some(at); + return Err(ApplicationError::InvalidVerificationTaskState { + message: "expired transitions require a terminal reason".to_owned(), + }); } } Ok(transitioned) } + /// Persists exact entitlement payload before any external publication attempt. + pub fn begin_entitlement_publication( + &self, + entitlement: VerifiedProofBundle, + ) -> Result { + self.validate_state()?; + self.validate_transition(VerificationTaskStatus::PublishingEntitlement)?; + let mut transitioned = self.clone(); + transitioned.status = VerificationTaskStatus::PublishingEntitlement; + transitioned.entitlement_to_publish = Some(entitlement); + Ok(transitioned) + } + + /// Marks publication complete only after equivalent entitlement read-back. + pub fn complete_entitlement_publication( + &self, + at: OffsetDateTime, + ) -> Result { + self.transition_to(VerificationTaskStatus::Completed, at, None) + } + + /// Terminalizes an actively running Paykit attempt without entitlement or failure. + pub fn expire( + &self, + reason: VerificationTerminalReason, + at: OffsetDateTime, + ) -> Result { + self.validate_state()?; + self.validate_transition(VerificationTaskStatus::Expired)?; + if self.status != VerificationTaskStatus::InProgress { + return Err(ApplicationError::InvalidVerificationTaskState { + message: "only an in-progress task can expire with a terminal reason".to_owned(), + }); + } + let mut transitioned = self.clone(); + transitioned.status = VerificationTaskStatus::Expired; + transitioned.completed_at = Some(at); + transitioned.failure_message = None; + transitioned.terminal_reason = Some(reason); + Ok(transitioned) + } + fn validate_transition(&self, next: VerificationTaskStatus) -> Result<(), ApplicationError> { - use VerificationTaskStatus::{Completed, Expired, Failed, InProgress, Pending}; + use VerificationTaskStatus::{ + Completed, Expired, Failed, InProgress, Pending, PublishingEntitlement, + }; let allowed = matches!( (self.status, next), (Pending, InProgress) | (Pending, Expired) | (InProgress, Pending) - | (InProgress, Completed) + | (InProgress, PublishingEntitlement) | (InProgress, Failed) | (InProgress, Expired) + | (PublishingEntitlement, Completed) ); if allowed { @@ -118,23 +216,38 @@ impl VerificationTaskRecord { } fn validate_state(&self) -> Result<(), ApplicationError> { - use VerificationTaskStatus::{Completed, Expired, Failed, InProgress, Pending}; + use VerificationTaskStatus::{ + Completed, Expired, Failed, InProgress, Pending, PublishingEntitlement, + }; let valid = match self.status { Pending => { self.started_at.is_none() && self.completed_at.is_none() && self.failure_message.is_none() + && self.terminal_reason.is_none() + && self.entitlement_to_publish.is_none() } InProgress => { self.started_at.is_some() && self.completed_at.is_none() && self.failure_message.is_none() + && self.terminal_reason.is_none() + && self.entitlement_to_publish.is_none() + } + PublishingEntitlement => { + self.started_at.is_some() + && self.completed_at.is_none() + && self.failure_message.is_none() + && self.terminal_reason.is_none() + && self.entitlement_to_publish.is_some() } Completed => { self.started_at.is_some() && self.completed_at.is_some() && self.failure_message.is_none() + && self.terminal_reason.is_none() + && self.entitlement_to_publish.is_some() } Failed => { self.started_at.is_some() @@ -143,8 +256,16 @@ impl VerificationTaskRecord { .failure_message .as_deref() .is_some_and(|message| !message.trim().is_empty()) + && self.terminal_reason.is_none() + && self.entitlement_to_publish.is_none() + } + Expired => { + self.started_at.is_some() + && self.completed_at.is_some() + && self.failure_message.is_none() + && self.terminal_reason.is_some() + && self.entitlement_to_publish.is_none() } - Expired => self.completed_at.is_some() && self.failure_message.is_none(), }; if valid { diff --git a/locks-service/src/application/ports/verification.rs b/locks-service/src/application/ports/verification.rs index ad706b9..1f3191b 100644 --- a/locks-service/src/application/ports/verification.rs +++ b/locks-service/src/application/ports/verification.rs @@ -1,11 +1,11 @@ use async_trait::async_trait; use locks_core::ids::{BundleId, CreatorPubky, TaskId}; use locks_core::lock_policy::VerifierType; -use locks_core::verification::CriterionVerificationResult; use crate::application::errors::ApplicationError; use crate::application::models::{ - ClaimedVerificationTask, CriterionVerificationRequest, VerificationTaskRecord, + ClaimedVerificationTask, CriterionVerificationOutcome, CriterionVerificationRequest, + VerificationTaskRecord, }; /// Repository for asynchronous verification task state. @@ -108,11 +108,11 @@ pub trait VerificationTaskIdGenerator: Send + Sync { /// Adapter boundary for criterion-specific verification logic. #[async_trait] pub trait CriterionVerifier: Send + Sync { - /// Verifies one criterion/proof pair and returns minimal criterion-level evidence. + /// Verifies one criterion/proof pair and returns a closed lifecycle decision. async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result; + ) -> Result; } /// Registry that dispatches protocol verifier types to concrete verifier adapters. diff --git a/locks-service/src/application/use_cases/complete_verification_task.rs b/locks-service/src/application/use_cases/complete_verification_task.rs index 3f34c63..2bc3695 100644 --- a/locks-service/src/application/use_cases/complete_verification_task.rs +++ b/locks-service/src/application/use_cases/complete_verification_task.rs @@ -10,8 +10,8 @@ use locks_core::verification::{ use crate::application::entitlement_evaluator::evaluate_entitlement; use crate::application::errors::ApplicationError; use crate::application::models::{ - ClaimedVerificationTask, CriterionVerificationRequest, VerificationTaskRecord, - VerificationTaskStatus, + ClaimedVerificationTask, CriterionVerificationOutcome, CriterionVerificationRequest, + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, }; use crate::application::ports::{ Clock, ContentLockRepository, CriterionVerifierRegistry, EntitlementRepository, @@ -47,6 +47,12 @@ pub struct CompleteVerificationTaskUseCase<'a> { verified_by: LockServerPubky, } +enum VerificationDecision { + Pending, + Satisfied(VerificationResult), + TerminalUnsatisfied(VerificationTerminalReason), +} + struct ClaimFencedTaskRepository<'a> { claim: ClaimedVerificationTask, claimer: &'a dyn VerificationTaskClaimer, @@ -124,6 +130,17 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { record: "verification_task", })?; + if task.status == VerificationTaskStatus::PublishingEntitlement { + let entitlement = task.entitlement_to_publish.clone().ok_or_else(|| { + ApplicationError::InvalidVerificationTaskState { + message: "publishing task is missing entitlement payload".to_owned(), + } + })?; + return self + .publish_entitlement_and_complete(request.task_id, task, entitlement) + .await; + } + let submitted = task.submitted_proof_bundle.clone(); let pubky_lock_resource = submitted.pubky_lock_resource.clone(); @@ -153,9 +170,41 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { } let verification_result = match self.verify_criteria(&task, &content_lock).await { - Ok(verification_result) => verification_result, + Ok(VerificationDecision::Satisfied(verification_result)) => verification_result, + Ok(VerificationDecision::Pending) => { + return Err(ApplicationError::VerificationPending); + } + Ok(VerificationDecision::TerminalUnsatisfied(reason)) => { + let in_progress = match task.status { + VerificationTaskStatus::Pending => { + let in_progress = task.transition_to( + VerificationTaskStatus::InProgress, + self.clock.now(), + None, + )?; + self.tasks + .update_verification_task(in_progress.clone()) + .await?; + in_progress + } + VerificationTaskStatus::InProgress => task, + _ => task, + }; + let completed_at = self.clock.now(); + let expired = in_progress.expire(reason, completed_at)?; + self.tasks.update_verification_task(expired).await?; + return Ok(CompletedVerificationTask { + task_id: request.task_id, + status: VerificationTaskStatus::Expired, + completed_at, + }); + } Err(error) => { - if matches!(error, ApplicationError::VerificationPending) { + if matches!( + error, + ApplicationError::VerificationPending + | ApplicationError::PaykitPaymentStatusConflict + ) { return Err(error); } self.persist_failed_task(task, viewer_safe_failure_message(&error).to_owned()) @@ -199,46 +248,10 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { verification_result, entitlement_lifetime: EntitlementLifetime::Unbounded, }; - if let Err(error) = self - .entitlements - .insert_verified_proof_bundle(entitlement.clone()) + task = task.begin_entitlement_publication(entitlement.clone())?; + self.tasks.update_verification_task(task.clone()).await?; + self.publish_entitlement_and_complete(request.task_id, task, entitlement) .await - { - match self - .entitlements - .get_verified_proof_bundle( - entitlement.pubky_lock_resource.creator(), - &entitlement.bundle_id, - ) - .await - { - Ok(Some(existing)) if same_entitlement_decision(&existing, &entitlement) => {} - Ok(_) => { - self.persist_failed_task(task, viewer_safe_failure_message(&error).to_owned()) - .await?; - return Err(error); - } - Err(lookup_error) => { - self.persist_failed_task( - task, - viewer_safe_failure_message(&lookup_error).to_owned(), - ) - .await?; - return Err(lookup_error); - } - } - } - - let completed_at = self.clock.now(); - let completed = - task.transition_to(VerificationTaskStatus::Completed, completed_at, None)?; - self.tasks.update_verification_task(completed).await?; - - Ok(CompletedVerificationTask { - task_id: request.task_id, - status: VerificationTaskStatus::Completed, - completed_at, - }) } /// Runs verifier work for a worker-owned lease and fences every terminal write by its token. @@ -250,10 +263,13 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { claimer: &dyn VerificationTaskClaimer, ) -> Result { if claim.task.task_id != request.task_id - || claim.task.status != VerificationTaskStatus::InProgress + || !matches!( + claim.task.status, + VerificationTaskStatus::InProgress | VerificationTaskStatus::PublishingEntitlement + ) { return Err(ApplicationError::InvalidVerificationTaskState { - message: "claimed completion requires the matching in-progress task".to_owned(), + message: "claimed completion requires the matching active task".to_owned(), }); } let fenced_tasks = ClaimFencedTaskRepository { @@ -274,11 +290,48 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { .await } + async fn publish_entitlement_and_complete( + &self, + task_id: TaskId, + task: VerificationTaskRecord, + entitlement: VerifiedProofBundle, + ) -> Result { + let write_error = self + .entitlements + .insert_verified_proof_bundle(entitlement.clone()) + .await + .err(); + let read_back = self + .entitlements + .get_verified_proof_bundle( + entitlement.pubky_lock_resource.creator(), + &entitlement.bundle_id, + ) + .await?; + if !read_back + .as_ref() + .is_some_and(|existing| same_entitlement_decision(existing, &entitlement)) + { + return Err(write_error.unwrap_or_else(|| ApplicationError::Storage { + message: "published entitlement was not equivalent on read-back".to_owned(), + })); + } + + let completed_at = self.clock.now(); + let completed = task.complete_entitlement_publication(completed_at)?; + self.tasks.update_verification_task(completed).await?; + Ok(CompletedVerificationTask { + task_id, + status: VerificationTaskStatus::Completed, + completed_at, + }) + } + async fn verify_criteria( &self, task: &VerificationTaskRecord, content_lock: &ContentLock, - ) -> Result { + ) -> Result { let verified_at = self.clock.now(); let submitted = &task.submitted_proof_bundle; let mut criteria = Vec::new(); @@ -295,22 +348,31 @@ impl<'a> CompleteVerificationTaskUseCase<'a> { verifier_type: criterion.verifier_type, }, )?; - criteria.push( - verifier - .verify(CriterionVerificationRequest { - bundle_id: submitted.bundle_id.clone(), - creator: submitted.pubky_lock_resource.creator().clone(), - lock_id: submitted.pubky_lock_resource.lock_id().clone(), - criterion: criterion.clone(), - proof: proof.clone(), - verified_by: self.verified_by.clone(), - verified_at, - }) - .await?, - ); + match verifier + .verify(CriterionVerificationRequest { + bundle_id: submitted.bundle_id.clone(), + creator: submitted.pubky_lock_resource.creator().clone(), + lock_id: submitted.pubky_lock_resource.lock_id().clone(), + criterion: criterion.clone(), + proof: proof.clone(), + verified_by: self.verified_by.clone(), + verified_at, + }) + .await? + { + CriterionVerificationOutcome::Pending => { + return Ok(VerificationDecision::Pending); + } + CriterionVerificationOutcome::TerminalUnsatisfied(reason) => { + return Ok(VerificationDecision::TerminalUnsatisfied(reason)); + } + CriterionVerificationOutcome::Satisfied(result) => criteria.push(result), + } } - Ok(VerificationResult { criteria }) + Ok(VerificationDecision::Satisfied(VerificationResult { + criteria, + })) } async fn persist_failed_task( @@ -343,9 +405,9 @@ fn viewer_safe_failure_message(error: &ApplicationError) -> &'static str { match error { ApplicationError::ContentLockUnavailable => "content lock unavailable", ApplicationError::EntitlementNotSatisfied => "entitlement not satisfied", - ApplicationError::UnsupportedVerifierType { .. } | ApplicationError::Verifier { .. } => { - "verification failed" - } + ApplicationError::UnsupportedVerifierType { .. } + | ApplicationError::Verifier { .. } + | ApplicationError::PaykitPaymentStatusInvalidResponse => "verification failed", ApplicationError::ContentLockHashMismatch { .. } | ApplicationError::ContentLockCanonicalization { .. } => "content lock invalid", ApplicationError::EmptyContentLockCriteria @@ -359,6 +421,7 @@ fn viewer_safe_failure_message(error: &ApplicationError) -> &'static str { | ApplicationError::MissingRecord { .. } | ApplicationError::InvalidVerificationTaskTransition { .. } | ApplicationError::VerificationPending + | ApplicationError::PaykitPaymentStatusConflict | ApplicationError::InvalidVerificationTaskState { .. } | ApplicationError::VerificationTaskClaimLost | ApplicationError::InvalidVerificationTaskFailureMessage @@ -390,20 +453,7 @@ fn same_entitlement_decision( existing: &VerifiedProofBundle, candidate: &VerifiedProofBundle, ) -> bool { - if existing.verification_result.criteria.len() != candidate.verification_result.criteria.len() { - return false; - } - - let mut normalized_candidate = candidate.clone(); - for (candidate_result, existing_result) in normalized_candidate - .verification_result - .criteria - .iter_mut() - .zip(&existing.verification_result.criteria) - { - candidate_result.verified_at = existing_result.verified_at; - } - existing == &normalized_candidate + existing == candidate } #[cfg(test)] @@ -432,8 +482,8 @@ mod tests { use super::{CompleteVerificationTaskRequest, CompleteVerificationTaskUseCase}; use crate::application::errors::ApplicationError; use crate::application::models::{ - ClaimedVerificationTask, CriterionVerificationRequest, VerificationTaskRecord, - VerificationTaskStatus, + ClaimedVerificationTask, CriterionVerificationOutcome, CriterionVerificationRequest, + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, }; use crate::application::ports::{ Clock, ContentLockRepository, CriterionVerifier, CriterionVerifierRegistry, @@ -445,7 +495,7 @@ mod tests { const BUNDLE_ID: &str = "000G40R40M30E209185GR38E1W"; #[tokio::test] - async fn claimed_completion_rejects_lost_lease_after_entitlement_publication() { + async fn lost_claim_cannot_publish_entitlement_before_durable_intent_wins() { let content_lock = content_lock_fixture(true); let in_progress = pending_task_for(&content_lock) .transition_to( @@ -493,12 +543,69 @@ mod tests { .await; assert_eq!(result, Err(ApplicationError::VerificationTaskClaimLost)); - assert_eq!(entitlements.stored().len(), 1); + assert!(entitlements.stored().is_empty()); + assert!(tasks.updates().is_empty()); + } + + #[tokio::test] + async fn claimed_terminal_unsatisfied_expires_with_reason_without_entitlement() { + let content_lock = content_lock_fixture(true); + let in_progress = pending_task_for(&content_lock) + .transition_to( + VerificationTaskStatus::InProgress, + datetime!(2026-05-29 12:00:30 UTC), + None, + ) + .unwrap(); + let claim = ClaimedVerificationTask { + task: in_progress, + claim_token: uuid::Uuid::new_v4(), + }; + let tasks = FakeTasks::new(None); + let content_locks = FakeContentLocks::new(Some(content_lock)); + let entitlements = FakeEntitlements::default(); + let verifier = TerminalVerifier; + let registry = FakeRegistry { + verifier: Some(&verifier), + }; + let claimer = CapturingClaimClaimer::default(); + let completed_at = datetime!(2026-05-29 12:01:00 UTC); + let clock = SequenceClock::new(vec![completed_at, completed_at, completed_at]); + let use_case = CompleteVerificationTaskUseCase::new( + &tasks, + &content_locks, + &entitlements, + ®istry, + &clock, + lock_server(), + ); + + let completed = use_case + .execute_claimed( + CompleteVerificationTaskRequest { task_id: task_id() }, + claim, + "worker-a", + &claimer, + ) + .await + .unwrap(); + + assert_eq!(completed.status, VerificationTaskStatus::Expired); + assert_eq!(completed.completed_at, completed_at); + let persisted = claimer.persisted().expect("terminal task persisted"); + assert_eq!(persisted.status, VerificationTaskStatus::Expired); + assert_eq!(persisted.completed_at, Some(completed_at)); + assert_eq!(persisted.failure_message, None); + assert_eq!( + persisted.terminal_reason, + Some(VerificationTerminalReason::PaymentRequestRejected) + ); + assert!(entitlements.stored().is_empty()); assert!(tasks.updates().is_empty()); } #[tokio::test] - async fn complete_verification_task_accepts_equivalent_existing_entitlement() { + async fn complete_verification_task_replays_exact_persisted_entitlement() { let content_lock = content_lock_fixture(true); let content_locks = FakeContentLocks::new(Some(content_lock.clone())); let entitlements = FakeEntitlements::default(); @@ -509,34 +616,49 @@ mod tests { let registry = FakeRegistry { verifier: Some(&verifier), }; + let initial_tasks = FakeTasks::new(Some(pending_task_for(&content_lock))); + let initial_clock = SequenceClock::new(vec![ + datetime!(2026-05-29 12:01:00 UTC), + datetime!(2026-05-29 12:02:00 UTC), + datetime!(2026-05-29 12:03:00 UTC), + ]); + CompleteVerificationTaskUseCase::new( + &initial_tasks, + &content_locks, + &entitlements, + ®istry, + &initial_clock, + lock_server(), + ) + .execute(CompleteVerificationTaskRequest { task_id: task_id() }) + .await + .unwrap(); - for attempt in 0..2 { - let tasks = FakeTasks::new(Some(pending_task_for(&content_lock))); - let verified_at = if attempt == 0 { - datetime!(2026-05-29 12:01:00 UTC) - } else { - datetime!(2026-05-29 12:04:00 UTC) - }; - let clock = SequenceClock::new(vec![ - verified_at, - datetime!(2026-05-29 12:02:00 UTC), - datetime!(2026-05-29 12:03:00 UTC), - ]); - let completed = CompleteVerificationTaskUseCase::new( - &tasks, - &content_locks, - &entitlements, - ®istry, - &clock, - lock_server(), + let exact_entitlement = entitlements.stored().into_iter().next().unwrap(); + let publishing = pending_task_for(&content_lock) + .transition_to( + VerificationTaskStatus::InProgress, + datetime!(2026-05-29 12:04:00 UTC), + None, ) - .execute(CompleteVerificationTaskRequest { task_id: task_id() }) - .await - .expect("equivalent existing entitlement is idempotent"); - - assert_eq!(completed.status, VerificationTaskStatus::Completed); - } + .unwrap() + .begin_entitlement_publication(exact_entitlement) + .unwrap(); + let replay_tasks = FakeTasks::new(Some(publishing)); + let replay_clock = SequenceClock::new(vec![datetime!(2026-05-29 12:05:00 UTC)]); + let completed = CompleteVerificationTaskUseCase::new( + &replay_tasks, + &content_locks, + &entitlements, + ®istry, + &replay_clock, + lock_server(), + ) + .execute(CompleteVerificationTaskRequest { task_id: task_id() }) + .await + .expect("exact persisted entitlement replay is idempotent"); + assert_eq!(completed.status, VerificationTaskStatus::Completed); assert_eq!(entitlements.stored().len(), 1); } @@ -581,7 +703,7 @@ mod tests { } #[tokio::test] - async fn current_claim_owner_completes_after_equivalent_entitlement_was_published() { + async fn current_claim_owner_completes_after_exact_entitlement_was_published() { let content_lock = content_lock_fixture(true); let content_locks = FakeContentLocks::new(Some(content_lock.clone())); let entitlements = FakeEntitlements::default(); @@ -610,8 +732,9 @@ mod tests { .await .unwrap(); + let exact_entitlement = entitlements.stored().into_iter().next().unwrap(); let claimer = InMemoryVerificationTaskClaimer::new(vec![pending_task_for(&content_lock)]); - let claim = claimer + let in_progress_claim = claimer .claim_next_verification_task( "worker-a", datetime!(2026-05-29 12:04:00 UTC), @@ -620,11 +743,29 @@ mod tests { .await .unwrap() .unwrap(); + let publishing = in_progress_claim + .task + .clone() + .begin_entitlement_publication(exact_entitlement) + .unwrap(); + claimer + .persist_claimed_verification_task_transition( + publishing.clone(), + "worker-a", + &in_progress_claim.claim_token, + datetime!(2026-05-29 12:04:00 UTC), + ) + .await + .unwrap() + .unwrap(); + let claim = ClaimedVerificationTask { + task: publishing, + claim_token: in_progress_claim.claim_token, + }; let tasks = FakeTasks::new(None); let retry_clock = SequenceClock::new(vec![ datetime!(2026-05-29 12:05:00 UTC), datetime!(2026-05-29 12:06:00 UTC), - datetime!(2026-05-29 12:07:00 UTC), ]); let completed = CompleteVerificationTaskUseCase::new( &tasks, @@ -649,7 +790,7 @@ mod tests { } #[tokio::test] - async fn complete_verification_task_rejects_mismatched_existing_entitlement() { + async fn mismatched_existing_entitlement_retains_replayable_publication_intent() { let content_lock = content_lock_fixture(true); let content_locks = FakeContentLocks::new(Some(content_lock.clone())); let entitlements = FakeEntitlements::default(); @@ -706,7 +847,74 @@ mod tests { ); assert_eq!( retry_tasks.updates().last().unwrap().status, - VerificationTaskStatus::Failed + VerificationTaskStatus::PublishingEntitlement + ); + assert!( + retry_tasks + .updates() + .last() + .unwrap() + .entitlement_to_publish + .is_some() + ); + } + + #[tokio::test] + async fn different_verified_at_is_not_an_exact_publication_replay() { + let content_lock = content_lock_fixture(true); + let content_locks = FakeContentLocks::new(Some(content_lock.clone())); + let entitlements = FakeEntitlements::default(); + let verifier = FakeVerifier { + satisfied: true, + error: None, + }; + let registry = FakeRegistry { + verifier: Some(&verifier), + }; + let initial_tasks = FakeTasks::new(Some(pending_task_for(&content_lock))); + let initial_clock = SequenceClock::new(vec![ + datetime!(2026-05-29 12:01:00 UTC), + datetime!(2026-05-29 12:02:00 UTC), + datetime!(2026-05-29 12:03:00 UTC), + ]); + CompleteVerificationTaskUseCase::new( + &initial_tasks, + &content_locks, + &entitlements, + ®istry, + &initial_clock, + lock_server(), + ) + .execute(CompleteVerificationTaskRequest { task_id: task_id() }) + .await + .unwrap(); + + let retry_tasks = FakeTasks::new(Some(pending_task_for(&content_lock))); + let retry_clock = SequenceClock::new(vec![ + datetime!(2026-05-29 12:04:00 UTC), + datetime!(2026-05-29 12:05:00 UTC), + datetime!(2026-05-29 12:06:00 UTC), + ]); + let result = CompleteVerificationTaskUseCase::new( + &retry_tasks, + &content_locks, + &entitlements, + ®istry, + &retry_clock, + lock_server(), + ) + .execute(CompleteVerificationTaskRequest { task_id: task_id() }) + .await; + + assert_eq!( + result, + Err(ApplicationError::DuplicateRecord { + record: "verified_proof_bundle" + }) + ); + assert_eq!( + retry_tasks.updates().last().unwrap().status, + VerificationTaskStatus::PublishingEntitlement ); } @@ -746,15 +954,23 @@ mod tests { assert_eq!(completed.status, VerificationTaskStatus::Completed); assert_eq!(completed.completed_at, datetime!(2026-05-29 12:03:00 UTC)); let updates = tasks.updates(); - assert_eq!(updates.len(), 2); + assert_eq!(updates.len(), 3); assert_eq!(updates[0].status, VerificationTaskStatus::InProgress); assert_eq!( updates[0].started_at, Some(datetime!(2026-05-29 12:02:00 UTC)) ); - assert_eq!(updates[1].status, VerificationTaskStatus::Completed); assert_eq!( - updates[1].completed_at, + updates[1].status, + VerificationTaskStatus::PublishingEntitlement + ); + assert_eq!( + updates[1].entitlement_to_publish.as_ref(), + entitlements.stored().first() + ); + assert_eq!(updates[2].status, VerificationTaskStatus::Completed); + assert_eq!( + updates[2].completed_at, Some(datetime!(2026-05-29 12:03:00 UTC)) ); let stored = entitlements.stored(); @@ -1034,6 +1250,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } @@ -1106,17 +1324,78 @@ mod tests { async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result { + ) -> Result { if let Some(error) = &self.error { return Err(error.clone()); } - Ok(CriterionVerificationResult { - criterion_id: request.criterion.criterion_id, - satisfied: self.satisfied, - verified_at: request.verified_at, - verified_by: request.verified_by, - verifier_type: request.criterion.verifier_type, - }) + Ok(CriterionVerificationOutcome::Satisfied( + CriterionVerificationResult { + criterion_id: request.criterion.criterion_id, + satisfied: self.satisfied, + verified_at: request.verified_at, + verified_by: request.verified_by, + verifier_type: request.criterion.verifier_type, + }, + )) + } + } + + struct TerminalVerifier; + + #[async_trait] + impl CriterionVerifier for TerminalVerifier { + async fn verify( + &self, + _request: CriterionVerificationRequest, + ) -> Result { + Ok(CriterionVerificationOutcome::TerminalUnsatisfied( + VerificationTerminalReason::PaymentRequestRejected, + )) + } + } + + #[derive(Default)] + struct CapturingClaimClaimer { + persisted: Mutex>, + } + + impl CapturingClaimClaimer { + fn persisted(&self) -> Option { + self.persisted.lock().unwrap().clone() + } + } + + #[async_trait] + impl VerificationTaskClaimer for CapturingClaimClaimer { + async fn claim_next_verification_task( + &self, + _worker_id: &str, + _now: OffsetDateTime, + _claim_expires_at: OffsetDateTime, + ) -> Result, ApplicationError> { + unreachable!("completion must not claim tasks") + } + + async fn schedule_verification_task_retry( + &self, + _task_id: &TaskId, + _worker_id: &str, + _claim_token: &uuid::Uuid, + _now: OffsetDateTime, + _next_attempt_at: OffsetDateTime, + ) -> Result, ApplicationError> { + unreachable!("completion must not schedule retries") + } + + async fn persist_claimed_verification_task_transition( + &self, + task: VerificationTaskRecord, + _worker_id: &str, + _claim_token: &uuid::Uuid, + _now: OffsetDateTime, + ) -> Result, ApplicationError> { + *self.persisted.lock().unwrap() = Some(task.clone()); + Ok(Some(task)) } } diff --git a/locks-service/src/application/use_cases/get_verification_task.rs b/locks-service/src/application/use_cases/get_verification_task.rs index 6a490c6..1dc6fd3 100644 --- a/locks-service/src/application/use_cases/get_verification_task.rs +++ b/locks-service/src/application/use_cases/get_verification_task.rs @@ -3,7 +3,9 @@ use time::OffsetDateTime; use locks_core::ids::{BundleId, CreatorPubky, TaskId}; use crate::application::errors::ApplicationError; -use crate::application::models::{VerificationTaskRecord, VerificationTaskStatus}; +use crate::application::models::{ + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, +}; use crate::application::ports::VerificationTaskRepository; /// Request to read verification task state. @@ -48,6 +50,8 @@ pub struct VerificationTaskLifecycleView { pub completed_at: Option, /// Viewer-safe failure detail for failed tasks. pub failure_message: Option, + /// Typed no-entitlement reason for expired Paykit attempts. + pub terminal_reason: Option, } impl From for VerificationTaskLifecycleView { @@ -60,6 +64,7 @@ impl From for VerificationTaskLifecycleView { started_at: task.started_at, completed_at: task.completed_at, failure_message: task.failure_message, + terminal_reason: task.terminal_reason, } } } @@ -79,6 +84,8 @@ pub struct VerificationTaskView { pub completed_at: Option, /// Persisted failure detail for failed tasks. pub failure_message: Option, + /// Typed no-entitlement reason for expired Paykit attempts. + pub terminal_reason: Option, } /// Read-only verification task polling use case. @@ -112,6 +119,7 @@ impl<'a> GetVerificationTaskUseCase<'a> { started_at: task.started_at, completed_at: task.completed_at, failure_message: task.failure_message, + terminal_reason: task.terminal_reason, }) } } @@ -245,6 +253,30 @@ mod tests { assert!(!debug.contains("satisfied")); } + #[test] + fn public_lifecycle_view_exposes_typed_terminal_reason() { + let task = verification_task() + .transition_to( + VerificationTaskStatus::InProgress, + datetime!(2026-05-29 12:01:00 UTC), + None, + ) + .unwrap() + .expire( + crate::application::models::VerificationTerminalReason::PaymentRequestCanceled, + datetime!(2026-05-29 12:02:00 UTC), + ) + .unwrap(); + + let view = VerificationTaskLifecycleView::from(task); + + assert_eq!( + view.terminal_reason, + Some(crate::application::models::VerificationTerminalReason::PaymentRequestCanceled) + ); + assert_eq!(view.failure_message, None); + } + #[tokio::test] async fn get_verification_task_by_handle_returns_public_lifecycle_view() { let task = verification_task() @@ -334,6 +366,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } diff --git a/locks-service/src/application/use_cases/submit_proof_bundle.rs b/locks-service/src/application/use_cases/submit_proof_bundle.rs index 2a14277..76c67ab 100644 --- a/locks-service/src/application/use_cases/submit_proof_bundle.rs +++ b/locks-service/src/application/use_cases/submit_proof_bundle.rs @@ -83,6 +83,8 @@ impl<'a> SubmitProofBundleUseCase<'a> { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, }; match self.tasks.insert_verification_task(task.clone()).await { @@ -126,6 +128,7 @@ mod tests { use locks_core::verification::{Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle}; use super::*; + use crate::application::models::VerificationTerminalReason; const BUNDLE_ID: &str = "000G40R40M30E209185GR38E1W"; const LOCK_ID: &str = "000G40R40M30E209185GR38E1W8124GK2GAHC5RR34D1P70X3RFG"; @@ -198,6 +201,52 @@ mod tests { assert_eq!(tasks.update_count(), 0); } + #[tokio::test] + async fn terminal_attempt_requires_fresh_bundle_id_to_create_new_lifecycle() { + let existing = verification_task_with_bundle(submitted_proof_bundle()) + .transition_to( + VerificationTaskStatus::InProgress, + datetime!(2026-05-29 12:01:00 UTC), + None, + ) + .unwrap() + .expire( + VerificationTerminalReason::PaymentRequestRejected, + datetime!(2026-05-29 12:02:00 UTC), + ) + .unwrap(); + let task_ids = FixedTaskIdGenerator::new(TaskId::from_str(TASK_ID).unwrap()); + let tasks = CapturingTaskRepository::with_existing(existing); + let clock = FixedClock::new(datetime!(2026-05-29 12:05:00 UTC)); + let use_case = SubmitProofBundleUseCase::new(&task_ids, &tasks, &clock); + + let same_attempt = use_case + .execute(SubmitProofBundleRequest { + submitted_proof_bundle: submitted_proof_bundle(), + }) + .await + .unwrap(); + assert_eq!(same_attempt.status, VerificationTaskStatus::Expired); + assert_eq!(task_ids.generate_count(), 0); + assert_eq!(tasks.insert_count(), 0); + + let fresh_bundle_id = BundleId::from_bytes([1; 16]); + let mut fresh_submission = submitted_proof_bundle(); + fresh_submission.bundle_id = fresh_bundle_id.clone(); + + let result = use_case + .execute(SubmitProofBundleRequest { + submitted_proof_bundle: fresh_submission, + }) + .await + .unwrap(); + + assert_eq!(result.bundle_id, fresh_bundle_id); + assert_eq!(result.status, VerificationTaskStatus::Pending); + assert_eq!(task_ids.generate_count(), 1); + assert_eq!(tasks.insert_count(), 1); + } + #[tokio::test] async fn submit_proof_bundle_conflicts_when_existing_handle_has_different_proof_bundle() { let existing = verification_task_with_bundle(submitted_proof_bundle()); @@ -308,6 +357,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } diff --git a/locks-service/src/infrastructure/memory/verification_task_claims.rs b/locks-service/src/infrastructure/memory/verification_task_claims.rs index f91c6fd..0bbac30 100644 --- a/locks-service/src/infrastructure/memory/verification_task_claims.rs +++ b/locks-service/src/infrastructure/memory/verification_task_claims.rs @@ -137,7 +137,7 @@ impl VerificationTaskClaimer for InMemoryVerificationTaskClaimer { && record.claim_token.as_ref() == Some(claim_token) && record .claim_expires_at - .is_some_and(|claim_expires_at| claim_expires_at >= now) + .is_some_and(|claim_expires_at| claim_expires_at > now) }) else { return Ok(None); }; @@ -167,23 +167,32 @@ impl VerificationTaskClaimer for InMemoryVerificationTaskClaimer { ) -> Result, ApplicationError> { if !matches!( task.status, - VerificationTaskStatus::Completed + VerificationTaskStatus::PublishingEntitlement + | VerificationTaskStatus::Completed | VerificationTaskStatus::Failed | VerificationTaskStatus::Expired ) { return Err(ApplicationError::InvalidVerificationTaskState { - message: "claimed task transition must be terminal".to_owned(), + message: "claimed task transition must publish or terminalize".to_owned(), }); } + let expected_status = match task.status { + VerificationTaskStatus::PublishingEntitlement => VerificationTaskStatus::InProgress, + VerificationTaskStatus::Completed => VerificationTaskStatus::PublishingEntitlement, + VerificationTaskStatus::Failed | VerificationTaskStatus::Expired => { + VerificationTaskStatus::InProgress + } + VerificationTaskStatus::Pending | VerificationTaskStatus::InProgress => unreachable!(), + }; let mut records = self.records.write().await; let Some(record) = records.iter_mut().find(|record| { record.task.task_id == task.task_id - && record.task.status == VerificationTaskStatus::InProgress + && record.task.status == expected_status && record.claimed_by.as_deref() == Some(worker_id) && record.claim_token.as_ref() == Some(claim_token) && record .claim_expires_at - .is_some_and(|claim_expires_at| claim_expires_at >= now) + .is_some_and(|claim_expires_at| claim_expires_at > now) }) else { return Ok(None); }; @@ -193,10 +202,12 @@ impl VerificationTaskClaimer for InMemoryVerificationTaskClaimer { .await?; } record.task = task; - record.claimed_by = None; - record.claim_token = None; - record.claim_expires_at = None; - record.next_attempt_at = None; + if record.task.status != VerificationTaskStatus::PublishingEntitlement { + record.claimed_by = None; + record.claim_token = None; + record.claim_expires_at = None; + record.next_attempt_at = None; + } Ok(Some(record.task.clone())) } } @@ -209,7 +220,10 @@ impl ClaimableVerificationTask { .is_none_or(|next_attempt_at| next_attempt_at <= now), VerificationTaskStatus::InProgress => self .claim_expires_at - .is_some_and(|claim_expires_at| claim_expires_at < now), + .is_some_and(|claim_expires_at| claim_expires_at <= now), + VerificationTaskStatus::PublishingEntitlement => self + .claim_expires_at + .is_some_and(|claim_expires_at| claim_expires_at <= now), VerificationTaskStatus::Completed | VerificationTaskStatus::Failed | VerificationTaskStatus::Expired => false, @@ -226,10 +240,15 @@ mod tests { use locks_core::ids::{BundleId, CreatorPubky, PubkyLockResource, TaskId}; use locks_core::lock_policy::VerifierType; - use locks_core::verification::{Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle}; + use locks_core::verification::{ + EntitlementLifetime, Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle, + VERIFIED_PROOF_BUNDLE_VERSION, VerificationResult, VerifiedProofBundle, + }; use super::InMemoryVerificationTaskClaimer; - use crate::application::models::{VerificationTaskRecord, VerificationTaskStatus}; + use crate::application::models::{ + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, + }; use crate::application::ports::{VerificationTaskClaimer, VerificationTaskRepository}; use crate::infrastructure::memory::verification_tasks::InMemoryVerificationTaskRepository; @@ -415,14 +434,13 @@ mod tests { .await .unwrap() .unwrap(); - let completed = second + let publishing = second .task .clone() - .transition_to( - VerificationTaskStatus::Completed, - reclaimed_at + time::Duration::seconds(1), - None, - ) + .begin_entitlement_publication(entitlement_for(&second.task)) + .unwrap(); + let completed = publishing + .complete_entitlement_publication(reclaimed_at + time::Duration::seconds(1)) .unwrap(); let failed = second .task @@ -433,12 +451,20 @@ mod tests { Some("stale failure".to_owned()), ) .unwrap(); + let expired = second + .task + .clone() + .expire( + VerificationTerminalReason::PaymentRequestRejected, + reclaimed_at + time::Duration::seconds(1), + ) + .unwrap(); - for terminal in [completed.clone(), failed] { + for stale_transition in [publishing.clone(), completed.clone(), failed, expired] { assert_eq!( claimer .persist_claimed_verification_task_transition( - terminal, + stale_transition, "worker-a", &first.claim_token, reclaimed_at, @@ -448,6 +474,19 @@ mod tests { None ); } + assert_eq!( + claimer + .persist_claimed_verification_task_transition( + publishing, + "worker-a", + &second.claim_token, + reclaimed_at, + ) + .await + .unwrap() + .map(|task| task.status), + Some(VerificationTaskStatus::PublishingEntitlement) + ); assert_eq!( claimer .persist_claimed_verification_task_transition( @@ -543,20 +582,94 @@ mod tests { ); } + #[tokio::test] + async fn claim_expiry_instant_rejects_owner_retry_and_allows_reclaim() { + let pending = task( + "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d10", + VerificationTaskStatus::Pending, + ); + let task_id = pending.task_id; + let claimer = InMemoryVerificationTaskClaimer::new(vec![pending]); + let first = claimer + .claim_next_verification_task("worker-a", NOW, CLAIM_EXPIRES_AT) + .await + .unwrap() + .unwrap(); + + assert_eq!( + claimer + .schedule_verification_task_retry( + &task_id, + "worker-a", + &first.claim_token, + CLAIM_EXPIRES_AT, + CLAIM_EXPIRES_AT + time::Duration::seconds(10), + ) + .await + .unwrap(), + None + ); + assert!( + claimer + .claim_next_verification_task( + "worker-b", + CLAIM_EXPIRES_AT, + CLAIM_EXPIRES_AT + time::Duration::minutes(5), + ) + .await + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn claim_expiry_instant_rejects_owner_terminal_write() { + let pending = task( + "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d10", + VerificationTaskStatus::Pending, + ); + let claimer = InMemoryVerificationTaskClaimer::new(vec![pending]); + let claim = claimer + .claim_next_verification_task("worker-a", NOW, CLAIM_EXPIRES_AT) + .await + .unwrap() + .unwrap(); + let expired = claim + .task + .clone() + .expire( + VerificationTerminalReason::PaymentRequestRejected, + CLAIM_EXPIRES_AT, + ) + .unwrap(); + + assert_eq!( + claimer + .persist_claimed_verification_task_transition( + expired, + "worker-a", + &claim.claim_token, + CLAIM_EXPIRES_AT, + ) + .await + .unwrap(), + None + ); + } + #[tokio::test] async fn terminal_tasks_are_not_claimed() { - let completed = task( + let completed_source = task( "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d10", VerificationTaskStatus::Pending, - ) - .transition_to(VerificationTaskStatus::InProgress, NOW, None) - .unwrap() - .transition_to( - VerificationTaskStatus::Completed, - datetime!(2026-05-29 12:11:00 UTC), - None, - ) - .unwrap(); + ); + let completed = completed_source + .transition_to(VerificationTaskStatus::InProgress, NOW, None) + .unwrap() + .begin_entitlement_publication(entitlement_for(&completed_source)) + .unwrap() + .complete_entitlement_publication(datetime!(2026-05-29 12:11:00 UTC)) + .unwrap(); let failed = task( "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d11", VerificationTaskStatus::Pending, @@ -573,7 +686,12 @@ mod tests { "018fc6ec-2f3d-4f7e-8b7d-6f5c4b3a2d12", VerificationTaskStatus::Pending, ) - .transition_to(VerificationTaskStatus::Expired, NOW, None) + .transition_to(VerificationTaskStatus::InProgress, NOW, None) + .unwrap() + .expire( + VerificationTerminalReason::PaymentDeadlineExpired, + datetime!(2026-05-29 12:11:00 UTC), + ) .unwrap(); let claimer = InMemoryVerificationTaskClaimer::new(vec![completed, failed, expired]); @@ -665,6 +783,18 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, + } + } + + fn entitlement_for(task: &VerificationTaskRecord) -> VerifiedProofBundle { + VerifiedProofBundle { + version: VERIFIED_PROOF_BUNDLE_VERSION, + bundle_id: task.submitted_proof_bundle.bundle_id.clone(), + pubky_lock_resource: task.submitted_proof_bundle.pubky_lock_resource.clone(), + verification_result: VerificationResult { criteria: vec![] }, + entitlement_lifetime: EntitlementLifetime::Unbounded, } } } diff --git a/locks-service/src/infrastructure/memory/verification_tasks.rs b/locks-service/src/infrastructure/memory/verification_tasks.rs index b2df467..d548241 100644 --- a/locks-service/src/infrastructure/memory/verification_tasks.rs +++ b/locks-service/src/infrastructure/memory/verification_tasks.rs @@ -281,6 +281,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } } diff --git a/locks-service/src/infrastructure/postgres/migrations.rs b/locks-service/src/infrastructure/postgres/migrations.rs index 29a2754..40552eb 100644 --- a/locks-service/src/infrastructure/postgres/migrations.rs +++ b/locks-service/src/infrastructure/postgres/migrations.rs @@ -55,6 +55,13 @@ mod tests { assert_column_exists(&mut connection, "verification_tasks", "bundle_id").await; assert_column_exists(&mut connection, "verification_tasks", "next_attempt_at").await; assert_column_exists(&mut connection, "verification_tasks", "claim_token").await; + assert_column_exists(&mut connection, "verification_tasks", "terminal_reason").await; + assert_column_exists( + &mut connection, + "verification_tasks", + "entitlement_to_publish", + ) + .await; assert_index_exists( &mut connection, "verification_tasks", @@ -176,7 +183,7 @@ mod tests { .fetch_all(database.pool()) .await .unwrap(); - assert_eq!(applied_versions, (1..=10).collect::>()); + assert_eq!(applied_versions, (1..=12).collect::>()); sqlx::query( "INSERT INTO frontend_sessions (token_hash, creator, created_at, expires_at) @@ -196,6 +203,36 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn verification_task_state_constraint_rejects_incomplete_terminal_and_publication_tuples() + { + let database = TestDatabase::create().await; + + for (status, extra_columns, extra_values) in [ + ("completed", "", ""), + ("failed", ", started_at, completed_at", ", NOW(), NOW()"), + ("expired", ", started_at, completed_at", ", NOW(), NOW()"), + ("publishing_entitlement", ", started_at", ", NOW()"), + ] { + let result = sqlx::query(&format!( + "INSERT INTO verification_tasks ( + task_id, status, submitted_proof_bundle, submitted_at, creator, bundle_id + {extra_columns} + ) VALUES ($1, $2, '{{}}'::jsonb, NOW(), $3, $4 {extra_values})" + )) + .bind(uuid::Uuid::new_v4()) + .bind(status) + .bind(format!("creator-{status}")) + .bind(format!("bundle-{status}")) + .execute(database.pool()) + .await; + + assert!(result.is_err(), "incomplete {status} tuple was accepted"); + } + + database.cleanup().await; + } + async fn assert_table_exists( connection: &mut sqlx::pool::PoolConnection, table_name: &str, diff --git a/locks-service/src/infrastructure/postgres/verification_task_claims.rs b/locks-service/src/infrastructure/postgres/verification_task_claims.rs index 8e12892..725461b 100644 --- a/locks-service/src/infrastructure/postgres/verification_task_claims.rs +++ b/locks-service/src/infrastructure/postgres/verification_task_claims.rs @@ -5,10 +5,12 @@ use sqlx::PgPool; use crate::application::errors::ApplicationError; use crate::application::models::{ ClaimedVerificationTask, VerificationTaskRecord, VerificationTaskStatus, + VerificationTerminalReason, }; use crate::application::ports::VerificationTaskClaimer; use crate::infrastructure::postgres::verification_tasks::{ VERIFICATION_TASK_ROW_COLUMNS, VerificationTaskRow, row_to_task, status_to_database, + verified_proof_bundle_to_json, }; /// Postgres-backed worker lease claimer for verification tasks. @@ -36,9 +38,9 @@ impl VerificationTaskClaimer for PostgresVerificationTaskClaimer { let sql = format!( "UPDATE verification_tasks SET - status = 'in_progress', + status = CASE WHEN status = 'pending' THEN 'in_progress' ELSE status END, claimed_by = $1, - claim_expires_at = $2, + claim_expires_at = clock_timestamp() + ($2 - $3), claim_token = $4, next_attempt_at = NULL, started_at = COALESCE(started_at, $3), @@ -48,8 +50,9 @@ impl VerificationTaskClaimer for PostgresVerificationTaskClaimer { SELECT task_id FROM verification_tasks WHERE ((status = 'pending' - AND (next_attempt_at IS NULL OR next_attempt_at <= $3)) - OR (status = 'in_progress' AND claim_expires_at < $3)) + AND (next_attempt_at IS NULL OR next_attempt_at <= clock_timestamp())) + OR (status IN ('in_progress', 'publishing_entitlement') + AND claim_expires_at <= clock_timestamp())) AND creator = split_part(submitted_proof_bundle->>'pubky_lock_resource', '/', 1) AND bundle_id = submitted_proof_bundle->>'bundle_id' ORDER BY submitted_at @@ -90,14 +93,14 @@ impl VerificationTaskClaimer for PostgresVerificationTaskClaimer { claimed_by = NULL, claim_token = NULL, claim_expires_at = NULL, - next_attempt_at = $5, + next_attempt_at = clock_timestamp() + ($5 - $4), last_attempt_error = NULL, updated_at = $4 WHERE task_id = $1::uuid AND status = 'in_progress' AND claimed_by = $2 AND claim_token = $3 - AND claim_expires_at >= $4 + AND claim_expires_at > clock_timestamp() RETURNING {VERIFICATION_TASK_ROW_COLUMNS}" ); let row = sqlx::query_as::<_, VerificationTaskRow>(&sql) @@ -122,31 +125,46 @@ impl VerificationTaskClaimer for PostgresVerificationTaskClaimer { ) -> Result, ApplicationError> { if !matches!( task.status, - VerificationTaskStatus::Completed + VerificationTaskStatus::PublishingEntitlement + | VerificationTaskStatus::Completed | VerificationTaskStatus::Failed | VerificationTaskStatus::Expired ) { return Err(ApplicationError::InvalidVerificationTaskState { - message: "claimed task transition must be terminal".to_owned(), + message: "claimed task transition must publish or terminalize".to_owned(), }); } + let expected_status = match task.status { + VerificationTaskStatus::PublishingEntitlement => "in_progress", + VerificationTaskStatus::Completed => "publishing_entitlement", + VerificationTaskStatus::Failed | VerificationTaskStatus::Expired => "in_progress", + VerificationTaskStatus::Pending | VerificationTaskStatus::InProgress => unreachable!(), + }; + let entitlement_to_publish = task + .entitlement_to_publish + .as_ref() + .map(verified_proof_bundle_to_json) + .transpose()?; + let retain_claim = task.status == VerificationTaskStatus::PublishingEntitlement; let sql = format!( "UPDATE verification_tasks SET status = $5, started_at = $6, completed_at = $7, failure_message = $8, - claimed_by = NULL, - claim_token = NULL, - claim_expires_at = NULL, + terminal_reason = $9, + entitlement_to_publish = $10, + claimed_by = CASE WHEN $11 THEN claimed_by ELSE NULL END, + claim_token = CASE WHEN $11 THEN claim_token ELSE NULL END, + claim_expires_at = CASE WHEN $11 THEN claim_expires_at ELSE NULL END, next_attempt_at = NULL, last_attempt_error = NULL, updated_at = $4 WHERE task_id = $1::uuid - AND status = 'in_progress' + AND status = $12 AND claimed_by = $2 AND claim_token = $3 - AND claim_expires_at >= $4 + AND claim_expires_at > clock_timestamp() RETURNING {VERIFICATION_TASK_ROW_COLUMNS}" ); let row = sqlx::query_as::<_, VerificationTaskRow>(&sql) @@ -158,6 +176,10 @@ impl VerificationTaskClaimer for PostgresVerificationTaskClaimer { .bind(task.started_at) .bind(task.completed_at) .bind(task.failure_message) + .bind(task.terminal_reason.map(VerificationTerminalReason::as_str)) + .bind(entitlement_to_publish) + .bind(retain_claim) + .bind(expected_status) .fetch_optional(&self.pool) .await .map_err(storage_error)?; @@ -181,10 +203,15 @@ mod tests { use locks_core::ids::{BundleId, CreatorPubky, PubkyLockResource, TaskId}; use locks_core::lock_policy::VerifierType; - use locks_core::verification::{Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle}; + use locks_core::verification::{ + EntitlementLifetime, Proof, SUBMITTED_PROOF_BUNDLE_VERSION, SubmittedProofBundle, + VERIFIED_PROOF_BUNDLE_VERSION, VerificationResult, VerifiedProofBundle, + }; use super::PostgresVerificationTaskClaimer; - use crate::application::models::{VerificationTaskRecord, VerificationTaskStatus}; + use crate::application::models::{ + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, + }; use crate::application::ports::{VerificationTaskClaimer, VerificationTaskRepository}; use crate::infrastructure::postgres::testing::TestDatabase; use crate::infrastructure::postgres::verification_tasks::PostgresVerificationTaskRepository; @@ -379,6 +406,7 @@ mod tests { .await .unwrap() .unwrap(); + mark_claim_expired(database.pool(), &pending.task_id).await; let reclaimed_at = CLAIM_EXPIRES_AT + time::Duration::milliseconds(1); let second = claimer .claim_next_verification_task( @@ -431,12 +459,16 @@ mod tests { VerificationTaskStatus::Pending, datetime!(2026-05-29 12:00:00 UTC), ); - repository.insert_verification_task(pending).await.unwrap(); + repository + .insert_verification_task(pending.clone()) + .await + .unwrap(); let first = claimer .claim_next_verification_task("worker-a", NOW, CLAIM_EXPIRES_AT) .await .unwrap() .unwrap(); + mark_claim_expired(database.pool(), &pending.task_id).await; let reclaimed_at = CLAIM_EXPIRES_AT + time::Duration::milliseconds(1); let second = claimer .claim_next_verification_task( @@ -447,14 +479,13 @@ mod tests { .await .unwrap() .unwrap(); - let completed = second + let publishing = second .task .clone() - .transition_to( - VerificationTaskStatus::Completed, - reclaimed_at + time::Duration::seconds(1), - None, - ) + .begin_entitlement_publication(entitlement_for(&second.task)) + .unwrap(); + let completed = publishing + .complete_entitlement_publication(reclaimed_at + time::Duration::seconds(1)) .unwrap(); let failed = second .task @@ -465,12 +496,20 @@ mod tests { Some("stale failure".to_owned()), ) .unwrap(); + let expired = second + .task + .clone() + .expire( + VerificationTerminalReason::PaymentRequestRejected, + reclaimed_at + time::Duration::seconds(1), + ) + .unwrap(); - for terminal in [completed.clone(), failed] { + for stale_transition in [publishing.clone(), completed.clone(), failed, expired] { assert_eq!( claimer .persist_claimed_verification_task_transition( - terminal, + stale_transition, "worker-a", &first.claim_token, reclaimed_at, @@ -480,6 +519,19 @@ mod tests { None ); } + assert_eq!( + claimer + .persist_claimed_verification_task_transition( + publishing, + "worker-a", + &second.claim_token, + reclaimed_at, + ) + .await + .unwrap() + .map(|task| task.status), + Some(VerificationTaskStatus::PublishingEntitlement) + ); assert_eq!( claimer .persist_claimed_verification_task_transition( @@ -530,6 +582,7 @@ mod tests { .unwrap(), None ); + mark_claim_expired(database.pool(), &pending.task_id).await; assert_eq!( claimer .schedule_verification_task_retry( @@ -543,6 +596,7 @@ mod tests { .unwrap(), None ); + mark_claim_active(database.pool(), &pending.task_id).await; let scheduled = claimer .schedule_verification_task_retry( &pending.task_id, @@ -570,6 +624,15 @@ mod tests { .unwrap(), None ); + sqlx::query( + "UPDATE verification_tasks + SET next_attempt_at = clock_timestamp() - INTERVAL '1 millisecond' + WHERE task_id = $1::uuid", + ) + .bind(pending.task_id.to_string()) + .execute(database.pool()) + .await + .unwrap(); assert!( claimer .claim_next_verification_task( @@ -597,11 +660,13 @@ mod tests { async fn mark_claim_expired(pool: &sqlx::PgPool, task_id: &TaskId) { sqlx::query( "UPDATE verification_tasks - SET claimed_by = 'worker-a', claim_expires_at = $2 + SET claimed_by = 'worker-a', + claim_token = COALESCE(claim_token, $2), + claim_expires_at = clock_timestamp() - INTERVAL '1 millisecond' WHERE task_id = $1::uuid", ) .bind(task_id.to_string()) - .bind(datetime!(2026-05-29 12:05:00 UTC)) + .bind(uuid::Uuid::new_v4()) .execute(pool) .await .expect("mark claim expired"); @@ -610,11 +675,13 @@ mod tests { async fn mark_claim_active(pool: &sqlx::PgPool, task_id: &TaskId) { sqlx::query( "UPDATE verification_tasks - SET claimed_by = 'worker-a', claim_expires_at = $2 + SET claimed_by = 'worker-a', + claim_token = COALESCE(claim_token, $2), + claim_expires_at = clock_timestamp() + INTERVAL '5 minutes' WHERE task_id = $1::uuid", ) .bind(task_id.to_string()) - .bind(datetime!(2026-05-29 12:11:00 UTC)) + .bind(uuid::Uuid::new_v4()) .execute(pool) .await .expect("mark claim active"); @@ -627,11 +694,9 @@ mod tests { .unwrap(); match status { VerificationTaskStatus::Completed => in_progress - .transition_to( - VerificationTaskStatus::Completed, - datetime!(2026-05-29 12:01:00 UTC), - None, - ) + .begin_entitlement_publication(entitlement_for(&in_progress)) + .unwrap() + .complete_entitlement_publication(datetime!(2026-05-29 12:01:00 UTC)) .unwrap(), VerificationTaskStatus::Failed => in_progress .transition_to( @@ -640,16 +705,15 @@ mod tests { Some("failed".to_owned()), ) .unwrap(), - VerificationTaskStatus::Expired => { - task(task_id, VerificationTaskStatus::Pending, started_at) - .transition_to( - VerificationTaskStatus::Expired, - datetime!(2026-05-29 12:01:00 UTC), - None, - ) - .unwrap() - } - VerificationTaskStatus::Pending | VerificationTaskStatus::InProgress => unreachable!(), + VerificationTaskStatus::Expired => in_progress + .expire( + VerificationTerminalReason::PaymentRequestRejected, + datetime!(2026-05-29 12:01:00 UTC), + ) + .unwrap(), + VerificationTaskStatus::Pending + | VerificationTaskStatus::InProgress + | VerificationTaskStatus::PublishingEntitlement => unreachable!(), } } @@ -681,6 +745,18 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, + } + } + + fn entitlement_for(task: &VerificationTaskRecord) -> VerifiedProofBundle { + VerifiedProofBundle { + version: VERIFIED_PROOF_BUNDLE_VERSION, + bundle_id: task.submitted_proof_bundle.bundle_id.clone(), + pubky_lock_resource: task.submitted_proof_bundle.pubky_lock_resource.clone(), + verification_result: VerificationResult { criteria: vec![] }, + entitlement_lifetime: EntitlementLifetime::Unbounded, } } diff --git a/locks-service/src/infrastructure/postgres/verification_tasks.rs b/locks-service/src/infrastructure/postgres/verification_tasks.rs index e025a07..99bcb7e 100644 --- a/locks-service/src/infrastructure/postgres/verification_tasks.rs +++ b/locks-service/src/infrastructure/postgres/verification_tasks.rs @@ -4,10 +4,12 @@ use async_trait::async_trait; use sqlx::{FromRow, PgPool}; use locks_core::ids::{BundleId, CreatorPubky, TaskId}; -use locks_core::verification::SubmittedProofBundle; +use locks_core::verification::{SubmittedProofBundle, VerifiedProofBundle}; use crate::application::errors::ApplicationError; -use crate::application::models::{VerificationTaskRecord, VerificationTaskStatus}; +use crate::application::models::{ + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, +}; use crate::application::ports::VerificationTaskRepository; /// Postgres-backed repository for Lock Server private verification task state. @@ -27,6 +29,8 @@ pub(super) struct VerificationTaskRow { started_at: Option, completed_at: Option, failure_message: Option, + terminal_reason: Option, + entitlement_to_publish: Option, } struct VerificationTaskWriteRow { @@ -39,6 +43,8 @@ struct VerificationTaskWriteRow { started_at: Option, completed_at: Option, failure_message: Option, + terminal_reason: Option<&'static str>, + entitlement_to_publish: Option, } pub(super) const VERIFICATION_TASK_ROW_COLUMNS: &str = " @@ -50,7 +56,9 @@ pub(super) const VERIFICATION_TASK_ROW_COLUMNS: &str = " submitted_at, started_at, completed_at, - failure_message"; + failure_message, + terminal_reason, + entitlement_to_publish"; impl PostgresVerificationTaskRepository { /// Creates a repository backed by the provided migrated Postgres pool. @@ -76,9 +84,11 @@ impl VerificationTaskRepository for PostgresVerificationTaskRepository { submitted_at, started_at, completed_at, - failure_message + failure_message, + terminal_reason, + entitlement_to_publish ) - VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING", ) .bind(row.task_id) @@ -90,6 +100,8 @@ impl VerificationTaskRepository for PostgresVerificationTaskRepository { .bind(row.started_at) .bind(row.completed_at) .bind(row.failure_message) + .bind(row.terminal_reason) + .bind(row.entitlement_to_publish) .execute(&self.pool) .await .map_err(storage_error)?; @@ -118,6 +130,8 @@ impl VerificationTaskRepository for PostgresVerificationTaskRepository { started_at = $7, completed_at = $8, failure_message = $9, + terminal_reason = $10, + entitlement_to_publish = $11, updated_at = now() WHERE task_id = $1::uuid", ) @@ -130,6 +144,8 @@ impl VerificationTaskRepository for PostgresVerificationTaskRepository { .bind(row.started_at) .bind(row.completed_at) .bind(row.failure_message) + .bind(row.terminal_reason) + .bind(row.entitlement_to_publish) .execute(&self.pool) .await .map_err(storage_error)?; @@ -232,6 +248,15 @@ impl TryFrom for VerificationTaskRecord { started_at: row.started_at, completed_at: row.completed_at, failure_message: row.failure_message, + terminal_reason: row + .terminal_reason + .as_deref() + .map(terminal_reason_from_database) + .transpose()?, + entitlement_to_publish: row + .entitlement_to_publish + .map(verified_proof_bundle_from_json) + .transpose()?, }) } } @@ -262,10 +287,24 @@ impl TryFrom<&VerificationTaskRecord> for VerificationTaskWriteRow { started_at: task.started_at, completed_at: task.completed_at, failure_message: task.failure_message.clone(), + terminal_reason: task.terminal_reason.map(VerificationTerminalReason::as_str), + entitlement_to_publish: task + .entitlement_to_publish + .as_ref() + .map(verified_proof_bundle_to_json) + .transpose()?, }) } } +fn terminal_reason_from_database( + value: &str, +) -> Result { + VerificationTerminalReason::from_storage_value(value).ok_or_else(|| ApplicationError::Storage { + message: format!("invalid verification task terminal_reason stored in Postgres: {value}"), + }) +} + fn submitted_proof_bundle_to_json( submitted_proof_bundle: &SubmittedProofBundle, ) -> Result { @@ -282,10 +321,27 @@ fn submitted_proof_bundle_from_json( }) } +pub(super) fn verified_proof_bundle_to_json( + entitlement: &VerifiedProofBundle, +) -> Result { + serde_json::to_value(entitlement).map_err(|error| ApplicationError::Storage { + message: format!("serialize entitlement publication payload for Postgres: {error}"), + }) +} + +fn verified_proof_bundle_from_json( + value: serde_json::Value, +) -> Result { + serde_json::from_value(value).map_err(|error| ApplicationError::Storage { + message: format!("deserialize entitlement publication payload from Postgres: {error}"), + }) +} + pub(super) fn status_to_database(status: VerificationTaskStatus) -> &'static str { match status { VerificationTaskStatus::Pending => "pending", VerificationTaskStatus::InProgress => "in_progress", + VerificationTaskStatus::PublishingEntitlement => "publishing_entitlement", VerificationTaskStatus::Completed => "completed", VerificationTaskStatus::Failed => "failed", VerificationTaskStatus::Expired => "expired", @@ -296,6 +352,7 @@ fn status_from_database(status: &str) -> Result Ok(VerificationTaskStatus::Pending), "in_progress" => Ok(VerificationTaskStatus::InProgress), + "publishing_entitlement" => Ok(VerificationTaskStatus::PublishingEntitlement), "completed" => Ok(VerificationTaskStatus::Completed), "failed" => Ok(VerificationTaskStatus::Failed), "expired" => Ok(VerificationTaskStatus::Expired), @@ -324,7 +381,9 @@ mod tests { use super::PostgresVerificationTaskRepository; use crate::application::errors::ApplicationError; - use crate::application::models::{VerificationTaskRecord, VerificationTaskStatus}; + use crate::application::models::{ + VerificationTaskRecord, VerificationTaskStatus, VerificationTerminalReason, + }; use crate::application::ports::VerificationTaskRepository; use crate::infrastructure::postgres::testing::TestDatabase; @@ -450,6 +509,41 @@ mod tests { database.cleanup().await; } + #[tokio::test] + async fn expired_terminal_reason_survives_repository_wrapper_recreation() { + let database = TestDatabase::create().await; + let original_repo = PostgresVerificationTaskRepository::new(database.pool().clone()); + let recreated_repo = PostgresVerificationTaskRepository::new(database.pool().clone()); + let task_id = TaskId::from_str(TASK_ID).unwrap(); + let expired = task(VerificationTaskStatus::Pending) + .transition_to( + VerificationTaskStatus::InProgress, + datetime!(2026-05-29 12:01:00 UTC), + None, + ) + .unwrap() + .expire( + VerificationTerminalReason::PaymentRequestCanceled, + datetime!(2026-05-29 12:02:00 UTC), + ) + .unwrap(); + + original_repo + .insert_verification_task(expired.clone()) + .await + .unwrap(); + + assert_eq!( + recreated_repo + .get_verification_task(&task_id) + .await + .unwrap(), + Some(expired) + ); + + database.cleanup().await; + } + #[tokio::test] async fn insert_rejects_task_when_record_creator_diverges_from_submitted_bundle() { let database = TestDatabase::create().await; @@ -537,6 +631,8 @@ mod tests { started_at: None, completed_at: None, failure_message: None, + terminal_reason: None, + entitlement_to_publish: None, } } } diff --git a/locks-service/src/infrastructure/verifiers/dev_static.rs b/locks-service/src/infrastructure/verifiers/dev_static.rs index 78986b5..45b0fda 100644 --- a/locks-service/src/infrastructure/verifiers/dev_static.rs +++ b/locks-service/src/infrastructure/verifiers/dev_static.rs @@ -4,7 +4,7 @@ use locks_core::lock_policy::VerifierType; use locks_core::verification::CriterionVerificationResult; use crate::application::errors::ApplicationError; -use crate::application::models::CriterionVerificationRequest; +use crate::application::models::{CriterionVerificationOutcome, CriterionVerificationRequest}; use crate::application::ports::CriterionVerifier; /// Development-only verifier controlled by `criterion.params.satisfied`. @@ -16,7 +16,7 @@ impl CriterionVerifier for DevStaticVerifier { async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result { + ) -> Result { let satisfied = request .criterion .params @@ -26,13 +26,15 @@ impl CriterionVerifier for DevStaticVerifier { message: "dev-static criterion params.satisfied must be a boolean".to_owned(), })?; - Ok(CriterionVerificationResult { - criterion_id: request.criterion.criterion_id, - satisfied, - verified_at: request.verified_at, - verified_by: request.verified_by, - verifier_type: VerifierType::DevStatic, - }) + Ok(CriterionVerificationOutcome::Satisfied( + CriterionVerificationResult { + criterion_id: request.criterion.criterion_id, + satisfied, + verified_at: request.verified_at, + verified_by: request.verified_by, + verifier_type: VerifierType::DevStatic, + }, + )) } } @@ -49,17 +51,20 @@ mod tests { use super::DevStaticVerifier; use crate::application::errors::ApplicationError; - use crate::application::models::CriterionVerificationRequest; + use crate::application::models::{CriterionVerificationOutcome, CriterionVerificationRequest}; use crate::application::ports::CriterionVerifier; const LOCK_ID: &str = "000G40R40M30E209185GR38E1W8124GK2GAHC5RR34D1P70X3RFG"; #[tokio::test] async fn dev_static_returns_satisfied_result_when_param_is_true() { - let result = DevStaticVerifier + let CriterionVerificationOutcome::Satisfied(result) = DevStaticVerifier .verify(request(json!({ "satisfied": true }))) .await - .unwrap(); + .unwrap() + else { + panic!("dev-static should return criterion evidence"); + }; assert_eq!(result.criterion_id, "criterion-1"); assert!(result.satisfied); @@ -70,10 +75,13 @@ mod tests { #[tokio::test] async fn dev_static_returns_unsatisfied_result_when_param_is_false() { - let result = DevStaticVerifier + let CriterionVerificationOutcome::Satisfied(result) = DevStaticVerifier .verify(request(json!({ "satisfied": false }))) .await - .unwrap(); + .unwrap() + else { + panic!("dev-static should return criterion evidence"); + }; assert_eq!(result.criterion_id, "criterion-1"); assert!(!result.satisfied); diff --git a/locks-service/src/infrastructure/verifiers/paykit_payment.rs b/locks-service/src/infrastructure/verifiers/paykit_payment.rs index 7cde36e..00939ee 100644 --- a/locks-service/src/infrastructure/verifiers/paykit_payment.rs +++ b/locks-service/src/infrastructure/verifiers/paykit_payment.rs @@ -1,33 +1,51 @@ use async_trait::async_trait; use locks_core::ids::{BundleId, CreatorPubky}; use locks_core::lock_policy::VerifierType; -use locks_core::verification::CriterionVerificationResult; use std::sync::Arc; use crate::application::errors::ApplicationError; -use crate::application::models::CriterionVerificationRequest; +use crate::application::models::{ + CriterionVerificationOutcome, CriterionVerificationRequest, VerificationTerminalReason, +}; use crate::application::ports::CriterionVerifier; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PaykitPaymentStatusKind { +pub enum PaykitPaymentRequestState { + Proposed, + ProposalExpired, + Accepted, + Rejected, + Canceled, + ProofSubmitted, + ActiveRecurring, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaykitPaymentState { Undetected, Detected, Confirmed, + Expired, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PaykitPaymentStatus { - pub status: PaykitPaymentStatusKind, + pub request_state: PaykitPaymentRequestState, + pub payment_state: PaykitPaymentState, pub confirmations: u32, pub amount_matched: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PaykitPaymentStatusError; +pub enum PaykitPaymentStatusError { + Unavailable, + Conflict, + InvalidResponse, +} #[async_trait] pub trait PaykitPaymentStatusClient: Send + Sync { - async fn transaction_status( + async fn payment_request_status( &self, creator: &CreatorPubky, bundle_id: &BundleId, @@ -39,12 +57,12 @@ impl PaykitPaymentStatusClient for Arc where C: PaykitPaymentStatusClient + ?Sized, { - async fn transaction_status( + async fn payment_request_status( &self, creator: &CreatorPubky, bundle_id: &BundleId, ) -> Result { - (**self).transaction_status(creator, bundle_id).await + (**self).payment_request_status(creator, bundle_id).await } } @@ -71,33 +89,83 @@ where async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result { - let status = self + ) -> Result { + let status = match self .client - .transaction_status(&request.creator, &request.bundle_id) + .payment_request_status(&request.creator, &request.bundle_id) .await - .map_err(|_| ApplicationError::VerificationPending)?; - if !payment_status_satisfies(status, self.minimum_confirmations) { - return Err(ApplicationError::VerificationPending); - } - Ok(CriterionVerificationResult { - criterion_id: request.criterion.criterion_id, - satisfied: true, - verified_at: request.verified_at, - verified_by: request.verified_by, - verifier_type: VerifierType::PaykitPayment, - }) + { + Ok(status) => Some(status), + Err(PaykitPaymentStatusError::Conflict) => { + return Err(ApplicationError::PaykitPaymentStatusConflict); + } + Err(PaykitPaymentStatusError::InvalidResponse) => { + return Err(ApplicationError::PaykitPaymentStatusInvalidResponse); + } + Err(PaykitPaymentStatusError::Unavailable) => None, + }; + Ok(payment_status_decision( + status, + self.minimum_confirmations, + request, + )) } } -fn payment_status_satisfies(status: PaykitPaymentStatus, minimum_confirmations: u32) -> bool { - if !status.amount_matched { - return false; +fn payment_status_decision( + status: Option, + minimum_confirmations: u32, + request: CriterionVerificationRequest, +) -> CriterionVerificationOutcome { + let Some(status) = status else { + return CriterionVerificationOutcome::Pending; + }; + let terminal_reason = match status.request_state { + PaykitPaymentRequestState::Rejected => { + Some(VerificationTerminalReason::PaymentRequestRejected) + } + PaykitPaymentRequestState::Canceled => { + Some(VerificationTerminalReason::PaymentRequestCanceled) + } + PaykitPaymentRequestState::ProposalExpired => { + Some(VerificationTerminalReason::ProposalExpired) + } + PaykitPaymentRequestState::Accepted + | PaykitPaymentRequestState::ProofSubmitted + | PaykitPaymentRequestState::ActiveRecurring + if status.payment_state == PaykitPaymentState::Expired => + { + Some(VerificationTerminalReason::PaymentDeadlineExpired) + } + _ => None, + }; + if let Some(reason) = terminal_reason { + return CriterionVerificationOutcome::TerminalUnsatisfied(reason); } - match (minimum_confirmations, status.status) { - (0, PaykitPaymentStatusKind::Detected | PaykitPaymentStatusKind::Confirmed) => true, - (required, PaykitPaymentStatusKind::Confirmed) => status.confirmations >= required, - _ => false, + let satisfied = status.amount_matched + && match (minimum_confirmations, status.payment_state) { + (0, PaykitPaymentState::Detected | PaykitPaymentState::Confirmed) => true, + (required, PaykitPaymentState::Confirmed) => status.confirmations >= required, + _ => false, + } + && matches!( + status.request_state, + PaykitPaymentRequestState::Accepted + | PaykitPaymentRequestState::ProofSubmitted + | PaykitPaymentRequestState::ActiveRecurring + ); + if satisfied { + CriterionVerificationOutcome::Satisfied( + locks_core::verification::CriterionVerificationResult { + criterion_id: request.criterion.criterion_id, + satisfied: true, + verified_at: request.verified_at, + verified_by: request.verified_by, + verifier_type: VerifierType::PaykitPayment, + }, + ) + } else { + CriterionVerificationOutcome::Pending } } @@ -115,28 +183,88 @@ mod tests { use locks_core::verification::Proof; use super::{ - PaykitPaymentStatus, PaykitPaymentStatusClient, PaykitPaymentStatusError, - PaykitPaymentStatusKind, PaykitPaymentVerifier, + PaykitPaymentRequestState, PaykitPaymentState, PaykitPaymentStatus, + PaykitPaymentStatusClient, PaykitPaymentStatusError, PaykitPaymentVerifier, }; use crate::application::errors::ApplicationError; - use crate::application::models::CriterionVerificationRequest; + use crate::application::models::{ + CriterionVerificationOutcome, CriterionVerificationRequest, VerificationTerminalReason, + }; use crate::application::ports::CriterionVerifier; const BUNDLE_ID: &str = "000G40R40M30E209185GR38E1W"; const LOCK_ID: &str = "000G40R40M30E209185GR38E1W8124GK2GAHC5RR34D1P70X3RFG"; + #[tokio::test] + async fn terminal_request_state_wins_over_confirmed_payment_evidence() { + for (request_state, reason) in [ + ( + PaykitPaymentRequestState::Rejected, + VerificationTerminalReason::PaymentRequestRejected, + ), + ( + PaykitPaymentRequestState::Canceled, + VerificationTerminalReason::PaymentRequestCanceled, + ), + ( + PaykitPaymentRequestState::ProposalExpired, + VerificationTerminalReason::ProposalExpired, + ), + ] { + let verifier = verifier( + PaykitPaymentStatus { + request_state, + payment_state: PaykitPaymentState::Confirmed, + confirmations: 6, + amount_matched: true, + }, + 1, + ); + + assert_eq!( + verifier.verify(request()).await.unwrap(), + CriterionVerificationOutcome::TerminalUnsatisfied(reason) + ); + } + } + + #[tokio::test] + async fn accepted_expired_payment_is_terminal_without_using_local_time() { + let verifier = verifier( + PaykitPaymentStatus { + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Expired, + confirmations: 6, + amount_matched: true, + }, + 1, + ); + + assert_eq!( + verifier.verify(request()).await.unwrap(), + CriterionVerificationOutcome::TerminalUnsatisfied( + VerificationTerminalReason::PaymentDeadlineExpired + ) + ); + } + #[tokio::test] async fn zero_confirmations_detected_amount_matched_satisfies_payment() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Detected, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Detected, confirmations: 0, amount_matched: true, }, 0, ); - let result = verifier.verify(request()).await.unwrap(); + let CriterionVerificationOutcome::Satisfied(result) = + verifier.verify(request()).await.unwrap() + else { + panic!("detected matched payment should satisfy"); + }; assert_eq!(result.criterion_id, "criterion-1"); assert!(result.satisfied); @@ -151,7 +279,8 @@ mod tests { async fn zero_confirmations_undetected_stays_pending() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Undetected, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Undetected, confirmations: 0, amount_matched: true, }, @@ -160,7 +289,7 @@ mod tests { assert_eq!( verifier.verify(request()).await, - Err(ApplicationError::VerificationPending) + Ok(CriterionVerificationOutcome::Pending) ); } @@ -168,7 +297,8 @@ mod tests { async fn confirmations_required_detected_stays_pending() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Detected, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Detected, confirmations: 3, amount_matched: true, }, @@ -177,7 +307,7 @@ mod tests { assert_eq!( verifier.verify(request()).await, - Err(ApplicationError::VerificationPending) + Ok(CriterionVerificationOutcome::Pending) ); } @@ -185,7 +315,8 @@ mod tests { async fn confirmed_below_required_confirmations_stays_pending() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Confirmed, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Confirmed, confirmations: 0, amount_matched: true, }, @@ -194,7 +325,7 @@ mod tests { assert_eq!( verifier.verify(request()).await, - Err(ApplicationError::VerificationPending) + Ok(CriterionVerificationOutcome::Pending) ); } @@ -202,21 +333,26 @@ mod tests { async fn confirmed_at_required_confirmations_satisfies_payment() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Confirmed, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Confirmed, confirmations: 1, amount_matched: true, }, 1, ); - assert!(verifier.verify(request()).await.unwrap().satisfied); + assert!(matches!( + verifier.verify(request()).await.unwrap(), + CriterionVerificationOutcome::Satisfied(result) if result.satisfied + )); } #[tokio::test] async fn amount_mismatch_stays_pending_even_when_confirmed() { let verifier = verifier( PaykitPaymentStatus { - status: PaykitPaymentStatusKind::Confirmed, + request_state: PaykitPaymentRequestState::Accepted, + payment_state: PaykitPaymentState::Confirmed, confirmations: 6, amount_matched: false, }, @@ -225,7 +361,7 @@ mod tests { assert_eq!( verifier.verify(request()).await, - Err(ApplicationError::VerificationPending) + Ok(CriterionVerificationOutcome::Pending) ); } @@ -235,7 +371,33 @@ mod tests { assert_eq!( verifier.verify(request()).await, - Err(ApplicationError::VerificationPending) + Ok(CriterionVerificationOutcome::Pending) + ); + } + + #[tokio::test] + async fn status_conflict_is_preserved_as_operator_visible_error() { + let verifier = PaykitPaymentVerifier::new( + FakeStatusClient::error_with(PaykitPaymentStatusError::Conflict), + 0, + ); + + assert_eq!( + verifier.verify(request()).await, + Err(ApplicationError::PaykitPaymentStatusConflict) + ); + } + + #[tokio::test] + async fn invalid_status_response_fails_closed() { + let verifier = PaykitPaymentVerifier::new( + FakeStatusClient::error_with(PaykitPaymentStatusError::InvalidResponse), + 0, + ); + + assert_eq!( + verifier.verify(request()).await, + Err(ApplicationError::PaykitPaymentStatusInvalidResponse) ); } @@ -296,8 +458,12 @@ mod tests { } fn error() -> Self { + Self::error_with(PaykitPaymentStatusError::Unavailable) + } + + fn error_with(error: PaykitPaymentStatusError) -> Self { Self { - response: Err(PaykitPaymentStatusError), + response: Err(error), requested_handles: Mutex::new(Vec::new()), } } @@ -309,7 +475,7 @@ mod tests { #[async_trait] impl PaykitPaymentStatusClient for FakeStatusClient { - async fn transaction_status( + async fn payment_request_status( &self, creator: &CreatorPubky, bundle_id: &BundleId, diff --git a/locks-service/src/infrastructure/verifiers/registry.rs b/locks-service/src/infrastructure/verifiers/registry.rs index 14c4c2c..40ed905 100644 --- a/locks-service/src/infrastructure/verifiers/registry.rs +++ b/locks-service/src/infrastructure/verifiers/registry.rs @@ -53,7 +53,7 @@ mod tests { use locks_core::verification::CriterionVerificationResult; use crate::application::errors::ApplicationError; - use crate::application::models::CriterionVerificationRequest; + use crate::application::models::{CriterionVerificationOutcome, CriterionVerificationRequest}; use crate::application::ports::{CriterionVerifier, CriterionVerifierRegistry}; use crate::infrastructure::verifiers::registry::StaticCriterionVerifierRegistry; @@ -91,7 +91,11 @@ mod tests { .verifier_for(VerifierType::DevStatic) .expect("registered dev-static verifier"); - let result = registered.verify(request()).await.unwrap(); + let CriterionVerificationOutcome::Satisfied(result) = + registered.verify(request()).await.unwrap() + else { + panic!("registered fake should return criterion evidence"); + }; assert_eq!(result.criterion_id, "criterion-1"); assert!(result.satisfied); @@ -131,14 +135,16 @@ mod tests { async fn verify( &self, request: CriterionVerificationRequest, - ) -> Result { - Ok(CriterionVerificationResult { - criterion_id: request.criterion.criterion_id, - satisfied: true, - verified_at: request.verified_at, - verified_by: request.verified_by, - verifier_type: request.criterion.verifier_type, - }) + ) -> Result { + Ok(CriterionVerificationOutcome::Satisfied( + CriterionVerificationResult { + criterion_id: request.criterion.criterion_id, + satisfied: true, + verified_at: request.verified_at, + verified_by: request.verified_by, + verifier_type: request.criterion.verifier_type, + }, + )) } } }