Sprout exposes a JSON API under /v1. User-authored semantic content must be
encrypted before it crosses the API boundary. The service authenticates the
caller from Authorization: Bearer <session>; creator/actor IDs are never
trusted from request bodies.
This document describes the implemented routes. It is not yet a generated
OpenAPI contract. The authoritative route registry remains
apps/server/src/routes/mod.rs, request/response types live in
crates/api-contract, and the browser client is in
frontend/sprout-web/src/api/client.ts.
Requirements: Docker with Compose v2. No local Rust, Node, PostgreSQL, jq, or
curl installation is needed.
From the repository root:
docker compose -f compose.validation.yml up \
--build \
--abort-on-container-exit \
--exit-code-from validationThat single command builds the API and the protocol-backed validation client, starts a disposable PostgreSQL 14 database, applies migrations, and runs a curl-based journey which:
- registers two independent users through the email API;
- decrypts development outbox tokens using the configured outbox key;
- encrypts project/topic/list/task documents with
sprout-crypto-protocol; - sends and retrieves ciphertext through the real HTTP API;
- decrypts the returned task only with the retained DEK and expected context;
- proves an unrelated user is denied, then accepts an invitation and reads the ciphertext as an authorized administrator;
- proves a wrong key cannot decrypt the ciphertext;
- races two authenticated task updates and requires exactly one
200and one409; - scans a PostgreSQL dump for the classified plaintext canary.
Expected final output:
HLT-12 encrypted API validation passed
T-LLR-12.1 authorization transition passed (...)
T-LLR-12.2 ciphertext round-trip, wrong-key denial, and plaintext scan passed
T-LLR-12.3 concurrent authenticated update returned exactly one commit
T-LLR-12.4 disposable Docker harness completed
Remove stopped containers and networks after validation:
docker compose -f compose.validation.yml down --volumes --remove-orphansThe PostgreSQL data directory is a tmpfs; validation data is not persisted.
The fixed keys in compose.validation.yml are public test fixtures and must
never be reused outside this disposable environment.
docker compose -f compose.validation.yml up --build -d postgres api
curl --fail --silent http://localhost:18080/health/readyThe API is then available at http://localhost:18080.
Unauthenticated example:
curl --silent --output /dev/null --write-out '%{http_code}\n' \
http://localhost:18080/v1/projectsExpected status: 401.
Run the complete encrypted curl pipeline at any time:
docker compose -f compose.validation.yml run --build --rm validationThe helper is deliberately a validation tool. It links the same
sprout-crypto-protocol crate used by the application; it does not implement a
second ad-hoc cipher format.
Encrypt stdin:
RESOURCE_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
KEY_ID="$(uuidgen | tr '[:upper:]' '[:lower:]')"
printf '{"name":"encrypted task"}' |
docker compose -f compose.validation.yml run --rm -T \
--entrypoint sprout-validation-crypto validation \
encrypt \
--resource-id "$RESOURCE_ID" \
--key-id "$KEY_ID" \
--context "manual/task/$RESOURCE_ID" \
> /tmp/sprout-encrypted.json
python3 -m json.tool /tmp/sprout-encrypted.jsonpayload is the object sent in API fields of type EncryptedPayloadDto.
dek_b64 stays client-side and must not be sent to the service.
Decrypt the saved payload:
docker compose -f compose.validation.yml run --rm -T \
--entrypoint sprout-validation-crypto validation \
decrypt \
--resource-id "$RESOURCE_ID" \
--context "manual/task/$RESOURCE_ID" \
< /tmp/sprout-encrypted.jsonChanging the resource ID, context, key, nonce, or ciphertext makes authentication fail.
Public account-ceremony routes:
POST /v1/auth/email/verification/startPOST /v1/auth/email/verification/finishPOST /v1/auth/email/recovery/startPOST /v1/auth/email/recovery/finishPOST /v1/auth/passkeys/register/startPOST /v1/auth/passkeys/register/finishPOST /v1/auth/passkeys/authenticate/startPOST /v1/auth/passkeys/authenticate/finish
All other /v1 routes require:
Authorization: Bearer v1.<identity-id>.<session-id>.<secret>The token is returned by a successful email or passkey ceremony. A client must treat it as a secret and keep it out of URLs and logs.
Identity and devices:
POST /v1/auth/email/verification/{start,finish}POST /v1/auth/email/recovery/{start,finish}POST /v1/auth/passkeys/register/{start,finish}POST /v1/auth/passkeys/authenticate/{start,finish}GET|POST /v1/devices/{device_id}/key-packagesDELETE /v1/devices/{device_id}/key-packages/{key_version}GET /v1/devices/{device_id}/key-transparency
Projects, invitations, keys, and recovery:
GET|POST /v1/projectsGET /v1/projects/{project_id}GET|POST /v1/projects/{project_id}/invitationsPOST /v1/projects/{project_id}/invitations/acceptPOST /v1/projects/{project_id}/participant-suggestionsGET /v1/projects/{project_id}/device-key-packagesPOST /v1/projects/{project_id}/recovery-requestsGET /v1/projects/{project_id}/recovery-requests/{request_id}POST /v1/projects/{project_id}/recovery-requests/{request_id}/approvalsPOST /v1/projects/{project_id}/recovery-requests/{request_id}/finalize
Resources and permissions:
POST /v1/projects/{project_id}/resourcesGET /v1/projects/{project_id}/resources/{resource_id}GET|POST /v1/projects/{project_id}/resources/{resource_id}/permissionsGET /v1/projects/{project_id}/resources/{resource_id}/permissions/{grant_id}/rotation-planDELETE /v1/projects/{project_id}/resources/{resource_id}/permissions/{grant_id}
Task domain:
GET|POST /v1/projects/{project_id}/topicsGET|PUT|DELETE /v1/projects/{project_id}/topics/{topic_id}POST /v1/projects/{project_id}/resources/{resource_id}/epochsGET /v1/projects/{project_id}/resource-key-envelopesGET /v1/projects/{project_id}/resources/{resource_id}/envelope-planPOST /v1/projects/{project_id}/member-resource-keysGET|POST /v1/projects/{project_id}/topics/{topic_id}/task-listsGET|PUT|DELETE /v1/projects/{project_id}/task-lists/{list_id}GET /v1/projects/{project_id}/task-lists/{list_id}/tasksGET|POST /v1/projects/{project_id}/topics/{topic_id}/info-documentsGET|POST /v1/projects/{project_id}/task-lists/{list_id}/info-documentsGET|PUT|DELETE /v1/projects/{project_id}/info-documents/{document_id}POST /v1/projects/{project_id}/tasksGET|PUT|DELETE /v1/projects/{project_id}/tasks/{task_id}POST /v1/projects/{project_id}/tasks/{task_id}/{complete,copy,move}GET|POST /v1/projects/{project_id}/tasks/{task_id}/assignmentsDELETE /v1/projects/{project_id}/tasks/{task_id}/assignments/{assignment_id}POST /v1/projects/{project_id}/tasks/{task_id}/complete-assignmentGET|POST /v1/projects/{project_id}/presetsGET|DELETE /v1/projects/{project_id}/presets/{preset_id}POST /v1/projects/{project_id}/presets/{preset_id}/versionsGET /v1/projects/{project_id}/presets/{preset_id}/versions/{version_id}POST /v1/projects/{project_id}/preset-assignmentsPOST /v1/projects/{project_id}/preset-assignments/{assignment_id}/materializePOST /v1/projects/{project_id}/recurrence-seriesGET /v1/projects/{project_id}/recurrence-series/{series_id}
Creating a topic, task list, or task also requires epoch and
envelopes in the request body. They register epoch one and signed hybrid
resource-key envelopes in the same database transaction as the resource.
Project roots are initialized immediately after project creation through the
/resources/{resource_id}/epochs route because the API allocates the root ID.
The envelope collection returns only active envelopes addressed to the
authenticated session's identity and device. The web client verifies the
sender package digest and both Ed25519/ML-DSA signatures before hybrid
unwrapping and local vault persistence.
After an invitation is accepted, a project manager can generate view grants and per-device envelopes from the People screen. Domain envelopes are committed with each hierarchical permission grant; the project-root key is shared only after those grants succeed. The disposable journey proves that the invited device independently unwraps its task key before decrypting the task payload. It then revokes the hierarchical grant, rotates all affected resources to epoch two, confirms that only remaining recipients receive new envelopes, and proves the revoked device's old key cannot decrypt the new payload.
The rotation plan is manager-only metadata. It returns the affected resource
IDs, active epoch commitments, and remaining recipient identities needed to
construct exact envelope coverage. Revocation then commits all next epochs and
the permission removal in one transaction. Existing ciphertext keeps its
original key_epoch; a later edit must encrypt under the active epoch and
submit that epoch with the update.
Info documents form an ordered, recursively nested document tree within a
topic or task-list resource. PostgreSQL stores only container/parent UUIDs,
versions, epochs, tombstones, and one opaque payload per document. Markdown,
URLs, filenames, MIME types, block order, and child labels stay inside the
client-encrypted payload. The payload is protected by the container resource
key and binds both the document UUID and container kind into canonical AAD.
Info is collaborative: every caller with full body visibility on the
associated topic or task list may read and edit its Info documents and files.
This does not grant generic write access to the associated resource;
container_only remains header-only and cannot access Info content.
The validation image exposes matching helpers:
sprout-validation-crypto device-create --device-id "$DEVICE_ID"
sprout-validation-crypto initial-epoch \
--project-id "$PROJECT_ID" \
--resource-id "$RESOURCE_ID" \
--recipient-identity-id "$IDENTITY_ID" \
--recipient-device-id "$DEVICE_ID" < epoch-input.json
sprout-validation-crypto unwrap-envelope < envelope-input.jsonPOST /v1/projects/{project_id}/recurrence-series/{series_id}/archive
Questionnaires:
GET|POST /v1/projects/{project_id}/questionnairesGET /v1/projects/{project_id}/questionnaires/{questionnaire_id}GET|POST /v1/projects/{project_id}/questionnaires/{questionnaire_id}/versionsGET|PUT /v1/projects/{project_id}/questionnaires/{questionnaire_id}/versions/{version_id}POST /v1/projects/{project_id}/questionnaires/{questionnaire_id}/versions/{version_id}/publishGET|PUT /v1/projects/{project_id}/tasks/{task_id}/questionnaire-submissionPOST /v1/projects/{project_id}/tasks/{task_id}/questionnaire-submission/submit
Files and attachments:
GET|POST /v1/projects/{project_id}/preset-versions/{version_id}/pretasks/{pretask_id}/attachmentsGET|POST /v1/projects/{project_id}/tasks/{task_id}/required-attachmentsGET|POST /v1/projects/{project_id}/tasks/{task_id}/completed-attachmentsPOST /v1/projects/{project_id}/info-documents/{document_id}/filesGET /v1/projects/{project_id}/files/{blob_id}GET|PUT /v1/projects/{project_id}/files/{blob_id}/content
Synchronization and retention:
POST /v1/sync/{push,pull}GET /v1/sync/wake(WebSocket upgrade)GET|PUT /v1/retention/preferencesGET /v1/retention/archivesGET /v1/retention/archives/{archive_id}/downloadPOST /v1/retention/archives/{archive_id}/receipt
Collection and document request schemas are defined in
crates/api-contract/src/lib.rs. The current project and invitation handlers
still contain local DTOs, so CI must not claim a frozen public contract until a
generated OpenAPI document and drift test are added.
200/201: successful read or mutation202: accepted account ceremony204: successful deletion/revocation400: malformed encrypted envelope or invalid command shape401: missing, invalid, expired, or revoked session403: authenticated actor lacks permission404: absent resource or intentionally non-disclosing authorization result409: stale version, idempotency collision, or invalid state transition413: request exceeds the configured body limit429: rate limit exceeded
The harness proves encrypted transport, authenticated multi-user authorization, wrong-key rejection, plaintext absence in PostgreSQL, and one real concurrent business mutation.
It currently transfers the validation DEK to the invited validator out of band.
It therefore does not close T-LLR-12.5 or HLT-06: a separate
three-client test must generate independent device packages, propagate
resource-key envelopes through the API, revoke a device, rotate the resource
epoch, and prove that the revoked device cannot decrypt the new revision.