Skip to content

Latest commit

 

History

History
692 lines (507 loc) · 48.8 KB

API.md

File metadata and controls

692 lines (507 loc) · 48.8 KB

Locks HTTP API Reference

Scope

This document describes the current Lock Server HTTP API contract for local development, tests, and implemented production creator-route authorization.

Creator publishing is authenticated. Creator routes require Authorization: Bearer <frontend_session_token>, derive creator identity from the Locks-local frontend session, reject request-body creator, and always use Pubky homeserver-backed creator storage.

This document is the living HTTP contract reference. ADRs explain why the boundaries exist; this file describes what callers can send and receive.

Executable creator-publishing examples live under locks-server/tests/fixtures/creator_publishing/ and are exercised by route contract tests named creator_publishing_contract_fixtures_*. The inline JSON below mirrors those fixtures; when changing route shapes, update the fixture and this document together.

For an executable creator-to-viewer local flow using the test-support client, see LOCAL_DEMO.md.

Runtime gates

The Lock Server has one non-production route family and one authenticated creator route family:

  • POST /verification-task-completions
    • Requires runtime.environment = "development".
    • staging and production never mount it.
  • Authenticated creator publishing routes: PUT /creator/priv-resources/content/<path>, DELETE /creator/priv-resources/content/<path>, POST /creator/content-locks, DELETE /creator/content-locks/{lock_id}, GET /creator/content-locks/{lock_id}/deletion, POST /creator/lock-service-config
    • Always Pubky homeserver-backed.
    • Can run in development, staging, or production.
    • Require Authorization: Bearer <frontend_session_token>.
    • 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
    • Requires Authorization: Bearer <frontend_session_token>.
    • Derives creator from the Locks-local frontend session.
    • Returns secret-free missing/present creator authority status.
  • Paykit setup readiness route: GET /creator/paykit/setup-status
    • Requires Authorization: Bearer <frontend_session_token>.
    • Derives Creator identity from the Locks frontend session and sends no browser-supplied Creator.
    • Returns only ready, setup_required, or unavailable.
  • Hosted creator connect/session routes: GET /connect, POST /connect/{flow_id}/complete, POST /frontend-sessions, DELETE /frontend-sessions/current
    • Mount when explicit [creator_authority_acquisition].enabled = true.
    • DELETE /frontend-sessions/current requires Authorization: Bearer <frontend_session_token> and revokes the current frontend session.
    • GET /connect validates return_to against the selected method's allowed_return_origins before starting a flow.
    • POST /connect/{flow_id}/complete completes the Lock-Server-owned flow, revalidates the stored return_to, and redirects with only state and one-time code.

When gated routes are disabled, they are not mounted and return 404 Not Found.

Route status summary

Gated-off routes are plain Axum 404 Not Found responses because the route is intentionally not mounted; they may not use the JSON error envelope.

