This is the current backend-only HTTP surface for Proofline. The API binary starts a main API/viewer listener and a private-admin listener on one or more configured bind addresses. Main /v1 routes require local account authentication except for login and the disabled-by-default registration/email-verification routes, and they use app-level route-class rate limits. Existing /admin/api/... JSON routes require an admin account with completed admin second-factor setup and are mounted only on the private-admin listener. The private-admin listener also serves the /admin dashboard route tree, which applies the same admin setup and active-factor session-verification gate before operator actions. Incident viewer routes are token-gated, read-only, and mounted on the main listener. The future canonical no-account viewer link belongs to the web-client origin as documented in web-client-viewer-routing.md; web-client implementation lives in open-proofline/web-client, while planned iOS and Android clients are not part of this repository yet.
Media bundle downloads are encrypted chunk bundles. The backend does not decrypt, merge, or produce playable media. Current encrypted uploads use the accepted post-quantum payload envelope documented in encryption.md and post-quantum-envelope.md; the API validates public envelope metadata but still treats payload bytes as opaque ciphertext.
The current API stores incidents owned by local accounts. Incidents are generic
by default and may include optional incident_mode, capture_profile,
escalation_policy, and sharing_state metadata on the main create/read
routes. Those fields are metadata only: they do not grant access, send
notifications, change key custody, expose trusted-contact workflows, or change
public viewer and bundle behavior. Mode-specific retention behavior is not
implemented; deletion and closed-incident retention enforcement are documented
in retention-backup-deletion.md. Planned
mode-driven behavior is documented in incident-modes.md.
Authenticated account-owner routes can store account/device recipient public-key
metadata, trusted-contact relationship lifecycle metadata, trusted-contact
public-key metadata, owner-scoped sharing-grant records, and wrapped
CEK/media-key metadata for active trusted-contact grants. Trusted-contact
relationship records do not grant keys, plaintext, notifications, emergency
dispatch, or viewer-token privileges by themselves. Authenticated
trusted-contact wrapped-key reads require an accepted relationship, a
recipient-bound active contact public key, an active unexpired ciphertext grant,
and an active wrapped-key record. Account/device wrapped-key delivery, browser
or backend decryption, notifications, raw key storage, and key escrow do not
exist yet. Future trusted-contact alerts, missed-check-in messages, and
viewer-link delivery are planned separately in
notification-boundary.md. The main API does include
a narrow public-safe owner incident list/detail read surface for future
production web-client use, but this does not make every /v1 route group public-ready without
route-level deployment review.
The public web-client route, CORS, CSRF, cookie, cache, edge, and logging
boundary is documented in
public-web-client-deployment-boundary.md.
Future capture stream groups, stream variant roles, source-timeline identity, and evidence supersession are planning-only in capture-stream-variants.md. Future full-fidelity GPS, speed, heading, and location freshness context is planning-only in encrypted-location-context.md. The current API still treats each media stream as one concrete upload lane and does not select canonical evidence across variants or implement encrypted location sidecars.
Default bind addresses:
- main API and incident viewer listener:
127.0.0.1:8080 - private admin dashboard listener:
127.0.0.1:8081
Use SAFE_MAIN_BIND_ADDRS and SAFE_ADMIN_BIND_ADDRS for comma-separated
bind-address lists. Legacy SAFE_PRIVATE_BIND_ADDRS still maps to the main
listener, but legacy SAFE_PUBLIC_BIND_ADDRS now fails startup so an old
public viewer bind cannot become the private-admin listener by accident.
Errors use:
{
"error": {
"code": "invalid_json",
"message": "request body must be valid JSON"
}
}Non-upload JSON bodies are limited to 64 KiB. Upload file bytes are limited by SAFE_MAX_UPLOAD_BYTES; multipart metadata has a small fixed overhead allowance. Accepted encrypted chunk bytes are also limited by the account-scoped committed blob quota configured with SAFE_ACCOUNT_DEFAULT_BLOB_QUOTA_BYTES, which defaults to 10 GB per owner account. Local temp-upload staging bytes are limited by SAFE_TEMP_UPLOAD_STAGING_QUOTA_BYTES, which defaults to 1 GB and applies to regular upload-* staging files before local or S3-compatible final commit. These byte settings accept a positive byte count or binary unit suffixes B, K/KB, M/MB, and G/GB. Fractional unit values are allowed when they resolve to at least one byte. Non-positive, sub-byte, invalid, and oversized values are rejected during startup.
Main API route classes are rate limited by default before authentication using
safe server-controlled keys based on route class and a hash of the socket peer
identity. Login/logout, public registration, registration email verification,
email second-factor challenge routes, TOTP setup/verification routes, and
WebAuthn register/verify routes have separate authentication-related route
classes. Existing main API limit classes
also cover account metadata, account/device recipient-key metadata, contact-key
metadata, trusted-contact relationship metadata, incident metadata reads and
writes, sharing-grant metadata, wrapped-key metadata, uploads, reconciliation,
streams, incident tokens, and private encrypted downloads. Rate-limit keys do
not include raw email addresses, raw usernames, verification tokens,
second-factor challenge codes, TOTP codes, TOTP seeds, WebAuthn challenge or
client-data values, raw session tokens, Authorization headers, raw idempotency
keys, request bodies, uploaded bytes,
incident IDs, stored paths, object keys, plaintext, raw keys, wrapped-key
ciphertext, or private deployment details. Exhausted limits return
429 rate_limited with Retry-After. A configured coordination limiter
failure returns 503 rate_limit_unavailable with a generic response. See
configuration for SAFE_MAIN_API_RATE_LIMIT_* settings.
The current listener split does not mount /v1/health/live or
/v1/health/ready on either listener. The private-admin listener is an
admin-only /admin/api/... and /admin surface, and the main listener must not publish
operator readiness details on the same origin as future public product API
routes. Local and CI smoke checks use token-neutral static assets plus the
admin bootstrap/login flow to prove both listener trees are serving.
Private /v1 routes require a bearer session token by default:
Authorization: Bearer <session_token>Session tokens are opaque server-side credentials. The raw token is returned only by login, while the metadata backend stores only its SHA-256 hash. Sessions expire after SAFE_SESSION_TTL, defaulting to 12h, and can be revoked by logout, password reset, or the admin session-revocation route.
When SAFE_WEB_AUTH_ENABLED=true, the main API also accepts a dedicated
browser session cookie for /v1 routes when no bearer token is present. Browser
cookie mode is intended for future production use by open-proofline/web-client:
web clients should call with credentials: "include" and should not store raw bearer
tokens in localStorage in production. If a request sends both
Authorization: Bearer ... and the browser session cookie, the server rejects
it with 400 ambiguous_credentials.
Cookie-authenticated unsafe requests such as POST and PATCH require a
session-bound CSRF token in the configured header, defaulting to
X-CSRF-Token. Bearer-authenticated requests keep their existing behavior and
do not require this CSRF header. Credentialed CORS is sent only for exact
origins configured with SAFE_WEB_ALLOWED_ORIGINS; CORS is not authentication.
On startup, the server fails closed unless an admin account already exists or a
bootstrap secret is configured. With that secret configured, create the first
admin through the private /admin bootstrap screen or by posting form fields
to POST /admin/bootstrap, then remove the bootstrap secret from TOML, the
environment, or the secret mount and restart or redeploy without it. JSON
POST /v1/bootstrap/admin is not mounted on either listener.
Public account registration is controlled by
SAFE_ACCOUNT_REGISTRATION_MODE, defaulting to disabled. Supported modes are:
| Mode | Public registration behavior |
|---|---|
disabled |
POST /v1/auth/register returns 403 registration_disabled. Existing accounts and sessions continue to work. |
admin_only |
Public registration returns 403 registration_disabled; admins can still create accounts through existing admin-only routes. |
open |
Public self-registration accepts a username, email, and password, creates a pending account, and sends an email verification link before login is allowed. |
paid |
Reserved for future hosted-service billing. Registration returns 503 registration_payment_unavailable and does not create an active account. |
Open registration requires an email backend and public web origin at startup. Verification links use:
{SAFE_PUBLIC_WEB_ORIGIN}/verify-email#token=<raw-token>
The token is placed in the URL fragment so a future production web-client viewer can submit it in the JSON request body without sending it to the web server as a path or query value. The backend stores only token hashes, not raw verification tokens.
Accounts also carry a factor-neutral second_factor_setup_state:
| State | Meaning |
|---|---|
not_required |
Product-route access is not blocked by second-factor setup. Existing migrated accounts default to this state for preview compatibility. |
setup_required |
Primary login can create bearer or browser-cookie sessions, but main product routes fail closed until setup is completed by email challenge, TOTP, or WebAuthn second-factor verification. |
complete |
Required second-factor setup is complete for product-route access. |
New accounts created through the private admin API, the /admin bootstrap
surface, or open registration start in setup_required. Email verification
only moves account_state from pending_email_verification to active; it
does not complete second-factor setup. While setup is required, authenticated
clients can inspect GET /v1/account, obtain browser CSRF metadata, request or
verify email/TOTP/WebAuthn second-factor setup, and log out. Other main product routes
return:
{
"error": {
"code": "second_factor_setup_required",
"message": "second factor setup is required before account access"
}
}Email challenge, TOTP, and disabled-by-default WebAuthn/FIDO2 are the
implemented second-factor setup methods. Accounts with active email challenge,
TOTP, or WebAuthn factors also require per-session second-factor verification
after primary login before product routes are available. Until that challenge
is satisfied, authenticated sessions can inspect GET /v1/account, obtain
browser CSRF metadata, verify the active email, TOTP, or WebAuthn challenge,
and log out. Other main
product routes return:
{
"error": {
"code": "second_factor_verification_required",
"message": "second factor verification is required before account access"
}
}Lost-factor recovery is private-admin assisted only. There is no self-service
recovery code, password recovery, email reset link, public recovery portal, or
factor bypass. A private-admin reset can remove enrolled email, TOTP, and
WebAuthn factors for a local account, mark second-factor setup required again,
revoke that account's active sessions, and write controlled audit metadata.
It does not alter account/device recipient keys, trusted-contact keys,
sharing grants, wrapped CEK/media-key records, incidents, blobs, key custody,
or decryption behavior. Lost account/device recipient keys remain managed by
the existing account recipient-key lost/revoke/replace routes after the account
is recovered. WebAuthn routes remain unavailable with
503 webauthn_unavailable until [webauthn] is explicitly enabled with an RP
ID and exact allowed origins.
Private admin operator access is stricter than product-route preview
compatibility. Admin accounts must have second_factor_setup_state=complete
before using the private /admin dashboard actions or /admin/api/... JSON
admin routes. Legacy admin accounts that still have not_required are treated
as setup-incomplete on the private admin surface. After setup is complete,
admin sessions with active email challenge, TOTP, or WebAuthn factors must
verify that same session before operator actions. WebAuthn/FIDO2 security keys
are preferred when configured; TOTP and email challenge remain lower-preference setup or
verification paths where available. If all admin factors are lost, recovery is
an operator procedure: use another already verified admin to run the private
second-factor reset route, or perform a deployment-local database/operator
recovery under the private boundary. There is no public recovery bypass.
Authenticated route for starting email second-factor setup after primary login or sending an active email-factor session verification code. Setup-incomplete bearer and browser-cookie sessions may call this route; cookie-authenticated requests still require the configured CSRF header.
Request:
{
"email": "user@example.com"
}If email is omitted, the server uses the account's already verified
registration email address when one exists. The registration email verification
record itself is not a second factor. Admin-created accounts without a verified
account email must provide an email address here. The server validates and
normalizes the address, creates or refreshes a pending email_challenge
second-factor record, stores only a challenge-code hash, and sends the raw code
only through the configured email sender. The response never includes the raw
code or destination email address.
Response 202:
{
"status": "challenge_sent",
"message": "If the email challenge can be completed, a verification email will be sent.",
"expires_at": "2026-06-10T12:10:00Z"
}Challenge codes use SAFE_SECOND_FACTOR_EMAIL_CHALLENGE_TTL, defaulting to
10m. The request and verification routes use the
SAFE_MAIN_API_RATE_LIMIT_AUTH_EMAIL_VERIFY route class. Email delivery
failures return 503 email_unavailable; invalid or missing email input returns
400 email_required or 400 invalid_email; an account that already has an
active email factor returns 409 second_factor_already_configured when the
current session is already second-factor verified. If an active email factor is
the configured session challenge, the route sends a new code to the stored
factor email address and does not require or return the destination address.
Authenticated route for consuming an email second-factor challenge code. The code is account-bound, single-use, expires, and is looked up by hash.
Request:
{
"code": "challenge-code-from-email"
}token is accepted as a compatibility alias for code. Successful verification
marks the email factor active, consumes other pending challenges for that
factor, sets the account second_factor_setup_state to complete, and marks
the current session email-verified.
Response 200:
{
"status": "verified",
"second_factor": {
"id": "sf_...",
"factor_type": "email_challenge",
"state": "active",
"verified_at": "2026-06-10T12:04:00Z"
},
"account": {
"id": "acct_...",
"username": "user",
"account_state": "active",
"second_factor_setup_state": "complete",
"second_factor_setup_required": false,
"role": "user",
"created_at": "2026-06-10T11:00:00Z",
"updated_at": "2026-06-10T12:04:00Z",
"password_changed_at": "2026-06-10T11:00:00Z"
},
"session": {
"session_id": "ses_...",
"second_factor_verified_at": "2026-06-10T12:04:00Z",
"second_factor_method": "email_challenge"
}
}Expired, reused, wrong-account, or invalid codes all return the same
400 second_factor_challenge_invalid response. Challenge codes, token hashes,
destination email addresses, request bodies, session tokens, CSRF tokens, and
other credential material must not be logged.
Authenticated setup route for starting TOTP authenticator-app enrollment after
primary login. Setup-incomplete sessions may call this route; cookie
authenticated requests still require the configured CSRF header. Calling enroll
again while a TOTP factor is still pending refreshes the pending seed and
invalidates setup codes from the older seed. Accounts with an active TOTP
factor receive 409 second_factor_already_configured.
Request:
{}Response 201:
{
"id": "sf_...",
"factor_type": "totp",
"state": "pending",
"secret": "BASE32SECRET...",
"otpauth_url": "otpauth://totp/Proofline:user?secret=BASE32SECRET...",
"issuer": "Proofline",
"account_name": "user",
"period_seconds": 30,
"digits": 6,
"algorithm": "SHA1"
}The secret and otpauth_url are setup credentials. They are returned only in
this enrollment response and must not be logged, stored in browser storage,
placed in URLs outside the otpauth_url, pasted into support artifacts, or
included in public issues.
Authenticated setup route for confirming a pending TOTP enrollment. The server
accepts six-digit SHA-1 TOTP codes with a 30-second step and one adjacent step
of clock skew on either side. Successful confirmation marks the factor active,
records the used time step to reject replay, sets the account
second_factor_setup_state to complete, and marks the current session as
second-factor verified.
Request:
{
"code": "123456"
}Response 200:
{
"status": "verified",
"second_factor": {
"id": "sf_...",
"factor_type": "totp",
"state": "active",
"verified_at": "2026-06-10T12:04:00Z"
},
"account": {
"id": "acct_...",
"username": "user",
"account_state": "active",
"second_factor_setup_state": "complete",
"second_factor_setup_required": false,
"role": "user",
"created_at": "2026-06-10T11:00:00Z",
"updated_at": "2026-06-10T12:04:00Z",
"password_changed_at": "2026-06-10T11:00:00Z"
},
"session": {
"session_id": "ses_...",
"second_factor_verified_at": "2026-06-10T12:04:00Z",
"second_factor_method": "totp"
}
}Invalid, stale, wrong-seed, or replayed setup codes return
400 totp_challenge_invalid.
Authenticated session challenge route for accounts that already have an active
TOTP factor. Primary login can create a bearer or browser-cookie session, but
product routes remain blocked with 403 second_factor_verification_required
until this route verifies a fresh TOTP code. A code whose time step is equal to
or older than the last accepted TOTP step is rejected to limit replay.
Request:
{
"code": "123456"
}Response 200:
{
"status": "verified",
"second_factor": {
"id": "sf_...",
"factor_type": "totp",
"state": "active",
"verified_at": "2026-06-10T12:04:00Z"
},
"session": {
"session_id": "ses_...",
"second_factor_verified_at": "2026-06-10T12:09:00Z",
"second_factor_method": "totp"
}
}Invalid, replayed, missing-factor, or wrong-account codes all return
400 totp_challenge_invalid. TOTP codes, seeds, otpauth_url values, request
bodies, session tokens, CSRF tokens, and other credential material must not be
logged.
Authenticated setup route for starting WebAuthn/FIDO2 passkey or roaming
security-key registration after primary login. Setup-incomplete bearer and
browser-cookie sessions may call this route; cookie-authenticated requests
still require the configured CSRF header. WebAuthn must be enabled with a valid
RP ID and exact allowed origins or the route returns 503 webauthn_unavailable.
Request:
{
"authenticator_attachment": "cross-platform"
}authenticator_attachment is optional. When present it must be platform or
cross-platform; the server uses it only to shape authenticator selection and
client hints.
Response 201:
{
"status": "registration_challenge_created",
"credential_creation": {
"publicKey": {}
},
"expires_at": "2026-06-10T12:05:00Z",
"authenticator_attachment": "cross-platform"
}The credential_creation.publicKey object is produced by the WebAuthn library
and includes the challenge and RP/user options needed by browser
navigator.credentials.create(). Challenge session data is stored server-side,
expires according to SAFE_WEBAUTHN_CHALLENGE_TTL, and is consumed on finish.
Authenticated setup route for finishing WebAuthn registration. The request body
is the browser WebAuthn attestation response from
navigator.credentials.create(). Successful verification stores public
credential material, sign counter, transports, attachment and backup flags,
sets the account second_factor_setup_state to complete, and marks the
current session as WebAuthn-verified.
Response 200:
{
"status": "verified",
"second_factor": {
"id": "sf_...",
"factor_type": "webauthn",
"attachment": "cross-platform",
"backup_eligible": true,
"backup_state": false,
"user_verified": true,
"clone_warning": false,
"verified_at": "2026-06-10T12:04:00Z"
},
"account": {
"id": "acct_...",
"username": "user",
"account_state": "active",
"second_factor_setup_state": "complete",
"second_factor_setup_required": false,
"role": "user",
"created_at": "2026-06-10T11:00:00Z",
"updated_at": "2026-06-10T12:04:00Z",
"password_changed_at": "2026-06-10T11:00:00Z"
},
"session": {
"session_id": "ses_...",
"second_factor_verified_at": "2026-06-10T12:04:00Z",
"second_factor_method": "webauthn"
}
}Expired, reused, wrong-account, malformed, or origin/RP-invalid ceremonies all
return the same generic 400 webauthn_challenge_invalid response. Raw
challenge values, client data JSON, credential bytes, request bodies, session
tokens, CSRF tokens, and other credential material must not be logged.
Authenticated session challenge route for accounts that already have an active
WebAuthn factor. Primary login can create a bearer or browser-cookie session,
but product routes remain blocked with
403 second_factor_verification_required until this route and the finish route
complete successfully.
Request:
{}Response 201:
{
"status": "verification_challenge_created",
"credential_assertion": {
"publicKey": {}
},
"expires_at": "2026-06-10T12:10:00Z"
}The credential_assertion.publicKey object is produced by the WebAuthn library
and includes the challenge and allowed credential IDs needed by browser
navigator.credentials.get().
Authenticated session challenge route for finishing WebAuthn assertion
verification. The request body is the browser WebAuthn assertion response from
navigator.credentials.get(). Successful verification updates credential
sign-count and backup/user-presence flags and marks the current session as
WebAuthn-verified.
Response 200:
{
"status": "verified",
"second_factor": {
"id": "sf_...",
"factor_type": "webauthn",
"backup_eligible": true,
"backup_state": false,
"user_verified": true,
"clone_warning": false,
"verified_at": "2026-06-10T12:04:00Z",
"last_used_at": "2026-06-10T12:09:00Z"
},
"session": {
"session_id": "ses_...",
"second_factor_verified_at": "2026-06-10T12:09:00Z",
"second_factor_method": "webauthn"
}
}Expired, reused, wrong-account, unknown-credential, malformed, or
origin/RP-invalid ceremonies all return the same generic
400 webauthn_challenge_invalid response.
Authenticates a local account and returns a raw session token once.
Request:
{
"username": "admin",
"password": "long local password"
}Response 201:
{
"session_id": "ses_...",
"account": {
"id": "acct_...",
"username": "admin",
"account_state": "active",
"second_factor_setup_state": "not_required",
"second_factor_setup_required": false,
"role": "admin",
"created_at": "2026-05-31T10:00:00Z",
"updated_at": "2026-05-31T10:00:00Z",
"password_changed_at": "2026-05-31T10:00:00Z"
},
"token": "...",
"second_factor_verification_required": false,
"created_at": "2026-05-31T10:00:00Z",
"expires_at": "2026-05-31T22:00:00Z"
}For accounts with an active email challenge, TOTP, or WebAuthn factor, primary login still
returns a session token, but second_factor_verification_required is true
and product routes fail closed until the matching second-factor verification
route succeeds.
Unauthenticated public registration endpoint. It is disabled unless
SAFE_ACCOUNT_REGISTRATION_MODE=open or paid.
Request for open registration:
{
"username": "new-user",
"email": "user@example.com",
"password": "long local password"
}In open mode the server validates the username, email, and password, creates
a pending_email_verification user account, stores a single-use
email_verification token hash, sends a verification email, and returns
202:
{
"status": "verification_required",
"message": "If registration can be completed, a verification email will be sent."
}The response does not include a session token. Duplicate username or email
registrations return the same generic 202 response and do not expose whether
the username or email already exists. Invalid fields can still return specific
validation errors such as invalid_username, invalid_email, or
invalid_password. A repeated registration with the same username and email
for a still-pending account may send a fresh verification email so transient
mail delivery failures can be retried without exposing the account state in the
HTTP response. Runtime mail delivery failures are logged with safe error
categories and keep the same generic 202 response shape.
In paid mode this route returns:
{
"error": {
"code": "registration_payment_unavailable",
"message": "paid registration is not available"
}
}No payment provider, checkout session, subscription state, active account, or billing webhook is created by this placeholder mode.
Unauthenticated email verification endpoint. The raw verification token is accepted only in the JSON body:
{
"token": "verification-token-from-email"
}The server hashes the token, looks up an unexpired and unconsumed
email_verification record, consumes it once, marks the email verified, and
activates the account if it was pending_email_verification. It does not
create a session automatically.
Success response:
{
"status": "verified"
}Invalid, expired, already consumed, wrong-purpose, or missing tokens return the
same generic 400 verification_token_invalid response.
Revokes the current session.
Enabled only when SAFE_WEB_AUTH_ENABLED=true. Authenticates a local account,
creates a normal hashed server-side session, sets the browser session cookie,
and returns safe session metadata without returning the raw session token in
JSON.
Response metadata includes second_factor_verification_required,
second_factor_verified_at, and second_factor_method with the same meaning
as bearer login. For accounts with an active email challenge, TOTP, or WebAuthn
factor, second_factor_verification_required is true until the matching
second-factor verification route succeeds with a valid CSRF header.
The preferred production cookie is __Host-proofline_session with HttpOnly,
Secure, SameSite=Lax, Path=/, no Domain attribute, and expiry aligned
with the server-side session. Local plain-HTTP development can opt into a
non-Secure non-__Host- cookie only for local web origins.
Enabled only when browser cookie auth is enabled and the request is authenticated by the browser session cookie. Returns the CSRF header name and a session-bound CSRF token for unsafe cookie-authenticated requests.
Enabled only when browser cookie auth is enabled. Requires the browser session
cookie and a valid CSRF header for an active session, revokes that server-side
session, and clears the browser session cookie. Existing POST /v1/auth/logout
continues to support bearer-token logout.
Returns the authenticated account for either bearer authentication or, when enabled, browser cookie authentication. This route remains available to setup-incomplete accounts so clients can display account and setup state.
Changes the authenticated account password after verifying current_password; other sessions for the account are revoked.
The private-admin listener serves the admin web surface outside the /v1 API
namespace. Scope, visual direction, sensitive-data display rules, and
validation expectations are documented in
private admin web scope. Current routes are:
GET /adminPOST /admin/loginPOST /admin/bootstrapPOST /admin/logoutPOST /admin/second-factor/email/challengePOST /admin/second-factor/email/verifyPOST /admin/second-factor/totp/verifyPOST /admin/passwordPOST /admin/accountsPOST /admin/accounts/{account_id}/passwordPOST /admin/accounts/{account_id}/second-factor/recovery/resetPOST /admin/accounts/{account_id}/sessions/revokePOST /admin/incidents/{incident_id}/deletionPOST /admin/incidents/{incident_id}/reassignmentGET /admin/static/...
GET /admin renders either the first-admin bootstrap form, the admin login
form, or the authenticated admin dashboard. The form handlers reuse the same
local account records and opaque server-side session store as the JSON API, but
the browser flow stores the raw session token in an HttpOnly, SameSite=Strict
cookie scoped to /admin.
The bootstrap screen is available only when no admin account exists and
SAFE_AUTH_BOOTSTRAP_SECRET is configured. It requires the bootstrap secret,
admin username, and admin password. The newly bootstrapped admin starts with
second-factor setup required and cannot use dashboard operator actions until
setup is complete. After an admin exists, /admin shows the login screen and
requires an admin account. Non-admin sessions are rejected.
The authenticated dashboard lists local accounts and safe incident-operation
metadata only after admin second-factor setup and any active email/TOTP/WebAuthn
session verification are satisfied. Setup-incomplete admins see a
second-factor setup screen instead of the dashboard. The screen prefers
configured WebAuthn/FIDO2 passkey or security-key setup, points TOTP setup to
the authenticated second-factor API, and offers an email challenge fallback
only when mail delivery is configured. Admins with active email factors can
send and verify an email code through
POST /admin/second-factor/email/challenge and
POST /admin/second-factor/email/verify, and admins with active TOTP factors
can verify the admin web session through
POST /admin/second-factor/totp/verify; WebAuthn/FIDO2 verification remains
available through configured second-factor clients/API sessions.
POST /admin/logout revokes the current admin web
session and remains available from the setup and verification screens.
Account forms can create local accounts, change the current admin password after verifying the current password, reset another local account password, revoke another account's sessions, and reset another account's second-factor recovery state with controlled reason codes. Incident forms can record a legacy unowned incident assignment or keep-unowned decision, request the same admin-global incident deletion decision as the private JSON API, and look up non-sensitive deletion status fields for an operator-provided incident ID. These authenticated state-changing forms use a session-bound CSRF token and block unsafe current-admin self-reset actions from per-account forms.
/admin/static/styles.css is unauthenticated because it is token-neutral static
CSS from the AGPL-licensed source tree. It does not contain incident data,
tokens, deployment details, keys, or evidence metadata. The admin HTML pages
use Cache-Control: no-store and conservative browser security headers.
The admin web surface shows only safe route-boundary status, local account-management data, count-oriented legacy unowned incident metadata, and non-sensitive deletion status fields. It does not expose incident notes, evidence content, viewer tokens, session tokens, CSRF tokens, password hashes, request bodies, uploaded bytes, Authorization headers, plaintext, raw keys, wrapped-key ciphertext, stored paths, object keys, private deployment details, or user safety narratives. It is not a public admin dashboard and must stay on the private-admin listener.
The following routes are mounted only on the private-admin listener and require an admin account session with completed admin second-factor setup. If the admin account has active email challenge, TOTP, or WebAuthn factors, the same session must also be second-factor verified before these routes run:
GET /admin/api/accountsPOST /admin/api/accountsPOST /admin/api/accounts/{account_id}/passwordPOST /admin/api/accounts/{account_id}/second-factor/recovery/resetPOST /admin/api/accounts/{account_id}/sessions/revokeGET /admin/api/incidents/unownedGET /admin/api/incidents/{incident_id}/deletionPOST /admin/api/incidents/{incident_id}/deletionPOST /admin/api/incidents/{incident_id}/reassignment
POST /admin/api/accounts accepts username, password, and role, where
role is user or admin. Admin password reset and explicit session
revocation revoke all sessions for the selected account.
POST /admin/api/accounts/{account_id}/second-factor/recovery/reset accepts a
controlled reason value:
lost_email_accesslost_totp_devicelost_webauthn_credentiallost_all_factorsadmin_created_setupoperator_review
The route removes enrolled email, TOTP, and WebAuthn factors and pending
second-factor challenges for the selected account, sets
second_factor_setup_state back to setup_required, revokes active sessions
for that account, and records an account_recovery_events audit row with
controlled counts. It does not accept free-text notes and does not return or
log raw challenge codes, TOTP seeds, WebAuthn challenge material, session
tokens, request bodies, key material, wrapped-key ciphertext, stored paths, or
private deployment details. It does not change password hashes, account/device
recipient keys, contact keys, sharing grants, wrapped-key records, incident
metadata, encrypted blobs, key custody, or decryption behavior.
These routes share the private-admin listener boundary with the /admin
dashboard but keep JSON bearer or browser-cookie session authentication and
admin-role checks. Private placement does not replace authentication, and
separate bind addresses are not a complete security model. Keep private-admin
listeners behind localhost, LAN, WireGuard, firewall rules, or a strict private
reverse proxy. Expose the main API only after deployment-specific TLS, path
routing, abuse controls, browser credential rules, CSRF decisions, logging
review, and production operations are explicitly designed and reviewed.
Contact public-key routes are mounted on the main API listener and require a
valid local account session. They are scoped to the authenticated account only;
admins do not use these routes to manage another account's contact keys. The
server stores public-key metadata, optional recipient_account_id binding,
wrapping algorithm names, fingerprints, state, and optional display labels. It
does not store contact private keys, raw CEKs, raw media keys, wrapped
CEKs/media keys, browser fragment secrets, plaintext, request bodies, uploaded
bytes, stored paths, staging paths, object keys, or private deployment details.
Contact key states are:
| State | Meaning |
|---|---|
pending_verification |
Registered but not eligible for new sharing grants. |
active |
Eligible for new sharing grants. |
replaced |
Superseded by another key version. |
revoked |
Revoked and not eligible for future grants. |
lost |
Marked lost and not eligible for future grants. |
New sharing grants and new wrapped-key records require an active contact
public key. The API can register a new contact by omitting contact_id, rotate
an existing contact by providing an account-owned contact_id, or replace a
specific key with POST /v1/contact-public-keys/{public_key_id}/replace.
Rotated or replaced keys receive the next version and mark prior nonterminal
versions for that contact replaced. Replacement does not mutate old
sharing-grant or wrapped-key records; those records remain bound to the
original contact public-key ID and version. Contact public-key routes store
owner-scoped public-key metadata only. The accepted v1 preview wrapped-key
profile is documented in
post-quantum-envelope.md and uses
proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1. The contact-key route does not
prove possession of a recipient private key by itself; wrapped-key record
creation validates the accepted PQ wrapping profile.
Registers trusted-contact public-key metadata for the authenticated account.
The optional key_state defaults to pending_verification.
recipient_account_id is optional for legacy or unbound contact-key metadata,
but trusted-contact wrapped-key read routes require it to match the signed-in
recipient account.
Request:
{
"recipient_account_id": "acct_contact...",
"display_label": "Trusted contact",
"wrapping_algorithm": "proofline-pq-mlkem768-hkdfsha384-aes256gcm",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "pending_verification"
}Response 201:
{
"contact_public_key": {
"public_key_id": "cpk_...",
"owner_account_id": "acct_...",
"contact_id": "ctc_...",
"recipient_account_id": "acct_contact...",
"version": 1,
"display_label": "Trusted contact",
"wrapping_algorithm": "proofline-pq-mlkem768-hkdfsha384-aes256gcm",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "pending_verification",
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-06-01T10:00:00Z"
}
}Lists contact public-key metadata owned by the authenticated account.
Returns one account-owned contact public-key record. Records owned by another
account return 404 contact_public_key_not_found.
Updates mutable contact-key metadata. The request may change display_label
and may move nonterminal keys between pending_verification and active.
Terminal states use the dedicated revoke, lost, and replace routes. Replaced,
revoked, or lost keys cannot be reactivated.
Request:
{
"display_label": "Verified contact",
"key_state": "active"
}Marks one account-owned contact public key revoked. Revocation prevents the key from receiving new sharing grants or future wrapped-key records. It does not delete already accepted ciphertext, bundle contents, or any material a future authorized actor may already have downloaded.
Marks one account-owned contact public key lost after the trusted contact or client reports private-key/device loss. Lost keys cannot receive new sharing grants or future wrapped-key records and cannot be reactivated. Existing sharing-grant and wrapped-key records are not rewritten.
Creates a successor public-key version for the same contact_id and marks
prior nonterminal versions for that contact replaced. The request body uses
the same public-key metadata fields as creation except contact_id.
Request:
{
"display_label": "Trusted contact replacement",
"wrapping_algorithm": "proofline-pq-mlkem768-hkdfsha384-aes256gcm",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "pending_verification"
}Response 201 returns the new contact_public_key. The old record remains
readable to the owner with key_state: "replaced", replaced_at, and
replaced_by_public_key_id. Existing wrapped-key records keep the old
contact_public_key_id and contact_public_key_version; delivery filters omit
them while the referenced key is no longer active.
Trusted-contact relationship routes are mounted on the main API listener and require a valid local account session. They model the account-to-account relationship lifecycle used by signed-in trusted-contact authorization. They are separate from public viewer tokens, contact public-key records, sharing grants, and wrapped-key records; relationship state alone does not deliver wrapped keys.
A trusted-contact relationship records owner account, recipient account, role, state, timestamps, optional display label, and revocation or replacement metadata. It does not store contact private keys, raw CEKs, raw media keys, wrapped-key ciphertext, plaintext, browser fragment secrets, notification payloads, emergency-dispatch state, request bodies, uploaded bytes, stored paths, object keys, or private deployment details.
Relationship states are:
| State | Meaning |
|---|---|
pending_invite |
Owner invited the recipient account; the recipient has not accepted or declined. |
active |
Recipient account accepted the relationship. |
declined |
Recipient account declined the invite. |
revoked |
Owner account revoked the relationship. |
replaced |
Owner account replaced this relationship with a successor invite. |
Only the owner account can create, revoke, or replace relationships. Only the
recipient account can accept or decline an invite. Opening an incident viewer
link does not create or accept a trusted-contact relationship, and a viewer
token is not a /v1 account credential.
The only implemented relationship role is:
| Role | Meaning |
|---|---|
trusted_contact |
General trusted-contact relationship metadata for future account-based access design. |
Creates a pending trusted-contact invite for another active local account. The
owner must supply recipient_account_id; relationship_role defaults to
trusted_contact.
Request:
{
"recipient_account_id": "acct_...",
"relationship_role": "trusted_contact",
"display_label": "Emergency contact"
}Response 201:
{
"trusted_contact_relationship": {
"relationship_id": "tcr_...",
"owner_account_id": "acct_owner...",
"recipient_account_id": "acct_contact...",
"relationship_role": "trusted_contact",
"relationship_state": "pending_invite",
"display_label": "Emergency contact",
"created_at": "2026-06-10T10:00:00Z",
"updated_at": "2026-06-10T10:00:00Z",
"invited_at": "2026-06-10T10:00:00Z"
}
}Duplicate open relationships for the same owner, recipient, and role return
409 trusted_contact_relationship_duplicate.
Lists relationships where the authenticated account is the owner or recipient.
Returns one relationship visible to the authenticated owner or recipient.
Unrelated accounts receive 404 trusted_contact_relationship_not_found.
Marks a pending invite active. Only the authenticated recipient account can use this route.
Marks a pending invite declined. Only the authenticated recipient account can use this route.
Marks a pending or active relationship revoked and records the owner account as the revoking account. Only the owner account can use this route. Revocation does not delete contact public keys, sharing grants, wrapped-key records, encrypted chunks, bundle contents, or material already downloaded by a future authorized actor.
Marks a pending or active relationship replaced and creates a successor
pending_invite relationship. The replacement request may supply a new
recipient_account_id, relationship_role, and display_label; omitted
recipient and role values reuse the previous relationship values.
Account/device recipient-key routes are mounted on the main API listener and require a valid local account session. They are scoped to the authenticated account only; admins do not use these product routes to manage another account's device keys unless the admin account owns those keys as its own account. The server stores public recipient-key metadata, non-secret key IDs, scheme/suite identifiers, fingerprints, state, timestamps, and optional display labels. It does not store private keys, raw media keys, raw CEKs, ML-KEM shared secrets, derived KEKs, plaintext, decrypted caches, browser fragment secrets, request bodies, uploaded bytes, stored paths, staging paths, object keys, or private deployment details.
These records are separate from trusted-contact public-key routes. They model
the owner's own account-level or device-level recipient keys so future clients
can wrap CEKs for the user's account or devices without treating every incident
as a new identity. Current wrapped-key creation remains trusted-contact
grant-scoped; account/device wrapped-key delivery is future work and must use
only active recipient-key versions when it is implemented.
Supported recipient types:
| Type | Meaning |
|---|---|
account |
Account-level recipient key metadata. |
device |
One owner device or client recipient key. |
Recipient key states:
| State | Meaning |
|---|---|
pending_verification |
Registered but not eligible for future wrapping. |
active |
Eligible for future account/device wrapping. |
replaced |
Superseded by another key version and not eligible for future wrapping. |
revoked |
Revoked and not eligible for future wrapping. |
lost |
Marked lost because the device/private-key copy is unavailable and not eligible for future wrapping. |
Revocation, replacement, and lost-device marking are forward-looking controls. They stop future wrapping to the affected key version. They cannot claw back wrapped keys, encrypted chunks, bundle contents, downloaded records, or any plaintext produced by a future authorized client before the state change.
The accepted v1 preview recipient-key profile is documented in post-quantum-envelope.md. The current route accepts:
scheme = proofline-pq-envelope-v1
suite_id = proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1
The route stores the caller-supplied public key_id and public key material as
metadata. It does not derive keys, prove private-key possession, unwrap CEKs, or
decrypt media.
Registers account/device recipient public-key metadata for the authenticated
account. recipient_type is required and must be account or device.
recipient_id is optional for a new recipient identity; if omitted, the server
creates one. New records default to pending_verification, and may start as
active only when the client has already completed its own verification.
Terminal states must be set with the dedicated revoke, replace, or lost routes.
Request:
{
"recipient_type": "device",
"key_id": "recipient-key-id",
"display_label": "Owner phone",
"scheme": "proofline-pq-envelope-v1",
"suite_id": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "pending_verification"
}Response 201:
{
"account_recipient_key": {
"recipient_key_id": "ark_...",
"owner_account_id": "acct_...",
"recipient_id": "arcp_...",
"recipient_type": "device",
"key_id": "recipient-key-id",
"version": 1,
"display_label": "Owner phone",
"scheme": "proofline-pq-envelope-v1",
"suite_id": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "pending_verification",
"created_at": "2026-06-10T10:00:00Z",
"updated_at": "2026-06-10T10:00:00Z"
}
}Duplicate recipient_id or key_id values for the same account return
409 account_recipient_key_duplicate. Use the replacement route for a new
version of an existing recipient identity.
Lists account/device recipient-key metadata owned by the authenticated account.
Returns one account-owned recipient-key record. Records owned by another
account return 404 account_recipient_key_not_found.
Updates mutable metadata. The request may change display_label and can move a
pending_verification key to active. It cannot reactivate revoked,
replaced, or lost keys, and it cannot use PATCH to set terminal states.
Request:
{
"display_label": "Verified owner phone",
"key_state": "active"
}Invalid state transitions return 409 invalid_account_recipient_key_state.
Marks one account-owned recipient key revoked and records revoked_at.
Revoked keys are not eligible for future account/device wrapped-key records.
Marks one account-owned recipient key lost and records lost_at. Use this when
a device or local private-key copy is unavailable, destroyed, taken, or
otherwise cannot be trusted for future wrapping.
Creates a successor version for the same recipient identity and marks the old
record replaced. Replacement is rejected for already revoked, replaced, or
lost keys.
Request:
{
"key_id": "replacement-recipient-key-id",
"display_label": "Replacement owner phone",
"scheme": "proofline-pq-envelope-v1",
"suite_id": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1",
"public_key": "base64url-or-profile-encoded-public-key",
"public_key_fingerprint": "fingerprint-...",
"key_state": "active"
}Response 201 contains the new account_recipient_key with the next version.
The old record remains readable as owner metadata with key_state: "replaced",
replaced_at, and replaced_by_recipient_key_id.
Sharing-grant routes are mounted on the main API listener and require a valid local account session. Grant creation and incident-scoped listing are account-owner actions: admins are not allowed to manage another account's sharing grants through these product routes unless the admin account owns the incident. Public viewer routes remain read-only and do not use these grant records.
Sharing grants authorize metadata, ciphertext, and wrapped-key delivery decisions. They do not decrypt media, create trusted-contact sessions, send notifications, alter incident-mode behavior, or change public viewer and bundle responses.
Grant data classes are:
| Data class | Meaning |
|---|---|
metadata |
Metadata access authorization. |
ciphertext |
Encrypted evidence access authorization. |
metadata_ciphertext |
Metadata and encrypted evidence access authorization. |
Creates an active sharing-grant record for an incident owned by the
authenticated account. stream_id is optional; omit it for incident scope.
The referenced contact must have an active contact public key owned by the same
account. If contact_public_key_id is omitted, the latest active key version
for the contact is used. recipient_type defaults to trusted_contact,
data_class defaults to metadata_ciphertext, and expires_at, when present,
must be in the future.
Request:
{
"stream_id": "str_...",
"contact_id": "ctc_...",
"data_class": "metadata_ciphertext",
"expires_at": "2026-06-08T10:00:00Z"
}Response 201:
{
"sharing_grant": {
"grant_id": "sgr_...",
"owner_account_id": "acct_...",
"incident_id": "inc_...",
"stream_id": "str_...",
"recipient_type": "trusted_contact",
"contact_id": "ctc_...",
"contact_public_key_id": "cpk_...",
"contact_public_key_version": 1,
"data_class": "metadata_ciphertext",
"grant_state": "active",
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-06-01T10:00:00Z",
"expires_at": "2026-06-08T10:00:00Z"
}
}Missing incidents, streams, or active contact public keys return
404 sharing_grant_dependency_not_found without revealing which dependency was
missing outside the owner boundary.
Lists sharing grants for an incident owned by the authenticated account.
Returns one sharing grant owned by the authenticated account.
Marks one account-owned sharing grant revoked and records the revoking account. Revocation stops future grant-based authorization or delivery. It does not delete encrypted chunks, incident metadata, bundle contents, or anything an authorized actor may already have downloaded.
Wrapped-key routes are mounted on the main API listener and require a valid local account session. They are account-owner routes: admins are not allowed to store, list, read, or revoke another account's wrapped-key records through these product routes unless the admin account owns the incident or record. Separate trusted-contact read-only routes deliver wrapped-key records only to the authenticated recipient account authorized by an accepted relationship, recipient-bound active contact key, active unexpired ciphertext grant, and active wrapped-key record.
The backend stores encrypted CEK/media-key material only when it is bound to an
active sharing grant that authorizes ciphertext access. The record includes the
incident, optional stream, media_key_id compatibility identifier, grant ID,
trusted-contact public key ID and version, wrapping algorithm/version,
wrapped-key ciphertext, and public wrapping metadata. In the future key model,
that identifier names the CEK scoped to the incident, stream, or bounded chunk
group. The record must not include raw CEKs, raw media keys, contact private
keys, plaintext, unwrapped shared secrets, browser fragment secrets, raw tokens,
or server escrow material.
Bundle manifests remain key-free. The current public incident viewer does not deliver wrapped keys, and public viewer bundle downloads keep their existing ciphertext-only behavior.
Wrapped-key records must use the accepted post-quantum profile in post-quantum-envelope.md with these field values:
wrapping_algorithm = proofline-pq-mlkem768-hkdfsha384-aes256gcm
wrapping_algorithm_version = 1
The API validates the accepted public metadata and wrapped-key frame shape without unwrapping CEKs or decrypting media.
Stores one wrapped CEK/media-key record for an incident owned by the authenticated
account. grant_id must name an active owner-scoped sharing grant for the same
incident. The grant must authorize ciphertext or metadata_ciphertext, must
not be expired, and must point at an active contact public key. If the grant is
stream-scoped, stream_id must match the grant's stream.
Request:
{
"stream_id": "str_...",
"grant_id": "sgr_...",
"media_key_id": "media-key-2026-06-01-audio",
"wrapping_algorithm": "proofline-pq-mlkem768-hkdfsha384-aes256gcm",
"wrapping_algorithm_version": "1",
"wrapped_key_ciphertext": "base64url-no-padding-PLPQWK1-frame",
"public_wrapping_metadata": {
"profile": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1",
"scheme": "proofline-pq-envelope-v1",
"suite_id": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1"
}
}Response 201:
{
"wrapped_key": {
"wrapped_key_id": "wkey_...",
"owner_account_id": "acct_...",
"incident_id": "inc_...",
"stream_id": "str_...",
"grant_id": "sgr_...",
"recipient_type": "trusted_contact",
"contact_id": "ctc_...",
"contact_public_key_id": "cpk_...",
"contact_public_key_version": 1,
"media_key_id": "media-key-2026-06-01-audio",
"wrapping_algorithm": "proofline-pq-mlkem768-hkdfsha384-aes256gcm",
"wrapping_algorithm_version": "1",
"wrapped_key_ciphertext": "base64url-no-padding-PLPQWK1-frame",
"public_wrapping_metadata": {
"profile": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1",
"scheme": "proofline-pq-envelope-v1",
"suite_id": "proofline-pq-mlkem768-hkdfsha384-aes256gcm-v1"
},
"wrapped_key_state": "active",
"created_at": "2026-06-01T10:00:00Z",
"updated_at": "2026-06-01T10:00:00Z"
}
}Missing incidents, streams, active grants, or active contact public keys return
404 wrapped_key_dependency_not_found. Metadata-only grants return
409 wrapped_key_grant_not_authorized. Reusing the same owner, incident,
stream scope, grant, media_key_id, and contact public key returns
409 wrapped_key_duplicate. Unsupported wrapping algorithms, malformed
wrapped-key frames, malformed public wrapping metadata, or profile mismatches
return 400 with a generic profile-validation error.
Lists active wrapped-key records for an incident owned by the authenticated account. The list is delivery-filtered: records are omitted when their grant is revoked or expired, their contact public key is no longer active, or the wrapped-key record itself is revoked or rotated.
Returns one active wrapped-key record owned by the authenticated account, subject to the same delivery filter as the incident list route.
Lists active wrapped-key records for the authenticated trusted-contact account. The response includes only records where all of the following are true:
- an active accepted trusted-contact relationship links the owner account to the authenticated recipient account
- the wrapped-key record uses a contact public key whose
recipient_account_idmatches the authenticated recipient account - the sharing grant is active, unexpired, and authorizes
ciphertextormetadata_ciphertext - the contact public key is active
- the wrapped-key record is active
Unrelated accounts receive an empty list. Public viewer-token routes do not mount this endpoint.
Returns one active wrapped-key record authorized for the authenticated
trusted-contact account under the same filters as the incident trusted-contact
list route. Unauthorized, expired, revoked, replaced, lost, rotated, or
unbound records return 404 wrapped_key_not_found.
Marks one account-owned wrapped-key record revoked and records the revoking account. Revocation stops future delivery of that wrapped-key record. It does not delete encrypted chunks, incident metadata, bundle contents, or material an authorized actor may already have downloaded.
Contact-key, sharing-grant, wrapped-key, and incident deletion-pruning lifecycle changes also write private repository-level audit metadata. The current API does not expose audit routes. Audit records use controlled IDs, actor/owner account IDs, action names, outcome categories, and timestamps only; owner-scoped automatic pruning decisions without an explicit account actor use the owner account as the private audit actor. They do not store raw keys, wrapped-key ciphertext, public wrapping metadata, plaintext, tokens, request bodies, uploaded bytes, stored paths, object keys, private deployment details, or user safety narratives.
Incident routes are mounted on the main API listener and require a valid session. Incidents are owned by the account that creates them. The account incident list and detail routes below return only public-safe metadata for incidents owned by the authenticated account. They do not include notes, chunks, checkins, stored paths, object keys, owner account IDs, wrapped keys, ciphertext, raw keys, plaintext, or user safety narrative. Admin accounts do not get cross-account reads through these account routes unless the admin account also owns the incident. Legacy unowned incidents are hidden from account list/detail reads unless an admin assigns one incident to an existing account through the private reassignment workflow; see legacy unowned incident reassignment.
Creates an open incident. When mode fields are omitted, the incident remains a generic legacy incident. The request may include optional mode metadata, but these fields do not grant access, create public links, send notifications, change retention, change key custody, expose trusted-contact workflows, or change public viewer and bundle behavior.
Request:
{
"client_label": "iphone",
"notes": "test incident",
"incident_mode": "interaction_record",
"capture_profile": "audio_location",
"escalation_policy": "none",
"sharing_state": "private"
}Optional mode values:
| Field | Accepted values |
|---|---|
incident_mode |
emergency, interaction_record, safety_check, evidence_note |
capture_profile |
audio_video_location, audio_location, location_checkin, note_or_attachment, custom |
escalation_policy |
none, trusted_contacts_on_start, trusted_contacts_on_missed_checkin, urgent_trusted_contact_alert |
sharing_state |
private, trusted_contact_access, public_link_created, legal_export_created, revoked_or_expired |
Response 201:
{
"incident_id": "inc_...",
"status": "open",
"incident_mode": "interaction_record",
"capture_profile": "audio_location",
"escalation_policy": "none",
"sharing_state": "private"
}Lists public-safe metadata for non-deleted incidents owned by the authenticated account. The response is ordered newest-updated first. Incidents owned by other accounts and legacy unowned incidents are omitted.
Response 200:
{
"incidents": [
{
"id": "inc_...",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z",
"status": "open",
"client_label": "iphone",
"incident_mode": "interaction_record",
"capture_profile": "audio_location",
"escalation_policy": "none",
"sharing_state": "private",
"deletion_state": "active"
}
]
}Returns public-safe metadata for one incident owned by the authenticated
account. Missing incidents, incidents owned by another account, legacy unowned
incidents, and deleted incidents all return 404 incident_not_found.
Response 200:
{
"incident": {
"id": "inc_...",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z",
"status": "open",
"client_label": "iphone",
"incident_mode": "interaction_record",
"capture_profile": "audio_location",
"escalation_policy": "none",
"sharing_state": "private",
"deletion_state": "active"
}
}Marks an incident closed. Later chunk uploads return 409 incident_closed.
Response 200 is the updated incident object. If the incident has
optional mode metadata, the same fields shown in the GET incident object can
be present. Closing an incident does not change sharing, retention, viewer,
notification, or key-custody behavior.
Requests deletion for an incident owned by the authenticated account. Admins can use this route only for incidents they own; use the admin route below for global deletion. The route creates durable deletion state and snapshots server-controlled stored paths from metadata before any blob is deleted. It is mounted only on the main API listener.
Request:
{
"reason_code": "account_delete",
"allow_open": true
}reason_code is optional and must be a short non-sensitive code using letters,
digits, _, -, ., or :. It must not contain raw tokens, request bodies,
evidence notes, private deployment details, plaintext, raw keys, stored paths,
object keys, or user safety narrative. Open incidents are rejected unless
allow_open is true. Repeating a deletion request for the same incident returns
the existing deletion decision.
Response 202:
{
"deletion": {
"decision_id": "del_...",
"incident_id": "inc_...",
"source": "account_request",
"reason_code": "account_delete",
"actor_account_id": "acct_...",
"allow_open": true,
"state": "deletion_pending",
"item_count": 2,
"requested_at": "2026-05-31T10:00:00Z",
"updated_at": "2026-05-31T10:00:00Z"
}
}Returns the non-sensitive deletion status for an incident visible to the authenticated account.
Lists legacy incidents whose owner_account_id is empty. This route is mounted
only on the private-admin listener and requires an admin account. It is for
one-incident-at-a-time operator review before reassignment or quarantine.
The optional limit query parameter defaults to 100 and is capped at 500.
The response is count-oriented and intentionally omits notes, chunk paths,
object keys, original_filename, location coordinates, raw tokens, request
bodies, uploaded bytes, plaintext, raw keys, and user safety narrative:
{
"incidents": [
{
"incident_id": "inc_...",
"status": "open",
"deletion_state": "active",
"created_at": "2026-06-02T10:00:00Z",
"updated_at": "2026-06-02T10:00:00Z",
"stream_count": 1,
"chunk_count": 5,
"checkin_count": 1,
"incident_token_count": 1,
"has_active_viewer_tokens": true,
"incident_mode": "interaction_record",
"capture_profile": "audio_location",
"escalation_policy": "none",
"sharing_state": "private"
}
]
}Records one private admin decision for an active legacy unowned incident. The
route either assigns the incident to an existing account or records a reviewed
keep_unowned decision. It updates only incidents whose owner_account_id is
still empty and does not change public viewer tokens, bundle behavior, deletion
state, retention state, encrypted blobs, token hashes, or key custody.
Assignment request:
{
"action": "assign_owner",
"new_owner_account_id": "acct_...",
"reason_code": "owner_verified"
}Keep-unowned request:
{
"action": "keep_unowned",
"reason_code": "keep_admin_only"
}Supported reason_code values are owner_verified, owner_request,
operator_review, keep_admin_only, unknown_owner, and
other_controlled. Free-form notes are not accepted.
Successful responses return safe audit metadata only:
{
"event": {
"id": "lra_...",
"incident_id": "inc_...",
"new_owner_account_id": "acct_...",
"actor_account_id": "acct_admin_...",
"action": "assign_owner",
"reason_code": "owner_verified",
"source": "admin_api",
"created_at": "2026-06-02T10:00:00Z",
"completed_at": "2026-06-02T10:00:00Z"
}
}Requests deletion for any incident visible to an admin account. The request and
response shape match the account route, but the source is admin_request.
Returns the non-sensitive deletion status for any incident by ID. This route requires an admin account.
Deletion states are:
| State | Meaning |
|---|---|
active |
Incident is not being deleted. |
deletion_pending |
A deletion decision exists and blob deletion items have been prepared. |
deleting |
The background worker is deleting encrypted blobs and metadata. |
deletion_failed |
Blob deletion failed and the deletion is retryable. |
deleted |
Encrypted blobs and sensitive child metadata have been removed or confirmed absent. |
While an incident is not active, write routes, bundle routes, chunk upload,
and new incident-token creation fail closed. Public viewer token lookups for
the incident return the same 404 incident_token_invalid shape used for
invalid, expired, or revoked tokens and do not reveal deletion state.
Chunk routes are mounted on the main API listener.
Uploads one already-encrypted chunk as multipart/form-data.
Optional header:
Idempotency-Key: stable key for this intended complete chunk upload. The value must be 1-255 visible ASCII characters. The server treats it as token-like: raw values are not logged, returned in errors, or stored raw.
Fields:
file: encrypted chunk bytesstream_id: media stream IDchunk_index: positive stream-local integermedia_type:audio,video,location, ormetadatastarted_at: RFC3339 timestampended_at: RFC3339 timestamp, not beforestarted_atsha256_hex: lowercase SHA-256 hex of the encrypted bytesoriginal_filename: optional client-supplied display metadata
Response 201:
{
"id": "chk_...",
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio",
"started_at": "2026-05-21T10:00:00Z",
"ended_at": "2026-05-21T10:00:10Z",
"original_filename": "chunk.enc",
"stored_path": "incidents/inc_.../streams/str_.../audio_000001.enc",
"byte_size": 23,
"sha256_hex": "...",
"created_at": "2026-05-21T10:00:11Z"
}When stream_id is provided, the stream must exist, belong to the same incident, be open, and have the same media_type as the uploaded chunk. Streamed chunks must use indexes starting at 1; chunk_index <= 0 returns 400 invalid_chunk_index. Uploads to completed or failed streams return 409 stream_not_open.
Clients must create a media stream and upload chunks with stream_id. Legacy
unstreamed chunk rows may still be read where they already exist, but new
uploads without a valid PQ payload frame and stream-bound identity fail closed.
Streamed chunk identity is (incident_id, stream_id, chunk_index), so each
stream can use normal stream-local chunk numbering.
Duplicate streamed (incident_id, stream_id, chunk_index) uploads without an
idempotency key return 409 duplicate_chunk. Hash mismatches return
400 hash_mismatch and do not commit a final file.
Before committing a new encrypted chunk, the server checks the owning account's
committed encrypted blob usage from accepted chunk metadata. If the additional
chunk would exceed SAFE_ACCOUNT_DEFAULT_BLOB_QUOTA_BYTES, the route returns
507 account_storage_quota_exceeded with a generic error. Equivalent duplicate
or idempotent retries do not add new committed bytes. Chunks continue to count
while incident deletion is pending or retrying and stop counting only after
durable blob deletion completes and chunk metadata is removed. Failed, staged,
or orphan temp uploads are not committed quota and are handled by separate temp
upload controls.
When regular local temp-upload staging files reach
SAFE_TEMP_UPLOAD_STAGING_QUOTA_BYTES, chunk upload returns
507 upload_staging_quota_exceeded with a generic error. The response does not
include temp paths, stored paths, object keys, bucket names, or uploaded bytes.
When Idempotency-Key is supplied, the server hashes the key and stores durable
upload-operation state in the configured metadata backend. The key is bound to
the upload_chunk operation and a request fingerprint covering normalized
chunk identity, media_type, started_at, ended_at, normalized
original_filename, ciphertext byte size, and ciphertext sha256_hex.
The first successful idempotent upload returns the normal 201 chunk response.
An equivalent retry with the same key and same complete encrypted chunk upload
can return 200 OK with the same chunk metadata shape and:
Idempotency-Replayed: trueReusing the same Idempotency-Key with a different chunk identity, metadata
fingerprint, byte size, or ciphertext hash returns 409 idempotency_conflict.
The conflict response is intentionally small and does not include uploaded
bytes, stored paths, object keys, raw keys, tokens, raw idempotency keys, or
private deployment details. Replays still upload the complete encrypted chunk;
this is not a resumable upload or partial-commit protocol.
When SAFE_COORDINATION_BACKEND=valkey or redis is configured, complete
chunk uploads also acquire a short-lived server-controlled coordination lease
for the normalized chunk identity after the server has read and validated the
multipart upload. If another API node is already processing that chunk
identity, the route returns a retryable response:
{
"error": {
"code": "upload_in_progress",
"message": "upload for this chunk identity is already in progress"
}
}The response status is 409 Conflict and includes Retry-After based on the
configured SAFE_UPLOAD_COORDINATION_LEASE_TTL. If configured coordination is
unavailable during upload, the route returns 503 upload_coordination_unavailable
with a safe Retry-After hint. Valkey lease keys are hashes of server-normalized
chunk identity; they do not contain raw tokens, raw idempotency keys, request
bodies, uploaded bytes, stored paths, object keys, plaintext, or raw keys.
These leases are in-progress hints only. Metadata uniqueness constraints,
upload-operation rows, and blob no-overwrite behavior remain final truth.
The repository rechecks incident state, stream state, and committed account quota when chunk metadata is inserted. If an upload races with incident close, stream completion, or quota exhaustion, the final metadata insert is rejected and the committed blob path is removed.
For clients using the PQ envelope, sha256_hex is the SHA-256 of the complete
uploaded PLPQENC1 payload frame bytes, not the plaintext. Missing, legacy,
downgraded, or mismatched envelope frames fail closed with
400 invalid_envelope.
original_filename is metadata, not a storage path. The server trims the value,
normalizes slash and backslash separators to a basename, falls back to the
multipart upload filename when the explicit field is empty, and stores the
resulting basename with chunk metadata. The value may be returned by private
chunk metadata routes, token-scoped public incident viewer summaries, and
completed stream or incident bundle manifests. Server stored paths, staging
paths, local filesystem paths, and object-storage keys are separate
server-controlled values and are not derived from original_filename.
Future clients should omit original_filename by default or send a generic,
non-identifying basename unless the user or a future protocol mode explicitly
chooses to preserve filename context as evidence metadata. Filenames can still
contain personal or contextual information even after path stripping. Do not use
original_filename for identity, authorization, storage lookup, decryption,
legal-record guarantees, or download path construction.
The current API does not implement resumable uploads, partial-upload lease
sessions, or client-side queue summary endpoints. Clients should retry
complete encrypted chunks, use Idempotency-Key for ambiguous complete-upload
outcomes, and use the duplicate chunk reconciliation route when they need to
compare a duplicate accepted chunk with a local expected fingerprint. The
resumable-upload planning decision is documented in
resumable-upload-lease-protocol.md.
Future upload telemetry remains client-local before v1 preview unless a later
issue implements the safe coarse-code boundary documented in
upload-telemetry-boundary.md.
The current API can issue short-lived regional relay upload and fanout
capabilities for authorized open streams and exposes narrow
service-authenticated core relay preflight, commit, and fanout authorization
routes. The separate cmd/stream-ingress relay can accept configured complete
encrypted chunk uploads at POST /upload/complete-chunk, stage ciphertext
temporarily, verify the declared SHA-256, forward exact bytes to the core
routes, and serve optimistic encrypted GET /fanout/subscribe SSE events
marked near_live_unconfirmed before emitting bounded relay_chunk_state
events for confirmed, rejected, or terminal_failure core commit
outcomes. Its token-neutral readiness route reports only safe aggregate
categories for upload readiness, core forwarding configuration, and
temp-staging pressure. It does not implement replay, relay metrics, production
service-identity rotation, or deployment automation. The relay upload and
fanout design is documented in
regional-stream-ingress-relay.md.
Any relay implementation must keep the core API authoritative for
authorization, idempotency, final blob commits, and metadata, and must not
expose the full /v1 control plane or admin routes through the relay.
Service-authenticated relay-to-core preflight route for cheap complete-chunk
metadata checks before the stream-ingress relay accepts a large body where
practical.
This route is mounted on the main API mux, not on the public incident viewer,
not on the private-admin listener, and not on cmd/stream-ingress.
The trusted relay must send:
X-Proofline-Relay-Service-Token: <relay-service-token>The service token is configured with SAFE_RELAY_SERVICE_AUTH_TOKEN or
SAFE_RELAY_SERVICE_AUTH_TOKEN_FILE and is separate from user bearer sessions,
browser cookies, viewer tokens, incident tokens, and relay upload
capabilities. The route also requires a signed relay upload capability issued
by POST /v1/incidents/{incident_id}/streams/{stream_id}/relay-session.
Request:
{
"relay_session_id": "Lzhc7ZXZQD6bLztwBBqJ8A",
"capability": "proofline-relay-capability-v1...",
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio",
"started_at": "2026-06-11T10:00:00Z",
"ended_at": "2026-06-11T10:00:10Z",
"byte_size": 32768,
"sha256_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"original_filename": "chunk-0001.pq"
}The core validates relay service auth, capability signature, expiry, upload
role, relay session binding, incident binding, stream binding, capability byte
and chunk limits, media type, incident state, stream state, duplicate chunk
identity, configured upload byte limit, and account committed-blob quota.
Response 200:
{
"relay_preflight": {
"status": "accepted",
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio",
"max_chunk_bytes": 52428800,
"max_chunks": 64
}
}Preflight success is a hint only. It does not stage bytes, reserve durable storage, insert metadata, create evidence, or guarantee a later commit.
Common safe failures include 401 relay_service_auth_required, 503 relay_service_auth_not_configured, 503 relay_capability_not_configured,
401 relay_capability_invalid, 401 relay_capability_expired, 403 relay_capability_wrong_role, 403 relay_capability_wrong_binding, 403 relay_capability_limit_exceeded, 409 duplicate_chunk, 409 incident_closed,
409 incident_deleting, 409 stream_not_open, 404 incident_not_found, 404 stream_not_found, 413 upload_too_large, and 507 account_storage_quota_exceeded.
Service-authenticated relay-to-core durable commit route for a complete
encrypted chunk. The route uses the same X-Proofline-Relay-Service-Token
header and relay capability validation as preflight, then commits through the
existing core storage and metadata path.
Request is multipart/form-data with these fields:
| Field | Meaning |
|---|---|
relay_session_id |
Relay session ID returned with the capability. |
capability |
Signed relay upload capability. |
incident_id |
Incident being uploaded to. |
stream_id |
Target open media stream. |
chunk_index |
Positive stream-local chunk index. |
media_type |
audio, video, location, or metadata; must match the stream. |
started_at / ended_at |
RFC3339 chunk time range. |
byte_size |
Declared encrypted byte size. |
sha256_hex |
Declared lowercase SHA-256 of encrypted bytes. |
original_filename |
Optional metadata basename; not a storage path. |
file |
Complete encrypted PQ payload frame bytes. |
The core streams file through the existing temporary upload path, enforces
SAFE_MAX_UPLOAD_BYTES and temp staging quota, verifies declared byte size,
verifies computed SHA-256 against sha256_hex, validates the accepted PQ
payload frame, applies the existing complete-upload coordination lease when
configured, commits the encrypted blob to local or S3-compatible durable
storage, and writes chunk metadata in SQLite or PostgreSQL.
Response 201:
{
"relay_commit": {
"status": "committed",
"chunk_id": "chk_...",
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio",
"byte_size": 32768,
"sha256_hex": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"created_at": "2026-06-11T10:00:11Z"
}
}The response intentionally omits stored_path, staging paths, object keys,
raw tokens, uploaded bytes, plaintext, raw keys, wrapped-key ciphertext, and
private deployment details. Hash mismatches return 400 hash_mismatch without
committing metadata. Declared byte-size mismatches return 400 byte_size_mismatch. Existing direct authenticated chunk upload behavior is
unchanged.
Service-authenticated relay-to-core authorization route for a relay fanout
subscriber. This route is mounted on the main API mux, not on the public
incident viewer, not on the private-admin listener, and not on
cmd/stream-ingress.
The trusted relay must send:
X-Proofline-Relay-Service-Token: <relay-service-token>Request:
{
"relay_session_id": "Lzhc7ZXZQD6bLztwBBqJ8A",
"capability": "proofline-relay-capability-v1...",
"incident_id": "inc_...",
"stream_id": "str_..."
}The core validates relay service auth, capability signature, expiry, fanout
role, relay session binding, incident binding, stream binding, incident state,
and stream state. A successful response authorizes only optimistic encrypted
fanout for that relay session/incident/stream context:
{
"relay_fanout": {
"status": "authorized",
"incident_id": "inc_...",
"stream_id": "str_..."
}
}The response does not commit evidence, create replay state, confirm chunk durability, or grant viewer-token, trusted-contact, admin, decryption, or key access.
The separate cmd/stream-ingress SSE fanout route remains outside the core API.
After successful fanout authorization, it sends relay_chunk events containing
base64 ciphertext and safe ciphertext metadata with state
near_live_unconfirmed. If the relay forwarded that exact ciphertext to the
core commit route, it then sends a relay_chunk_state event without
payload_b64: confirmed for core 201 or 200, rejected with
terminal: true and retryable: false for non-retryable core rejection, or
terminal_failure with terminal: true and retryable: true for timeout,
network loss, core 429, core 5xx, or invalid core success response.
Rejected and terminal-failure events close the affected in-memory fanout
stream/session. Hash mismatches are rejected before fanout publication.
Reconciles a duplicate chunk identity against already accepted metadata without re-uploading ciphertext. The route is mounted on the main API listener.
This is a separate private query workflow, not a public route and not an
enriched 409 duplicate_chunk upload response. A separate route lets clients
compare expected metadata without re-uploading ciphertext, keeps duplicate
upload errors small, and coexists with the current idempotency-key
retry-success path.
Request:
{
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio",
"started_at": "2026-05-21T10:00:00Z",
"ended_at": "2026-05-21T10:00:10Z",
"byte_size": 23,
"sha256_hex": "...",
"original_filename": "chunk.enc"
}For streamed chunks, stream_id is required and identity is
(incident_id, stream_id, chunk_index). media_type remains required and must
match the stream media type. Legacy unstreamed chunks are not accepted by the
current PQ upload default.
The comparison fingerprint is:
- normalized chunk identity
media_typestarted_atended_at- normalized
original_filename, including empty value - ciphertext
byte_size - ciphertext
sha256_hex
The route allows reconciliation after an incident is closed or a stream is complete or failed, because it is read-only and only confirms already accepted metadata. It does not overwrite, replace, delete, rewrite, or re-commit stored chunks.
Matched response 200:
{
"reconciliation": {
"status": "matched",
"identity": {
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio"
},
"chunk_id": "chk_...",
"byte_size": 23,
"sha256_hex": "...",
"started_at": "2026-05-21T10:00:00Z",
"ended_at": "2026-05-21T10:00:10Z",
"created_at": "2026-05-21T10:00:11Z"
}
}Conflict response 409:
{
"error": {
"code": "duplicate_chunk_conflict",
"message": "existing chunk does not match expected ciphertext or metadata"
},
"reconciliation": {
"status": "conflict",
"identity": {
"incident_id": "inc_...",
"stream_id": "str_...",
"chunk_index": 1,
"media_type": "audio"
},
"mismatched_fields": ["sha256_hex", "byte_size"]
}
}The conflict response should identify mismatched field names, not the existing
stored values. If no accepted chunk exists for the requested identity, return
404 chunk_not_found. Invalid identity or fingerprint fields should reuse the
existing upload validation error codes where practical, such as
400 invalid_chunk_index, 400 invalid_media_type, or
400 invalid_sha256_hex. Invalid or missing byte_size returns
400 invalid_byte_size.
Safe reconciliation responses may return server-generated chunk ID, normalized
identity fields, timestamps, byte size, ciphertext hash, creation time, and
field names that matched or mismatched. They must not return uploaded bytes,
plaintext, raw keys, raw tokens, request bodies, local filesystem paths,
stored_path, staging paths, object-storage keys, or object-storage
credentials.
HTTP coverage in internal/httpapi/uploads_test.go includes:
- matched streamed duplicate reconciliation
- conflicting streamed duplicate reconciliation
- fail-closed behavior for legacy unstreamed upload attempts
- omission of
stored_pathand stored conflicting values from reconciliation responses - read-only reconciliation after stream completion, stream failure, or incident close
Lists chunk metadata for one incident. This is not part of the public-safe account incident list/detail surface.
Returns encrypted bytes for an existing legacy unstreamed chunk as
application/octet-stream. This route is private/dev-only and is not used by
the incident viewer. New uploads should be stream-scoped PQ payload frames.
Streamed chunks are read through completed stream bundle downloads rather than
this legacy media/index route.
Media stream routes are mounted on the main API listener.
The current MediaStream model represents one concrete upload lane with one
media_type, stream-local chunk indexes, and open, complete, or failed
state. Future browser and native capture may group multiple concrete streams
under one capture source timeline, with explicit variant roles such as
live_preview, evidence_master, or audio_priority; that design is tracked
in capture-stream-variants.md. Do not encode
critical future variant semantics only in the free-form label field.
Creates an open media stream for an incident.
Request:
{
"media_type": "audio",
"label": "main audio recording"
}Response 201:
{
"stream": {
"id": "str_...",
"incident_id": "inc_...",
"media_type": "audio",
"label": "main audio recording",
"status": "open",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z"
}
}Invalid media types return 400 invalid_media_type.
Lists media streams for an incident.
Response 200:
{
"streams": []
}Returns one stream as:
{
"stream": {
"id": "str_...",
"incident_id": "inc_...",
"media_type": "audio",
"status": "open",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z"
}
}Marks an open stream complete after verifying chunks 1..expected_chunk_count exist contiguously and each stored file is readable. Completion revalidates chunk rows in the repository before committing the state change.
Request:
{
"expected_chunk_count": 12
}Response 200:
{
"stream": {
"id": "str_...",
"incident_id": "inc_...",
"media_type": "audio",
"status": "complete",
"expected_chunk_count": 12,
"completed_at": "2026-05-21T10:02:00Z",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:02:00Z"
}
}Missing or non-contiguous chunks return 409 stream_chunks_incomplete or 409 stream_chunks_not_contiguous. Completing an already complete or failed stream returns 409.
Marks an open stream failed while preserving uploaded chunks.
Request:
{
"failure_reason": "client stopped recording unexpectedly"
}Response 200 is the updated stream object with status: "failed" and failed_at set.
Failed streams can still contain preserved backend-confirmed evidence. Future evidence resolution should account for failed or incomplete stream variants instead of treating them as disposable previews.
Issues short-lived regional relay upload and fanout capabilities for one authorized open stream. This route is mounted on the authenticated main API listener and uses the same incident write authorization as encrypted chunk upload. It does not upload bytes, stage ciphertext, call a relay, commit to storage, fan out live chunks, or create a durable evidence record.
Relay capability issuance is disabled until a secret is configured with
SAFE_RELAY_CAPABILITY_SECRET or SAFE_RELAY_CAPABILITY_SECRET_FILE. The
secret must be at least 32 bytes. The issued capabilities are HMAC-signed,
expire after SAFE_RELAY_CAPABILITY_TTL, are bound to the returned
relay_session_id, incident ID, stream ID, and either the upload or
fanout role, and carry bounded upload limits. They are bearer-like
credentials and must not be logged or copied into public artifacts.
Response 201:
{
"relay_session": {
"relay_session_id": "Lzhc7ZXZQD6bLztwBBqJ8A",
"capability": "proofline-relay-capability-v1...",
"fanout_capability": "proofline-relay-capability-v1...",
"role": "upload",
"incident_id": "inc_...",
"stream_id": "str_...",
"expires_at": "2026-06-11T10:05:00Z",
"max_chunk_bytes": 52428800,
"max_chunks": 64,
"allowed_media_types": ["audio"]
}
}Closed incidents return 409 incident_closed; missing streams return
404 stream_not_found; completed or failed streams return 409 stream_not_open; missing capability secret returns 503 relay_capability_not_configured.
Downloads a completed stream as a ZIP bundle. Open or failed streams return 409 stream_not_complete.
Successful responses include:
Content-Type: application/zip
Content-Disposition: attachment; filename="incident_inc_..._audio_str_....zip"
Content-Security-Policy: default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; object-src 'none'
X-Content-Type-Options: nosniff
Cache-Control: no-store
Referrer-Policy: no-referrer
Permissions-Policy: geolocation=(), microphone=(), camera=()
X-Frame-Options: DENYZIP contents:
manifest.json
chunks/audio_000001.enc
chunks/audio_000002.enc
The manifest is generated from trusted database metadata and includes incident
ID, stream ID, media type, status, chunk count, total bytes, chunk SHA-256
metadata, and any stored original_filename basename for each chunk. Server
filesystem paths are not included.
It also includes a non-secret encryption hint indicating expected client-side encryption and server_decrypts: false.
Before sending ZIP headers or body bytes, the server reads each committed chunk
from storage and verifies its byte count and SHA-256 hash against database
metadata. Missing or mismatched committed chunks fail closed with 409 stream_bundle_inconsistent; the error response does not include server
filesystem paths, stored chunk paths, object keys, ZIP entry names, or chunk
bytes.
Future live or partial stream access is planning-only and should not be inferred from this completed bundle route. See live-partial-stream-access-boundary.md.
Downloads a ZIP bundle containing all completed streams for an incident:
manifest.json
streams/{stream_id}/manifest.json
streams/{stream_id}/chunks/audio_000001.enc
Open, failed, and legacy unstreamed chunks are omitted from this initial bundle format.
If any completed stream cannot be reconstructed or if any committed chunk's
stored byte count or SHA-256 hash does not match metadata, the incident bundle
request fails with 409 incident_bundle_inconsistent rather than returning a
partial bundle. Verification happens before ZIP headers or body bytes are sent.
The error response does not include server filesystem paths, stored chunk paths,
object keys, ZIP entry names, or chunk bytes.
Future canonical bundle or export manifests may use source-timeline evidence resolution to choose the best backend-confirmed variant per segment. That must be a separate manifest/API design and must not delete lower-quality fallback chunks merely because a higher-quality variant was planned.
Checkin routes are mounted on the main API listener.
Adds optional device status and location metadata. These fields are existing server-visible check-in metadata. They are not the future full-fidelity encrypted GPS/location evidence model documented in encrypted-location-context.md.
Request:
{
"device_battery_percent": 82,
"device_network": "wifi",
"latitude": -37,
"longitude": 145,
"accuracy_meters": 20
}Response 201 is the created checkin.
Incident-token creation, owner metadata reads, and revocation routes are mounted on the main API listener.
Creates a read-only viewer token for one incident. The raw token is returned only in this response; the configured metadata backend stores only a SHA-256 hash.
expires_at is optional. When omitted, the API applies the configured default token lifetime, which is 24 hours unless SAFE_DEFAULT_INCIDENT_TOKEN_TTL is changed. Explicit expires_at values are preserved; send null to explicitly create a token that remains valid until revoked. Setting SAFE_DEFAULT_INCIDENT_TOKEN_TTL=0 disables the default and lets omitted expiries remain valid until revoked.
Request:
{
"label": "trusted contact",
"expires_at": "2030-01-01T00:00:00Z"
}Response 201:
{
"token_id": "itk_...",
"incident_id": "inc_...",
"token": "...",
"label": "trusted contact",
"created_at": "2026-05-21T10:00:00Z",
"expires_at": "2030-01-01T00:00:00Z"
}The response includes Cache-Control: no-store.
Lists non-secret viewer-token metadata for an incident owned by the authenticated account.
Response 200:
{
"incident_tokens": [
{
"token_id": "itk_...",
"incident_id": "inc_...",
"label": "trusted contact",
"token_state": "active",
"created_at": "2026-05-21T10:00:00Z",
"expires_at": "2030-01-01T00:00:00Z"
}
]
}token_state is one of active, expired, or revoked. The response never
includes the raw viewer token or token hash. It does not allow token replay and
does not expose public token lookup by token ID. Non-owner accounts cannot read
another account's viewer-token metadata.
Reads one non-secret viewer-token metadata record for an incident owned by the authenticated account.
Response 200:
{
"incident_token": {
"token_id": "itk_...",
"incident_id": "inc_...",
"label": "trusted contact",
"token_state": "revoked",
"created_at": "2026-05-21T10:00:00Z",
"expires_at": "2030-01-01T00:00:00Z",
"revoked_at": "2026-05-21T11:00:00Z"
}
}If the token metadata record is not found for that incident, the route returns
404 incident_token_not_found. The response never includes the raw viewer token
or token hash.
Revokes a viewer token by ID.
Response 200:
{
"token_id": "itk_...",
"revoked": true
}Incident viewer routes are mounted on the main API/viewer listener. The current
server-rendered /i/{token} page and pre-rename /e/{token} aliases are
implemented prototype/local compatibility routes, not the long-term canonical
viewer surface. Proofline has no current public deployments that require a
long-lived compatibility window for these route shapes. Future no-account
viewer links should point at the web-client origin using the fragment-token
shape documented in web-client-viewer-routing.md.
The backend may still keep token-scoped data and encrypted download primitives
that a web-client viewer needs. Do not treat this section as approval for broad
public /v1 exposure or for routing private write/admin routes from public
viewer edges.
Renders a read-only HTML summary for a valid, unexpired, unrevoked token. The page includes embedded static CSS/JS files served from /static/.
Returns the same read-only summary as JSON for polling.
Response 200:
{
"incident": {
"id": "inc_...",
"status": "open",
"client_label": "iphone",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z"
},
"latest_checkin": null,
"chunk_count_by_media_type": {},
"latest_chunk_by_media_type": {},
"media": [
{
"media_type": "audio",
"chunk_count": 0
},
{
"media_type": "video",
"chunk_count": 0
},
{
"media_type": "location",
"chunk_count": 0
},
{
"media_type": "metadata",
"chunk_count": 0
}
],
"streams": [],
"completed_streams": [],
"warning": "If you are concerned about immediate safety, call emergency services now.",
"generated_at": "2026-05-21T10:00:12Z"
}Incident viewer responses include Referrer-Policy: no-referrer, X-Content-Type-Options: nosniff, Permissions-Policy: geolocation=(), microphone=(), camera=(), X-Frame-Options: DENY, and a strict Content-Security-Policy with frame-ancestors 'none'. Token-protected pages, JSON, errors, and downloads include Cache-Control: no-store. Invalid, expired, and revoked tokens all return 404 incident_token_invalid. App-level public viewer rate limits return 429 rate_limited with a safe JSON error body and Retry-After; limiter backend failures return 503 rate_limit_unavailable.
The Go app does not set Strict-Transport-Security in local/dev HTTP mode. Set HSTS at the HTTPS reverse proxy or deployment edge for production hostnames.
Returns the stable, token-scoped no-account viewer payload intended for the
future production web-client viewer. It is narrower than /i/{token}/data: it provides
incident status, latest check-in time, safe device state when present, and a
single latest shared or last reported location context when a check-in contains
both latitude and longitude. It does not expose chunk summaries, stream
summaries, encrypted bundle metadata, raw viewer tokens, token hashes, session
tokens, Authorization headers, request bodies, uploaded bytes, stored paths,
object keys, plaintext, raw keys, wrapped-key ciphertext, admin/operator
details, backend diagnostics, private deployment details, user safety
narrative, map-provider links, map API keys, or decryption material.
Response 200:
{
"payload_version": "proofline.viewer.basic.v1",
"incident": {
"id": "inc_...",
"status": "open",
"client_label": "iphone",
"created_at": "2026-05-21T10:00:00Z",
"updated_at": "2026-05-21T10:00:00Z"
},
"latest_checkin": {
"server_received_at": "2026-05-21T10:02:00Z",
"safe_device_state": {
"device_battery_percent": 82,
"device_network": "wifi"
}
},
"latest_shared_location": {
"latitude": -37.0,
"longitude": 145.0,
"accuracy_meters": 20,
"source": "checkin",
"server_received_at": "2026-05-21T10:02:00Z",
"freshness_status": "recent"
},
"warning": "If you are concerned about immediate safety, call emergency services now.",
"generated_at": "2026-05-21T10:02:12Z"
}latest_shared_location is omitted when no check-in contains both coordinates.
client_reported_at is reserved and omitted until clients submit a separate
client-reported check-in timestamp. freshness_status is based on the
server-received check-in timestamp and is either recent or stale for a
reported location. This is not a live-tracking contract, does not imply
emergency dispatch, notification delivery, provider delivery, or
map-provider backend integration. Future viewer-link notifications must follow
notification-boundary.md.
Invalid, expired, and revoked tokens return the same
404 incident_token_invalid response as the other public viewer routes. The
route is read-only and uses the public viewer data rate-limit class.
Downloads a completed stream bundle for the token's incident. The route is read-only and never accepts a client-provided file path. Invalid, expired, and revoked tokens return 404 incident_token_invalid. Missing or mismatched committed chunks fail closed with 409 stream_bundle_inconsistent before ZIP headers or body bytes are sent.
Open and failed streams are visible only as metadata in the current viewer summary. The current token-scoped viewer does not expose live chunk bytes or partial stream manifests. See live-partial-stream-access-boundary.md.
Downloads all completed streams for the token's incident as one encrypted evidence ZIP. Failed/open streams and legacy unstreamed chunks are omitted.
If any completed stream cannot be reconstructed or any committed chunk fails
byte-count or SHA-256 verification against metadata, the incident bundle request
fails with 409 incident_bundle_inconsistent rather than returning a partial
bundle. Invalid, expired, and revoked tokens still return 404 incident_token_invalid.