Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://127.0.0.1:3001>. The reader remains at <http://127.0.0.1:8088/reader/> 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 <http://127.0.0.1:3001> and keeps the reader at <http://127.0.0.1:8088/reader/>. Payment remains a manual operator action.

For the helper-free loopback browser demo against deployed staging Locks and Paykit services:

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions compose.paykit-local-demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 7 additions & 12 deletions docs/ADRs/0020-locks-paykit-v1-integration-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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

Expand All @@ -108,22 +102,23 @@ 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

- **Let Paykit decide access:** rejected because payment observation is not entitlement policy.
- **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
Expand Down
4 changes: 3 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions docs/DOMAIN_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ Fields:
- `started_at: Option<OffsetDateTime>`
- `completed_at: Option<OffsetDateTime>`
- `failure_message: Option<String>`
- `terminal_reason: Option<VerificationTerminalReason>`

Invariants:

Expand All @@ -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
Expand Down
Loading
Loading