Route Success Auth / mount Secret handoff Representative errors
PUT /creator/priv-resources/content/<path> 200 JSON storage-authoritative guarded-resource descriptor Requires Authorization: Bearer <frontend_session_token>. 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/<path> 204 empty response Requires Authorization: Bearer <frontend_session_token>. 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 <frontend_session_token>. 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, 409 content_lock_deletion_in_progress, 503 creator_authority_unavailable
DELETE /creator/content-locks/{lock_id} Graceful: 202 redacted lifecycle, or 200 completed absent postcondition. Replaying a failed graceful job requeues the same frozen manifest. force=true: synchronous 200 force summary when no active graceful job exists; a queued or running job is requeued for worker force processing and returns 202. A failed job with unresolved entitlement publication is safely resumed as graceful work before force may be retried. Requires Authorization: Bearer <frontend_session_token>. Default and graceful=true are graceful; force=true is explicit and mutually exclusive. No snapshots, task IDs, attempts, dependency errors, or force marker in lifecycle responses. Force summary contains only Lock ID, lock-deleted boolean, and failed guarded paths. 400 invalid_request, 400 invalid_identifier, 401 frontend_session_unavailable, 401 frontend_session_expired, 503 creator_authority_unavailable
GET /creator/content-locks/{lock_id}/deletion 200 redacted lifecycle JSON Requires Authorization: Bearer <frontend_session_token>. Contains only Lock ID, stable status, and an optional closed failure code. Pending force receipts project as queued until cleanup is verified; completed receipts project as completed. 400 invalid_identifier, 401 frontend_session_unavailable, 401 frontend_session_expired, 404 content_lock_deletion_not_found
POST /creator/lock-service-config 200 JSON lock-service pointer Requires Authorization: Bearer <frontend_session_token>. 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
POST /frontend-sessions 200 JSON frontend session token handoff No bearer auth. Requires one-time code plus matching state. Response intentionally contains raw session_token exactly once; no creator authority secret, authorization URL, or one-time code reuse. 400 invalid_request, 400 frontend_session_state_mismatch, 404 frontend_session_code_unavailable, 410 frontend_session_code_expired, 410 frontend_session_code_already_consumed, 404 when route gated off
DELETE /frontend-sessions/current 204 empty response Requires Authorization: Bearer <frontend_session_token>. Mounted with creator authority acquisition. Token is request-only and is deleted from the frontend session store. 401 frontend_session_unavailable, 401 frontend_session_expired, 404 when route gated off
GET /.well-known/locks-server 200 JSON service identity Public. Always mounted. CORS-enabled. No secrets. Used by browser SDK to verify service, API version, and Lock Server Pubky identity. n/a
GET /creator/authority-status 200 JSON secret-free authority status Requires Authorization: Bearer <frontend_session_token>. Creator is derived from the frontend session. Response contains only creator, boolean status, auth kind, scopes, and optional expiry; no tokens, codes, authorization URLs, secrets, or DB/config values. 401 frontend_session_unavailable, 401 frontend_session_expired, 404 only if route absent in older deployments
GET /creator/paykit/setup-status 200 JSON coarse Paykit setup status Requires Authorization: Bearer <frontend_session_token>. Creator is derived from the session; query/body Creator input is rejected. Response contains only status; Paykit URL, HTTP status, authority details, credentials, and internal failures are never exposed. 401 frontend_session_unavailable, 401 frontend_session_expired; authenticated Paykit failures return 200 {"status":"unavailable"}
POST /proof-bundles 200 JSON lifecycle Public viewer route. New paykit-payment submissions durably persist one pending invoice-admission task before any Reader/Paykit I/O; exact persisted replay returns same lifecycle. No bearer secrets, invoice data, connection state, or raw proof material in response. 400 invalid_request, 409 task_state_conflict, 409 content_lock_deletion_in_progress, 422 unsupported_verifier_type, 422 paykit_not_configured, 429 rate_limited
POST /verification-task-lookups 200 JSON lifecycle Public viewer route. No bearer secrets in response. 400 invalid_request, 404 verification_task_not_found
POST /paykit-connection-state-lookups 200 JSON Paykit-local connection state Public viewer route bound to an existing paykit-payment task handle. No arbitrary peer/path input, invoice data, payment status, or raw proof material. 400 invalid_request, 404 verification_task_not_found, 422 not_paykit_payment, 422 paykit_not_configured, 429 rate_limited, 502 paykit_connection_state_unavailable, 504 paykit_connection_state_timeout
POST /verification-task-completions 200 JSON lifecycle Dev-only completion gate. No bearer secrets in response. 400 invalid_request, 404 verification_task_not_found, 409 task_state_conflict, 404 when route gated off
POST /access-credentials 200 JSON credential Public viewer route after entitlement. Response intentionally contains raw viewer access credential exactly once. 400 invalid_request, 403 entitlement_not_authorized, 404 verification_task_not_found
GET /priv-resources/content/<path> 200 raw bytes Requires viewer Authorization: Bearer <access_credential>. No JSON response; credential is request-only. 401 invalid_access_credential, 401 expired_access_credential, 403 entitlement_not_authorized, 404 guarded_resource_not_found

Error envelope

Non-2xx JSON errors use the stable envelope:

{
  "error": {
    "code": "invalid_request",
    "message": "human-readable explanation"
  }
}

Error code is the stable programmatic field. Error message is diagnostic and may change.

Representative error examples live under locks-server/tests/fixtures/errors/ and are checked by locks-server/src/api/errors.rs serialization tests.

Stable error codes and statuses mirror locks-server/src/api/errors.rs tests:

Code Status Meaning
invalid_request 400 JSON shape, validation, or malformed domain object failed.
invalid_identifier 400 Identifier syntax is invalid.
invalid_access_credential 401 Bearer credential is missing, malformed, unknown, or supplied outside the Authorization header.
expired_access_credential 401 Bearer credential existed but expired.
entitlement_not_authorized 403 Credential exists but entitlement/content-lock checks no longer authorize access.
verification_task_not_found 404 No verification task exists for the public handle.
guarded_resource_not_found 404 Referenced guarded resource bytes are unavailable.
content_lock_not_found 404 Referenced content lock is unavailable.
creator_connect_flow_unavailable 404 Pending creator connect flow is missing.
creator_connect_flow_expired 410 Pending creator connect flow expired.
frontend_session_code_unavailable 404 One-time frontend session code is missing.
frontend_session_code_expired 410 One-time frontend session code expired.
frontend_session_code_already_consumed 410 One-time frontend session code was already used.
frontend_session_unavailable 401 Frontend session token is missing or unknown.
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 A creator-scoped guarded path already has an in-flight/published owner, or the canonical Content Lock publication itself is still in flight.
task_state_conflict 409 Submission or completion conflicts with existing task state.
content_lock_deletion_in_progress 409 A deletion cutoff committed before this new proof Bundle could be admitted.
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.
not_paykit_payment 422 Connection state was requested for a verification task that is not Paykit-backed.
rate_limited 429 Submission or Paykit connection-state lookup exceeded configured admission limits.
payload_too_large 413 Raw guarded-resource upload exceeded [content_locks].max_resource_bytes.

