Five sequential plans: foundations (deferred_bindings, attach.Store, lagoon.Date/TimeOfDay, purge), cabana datepicker and fileupload, relation child CRUD with deferral, admin SPA, and unit and security tests. Adds D-22..D-24 from the plan-count checkpoint and the pattern map.
45 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 12.2-admin-form-fields-date-file-upload-relation-editing-with-def | 02 | execute | 2 |
|
|
true |
|
|
|
Phase Goal
ROADMAP Phase 12.2 goal (verbatim): A plugin's admin forms cover the three gaps a downstream project on SummerCMS v0.1 hit: a date/datetime field, a file upload field, and creating, editing and deleting related records inside the parent form (WinterCMS RelationController parity). Uploads and related-record changes on a record that is not saved yet use Winter-like deferred binding: they are held against a session key and committed with the parent's first save, or discarded with it.
This plan's slice: the admin API side of type: fileupload and type: datepicker, with file uploads deferred against the SPA's session key and committed in the parent's save transaction. After it an API client (the SPA in plan 04) can upload, list, caption, reorder and remove files on saved and unsaved records, download protected files, and save date, datetime and time fields.
Purpose: success criteria 1 and 2 and the file half of criterion 4. Plan 03 reuses the session key, the file handlers (through a child file scope) and the commit function for relation bindings. Output: compiled field types, routes, commit, OpenAPI, smoke tests, docs.
Repo: summercms.go only; fonoteka.go is verified (build, vet, admin tests), never edited. Neutral names (acme, blog) in code, tests and docs. Code and planning docs in separate commits; no co-author tags.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
(This plan's share.)
- Field types
datepicker,fileupload; FormField keysmode,format,displayFormat,minDate,maxDate,yearRange,firstDay,twelveHour,ignoreTimezone,fileTypes,mimeTypes,maxFilesize,maxFiles,imageWidth,imageHeight,thumbOptions,useCaption,prompt,protected(true for a Public false relation;multipleis true for attachMany); list column typesdate,time. - cabana exports:
SessionKeyHeader = "X-Session-Key",RecordInput.SessionKey,FileItem,FileMutationResult{Removed int},AdminFileCaptionRequest{Title, Description *string},ThumbOptions{Mode string}; error codepayload_too_large(413). - Routes (prefix-relative, backend guard, writes under requireAjax):
GET /{vendor}/{plugin}/{controller}/{id}/files/{field}(via nestedGet),POST /{vendor}/{plugin}/{controller}/{id}/files/{field},PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file},DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file},POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder,GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download,GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb; swag doc funcsAdminFileList,AdminFileUpload,AdminFileUpdate,AdminFileRemove,AdminFileReorder,AdminFileDownload,AdminFileThumb. - Internal:
compiledFile,compiledDate,fileScope,parentFileScope,commitDeferred,sessionKeyFrom.
Planner assumptions recorded for this plan
- A1: record id
0in the existing route patterns means "the record being created in this session". - A2: the session key travels in the
X-Session-Keyheader (discretion item); pattern^[A-Za-z0-9_-]{32,128}$. - A7: reorder and caption apply immediately (Winter parity); cancel does not revert them.
- A10: fileupload
requiredis checked at commit as at least one file after applying bindings. - A11: preview thumbnails default to 240x240 with
thumbOptions.modeorcrop. - A12:
thumbOptionsaccepts onlymode; A13: Winter keys outside D-08/D-20 (showWeekNumber, attachOnUpload, iconClass, emptyIcon) are boot errors.
(1) Compile (D-08): add fileupload to formFieldTypes and the D-08 keys to formFieldKeys; add compileFileuploadKeys(typ, values, field) modelled on compileWidgetKeys: on other types each fileupload-only key answers " is only valid on type: fileupload" (mode is shared with datepicker in Task 3, so gate its value per type); on fileupload decode mode (image|file, default file), fileTypes and mimeTypes (a comma- or pipe-separated string or a YAML list; fileTypes tokens ^[a-z0-9]{1,10}$ lower-cased, and in image mode only jpg, jpeg, png, gif, webp), maxFilesize (positive number of MB), maxFiles (positive integer), imageWidth and imageHeight (1..4096), thumbOptions (a mapping whose only key is mode in auto|exact|crop|fit), useCaption (bool), prompt (phrase key, localized like comment). Refuse options, emptyOption and nameFrom on fileupload. Add the flat omitempty FormField fields named in Artifacts (ThumbOptions as *ThumbOptions, numbers as pointers so absent stays absent) and localize prompt in FormSchema.Localize. fileupload stays non-scalar (not in scalarFormField, never a writable column).
(2) Boot checks (D-06, D-08) in compileRegistry (or a helper it calls): for every fileupload field the record model from pact.AdminRecordSource.NewRecord() must implement attach.Owner and attach.HasRelations with a Relation whose Name equals the field name; maxFiles on a Relation with Many false is an error; set field.Multiple = rel.Many and field.Protected = !rel.Public; build an unexported compiledFile per field on CompiledController (files map[string]*compiledFile: the field, the Relation, attach.Limits from mode/fileTypes/mimeTypes/maxFilesize, thumb width/height/mode per A11, the owner morph type via lagoon.MorphType). Errors use bootErr with the fields.yaml path. In Activate read http.body_limits.upload_bytes and http.body_limits.default_bytes when app.Config is set (the surf requiredBytes rules, unexported copy in cabana) onto the service, and refuse a maxFilesize above upload_bytes ("maxFilesize exceeds http.body_limits.upload_bytes").
(3) Session key (D-02) in modules/cabana/deferred.go: SessionKeyHeader = "X-Session-Key", sessionKeyFrom(r) (key string, present bool, err error) validating ^[A-Za-z0-9_-]{32,128}$ (malformed is a ValidationError on session_key); the admin id comes from bouncer.User(ctx). Add SessionKey string to RecordInput (additive); the create and update handlers fill it from the header (malformed: 422).
(4) File scope and routes in modules/cabana/field_file.go: fileScope (compiled file, owner morph, owner id with 0 meaning unsaved, the loaded owner or nil, the DeferredKey) and parentFileScope(ctx, tx, r, cc): the field must be a compiledFile allowed by context for the operation (id 0 is create, otherwise update) else 404; id 0 needs a valid key (else 404) and the create operation declared; id > 0 loads the owner with loadRecord (FormExtendQuery, 404 on miss). Write every file handler as a function of a resolved fileScope so plan 03 can add a child scope without touching handler bodies. Upload (POST .../{id}/files/{field}, requireAjax): a valid key is required (422 on session_key); wrap r.Body in http.MaxBytesReader at min(upload_bytes, maxFilesize bytes + 65536) (128 MiB when neither is configured); read the body with r.MultipartReader(): exactly one part, form name file_data, with a file name, else 422 on body; refuse when attached-minus-pending-removals plus pending uploads already reach maxFiles on attachMany (422 on the field, lagoon::validation.max.array); inside one lagoon.Transaction call attach.Store with the field's Limits and Public from the Relation, then lagoon.DeferredBind(ctx, tx, key, field, lagoon.DeferredFileType, id); when the transaction fails after Store returned, delete the stored blob keys with context.WithoutCancel. Map ErrTooLarge, ErrFileType, ErrMIMEType and ErrNotImage to a 422 on the field name using lagoon::validation.max.file, mimes, mimetypes and image through s.translator() (Laravel English text when there is no translator), and a *http.MaxBytesError to 413 with code payload_too_large. Answer 201 Envelope[FileItem] with pending true. List (GET .../{id}/files/{field} dispatched by nestedGet on segment == "files"): rows attached to the owner (attachment_type = morph, attachment_id = id as text, field) minus DeferredSlaves(...unbinds), plus DeferredSlaves(...binds) rows when a key is present; order sort_order, id; FileItem.URL and ThumbURL only for public relations (ThumbURL through (*File).Thumb at the compiled size and mode, only for image content types); Pending true for session-bound rows.
(5) Commit (D-04) in deferred.go: commitDeferred(ctx, tx, cc, target, op, in RecordInput) called from CRUDService.save after syncBelongsToMany and before formAfterCreate/formAfterUpdate, only when in.SessionKey is set and an admin is on the context: read lagoon.DeferredBindings for (key, admin id, controller morph type) restricted to fileupload fields allowed in op; apply in id order: a bind loads the system_files row by id FOR UPDATE, ignores it unless its attachment_id is empty, and sets attachment_type, attachment_id (the saved primary key as text) and field; an unbind is applied in Task 2 (until then leave unbind rows untouched for the purge). Delete the applied rows with DeferredForget in the same transaction. Bindings of other fields or another operation's context stay in place.
(6) OpenAPI and inventories: swag doc funcs AdminFileList and AdminFileUpload (multipart: @Accept multipart/form-data, @Param file_data formData file true, @Param X-Session-Key header string false, 201 Envelope[FileItem], 401/403/404/413/422 ErrorEnvelope); add both routes to phase09Routes (the list with mounted: nestedGetRoute) and the handlers to phase09ProtectedCalls; extend the conformance fixture: conformGadget implements attach.Owner (MorphName acme.conform.gadget) and HasRelations (photos, Many, Public), conformFS's gadget fields.yaml gains a photos fileupload field in image mode, newConformEnv publishes a mem:// bucket with attach.Publish; add conformance cases that upload a small PNG multipart to id 0 with a session key (201) and list it (200). Run scripts/check-admin-openapi.sh and commit admin/openapi/admin.json and admin/src/api/schema.d.ts with the code.
(7) Smoke test modules/cabana/fileupload_smoke_test.go TestFileuploadSmokeCreateCommit through the assembled router (reuse the conformance env helpers or the same fixture shape): upload to id 0, create the gadget with the same X-Session-Key, then list on the new id shows the file not pending and the deferred_bindings table has no row for the key; TestFileuploadSmokeForeignAdmin: a second admin with the first admin's key lists nothing.
(8) Docs in the same change: modules/cabana/README.md (Admin API routes table rows for the two routes, the fileupload keys, SessionKeyHeader, FileItem, payload_too_large), docs/backend/forms.md (Field types table row for fileupload; a "File uploads" section: AttachRelations, the keys, deferral until Save, the session key header, limits enforced on the server, maxFilesize versus http.body_limits.upload_bytes). Replace only the file-upload part of the "not provided" sentence here (Task 3 finishes it).
go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestFileuploadSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestFileuploadSmokeCreateCommit", "--- PASS: TestPhase09PermissionMatrix" or "--- PASS: TestPhase10OpenAPIConformance"; check-admin-openapi.sh prints "committed admin OpenAPI output is stale"; a ServeMux pattern conflict panic appears in the output.</fails_when>
<acceptance_criteria>
- grep -c '"fileupload"' modules/cabana/form_schema.go prints at least 1 and grep -c 'is only valid on type: fileupload' modules/cabana/form_schema.go modules/cabana/field_file.go | awk -F: '{s+=$2} END {print s}' prints at least 1.
- grep -c 'MaxBytesReader' modules/cabana/field_file.go prints at least 1 and grep -c 'commitDeferred' modules/cabana/crud.go prints at least 1.
- grep -c 'files/{field}' modules/cabana/security_coverage_test.go prints at least 2 and grep -c '/files/{field}' admin/openapi/admin.json prints at least 1.
- go doc ./modules/cabana SessionKeyHeader and go doc ./modules/cabana FileItem exit 0.
- grep -c 'fileupload' docs/backend/forms.md and grep -c 'X-Session-Key' modules/cabana/README.md each print at least 1.
</acceptance_criteria>
An upload on the create screen survives until Save and is attached to the new record in the same transaction; another admin cannot see or commit it.
(1) Remove (DELETE .../files/{field}/{file}, requireAjax, valid key required): lagoon.DeferredUnbind; when it returns a cancelled pending bind, delete that file row in the same transaction and register attach.DeleteKeys(attach.BlobKeys(row)) with lagoon.AfterCommit; answer Envelope[FileMutationResult] with removed 1.
(2) Caption (PUT .../files/{field}/{file}, requireAjax): 403 when the field lacks useCaption; body capped with MaxBytesReader at default_bytes, decoded into AdminFileCaptionRequest with DisallowUnknownFields and no trailing data; title and description saved at once (A7); answer Envelope[FileItem].
(3) Reorder (POST .../files/{field}/reorder, requireAjax): attachMany only (403 otherwise); body {ids} capped and decoded strictly; the id set must equal the field's visible set (attached minus pending removals plus pending uploads) exactly, else 422 on ids; assign the visible rows' existing sort_order values, sorted ascending, to the ids in submitted order in one transaction; answer the reordered Envelope[[]FileItem].
(4) Protected download and thumb (GET .../files/{field}/{file}/download and /thumb): only rows with is_public false (a public row is 404); a key in the header is optional and only widens the scope to pending rows of this admin. Download streams the blob from BlobKey(disk_name); thumb uses (*File).ThumbKey at the compiled size and mode and is 404 for a non-image content type. Headers on both: X-Content-Type-Options: nosniff, Cache-Control: private, no-store, Content-Security-Policy: default-src 'none'; sandbox; content types in attach.AllowedImageMIMEs are served inline with that type, everything else as application/octet-stream with Content-Disposition: attachment; filename*=UTF-8''<percent-encoded file name>.
(5) Commit rules in commitDeferred (D-04): a bind on an attachOne field first deletes the field's currently attached rows for this owner (blob keys through AfterCommit); an unbind deletes the attached row of this owner and field (blob keys through AfterCommit) and is ignored when the row is not attached there; after applying every binding, for each fileupload field allowed in op count the owner's attached rows: more than maxFiles answers 422 on the field (lagoon::validation.max.array), zero on a required: true field answers 422 on the field (lagoon::validation.required), so the transaction rolls back and the bindings remain. A required fileupload is checked on every create and update save, with or without a session key.
(6) OpenAPI, inventory and conformance: swag doc funcs AdminFileUpdate, AdminFileRemove, AdminFileReorder, AdminFileDownload (@Produce octet-stream, @Success 200 {file} file) and AdminFileThumb; add the five routes to phase09Routes and their handlers to phase09ProtectedCalls; give the conformance fixture a protected attachOne relation manual (file mode) and add one conformance case per route (binary routes: extend conformCase with a raw response check of status, Content-Type and nosniff instead of JSON decoding); regenerate admin.json and schema.d.ts.
(7) Smoke tests in modules/cabana/fileupload_smoke_test.go: TestFileuploadSmokeRemoveCancelsPending (row and blob gone after commit), TestFileuploadSmokeAttachOneReplace, TestProtectedFileSmoke (another gadget's protected file is 404; an SVG stored in file mode downloads as attachment with nosniff).
(8) Docs: modules/cabana/README.md route rows for the five routes; docs/database/attachments.md "Protected files in the admin" (the download and thumb routes, their headers, the 404 scoping).
go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestFileuploadSmoke.*|TestProtectedFileSmoke|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestProtectedFileSmoke" and "--- PASS: TestFileuploadSmokeAttachOneReplace"; check-admin-openapi.sh reports stale output.</fails_when>
<acceptance_criteria>
- grep -c 'nosniff' modules/cabana/field_file.go prints at least 1 and grep -c "sandbox" modules/cabana/field_file.go prints at least 1.
- grep -c 'AdminFileDownload\|AdminFileThumb\|AdminFileReorder\|AdminFileRemove\|AdminFileUpdate' modules/cabana/admin_openapi.go prints at least 5.
- grep -c '/files/{field}' modules/cabana/security_coverage_test.go prints at least 7.
- grep -c 'download' docs/database/attachments.md prints at least 1.
</acceptance_criteria>
Every file operation of D-09 works on saved and unsaved records, attachOne replacement and limits hold at Save, and protected files leave only through the scoped admin route.
(1) Compile in modules/cabana/field_date.go, called from compileFieldNode like compileWidgetKeys: add datepicker to formFieldTypes and the D-20 keys to formFieldKeys; datepicker-only keys on other types answer " is only valid on type: datepicker"; mode on datepicker is date|datetime|time (default datetime, Winter's default); minDate/maxDate parse with lagoon.ParseDate (or the date part of an RFC 3339 value) and are refused on time mode and when min is after max; yearRange is a positive integer or a two-integer list with from <= to (served as yearRange [n] or [from, to]); firstDay 0..6; twelveHour bool; ignoreTimezone bool, datetime mode only; format is Winter's PHP date format, mapped at boot to displayFormat with the DateTime::momentFormat table (d to DD, j to D, m to MM, n to M, Y to YYYY, y to YY, H to HH, G to H, h to hh, g to h, i to mm, s to ss, A and a, D/l day names, M/F month names, backslash escapes kept literal) and a boot error naming the token for t, L, B, I, O, P, T, Z, c, r, U or any other unmapped letter. Refuse options, emptyOption and nameFrom on datepicker. Add datepicker to scalarFormField so it binds as a writable column and its required merges into the rules.
(2) Go-type check (D-19) after BindWritableFields in compileRegistry: the bound column's Go type must be time.Time or *time.Time for datetime, lagoon.Date or *lagoon.Date for date, lagoon.TimeOfDay or *lagoon.TimeOfDay for time; any other type stops boot with bootErr naming the field, the mode and the found type. Keep an unexported compiledDate per field (mode, min, max, ignoreTimezone) on CompiledController (dates map[string]*compiledDate).
(3) Server bounds (D-20): in CRUDService.save after Fill and before lagoon.Validate's result is returned, for each datepicker with min or max allowed in op whose value is set: compare the calendar date (date mode: the Date; datetime: the UTC date of the instant, or its wall-clock date with ignoreTimezone) inclusively; a violation is a ValidationError on the field with Laravel's after_or_equal/before_or_equal English text ("The must be a date after or equal to .").
(4) Lists (Pitfall 2): isListRelation returns false for a struct type whose pointer implements sql.Scanner or which implements driver.Valuer (the embeddedStructType rule); apply the same exclusion at the struct-kind switches in partial_render.go and query.go wherever a struct is treated as a relation or nested value; add date and time to listColumnTypes. Record values of Date and TimeOfDay serialise through their MarshalJSON.
(5) Conformance and OpenAPI: give conformGadget a released_on lagoon.Date column (gorm:"type:date") and a starts_at *time.Time column with datepicker fields in the fixture fields.yaml (date with minDate, datetime), so the form schema, create and update conformance cases carry them; regenerate admin.json and schema.d.ts.
(6) Smoke test modules/cabana/datepicker_smoke_test.go: TestDatepickerSmokeCompile (unknown key, ignoreTimezone on date mode and an unmapped format token fail; displayFormat for d.m.Y H:i is DD.MM.YYYY HH:mm), TestDatepickerSmokeTypeMismatch (a date-mode field on a time.Time column fails boot naming the field), TestDatepickerSmokeSave (create with "released_on":"2026-10-02" and "starts_at":"2026-10-02T12:30:00+02:00" stores DATE 2026-10-02 and 10:30 UTC; a date before minDate is 422).
(7) Docs: docs/backend/forms.md (Field types row for datepicker; a "Date pickers" section with the modes, the Go types per mode, the keys, server-side bounds, time zones per D-18; rewrite the remaining "not provided" sentence so it lists the Winter widgets that are still not provided and no longer names the file upload), docs/backend/lists-and-filters.md (type: date and type: time columns), modules/cabana/README.md (field types and keys).
go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestDatepickerSmoke.*|TestPhase10OpenAPIConformance)$' && go test ./modules/cabana -count=1 && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestDatepickerSmokeSave"; docs:build --check prints a problem line; a fonoteka.go admin test fails (a cabana change broke an application controller).</fails_when>
<acceptance_criteria>
- grep -c '"datepicker"' modules/cabana/form_schema.go prints at least 1 and awk '/^func scalarFormField/,/^}/' modules/cabana/crud.go | grep -c '"datepicker"' prints 1.
- grep -c '"date": {}' modules/cabana/list_schema.go prints 1 and grep -c '"time": {}' modules/cabana/list_schema.go prints 1.
- grep -c 'displayFormat' admin/openapi/admin.json prints at least 1.
- grep -c 'datepicker' docs/backend/forms.md prints at least 2 and grep -c 'type: date' docs/backend/lists-and-filters.md prints at least 1.
- go test ./modules/cabana -count=1 passes with Docker up (no existing cabana test broken by the relation-detection change).
</acceptance_criteria>
Date, datetime and time fields compile, type-check against the model, save with server-side bounds and show in lists, without a plugin defining its own date type.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| SPA (cookie + CSRF header) → admin file routes | Untrusted multipart bodies, file ids, session keys and JSON bodies |
| Session key header → deferred_bindings | A client-chosen key selects pending work |
| Admin API → browser (download/thumb) | Stored bytes rendered by the admin's browser |
| fields.yaml → boot compile | Trusted plugin config, still validated fail-loud |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-12.2-09 | Spoofing | session key replay by another admin | high | mitigate | Every binding read, list and commit is keyed by (key, admin id from bouncer, morph type); a foreign key finds nothing (Task 1). |
| T-12.2-10 | Tampering | cross-controller binding replay | high | mitigate | commitDeferred filters by the controller's morph type and the fileupload fields allowed in the operation context (Task 1). |
| T-12.2-11 | Information Disclosure | file ids on remove, caption, reorder, download, thumb | high | mitigate | One parent-scoped query (owner loaded via FormExtendQuery, or the admin's pending bindings); miss is 404 (Task 2). |
| T-12.2-12 | Elevation of Privilege | protected download rendering active content | high | mitigate | Inline only for jpeg/png/gif/webp; everything else octet-stream attachment; nosniff, private no-store, CSP sandbox (Task 2). |
| T-12.2-13 | Information Disclosure | protected file reachable by public URL | medium | mitigate | url/thumb_url never emitted for Public false relations; admin route only; docs tell hosts to mount StaticHandlerPublic (Tasks 1, 2; plan 01 docs). |
| T-12.2-14 | Denial of Service | upload body size | high | mitigate | MaxBytesReader at min(upload_bytes, maxFilesize + 64 KiB), 413; attach.Store counting limit; one part only (Task 1). |
| T-12.2-15 | Tampering | CSRF on new write routes | high | mitigate | requireAjax on upload, remove, caption, reorder; TestPhase09PermissionMatrix inventories every route (Tasks 1, 2). |
| T-12.2-16 | Tampering | double submit commits twice | medium | mitigate | lagoon.DeferredBindings reads FOR UPDATE inside the save transaction; applied rows deleted in the same transaction (Task 1). |
| T-12.2-17 | Tampering | datepicker bounds bypass | medium | mitigate | minDate/maxDate re-checked in the save transaction, 422 (Task 3). |
| T-12.2-18 | Denial of Service | JSON bodies of caption and reorder | medium | mitigate | MaxBytesReader at default_bytes, DisallowUnknownFields, trailing data refused (Task 2). |
| T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package added; swag v1.16.6 and openapi-typescript are already pinned and only regenerate committed outputs. |
| </threat_model> |
<success_criteria>
type: fileuploaduploads, lists, removes, captions, reorders and serves protected files on saved and unsaved records, with server-side limits (D-06 to D-10).- File bindings commit inside the create/update transaction and survive a 422 (D-04); foreign keys and foreign admins see nothing (D-02).
type: datepickercompiles, type-checks and saves date, datetime and time columns with server-side bounds (D-18 to D-20); lists render date and time columns.- OpenAPI, TS types, route inventory, conformance, README and docs updated in the same change. </success_criteria>