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.
The Lock Server has one non-production route family and one authenticated creator route family:
POST /verification-task-completions- Requires
runtime.environment = "development". stagingandproductionnever mount it.
- Requires
- 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, orproduction. - Require
Authorization: Bearer <frontend_session_token>. - Derive creator identity from the frontend session. Request-body
creatoris 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_unavailableor401 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.
- Requires
- 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, orunavailable.
- Requires
- 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/currentrequiresAuthorization: Bearer <frontend_session_token>and revokes the current frontend session.GET /connectvalidatesreturn_toagainst the selected method'sallowed_return_originsbefore starting a flow.POST /connect/{flow_id}/completecompletes the Lock-Server-owned flow, revalidates the storedreturn_to, and redirects with onlystateand one-timecode.
- Mount when explicit
When gated routes are disabled, they are not mounted and return 404 Not Found.
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 |
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. |
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 1lock_servermatches 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.
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 /connectPOST /connect/{flow_id}/completePOST /frontend-sessionsDELETE /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.
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-statereturn_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}/completeCompletes 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.
Exchanges a one-time code plus original state for a Locks-local frontend session token.
{
"code": "<one-time-code>",
"state": "opaque-state"
}{
"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.
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.
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.
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 assetup_requiredand 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 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.
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.
PUT /creator/priv-resources/content/example.txt
Authorization: Bearer <frontend_session_token>
Content-Type: text/plain
guarded bytesThe 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.
{
"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.
- Missing, unknown, or expired frontend session:
401 frontend_session_unavailableor401 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 betweenPUTandHEAD; clients may retry. - Missing/revoked creator-granted homeserver authority:
503 creator_authority_unavailable. - Old
POST /creator/priv-resourcesJSON/base64 route:404 Not Found.
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.
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.
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.
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 fullGuardedResourcedescriptor.secondary_resources: optional map keyed by full canonical private path. Values containhash,content_type, andsizeonly.
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.
{
"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"
}
}{
"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.
- 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.
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.
{
"default_lock_server": "pubkyserver123"
}{
"creator": "pubkycreator123",
"path": "/pub/app.locks/config.json",
"lock_service_pointer": {
"version": 1,
"default_lock_server": "pubkyserver123",
"created_at": "2026-06-03T00:00:00Z"
}
}- Invalid JSON shape:
400 invalid_request. - Authenticated request includes body
creator:400 invalid_request. - Invalid
default_lock_server:400 invalid_request.
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.
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.
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
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
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.
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-Typeis the locked resource descriptorcontent_typeContent-Lengthis the returned byte lengthETagis 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.