| paykit_connection_state_unavailable | 502 | Paykit connection-state lookup failed or returned an invalid response. | | paykit_connection_state_timeout | 504 | Paykit connection-state lookup exceeded its whole-request deadline. | | internal_error | 500 | Unexpected server-side failure. |

Service discovery

GET /.well-known/locks-server

Returns minimal public service identity for browser SDK bootstrap and compatibility checks.

{
  "service": "pubky-locks-server",
  "api_version": "0.1",
  "lock_server": "pubkyserver123"
}

The SDK should verify all three fields:

  • service == "pubky-locks-server"
  • api_version == "0.1" for milestone 1
  • lock_server matches the explicit Lock Server Pubky resolved through PKARR

The endpoint is public, always mounted, CORS-enabled, and must not return secrets or runtime storage details.

Creator connect/session routes

These routes implement Creator Authority Acquisition using the explicitly configured method. Production deployments should use method = "grant-connect"; this emits exactly one signin_grant QR/deeplink and never starts or falls back to cookie auth. method = "legacy-connect" remains an explicit compatibility mode.

The hosted browser routes are mounted when [creator_authority_acquisition].enabled = true:

  • GET /connect
  • POST /connect/{flow_id}/complete
  • POST /frontend-sessions
  • DELETE /frontend-sessions/current

In grant mode, the Lock Server owns a per-flow deterministic PoP signer, validates grant issuer/session identity, configured client_id, PoP key, exact requested capability set, and expiry, then stores encrypted delegated restore state without storing the PoP private key. Creator I/O restores and revalidates the grant against the current configured client ID and exact required scopes, including after a Postgres-backed restart. Restore uses the homeserver stored at approval time, so a creator who moves homeserver must reconnect. The persisted authorization URL identifies whether each pending flow is signin or signin_grant; changing acquisition method while a flow is pending therefore fails closed instead of completing it through the other mechanism. Legacy records fail closed in grant mode and require creator reconnection. Both methods preserve the hosted shell callback, one-time frontend code, Locks-local frontend session, return_to validation, and CSP contract.

GET /connect

Starts the human-facing Lock-Server-hosted connect shell using the selected acquisition method.

GET /connect?return_to=https%3A%2F%2Fpubky.app%2Flocks%2Fcallback&state=opaque-state

return_to must be a full http/https URL whose origin matches [creator_authority_acquisition.grant_connect].allowed_return_origins in grant mode or [creator_authority_acquisition.legacy_connect].allowed_return_origins in legacy mode. Grant mode requires at least one grant-specific origin and does not fall back to legacy or top-level origins. Outside production, an explicit wildcard may admit the concrete return_to origin. The Lock Server validates this before starting Pubky auth.

Success returns 200 text/html; charset=utf-8. The HTML contains one Pubky authorization QR SVG and a touch-device deeplink button on the Lock Server origin. In grant mode its URL host is signin_grant; no cookie-auth URL is generated.

The HTML form posts back to:

POST /connect/{flow_id}/complete

POST /connect/{flow_id}/complete

Completes the pending Lock-Server-owned auth flow, stores creator authority, issues a one-time frontend-session code, revalidates the stored return_to, and redirects back to pubky.app:

303 See Other
Location: https://pubky.app/locks/callback?state=opaque-state&code=<one-time-code>

Approval/retrieval errors leave the pending flow available for bounded client retry. After approval succeeds, completion atomically consumes the pending flow before the creator-authority and one-time-code stores are updated, preventing concurrent completion from issuing two codes. Those final two writes are not one cross-store transaction: a storage failure after flow consumption can require a new connect flow and can leave already-validated creator authority stored without a frontend code. It cannot make unvalidated authority usable.

The callback URL carries only state and code. It must not contain authorization_url, pubkyauth, creator-authority session material, or frontend session tokens. pubky.app then calls POST /frontend-sessions with the code and state.

POST /frontend-sessions

Exchanges a one-time code plus original state for a Locks-local frontend session token.

Request

{
  "code": "<one-time-code>",
  "state": "opaque-state"
}

Success response

{
  "session_token": "<locks-local-session-token>",
  "creator": "pubkycreator123",
  "expires_at": "2026-06-17T13:00:00Z"
}

The raw session_token is returned exactly once. Frontend session tokens authenticate only future pubky.app/browser -> Lock Server APIs; they are not Pubky homeserver sessions and must not be confused with Locks-issued reader access credentials.

DELETE /frontend-sessions/current

Revokes the current Locks-local frontend session token.

DELETE /frontend-sessions/current
Authorization: Bearer <frontend_session_token>

Success returns 204 No Content. The request token is deleted from the frontend session store; subsequent authenticated creator requests with that token return 401 frontend_session_unavailable. The response body is empty and must not echo the raw token.

Creator authority status route

GET /creator/authority-status

