diff --git a/README.md b/README.md index 01d7e3c..78c1daf 100644 --- a/README.md +++ b/README.md @@ -454,7 +454,8 @@ Example: "params": { "recipient_pubky": "pubky", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } } ], @@ -487,7 +488,7 @@ For example: A submitted proof bundle is sent by the viewer before verification. It is not stored as an entitlement unless verification succeeds. -For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, and non-empty `asset`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice. +For `paykit-payment`, the content lock criterion params are exactly `recipient_pubky`, positive base-unit string `amount`, non-empty `asset`, and positive whole-hour JSON `u64` `payment_in`. `recipient_pubky` must equal the content-lock creator. In v1 it must be the lock's only criterion, referenced exactly once by the lock logic. The submitted proof carries no payment details in its proof payload; it uses top-level `reader_public_key` plus the canonical `pubky_lock_resource` so the Lock Server can create the Paykit invoice. Example: diff --git a/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md b/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md index 797b157..8868191 100644 --- a/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md +++ b/docs/ADRs/0020-locks-paykit-v1-integration-boundary.md @@ -27,13 +27,15 @@ The v1 content-lock criterion has verifier wire value `paykit-payment` and param { "recipient_pubky": "pubky", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } ``` - `recipient_pubky` must equal the canonical content-lock creator. - `amount` is a positive decimal integer string in the asset's base unit. - `asset` is an opaque, non-empty string to Locks. Paykit Server owns deployment-specific asset support and base-unit interpretation. +- `payment_in` is a required, nonzero JSON `u64` number of whole hours in Locks policy. - V1 permits exactly one payment criterion, referenced exactly once by the lock logic, and exactly one submitted payment proof. - The submitted payment proof payload is `{}`. `reader_public_key` is top-level submission data. - Content-lock authoring does not require runtime Paykit configuration or availability. diff --git a/docs/API.md b/docs/API.md index 4d256d1..ddc2e63 100644 --- a/docs/API.md +++ b/docs/API.md @@ -24,6 +24,7 @@ The Lock Server has one non-production route family and one authenticated creato - Can run in `development`, `staging`, or `production`. - Require `Authorization: Bearer `. - Derive creator identity from the frontend session. Request-body `creator` is rejected for authenticated routes. + - A guarded path can be owned by only one managed Content Lock for that creator. Creating a different Lock ID for an owned path returns `409 content_lock_path_conflict`. - Missing/unknown/expired frontend sessions use the JSON error envelope (`401 frontend_session_unavailable` or `401 frontend_session_expired`). - Missing/revoked creator-granted homeserver authority remains a separate operational error (`503 creator_authority_unavailable`). - Creator authority status route: `GET /creator/authority-status` @@ -50,7 +51,7 @@ Gated-off routes are plain Axum `404 Not Found` responses because the route is i | --- | --- | --- | --- | --- | | `PUT /creator/priv-resources/content/` | `200` JSON storage-authoritative guarded-resource descriptor | Requires `Authorization: Bearer `. Raw bytes body; declared `Content-Type` is validated. | No bearer secrets or raw bytes in response. The returned MIME comes from storage readback and may differ from the request header. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `413 payload_too_large`, `503 creator_authority_unavailable` | | `DELETE /creator/priv-resources/content/` | `204` empty response | Requires `Authorization: Bearer `. | No bearer secrets or raw bytes in response. | `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404 guarded_resource_not_found`, `503 creator_authority_unavailable` | -| `POST /creator/content-locks` | `200` JSON content lock | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `404 guarded_resource_not_found`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `503 creator_authority_unavailable` | +| `POST /creator/content-locks` | `200` JSON content lock | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `404 guarded_resource_not_found`, `409 content_lock_path_conflict`, `503 creator_authority_unavailable` | | `POST /creator/lock-service-config` | `200` JSON lock-service pointer | Requires `Authorization: Bearer `. | No bearer secrets in response. | `400 invalid_request`, `401 frontend_session_unavailable`, `401 frontend_session_expired`, `503 creator_authority_unavailable` | | `GET /connect` | `200` HTML Lock-Server-hosted connect shell | No bearer auth. Mounted when `[creator_authority_acquisition].enabled = true`; `return_to` must match `allowed_return_origins` or explicit wildcard policy. | HTML intentionally contains the secret-bearing Pubky authorization URL on Lock Server origin; response must not contain frontend session token, one-time code, or creator authority secret. | `400 invalid_request`, `503 creator_authority_unavailable`, `404` when route gated off | | `POST /connect/{flow_id}/complete` | `303` redirect to stored `return_to` | No bearer auth. Mounted when `[creator_authority_acquisition].enabled = true`; stored `return_to` is revalidated before redirect. | `Location` contains only callback `state` and one-time `code`; no authorization URL, frontend session token, or creator authority secret. | `400 invalid_request`, `404 creator_connect_flow_unavailable`, `410 creator_connect_flow_expired`, `503 creator_authority_unavailable`, `404` when route gated off | @@ -104,6 +105,7 @@ Stable error codes and statuses mirror `locks-server/src/api/errors.rs` tests: | `frontend_session_expired` | 401 | Frontend session token existed but expired. | | `frontend_session_state_mismatch` | 400 | One-time code exchange state did not match. | | `creator_authority_unavailable` | 503 | Creator-granted homeserver authority is unavailable or could not be revalidated. | +| `content_lock_path_conflict` | 409 | The creator-scoped guarded path already has an in-flight or published Content Lock owner. | | `task_state_conflict` | 409 | Submission or completion conflicts with existing task state. | | `unsupported_verifier_type` | 422 | Proof references a verifier unavailable in the current runtime. | | `paykit_not_configured` | 422 | A `paykit-payment` proof was submitted to a Lock Server without a `[paykit]` runtime section. | @@ -395,11 +397,12 @@ Every referenced guarded resource must currently exist for the same creator/path { "recipient_pubky": "pubky", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } ``` -`recipient_pubky` must be a valid Pubky public key string equal to the content-lock creator, `amount` must be a positive base-unit integer encoded as a string, and `asset` must be a non-empty string. The lock params do not include Paykit server URLs, account IDs, memos, expiry, payment references, or reader identity. A v1 content lock that uses `paykit-payment` must contain exactly that one criterion, and its `all` or `any` lock logic must reference that criterion exactly once. Mixed criteria, multiple payment criteria, recipient/creator mismatch, and duplicate or mismatched logic references return `400 invalid_request`. +`recipient_pubky` must be a valid Pubky public key string equal to the content-lock creator, `amount` must be a positive base-unit integer encoded as a string, `asset` must be a non-empty string, and `payment_in` must be a positive whole-hour JSON `u64`. The lock params do not include Paykit server URLs, account IDs, memos, expiry, payment references, or reader identity. A v1 content lock that uses `paykit-payment` must contain exactly that one criterion, and its `all` or `any` lock logic must reference that criterion exactly once. Mixed criteria, multiple payment criteria, recipient/creator mismatch, and duplicate or mismatched logic references return `400 invalid_request`. #### Request diff --git a/docs/plans/2026-08-10-graceful-content-lock-deletion.md b/docs/plans/2026-08-10-graceful-content-lock-deletion.md new file mode 100644 index 0000000..6f6402b --- /dev/null +++ b/docs/plans/2026-08-10-graceful-content-lock-deletion.md @@ -0,0 +1,496 @@ +# Graceful Content-Lock Deletion and Payment Deadline Implementation Plan + +> **For Hermes:** Use subagent-driven-development to implement this plan one review-gated commit slice at a time. Stop after each slice; the user commits before the next slice. + +**Goal:** Add a bounded `paykit-payment` deadline and creator-authorized graceful content-lock deletion that withdraws the public lock immediately, drains accepted payment and access obligations durably, removes guarded content, and safely permits later republication after complete graceful cleanup. + +**Architecture:** Locks owns the public tombstone, admission cutoff, verification tasks, credentials, guarded content, path ownership, and overall deletion job. Paykit Server owns invoice timestamps, Payment Request lifecycle classification, cancellation, Bitcoin observation, and a durable lock-wide payment drain. PostgreSQL stores retryable Locks workflow state; Pubky remains authoritative for public lock/tombstone and private guarded bytes. + +**Tech Stack:** Rust 2024, Axum, Tokio, SQLx/PostgreSQL, Pubky homeserver storage, `time`, AEAD via `chacha20poly1305`, existing Locks SDK and JS/WASM bindings. + +**Sibling plan:** Paykit Server `docs/plans/2026-08-10-lock-payment-draining.md`. Both plans repeat the shared wire contract deliberately. + +--- + +## Status and provenance + +- Plan status: **accepted product design; implementation not started**. +- Repository inspected: `/home/u/Projects/Synonym/Pubky/locks-public`. +- Planning base when written: clean `master` at `ba49a77`. +- There has been no production deployment. New persistence may require a clean pre-production database; no historical backfill is required. +- No Paykit Rust protocol change is planned. `proposal_expires_at` retains its existing pre-acceptance meaning. + +### Explicit requirements and confirmed decisions + +1. `paykit-payment` criterion params gain required `payment_in`. +2. `payment_in` is a nonzero JSON `u64` integer measured in whole hours. Zero, fractional, negative, string, and out-of-range values are invalid. There is no product maximum beyond checked duration/timestamp representation. +3. Locks includes `payment_in` in the signed invoice request. Paykit independently reads the canonical lock and rejects a mismatch before side effects. +4. Paykit commits `invoice_created_at` and `payment_deadline = checked(invoice_created_at + payment_in hours)` atomically with invoice creation. Exact replay returns the original timestamps. +5. Locks persists the returned timestamps before admitting the verification task. Retry never restarts the payment window. +6. Paykit sets Payment Request `proposal_expires_at` to `payment_deadline`, but the field remains proposal-only. Locks and Paykit Server enforce the post-acceptance deadline as application state. +7. Payment is timely when Paykit’s durable `first_amount_matched_observed_at <= payment_deadline`. An earlier underpayment does not lend its timestamp to a later qualifying output. Polling latency is accepted. +8. At the deadline, undetected and underpaid invoices expire and stop active observation. A timely amount-matched payment may continue confirmation observation after the deadline without a second timeout. +9. Locks alone applies configured `minimum_confirmations`; it is not sent to Paykit’s drain endpoint. +10. Payment after application expiry never opens the lock. Reader UI blocks/removes payment instructions at the deadline and warns that late payment receives no access or automatic refund. +11. Graceful deletion is the default creator DELETE mode. `graceful=true` is an alias; `force=true` is mutually exclusive and explicit. +12. Graceful deletion is irreversible once its durable job is persisted. There is no cancellation API. +13. Public withdrawal replaces `/pub/locks.app/{lock_id}.json` with an exact tombstone after durably storing the original canonical lock: + +```json +{ + "version": 1, + "type": "content_lock_deletion", + "lock_id": "", + "deletion_started_at": "" +} +``` + +14. Persisting the deletion job is the proof-admission cutoff. New Bundle IDs are rejected; exact replay/status for previously persisted tasks remains available. +15. Paykit atomically classifies Payment Requests at drain start: accepted/rejected persisted before the cutoff retain that state; unanswered requests are durably canceled; later acceptance loses. +16. Durable cancellation enqueue is enough to stop blocking; delivery/acknowledgment is not awaited. +17. Rejected and canceled requests do not block. Accepted requests block until payment expires or satisfies Locks’ frozen rule. Timely amount-matched payment continues through required confirmations. +18. Existing access credentials remain reusable until their original expiry. +19. Existing and final drain credentials resolve authorization and resource descriptors from the deletion job’s frozen canonical manifest while the public path contains a tombstone. The tombstone is never treated as a valid Content Lock, and callers outside the persisted drain receive no new access. +20. Every already-paid entitlement lacking an active credential at tombstoning, plus every payment completed during draining, may obtain exactly one final drain credential. +21. Default final-credential issuance window is 15 minutes; configured maximum is one hour. Default read window is 15 minutes; configured maximum is one hour. Retry does not extend either. +22. Final credential permits one successful GET per frozen resource. Each path uses an atomic claim. Consumption occurs after upstream bytes are fetched/validated and a `200` response is constructed; a later disconnect does not restore it. Pre-response fetch failure releases the claim. +23. Exact credential issuance replay returns the same random bearer. Persist a versioned encrypted envelope using a domain-separated key derived from the existing runtime master key; bind creator, Bundle ID, deletion job, and envelope version as AEAD context. +24. Deletion worker retries transient failures with durable exponential backoff: one second initial, five-minute cap, full jitter, ten attempts per phase by default. Attempts reset on phase advance. +25. Creator-visible job status is only `queued|running|completed|failed`; failed responses include a stable secret-free `failure_code` only. +26. Missing or replaced tombstone before destructive work halts as failed. Creator restores the exact tombstone and repeats graceful DELETE to resume the same job. +27. Guarded paths are exclusive to one managed lock. Enforce unique `(creator, guarded_path)` ownership in PostgreSQL. There is no historical backfill. +28. Lock publication uses best-effort reservation compensation and accepts crash-orphaned ownership requiring operator cleanup; do not claim cross-system atomicity. +29. Graceful final cleanup deletes guarded content first and tombstone last, purges Locks authorization/task/job state, asks Paykit to remove operational drain state, and releases path ownership. It forgets the deletion so the same canonical Lock ID may later be published fresh with new Bundle IDs. +30. Paykit retains terminal financial invoice/payment history; delayed old lifecycle events cannot reactivate a fresh publication. +31. New force deletion is synchronous: persist a permanent minimal blocking receipt, delete lock/tombstone first, then best-effort guarded resources. Do not drain Paykit/tasks/credentials. Return failed paths. A force-deleted Lock ID can never be republished. +32. `force=true` against an active graceful job persists `force_requested` and returns `202`; the worker escalates asynchronously under exclusive action ownership, skips drains, deletes tombstone then content, and finishes forced. + +### Source-derived constraints + +- Lock ID is BLAKE3 over complete canonical lock JSON. A mutable `deleting` field cannot be added under the same ID. +- Readers fetch the public lock directly from the creator homeserver; a Locks Server GET gate cannot withdraw it. +- Guarded content and public lock JSON are separate Pubky records; no delete cascade exists. +- Current creation validates resource descriptors but does not enforce cross-lock path exclusivity (`locks-service/src/application/use_cases/create_content_lock.rs`). +- Current verification tasks use PostgreSQL leases and fresh claim tokens; deletion needs a separate queue but the same fenced-transition discipline. +- Current access-credential storage keeps only a bearer lookup hash; exact replay requires new encrypted bearer persistence. +- `proposal_expires_at` expires only `Proposed` Paykit SDK state and has no accepted-payment effect. +- Pubky, Locks PostgreSQL, and Paykit PostgreSQL cannot participate in one atomic transaction. + +### Explicitly accepted risks + +- Late Bitcoin payment may receive no content and no refund. +- Paykit polling latency can make a pre-deadline broadcast late. +- Timely amount-matched payment can block deletion indefinitely while confirmations/reorg state remains unresolved. +- Durable cancellation enqueue may precede actual counterparty delivery. +- Best-effort lock-publication reservation compensation can leave operator-cleaned orphan ownership after process death. +- Force deletion deliberately abandons active payment/access obligations and may orphan content after a crash. + +## Repository ownership matrix + +| Contract/state | Owner | +| --- | --- | +| `payment_in` criterion schema and validation | Locks Core | +| Signed invoice request producer and response persistence | Locks Server/Service | +| `invoice_created_at`, `payment_deadline`, proposal expiry | Paykit Server | +| Payment Request acceptance/rejection/cancellation projection | Paykit Server | +| Bitcoin first-observation and confirmations | Paykit Server | +| `minimum_confirmations` entitlement decision | Locks | +| Public tombstone and frozen lock manifest | Locks | +| Proof admission cutoff and task transitions | Locks | +| Credentials, per-path consumption, content serving | Locks | +| Lock-wide payment drain and aggregate status | Paykit Server | +| Overall deletion orchestration and final cleanup | Locks | +| Terminal financial history | Paykit Server | + +## Shared service-to-service contract + +All requests use existing `X-Paykit-Signature` over canonical JSON. Secret/correlation identifiers stay in POST bodies and must not be logged. + +### Invoice creation + +```http +POST /invoices + +{ + "bundle_id": "...", + "lock_resource": "pubky.../pub/locks.app/.json", + "reader": "pubky...", + "payment_in": 24 +} +``` + +Success changes from ignored-body 2xx to closed JSON: + +```json +{ + "invoice_created_at": "", + "payment_deadline": "" +} +``` + +Paykit compares request `payment_in` with canonical criterion `payment_in`. Exact replay returns the original response. + +### Lock-wide drain + +```http +POST /payment-request-drains +{ "lock_resource": "..." } +``` + +Starts or exactly replays an atomic persisted classification. No `minimum_confirmations` field. + +```http +POST /payment-request-drain-lookups +{ "lock_resource": "..." } +``` + +Returns aggregate factual state only; no Bundle IDs, readers, Payment Request IDs, addresses, or raw errors. + +### Per-Bundle status + +```http +POST /payment-requests/status +{ "creator": "pubky...", "bundle_id": "..." } +``` + +Returns orthogonal `request_state` and `payment_state`, immutable invoice/deadline timestamps, confirmations, and amount match. Exact enum spellings and invalid/recovery-state HTTP mapping are an implementation-contract gate and must be synchronized in both plans before code. + +### Drain cleanup + +Paykit Server needs an idempotent signed operation to remove only the completed operational drain row after Locks has completed all external deletion effects. Exact route/body is an implementation-contract gate; it must not remove financial invoice/payment history. + +## HTTP creator contract + +```http +DELETE /creator/content-locks/{lock_id} +DELETE /creator/content-locks/{lock_id}?graceful=true +``` + +Starts/replays/resumes graceful deletion and returns `202` for queued/running work. A completed-and-forgotten absent lock is an idempotent absent postcondition. + +```http +DELETE /creator/content-locks/{lock_id}?force=true +``` + +- No graceful job: synchronous `200` force summary. +- Existing graceful job: persist `force_requested`, return `202` job status. + +Reject `force=true&graceful=true`, unknown fields, malformed booleans, and duplicate conflicting query values. + +```http +GET /creator/content-locks/{lock_id}/deletion +``` + +Authenticated response contains Lock ID and `status`; include `failure_code` only for failed jobs. Do not expose phases, leases, retries, Bundle IDs, readers, credentials, paths, Paykit IDs, or dependency errors. + +## Internal state model + +Internal phase names are not public API. The implementation should represent at least: + +1. `withdraw`: persist frozen payload/job/admission cutoff, write tombstone, read back exact bytes. +2. `start_payment_drain`: exact Paykit drain creation. +3. `drain_payments`: poll aggregate drain and per-Bundle statuses; transition frozen tasks. +4. `drain_existing_credentials`: wait for credentials active at cutoff to expire. +5. `issue_final_credentials`: allow bounded issuance for eligible entitlements. +6. `drain_final_reads`: enforce per-path claims/consumption and read deadlines. +7. `delete_content`: idempotently delete every frozen resource while tombstone remains exact. +8. `delete_tombstone`: persist intent-to-remove phase before external delete so missing-on-retry is success. +9. `purge_operational_state`: remove Paykit operational drain, then atomically purge Locks lock-scoped authorization/task/job state and release path ownership. + +Use separate durable `state`, `phase`, `attempt_count`, `next_attempt_at`, claim owner/token/expiry, and force-request fields. Use a per-job PostgreSQL advisory action lock where lease expiry must not permit overlapping external effects. SQLx advisory-lock connections must be close-on-drop and explicitly unlocked/closed. + +## Implementation sequence + +Each task is a separate review/commit checkpoint. Do not commit automatically. + +### Task 1: Lock the `payment_in` core contract + +**Objective:** Make the content-addressed lock schema reject every non-approved timing shape. + +**Files:** +- Modify: `locks-core/src/lock_policy.rs` +- Modify: `locks-core/src/creator_publishing.rs` +- Modify: `locks-sdk/bindings/js/src/creator.rs` +- Test: neighboring unit/public API tests in those files and `locks-sdk/tests/public_api.rs` + +**RED:** Add serialization/validation tests for required nonzero JSON `u64`, unknown/missing field rejection, zero/fraction/string/overflow rejection, and canonical Lock ID sensitivity. + +**GREEN:** Extend the closed `paykit-payment` params parser/typed accessors and JS creator builder. + +**Verify:** + +```bash +cargo test -p locks-core +cargo test -p locks-sdk +cargo test -p locks-sdk-wasm +cargo test --workspace --no-run +``` + +**Suggested commit:** `feat(core): add paykit payment deadline hours` + +### Task 2: Persist exclusive guarded-path ownership + +**Objective:** Enforce one managed Content Lock per creator/path and retain ownership safely across deletion failures. + +**Files:** +- Modify: `locks-service/src/infrastructure/postgres/migrations.rs` +- Create: `locks-service/src/application/models/content_lock_ownership.rs` +- Create: `locks-service/src/application/ports/content_lock_ownership.rs` +- Create: `locks-service/src/infrastructure/postgres/content_lock_ownership.rs` +- Modify: relevant `mod.rs` exports +- Modify: `locks-service/src/application/use_cases/create_content_lock.rs` +- Modify: in-memory test adapters +- Test: `locks-e2e/tests/postgres_runtime.rs` +- Test: `locks-e2e/tests/production_creator_publishing_http.rs` + +**RED:** Prove duplicate `(creator,path)` rejection, atomic all-path reservation, ordinary-error compensation, retained ownership after failed deletion, and clean-database rollout. + +**GREEN:** Add unique ownership rows carrying creator, full path, intended Lock ID, and status. Reserve before Pubky publication; best-effort compensate ordinary publication failure. Do not invent historical backfill. + +**Verify:** + +```bash +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test postgres_runtime +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test production_creator_publishing_http +cargo test --workspace --no-run +``` + +**Suggested commit:** `feat(service): enforce guarded path ownership` + +### Task 3: Upgrade the Locks-to-Paykit invoice boundary + +**Objective:** Send `payment_in`, require the closed timestamp response, and durably bind it to the verification task before admission. + +**Files:** +- Modify: `locks-server/src/paykit_http_client.rs` +- Modify: `locks-service/src/application/models/verification.rs` +- Modify: `locks-service/src/application/ports/verification.rs` +- Modify: verification task PostgreSQL/memory adapters and migration +- Modify: `locks-service/src/application/use_cases/submit_proof_bundle.rs` +- Test: `locks-server/src/api/routes/tests.rs` +- Test: `locks-e2e/tests/postgres_runtime.rs` + +**Dependency gate:** Implement only after the Paykit Server invoice-response slice is reviewed and committed. + +**RED:** Test canonical signed request body, strict timestamp response decoding, checked ordering (`created <= deadline`), exact task replay preserving timestamps, and rollback/no-task on invoice rejection. + +**GREEN:** Persist immutable invoice timestamps with the task in the same local transaction that admits it. Do not recompute on retry. + +**Verify:** focused unit tests, PostgreSQL E2E, then `cargo test --workspace --no-run`. + +**Suggested commit:** `feat(paykit): persist invoice payment deadlines` + +### Task 4: Add deletion/tombstone domain and persistence + +**Objective:** Persist frozen manifests, cutoff state, leases, retry scheduling, force receipts, and minimal public DTOs. + +**Files:** +- Create: `locks-core/src/content_lock_deletion.rs` +- Modify: `locks-core/src/lib.rs` +- Create: `locks-service/src/application/models/content_lock_deletion.rs` +- Create: `locks-service/src/application/ports/content_lock_deletion.rs` +- Create: `locks-service/src/infrastructure/postgres/content_lock_deletions.rs` +- Create: `locks-service/src/infrastructure/memory/content_lock_deletions.rs` +- Modify: PostgreSQL migration/module exports +- Modify: `locks-service/src/application/errors.rs` + +**RED:** Test exact tombstone JSON, strict unknown-field rejection, frozen payload integrity, unique creator/Lock ID job identity, due claims, lease reclaim/fresh tokens, stale-token rejection, per-phase attempt reset, and permanent force receipt. + +**GREEN:** Implement the minimal state model. Keep public status conversion separate from internal phases. + +**Suggested commit:** `feat(service): persist content lock deletion jobs` + +### Task 5: Serialize deletion start against proof admission + +**Objective:** Make database commit order the authoritative cutoff for new Bundle IDs. + +**Files:** +- Modify: `locks-service/src/application/use_cases/submit_proof_bundle.rs` +- Create: `locks-service/src/application/use_cases/start_content_lock_deletion.rs` +- Modify: relevant repositories/PostgreSQL transaction helpers +- Test: `locks-e2e/tests/postgres_runtime.rs` +- Test: `locks-server/src/api/routes/tests.rs` + +**RED:** Concurrent tests prove task-first commit joins snapshot, deletion-first commit rejects a new Bundle, exact old replay succeeds, and conflicting replay remains rejected. + +**GREEN:** Use per-lock database serialization and one transaction for job persistence/task snapshot. Do not use viewer timestamps or tombstone publication as the cutoff. + +**Suggested commit:** `feat(service): enforce deletion admission cutoff` + +### Task 6: Add creator deletion/status APIs and SDKs + +**Objective:** Expose authenticated graceful default, explicit force, and minimal status consistently across Rust and JS. + +**Files:** +- Modify: `locks-server/src/api/creator_publishing.rs` +- Modify: `locks-server/src/api/dtos.rs` +- Modify: `locks-server/src/api/errors.rs` +- Modify: `locks-server/src/api/routes.rs` +- Modify: `locks-sdk/src/creator.rs` +- Modify: `locks-sdk/src/transport.rs` +- Modify: `locks-sdk/bindings/js/src/creator.rs` +- Test: `locks-server/src/api/routes/tests.rs` +- Test: `locks-sdk/tests/public_api.rs` +- Test: `locks-e2e/tests/production_creator_publishing_http.rs` + +**RED:** Cover query matrix, auth creator binding, 202 replay/resume/escalation, synchronous 200 force, permanent force receipt, absent postcondition, and redacted status. + +**GREEN:** Implement the closed routes exactly as documented. No immediate force through an omitted query option. + +**Suggested commit:** `feat(api): add creator content lock deletion` + +### Task 7: Integrate Paykit drain/status client + +**Objective:** Start/poll Paykit’s lock-wide drain and resolve each existing verification task from factual status. + +**Files:** +- Modify: `locks-server/src/paykit_http_client.rs` +- Modify: `locks-server/src/app_state/mod.rs` +- Create: `locks-service/src/application/ports/payment_drain.rs` +- Create: `locks-service/src/application/use_cases/drain_lock_payments.rs` +- Test: `locks-server/src/paykit_http_client.rs` +- Test: deletion use-case tests and HTTP integration fixtures + +**Dependency gate:** Patch both plans with exact per-Bundle enums, error mappings, and drain-cleanup route before RED tests. Then implement Paykit Server routes first. + +**RED:** Test exact signed JSON, no `minimum_confirmations` leak, aggregate redaction, local application of confirmations, canceled/rejected/expired transitions, timely matched confirmation continuation, and retryable transport errors. + +**Suggested commit:** `feat(paykit): drain deleting lock payments` + +### Task 8: Implement final credential/read draining + +**Objective:** Preserve existing credential TTL behavior while giving eligible paid entitlements one bounded per-resource final read. + +**Files:** +- Modify: `locks-service/src/application/models/access.rs` +- Modify: `locks-service/src/application/ports/access.rs` +- Modify: `locks-service/src/infrastructure/postgres/access_credentials.rs` +- Modify: `locks-service/src/infrastructure/postgres/migrations.rs` +- Modify: `locks-service/src/application/use_cases/issue_access_credential.rs` +- Modify: `locks-service/src/application/use_cases/proxy_read_guarded_resource.rs` +- Modify: `locks-server/src/storage.rs` and secret composition as needed +- Test: `locks-service/src/application/use_cases/credential_flow_tests.rs` +- Test: `locks-service/src/application/use_cases/retrieval_access_flow_tests.rs` +- Test: `locks-e2e/tests/retrieval_access_http.rs` + +**RED:** Cover exact encrypted replay, wrong-key/corrupt/version rejection, no secret Debug/log output, issuance/read deadlines, no deadline extension, existing/final access through the frozen manifest while the public path is a tombstone, denial outside the persisted drain, one concurrent success per path, claim release before response construction, consumption after construction, and automatic revocation when complete/expired. + +**GREEN:** Use versioned AEAD and domain-separated key derivation; retain lookup hashes. Resolve draining reads from the frozen manifest rather than parsing the tombstone. Do not store plaintext bearer. + +**Suggested commit:** `feat(access): drain final deletion credentials` + +### Task 9: Implement and supervise the deletion worker + +**Objective:** Execute external phases retryably without overlapping destructive actions or breaking shutdown. + +**Files:** +- Create: `locks-server/src/deletion_worker.rs` +- Modify: `locks-server/src/main.rs` +- Modify: `locks-server/src/config/schema.rs` +- Modify: `locks-server/src/config/defaults.rs` +- Modify: `locks-server/src/config/validation.rs` +- Modify: `locks-server/src/app_state/readiness.rs` +- Modify: `locks-server/src/api/runtime.rs` +- Test: worker unit tests and `locks-e2e/tests/postgres_runtime.rs` + +**RED:** Crash/reclaim tests after every external side effect; advisory ownership exclusion; tombstone read-back/replacement failure; retry exhaustion/resume; force escalation; content-first/tombstone-last; missing tombstone allowed only after durable final-removal phase; readiness degradation; shutdown stops claims and bounds worker join. + +**GREEN:** Reuse existing worker configuration conventions but keep queue cadence and retry due time separate. Never log manifest, resource paths, Bundle IDs, credentials, readers, or Paykit payloads. + +**Suggested commit:** `feat(server): run graceful deletion worker` + +### Task 10: Purge graceful state and preserve force blocks + +**Objective:** Complete graceful forget/republication without reactivating old authority, while permanently blocking force-deleted Lock IDs. + +**Files:** +- Create/modify: lock-scoped purge repository/use case in `locks-service/src/` +- Modify: deletion worker +- Modify: content-lock creation ownership/force-receipt checks +- Test: PostgreSQL E2E and creator publishing HTTP E2E + +**RED:** Prove all Locks task/proof/entitlement/credential/job rows are gone after graceful completion, ownership is released only after external cleanup, fresh same-ID publication accepts only new Bundle IDs, late old task replay cannot reactivate, force receipt blocks same-ID publication forever, and failed force paths retain ownership. + +**Suggested commit:** `feat(service): finalize lock deletion lifecycle` + +### Task 11: Reader UX and documentation + +**Objective:** Make the application deadline visible and prevent accidental late manual payment. + +**Files:** +- Modify only currently active reader/demo files discovered at implementation time; audit `examples/js-sdk/`, `README.md`, and `docs/LOCAL_OPERATOR_DEMO.md` before naming exact files. +- Modify: protocol/API documentation for criterion and deletion routes. + +**RED:** Browser/demo test with injected clock proves payment action disabled at equality boundary only after the inclusive deadline has passed, warning is visible, and no automatic payment is initiated. + +**GREEN:** Display Paykit-returned absolute deadline; do not derive from browser clock plus duration. + +**Suggested commit:** `docs: document payment deadlines and lock deletion` + +## Cross-repository implementation/review order + +1. Commit synchronized plan-only changes separately in Locks and Paykit Server. +2. Locks Task 1 (`payment_in`) and publish/review the exact Locks Core revision Paykit will consume. +3. Paykit Server invoice persistence/response and deadline observation slices. +4. Resolve and patch the exact per-Bundle enums and operational-drain cleanup route in both plans. +5. Paykit Server drain/status API slices. +6. Locks invoice persistence and payment-drain client slices. +7. Locks deletion persistence/API/worker/credential slices. +8. Cross-service E2E and docs. + +No repository may claim the sibling contract implemented until pinned dependency/revision and live tests prove it. + +## Verification + +Repository-local final verification: + +```bash +cargo fmt --all +cargo test -p locks-core +cargo test -p locks-service +cargo test -p locks-server +cargo test -p locks-sdk +cargo test -p locks-sdk-wasm +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test postgres_runtime +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test production_creator_publishing_http +TEST_DATABASE_URL="$TEST_DATABASE_URL" cargo test -p locks-e2e --test retrieval_access_http +cargo test --workspace +cargo clippy --workspace --all-targets -- -D warnings +cargo fmt --all --check +git diff --check +``` + +Cross-service acceptance must additionally prove: + +- invoice timestamp exact replay; +- canonical lock/request `payment_in` mismatch rejection with no side effects; +- inclusive first amount-matched-observation deadline; +- underpayment expiry and matched-payment confirmation continuation; +- atomic acceptance/cancellation drain cutoff; +- cancellation enqueue without delivery wait; +- Locks-only minimum-confirmation decision; +- deletion crash recovery after every remote effect; +- exact tombstone replacement halt/resume; +- existing/final credential drain and concurrent per-path consumption; +- graceful same-ID republication with no old authorization revival; +- permanent force same-ID block. + +## Remaining implementation-contract gates + +These do not reopen accepted product semantics, but code must not start for the affected slice until both plans are patched identically: + +1. Exact `request_state` and `payment_state` wire enum values and mappings for Paykit recovery/conflict conditions. +2. Exact aggregate drain response fields/status values. +3. Exact signed route/body for deleting completed Paykit operational drain state. +4. Exact Locks stable `failure_code` vocabulary. +5. Exact configuration keys for retry attempts/backoff and final credential windows, within the accepted defaults/maxima. + +## Out of scope + +- Paykit protocol `payment_due_at` field or accepted-expiry event. +- Automatic refunds or late-payment access. +- Manual/automatic Bitcoin payment from the reader. +- Cross-system transactions or exactly-once external effects. +- Historical production-data migration/backfill. +- Republishing force-deleted Lock IDs. +- Deleting reader-downloaded copies. diff --git a/examples/js-sdk/scripts/test-pubky.mjs b/examples/js-sdk/scripts/test-pubky.mjs index 7e483b6..df585b9 100644 --- a/examples/js-sdk/scripts/test-pubky.mjs +++ b/examples/js-sdk/scripts/test-pubky.mjs @@ -9,7 +9,7 @@ const session = { id: 'session' }; const signer = { pkdns: { free() {}, - async publishHomeserverIfStale(value) { + async publishHomeserverForce(value) { calls.push(['publish', value]); }, }, diff --git a/locks-core/src/lock_policy.rs b/locks-core/src/lock_policy.rs index 2c6d57c..6fb2e13 100644 --- a/locks-core/src/lock_policy.rs +++ b/locks-core/src/lock_policy.rs @@ -336,6 +336,35 @@ pub enum PaykitPaymentParamsValidationError { InvalidAmount, #[error("paykit-payment asset must be a non-empty string")] InvalidAsset, + #[error("paykit-payment payment_in must be a positive whole-hour JSON u64")] + InvalidPaymentIn, +} + +/// Validated public parameters for a `paykit-payment` criterion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaykitPaymentParams { + recipient_pubky: CreatorPubky, + amount: String, + asset: String, + payment_in: u64, +} + +impl PaykitPaymentParams { + pub fn recipient_pubky(&self) -> &CreatorPubky { + &self.recipient_pubky + } + + pub fn amount(&self) -> &str { + &self.amount + } + + pub fn asset(&self) -> &str { + &self.asset + } + + pub fn payment_in(&self) -> u64 { + self.payment_in + } } /// Invalid v1 content-lock policy containing a `paykit-payment` criterion. @@ -370,22 +399,32 @@ pub struct Criterion { impl Criterion { /// Validates verifier-specific public criterion params. pub fn validate_params(&self) -> Result<(), PaykitPaymentParamsValidationError> { + self.paykit_payment_params().map(|_| ()) + } + + /// Returns typed parameters when this is a `paykit-payment` criterion. + pub fn paykit_payment_params( + &self, + ) -> Result, PaykitPaymentParamsValidationError> { match self.verifier_type { - VerifierType::DevStatic => Ok(()), - VerifierType::PaykitPayment => validate_paykit_payment_params(&self.params), + VerifierType::DevStatic => Ok(None), + VerifierType::PaykitPayment => validate_paykit_payment_params(&self.params).map(Some), } } } fn validate_paykit_payment_params( params: &Value, -) -> Result<(), PaykitPaymentParamsValidationError> { +) -> Result { let object = params .as_object() .ok_or(PaykitPaymentParamsValidationError::NotObject)?; for key in object.keys() { - if !matches!(key.as_str(), "recipient_pubky" | "amount" | "asset") { + if !matches!( + key.as_str(), + "recipient_pubky" | "amount" | "asset" | "payment_in" + ) { return Err(PaykitPaymentParamsValidationError::UnknownField( key.clone(), )); @@ -398,7 +437,7 @@ fn validate_paykit_payment_params( .ok_or(PaykitPaymentParamsValidationError::MissingField( "recipient_pubky", ))?; - CreatorPubky::from_str(recipient_pubky) + let recipient_pubky = CreatorPubky::from_str(recipient_pubky) .map_err(|_| PaykitPaymentParamsValidationError::InvalidRecipientPubky)?; let amount = object @@ -422,7 +461,21 @@ fn validate_paykit_payment_params( return Err(PaykitPaymentParamsValidationError::InvalidAsset); } - Ok(()) + let payment_in = object + .get("payment_in") + .ok_or(PaykitPaymentParamsValidationError::MissingField( + "payment_in", + ))? + .as_u64() + .filter(|payment_in| *payment_in > 0) + .ok_or(PaykitPaymentParamsValidationError::InvalidPaymentIn)?; + + Ok(PaykitPaymentParams { + recipient_pubky, + amount: amount.to_owned(), + asset: asset.to_owned(), + payment_in, + }) } /// Logic expression over criterion identifiers. @@ -473,7 +526,7 @@ mod tests { AccessPolicy, CONTENT_LOCK_VERSION, ContentLock, ContentLockValidationError, Criterion, GuardedResource, GuardedResourceValidationError, LockLogic, LockServerConfig, PRIVATE_PROOF_BUNDLE_PATH_PREFIX, PRIVATE_RESOURCE_CONTENT_PATH_PREFIX, - PUBLIC_LOCKS_APP_PATH_PREFIX, PaykitPaymentParamsValidationError, + PUBLIC_LOCKS_APP_PATH_PREFIX, PaykitPaymentParams, PaykitPaymentParamsValidationError, PaykitPaymentPolicyValidationError, SecondaryGuardedResource, VerifierType, verified_proof_bundle_path, }; @@ -550,7 +603,8 @@ mod tests { params: json!({ "recipient_pubky": recipient_pubky.to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }), } } @@ -868,77 +922,91 @@ mod tests { "recipient_pubky": test_pubky_identity(), "amount": "50000", "asset": "BTC", + "payment_in": 24, }), }; assert_eq!(criterion.validate_params(), Ok(())); + let params = criterion.paykit_payment_params().unwrap().unwrap(); + assert_eq!(params.amount(), "50000"); + assert_eq!(params.asset(), "BTC"); + assert_eq!(params.payment_in(), 24); + assert_eq!( + params.recipient_pubky().to_string(), + criterion.params["recipient_pubky"] + ); + let _: PaykitPaymentParams = params; } #[test] fn paykit_payment_params_reject_invalid_shapes() { + let recipient = test_pubky_identity(); + let overflow = serde_json::from_str(&format!( + r#"{{"recipient_pubky":"{recipient}","amount":"50000","asset":"BTC","payment_in":18446744073709551616}}"# + )) + .unwrap(); for (params, expected) in [ (json!(null), PaykitPaymentParamsValidationError::NotObject), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "50000", - "asset": "BTC", - "memo": "extra", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 24, "memo": "extra" }), PaykitPaymentParamsValidationError::UnknownField("memo".to_owned()), ), ( - json!({ "amount": "50000", "asset": "BTC" }), + json!({ "amount": "50000", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("recipient_pubky"), ), ( - json!({ "recipient_pubky": test_pubky_identity(), "asset": "BTC" }), + json!({ "recipient_pubky": recipient, "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("amount"), ), ( - json!({ "recipient_pubky": test_pubky_identity(), "amount": "50000" }), + json!({ "recipient_pubky": recipient, "amount": "50000", "payment_in": 24 }), PaykitPaymentParamsValidationError::MissingField("asset"), ), ( - json!({ - "recipient_pubky": "not-a-pubky", - "amount": "50000", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC" }), + PaykitPaymentParamsValidationError::MissingField("payment_in"), + ), + ( + json!({ "recipient_pubky": "not-a-pubky", "amount": "50000", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidRecipientPubky, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "0", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "0", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "0.5", - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": "0.5", "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": 50000, - "asset": "BTC", - }), + json!({ "recipient_pubky": recipient, "amount": 50000, "asset": "BTC", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAmount, ), ( - json!({ - "recipient_pubky": test_pubky_identity(), - "amount": "50000", - "asset": "", - }), + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "", "payment_in": 24 }), PaykitPaymentParamsValidationError::InvalidAsset, ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 0 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": -1 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": 1.5 }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + json!({ "recipient_pubky": recipient, "amount": "50000", "asset": "BTC", "payment_in": "24" }), + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), + ( + overflow, + PaykitPaymentParamsValidationError::InvalidPaymentIn, + ), ] { let criterion = Criterion { criterion_id: "criterion-1".to_owned(), @@ -1214,4 +1282,17 @@ mod tests { without_override.lock_id().unwrap() ); } + + #[test] + fn changing_paykit_payment_in_changes_lock_id() { + let mut shorter = content_lock_fixture(); + shorter.criteria = vec![paykit_criterion("payment", &shorter.creator)]; + shorter.lock_logic = LockLogic::All { + criteria: vec!["payment".to_owned()], + }; + let mut longer = shorter.clone(); + longer.criteria[0].params["payment_in"] = json!(25); + + assert_ne!(shorter.lock_id().unwrap(), longer.lock_id().unwrap()); + } } diff --git a/locks-e2e/tests/creator_publishing_http.rs b/locks-e2e/tests/creator_publishing_http.rs index 4685495..9f33835 100644 --- a/locks-e2e/tests/creator_publishing_http.rs +++ b/locks-e2e/tests/creator_publishing_http.rs @@ -377,7 +377,8 @@ async fn creator_publishing_http_rejects_invalid_paykit_payment_params() { "params": { "recipient_pubky": creator().to_string(), "amount": "0", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -399,7 +400,8 @@ async fn creator_publishing_http_rejects_invalid_paykit_payment_params() { "params": { "recipient_pubky": "pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -515,7 +517,8 @@ async fn creator_publishing_http_paykit_payment_flow_creates_invoice_verifies_an "params": { "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }]), standard_lock_logic(), @@ -765,7 +768,8 @@ fn paykit_criterion_json(criterion_id: &str) -> serde_json::Value { "params": { "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 } }) } diff --git a/locks-e2e/tests/postgres_runtime.rs b/locks-e2e/tests/postgres_runtime.rs index 976dab8..4777329 100644 --- a/locks-e2e/tests/postgres_runtime.rs +++ b/locks-e2e/tests/postgres_runtime.rs @@ -22,8 +22,9 @@ use locks_server::config::{ }; use locks_server::worker::{VerificationWorker, WorkerTick}; use locks_service::application::models::{ - AccessCredential, AccessCredentialLookupKey, CreatorAuthorityAuthKind, CreatorAuthorityRecord, - CreatorAuthoritySecret, VerificationTaskStatus, + AccessCredential, AccessCredentialLookupKey, ContentLockOwnershipStatus, + CreatorAuthorityAuthKind, CreatorAuthorityRecord, CreatorAuthoritySecret, + VerificationTaskStatus, }; use locks_service::infrastructure::memory::{ content_locks::InMemoryContentLockRepository, entitlements::InMemoryEntitlementRepository, @@ -45,8 +46,20 @@ async fn postgres_runtime_state_survives_app_state_recreation() { return; }; let content_lock = content_lock(); + let lock_id = content_lock.lock_id().unwrap(); + let guarded_paths = vec![content_lock.primary_resource.as_ref().unwrap().path.clone()]; let first_state = app_state(database.pool().clone()); + first_state + .content_lock_ownership() + .reserve_paths(&creator(), &guarded_paths, &lock_id) + .await + .unwrap(); + first_state + .content_lock_ownership() + .mark_paths_published(&creator(), &guarded_paths, &lock_id) + .await + .unwrap(); seed_content_lock(&first_state, content_lock.clone()).await; let first_router = router(first_state.clone()); submit_task(&first_router, submitted_proof_bundle_for(&content_lock)).await; @@ -59,6 +72,14 @@ async fn postgres_runtime_state_survives_app_state_recreation() { .unwrap() .unwrap(); assert_eq!(recreated_task.status, VerificationTaskStatus::Pending); + let ownership = recreated_state + .content_lock_ownership() + .get_path_ownership(&creator(), &guarded_paths[0]) + .await + .unwrap() + .unwrap(); + assert_eq!(ownership.lock_id, lock_id); + assert_eq!(ownership.status, ContentLockOwnershipStatus::Published); seed_content_lock(&recreated_state, content_lock.clone()).await; let worker = VerificationWorker::from_state(&recreated_state); diff --git a/locks-e2e/tests/production_creator_publishing_http.rs b/locks-e2e/tests/production_creator_publishing_http.rs index 304d7e2..471982e 100644 --- a/locks-e2e/tests/production_creator_publishing_http.rs +++ b/locks-e2e/tests/production_creator_publishing_http.rs @@ -96,7 +96,7 @@ async fn production_creator_publishing_http_flow_writes_to_pubky_storage_when_fr let content_lock_json = client .create_content_lock( - guarded_resource, + guarded_resource.clone(), json!([{ "criterion_id": "criterion-1", "verifier_type": "dev-static", @@ -108,6 +108,23 @@ async fn production_creator_publishing_http_flow_writes_to_pubky_storage_when_fr ) .await .unwrap(); + let conflict = client + .create_content_lock( + guarded_resource, + json!([{ + "criterion_id": "criterion-1", + "verifier_type": "dev-static", + "params": { "satisfied": false } + }]), + json!({ "type": "all", "criteria": ["criterion-1"] }), + json!({ "requested_credential_ttl_seconds": 900 }), + json!({ "override": "pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo" }), + ) + .await + .unwrap_err(); + assert_eq!(conflict.status, StatusCode::CONFLICT); + assert_eq!(conflict.body["error"]["code"], "content_lock_path_conflict"); + assert_secret_free(&conflict.body); let content_lock_path = ContentLockPath::from_str( content_lock_json["content_lock_path"] .as_str() diff --git a/locks-sdk/bindings/js/src/creator.rs b/locks-sdk/bindings/js/src/creator.rs index 9a0b0bf..111d8cd 100644 --- a/locks-sdk/bindings/js/src/creator.rs +++ b/locks-sdk/bindings/js/src/creator.rs @@ -213,6 +213,14 @@ impl CreateContentLockRequestBuilder { .criteria .as_ref() .ok_or_else(|| "content lock request requires criteria".to_owned())?; + let typed_criteria: Vec = + serde_json::from_value(criteria.clone()) + .map_err(|err| format!("invalid content lock criteria: {err}"))?; + for criterion in &typed_criteria { + criterion + .validate_params() + .map_err(|err| format!("invalid content lock criterion: {err}"))?; + } body.insert("criteria".to_owned(), criteria.clone()); let lock_logic = state .lock_logic @@ -654,6 +662,31 @@ mod tests { assert!(format!("{err:?}").contains("criteria")); } + #[test] + fn create_content_lock_request_builder_rejects_invalid_paykit_payment_in() { + let builder = complete_builder(); + builder.state.borrow_mut().primary_resource = + Some(resource("/priv/locks.app/content/example.txt", "hash", 13)); + builder.state.borrow_mut().criteria = Some(serde_json::json!([{ + "criterion_id": "payment", + "verifier_type": "paykit-payment", + "params": { + "recipient_pubky": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", + "amount": "50000", + "asset": "BTC", + "payment_in": 0 + } + }])); + builder.state.borrow_mut().lock_logic = Some(serde_json::json!({ + "type": "all", + "criteria": ["payment"] + })); + + let err = builder.build_value().unwrap_err(); + + assert!(err.contains("payment_in")); + } + #[test] fn create_content_lock_request_builder_rejects_duplicate_secondary_path() { let builder = complete_builder(); diff --git a/locks-server/src/api/creator_publishing.rs b/locks-server/src/api/creator_publishing.rs index 8b37395..23618db 100644 --- a/locks-server/src/api/creator_publishing.rs +++ b/locks-server/src/api/creator_publishing.rs @@ -98,6 +98,7 @@ pub(super) async fn create_content_lock_for_authenticated_creator( validate_content_lock_limits(&request, state.config().content_locks.clone())?; let use_case = CreateContentLockUseCase::new( state.content_locks().as_ref(), + state.content_lock_ownership().as_ref(), state.guarded_resources().as_ref(), state.clock().as_ref(), ); diff --git a/locks-server/src/api/errors.rs b/locks-server/src/api/errors.rs index 4ad70bc..7efced4 100644 --- a/locks-server/src/api/errors.rs +++ b/locks-server/src/api/errors.rs @@ -23,6 +23,7 @@ pub enum ApiErrorCode { FrontendSessionUnavailable, FrontendSessionExpired, FrontendSessionStateMismatch, + ContentLockPathConflict, TaskStateConflict, UnsupportedVerifierType, PaykitNotConfigured, @@ -56,6 +57,7 @@ impl ApiErrorCode { Self::FrontendSessionUnavailable => "frontend_session_unavailable", Self::FrontendSessionExpired => "frontend_session_expired", Self::FrontendSessionStateMismatch => "frontend_session_state_mismatch", + Self::ContentLockPathConflict => "content_lock_path_conflict", Self::TaskStateConflict => "task_state_conflict", Self::UnsupportedVerifierType => "unsupported_verifier_type", Self::PaykitNotConfigured => "paykit_not_configured", @@ -90,7 +92,7 @@ impl ApiErrorCode { StatusCode::UNAUTHORIZED } Self::FrontendSessionStateMismatch => StatusCode::BAD_REQUEST, - Self::TaskStateConflict => StatusCode::CONFLICT, + Self::ContentLockPathConflict | Self::TaskStateConflict => StatusCode::CONFLICT, Self::UnsupportedVerifierType | Self::PaykitNotConfigured | Self::NotPaykitPayment @@ -203,6 +205,10 @@ impl From for ApiError { ApiErrorCode::FrontendSessionStateMismatch, "frontend session state mismatch", ), + ApplicationError::ContentLockPathConflict { .. } => Self::new( + ApiErrorCode::ContentLockPathConflict, + "content lock path is already owned", + ), ApplicationError::InvalidGuardedResource { .. } => { Self::new(ApiErrorCode::InvalidRequest, "invalid guarded resource") } @@ -420,6 +426,11 @@ mod tests { StatusCode::CONFLICT, "task_state_conflict", ), + ( + ApiErrorCode::ContentLockPathConflict, + StatusCode::CONFLICT, + "content_lock_path_conflict", + ), ( ApiErrorCode::UnsupportedVerifierType, StatusCode::UNPROCESSABLE_ENTITY, @@ -508,6 +519,26 @@ mod tests { ); } + #[test] + fn content_lock_path_conflict_maps_to_409_stable_envelope() { + let api_error = ApiError::from(ApplicationError::ContentLockPathConflict { + guarded_path: "/priv/locks.app/content/already-owned.txt".to_owned(), + }); + + assert_eq!(api_error.status_code(), StatusCode::CONFLICT); + let json = serde_json::to_value(api_error.error_response()).unwrap(); + assert_eq!( + json, + json!({ + "error": { + "code": "content_lock_path_conflict", + "message": "content lock path is already owned" + } + }) + ); + assert!(!json.to_string().contains("already-owned.txt")); + } + #[test] fn invalid_guarded_resource_maps_to_400_stable_envelope() { let api_error = ApiError::from(ApplicationError::InvalidGuardedResource { diff --git a/locks-server/src/api/routes/tests.rs b/locks-server/src/api/routes/tests.rs index 82eff18..067ebe1 100644 --- a/locks-server/src/api/routes/tests.rs +++ b/locks-server/src/api/routes/tests.rs @@ -407,7 +407,8 @@ async fn post_proof_bundles_rejects_paykit_payment_when_paykit_is_not_configured content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(creator()); @@ -446,7 +447,8 @@ async fn post_proof_bundles_replay_returns_lifecycle_without_replaying_paykit_in content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(other_creator()); @@ -499,7 +501,8 @@ async fn paykit_connection_state_lookup_returns_server_local_recovery_state() { content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(other_creator()); @@ -714,7 +717,8 @@ async fn paykit_connection_state_lookup_maps_invalid_paykit_response_to_bad_gate content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(other_creator()); @@ -772,7 +776,8 @@ async fn paykit_connection_state_lookup_maps_response_body_timeout_to_gateway_ti content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(other_creator()); @@ -3451,7 +3456,8 @@ fn paykit_content_lock_and_bundle() -> (ContentLock, SubmittedProofBundle) { content_lock.criteria[0].params = json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }); let mut bundle = submitted_proof_bundle_for(&content_lock); bundle.reader_public_key = Some(other_creator()); diff --git a/locks-server/src/app_state/mod.rs b/locks-server/src/app_state/mod.rs index eec81d8..92bad5b 100644 --- a/locks-server/src/app_state/mod.rs +++ b/locks-server/src/app_state/mod.rs @@ -17,24 +17,26 @@ use locks_service::{ errors::ApplicationError, models::AccessCredentialPolicy, ports::{ - AccessCredentialStore, Clock, ContentLockRepository, CreatorAuthorityManager, - CreatorAuthorityStore, CreatorConnectFlowStore, EntitlementRepository, - FrontendSessionCodeStore, FrontendSessionStore, GuardedResourceRepository, - LegacyCreatorConnectFlowClient, LockServicePointerRepository, VerificationTaskClaimer, - VerificationTaskRepository, + AccessCredentialStore, Clock, ContentLockOwnershipRepository, ContentLockRepository, + CreatorAuthorityManager, CreatorAuthorityStore, CreatorConnectFlowStore, + EntitlementRepository, FrontendSessionCodeStore, FrontendSessionStore, + GuardedResourceRepository, LegacyCreatorConnectFlowClient, + LockServicePointerRepository, VerificationTaskClaimer, VerificationTaskRepository, }, }, infrastructure::{ memory::{ access_credentials::InMemoryAccessCredentialStore, + content_lock_ownership::InMemoryContentLockOwnershipRepository, verification_task_claims::InMemoryVerificationTaskClaimer, verification_tasks::InMemoryVerificationTaskRepository, }, postgres::{ CreatorAuthoritySecretCipher, PostgresAccessCredentialStore, - PostgresCreatorAuthorityStore, PostgresCreatorConnectFlowStore, - PostgresFrontendSessionCodeStore, PostgresFrontendSessionStore, - PostgresVerificationTaskClaimer, PostgresVerificationTaskRepository, + PostgresContentLockOwnershipRepository, PostgresCreatorAuthorityStore, + PostgresCreatorConnectFlowStore, PostgresFrontendSessionCodeStore, + PostgresFrontendSessionStore, PostgresVerificationTaskClaimer, + PostgresVerificationTaskRepository, }, pubky::{ AuthorizingPubkyHomeserverStorageClient, LegacyCookieCreatorAuthorityManager, @@ -162,6 +164,7 @@ pub struct AppState { content_locks: Arc, guarded_resources: Arc, lock_service_pointers: Arc, + content_lock_ownership: Arc, verification_tasks: Arc, verification_task_claimer: Arc, entitlements: Arc, @@ -243,6 +246,7 @@ impl AppState { ); let private_runtime = PrivateRuntimeAdapters { + content_lock_ownership: Arc::new(InMemoryContentLockOwnershipRepository::new()), verification_tasks, verification_task_claimer, access_credentials, @@ -291,6 +295,7 @@ impl AppState { ); let private_runtime = PrivateRuntimeAdapters { + content_lock_ownership: Arc::new(InMemoryContentLockOwnershipRepository::new()), verification_tasks, verification_task_claimer, access_credentials, @@ -356,6 +361,7 @@ impl AppState { ); let private_runtime = PrivateRuntimeAdapters { + content_lock_ownership: Arc::new(InMemoryContentLockOwnershipRepository::new()), verification_tasks, verification_task_claimer, access_credentials, @@ -410,6 +416,9 @@ impl AppState { }; let private_runtime = PrivateRuntimeAdapters { + content_lock_ownership: Arc::new(PostgresContentLockOwnershipRepository::new( + pool.clone(), + )), verification_tasks, verification_task_claimer, access_credentials, @@ -471,6 +480,9 @@ impl AppState { entitlements, ); let private_runtime = PrivateRuntimeAdapters { + content_lock_ownership: Arc::new(PostgresContentLockOwnershipRepository::new( + pool.clone(), + )), verification_tasks, verification_task_claimer, access_credentials, @@ -543,6 +555,7 @@ impl AppState { content_locks: creator_repositories.content_locks, guarded_resources: creator_repositories.guarded_resources, lock_service_pointers: creator_repositories.lock_service_pointers, + content_lock_ownership: private_runtime.content_lock_ownership, verification_tasks: private_runtime.verification_tasks, verification_task_claimer: private_runtime.verification_task_claimer, entitlements: creator_repositories.entitlements, @@ -591,6 +604,10 @@ impl AppState { &self.guarded_resources } + pub fn content_lock_ownership(&self) -> &Arc { + &self.content_lock_ownership + } + pub fn lock_service_pointers(&self) -> &Arc { &self.lock_service_pointers } diff --git a/locks-server/src/app_state/private_runtime.rs b/locks-server/src/app_state/private_runtime.rs index 93d83c7..af87ed1 100644 --- a/locks-server/src/app_state/private_runtime.rs +++ b/locks-server/src/app_state/private_runtime.rs @@ -11,9 +11,10 @@ use locks_service::application::{ PendingCreatorConnectFlowRecord, }, ports::{ - AccessCredentialStore, CreatorAuthorityManager, CreatorAuthorityStore, - CreatorConnectFlowStore, FrontendSessionCodeStore, FrontendSessionStore, - LegacyCreatorConnectFlowClient, VerificationTaskClaimer, VerificationTaskRepository, + AccessCredentialStore, ContentLockOwnershipRepository, CreatorAuthorityManager, + CreatorAuthorityStore, CreatorConnectFlowStore, FrontendSessionCodeStore, + FrontendSessionStore, LegacyCreatorConnectFlowClient, VerificationTaskClaimer, + VerificationTaskRepository, }, }; use time::OffsetDateTime; @@ -21,6 +22,7 @@ use tokio::sync::RwLock; #[derive(Clone)] pub(super) struct PrivateRuntimeAdapters { + pub(super) content_lock_ownership: Arc, pub(super) verification_tasks: Arc, pub(super) verification_task_claimer: Arc, pub(super) access_credentials: Arc, diff --git a/locks-service/migrations/0010_content_lock_ownership.sql b/locks-service/migrations/0010_content_lock_ownership.sql new file mode 100644 index 0000000..518ace4 --- /dev/null +++ b/locks-service/migrations/0010_content_lock_ownership.sql @@ -0,0 +1,8 @@ +CREATE TABLE content_lock_ownership ( + creator TEXT NOT NULL, + guarded_path TEXT NOT NULL, + lock_id TEXT NOT NULL, + status TEXT NOT NULL, + CONSTRAINT content_lock_ownership_creator_path_unique UNIQUE (creator, guarded_path), + CONSTRAINT content_lock_ownership_status_valid CHECK (status IN ('reserved', 'published')) +); diff --git a/locks-service/src/application/errors.rs b/locks-service/src/application/errors.rs index 748fcb2..98e7ed9 100644 --- a/locks-service/src/application/errors.rs +++ b/locks-service/src/application/errors.rs @@ -12,6 +12,12 @@ pub enum ApplicationError { /// Stable record kind for diagnostics. record: &'static str, }, + /// A guarded path already has an in-flight or published ownership record. + #[error("content lock path conflict")] + ContentLockPathConflict { + /// Full creator-scoped guarded path for structured internal handling. + guarded_path: String, + }, /// An update-only operation targeted a missing record. #[error("missing {record} record")] MissingRecord { diff --git a/locks-service/src/application/models/content_lock_ownership.rs b/locks-service/src/application/models/content_lock_ownership.rs new file mode 100644 index 0000000..3692729 --- /dev/null +++ b/locks-service/src/application/models/content_lock_ownership.rs @@ -0,0 +1,46 @@ +use locks_core::ids::{CreatorPubky, LockId}; + +use crate::application::errors::ApplicationError; + +/// Durable lifecycle status for exclusive guarded-path ownership. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentLockOwnershipStatus { + /// The path is reserved for an intended lock before public publication. + Reserved, + /// The intended lock was published successfully. + Published, +} + +impl ContentLockOwnershipStatus { + /// Returns the stable Postgres representation. + pub fn as_str(self) -> &'static str { + match self { + Self::Reserved => "reserved", + Self::Published => "published", + } + } + + /// Parses a status loaded from persistence. + pub fn from_storage(value: &str) -> Result { + match value { + "reserved" => Ok(Self::Reserved), + "published" => Ok(Self::Published), + _ => Err(ApplicationError::Storage { + message: format!("invalid content lock ownership status: {value}"), + }), + } + } +} + +/// Exclusive ownership of one creator-scoped guarded path by an intended lock. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentLockOwnership { + /// Creator who owns the guarded path. + pub creator: CreatorPubky, + /// Full canonical guarded-resource path. + pub guarded_path: String, + /// Canonical Lock ID intended to own the path. + pub lock_id: LockId, + /// Reservation/publication lifecycle status. + pub status: ContentLockOwnershipStatus, +} diff --git a/locks-service/src/application/models/mod.rs b/locks-service/src/application/models/mod.rs index 68d2597..ed27c07 100644 --- a/locks-service/src/application/models/mod.rs +++ b/locks-service/src/application/models/mod.rs @@ -1,10 +1,12 @@ mod access; +mod content_lock_ownership; mod creator_authority; mod frontend_session; mod guarded_resource; mod verification; pub use access::*; +pub use content_lock_ownership::*; pub use creator_authority::*; pub use frontend_session::*; pub use guarded_resource::*; diff --git a/locks-service/src/application/ports/content_lock_ownership.rs b/locks-service/src/application/ports/content_lock_ownership.rs new file mode 100644 index 0000000..323a7ee --- /dev/null +++ b/locks-service/src/application/ports/content_lock_ownership.rs @@ -0,0 +1,48 @@ +use async_trait::async_trait; +use locks_core::ids::{CreatorPubky, LockId}; + +use crate::application::errors::ApplicationError; +use crate::application::models::ContentLockOwnership; + +/// Repository for exclusive creator-scoped guarded-path ownership. +#[async_trait] +pub trait ContentLockOwnershipRepository: Send + Sync { + /// Atomically reserves every path for the intended lock. + /// + /// Exact retry for the same published Lock ID is idempotent. An existing + /// reservation, or a path owned by a different Lock ID, returns + /// `ContentLockPathConflict` and reserves none of the previously unowned + /// paths in the request. Blocking in-flight reservations prevents one + /// publisher from compensating another publisher's ownership. + async fn reserve_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError>; + + /// Marks the intended lock's complete path set as successfully published. + async fn mark_paths_published( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError>; + + /// Best-effort publication-failure compensation for matching reserved rows. + /// + /// Published ownership is deliberately retained. + async fn compensate_reserved_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError>; + + /// Reads current ownership for a creator-scoped guarded path. + async fn get_path_ownership( + &self, + creator: &CreatorPubky, + guarded_path: &str, + ) -> Result, ApplicationError>; +} diff --git a/locks-service/src/application/ports/mod.rs b/locks-service/src/application/ports/mod.rs index afa0e29..fff16d3 100644 --- a/locks-service/src/application/ports/mod.rs +++ b/locks-service/src/application/ports/mod.rs @@ -1,6 +1,7 @@ pub mod semantics {} mod access; +mod content_lock_ownership; mod creator_authority; mod entitlement; mod guarded_resources; @@ -9,6 +10,7 @@ mod runtime; mod verification; pub use access::*; +pub use content_lock_ownership::*; pub use creator_authority::*; pub use entitlement::*; pub use guarded_resources::*; 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 9e00837..deb1571 100644 --- a/locks-service/src/application/use_cases/complete_verification_task.rs +++ b/locks-service/src/application/use_cases/complete_verification_task.rs @@ -356,6 +356,7 @@ fn viewer_safe_failure_message(error: &ApplicationError) -> &'static str { } ApplicationError::Storage { .. } | ApplicationError::DuplicateRecord { .. } + | ApplicationError::ContentLockPathConflict { .. } | ApplicationError::MissingRecord { .. } | ApplicationError::InvalidVerificationTaskTransition { .. } | ApplicationError::VerificationPending diff --git a/locks-service/src/application/use_cases/create_content_lock.rs b/locks-service/src/application/use_cases/create_content_lock.rs index 5eb0496..4f78029 100644 --- a/locks-service/src/application/use_cases/create_content_lock.rs +++ b/locks-service/src/application/use_cases/create_content_lock.rs @@ -7,7 +7,9 @@ use locks_core::lock_policy::{ }; use crate::application::errors::ApplicationError; -use crate::application::ports::{Clock, ContentLockRepository, GuardedResourceRepository}; +use crate::application::ports::{ + Clock, ContentLockOwnershipRepository, ContentLockRepository, GuardedResourceRepository, +}; /// Request to create a local content lock for an already-registered guarded resource. #[derive(Debug, Clone, PartialEq, Eq)] @@ -42,6 +44,7 @@ pub struct CreatedContentLock { /// Creates local content locks after verifying current guarded resource metadata. pub struct CreateContentLockUseCase<'a> { content_locks: &'a dyn ContentLockRepository, + content_lock_ownership: &'a dyn ContentLockOwnershipRepository, guarded_resources: &'a dyn GuardedResourceRepository, clock: &'a dyn Clock, } @@ -50,11 +53,13 @@ impl<'a> CreateContentLockUseCase<'a> { /// Creates a content-lock use case from its application ports. pub fn new( content_locks: &'a dyn ContentLockRepository, + content_lock_ownership: &'a dyn ContentLockOwnershipRepository, guarded_resources: &'a dyn GuardedResourceRepository, clock: &'a dyn Clock, ) -> Self { Self { content_locks, + content_lock_ownership, guarded_resources, clock, } @@ -115,13 +120,33 @@ impl<'a> CreateContentLockUseCase<'a> { message: error.to_string(), } })?; + let guarded_paths = resource_descriptors(&content_lock) + .into_iter() + .map(|resource| resource.path) + .collect::>(); + + self.content_lock_ownership + .reserve_paths(&request.creator, &guarded_paths, &lock_id) + .await?; - self.content_locks + if let Err(error) = self + .content_locks .upsert_content_lock( - request.creator, + request.creator.clone(), content_lock_path.clone(), content_lock.clone(), ) + .await + { + let _ = self + .content_lock_ownership + .compensate_reserved_paths(&request.creator, &guarded_paths, &lock_id) + .await; + return Err(error); + } + + self.content_lock_ownership + .mark_paths_published(&request.creator, &guarded_paths, &lock_id) .await?; Ok(CreatedContentLock { @@ -169,6 +194,7 @@ fn resource_descriptors(content_lock: &ContentLock) -> Vec { mod tests { use std::str::FromStr; + use async_trait::async_trait; use serde_json::json; use time::OffsetDateTime; use time::macros::datetime; @@ -178,7 +204,10 @@ mod tests { use super::*; use crate::application::models::GuardedResourceRecord; - use crate::application::ports::{Clock, ContentLockRepository, GuardedResourceRepository}; + use crate::application::ports::{ + Clock, ContentLockOwnershipRepository, ContentLockRepository, GuardedResourceRepository, + }; + use crate::infrastructure::memory::content_lock_ownership::InMemoryContentLockOwnershipRepository; use crate::infrastructure::memory::content_locks::InMemoryContentLockRepository; use crate::infrastructure::memory::guarded_resources::InMemoryGuardedResourceRepository; @@ -219,6 +248,14 @@ mod tests { .unwrap(), Some(result.content_lock) ); + let ownership = fixture + .content_lock_ownership + .get_path_ownership(&creator(), "/priv/locks.app/content/hello.txt") + .await + .unwrap() + .unwrap(); + assert_eq!(ownership.lock_id, result.lock_id); + assert_eq!(ownership.status.as_str(), "published"); } #[tokio::test] @@ -304,7 +341,7 @@ mod tests { } #[tokio::test] - async fn changed_criteria_create_different_lock_id_and_path() { + async fn changed_criteria_rejects_path_owned_by_different_lock() { let fixture = Fixture::seeded().await; let use_case = fixture.use_case(); let first_request = content_lock_request(registered_guarded_resource()); @@ -312,12 +349,21 @@ mod tests { second_request.criteria[0].params = json!({ "satisfied": false }); let first = use_case.execute(first_request).await.unwrap(); - let second = use_case.execute(second_request).await.unwrap(); + let second = use_case.execute(second_request).await; - assert_ne!(second.lock_id, first.lock_id); - assert_ne!(second.content_lock_path, first.content_lock_path); - assert_ne!(second.content_lock, first.content_lock); - assert_eq!(fixture.content_locks_len().await, 2); + assert!(matches!( + second, + Err(ApplicationError::ContentLockPathConflict { ref guarded_path }) + if guarded_path == "/priv/locks.app/content/hello.txt" + )); + assert_eq!(fixture.content_locks_len().await, 1); + let ownership = fixture + .content_lock_ownership + .get_path_ownership(&creator(), "/priv/locks.app/content/hello.txt") + .await + .unwrap() + .unwrap(); + assert_eq!(ownership.lock_id, first.lock_id); } #[tokio::test] @@ -330,6 +376,7 @@ mod tests { "recipient_pubky": creator().to_string(), "amount": "0", "asset": "BTC", + "payment_in": 24, }); let result = use_case.execute(request).await; @@ -353,7 +400,8 @@ mod tests { params: json!({ "recipient_pubky": creator().to_string(), "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }), }); request.lock_logic = LockLogic::All { @@ -369,8 +417,61 @@ mod tests { assert_eq!(fixture.content_locks_len().await, 0); } + #[tokio::test] + async fn publication_failure_compensates_reserved_path_ownership() { + let fixture = Fixture::seeded().await; + let use_case = CreateContentLockUseCase::new( + &FailingContentLockRepository, + &fixture.content_lock_ownership, + &fixture.guarded_resources, + &fixture.clock, + ); + + let result = use_case + .execute(content_lock_request(registered_guarded_resource())) + .await; + + assert!(matches!( + result, + Err(ApplicationError::Storage { ref message }) if message == "publication failed" + )); + assert_eq!( + fixture + .content_lock_ownership + .get_path_ownership(&creator(), "/priv/locks.app/content/hello.txt") + .await + .unwrap(), + None + ); + } + + struct FailingContentLockRepository; + + #[async_trait] + impl ContentLockRepository for FailingContentLockRepository { + async fn upsert_content_lock( + &self, + _creator: CreatorPubky, + _path: ContentLockPath, + _content_lock: ContentLock, + ) -> Result<(), ApplicationError> { + Err(ApplicationError::Storage { + message: "publication failed".to_owned(), + }) + } + + async fn get_content_lock( + &self, + _creator: &CreatorPubky, + _path: &ContentLockPath, + ) -> Result, ApplicationError> { + Ok(None) + } + } + struct Fixture { content_locks: InMemoryContentLockRepository, + content_lock_ownership: InMemoryContentLockOwnershipRepository, guarded_resources: InMemoryGuardedResourceRepository, clock: FixedClock, } @@ -379,6 +480,7 @@ mod tests { fn empty() -> Self { Self { content_locks: InMemoryContentLockRepository::new(), + content_lock_ownership: InMemoryContentLockOwnershipRepository::new(), guarded_resources: InMemoryGuardedResourceRepository::new(), clock: FixedClock(datetime!(2026-06-03 12:00:00 UTC)), } @@ -403,7 +505,12 @@ mod tests { } fn use_case(&self) -> CreateContentLockUseCase<'_> { - CreateContentLockUseCase::new(&self.content_locks, &self.guarded_resources, &self.clock) + CreateContentLockUseCase::new( + &self.content_locks, + &self.content_lock_ownership, + &self.guarded_resources, + &self.clock, + ) } async fn content_locks_len(&self) -> usize { diff --git a/locks-service/src/application/use_cases/validate_paykit_payment_submission.rs b/locks-service/src/application/use_cases/validate_paykit_payment_submission.rs index 01794a3..73e2699 100644 --- a/locks-service/src/application/use_cases/validate_paykit_payment_submission.rs +++ b/locks-service/src/application/use_cases/validate_paykit_payment_submission.rs @@ -264,7 +264,8 @@ mod tests { params: json!({ "recipient_pubky": CREATOR, "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }), }], lock_logic: LockLogic::All { diff --git a/locks-service/src/infrastructure/memory/content_lock_ownership.rs b/locks-service/src/infrastructure/memory/content_lock_ownership.rs new file mode 100644 index 0000000..6b0f35e --- /dev/null +++ b/locks-service/src/infrastructure/memory/content_lock_ownership.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; + +use async_trait::async_trait; +use locks_core::ids::{CreatorPubky, LockId}; +use tokio::sync::RwLock; + +use crate::application::errors::ApplicationError; +use crate::application::models::{ContentLockOwnership, ContentLockOwnershipStatus}; +use crate::application::ports::ContentLockOwnershipRepository; + +type OwnershipKey = (CreatorPubky, String); + +/// In-memory exclusive guarded-path ownership repository for tests and ephemeral runtime. +#[derive(Debug, Default)] +pub struct InMemoryContentLockOwnershipRepository { + records: RwLock>, +} + +impl InMemoryContentLockOwnershipRepository { + /// Creates an empty repository. + pub fn new() -> Self { + Self::default() + } +} + +#[async_trait] +impl ContentLockOwnershipRepository for InMemoryContentLockOwnershipRepository { + async fn reserve_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let mut records = self.records.write().await; + for guarded_path in guarded_paths { + if let Some(existing) = records.get(&(creator.clone(), guarded_path.clone())) + && (existing.lock_id != *lock_id + || existing.status == ContentLockOwnershipStatus::Reserved) + { + return Err(ApplicationError::ContentLockPathConflict { + guarded_path: guarded_path.clone(), + }); + } + } + + for guarded_path in guarded_paths { + records + .entry((creator.clone(), guarded_path.clone())) + .or_insert_with(|| ContentLockOwnership { + creator: creator.clone(), + guarded_path: guarded_path.clone(), + lock_id: lock_id.clone(), + status: ContentLockOwnershipStatus::Reserved, + }); + } + Ok(()) + } + + async fn mark_paths_published( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let mut records = self.records.write().await; + for guarded_path in guarded_paths { + let Some(ownership) = records.get(&(creator.clone(), guarded_path.clone())) else { + return Err(ApplicationError::MissingRecord { + record: "content_lock_ownership", + }); + }; + if ownership.lock_id != *lock_id { + return Err(ApplicationError::ContentLockPathConflict { + guarded_path: guarded_path.clone(), + }); + } + } + for guarded_path in guarded_paths { + let ownership = records + .get_mut(&(creator.clone(), guarded_path.clone())) + .expect("ownership set was validated while holding the write lock"); + ownership.status = ContentLockOwnershipStatus::Published; + } + Ok(()) + } + + async fn compensate_reserved_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let mut records = self.records.write().await; + for guarded_path in guarded_paths { + let key = (creator.clone(), guarded_path.clone()); + let remove = records.get(&key).is_some_and(|ownership| { + ownership.lock_id == *lock_id + && ownership.status == ContentLockOwnershipStatus::Reserved + }); + if remove { + records.remove(&key); + } + } + Ok(()) + } + + async fn get_path_ownership( + &self, + creator: &CreatorPubky, + guarded_path: &str, + ) -> Result, ApplicationError> { + Ok(self + .records + .read() + .await + .get(&(creator.clone(), guarded_path.to_owned())) + .cloned()) + } +} diff --git a/locks-service/src/infrastructure/memory/mod.rs b/locks-service/src/infrastructure/memory/mod.rs index 437553b..83b00d7 100644 --- a/locks-service/src/infrastructure/memory/mod.rs +++ b/locks-service/src/infrastructure/memory/mod.rs @@ -1,4 +1,5 @@ pub mod access_credentials; +pub mod content_lock_ownership; pub mod content_locks; pub mod entitlements; pub mod guarded_resources; diff --git a/locks-service/src/infrastructure/postgres/content_lock_ownership.rs b/locks-service/src/infrastructure/postgres/content_lock_ownership.rs new file mode 100644 index 0000000..9494a08 --- /dev/null +++ b/locks-service/src/infrastructure/postgres/content_lock_ownership.rs @@ -0,0 +1,442 @@ +use std::collections::BTreeSet; +use std::str::FromStr; + +use async_trait::async_trait; +use locks_core::ids::{CreatorPubky, LockId}; +use sqlx::PgPool; + +use crate::application::errors::ApplicationError; +use crate::application::models::{ContentLockOwnership, ContentLockOwnershipStatus}; +use crate::application::ports::ContentLockOwnershipRepository; + +/// PostgreSQL-backed exclusive guarded-path ownership repository. +#[derive(Debug, Clone)] +pub struct PostgresContentLockOwnershipRepository { + pool: PgPool, +} + +impl PostgresContentLockOwnershipRepository { + /// Creates an ownership repository backed by the supplied pool. + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ContentLockOwnershipRepository for PostgresContentLockOwnershipRepository { + async fn reserve_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let creator = creator.to_string(); + let lock_id = lock_id.to_string(); + let mut transaction = self.pool.begin().await.map_err(map_sqlx_error)?; + + for guarded_path in sorted_unique_paths(guarded_paths) { + let insert = sqlx::query( + r#" + INSERT INTO content_lock_ownership (creator, guarded_path, lock_id, status) + VALUES ($1, $2, $3, 'reserved') + ON CONFLICT (creator, guarded_path) DO NOTHING + "#, + ) + .bind(&creator) + .bind(guarded_path) + .bind(&lock_id) + .execute(&mut *transaction) + .await + .map_err(map_sqlx_error)?; + + let (existing_lock_id, existing_status) = sqlx::query_as::<_, (String, String)>( + r#" + SELECT lock_id, status + FROM content_lock_ownership + WHERE creator = $1 AND guarded_path = $2 + "#, + ) + .bind(&creator) + .bind(guarded_path) + .fetch_one(&mut *transaction) + .await + .map_err(map_sqlx_error)?; + if existing_lock_id != lock_id + || (insert.rows_affected() == 0 && existing_status == "reserved") + { + return Err(ApplicationError::ContentLockPathConflict { + guarded_path: guarded_path.to_owned(), + }); + } + } + + transaction.commit().await.map_err(map_sqlx_error) + } + + async fn mark_paths_published( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let creator = creator.to_string(); + let lock_id = lock_id.to_string(); + let mut transaction = self.pool.begin().await.map_err(map_sqlx_error)?; + + for guarded_path in sorted_unique_paths(guarded_paths) { + let result = sqlx::query( + r#" + UPDATE content_lock_ownership + SET status = 'published' + WHERE creator = $1 AND guarded_path = $2 AND lock_id = $3 + "#, + ) + .bind(&creator) + .bind(guarded_path) + .bind(&lock_id) + .execute(&mut *transaction) + .await + .map_err(map_sqlx_error)?; + if result.rows_affected() == 0 { + let existing_lock_id = sqlx::query_scalar::<_, String>( + r#" + SELECT lock_id + FROM content_lock_ownership + WHERE creator = $1 AND guarded_path = $2 + "#, + ) + .bind(&creator) + .bind(guarded_path) + .fetch_optional(&mut *transaction) + .await + .map_err(map_sqlx_error)?; + return match existing_lock_id { + Some(_) => Err(ApplicationError::ContentLockPathConflict { + guarded_path: guarded_path.to_owned(), + }), + None => Err(ApplicationError::MissingRecord { + record: "content_lock_ownership", + }), + }; + } + } + + transaction.commit().await.map_err(map_sqlx_error) + } + + async fn compensate_reserved_paths( + &self, + creator: &CreatorPubky, + guarded_paths: &[String], + lock_id: &LockId, + ) -> Result<(), ApplicationError> { + let creator = creator.to_string(); + let lock_id = lock_id.to_string(); + let mut transaction = self.pool.begin().await.map_err(map_sqlx_error)?; + + for guarded_path in sorted_unique_paths(guarded_paths) { + sqlx::query( + r#" + DELETE FROM content_lock_ownership + WHERE creator = $1 + AND guarded_path = $2 + AND lock_id = $3 + AND status = 'reserved' + "#, + ) + .bind(&creator) + .bind(guarded_path) + .bind(&lock_id) + .execute(&mut *transaction) + .await + .map_err(map_sqlx_error)?; + } + + transaction.commit().await.map_err(map_sqlx_error) + } + + async fn get_path_ownership( + &self, + creator: &CreatorPubky, + guarded_path: &str, + ) -> Result, ApplicationError> { + let row = sqlx::query_as::<_, (String, String)>( + r#" + SELECT lock_id, status + FROM content_lock_ownership + WHERE creator = $1 AND guarded_path = $2 + "#, + ) + .bind(creator.to_string()) + .bind(guarded_path) + .fetch_optional(&self.pool) + .await + .map_err(map_sqlx_error)?; + + row.map(|(lock_id, status)| { + Ok(ContentLockOwnership { + creator: creator.clone(), + guarded_path: guarded_path.to_owned(), + lock_id: LockId::from_str(&lock_id).map_err(|error| ApplicationError::Storage { + message: format!("invalid stored content lock ownership Lock ID: {error}"), + })?, + status: ContentLockOwnershipStatus::from_storage(&status)?, + }) + }) + .transpose() + } +} + +fn sorted_unique_paths(guarded_paths: &[String]) -> Vec<&str> { + guarded_paths + .iter() + .map(String::as_str) + .collect::>() + .into_iter() + .collect() +} + +fn map_sqlx_error(error: sqlx::Error) -> ApplicationError { + ApplicationError::Storage { + message: error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use std::sync::Arc; + + use locks_core::ids::{CreatorPubky, LockHash, LockId}; + use tokio::sync::Barrier; + + use super::PostgresContentLockOwnershipRepository; + use crate::application::errors::ApplicationError; + use crate::application::models::ContentLockOwnershipStatus; + use crate::application::ports::ContentLockOwnershipRepository; + use crate::infrastructure::postgres::testing::TestDatabase; + + #[tokio::test] + async fn reserved_path_blocks_retry_and_conflicting_multi_path_request_is_atomic() { + let database = TestDatabase::create().await; + let store = PostgresContentLockOwnershipRepository::new(database.pool().clone()); + let creator = creator(); + let first_lock = lock_id(1); + let second_lock = lock_id(2); + let owned_paths = paths(&["a.txt", "b.txt"]); + + store + .reserve_paths(&creator, &owned_paths, &first_lock) + .await + .unwrap(); + assert_eq!( + store + .reserve_paths(&creator, &owned_paths, &first_lock) + .await, + Err(ApplicationError::ContentLockPathConflict { + guarded_path: owned_paths[0].clone(), + }) + ); + + let conflicting_paths = paths(&["c.txt", "b.txt"]); + assert_eq!( + store + .reserve_paths(&creator, &conflicting_paths, &second_lock) + .await, + Err(ApplicationError::ContentLockPathConflict { + guarded_path: owned_paths[1].clone(), + }) + ); + assert_eq!( + store + .get_path_ownership(&creator, &conflicting_paths[0]) + .await + .unwrap(), + None + ); + for guarded_path in &owned_paths { + let ownership = store + .get_path_ownership(&creator, guarded_path) + .await + .unwrap() + .unwrap(); + assert_eq!(ownership.lock_id, first_lock); + assert_eq!(ownership.status, ContentLockOwnershipStatus::Reserved); + } + store + .reserve_paths(&second_creator(), &owned_paths, &second_lock) + .await + .unwrap(); + assert_eq!( + store + .get_path_ownership(&second_creator(), &owned_paths[0]) + .await + .unwrap() + .unwrap() + .lock_id, + second_lock + ); + + database.cleanup().await; + } + + #[tokio::test] + async fn concurrent_competing_reservations_choose_one_owner_without_partial_rows() { + let database = TestDatabase::create().await; + let store = PostgresContentLockOwnershipRepository::new(database.pool().clone()); + let creator = creator(); + let first_lock = lock_id(1); + let second_lock = lock_id(2); + let shared_path = "/priv/locks.app/content/a-shared.txt".to_owned(); + let first_only_path = "/priv/locks.app/content/b-first.txt".to_owned(); + let second_only_path = "/priv/locks.app/content/c-second.txt".to_owned(); + let barrier = Arc::new(Barrier::new(3)); + + let first_store = store.clone(); + let first_creator = creator.clone(); + let first_paths = vec![shared_path.clone(), first_only_path.clone()]; + let first_task_lock = first_lock.clone(); + let first_barrier = barrier.clone(); + let first = tokio::spawn(async move { + first_barrier.wait().await; + first_store + .reserve_paths(&first_creator, &first_paths, &first_task_lock) + .await + }); + + let second_store = store.clone(); + let second_creator = creator.clone(); + let second_paths = vec![shared_path.clone(), second_only_path.clone()]; + let second_task_lock = second_lock.clone(); + let second_barrier = barrier.clone(); + let second = tokio::spawn(async move { + second_barrier.wait().await; + second_store + .reserve_paths(&second_creator, &second_paths, &second_task_lock) + .await + }); + + barrier.wait().await; + let first_result = first.await.unwrap(); + let second_result = second.await.unwrap(); + let shared_owner = store + .get_path_ownership(&creator, &shared_path) + .await + .unwrap() + .unwrap(); + + match (first_result, second_result, shared_owner.lock_id) { + (Ok(()), Err(ApplicationError::ContentLockPathConflict { guarded_path }), owner) + if guarded_path == shared_path && owner == first_lock => + { + assert!( + store + .get_path_ownership(&creator, &first_only_path) + .await + .unwrap() + .is_some() + ); + assert_eq!( + store + .get_path_ownership(&creator, &second_only_path) + .await + .unwrap(), + None + ); + } + (Err(ApplicationError::ContentLockPathConflict { guarded_path }), Ok(()), owner) + if guarded_path == shared_path && owner == second_lock => + { + assert_eq!( + store + .get_path_ownership(&creator, &first_only_path) + .await + .unwrap(), + None + ); + assert!( + store + .get_path_ownership(&creator, &second_only_path) + .await + .unwrap() + .is_some() + ); + } + results => panic!("expected exactly one complete reservation, got {results:?}"), + } + + database.cleanup().await; + } + + #[tokio::test] + async fn compensation_removes_only_reserved_rows_and_published_ownership_is_durable() { + let database = TestDatabase::create().await; + let store = PostgresContentLockOwnershipRepository::new(database.pool().clone()); + let recreated = PostgresContentLockOwnershipRepository::new(database.pool().clone()); + let creator = creator(); + let lock_id = lock_id(1); + let guarded_paths = paths(&["a.txt"]); + + store + .reserve_paths(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + store + .compensate_reserved_paths(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + assert_eq!( + store + .get_path_ownership(&creator, &guarded_paths[0]) + .await + .unwrap(), + None + ); + + store + .reserve_paths(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + store + .mark_paths_published(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + store + .reserve_paths(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + store + .compensate_reserved_paths(&creator, &guarded_paths, &lock_id) + .await + .unwrap(); + + let ownership = recreated + .get_path_ownership(&creator, &guarded_paths[0]) + .await + .unwrap() + .unwrap(); + assert_eq!(ownership.lock_id, lock_id); + assert_eq!(ownership.status, ContentLockOwnershipStatus::Published); + + database.cleanup().await; + } + + fn creator() -> CreatorPubky { + CreatorPubky::from_str("pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy").unwrap() + } + + fn second_creator() -> CreatorPubky { + CreatorPubky::from_str("pubky7ir1ttte48bcp4zjychjyscicrwi1j34mtt91ptsafdbjmr8g9eo").unwrap() + } + + fn lock_id(byte: u8) -> LockId { + LockId::from_hash(LockHash::from_bytes([byte; 32])) + } + + fn paths(names: &[&str]) -> Vec { + names + .iter() + .map(|name| format!("/priv/locks.app/content/{name}")) + .collect() + } +} diff --git a/locks-service/src/infrastructure/postgres/migrations.rs b/locks-service/src/infrastructure/postgres/migrations.rs index 22faed1..a9bccc6 100644 --- a/locks-service/src/infrastructure/postgres/migrations.rs +++ b/locks-service/src/infrastructure/postgres/migrations.rs @@ -49,6 +49,7 @@ mod tests { assert_table_exists(&mut connection, "pending_creator_connect_flows").await; assert_table_exists(&mut connection, "frontend_session_codes").await; assert_table_exists(&mut connection, "frontend_sessions").await; + assert_table_exists(&mut connection, "content_lock_ownership").await; assert_column_exists(&mut connection, "verification_tasks", "creator").await; assert_column_exists(&mut connection, "verification_tasks", "bundle_id").await; assert_column_exists(&mut connection, "verification_tasks", "next_attempt_at").await; @@ -70,12 +71,22 @@ mod tests { .await; assert_column_exists(&mut connection, "frontend_session_codes", "code_hash").await; assert_column_exists(&mut connection, "frontend_sessions", "token_hash").await; + assert_column_exists(&mut connection, "content_lock_ownership", "creator").await; + assert_column_exists(&mut connection, "content_lock_ownership", "guarded_path").await; + assert_column_exists(&mut connection, "content_lock_ownership", "lock_id").await; + assert_column_exists(&mut connection, "content_lock_ownership", "status").await; assert_unique_constraint_exists( &mut connection, "verification_tasks", "verification_tasks_creator_bundle_unique", ) .await; + assert_unique_constraint_exists( + &mut connection, + "content_lock_ownership", + "content_lock_ownership_creator_path_unique", + ) + .await; drop(connection); database.cleanup().await; diff --git a/locks-service/src/infrastructure/postgres/mod.rs b/locks-service/src/infrastructure/postgres/mod.rs index 1366910..2ddf224 100644 --- a/locks-service/src/infrastructure/postgres/mod.rs +++ b/locks-service/src/infrastructure/postgres/mod.rs @@ -7,6 +7,7 @@ //! adapters or explicit production indexes are designed. pub mod access_credentials; +pub mod content_lock_ownership; pub mod creator_authority; pub mod creator_connect_flows; pub mod errors; @@ -18,6 +19,7 @@ pub mod verification_task_claims; pub mod verification_tasks; pub use access_credentials::PostgresAccessCredentialStore; +pub use content_lock_ownership::PostgresContentLockOwnershipRepository; pub use creator_authority::{CreatorAuthoritySecretCipher, PostgresCreatorAuthorityStore}; pub use creator_connect_flows::PostgresCreatorConnectFlowStore; pub use errors::PostgresError; diff --git a/locks-service/src/infrastructure/verifiers/paykit_payment.rs b/locks-service/src/infrastructure/verifiers/paykit_payment.rs index 7cde36e..a71875a 100644 --- a/locks-service/src/infrastructure/verifiers/paykit_payment.rs +++ b/locks-service/src/infrastructure/verifiers/paykit_payment.rs @@ -257,7 +257,8 @@ mod tests { params: json!({ "recipient_pubky": "pubkytkrq8zmwb8a3m9k15csu3q17qmfgqnp9dskbrg9uq1rydpyxp7qy", "amount": "50000", - "asset": "BTC" + "asset": "BTC", + "payment_in": 24 }), }, proof: Proof {