Documents React Integration
This skill is intended for the external React application's agent to read from
its AuthCrunch library checkout. Keep implementation in the task's authorized
repository; reading this skill does not authorize edits to sibling checkouts.
For an app-local AGENTS.md, add a route pointing to this skill at the actual
library checkout location. Do not copy a machine-specific path into the app's
source or pretend the compiled app already ships in AuthCrunch.
Read the crypto/wire contract
and use the portable
crypto.mjs module.
Vendor a reviewed version into the frontend's source or bundle it via an explicit
build dependency. It is plain ESM using native Web Crypto with no third-party
runtime dependencies. It is not currently served by a production asset route.
Preserve the protocol and test against the
interoperability vector.
Use the shared Mermaid sequences and design rationale
to follow enrollment/unlock, encrypted upload/download, sharing, revocation and
reset. They distinguish local crypto and required React UX from implemented
server behavior, and explain retries, key context and account-switch protection.
Keep these sequences and the protocol aligned when changing a client workflow.
The OpenAPI source describes the API.
Its rendered /#tag/documents catalogue documents every operation's fields,
request/response messages, state transitions and retry behavior. Every operation
has its own method/resource path, expected response schema, parameter/body examples and native
Test Request console.
No plaintext-upload endpoint, username-directory endpoint or public share link
exists. The manager application and profile application are separate products.
Session and capability discovery
The host must explicitly enable APIConfig.DocumentsEnabled; the feature is
disabled by default and independent of Admin/Profile. A configured storage path
alone does not enable it.
Configure the exact portal origin/base path via the embedding app's trusted
bootstrap; API base is <basePath>/api/documents. Every operation has its own
method/resource path; use the complete
operation table.
GET reads; PUT registers keys and writes immutable manifests, chunks or grants;
DELETE removes documents or grants; POST allocates, commits and resets. Paths use
fetch, never get. For example, fetch a chunk with
GET /api/documents/file/{id}/chunk/{idx}/fetch and delete an owned file with
DELETE /api/documents/file/{id}/delete. Select from the fixed catalogue; never
accept a complete operation URL from untrusted file data. Validate and encode
individual path parameters. Use canonical decimal chunk indices without leading
zeros. List files with GET /api/documents/files and allocate uploads with
POST /api/documents/files/allocate. Own-key operations use /keys/fetch,
/keys/register and /keys/reset beneath the API base; recipient lookup remains
/key/{user_id}/lookup. The only query is file listing's optional after cursor.
Use relative same-origin fetch URLs and credentials: "same-origin". GET, DELETE
and commit have no body. Other writes require Content-Type: application/json
and only their documented direct fields. Do not send operation/payload
wrappers or repeat resource path parameters in a body. Every success is HTTP 200
with resource fields directly in the response. Validate
them against the selected method/path schema. There is no operation or result
wrapper. Mutations without resource data return {"success":true}; require the
literal boolean true. Failures have an HTTP error
status and {status,message}. See the linked protocol's operation table for exact
payloads. Bound response reads to 2 MiB; reject oversized or malformed JSON.
For example, capability discovery sends:
const response = await fetch(`${apiBase}/bootstrap`, {
method: "GET",
credentials: "same-origin",
signal,
});
// Read a bounded response, check HTTP status, then validate its direct fields.
Do not store JWTs in JavaScript
storage or introduce a token in URLs. The API needs both a valid portal cookie
and server-cached local browser login evidence. Do not use access-only JSON login
as a substitute. A document recovery key is an additional client-only secret,
independent of the portal password and MFA.
GET /bootstrap, verify protocol === "authcrunch-documents", obtain the
portal ID, exact local user ID and limits, then key.fetch. Distinguish its 404 (not enrolled)
from the bootstrap's 404 (feature unavailable). Handle 401 through a fresh portal
browser login. When the host already provides refresh/session coordination,
reuse it for expiry; do not invent a competing refresh loop or repeatedly refresh
revoked identity evidence. Follow the host's validated return-navigation contract.
403 is a permission/origin/realm failure; 503 is temporary storage unavailability.
Never persist decrypted documents or keys in localStorage, service-worker caches,
analytics, error monitoring payloads, logs, query strings or React debug tools.
Keep secrets out of React component state where a dedicated worker can own them.
On account/portal change, logout or lock, abort requests, clear decrypted state,
terminate the crypto worker, and revoke object URLs. Bootstrap user ID/portal
must match the unlocked vault before every operation; transformed display names
are not ownership identifiers.
Enrollment, unlock, and key loss
enroll(bootstrap) returns {registration,recoveryKey}. The recovery key is a
random 256-bit secret encoded as 43-character base64url. Require the user to save
it and confirm the backup before key.register with {key:registration} only. Show the
key locally; do not send it in telemetry or application API state. Explain that
anyone with this key and the encrypted vault can decrypt the user's files and
received shares. Login-password reset cannot recover document keys.
On later devices, fetch the registration and call
unlock(bootstrap, registration, recoveryKey). Retain the non-exportable private
CryptoKey in memory. Wrong keys and tampered vaults must stop before showing files.
Never derive a recovery key from the portal password or accept a weak human
passphrase in its place; the client has no passphrase KDF.
On lost recovery key, present a deliberate destructive-reset flow. Explain that
key.reset with {user_id:confirmedUserID,key_id:confirmedKeyID} removes all
owned documents, their shares, and received grants,
then allows fresh enrollment; it does not recover plaintext or affect other
owners' stored files. Require an explicit user confirmation in the UI before
sending that deletion. Capture the user ID and fingerprint from the registration
shown in the confirmation UI; do not replace them with a later bootstrap/key
value before sending. A cookie account switch or changed registration causes 409
and must invalidate the confirmation. Never automatically retarget or reconfirm
a reset after a conflict. This server check closes the gap between a UI check
and another tab switching cookies. A different key.register registration returns 409. If an enrollment
response is lost, key.fetch and reconcile the fingerprint and vault before retry.
Never auto-reset after an unlock or network error.
Upload workflow
- Read
File.size locally; call describeSize(size) and compare its ciphertext
size against the server limit. Send file.allocate with only {size,chunks}.
Keep the returned ID/owner/size/chunks immutable. Allocation is not idempotent;
after a lost response, inspect the owner's pending list. Concurrent tabs can
allocate indistinguishable descriptors: never guess ownership of an operation
from size/time or automatically delete a matching candidate. Present uncertain
pending entries for deliberate abandonment; fresh attempts use a fresh ID/key.
- Call
prepareUpload(bootstrap, descriptor, ownRegistration, {name:file.name,type:file.type,size:file.size}). It returns manifest,
encryptChunk(idx,bytes) and dispose().
- PUT
{manifest} to /file/{id}/manifest. Read each Blob slice of at most
1048576 plaintext bytes, encrypt once, then PUT
{ciphertext:encode(encryptedBytes)} to /file/{id}/chunk/{idx}/put. No FormData, plaintext metadata or data URLs go
to the API. Limit concurrent encryption/network work to bound memory.
- Retry uncertain writes using the exact buffered ciphertext. The module rejects
encrypting the same
idx twice. Do not mix chunks from different prepareUpload
calls. If exact upload state is lost on reload, delete/abandon that allocation
and allocate a fresh document/key. chunk.fetch does not support pending files.
- POST
/file/{id}/commit without a body after every chunk succeeds. Identical
commits/chunk and manifest writes are idempotent; conflicting ciphertext returns 409. Check the
descriptor after an uncertain commit. Finally dispose the upload key buffer.
Crypto calls snapshot input buffers and the context fields they use before their
first await. You may clear or reuse your own Uint8Array after starting a call;
the helper clears only its owned copies. This includes Node Buffers. Keep
concurrency bounded because each pending call owns a copy. This snapshot is not
cancellation: dispose prevents new chunk encryption but already started work may
finish. After logout or an account switch, discard late results and stop network
writes even if the cryptographic promise succeeds.
Cancel aborts requests and may DELETE /file/{id}/delete without a body for the pending owner file. Pending uploads have
an absolute TTL; activity does not extend it. Expired uploads return 404. Handle
507 as quota exhaustion, retaining the user's original local file. Empty files
still upload one 16-byte ciphertext/tag chunk. Files and metadata are immutable;
editing means allocating a new file/key. Revocation-sensitive new versions must
use a new key and only wrap it for current recipients.
List and download workflow
GET /files without a body or query for the first page. Follow next using
/files?after=<next>. Lists omit metadata/envelopes, so GET /file/{id}/fetch
with bounded concurrency for display.
After unlock, call unwrapKey(bootstrap, detail, ownRegistration, privateKey) and
decryptMetadata(...). Render filenames as text; never use decrypted metadata
as HTML or a navigation URL. This API supports flat documents, not server-side
folders or plaintext search; any name search happens locally after decryption.
GET every expected /file/{id}/chunk/{idx}/fetch without a body, decode its
ciphertext using the module's canonical base64url decoder, and call
decryptChunk(bootstrap, detail, documentKey, idx, ciphertext). Verify all tags,
indices, lengths and the metadata plaintext size before marking the download
complete. Fail closed on corruption/truncation; never offer partial data as a
successful file. A bounded Blob fallback can use URL.createObjectURL, an anchor
with a sanitized basename in download, and timely URL.revokeObjectURL after
the browser accepts the save. Use application/octet-stream for the download;
avoid inline execution or preview of uploaded HTML/SVG. For large files, use a
supported local streaming save flow with explicit partial-file cleanup on failure.
The client cannot guarantee secure erasure of strings or browser-managed memory.
Clear owned Uint8Arrays with fill(0), release CryptoKeys, and stop workers when
finished. Avoid buffering an entire large file solely to make an object URL.
Sharing
Use a JSON share code with exactly these string fields:
{"protocol":"authcrunch-documents","portal_id":"<bootstrap.portal_id>","user_id":"<bootstrap.user_id>","key_id":"<registration.key_id>"}
The portal ID and key fingerprint are canonical 43-character base64url values.
user_id is the exact canonical lowercase non-nil UUID (36 characters) from the
local user database. Never substitute email, username, a hash or an external
provider subject. Future federation-to-local mapping is not implemented here. Bound pasted
codes to 1 KiB and reject unknown fields, invalid identifiers and unsupported
protocols. This is public identity data, not a
secret link or a bearer grant. Display the portal and recipient fingerprint for
confirmation. Never include recovery/document keys or session credentials.
Compare the code's portal ID against the current bootstrap, then GET
/key/{user_id}/lookup and verify its user_id and full fingerprint match the code.
The public-key response has no portal ID field. Refuse changed keys and require an explicitly confirmed new
code. Owner unwraps the existing document key, calls
wrapKey(bootstrap, detail, recipientPublicRegistration, documentKey) and sends
PUT /file/{id}/share/{user_id}/put with only {envelope}. Only owners can share. Recipients
receive read access and use their own vault/key; no private key is exchanged.
GET /file/{id}/share/list returns recipient UUIDs only.
DELETE /file/{id}/share/{user_id}/delete without a body stops future downloads.
Clearly explain that recipients can keep or redistribute plaintext already read.
Product and integration limits
Present encryption as protection against passive traffic/content inspection,
with a server that stores ciphertext. Operation paths, resource IDs and list cursors are visible in URL logs.
Decrypted TLS messages also reveal sharing metadata and ciphertext sizes;
do not promise otherwise. Do not promise protection against modified
JavaScript, compromised browsers/devices, a malicious recipient, traffic analysis,
or file recovery without the key. The protocol is public; its
security relies on keys rather than obscurity. Recovery-key compromise exposes
historical documents; there is no forward-secrecy claim.
The Go repository currently embeds only the existing Profile SPA. This work adds
the Documents API and reusable crypto module, not an embedded Documents build or
new page route. App build packaging, hosting, route reservation, CSP and frontend
links must be integrated explicitly when the external React app is ready; do not
place a source tree into the compiled pkg/authn/ui/profile directory or run the
profile-only asset refresh script for Documents.
Acceptance tests
Use actual browser Web Crypto and real TLS portal login with two distinct users.
Assert actual network requests use each documented method and resource path,
with only the optional list cursor in queries. GET, DELETE and commit send no
body; other writes send direct JSON without repeated path parameters. Cover rejection of obsolete get paths, operation/payload wrappers and fields
that do not belong to the selected operation. Cover enrollment backup/unlock on another device, zero-byte and multichunk files,
owner and recipient decryption, outsider rejection, pagination, retry of identical
ciphertext, interrupted uploads, stale key fingerprints, reset, revocation, logout,
401 recovery and account switches. Check URLs and decoded TLS-visible request bodies for
plaintext filenames, file content, recovery keys and unwrapped keys. Tamper with
ciphertext, metadata, portal/owner/document IDs, sizes and chunk ordering and
require decryption failure. Verify safe filename rendering and completed download
contents. Start encryption/wrapping, immediately clear the input buffers, and
verify the original bytes still decrypt; mutate caller-owned context during a
pending operation and verify that it uses the call-entry snapshot. Run the
independent protocol vector along with the frontend tests.
Delay an upload while its login evidence is revoked, and delay a share while
the recipient resets and reenrolls. The first request must return 401 without
writing a chunk; the second must return 409 without creating a grant for the old
fingerprint. Retain the user's source file and require fresh login or explicitly
confirmed recipient identity before starting new work.
The backend's reference Chrome journey is
documents_browser_e2e.cjs.
It checks an HttpOnly cookie bootstrap using a session established by real HTML
login, then uses bearer transport for isolated multi-user crypto/API assertions.
The external app must additionally exercise its own login navigation, cookie-only
journey, account switching and session-expiry UI; the reference is not a React UI.