Returns secret-free status for the Lock Server's creator-granted homeserver authority, derived from the authenticated Locks-local frontend session.

Request:

GET /creator/authority-status
Authorization: Bearer <frontend_session_token>

No request body, no query-string creator, and no query-string token are accepted. The creator identity is derived from the frontend session token.

Missing authority response:

{
  "creator": "pubkycreator123",
  "authorized": false,
  "auth_kind": null,
  "granted_scopes": [],
  "session_expires_at": null
}

Authorized response:

{
  "creator": "pubkycreator123",
  "authorized": true,
  "auth_kind": "legacy_cookie",
  "granted_scopes": ["/pub/app.locks/:rw", "/priv/app.locks/:rw"],
  "session_expires_at": null
}

The response must not include creator authority secrets, frontend session tokens, one-time codes, authorization URLs, or database configuration. This route is a read model only; it does not revalidate the underlying Pubky session. Pubky I/O paths still revalidate before writes/reads.

Error cases:

  • Missing/malformed frontend session bearer: 401 frontend_session_unavailable.
  • Expired frontend session: 401 frontend_session_expired.

Paykit setup readiness route

GET /creator/paykit/setup-status

Returns whether the authenticated Creator already has usable authority configured on Paykit Server. Creator identity comes only from the Locks frontend session.

GET /creator/paykit/setup-status?asset=USD
Authorization: Bearer <frontend_session_token>

The required asset query parameter is BTC, USD, or USDT, matching the invoice denomination. Other query fields and a request body are rejected. Readiness covers authority and a receiving method usable for that denomination; USDT address sharing remains optional for Bitcoin-only creators. Success is always a closed one-field object:

{"status":"ready"}
{"status":"setup_required"}
{"status":"unavailable"}
  • ready: skip Paykit authorization.
  • setup_required: launch the Paykit setup iframe.
  • unavailable: show a retry/degraded state. Never treat this as setup_required and never launch authorization from it.

For authenticated requests, missing Paykit configuration, timeout/network failure, non-success Paykit responses, and malformed/unsupported Paykit success bodies all project to {"status":"unavailable"}. Frontend-session failures retain the existing 401 error envelope. The browser response never exposes Paykit failure details, configured URLs, Creator authority details, or credentials.

Creator publishing routes

Creator publishing routes always use Pubky homeserver-backed repositories. Callers must pass Authorization: Bearer <frontend_session_token>. Handlers derive creator from the Locks-local frontend session and reject request-body creator; frontend session tokens must not be supplied in query strings, request bodies, or cookies.

PUT /creator/priv-resources/content/<path>

Response-shape fixture: locks-server/tests/fixtures/creator_publishing/register_guarded_resource_response_shape.json

Registers or replaces the current guarded resource bytes for the authenticated creator and decoded relative content path. The server reconstructs the canonical private guarded resource path as /priv/app.locks/content/<path>. With Pubky-backed repositories, it writes bytes to the creator homeserver under that path.

Request

PUT /creator/priv-resources/content/example.txt
Authorization: Bearer <frontend_session_token>
Content-Type: text/plain

guarded bytes

The request body is raw resource bytes, not JSON. Content-Type is required and validated before upload, but Pubky homeserver storage determines the MIME type recorded for the resource. After writing, the Lock Server performs a metadata-only HEAD, verifies its ETag/content hash and length against the upload, and returns a descriptor containing the storage-authoritative content_type. It may differ from the request header—for example, an extensionless SVG may be returned as application/octet-stream, and WAV may be returned as audio/x-wav. The resource body is not downloaded again.

Path rules:

  • MUST be non-empty after percent-decoding.
  • MUST be relative to /priv/app.locks/content/; callers supply only the relative path, not the full private path.
  • MAY contain nested slash-separated segments.
  • MUST NOT start with /.
  • MUST NOT contain .. traversal segments, including encoded traversal.
  • MUST NOT contain // ambiguity.
  • MUST NOT be URL-like content.

Upload size is limited by [content_locks].max_resource_bytes and defaults to 10,000,000 bytes.

Success response

{
  "creator": "pubkycreator123",
  "guarded_resource": {
    "path": "/priv/app.locks/content/example.txt",
    "hash": "<guarded_resource_hash>",
    "content_type": "text/plain",
    "size": 13
  }
}

The response is descriptor-only. It does not return raw bytes. Clients must use the returned descriptor unchanged when creating a content lock; they do not need to predict or normalize the homeserver's MIME type.

Error cases

  • Missing, unknown, or expired frontend session: 401 frontend_session_unavailable or 401 frontend_session_expired.
  • Missing or invalid Content-Type: 400 invalid_request.
  • Invalid relative path: 400 invalid_request.
  • Empty body: 400 invalid_request.
  • Body exceeds configured upload limit: 413 payload_too_large.
  • Missing, malformed, or mismatched metadata after a successful upload: 500 internal_error. This includes concurrent replacement between PUT and HEAD; clients may retry.
  • Missing/revoked creator-granted homeserver authority: 503 creator_authority_unavailable.
  • Old POST /creator/priv-resources JSON/base64 route: 404 Not Found.

