M3-05: File download with download tickets¶
Status: ready | Depends on: M3-04 | Milestone: M3
Goal¶
After this ticket the server hands out the bytes of a file, its thumbnails and its transcript only through itself and only after authorize(file.read) at fetch time (ADR 0048, R-FI-03). downloadFile and downloadThumbnail stream from the object store with the headers of 12 "9.3 Response" and support exactly one byte range (206). createFileTicket issues a short-lived HMAC ticket bound to file, variant, principal and session, and a media player that cannot send a DPoP proof fetches with that ticket in the query parameter ticket alone. Every fetch with a ticket checks signature, expiry, session and current rights again, and every failure answers the same 404 as an unknown id, so no ticket survives a revoked grant, a revoked session or its own expiry. A file that is still being checked answers 409 file_not_ready with Retry-After, a blocked or failed file 409 file_blocked, and a principal without read access always gets 404. HTML, SVG and every other type outside the inline list leave the server only as attachment with application/octet-stream, nosniff and the sandbox CSP, so an upload never runs as a page of the instance's origin (SEC-T03, part files). This is the read half of 12 "9. Download" and makes the M3 goal that files "reach people and agents only after a check" true for downloads.
Read first¶
docs/spec/12-files.md: "1. Goals and limits" (points 1 and 2), "2. States of a file" (definition of "servable" and the table), "3.7 Purpose of a file", "4.1 Port" (Get(key, range), no presigned GET), "4.4 Key schema" (objects are never overwritten), "6.2 File names", "7.1 Rule" (which stored images are inline), "7.3 Thumbnails", "8.2 Transcript", "9.1 Pass-through via the server", "9.2 File tickets", "9.3 Response", "13. Endpoints and actions", "15. Tests" (rows "clamd stopped", "HTML and SVG upload", "File name with U+202E ...", "Someone else's file ID and a non-existent ID", "Ticket: range download ..."), "16. Decisions of this chapter" (R-FI-03), "18. Configuration values of this chapter".docs/spec/05-actions.md: "1. Binding rules" rules 1 and 2; "2.2 Target of a resource" (read path perpurpose); "4.8 Files and search" (file.read: resourcefile, rightread, Auditno,rl.read);docs/spec/actions.yamlentryfile.read(M1-02).docs/spec/07-permissions.md: "4.1 Decisions and reasons" (no_grant,not_self,session_revoked,step_up_not_possible), "4.2 Flow" (order, audit of denials, deferred entries), "4.3 Visibility", "8.1 Delivery filter" (row "File fetch"), "8.2 Immediate effect on revocation", "11.2 Required tests" (AZ-P4).docs/spec/06-api.md: "4. Authorization in the API" point 3, "5. Errors" (file_not_ready,file_blocked,range_not_satisfiable,not_found), "7. Idempotency" (createFileTicketisread_only), "9. Rate limits", "10. Files" points 2 and 3, "15. Contract tests and gate" (G1 to G3, G7, G8).docs/spec/08-identity.md: "7.1 Overview", "7.3 DPoP" (why players need tickets).docs/spec/02-architecture.md: "4.3 Ports, events and sources per module" (filesportFiles: read, open, preview, file tickets, delete), "4.4 Allowed dependencies" (filesmay useidentityandchat), "4.5 Cross-cutting ports (internal/kernel)" (kernel/actor,kernel/authorize), "9.1 Chain in the api node", "9.3 Proof of the check", "14.2 Deadlines" (download underhttp.transfer_timeout).docs/spec/04-sync-protocol.md: "8.1 Basic rule", "8.5 Revocation of a session, device or principal", "13.1 Local cache" (client.cache_max_age).docs/spec/03-data-model.md"4.7 Files (files)":files(object_key,sha256,size_bytes,mime_type,original_name,uploaded_by,purpose,scope_channel_id,upload_state,scan_status,deleted_at, thetranscript_*columns),file_thumbnails,message_attachments(M3-07); R-DM-13, R-DM-28.docs/spec/19-security.md: "3. HTTP security headers" (SEC-HDR-1, SEC-HDR-2, SEC-HDR-4), "7. Upload hardening" (SEC-UP-3, SEC-UP-6, SEC-UP-8, SEC-UP-11), "9. Logs without secrets and without content" (SEC-LOG-3), "14. Tests" (SEC-T03, SEC-T10).docs/spec/17-observability.md: "4. Privacy", "10. Tests that hold these rules".docs/spec/01-product.md"3.4 Files and voice" (row "Files and images with preview, in and out").docs/api/openapi.yaml: operationsdownloadFile,downloadThumbnail,createFileTicket,getTranscript,getFile; schemasFile,Thumbnail,FileTicketCreate,FileTicket,Transcript,TranscriptStatus,TranscriptSource,Problem(x-problem-codes); parametersFileIdPath,VariantPath; responsesConflict,NotFound,RangeNotSatisfiable,Unauthorized,Forbidden; headerRetry-After,ETag; security schemesdpopAuth,bearerAuth,fileTicket.- ADRs 0048 (whole document, including "Confirmation"), 0010, 0013, 0015, 0017, 0020, 0033, 0047, 0049, 0053.
docs/DECISIONS.md: D-FI-03, D-FI-11, D-K-X6, D-L16, D-W-32, D-W-100; section "10. Rulings of the build milestones" (format of the rows you add).docs/security/threat-model.md: "5.8 Files, object storage, virus scanner", T-UP-01, T-AZ-02, T-AZ-03, T-LG-01.docs/RISKS.md: RK-43 (a long clamd outage keeps filespending).docs/operations/configuration.md:file.ticket_ttl,file.ticket_key_ref,client.cache_max_age,http.transfer_timeout,http.request_timeout,http.unavailable_retry_after,secrets.cache_ttl; section "5. Secrets" rowfile_ticket_key.docs/PLAN.md: "3. Way of working per ticket"; "9. M3 Platform services" (Goal, Scope, Out of scope, Exit criteria, Test strategy, Red team: "file tickets after revocation", Risks); rows M3-01, M3-03, M3-04, M3-06, M3-07, M3-08, M3-09, M3-10, M3-11 and M3-22.- Briefs this ticket builds on:
docs/briefs/M1-09-secret-provider.md(Provider, cache and rotation),M1-10-telemetry-foundation.md(span attribute allowlist),M1-17-api-server-stubs.md(enable list;createFileTicketis listed there for M3-05; route metadata),M1-18-http-chain.md(stages, headers, problem writer, proof of the check, "a file handler of M3 may override"Cache-Control),M1-19-rate-limits.md(anonymous requests use the client IP),M1-28-authz-core.md(descriptor, requirements, visibility, deferredfile.read),M1-29-authz-invariants.md(RevokeGrant),M1-33-identity-devices-dpop.md(identitytest),M1-34-identity-sessions.md(Authenticator step 6,Auth.RevokeSession), and the M3 briefsM3-01-object-storage-fs.md,M3-03-upload-sessions.md,M3-04-clamd-scan.md. - Dependencies implied through M3-04 and therefore not in the header: M3-03 (the
filestable, upload flow,getFileand with it thefile.readrequirement), M3-01 (ObjectStoreport and file system driver), M1-55 and through it M1-09, M1-10, M1-17, M1-18, M1-28, M1-29, M1-33, M1-34. - Code:
services/server/internal/files(M3-01, M3-03, M3-04),internal/identity(M1-33, M1-34),internal/authz(M1-28, M1-29),internal/kernel/authorize,internal/kernel/actor,internal/kernel/errs(CodeFileNotReady,CodeFileBlocked,CodeRangeNotSatisfiable,WithRetryAfter),internal/kernel/clock,internal/platform/httpserver,internal/platform/secrets,internal/platform/otel,internal/wire,api/routes,api/operations.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- Bytes of files, thumbnails and transcripts leave only through the server; there is no presigned GET and no other download path, and every fetch calls
authorize(principal, file.read, file)with the rights at fetch time (12 "9.1", ADR 0048 point 1, R-FI-03, 05 rule 1, 07 "8.1" row "File fetch", SEC-UP-11). - A file is deliverable exactly when
upload_state = 'complete',scan_status IN ('clean','skipped')anddeleted_at IS NULL.downloadFile,downloadThumbnailandgetTranscriptanswer409 file_not_readywithRetry-Afterwhile the upload or the check is pending and409 file_blockedforinfected,erroror a failed upload; an authenticated caller or a ticket without read access always gets404, while an anonymous request without a ticket gets401before the file is looked up (design note 2a; 12 "2", "9.3", 06 "10" point 2, SEC-UP-6). - Response headers of 12 "9.3" and SEC-HDR-4:
Content-Typeis the detected type only for the inline list, otherwiseapplication/octet-stream;Content-Disposition: inlineonly for the inline list (storedimage/jpeg,image/png,image/gif, thumbnails,audio/mp4,audio/mpeg,video/mp4),attachmentfor everything else (HTML, SVG, PDF and the rest, SEC-UP-3), alwaysfilename="<ASCII fallback>"beforefilename*=UTF-8''<percent-encoded>;X-Content-Type-Options: nosniff;Content-Security-Policy: default-src 'none'; sandbox;Cross-Origin-Resource-Policy: same-origin;Cache-Controlprivate, max-age=<client.cache_max_age>, immutablewith normal authentication andprivate, no-storewith a ticket;ETag: "<sha256 hex>"andRepr-Digest: sha-256=:<base64>:of the delivered variant;Accept-Ranges: bytes. - Exactly one byte range gives
206; several ranges are ignored (200with the whole variant); a range outside the variant gives416 range_not_satisfiable(12 "9.3", 06 "5", ADR 0048 point 1). createFileTicket(file.read,rl.read,x-idempotency-exempt: read_only) answers201withFileTicket {url, expires_at}. The ticket isbase64url(payload || HMAC-SHA-256(key, payload)); the payload holdsfile_id,variant,principal_id,auth_session_idand the expiry (file.ticket_ttl); the key comes from the secret provider throughfile.ticket_key_ref, and two keys are valid at the same time so a key change causes no outage (12 "9.2" points 1 and 2, ADR 0048 point 2).- Every fetch with a ticket checks signature, expiry, that the session is not revoked, and
authorizewith current rights; every failure gives the same404as an unknown id (12 "9.2" point 3, ADR 0048 point 3). The ticket is accepted only in the query parameterticket(security schemefileTicket) and only bydownloadFileanddownloadThumbnail(openapifileTicket); responses with a ticket carryCache-Control: private, no-store(ADR 0048 point 4). - The ticket is a bearer secret: it never appears in the server's logs, span attributes, problem bodies or audit rows (12 "9.2" point 4, SEC-LOG-3, 17 "4", SEC-T10).
- A foreign file id and an unknown file id give identical
404answers (B7, 05 rule 2, R-AZ-01, 12 "15", AZ-P4). - The read path follows
files.purpose(05 "2.2", 12 "3.7"): an attachment before it is attached is read only by its uploader with read access onscope_channel_id;avatarby every active authenticated principal;brandingpublicly;gdpr_exportnever throughfile.read. - Downloads run under
http.transfer_timeout, nothttp.request_timeout(02 "14.2", ADR 0048 "Consequences").
Scope¶
All server paths under services/server/ unless noted.
Create:
internal/files/domain/ticket.go: the ticket codec, pure and clock-injected:EncodeTicket(key []byte, t Ticket) string,DecodeTicket(keys [][]byte, s string, now time.Time) (Ticket, error)withTicket{FileID, PrincipalID, SessionID ids.ID; Variant Variant; ExpiresAt time.Time}(layout in Design note 5).internal/files/domain/byterange.go:ParseRange(header, ifRange, etag string, size int64) RangeResult(outcomesFull,Partial{First, Last},Unsatisfiable; rules in Design note 9).internal/files/domain/delivery.go:Variant(original,small,medium,large,poster),Readiness(uploadState, scanStatus string) Readiness(Deliverable,NotReady,Blocked),Presentation(mimeType string, variant Variant) (contentType string, inline bool),ContentDisposition(name string, inline bool) string,ThumbnailName(originalName string, v Variant, mimeType string) string.internal/files/app/download.go(use caseOpen),internal/files/app/tickets.go(use caseIssueTicket, ticket verification, key ring),internal/files/app/transcript.go(use caseTranscript).internal/files/adapters/httpapi/download.go: the strict-server methodsDownloadFile,DownloadThumbnail,CreateFileTicket,GetTranscriptand the streaming response objects (Design note 12).internal/files/adapters/postgres/queries/download.sql:GetFileForDelivery(one row offilesby id with the columns of Design note 2) andGetThumbnail(file_thumbnailsby(file_id, variant)).- Test helpers in
internal/files/adapters/filestest(extend the package if M3-03 or M3-04 created it): seed a file through the real upload flow in a chosen state, seed a thumbnail object and row, set a transcript. Test code only; never wired into the server. - Only if the base branch has no migration for
file_thumbnails(03 "4.7"; M3-06 is not in this ticket's dependency closure):migrations/<next free number>_files_thumbnails.sqlcreatingfile_thumbnailsexactly as 03 describes (columns,CHECK, PK(file_id, variant), FK withON DELETE CASCADE, uniqueobject_key), thenmake migrations-lock. If M3-06 already merged it, use it and create nothing.
Change:
internal/filesport (root package): add to theFilesport of M3-03Open(ctx context.Context, a actor.Actor, req OpenRequest) (Delivery, error),IssueTicket(ctx context.Context, a actor.Actor, fileID ids.ID, v Variant) (IssuedTicket, error)andTranscript(ctx context.Context, a actor.Actor, fileID ids.ID) (TranscriptView, error);OpenRequest{FileID ids.ID; Variant Variant; Ticket string; Range, IfRange string};Deliverycarries status (200or206), total size, served range, content type, disposition, ETag, Repr-Digest, whether a ticket authenticated it, and the body reader;IssuedTicket{URL string; ExpiresAt time.Time}. Doc comment on each. M3-22 builds the push previewimage_urlonIssueTicket.internal/identityportAuth(M1-34): addSessionActor(ctx context.Context, userID, sessionID ids.ID) (actor.Actor, error)with implementation and tests (Design note 6).internal/kernel/authorizeandinternal/authz: the descriptor factIssueTicket bool(file.readthroughcreateFileTicket) and the session rule of Design note 7; complete thefile.readrequirement and thefilevisibility per purpose where M3-03 left a gap (Design note 7).internal/platform/secrets/stack(M1-09): the catalogue entryfile_ticket_key(generated, length as indocs/operations/configuration.md"5. Secrets", serviceserver) only if it is missing.internal/wire: build the ticket key source from the M1-09 secret provider andfile.ticket_key_ref; pass the filesConfig(file.ticket_ttl,file.not_ready_retry_after,client.cache_max_age) and the identityAuthport tofiles; wire the four handlers.- Configuration schema (M1-07):
file.ticket_ttl(duration, greater than zero),file.ticket_key_ref(secret reference) andclient.cache_max_age(duration) if absent; the new keyfile.not_ready_retry_after(duration, greater than zero; default the same value ashttp.unavailable_retry_after). Start check: roleapiandallfail to start when the ticket key is missing or shorter than the length the secrets catalogue indocs/operations/configuration.mdsection "5. Secrets" names forfile_ticket_key. - Route metadata generator of M1-17 (
api/routes) andStageRouteof M1-18 (internal/platform/httpserver): a flag onroutes.Operationfor operations whose2xxresponse has aformat: binarybody, andStageRouteapplieshttp.transfer_timeout(throughhttp.ResponseController) to them; regenerate,make codegen-check. api/operations.yaml: movecreateFileTicket,downloadFile,downloadThumbnailandgetTranscriptfromlatertoenabled; rerun the route generator.docs/api/openapi.yaml: Design note 13.docs/spec/12-files.md: "9.2" (tickets need a session; current and previous key; every failure of the ticket path including a non-allow decision gives404), "9.3" (Retry-Afterkey, range rules, missing variant, thumbnail ETag, thumbnail file name), "18" (rowfile.not_ready_retry_after);docs/spec/07-permissions.md"4.1" rowstep_up_not_possible(alsocreateFileTicketwithout a session). Thenmake config-reference.docs/security/threat-model.md"5.8": one STRIDE rowI | Ticket URL reaches third parties (Open with, log of a player) | short validity file.ticket_ttl, binding to principal and session, file.read on every fetch, ticket never in logs | T-AZ-03, T-LG-01.test/canary(M1-69),test/leak(M1-70),tools/contractand the G8 validator (M1-71); see Design note 14.- READMEs of
files,identity,authz,internal/platform/httpserverandapi;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M3-05-<n>.
This ticket creates no Makefile target, no event, no job and no metric.
Out of scope¶
- Upload sessions, parts and completion,
getFile,getUpload,abortUploadand thefiles,file_upload_sessions,file_upload_partsmigrations (M3-03); thefile.readrequirement itself is M3-03's becausegetFileneeds it (this ticket completes it only where Design note 7 finds a gap). - Scan pass, states and
files.scan(M3-04); the order in which metadata removal and deliverability happen (M3-04, M3-06; see the report item in Design note 16). - Metadata removal, thumbnail generation and writes to
file_thumbnails(M3-06). - Attaching files to messages,
message_attachmentsand the read path "channel of the message" for attached files (M3-07); until then every attachment is unattached and only its uploader reads it. deleteFile, cleanup jobs and object purge (M3-08).requestTranscription,submitTranscriptand voice messages (M3-09); this ticket only readstranscript_*.- Avatar and logo uploads and
getBrandingLogo(M3-10); channel export (M3-11); push preview and itsimage_url(M3-22). - The
playbackvariant and transcoding (V3; 12 "8.3"; PLAN M3 "Out of scope"). - Players and caches in the apps (M4, M5); console cookie sessions (M6-01);
downloadGdprExport(M7). - A reverse proxy that serves the bytes (ADR 0048, rejected option kept as later optimisation).
- The canary, leak and contract test frameworks themselves (M1-69, M1-70, M1-71); this ticket only extends them.
- Changes to the core contract.
Boundaries¶
- A new migration (only the conditional
file_thumbnailsmigration of Scope) needs its line inmigrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M3-05-file-download; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(files): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -dort.TempDir(), removed at the end; the file system driver of the object store runs only in such a directory. - Never read or print secrets. Ticket keys are generated per test run into a temporary secrets directory; tests never print a ticket, a key or a ticket URL (assert on them, log only their length). No network calls except package downloads for pinned tools and Go modules.
- No real third-party service in tests: the object store is the file system driver of M3-01; scan results come from a fake
Scanner(or the pinned clamd container of M3-04, only if you need it); no external media player. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 or M2 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for the merged code of M3-01, M3-03 and M3-04, whose briefs were written in parallel with this one: follow their names; if a column of 03 that this ticket reads is missing from the merged
filestable, stop withVerdict: BLOCKED <column>.
Design notes¶
-
Layers. The adapter
internal/files/adapters/httpapiparses path, query and the headersRangeandIf-Range, calls the port and writes headers and body; it holds no rule. Ticket verification, the actor of a ticket,authorize, readiness, variant lookup and the range decision run ininternal/files/app; the pure rules of Scope "Create" live ininternal/files/domain.appreaches the object store only through theObjectStoreport of M3-01 and identity only through its port (02 "4.4"). Downloads, ticket issue and transcript reads write nothing: no unit of work, no lock, no event, no job. -
Open(downloadFile with variantoriginal, downloadThumbnail with its variant), in this order: a. Who asks. If the chain gave an authenticated actor, use it and ignore anyticketparameter (Ruling for docs/DECISIONS.md: theAuthorizationheader wins; such a response gets the authenticatedCache-Control). If the actor is anonymous andticketis not empty, verify the ticket (note 5): bad encoding, bad MAC under every key of the ring,now >= ExpiresAt,FileIDdifferent from the path orVariantdifferent from the route (originalforcontent) gives404 not_found. Thenidentity.Auth.SessionActor(ticket.PrincipalID, ticket.SessionID)(note 6); an error gives404. If the actor is anonymous and there is no ticket, callauthorizewith the anonymous actor and a descriptor that carries only kindfileand the id: step 1 of 07 "4.2" answersunauthenticatedbefore any resolution, and the chain renders401 unauthenticated(the operation is not public, so an anonymous caller learns nothing about the id). b. Load the row withGetFileForDelivery(id,object_key,sha256,size_bytes,mime_type,original_name,uploaded_by,purpose,scope_channel_id,upload_state,scan_status,deleted_at). No row, ordeleted_atset, gives404 not_found(Ruling for docs/DECISIONS.md: a deleted file answers like an unknown id; 12 "2" ends the state "deleted" without delivery). c.authorize(actor, file.read, descriptor)with the descriptor of note 7. On the normal path a non-allow decision becomesDecision.Err()(404fornot_found,401,403as M1-28 maps them). On the ticket path every non-allow decision, includingunauthenticatedwithsession_revoked,principal_inactiveordevice_inactive, becomes the same404 not_found(12 "9.2" point 3). Denials are audited byauthzas for every request (07 "4.2"); do not use filter mode. d. Readiness (domain.Readiness):upload_state = 'failed'orscan_status IN ('infected','error')gives409 file_blocked; any other state that is not deliverable gives409 file_not_readywithRetry-Afterfromfile.not_ready_retry_afterin whole seconds (errs.New(errs.CodeFileNotReady, ...).WithRetryAfter(...)). Readiness comes afterauthorize, so a principal without read access never learns the state (12 "9.3": "Without read permission always404"). e. Variant.original: the row offiles. A thumbnail:GetThumbnail(file_id, variant); no row gives404 not_found(Ruling for docs/DECISIONS.md: clients request only the variants thatFile.thumbnailslists, andfile.updatedannounces new ones; a missing variant of a readable file reveals nothing). f. Range (note 9) againstsize_bytesof the variant;Unsatisfiablegives416 range_not_satisfiablewithContent-Range: bytes */<size>. g. Stream withObjectStore.Get(key, range); headers of note 8 before the first body byte. A missing object for a deliverable file is a server fault:500 internal_error, the file id in the error log, never in the body (Ruling for docs/DECISIONS.md). An error after the first byte aborts the connection (http.ErrAbortHandler); the client sees a short body, never a wrong one. Unknown ids end in step b without callingauthorize, foreign ids in step c; both produce the identical problem404(status, body after removingcorrelation_id, headers), as M1-62 does for unknown threads. -
IssueTicket(createFileTicket), in this order: the chain authenticated the actor (aticketquery parameter is never read here: an anonymous request gets401); load as 2b;authorize(actor, file.read, descriptor with IssueTicket = true)with the session rule of note 7 (403 forbidden,reasonstep_up_not_possible, for a principal without a session; Ruling for docs/DECISIONS.md, because 12 "9.2" binds every ticket toauth_session_idand a principal with an API token sends its token with every request and needs no ticket); readiness as 2d (Ruling for docs/DECISIONS.md: a ticket is never issued for a file that cannot be delivered); variant as 2e forsmall,medium,large,poster. Then mint with the current key:ExpiresAt= now plusfile.ticket_ttl, truncated to whole seconds;urlis the relative reference/api/v1/files/{file_id}/content?ticket=<t>fororiginaland/api/v1/files/{file_id}/thumbnails/{variant}?ticket=<t>otherwise (as theFileTicketexample); answer201withFileTicket. NoIdempotency-Key(read_only); a repeated call mints a new ticket. -
Transcript(getTranscript): normal authentication only (no ticket, openapi security of the operation); load as 2b;authorizeas 2c; readiness as 2d; answer200withTranscript {file_id, status}fromtranscript_status, plustext,languageandsourceonly whenstatus = done(no JSONnull, ADR 0053). Any readable deliverable file answers, also one withstatus = none(Ruling for docs/DECISIONS.md). JSON responses keep the API headers of M1-18 (Cache-Control: no-store); there is no range on this operation (ADR 0048 point 1 names bytes withRangefor content and thumbnails). -
Ticket format and keys (12 "9.2", ADR 0048 point 2; Ruling for docs/DECISIONS.md, layout recorded in the files README):
- Payload, fixed layout: a format version byte, the file id as raw UUID bytes, a variant code byte (
original,small,medium,large,posterin this order), the principal id and the auth session id as raw UUID bytes, and the expiry as unsigned big-endian Unix seconds (uint64). Ticket = base64url without padding (RFC 4648 section 5) of payload followed by the full HMAC-SHA-256 tag over the payload. Any other length, alphabet, version or variant code is invalid. Compare tags withhmac.Equal. - Key ring: a narrow port in
internal/files/app(TicketKeySource, returning the current key value and its version), implemented ininternal/wirewith the M1-09secrets.Providerandfile.ticket_key_ref.appkeeps the key it saw before the last version change as the previous key and verifies against current and previous; it mints with current. A key change therefore takes effect per process aftersecrets.cache_ttl, and tickets minted before keep working until they expire; a ticket minted by a process that already switched may be refused by a process that has not, for at mostsecrets.cache_ttl, and the player asks for a new ticket. Write this rotation procedure into the files README. - The
file_ticket_keysecret is generated bybothy secrets initfrom the M1-09 catalogue (docs/operations/configuration.md"5. Secrets"); check that the catalogue entry exists in code and add it if missing.
- Payload, fixed layout: a format version byte, the file id as raw UUID bytes, a variant code byte (
-
Actor of a ticket.
identity.Auth.SessionActor(userID, sessionID)builds theactor.Actorwith the same single lookup as Authenticator step 6 of M1-34 (session joined with its device and the device's activedpopkey): kinduser,ID=userID,SessionID,DeviceIDfrom the session row,DeviceState,ClientComponent,AuthMethod;SessionActivetrue only if the row exists,revoked_atis null,expires_atis in the future anduser_idequalsuserID;DeviceKeyActivetrue only if the device's activedpopkey has thejktof the session's refresh family (the binding a DPoP request proves withcnf.jkt); no step-up time. It never rejects on these flags:authorize(check_active) does, so the denial is audited with its reason (07 "4.2"). An unknown session gives an actor withSessionActivefalse. Session state is read per call, so a revoked session takes effect on the next fetch in every process (04 "8.5"). -
file.readinauthz(05 "2.2", rules 1 and 2; 07 "4.2", "4.3"). M3-03 replaced the deferred entryfile.readof M1-28 when it enabledgetFile; check the merged requirement and visibility against this table and add what is missing with a test per row (list each addition in the report). Iffile.readis still deferred, replace the entry here and remove it from the golden list of deferred actions.purposeVisible to (otherwise not_found)Requirement after visibility attachment, not attachedthe uploader ( OwnerID=uploaded_by) with effectivereadon the path ofscope_channel_id; DM channels follow M1-28 (participants only, owners only as participants)Level(read)attachment, attachedpath of the message's thread (M3-07 adds this row) Level(read)avatarevery active authenticated principal Authbrandingeverybody; anonymous callers reach it only through getBrandingLogo(M3-10), becausedownloadFileis not a public operationPublicgdpr_exportnobody through file.read(onlygdpr.export.download, M7)none A non-uploader with read access on the channel gets
not_found, neverforbidden(05 rule 2). The session rule for tickets is part of thefile.readentry:IssueTickettrue and the principal has no session (SessionIDzero: agents, apps, API tokens) givesforbiddenwith reasonstep_up_not_possible, whose 07 "4.1" meaning is "token without a session"; evaluate it after visibility, like step 8 of 07 "4.2", so an invisible file still gives404. Rights logic stays inauthz(05 rule 1). REST decisions resolve the file's path per request from the row loaded in 2b; nothing caches a file-to-channel mapping. -
Headers of
200and206(12 "9.3", SEC-HDR-1, SEC-HDR-2, SEC-HDR-4; Ruling for docs/DECISIONS.md where marked):Content-Typeand disposition fromdomain.Presentation: originals withmime_typein {image/jpeg,image/png,image/gif,audio/mp4,audio/mpeg,video/mp4} keep their type and areinline; thumbnails keep theirfile_thumbnails.mime_typeand areinline; every other type (includingtext/html,image/svg+xml,application/pdf,image/webpstored unchanged,image/heic) isapplication/octet-streamandattachment.Content-Disposition: <inline|attachment>; filename="<fallback>"; filename*=UTF-8''<encoded>(RFC 6266 with RFC 8187). Fallback (ruling): every character outside printable ASCII and every",\,%and;becomes_. Encoding: UTF-8 bytes, every byte outside the RFC 8187attr-charset as%XX. The name isoriginal_name, already cleaned by 12 "6.2" (M3-03); for a thumbnail it isdomain.ThumbnailName: the original name without its last extension, then-<variant>, then.jpgor.pngfrom the thumbnail's type (ruling).X-Content-Type-Options: nosniffandReferrer-Policy: no-referrer(already set by M1-18),Content-Security-Policy: default-src 'none'; sandbox,Cross-Origin-Resource-Policy: same-origin.Cache-Control: with normal authenticationprivate, max-age=<client.cache_max_age in whole seconds>, immutable; with a ticketprivate, no-store. This overrides theno-storethat M1-18 sets before the handler; problem responses keep the API headers.ETag: "<lowercase hex of sha256>"andRepr-Digest: sha-256=:<standard base64 of sha256>:of the whole variant:files.sha256for the original.file_thumbnailshas no checksum column (03), so for a thumbnail compute both by reading the object once in full throughObjectStore.Getbefore serving it with a second, rangedGet(ruling: constant memory, thumbnails are small; see spec gaps).Accept-Ranges: bytes;Content-Lengthof the served bytes;206addsContent-Range: bytes <first>-<last>/<size>.- Make sure no chain stage derives an
ETagfrom a JSONversionfor these routes (M1-17ETagStatuses); the file handler owns it.
-
Range rules (
domain.ParseRange, RFC 9110 section 14; Ruling for docs/DECISIONS.md where 12 is silent): only the unitbytes. One range in the formsfirst-last,first-or-suffixgivesPartial(lastcapped atsize - 1, a suffix longer than the variant means the whole variant); a set with more than one range givesFull(12 "9.3"); a syntactically invalid value is ignored and givesFull; a single range whose first byte is at or aftersize, or a zero-length suffix, givesUnsatisfiable.If-Rangeis honoured only with a strong ETag equal to the current one; any other value (also an HTTP date, the server sends noLast-Modified) givesFull.If-None-Matchis not evaluated and there is no304, because the contract has none and content never changes once deliverable (12 "4.4"). -
Retry-After value. 12 "9.3" demands
Retry-Afterbut names no key. Addfile.not_ready_retry_afterto 12 "18" (default: the same value ashttp.unavailable_retry_after; meaning "Retry-Afterof the409 file_not_readyresponses (in whole seconds)"), to the schema and throughmake config-referenceto the reference (Ruling for docs/DECISIONS.md). -
Rate limits and audit of tickets. A ticket request is anonymous in the chain, so M1-19 takes the
rl.readtoken from the bucket of the client IP before the handler runs; an invalid ticket is a404from the handler, not a failed credential, so it does not count againstrl.public_authand is not audited, because no principal exists yet (Ruling for docs/DECISIONS.md; the MAC is not guessable). A valid ticket whoseauthorizefails is audited byauthzwith its reason. -
Strict server and streaming. Return response objects that implement the generated
...ResponseObjectinterfaces ofdownloadFileanddownloadThumbnailand write headers and body themselves (Visit...Response(w)), so the body streams from the object store without buffering; the M1-18 response writer streams non-JSON bodies. Route metadata marks both operations as binary responses, andStageRouteapplieshttp.transfer_timeoutto them (02 "14.2"; Ruling for docs/DECISIONS.md).StageValidatemust accept a request that carries only theticketquery parameter on these two operations; add a test if the validator checks security requirements. -
docs/api/openapi.yaml(spec first, rule 10):downloadFileanddownloadThumbnail: header parametersRangeandIf-Range(type: string, no pattern, semantics in the description, so invalid values stay valid input that the server ignores);200and a new206with the media types of note 8 (downloadFile:application/octet-stream,image/jpeg,image/png,image/gif,audio/mp4,audio/mpeg,video/mp4;downloadThumbnail:image/jpeg,image/png), each with the headers of the existing200plusContent-Disposition,Content-Security-Policy,Cross-Origin-Resource-Policy,Cache-Control,ETag(described as the SHA-256 of the variant, not a version),Repr-Digest,Accept-Ranges, andContent-Rangeon206; add the headerContent-Rangeto the response componentRangeNotSatisfiable(only file downloads use it) and reference that component fromdownloadThumbnailtoo; descriptions name ADR 0048 and 12 "9.3".409ofdownloadFile,downloadThumbnail,createFileTicketandgetTranscript: a response componentFileConflict(x-status: 409, "Problem codes:file_not_ready,file_blocked.") with headersCorrelation-IdandRetry-After(sent withfile_not_ready).createFileTicketdescription: session requirement (403 forbiddenwithreasonstep_up_not_possible), readiness and missing variant;getTranscriptdescription: no ticket.- Run
make api-lint,make api-checks,make api-tables,make codegen-check; regenerate client packages if one has agenerate-checktarget (make api-clients).
-
Suites that iterate over every enabled operation (M1-69, M1-70 and M1-71 are in this ticket's dependency closure; extend each):
- Canary (
test/canary): call the four operations inscenario.goand register every issued ticket as a canary of classsecret(allowed only in thecreateFileTicketresponse and the request that carries it), soTestSEC_T10_ScenarioCoversEveryEnabledOperationandTestSEC_T10_CanaryAcrossAllOutputspass (ADR 0048 "Confirmation"). - Leak (
test/leak):TestAZ_P4_RouteCoveragecoversdownloadFile,downloadThumbnailandgetTranscript: afile_idfixture (a file of Alex for a requester who is not its uploader; add it if M3-03 did not) and a valid value for the non-UUID parametervariant. - Contract (
tools/contract, G8 validator): the four operations pass G7 in both passes, and every integration response of this ticket passes G8.
- Canary (
-
Rulings for docs/DECISIONS.md (one row each,
D-M3-05-<n>, with reason): ticket layout and key ring (note 5); session rule and its reason code (notes 3, 7);Authorizationwins overticket(note 2a); every ticket-path failure is the unknown-id404(note 2c); deleted file and missing variant404(notes 2b, 2e); missing object500(note 2g); readiness check on ticket issue (note 3); transcript answer (note 4); headers, fallback file name and thumbnail name (note 8); thumbnail digest by an extra read (note 8); range,If-Range, no304(note 9);file.not_ready_retry_after(note 10); tickets and rate limits or audit (note 11); transfer deadline for binary responses (note 12); the start check on the key length (Scope); the conditionalfile_thumbnailsmigration if you created it. Already fixed by the spec and not to be repeated: no presigned GET, ticket contents,404for every ticket failure,Cache-Controlper path, the inline list. -
Report item, not a change here. 12 "3.4" and "3.5" make a file deliverable before the job that removes image metadata, while 12 "7.1" removes metadata "when the upload completes" and fails the upload when the filter fails. This ticket serves
files.object_keyas stored; name in the report whether, on the merged base, a JPEG with location data can be downloaded betweencleanand the end of the image job (M3-04, M3-06 own the order).
Tests (write them first)¶
Unit:
internal/files/domain:TestTicket_RoundTrip(encode then decode gives the same fields; URL-safe alphabet, no padding);TestTicket_TamperedByteRejected(flipping any single byte of payload or tag fails);TestTicket_WrongLengthAlphabetVersionVariantRejected;TestTicket_ExpiryBoundary(valid one second beforeExpiresAt, invalid at and after it, fake clock);TestTicket_CurrentAndPreviousKey(minted with the previous key verifies while it is in the ring, with an older key fails, minting uses current);FuzzDecodeTicket(never panics, random input never verifies).internal/files/domain:TestParseRange_Table(each form of note 9, caps, suffix longer than size, two ranges, invalid syntax, start at size, zero suffix,If-Rangeequal, different, date);FuzzParseRange(never panics,Partialalways inside the variant).internal/files/domain:TestPresentation_InlineList(table over the inline types,text/html,image/svg+xml,application/pdf,image/webp,image/heic, thumbnails);TestContentDisposition_TableandFuzzContentDisposition(non-ASCII such as "Résumé for Sam.pdf", quotes, backslash, percent, semicolon; output has no CR, LF or unescaped quote,filenamebeforefilename*);TestThumbnailName;TestReadiness_Table(every combination ofupload_stateandscan_status).internal/authz:TestFileReadRequirement(one row per line of the note 7 table: uploader with read getsallow; Sam with read on the channel getsnot_found; Robin without read getsnot_found; an owner who is not the uploader getsnot_found; avatar for any authenticated principal;gdpr_exportnot_foundfor its uploader);TestFileReadIssueTicketNeedsSession(API token principal:forbiddenstep_up_not_possible; invisible file with API token:not_found; session principal:allow).internal/identity:TestSessionActor_ActiveRevokedExpiredForeignUnknown(flags as in note 6; device and family key binding; no step-up time).api/routes:TestOperations_BinaryResponseFlag(set fordownloadFileanddownloadThumbnail, not forgetTranscript);internal/platform/httpserver:TestStageRoute_BinaryResponseUsesTransferTimeout.
Integration (PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain, people, devices and sessions from the identity test helpers, the file system object store in t.TempDir(), a fake Scanner whose answer each test sets, services/server/test/harness (M1-71); the world: space Support with channel Allgemein, Alex with write and upload, Sam with read, Robin without a grant; Alex uploads every file through the real upload flow of M3-03):
TestDownload_InlineImageHeadersAndBody: a PNG answers200withContent-Type: image/png,inline, both file name parameters,nosniff, the sandbox CSP,Cross-Origin-Resource-Policy: same-origin,Cache-Control: private, max-age=<client.cache_max_age seconds>, immutable(computed from the configuration, never a literal),ETagandRepr-Digestequal to the SHA-256 of the bytes,Accept-Ranges: bytes,Content-Length; the body equals the upload.TestSECT03_HTMLAndSVGAsAttachment(SEC-T03 part files; 12 "15" row "HTML and SVG upload"): an HTML file with a script and an SVG with a script answerContent-Type: application/octet-stream,Content-Disposition: attachment,X-Content-Type-Options: nosniffandContent-Security-Policy: default-src 'none'; sandbox; same for a PDF.TestSECT03_FileResponseHeaders(SEC-T03): for each kind (200authenticated,200with ticket,206,416,409,404) the header set equals the target list of SEC-HDR-1, SEC-HDR-2 and SEC-HDR-4; noServerversion.TestDownload_ContentDispositionSanitizedName(12 "15" row "File name with U+202E,../, control characters"): names with U+202E,../and control characters give the cleaned name in both parameters, with correct fallback and encoding.TestDownload_Range(12 "9.3"):bytes=0-99,bytes=100-andbytes=-10give206withContent-Range,Content-Lengthand the exact slice; two ranges give200with the whole file; a range starting at the size gives416 range_not_satisfiablewithContent-Range: bytes */<size>; an invalid value gives200;If-Rangewith the ETag gives206, with another value200.TestDownload_ForeignAndUnknownIDSame404(12 "15" row "Someone else's file ID and a non-existent ID"; AZ-P4 status, body and header part): Sam (reads the channel, not the uploader) and Robin (no read) request Alex's file and a random UUIDv7 ondownloadFile,downloadThumbnail,getTranscriptandcreateFileTicket: identical status, headers and body after removingCorrelation-Id,Date,correlation_idand the id ininstance.TestDownload_NotReadyAndBlocked(12 "15" row "clamd stopped"; EICAR row "never servable"): with the fake scanner unavailable the file stayspendinganddownloadFile,downloadThumbnail,getTranscriptandcreateFileTicketanswer409 file_not_readywithRetry-Afterequal tofile.not_ready_retry_afterin seconds; after the scanner answersOKand the job runs,200; aFOUNDanswer (infected), an error answer (error) and an upload whose declared SHA-256 does not match its bytes (upload_state = 'failed') each give409 file_blockedwithoutRetry-After; Sam gets404for the pending file, never409.TestDownload_DeletedFileIs404:deleted_atset gives the unknown-id404.TestTicket_RangeAfterRevocationSessionRevocationExpiry(12 "15" row "Ticket: range download; after revocation of permissions, after session revocation, after expiry"; ADR 0048 "Confirmation"; PLAN M3 Red team "file tickets after revocation"): Alex creates a ticket (201, relativeurl,expires_at= now plusfile.ticket_ttl); the URL withoutAuthorizationand withRangegives206andCache-Control: private, no-store; afterRevokeGrantof Alex's grant (M1-29) the same URL gives404; with a new grant and a new ticket, afterAuth.RevokeSession(M1-34)404; with a new session and ticket, after advancing the clock byfile.ticket_ttl,404; each404is identical to the unknown-id404; after the denial aggregation of the M1-24 recorder is flushed, the audit log holdsfile.readdenials for Alex with the reasonsno_grantandsession_revoked, and no row contains the ticket.TestTicket_OnlyQueryParameterOnlyDownloads: the ticket asAuthorization: Bearer <ticket>gives401; as query parameter ongetTranscriptandcreateFileTicket,401; a ticket of file A on file B, asmallticket oncontent, anoriginalticket onthumbnails/smalland a tampered ticket give404;Authorizationplus aticketusesAuthorization(authenticatedCache-Control).TestTicket_ThumbnailVariant: a seededsmallthumbnail; the ticket URL points tothumbnails/smalland answers200inline with the thumbnail's type, ETag and Repr-Digest of the thumbnail bytes and the thumbnail file name; a ticket request for a missinglargegives404.TestCreateFileTicket_NeedsSession: Alex with a personal API token (M1-44) gets403 forbiddenwithreasonstep_up_not_possible; for Robin's invisible file with that token404.TestTicket_KeyRotation: replace the key file; aftersecrets.cache_ttlon the fake clock old tickets still verify and new tickets verify only with the new key; a ticket under a key two versions back gives404.TestTicket_NeverInLogsOrSpans(17 "4", SEC-LOG-3): with log capture and the in-memory span exporter of M1-10, a ticket download and a failed one leave the ticket (raw, base64 and hex forms of the decoded bytes) in no log record, span attribute, span event or problem body; the access log shows the route pattern.TestDownload_PurposeRules(05 "2.2"): a seededavatarfile is readable by Robin; abrandingfile by every authenticated principal; agdpr_exportfile gives404to its uploader.TestTranscript_Read:donereturnstext,language,source;nonereturnsstatusonly; no JSONnull.- Every response above passes the G8 validator of M1-71.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/files/... ./internal/identity/... ./internal/authz/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./api/...make codegen-check,make arch,make api-lint,make api-checks,make api-tables,make api-clientsmake config-reference(no diff afterwards),make config-doc-check,make docs,make linkcheckmake check
Acceptance criteria¶
-
createFileTicket,downloadFile,downloadThumbnailandgetTranscriptare enabled, registered and pass AZ-P1;openapi.yamldeclares206,416,Range,If-Range, the file headers andFileConflictwithRetry-After. - HTML and SVG (and every type outside the inline list) are served as
attachmentwithapplication/octet-stream,nosniffandContent-Security-Policy: default-src 'none'; sandbox(SEC-T03, part files); inline types and thumbnails asinline; both file name parameters are correct. - One range gives
206withContent-Range; several ranges give200; a range outside gives416 range_not_satisfiable. - The ticket is accepted only in the query parameter
ticket(security schemefileTicket) and only bydownloadFileanddownloadThumbnail; after grant revocation, session revocation or expiry it gives404, identical to an unknown id. - A pending file gives
409 file_not_readywithRetry-Afterfromfile.not_ready_retry_after; a blocked or failed file gives409 file_blocked; without read access404. - Foreign and unknown file ids give the same
404on all four operations. -
Cache-Control,ETag,Repr-DigestandAccept-Rangesfollow 12 "9.3" for originals and thumbnails, with and without a ticket. - A ticket never appears in logs, span attributes or problem bodies; tickets require a session (
403 forbidden,step_up_not_possible). - 12 "9.2", "9.3", "18", 07 "4.1", the threat model and
docs/operations/configuration.mdare updated; the rulings are indocs/DECISIONS.md. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
files,identity,authz,internal/platform/httpserverandapiupdated (portFileswithOpen,IssueTicket,Transcript; ticket layout, key ring and rotation procedure; delivery headers;Auth.SessionActor; thefile.readrule per purpose and the session rule; binary routes underhttp.transfer_timeout). - Rulings of Design note 15 recorded in
docs/DECISIONS.md(one row each with reason). services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.