DELETE /creator/priv-resources/content/<path>

Deletes the current guarded resource bytes for the authenticated creator and decoded relative path. Deleting or replacing a guarded resource path makes existing content locks that reference the previous descriptor unreadable for that path.

DELETE /creator/priv-resources/content/example.txt
Authorization: Bearer <frontend_session_token>

Success returns 204 No Content. Missing resources return 404 guarded_resource_not_found.

DELETE /creator/content-locks/{lock_id}

Requires the authenticated creator frontend session. With no query, or with graceful=true, it starts or replays the durable graceful deletion job. Queued and running work returns 202 with only lock_id and status; a completed-and-forgotten absent lock returns 200 { "lock_id": "...", "status": "completed" }.

force=true is explicit and cannot be combined with graceful=true. With no active graceful job, force deletion synchronously stores a permanent blocking receipt with the frozen manifest, removes the public lock/tombstone, verifies absence, and then attempts every guarded-resource deletion. The receipt becomes completed only after public absence and every guarded-resource delete attempt succeeds; otherwise it remains queued and a repeated graceful or force DELETE retries cleanup from the retained manifest. Queued or running graceful jobs keep their frozen manifest, are requeued for worker force processing, and return 202; worker cleanup likewise remains pending and retryable until every guarded-resource deletion succeeds. Terminal jobs normally enter synchronous receipt-backed cleanup. A failed job that still owns an unresolved entitlement-publication claim is instead requeued for graceful reconciliation; retry force after that publication fence clears. Frozen-manifest retries validate immutable identity and resource-set integrity but do not reapply mutable admission limits, so lowering limits after admission cannot strand cleanup. A corrupt frozen manifest fails closed as state_corrupt while retaining the pending receipt. Synchronous success returns exactly { "lock_id": "...", "lock_deleted": true, "failed_resource_paths": ["..."] }. An in-flight canonical publication returns redacted 409 content_lock_path_conflict; force has not started and no receipt exists, so the creator may retry after publication reconciles.

Unknown query keys, malformed or false booleans, and ambiguous modes return 400 invalid_request. The accepted wire forms are exactly no query, graceful=true, or force=true.

Rust SDK callers use CreatorLocks::delete_content_lock(DeleteContentLockRequest { lock_id, mode }), where DeleteContentLockMode is closed to DefaultGraceful, ExplicitGraceful, and Force. JS/WASM callers use creator.deleteContentLock(lockId, options?); omitting options selects default graceful, while new DeleteContentLockOptions(DeleteContentLockMode.ExplicitGraceful) and new DeleteContentLockOptions(DeleteContentLockMode.Force) select the two explicit query modes.

GET /creator/content-locks/{lock_id}/deletion

Returns only { "lock_id": "...", "status": "queued|running|completed|failed", "failure_code"?: "..." }. The optional failure vocabulary is closed to tombstone_missing, tombstone_replaced, retry_exhausted, and state_corrupt. A pending force receipt returns queued; only a receipt whose external cleanup completion is durable returns completed. If neither a job nor permanent force receipt exists, it returns 404 content_lock_deletion_not_found.

POST /creator/content-locks

Fixtures:

  • Request: locks-server/tests/fixtures/creator_publishing/create_content_lock_request.json
  • Response shape: locks-server/tests/fixtures/creator_publishing/create_content_lock_response_shape.json

Creates or replaces a content lock from a resource set. A content lock may contain:

  • primary_resource: optional full GuardedResource descriptor.
  • secondary_resources: optional map keyed by full canonical private path. Values contain hash, content_type, and size only.

At least one resource is required. If a primary resource is present, its path must not also appear in secondary_resources. secondary_resources keys are full canonical private paths such as /priv/app.locks/content/attachments/a.txt.

With Pubky-backed repositories, this writes the public content lock JSON to the creator homeserver under its derived content_lock_path. Before external publication, Locks persists an opaque per-lock publication intent plus the complete canonical original lock (including its original created_at) under the same PostgreSQL fence used by graceful deletion and force-receipt establishment. Deletion cannot start while that intent exists; force therefore cannot report permanent deletion and then lose a race to a late Pubky write. Creator authority, session, grant, serialization, typed homeserver 401/403, connect failures, and other failures proved to occur before external dispatch verify the exact uncompleted token and remove its intent; newly reserved ownership is removed, while ownership retained from an earlier completed publication stays Reserved. Cached grant sessions get one bounded reacquisition attempt. Publication bookkeeping gets three bounded attempts. HTTP handler cancellation does not cancel admitted publication work. A failure after possible dispatch, process termination, or exhausted bookkeeping attempts remains fail-closed and retains the intent. An authenticated identical retry is checked against the retained canonical original but returns 409 content_lock_path_conflict until that original token has durably recorded write completion. It neither trusts public readback, which may predate the in-flight attempt, nor dispatches a replacement PUT, because the original write may still land. Changed requests cannot recover the intent. Ownership is never promoted and success is never returned while reconciliation remains incomplete. Exact replay also checks graceful jobs and force receipts under the same per-lock fence and returns 409 content_lock_deletion_in_progress once deletion wins admission. Test-support composition may use in-memory repositories behind the same authenticated route contract.

Operator recovery is deliberately offline. First quiesce publication for the creator and confirm no publication task or old process can still issue the PUT. Under the same pg_advisory_xact_lock(hashtextextended('locks:proof-admission:v1:<creator>:<lock_id>', 0)) transaction fence, decode public JSON as a ContentLock, validate its creator and derived Lock ID, deserialize original_content_lock JSONB as the same type, and compare those typed canonical values. If they match, set external_write_completed_at on the exact retained intent and let an identical authenticated POST reconcile it. If the public path is absent and no write can still land, invoke the fenced compensate_publication operation: in one transaction it verifies the exact creator, Lock ID, token, and uncompleted state, removes only that lock's Reserved ownership rows, then removes the intent. Never delete only the intent for a first publication. A replaced, malformed, or uncertain public value stays fenced; do not clear it by readback alone.

Every referenced guarded resource must currently exist for the same creator/path and must match hash, content type, and size. If the creator has overwritten or deleted a guarded resource path, content lock creation rejects the stale descriptor.

paykit-payment is the only active payment verifier wire value. Its v1 criterion params are exactly:

{
  "recipient_pubky": "pubky<recipient>",
  "amount": "50000",
  "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, 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

{
  "primary_resource": {
    "path": "/priv/app.locks/content/post.json",
    "hash": "<primary_hash>",
    "content_type": "application/json",
    "size": 123
  },
  "secondary_resources": {
    "/priv/app.locks/content/attachments/a.txt": {
      "hash": "<attachment_hash>",
      "content_type": "text/plain",
      "size": 13
    }
  },
  "criteria": [
    {
      "criterion_id": "criterion-1",
      "verifier_type": "dev-static",
      "params": { "satisfied": true }
    }
  ],
  "lock_logic": {
    "type": "all",
    "criteria": ["criterion-1"]
  },
  "access_policy": {
    "requested_credential_ttl_seconds": 900
  },
  "lock_server": {
    "override": "pubkyserver123"
  }
}

Success response

{
  "lock_id": "<lock_id>",
  "content_lock_path": "/pub/app.locks/<lock_id>.json",
  "content_lock": {
    "version": 1,
    "creator": "pubkycreator123",
    "primary_resource": {
      "path": "/priv/app.locks/content/post.json",
      "hash": "<primary_hash>",
      "content_type": "application/json",
      "size": 123
    },
    "secondary_resources": {
      "/priv/app.locks/content/attachments/a.txt": {
        "hash": "<attachment_hash>",
        "content_type": "text/plain",
        "size": 13
      }
    },
    "criteria": [
      {
        "criterion_id": "criterion-1",
        "verifier_type": "dev-static",
        "params": { "satisfied": true }
      }
    ],
    "lock_logic": {
      "type": "all",
      "criteria": ["criterion-1"]
    },
    "access_policy": {
      "requested_credential_ttl_seconds": 900
    },
    "lock_server": {
      "override": "pubkyserver123"
    },
    "created_at": "2026-06-03T12:00:00Z"
  }
}

The response does not include raw guarded resource bytes.

Error cases

  • Invalid JSON shape: 400 invalid_request.
  • Authenticated request includes body creator: 400 invalid_request.
  • Empty resource set: 400 invalid_request.
  • Too many resources or aggregate descriptor size over [content_locks] limits: 400 invalid_request.
  • Guarded resource descriptor is malformed: 400 invalid_request.
  • Guarded resource descriptor exists but is stale/mismatched: 400 invalid_request.
  • Guarded resource is missing for creator/path: 404 guarded_resource_not_found.

POST /creator/lock-service-config

Fixtures:

  • Request: locks-server/tests/fixtures/creator_publishing/set_lock_service_config_request.json
  • Response shape: locks-server/tests/fixtures/creator_publishing/set_lock_service_config_response_shape.json

Stores or replaces the creator's default Lock Service Pointer for the canonical Pubky path /pub/app.locks/config.json.

With Pubky-backed repositories, this writes the pointer JSON to the creator homeserver at /pub/app.locks/config.json. Test-support composition may use in-memory repositories behind the same authenticated route contract.

Content lock creation does not require a Lock Service Pointer. A content lock may carry lock_server.override; future viewer discovery can use the pointer when no override exists.

Request

{
  "default_lock_server": "pubkyserver123"
}

Success response

{
  "creator": "pubkycreator123",
  "path": "/pub/app.locks/config.json",
  "lock_service_pointer": {
    "version": 1,
    "default_lock_server": "pubkyserver123",
    "created_at": "2026-06-03T00:00:00Z"
  }
}

Error cases

  • Invalid JSON shape: 400 invalid_request.
  • Authenticated request includes body creator: 400 invalid_request.
  • Invalid default_lock_server: 400 invalid_request.

Viewer/access routes

POST /proof-bundles

Submits a viewer proof bundle for asynchronous verification.

Fixtures:

  • Request: locks-server/tests/fixtures/viewer_access/submit_proof_bundle_request.json
  • Response shape: locks-server/tests/fixtures/viewer_access/submit_proof_bundle_response_shape.json

Request envelope:

{
  "submitted_proof_bundle": {
    "version": 1,
    "bundle_id": "<bundle_id>",
    "pubky_lock_resource": "pubky<creator>/pub/app.locks/<lock_id>.json",
    "reader_public_key": "pubky<reader>",
    "proofs": [
      {
        "criterion_id": "criterion-1",
        "verifier_type": "paykit-payment",
        "payload": {}
      }
    ]
  }
}

Success response returns lifecycle metadata only. It does not return connection state, internal task_id, raw proof material, entitlement evidence, or access credentials.

For non-payment verifier types, reader_public_key may be omitted. For paykit-payment, reader_public_key is required as a top-level field on submitted_proof_bundle; the payment proof payload itself must be {}. Payment submissions are v1 single-proof only: a bundle with more than one paykit-payment proof, or a mix of paykit-payment and any other proof type, is rejected with 400 invalid_request.

Submission processing applies rate limiting, then checks permanent lifecycle identity { creator, bundle_id }. Changed submitted proof material returns 409 task_state_conflict; exact persisted replays return the existing lifecycle without depending on mutable current lock state. New submissions validate proof shape, load the current canonical content lock referenced by pubky_lock_resource, verify its lock identity and payment policy (including recipient/creator equality), then atomically persist a pending verification task plus immutable invoice-admission intent. The intent includes payment_in copied from the validated canonical paykit-payment criterion and is stored before any Reader homeserver or Paykit I/O. The response returns that pending lifecycle immediately; browser clients poll it and must not resubmit to drive invoice creation.

A claimed background worker sends the persisted body below to Paykit POST /invoices, using the task UUID as stable X-Request-ID for exact Paykit replay after an ambiguous response or process restart:

{
  "bundle_id": "<bundle_id>",
  "lock_resource": "pubky<creator>/pub/app.locks/<lock_id>.json",
  "reader": "pubky<reader>",
  "payment_in": 24
}

Paykit independently reads the canonical lock and rejects a request whose payment_in differs from the canonical criterion before side effects. Invoice success is 200 OK with invoice_created_at and payment_deadline RFC 3339 timestamps. Locks validates the response, marks invoice admission ready with the exact live claim token, then leaves existing payment-status/outbox processing unchanged. No entitlement can be issued before admission is ready.

Transport errors, typed Reader setup/registry failures (including malformed or oversized registry data), 429, and every 5xx response schedule durable full-jitter retry (2-second initial cap, then exponential caps through 60 seconds) without extending the DB-owned 10-minute admission deadline. Numeric Retry-After is clamped to 60 seconds. 409 reader_not_payable maps to reader is not payable; 409 invoice_conflict maps to paykit invoice conflict; any other 409 and other terminal Paykit admission errors map to paykit invoice admission failed. A status-only 409 (no typed code, such as Paykit creator_session_invalid) is a terminal creator-side failure: it is not retried, and the purchase ends on the first attempt. 400 remains terminal. Deadline expiry maps to invoice admission deadline exceeded; it bounds when new Paykit attempts start, so a claim taken before the deadline is not cut off by it. If Paykit's 200 for such a claim arrives after the deadline but while the claim lease is still live, the admission still becomes ready, because Paykit has already created the invoice and durably queued its delivery to the Reader's wallet. A late 200 whose lease was lost, or whose claim token was superseded, is dropped. These four messages are the closed invoice-admission failure vocabulary; every failure is worker-owned and issues no entitlement.

Task insertion and deletion start serialize on one per-lock database fence. If task insertion commits first, deletion snapshots it; if deletion commits first, a genuinely new task returns 409 content_lock_deletion_in_progress. Exact pre-cutoff replay remains available after tombstoning. A deletion job cannot start Paykit draining while any snapshotted task remains invoice_pending.

POST /paykit-connection-state-lookups

Reads Paykit Server's local Noise peer state for an existing payment verification task. Request body is the same { creator, bundle_id } handle used for lifecycle lookup. Locks loads the persisted task, rejects non-paykit-payment tasks, derives the accepted Creator and bundle binding, then sends signed canonical JSON to Paykit POST /connections/status. Browser callers cannot supply a peer key or receiver path.

Success response is exactly one of none, handshake, connected, recovery_required, or blocked:

{ "state": "handshake" }

This lookup is independent from verification lifecycle. connected does not mean payment or verification completed. Connection lookup errors do not fabricate connection states and should not stop lifecycle polling. blocked requires authorized operator action; recovery_required means the current local cryptographic generation must be recovered or relinked.

Locks limits this public outbound proxy independently from proof submission. Default admission is 60 requests per 60 seconds for each (client IP, creator, bundle_id) plus 16 concurrent outbound Paykit status requests process-wide. Fixed-window rejection includes Retry-After; either limit returns 429 rate_limited before another Paykit request is sent.

Paykit status verification is worker-owned. The Lock Server sends canonical JSON { "creator": "pubky...", "bundle_id": "..." } to POST /payment-requests/status. X-Paykit-Signature signs b"paykit-http-signature-v1\0" + uppercase_method + b"\0" + exact_query_free_path + b"\0" + exact_canonical_body; body-only signatures are unsupported. The closed response keeps request_state (proposed, proposal_expired, accepted, rejected, canceled, proof_submitted, active_recurring) separate from payment_state (undetected, detected, confirmed, expired) and includes invoice_created_at, payment_deadline, and nullable bitcoin and usdt_arbitrum observations. Each observation contains confirmations, amount_matched, and paid_on_time; USDT also reports finalized. Amount and timing must qualify on the same rail. Bitcoin uses the configured confirmation threshold; USDT unlocks after verified successful Arbitrum inclusion, without waiting for finalized. No raw transaction hash or payer claim can grant access. Locks validates timestamp syntax and strict ordering but never infers expiry from its local clock.

Rejected, canceled, proposal-expired, and payment-expired attempts return lifecycle status: "expired" with terminal_reason equal to payment_request_rejected, payment_request_canceled, proposal_expired, or payment_deadline_expired. Such responses have failure_message: null, issue no entitlement, and are not retried. Transport failures, timeouts, response-body read failures, and every non-200 response remain no-entitlement and retryable; 409 Conflict is preserved as an operator-visible conflict. A 200 response with malformed JSON, missing or unknown fields, an unknown state, an oversized body, or invalid or misordered timestamps is a permanent contract failure: the worker transitions the attempt to failed with a viewer-safe failure_message and no entitlement. failed remains distinct from payment-lifecycle expired, but both are terminal for that Bundle ID; another attempt requires a new Bundle ID.

Rate limiting, when enabled, returns 429 rate_limited with the stable error envelope.

POST /verification-task-lookups

Looks up lifecycle metadata by public handle { creator, bundle_id } using a JSON body. Bundle ID is bearer-secret-like, so it is not placed in URL paths or query strings.

Lifecycle responses include nullable status_message and admission_deadline_at. While invoice admission is pending, admission_deadline_at exposes the persisted DB-owned RFC 3339 cutoff; clients may use it to distinguish ordinary progress from an overdue/stalled worker, but must keep polling because only the worker may persist terminal failure. It becomes null after admission reaches ready or failed. Compare against a server-synchronized clock: DB time owns enforcement, so host/browser clock skew can make a local countdown approximate.

While invoice admission is durably retrying reader_setup_pending or malformed/oversized Reader registry data, status_message is "Reader wallet setup needed"; clients should show that safe message and continue polling the existing task. Once observed, this reason remains sticky across unrelated transient retries until admission becomes ready or failed. Other states use null. Neither projection implies entitlement, invoice readiness, worker execution, or synthetic failure.

Fixture: locks-server/tests/fixtures/viewer_access/verification_task_handle_request.json

POST /verification-task-completions

Dev/test-only route that completes a verification task by public handle. It is not mounted in production mode and is separate from normal worker-owned completion.

Request fixture: locks-server/tests/fixtures/viewer_access/verification_task_handle_request.json

POST /access-credentials

Issues a raw opaque bearer access credential after a verified entitlement exists.

Fixtures:

  • Request: locks-server/tests/fixtures/viewer_access/verification_task_handle_request.json
  • Response shape: locks-server/tests/fixtures/viewer_access/access_credential_response_shape.json

Request:

{
  "creator": "pubkycreator123",
  "bundle_id": "<bundle_id>"
}

Response includes the raw credential exactly once. Polling routes never return credentials.

GET /priv-resources/content/<path>

Proxy-reads one guarded resource from the content lock authorized by a bearer credential. The <path> segment is the same relative path used for upload; the server reconstructs /priv/app.locks/content/<path> and verifies that path is in the credential's content lock resource set before reading bytes.

Credentials are accepted only through:

Authorization: Bearer <credential>

Successful response:

  • status 200 OK
  • Content-Type is the locked resource descriptor content_type
  • Content-Length is the returned byte length
  • ETag is the verified resource hash quoted as an HTTP entity tag
  • body is raw guarded resource bytes
  • no JSON/base64 wrapping

Missing or malformed bearer credentials return 401 invalid_access_credential. Unknown paths, deleted resources, or stale/reuploaded resources return opaque 404 guarded_resource_not_found.

The old no-path GET /priv-resources route is removed and returns plain 404 Not Found because it is not mounted.