docs(12.2): create phase plan
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.
This commit is contained in:
@@ -670,10 +670,29 @@ Plans:
|
||||
4. Deferred binding: on an unsaved parent, uploads and related-record changes bind to a session key, are committed in the parent's create transaction, and are discarded with the session. Orphaned deferred bindings and their files are purged.
|
||||
5. The new code has unit tests, delivered in the phase's last plan, and the docs checker passes.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 5 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /gsd-plan-phase 12.2 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 12.2-01-PLAN.md — Foundations: deferred_bindings table and store, attach.Store with image guard, attach.Relation, lagoon.Date/TimeOfDay with Fill and required, deferred:purge with the daily framework schedule, pact relation hooks
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 12.2-02-PLAN.md — cabana fileupload (seven file routes, protected download) and datepicker fields, session key, commit of file bindings in the save transaction, OpenAPI
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 12.2-03-PLAN.md — cabana relation child CRUD (hasMany kind, manage/view/pivot forms, toolbar buttons), D-15 parent scoping, unsaved-parent deferral and relation commit, child file routes
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 12.2-04-PLAN.md — Admin SPA: FileuploadField, DatepickerField (Reka), child and pivot modals, create-screen deferral, date/time cells, rebuilt dist
|
||||
|
||||
**Wave 5** *(blocked on Wave 4 completion)*
|
||||
- [ ] 12.2-05-PLAN.md — Unit and security tests (D-15 suite, test map, SPA backstops), check-phase12.2.sh, security review, v0.1.1 tag checkpoint
|
||||
|
||||
**Cross-cutting constraints:**
|
||||
- Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected
|
||||
- Datetime round trip in a fixed non-UTC zone shows the local wall clock and emits the UTC string; ignoreTimezone emits the wall clock unchanged
|
||||
- Protected thumbnails are requested with X-Session-Key, rendered from an object URL and the URL is revoked on unmount
|
||||
- Keyboard reorder: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one debounced reorder request
|
||||
|
||||
### Phase 13: Płytarium API — wishlist, notifications, CSV, credentials, public routes
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: 12
|
||||
current_phase_name: Płytarium API — Collections and Albums
|
||||
current_phase: "12.2"
|
||||
current_phase_name: admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
status: executing
|
||||
stopped_at: Phase 12.2 context gathered
|
||||
last_updated: "2026-10-02T13:01:26.264Z"
|
||||
stopped_at: Phase 12.2 UI-SPEC approved
|
||||
last_updated: "2026-10-02T14:53:28.651Z"
|
||||
last_activity: 2026-10-02
|
||||
last_activity_desc: Phase 12 execution started
|
||||
state_head: 4712e94a12dfee779de0d3e15ace88bb19f5f878
|
||||
state_head: 6f4386c2f9ff8670205b2521eebe7556e5d0b510
|
||||
progress:
|
||||
total_phases: 21
|
||||
completed_phases: 10
|
||||
total_plans: 101
|
||||
total_plans: 106
|
||||
completed_plans: 100
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -28,7 +28,7 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 12 (Płytarium API — Collections and Albums) — EXECUTING
|
||||
Phase: 12.2 (admin-form-fields-date-file-upload-relation-editing-with-def) — READY TO EXECUTE
|
||||
Plan: 5 of 5
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-02 — Phase 12 execution started
|
||||
@@ -500,6 +500,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-02T13:01:25.669Z
|
||||
Stopped at: Phase 12.2 context gathered
|
||||
Resume file: .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
Last session: 2026-10-02T13:52:22.629Z
|
||||
Stopped at: Phase 12.2 UI-SPEC approved
|
||||
Resume file: .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/guard.go
|
||||
- modules/lagoon/attach/relation.go
|
||||
- modules/lagoon/attach/file.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/deferred.go
|
||||
- modules/lagoon/deferred_migrations.go
|
||||
- modules/lagoon/purge.go
|
||||
- modules/lagoon/deferred_test.go
|
||||
- modules/lagoon/migrations.go
|
||||
- modules/lagoon/date.go
|
||||
- modules/lagoon/date_test.go
|
||||
- modules/lagoon/fill.go
|
||||
- modules/lagoon/validate.go
|
||||
- modules/lagoon/commands.go
|
||||
- modules/lagoon/schedule.go
|
||||
- modules/lagoon/migrations_test.go
|
||||
- modules/lagoon/README.md
|
||||
- modules/conga/scheduler.go
|
||||
- modules/conga/schedule_test.go
|
||||
- modules/conga/README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- docs/database/models.md
|
||||
- docs/database/casts-and-validation.md
|
||||
- docs/database/attachments.md
|
||||
- docs/plugins/scheduling.md
|
||||
- docs/console/setup-and-maintenance.md
|
||||
- ../fonoteka.go/parity/schema_diff_test.go
|
||||
- ../fonoteka.go/parity/migrate_test.go
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-4]
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-01, `lagoon.Migrate` creates Winter's `deferred_bindings` table (id, master_type, master_field, slave_type, slave_id, pivot_data, session_key, is_bind, created_at, updated_at) plus exactly one added column, `backend_user_id`, under its own framework history id `summercms.deferred`; rolling the set back drops the table."
|
||||
- "Per D-02 and D-01, every deferred-binding store operation takes a `lagoon.DeferredKey{SessionKey, AdminID, MasterType}`, writes `backend_user_id` on every row and never reads or changes a row of another admin or another master type."
|
||||
- "Per D-04 (Winter `beforeCreate` semantics), binding the same slave twice in one session writes one row, and an unbind of a slave with a pending bind cancels the pair: the bind row is deleted and returned to the caller, no unbind row is written."
|
||||
- "Per D-22, a binding for a child created under deferral carries the framework envelope `{\"created\":true,\"pivot\":{...}}` in `pivot_data`; `lagoon.DeferredEnvelope` round-trips it and a binding without the envelope is a plain link."
|
||||
- "Per D-05, `lagoon.PurgeDeferred` removes bindings older than the cut-off, deletes an unattached `system_files` row a bind points at and, only after the transaction commits, its blob and thumbnails through `attach.DeleteKeys`; it deletes a slave model only when the binding carries `created: true`, and it keeps rows that were only linked."
|
||||
- "Per D-05, `summer deferred:purge [--days=N]` exists (default 5 days, config `database.deferred_bindings.purge_days`), and conga schedules it daily at `database.deferred_bindings.purge_at` (default `03:00`; an empty value disables the framework entry) under the entry id `summercms.lagoon[0]:deferred:purge`."
|
||||
- "Per D-06, `attach.Relation{Name, Many, Public}` and the `attach.HasRelations` interface (`AttachRelations() []attach.Relation`) exist for models to declare attachOne/attachMany relations."
|
||||
- "Per D-07 and D-08, `attach.Store` writes the blob under the Winter partition key with a server-generated disk name, inserts an unattached `system_files` row with `sort_order` equal to its id, enforces `Limits.MaxBytes`, extensions and MIME types, applies the image-content guard (jpeg, png, gif and webp; sniffed bytes plus `image.DecodeConfig` plus a 4096 by 4096 pixel ceiling) when `Limits.Image` is set, and deletes the blob again when the row insert fails."
|
||||
- "Per D-19, `lagoon.Date` (DATE, JSON `\"2026-10-02\"`) and `lagoon.TimeOfDay` (TIME, JSON `\"14:30:00\"`) implement `sql.Scanner`, `driver.Valuer`, JSON and text marshalling, and a zero value stores NULL; `*lagoon.Date` and `*lagoon.TimeOfDay` are the nullable variants."
|
||||
- "Per D-19 and RESEARCH Pitfall 1, `lagoon.Fill` writes a JSON string into `time.Time`, `*time.Time`, `lagoon.Date`, `*lagoon.Date`, `lagoon.TimeOfDay` and `*lagoon.TimeOfDay` fields through `encoding.TextUnmarshaler`, and every conversion that worked before this plan behaves the same."
|
||||
- "Per RESEARCH Pitfall 3, `lagoon.Validate` treats a zero `time.Time`, `lagoon.Date` or `lagoon.TimeOfDay` (and a pointer to one) as empty for `required`; no other type's emptiness changes."
|
||||
- "Per D-16, pact declares the optional controller hooks `RelationBeforeCreate`, `RelationAfterCreate`, `RelationBeforeUpdate`, `RelationAfterUpdate`, `RelationBeforeDelete` and `RelationAfterDelete` in the same one-method style as `RelationBeforeLink`."
|
||||
- "Edge (D-19 timezone): `lagoon.Date.Scan` of a `time.Time` takes the calendar date the driver returned (pgx gives DATE as UTC midnight), so a value written as 2026-10-02 reads back as 2026-10-02 whatever the process time zone."
|
||||
- "Edge (D-05 concurrency): the purge locks the bindings it processes with `FOR UPDATE SKIP LOCKED`, so a parent save that is committing the same session's bindings is never disturbed."
|
||||
- "Edge (D-08 boundary): a body of exactly `Limits.MaxBytes` bytes is stored and `Limits.MaxBytes + 1` bytes answers `attach.ErrTooLarge` with no blob and no row left behind."
|
||||
artifacts:
|
||||
- path: "modules/lagoon/deferred_migrations.go"
|
||||
provides: "DeferredBindingMigrations (deferred_bindings table, D-01)"
|
||||
contains: "backend_user_id"
|
||||
- path: "modules/lagoon/deferred.go"
|
||||
provides: "DeferredBinding, DeferredKey, DeferredEnvelope, DeferredBind, DeferredUnbind, DeferredBindings, DeferredForget, DeferredSlaves, MorphType"
|
||||
contains: "func DeferredBind("
|
||||
- path: "modules/lagoon/purge.go"
|
||||
provides: "PurgeDeferred, PurgeOptions, PurgeResult"
|
||||
contains: "SKIP LOCKED"
|
||||
- path: "modules/lagoon/date.go"
|
||||
provides: "Date, TimeOfDay and their constructors and parsers"
|
||||
contains: "type TimeOfDay struct"
|
||||
- path: "modules/lagoon/schedule.go"
|
||||
provides: "FrameworkSchedule, FrameworkScheduleID"
|
||||
contains: "deferred:purge"
|
||||
- path: "modules/lagoon/attach/store.go"
|
||||
provides: "Store, Upload, Limits, ErrTooLarge, ErrFileType, ErrMIMEType, ErrNotImage, DefaultImageExtensions, DefaultFileExtensions"
|
||||
contains: "func Store("
|
||||
- path: "modules/lagoon/attach/guard.go"
|
||||
provides: "AllowedImageMIMEs, IsAllowedImage, MaxImagePixels"
|
||||
- path: "modules/lagoon/attach/relation.go"
|
||||
provides: "Relation, HasRelations"
|
||||
contains: "AttachRelations() []Relation"
|
||||
key_links:
|
||||
- from: "modules/lagoon/migrations.go"
|
||||
to: "modules/lagoon/deferred_migrations.go"
|
||||
via: "Migrate runs DeferredBindingMigrations under history id summercms.deferred"
|
||||
pattern: "summercms.deferred"
|
||||
- from: "modules/lagoon/purge.go"
|
||||
to: "modules/lagoon/attach/file.go"
|
||||
via: "blob keys from attach.BlobKeys are deleted through attach.DeleteKeys inside lagoon.AfterCommit"
|
||||
pattern: "AfterCommit"
|
||||
- from: "modules/conga/scheduler.go"
|
||||
to: "modules/lagoon/schedule.go"
|
||||
via: "scheduleEntries prepends lagoon.FrameworkSchedule entries with plugin id summercms.lagoon"
|
||||
pattern: "FrameworkSchedule"
|
||||
- from: "modules/lagoon/fill.go"
|
||||
to: "modules/lagoon/date.go"
|
||||
via: "convertValue falls back to encoding.TextUnmarshaler for string sources"
|
||||
pattern: "TextUnmarshaler"
|
||||
prohibitions:
|
||||
- statement: "Blob objects MUST NOT be deleted inside a database transaction; every blob delete runs in lagoon.AfterCommit through attach.DeleteKeys"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "The purge MUST NOT delete a slave row whose binding lacks the created envelope (a linked-only record) or a system_files row that is attached to an owner"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "attach MUST NOT import lagoon (import cycle); anything needing both lives in lagoon"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "fonoteka.go application code (album photos, collection media, user avatar upload code) MUST NOT change in this plan; only the two parity test expectation files are edited"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "No new Go module dependency is added; go.mod and go.sum stay unchanged"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Phase 12.2 goal (verbatim, not in user-story form): 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 storage primitives every later plan builds on. After it, a host application can store an upload with the framework's guard, hold it against an admin's session key, purge abandoned bindings from the CLI or the daily schedule, and fill date and time columns from JSON without a plugin-defined date type. Nothing is visible in the admin SPA yet; plans 02 to 04 wire these pieces into cabana and the SPA.
|
||||
|
||||
<objective>
|
||||
Build the lagoon, lagoon/attach, conga and pact foundations of Phase 12.2 (summercms.go), plus the two fonoteka.go parity test expectations that a new framework table changes.
|
||||
|
||||
Purpose: plans 02 and 03 commit and list bindings, store uploads and run relation hooks through these APIs; plan 05 brings them to full coverage. Smoke tests only here (project rule: unit tests are the last plan).
|
||||
Output: `deferred_bindings` migration set and store ops, `attach.Store` with the ported image guard, `attach.Relation`, `lagoon.Date`/`lagoon.TimeOfDay` with Fill and `required` support, `deferred:purge` with the framework schedule entry, pact relation hooks; lagoon, pact and conga READMEs and the affected docs pages.
|
||||
|
||||
Repos: summercms.go (framework) and fonoteka.go (two parity test expectation files only, committed separately). Framework code, READMEs and docs use neutral names (acme, blog) and never name the application. Planning docs and code go in separate commits; never add co-author tags.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md
|
||||
@modules/lagoon/migrations.go
|
||||
@modules/lagoon/fill.go
|
||||
@modules/lagoon/attach/file.go
|
||||
@modules/conga/scheduler.go
|
||||
|
||||
<interfaces>
|
||||
- lagoon (today): `Migrate(gdb, plugins)` runs `migrator(gdb, "summercms.attach", attach.Migrations)`, then `migrator(gdb, "summercms.cabana", BackendAdminMigrations)`, then `migrator(gdb, QueueHistoryID, QueueMigrations(sqlDB))`, then plugin sets; `HistoryTableName(pluginID)` maps `summercms.deferred` to `summer_migrations_summercms_deferred`. `Transaction(ctx, gdb, fn func(ctx, tx) error) error`; `AfterCommit(ctx, db, fn func(ctx, db))` runs fn only after the outermost lagoon.Transaction commits. `Fill(model, allowed []string, requested map[string]any, production bool) error`; `setField` converts pointer fields to the element type first (`convertValue(src, elemType)`), non-pointer fields fall back to `sql.Scanner`; `convertValue` order today: Encrypted special case, AssignableTo, json.Number, ConvertibleTo. `isEmptyValue(val any) bool` (validate.go:264) treats nil, nil pointers, empty strings/slices/maps as empty. `RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command` with `withDB(ctx, app, fn)`; flags are `[]bonfire.Flag{{Name, Description}}`, read with `in.Flag(name)`.
|
||||
- attach (today): `Owner{MorphName() string}`; `File{ID, DiskName, FileName, FileSize, ContentType, Title, Description *string, Field, AttachmentID, AttachmentType string, IsPublic *bool, SortOrder int, Metadata *string, CreatedAt, UpdatedAt}` (TableName `system_files`); unexported `blobKeysFor(f File) []string` returns `[partition+disk, partition+"thumb_<id>_"]`; `DeleteKeys(ctx, bucket, keys)` treats keys ending in `_` as prefixes; `BlobKey(disk)`, `PartitionDirectory(disk)`, `PublicURL(key)`, `(*File).URL()`, `(*File).Thumb(ctx, bucket, w, h, mode) (publicURL string, err error)`; `maxThumbSourcePixels = 4096 * 4096`; thumb.go blank-imports the gif/jpeg/png/webp decoders.
|
||||
- conga: `scheduleEntries(app, plugins) ([]scheduleEntry, error)` compiles `pact.HasSchedule` entries with ids `fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command)` via `scheduleFor(sc.Cadence, loc)`; `periodicJobs` builds the River periodic jobs and the entry table that `runScheduled` checks (forged rows are skipped). conga already imports lagoon.
|
||||
- pact: `ScheduledCommand{Command string; Args []string; Cadence Cadence}`, `DailyAt(hour, minute int) Cadence`, `HasModels{Models() []any}`, `RelationBeforeLink{RelationBeforeLink(ctx, relation string, parent, related any, pivot map[string]any) error}`.
|
||||
- compass: `(*Config).Lookup(path) (any, bool)`, `String(path)`, `Int(path)`, `Has(path)`.
|
||||
- Application reference only (do not change): `../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go` (`IsAllowedImage`, `AllowedImageMIMEs`) and `classes/album_files.go` (`StorePublicFile`: 11 random bytes hex disk name plus lower-cased extension, `DetectContentType`, sort_order = id).
|
||||
- Winter sources (meta repo `../examples/golem15-wintercms-starter`): `vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php`, `2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php`, `vendor/winter/storm/src/Database/Models/DeferredBinding.php` (beforeCreate, cleanUp, deleteSlaveRecord), `vendor/winter/storm/src/Filesystem/Definitions.php` (default extension lists).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Table `deferred_bindings` (columns per D-01 plus `backend_user_id INTEGER NOT NULL`), indexes `deferred_bindings_master_type_index`, `_master_field_index`, `_slave_type_index`, `_slave_id_index`, `_session_lookup_index (session_key, backend_user_id, master_type)`, `_created_at_index`; migration id `202610020001_create_deferred_bindings`; history id `summercms.deferred` (table `summer_migrations_summercms_deferred`).
|
||||
- lagoon: `DeferredBindingMigrations []*gormigrate.Migration`, `DeferredHistoryID = "summercms.deferred"`, `DeferredBinding` (model, TableName `deferred_bindings`), `DeferredKey{SessionKey string; AdminID uint; MasterType string}`, `DeferredEnvelope{Created bool; Pivot map[string]any}`, `(DeferredBinding).Envelope() (DeferredEnvelope, error)`, `DeferredFileType = "system_files"`, `MorphType(db *gorm.DB, model any) (string, error)`, `DeferredBind(ctx, tx, key, field, slaveType, slaveID string, env *DeferredEnvelope) error`, `DeferredUnbind(ctx, tx, key, field, slaveType, slaveID string) (*DeferredBinding, error)`, `DeferredBindings(ctx, tx, key, fields []string) ([]DeferredBinding, error)`, `DeferredForget(ctx, tx, ids []uint) error`, `DeferredSlaves(tx, key, field, slaveType string, bind bool) *gorm.DB`, `PurgeDeferred(ctx, db, bucket, PurgeOptions) (PurgeResult, error)`, `PurgeOptions{Before time.Time; Models func(slaveType string) (any, bool)}`, `PurgeResult{Bindings, Files, Children, Skipped int}`, `Date`, `NewDate`, `DateOf`, `ParseDate`, `TimeOfDay`, `NewTimeOfDay`, `ParseTimeOfDay`, `FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error)`, `FrameworkScheduleID = "summercms.lagoon"`.
|
||||
- attach: `Upload{FileName string; Body io.Reader; Public bool}`, `Limits{MaxBytes int64; Extensions []string; MIMETypes []string; Image bool}`, `Store(ctx, db, bucket, Upload, Limits) (*File, error)`, `ErrTooLarge`, `ErrFileType`, `ErrMIMEType`, `ErrNotImage`, `DefaultImageExtensions`, `DefaultFileExtensions`, `AllowedImageMIMEs`, `IsAllowedImage(data []byte) bool`, `MaxImagePixels`, `Relation{Name string; Many bool; Public bool}`, `HasRelations{AttachRelations() []Relation}`, `BlobKeys(f File) []string`, `(*File).ThumbKey(ctx, bucket, w, h, mode) (string, error)`.
|
||||
- pact: `RelationBeforeCreate`, `RelationAfterCreate`, `RelationBeforeUpdate`, `RelationAfterUpdate`, `RelationBeforeDelete`, `RelationAfterDelete` (each `Relation<X>(ctx, relation string, parent, child any) error`).
|
||||
- CLI command `deferred:purge [--days=N]`; config keys `database.deferred_bindings.purge_days` (default 5) and `database.deferred_bindings.purge_at` (default `"03:00"`, empty disables the framework schedule entry); schedule entry id `summercms.lagoon[0]:deferred:purge`.
|
||||
|
||||
## Assumption-delta decision
|
||||
|
||||
<assumption_delta_decision>
|
||||
Detector not fired for this plan's surface. Considered `DeferredKey` by hand: the binding owner is the pair (session key, backend admin), never a second identity model; the backend admin stays the only principal. Noun primary: deferred binding. Decision: no-change.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Planner assumptions recorded for this plan
|
||||
|
||||
- A4: `master_type`/`slave_type` are `MorphName()` when the model implements `attach.Owner`, else the GORM table name (`lagoon.MorphType`); the file slave type is the table name `system_files` (`lagoon.DeferredFileType`), never a PHP class string.
|
||||
- A5: the purge lives in `lagoon.RuntimeCommands(app, plugins)` and resolves created-child models from every plugin's `pact.HasModels`.
|
||||
- A8: `Limits.MIMETypes` entries containing `/` are MIME patterns (`image/*` allowed) matched against the sniffed type; entries without `/` are extensions.
|
||||
- A9: purge config keys as listed in Artifacts.
|
||||
- RESEARCH Open Question 1: no second bucket for protected files; protected rows live in the same bucket under unguessable disk names.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: A file stored for an unsaved record is held by an admin's deferred binding, and an expired binding is purged with its row and, after commit, its blob</name>
|
||||
<reversibility rating="costly">D-01 table shape is user-locked in CONTEXT.md (already decided, no checkpoint): every host application migrates it, and a later shape change needs a migration plus a data rewrite.</reversibility>
|
||||
<files>modules/lagoon/attach/store.go, modules/lagoon/attach/guard.go, modules/lagoon/attach/relation.go, modules/lagoon/attach/file.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/store_test.go, modules/lagoon/deferred.go, modules/lagoon/deferred_migrations.go, modules/lagoon/purge.go, modules/lagoon/deferred_test.go, modules/lagoon/migrations.go, modules/lagoon/README.md, docs/database/attachments.md, ../fonoteka.go/parity/schema_diff_test.go, ../fonoteka.go/parity/migrate_test.go</files>
|
||||
<read_first>modules/lagoon/migrations.go, modules/lagoon/attach/migrations.go, modules/lagoon/attach/file.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/lifecycle_test.go (Postgres harness), modules/lagoon/transaction.go (Transaction, AfterCommit), modules/lagoon/postgres_test.go (TestMain, dedicatedDB), modules/lagoon/README.md, docs/database/attachments.md, ../fonoteka.go/plugins/golem15/fonoteka/classes/image_guard.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/album_files.go, ../fonoteka.go/parity/schema_diff_test.go (allowedDiffs), ../fonoteka.go/parity/migrate_test.go (wantTables), ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Migrations/2013_10_01_000001_Db_Deferred_Bindings.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Migrations/2021_01_19_000001_Db_Add_Pivot_Data_To_Deferred_Bindings.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Models/DeferredBinding.php, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Filesystem/Definitions.php, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Code Examples, Pitfalls 5, 6, 8, 9)</read_first>
|
||||
<action>Per D-01, D-02, D-05, D-06, D-07, D-08 and D-22, wire one path end to end: attach.Store, then a deferred bind owned by an admin, then PurgeDeferred removing binding, row and (after commit) blob.
|
||||
|
||||
(1) Migration (D-01): modules/lagoon/deferred_migrations.go exports `DeferredHistoryID = "summercms.deferred"` and `DeferredBindingMigrations` with one gormigrate migration, ID `202610020001_create_deferred_bindings`, in the `attach.Migrations` style: CREATE TABLE deferred_bindings (id SERIAL PRIMARY KEY, master_type TEXT NOT NULL, master_field TEXT NOT NULL, slave_type TEXT NOT NULL, slave_id TEXT NOT NULL, pivot_data TEXT, session_key TEXT NOT NULL, is_bind BOOLEAN NOT NULL DEFAULT TRUE, backend_user_id INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()) and the six indexes named in Artifacts; Rollback drops the table. In migrations.go `Migrate` runs this set with `migrator(gdb, DeferredHistoryID, DeferredBindingMigrations)` right after the `summercms.cabana` set and before the queue set, with error prefix `lagoon: migrate deferred bindings`; update the Migrate doc comment. Do not append to attach.Migrations or BackendAdminMigrations.
|
||||
|
||||
(2) Store ops in modules/lagoon/deferred.go: model `DeferredBinding` (columns per D-01; `IsBind bool` with no gorm default tag so an explicit false is written; `PivotData *string`; `BackendUserID uint`), `DeferredKey`, `DeferredEnvelope` (JSON `{"created":true,"pivot":{...}}`, both keys omitempty, D-22), `(DeferredBinding).Envelope()`, `DeferredFileType = "system_files"`, and `MorphType(db, model)` (attach.Owner's MorphName when implemented, else the GORM table name parsed with db's naming strategy; error on an empty result). `DeferredBind` and `DeferredUnbind` port Winter's beforeCreate: look up a row with the same master_type, master_field, slave_type, slave_id, session_key and backend_user_id; same direction means no new row; opposite direction deletes the existing row (and DeferredUnbind returns that cancelled bind so the caller removes the slave); otherwise insert. Both refuse an empty session key, a zero AdminID or an empty MasterType with an error. `DeferredBindings` selects the key's rows whose master_field is in fields, ordered by id, `FOR UPDATE`. `DeferredForget` deletes exactly the rows with the given ids (the caller passes only ids it read for its own key). `DeferredSlaves(tx, key, field, slaveType, bind)` returns a subquery selecting `slave_id` for the key, field, slave type and direction, for use as `CAST(pk AS TEXT) IN (?)` (RESEARCH Pattern 4).
|
||||
|
||||
(3) attach (D-06, D-07, D-08): relation.go defines `Relation{Name string; Many bool; Public bool}` and `HasRelations{AttachRelations() []Relation}` beside `Owner`. guard.go ports the application guard: `AllowedImageMIMEs` (jpeg, png, gif, webp), `IsAllowedImage(data []byte) bool` (fail closed on empty data; sniff with http.DetectContentType must be allowed; `image.DecodeConfig` must succeed with format jpeg, png, gif or webp and positive width and height) and `MaxImagePixels = maxThumbSourcePixels`; an image over the ceiling is refused. store.go defines `Upload`, `Limits`, the four sentinel errors, `DefaultImageExtensions` (jpg, jpeg, png, gif, webp) and `DefaultFileExtensions` (Winter's Definitions.php default list minus svg, js, map, css, less, scss, swf and xml, the script-capable types; record the final list in the godoc). `Store` lower-cases the client extension and accepts it only when it matches `^[a-z0-9]{1,10}$` and is in `Limits.Extensions` (or the default list for the mode when empty); peeks up to 1 MiB of the body through a bufio.Reader for the sniff and, with `Limits.Image`, `IsAllowedImage` on the peeked bytes; checks `Limits.MIMETypes` per A8; streams the body through a counting `io.LimitReader(MaxBytes+1)` into `bucket.NewWriter` at `BlobKey(diskName)` (disk name: 11 crypto/rand bytes as 22 lowercase hex characters plus "." and the extension; never any client path component), aborting and deleting the key with ErrTooLarge when the count exceeds MaxBytes (MaxBytes 0 means no field limit); inserts the `File` row with attachment columns empty, `IsPublic` from Upload, `FileSize`, the sniffed base media type (or `mime.TypeByExtension` when the sniff is application/octet-stream), then sets `sort_order` to the new id; on a row error deletes the blob with `context.WithoutCancel`. Export `BlobKeys(f File) []string` (the blobKeysFor body; keep the unexported name as a call-through or replace its callers) and `(*File).ThumbKey(ctx, bucket, w, h, mode) (string, error)` returning the blob key `Thumb` already computes, with `Thumb` becoming `PublicURL(ThumbKey(...))` so its behaviour is unchanged. attach never imports lagoon.
|
||||
|
||||
(4) Purge (D-05, D-22) in modules/lagoon/purge.go: `PurgeDeferred(ctx, db, bucket, opts)` processes bindings with `created_at < opts.Before` in id order, batches of 500, each batch in `lagoon.Transaction` selecting with `FOR UPDATE SKIP LOCKED`. Per binding: is_bind with slave_type `DeferredFileType` deletes the `system_files` row only when its attachment_id is empty or NULL, collecting `attach.BlobKeys`; is_bind with an envelope whose Created is true resolves `opts.Models(slaveType)`, loads the row by primary key into a fresh model and deletes it through GORM (model hooks and soft delete apply); an unresolvable slave type leaves the binding in place, counts `Skipped` and logs once per type at Warn; every other binding (unbinds, linked-only binds) is just deleted. Processed bindings are deleted in the same transaction, and the collected keys go to `lagoon.AfterCommit(ctx, tx, …)` calling `attach.DeleteKeys`. A nil bucket with file deletions is an error before any delete.
|
||||
|
||||
(5) Smoke tests (full coverage is plan 05): modules/lagoon/deferred_test.go `TestDeferredUploadPurgeTracer` on the lagoon Postgres harness with a `mem://` bucket: Migrate, attach.Store a small PNG with `Limits{Image: true}`, DeferredBind it for an acme master type and admin 7, set created_at back 6 days, PurgeDeferred with Before = now minus 5 days, then assert the binding and the row are gone and the blob key no longer exists; also one bind/unbind cancel pair. modules/lagoon/attach/store_test.go `TestStoreSmoke`: a PNG stored with sort_order equal to id, an SVG refused with ErrNotImage in image mode, a body of MaxBytes+1 refused with ErrTooLarge and no blob left.
|
||||
|
||||
(6) fonoteka.go test expectations (a separate fonoteka.go commit; no application code changes): add `"deferred_bindings"` to `allowedDiffs` in parity/schema_diff_test.go with a reason naming 12.2 D-01 (Winter core table, snapshot does not dump Winter core tables, Go adds backend_user_id), and add `"summer_migrations_summercms_deferred"` to `wantTables` in parity/migrate_test.go in sorted position.
|
||||
|
||||
(7) Docs in the same change (CLAUDE.md): modules/lagoon/README.md (Features, API reference rows for every new lagoon and attach identifier above, the framework migration list now naming deferred_bindings) and docs/database/attachments.md (a "Storing an upload" section: attach.Store, Limits, the image guard, the default extension lists, attach.Relation and HasRelations; a "Protected files" paragraph: one bucket, unguessable disk names, the framework never builds a public URL for an is_public=false row, mount attach.StaticHandlerPublic or keep directory listing off). Prose only, no hand-written Go fences; every identifier named must exist.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/lagoon ./modules/lagoon/attach -count=1 -v -run '^(TestDeferredUploadPurgeTracer|TestStoreSmoke.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestDeferredUploadPurgeTracer" and a "--- PASS: TestStoreSmoke" line; TestDocsTree reports an unknown identifier; TestSchemaMatchesPHPSnapshot reports "Go extra table deferred_bindings"; TestMigrateSeedsCanonicalGenres reports a history table mismatch.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'backend_user_id INTEGER NOT NULL' modules/lagoon/deferred_migrations.go` prints 1 and `grep -c '202610020001_create_deferred_bindings' modules/lagoon/deferred_migrations.go` prints 1.
|
||||
- `grep -c 'DeferredHistoryID' modules/lagoon/migrations.go` prints at least 1.
|
||||
- `go doc ./modules/lagoon DeferredBind`, `go doc ./modules/lagoon DeferredUnbind`, `go doc ./modules/lagoon PurgeDeferred`, `go doc ./modules/lagoon MorphType`, `go doc ./modules/lagoon/attach Store`, `go doc ./modules/lagoon/attach IsAllowedImage`, `go doc ./modules/lagoon/attach HasRelations`, `go doc ./modules/lagoon/attach BlobKeys` and `go doc ./modules/lagoon/attach File.ThumbKey` exit 0.
|
||||
- `grep -c 'SKIP LOCKED' modules/lagoon/purge.go` prints at least 1 and `grep -c 'AfterCommit' modules/lagoon/purge.go` prints at least 1.
|
||||
- `go list -deps ./modules/lagoon/attach | grep -cx 'git.golem15.com/golem15/summercms/modules/lagoon'` prints 0 (attach does not import lagoon).
|
||||
- `grep -c '"deferred_bindings"' ../fonoteka.go/parity/schema_diff_test.go` prints 1 and `grep -c 'summer_migrations_summercms_deferred' ../fonoteka.go/parity/migrate_test.go` prints 1.
|
||||
- `git diff --stat HEAD -- go.mod go.sum` prints nothing (no new Go dependency).
|
||||
- `grep -c 'attach.Store' docs/database/attachments.md` and `grep -c 'deferred_bindings' modules/lagoon/README.md` each print at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A host application can store a guarded upload, hold it for one admin's session key, and purge it later; the table is migrated in every application and fonoteka.go's parity suite knows the new framework table.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A model fills and stores date, datetime and time columns from JSON strings with framework types, and required rejects an empty date</name>
|
||||
<reversibility rating="reversible">New value types and an additive Fill fallback ordered after every existing conversion.</reversibility>
|
||||
<files>modules/lagoon/date.go, modules/lagoon/date_test.go, modules/lagoon/fill.go, modules/lagoon/validate.go, modules/lagoon/README.md, docs/database/models.md, docs/database/casts-and-validation.md</files>
|
||||
<read_first>modules/lagoon/fill.go (setField, convertValue, convertNumber, fillScanSource), modules/lagoon/validate.go (isEmptyValue and its callers at lines 63, 114, 154, 179, 349), modules/lagoon/encrypted.go (Scan/Value style), modules/lagoon/fill_test.go, modules/lagoon/validate_test.go, modules/lagoon/README.md, docs/database/models.md, docs/database/casts-and-validation.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pitfalls 1 and 3, "lagoon.Date / TimeOfDay")</read_first>
|
||||
<action>Per D-19 and RESEARCH Pitfalls 1 and 3 (success criterion 1: no plugin-defined date types).
|
||||
|
||||
(1) modules/lagoon/date.go: `Date` (unexported year, month, day and a valid flag) with `NewDate(y int, m time.Month, d int) Date` (normalises through time.Date and stays valid), `DateOf(t time.Time) Date` (calendar date in t's own location), `ParseDate(s string) (Date, error)` (exactly `2006-01-02`), methods `Time(loc *time.Location) time.Time`, `String() string`, `IsZero() bool`, `Scan(src any) error` (nil, time.Time taking t's Year/Month/Day without conversion, string and []byte in `2006-01-02` or a leading `2006-01-02T…` timestamp), `Value() (driver.Value, error)` (NULL when zero, else the `2006-01-02` string), `MarshalJSON` (null when zero), `UnmarshalJSON` (null gives zero), `MarshalText`, `UnmarshalText`. `TimeOfDay` (hour, minute, second, valid) with `NewTimeOfDay(h, m, s int)`, `ParseTimeOfDay(s string)` accepting `15:04` and `15:04:05` and truncating fractional seconds, methods `Hour()`, `Minute()`, `Second()`, `String()` (`15:04:05`), `IsZero()`, `Scan` (nil, string, []byte, time.Time clock), `Value` (NULL when zero, else `15:04:05`), JSON and text marshalling with the same null rule. Errors use the prefix `lagoon: date` / `lagoon: time of day`. Pointer variants need no extra code.
|
||||
|
||||
(2) fill.go: in `convertValue`, after the ConvertibleTo branch fails and only when the source is a string or []byte, try `encoding.TextUnmarshaler` on `reflect.New(destType)`; success returns the element, failure returns the original "cannot assign" error wrapped so cabana still reports a FillTypeError. `time.Time` parses RFC 3339 through its own UnmarshalText (an offset such as `+02:00` is kept as the instant; GORM writes timestamptz in UTC). Every conversion that worked before stays first, so nothing that filled before changes.
|
||||
|
||||
(3) validate.go: `isEmptyValue` also returns true for a zero `time.Time`, `Date` or `TimeOfDay` value and for a non-nil pointer to one; it checks these three types explicitly (no generic IsZero interface), so a numeric or other type's emptiness never changes.
|
||||
|
||||
(4) Smoke tests in modules/lagoon/date_test.go: `TestDateSmoke` (ParseDate, JSON round trip, Scan of a UTC-midnight time.Time and of a string, Value of zero is nil), `TestTimeOfDaySmoke`, `TestFillTextSmoke` (Fill a struct with time.Time, *time.Time, Date, *Date, TimeOfDay, *TimeOfDay from JSON strings; a garbage date is a FillTypeError), `TestValidateRequiredZeroDateSmoke` (required fails on a zero Date and passes on a set one).
|
||||
|
||||
(5) Docs in the same change: modules/lagoon/README.md (Date, TimeOfDay and their constructors in Features and API reference; a note that `required` now treats a zero date or time as empty, a lagoon behaviour change, and that optional dates should use pointer fields), docs/database/models.md (mass assignment: Fill accepts date and time strings), docs/database/casts-and-validation.md (a "Date and time columns" section mapping DATE, TIME and timestamptz to the Go types and their JSON shapes). Prose and tables only.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/lagoon -count=1 -v -run '^(TestDateSmoke|TestTimeOfDaySmoke|TestFillTextSmoke|TestValidateRequiredZeroDateSmoke)$' && go test ./modules/lagoon -count=1 -run '^(TestFill.*|TestValidate.*)$' && go test ./cmd/summer -run TestDocsTree -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS" line for each of the four smoke tests; an existing TestFill or TestValidate test fails (behaviour of an earlier conversion changed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go doc ./modules/lagoon Date`, `go doc ./modules/lagoon TimeOfDay`, `go doc ./modules/lagoon ParseDate` and `go doc ./modules/lagoon ParseTimeOfDay` exit 0.
|
||||
- `grep -c 'TextUnmarshaler' modules/lagoon/fill.go` prints at least 1.
|
||||
- `grep -c 'TimeOfDay' modules/lagoon/validate.go` prints at least 1.
|
||||
- `grep -c 'lagoon.Date' docs/database/casts-and-validation.md` prints at least 1 and `grep -c 'TimeOfDay' modules/lagoon/README.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin model declares `lagoon.Date`, `lagoon.TimeOfDay` or `time.Time` columns, fills them from the admin's JSON strings and gets a 422 from `required` on an empty date.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: An operator purges abandoned bindings with `deferred:purge`, the scheduler runs it daily, and controllers can hook child create, update and delete</name>
|
||||
<reversibility rating="reversible">New command, two optional config keys with defaults, a schedule entry that config can disable, and optional interfaces nothing has to implement.</reversibility>
|
||||
<files>modules/lagoon/commands.go, modules/lagoon/schedule.go, modules/lagoon/migrations_test.go, modules/lagoon/README.md, modules/conga/scheduler.go, modules/conga/schedule_test.go, modules/conga/README.md, modules/pact/capabilities.go, modules/pact/README.md, docs/plugins/scheduling.md, docs/console/setup-and-maintenance.md</files>
|
||||
<read_first>modules/lagoon/commands.go, modules/lagoon/migrations_test.go (TestRuntimeCommandsRegisterBareAndColonNames), modules/conga/scheduler.go, modules/conga/schedule_test.go (every scheduleEntries and periodicJobs expectation), modules/conga/commands.go, modules/conga/README.md, modules/pact/capabilities.go (RelationBeforeLink and neighbours), modules/pact/README.md, modules/lagoon/attach/bucket.go (OpenBucket, Publish), docs/plugins/scheduling.md, docs/console/setup-and-maintenance.md, cmd/summer/docs.go (docsCommands picks up lagoon.RuntimeCommands names)</read_first>
|
||||
<action>Per D-05 and D-16.
|
||||
|
||||
(1) `deferred:purge` in `lagoon.RuntimeCommands` (update the doc comment that lists the commands): flag `days` ("Purge bindings older than this many days"); without the flag the days come from `database.deferred_bindings.purge_days` when set, else 5 (Winter cleanUp(5)); a negative or non-integer value is an error naming the flag. The command opens the database through `withDB`, takes the bucket from `app.Lookup[*blob.Bucket]()` or opens one with `attach.OpenBucket(ctx, app.Config)` (closing what it opened), builds the model resolver from every plugin's `pact.HasModels().Models()` keyed by `MorphType` (a duplicate key is an error naming both types), calls `PurgeDeferred` with Before = now minus the days, and prints one success line with the bindings, files, children and skipped counts.
|
||||
|
||||
(2) modules/lagoon/schedule.go: `FrameworkScheduleID = "summercms.lagoon"` and `FrameworkSchedule(app *backpack.App) ([]pact.ScheduledCommand, error)` returning `{Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}` from `database.deferred_bindings.purge_at` (`HH:MM`, default `03:00`); an explicitly empty value returns no entries; a malformed value (not two-digit hour 00-23, colon, two-digit minute 00-59) returns an error naming the key, which conga reports as a boot error. A nil app or nil Config returns no entries and no error, so config-less test apps see no change.
|
||||
|
||||
(3) conga/scheduler.go: `scheduleEntries` prepends the framework entries with plugin id `lagoon.FrameworkScheduleID` and the same id format and compile path (`scheduleFor`), before the plugin loop, so they appear in `schedule:list`, run through `runScheduled` and are protected by the compiled entry table like plugin entries. Update every conga test whose expected entries change when an app config is present, and add `TestFrameworkScheduleSmoke` (an app with config gets `summercms.lagoon[0]:deferred:purge` first; `purge_at: ""` removes it; a nil-config app keeps its old entry list).
|
||||
|
||||
(4) pact/capabilities.go: the six optional controller interfaces named in Artifacts, each `Relation<Phase>(ctx context.Context, relation string, parent, child any) error`, documented beside RelationBeforeLink: Before hooks run inside the child write's transaction before the row write, After hooks after it and before commit; `parent` is the loaded parent, or a fresh zero-key record when the parent is not saved yet (deferral); an error rolls the write back and cabana answers its opaque lifecycle error.
|
||||
|
||||
(5) Docs in the same change: modules/lagoon/README.md (CLI commands table row for `deferred:purge`, Configuration rows for both keys, API rows for FrameworkSchedule and FrameworkScheduleID), modules/conga/README.md (framework schedule entries), modules/pact/README.md (API reference rows for the six hooks), docs/plugins/scheduling.md (the framework's daily purge entry and how to disable or move it), docs/console/setup-and-maintenance.md (the `deferred:purge` command and its flag). Config keys are not checked automatically: review them by hand against the code.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/conga -count=1 -v -run '^(TestFrameworkScheduleSmoke)$' && go test -short ./modules/conga ./modules/lagoon ./modules/pact -count=1 && go test ./cmd/summer -count=1 -run '^(TestDocsTree|TestDocsCommandsMirrorGeneratedMain)$' && go run ./cmd/summer docs:build --check && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./...</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestFrameworkScheduleSmoke"; docs:build --check prints a problem line (for example an unknown command `deferred:purge`); the fonoteka.go build or vet fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"deferred:purge"' modules/lagoon/commands.go` prints at least 1 and `grep -c 'purge_days' modules/lagoon/commands.go` prints at least 1.
|
||||
- `grep -c 'FrameworkSchedule' modules/conga/scheduler.go` prints at least 1.
|
||||
- `go doc ./modules/pact RelationBeforeCreate`, `go doc ./modules/pact RelationAfterDelete` and `go doc ./modules/lagoon FrameworkSchedule` exit 0.
|
||||
- `grep -c 'deferred:purge' docs/console/setup-and-maintenance.md`, `grep -c 'deferred:purge' docs/plugins/scheduling.md` and `grep -c 'purge_at' modules/lagoon/README.md` each print at least 1.
|
||||
- `go test -short ./modules/conga -count=1` passes (existing schedule tests updated, none deleted).
|
||||
</acceptance_criteria>
|
||||
<done>Abandoned uploads and pending children are cleaned up daily or on demand, and controllers can observe child writes; plan 03 calls the hooks.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Uploaded bytes and client file name → attach.Store | Untrusted content and names reach the blob store, the sniffer and the image decoders |
|
||||
| Session key + admin id → deferred_bindings | Ownership of pending work; later plans pass request-derived keys here |
|
||||
| deferred_bindings rows → purge | Stored rows decide which records and blobs are deleted |
|
||||
| River job rows → scheduled command | A job row names a command to run |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.2-01 | Denial of Service | attach.Store body | high | mitigate | Counting LimitReader(MaxBytes+1) while streaming, blob deleted on overflow, 1 MiB bounded peek; callers add MaxBytesReader (plan 02) (Task 1). |
|
||||
| T-12.2-02 | Elevation of Privilege | polyglot or script-capable upload | high | mitigate | Image mode: sniff plus DecodeConfig plus pixel ceiling, fail closed; file-mode default list drops svg, js, map, css, less, scss, swf, xml (Task 1). |
|
||||
| T-12.2-03 | Tampering | client file name | high | mitigate | Disk name is 22 random hex characters plus a validated `[a-z0-9]{1,10}` extension; no client path component reaches a blob key (Task 1). |
|
||||
| T-12.2-04 | Tampering | PurgeDeferred | high | mitigate | Deletes a slave model only with the D-22 `created` envelope and a file only when unattached; linked-only rows are kept; rows locked with SKIP LOCKED (Task 1). |
|
||||
| T-12.2-05 | Tampering | blob deletes | medium | mitigate | Blob keys collected in the transaction and deleted in lagoon.AfterCommit via attach.DeleteKeys; a rollback keeps the bytes (Task 1). |
|
||||
| T-12.2-06 | Spoofing | deferred-binding ownership | high | mitigate | backend_user_id NOT NULL; every store op takes a DeferredKey with AdminID and refuses zero or empty parts (Task 1). |
|
||||
| T-12.2-07 | Tampering | forged scheduled job row | medium | mitigate | The framework entry joins conga's compiled entry table, so runScheduled still runs only exact compiled entries (T-11-09) (Task 3). |
|
||||
| T-12.2-08 | Denial of Service | Fill text parsing | low | accept | TextUnmarshaler runs only for string sources already bounded by the request body cap; parsers are linear stdlib parsers. |
|
||||
| T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package is added in this plan (go.mod/go.sum unchanged, acceptance criterion). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green.
|
||||
- fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestMigrateSeedsCanonicalGenres)$' -count=1` green.
|
||||
- The fonoteka.go commit touches only parity/schema_diff_test.go and parity/migrate_test.go.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- deferred_bindings migrates under `summercms.deferred`; store ops, envelope and purge behave per D-01, D-02, D-05 and D-22.
|
||||
- attach.Store stores guarded uploads per D-07/D-08; attach.Relation exists per D-06.
|
||||
- lagoon.Date/TimeOfDay fill from JSON and `required` rejects a zero date per D-19.
|
||||
- `deferred:purge` and the framework schedule entry exist; pact has the D-16 hooks; READMEs and docs updated; docs checker green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,314 @@
|
||||
---
|
||||
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["12.2-01"]
|
||||
files_modified:
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/field_date.go
|
||||
- modules/cabana/deferred.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/partial_render.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/fileupload_smoke_test.go
|
||||
- modules/cabana/datepicker_smoke_test.go
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- docs/database/attachments.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-2, SC-4]
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-08, `fields.yaml` accepts `type: fileupload` with exactly the keys mode (image|file, default file), fileTypes, mimeTypes, maxFilesize (MB), maxFiles, imageWidth, imageHeight, thumbOptions (mapping with only `mode` in auto|exact|crop|fit), useCaption and prompt, beside the generic field keys; any other key, a datepicker-only key, or an image-mode fileType outside jpg, jpeg, png, gif, webp stops boot with an error naming plugin, controller and file."
|
||||
- "Per D-06, a fileupload field whose name is not an `attach.Relation` returned by the model's `AttachRelations()`, or whose model does not implement `attach.Owner`, stops boot; `maxFiles` on an attachOne relation and a `maxFilesize` above `http.body_limits.upload_bytes` stop boot."
|
||||
- "Per D-02, the SPA's session key arrives in the `X-Session-Key` header; a key outside `^[A-Za-z0-9_-]{32,128}$` answers 422 on `session_key`; record id `0` without a valid key answers 404; bindings are written and read only for the authenticated admin's id."
|
||||
- "Per D-03 and D-09, `POST {prefix}/api/v1/{vendor}/{plugin}/{controller}/{id}/files/{field}` stores one multipart `file_data` part with `attach.Store` and only binds it to the session (id 0 for an unsaved record), and `GET .../{id}/files/{field}` lists attached files minus the session's pending removals plus the session's pending uploads, ordered by sort_order, each with `pending`, and with `url`/`thumb_url` only for public relations."
|
||||
- "Per D-04, a create or update save with `X-Session-Key` applies every binding of (key, admin, controller morph type) whose master_field is a fileupload field allowed in that operation's context, inside the save transaction after the row write and before FormAfterCreate/FormAfterUpdate; a 422 rolls back and leaves the bindings in place; a successful save deletes the applied rows; bindings for other fields stay for the purge."
|
||||
- "Per D-04 (Winter attachOne semantics), applying a bind on an attachOne field deletes the field's previously attached file rows and, after commit, their blobs; applying an unbind deletes the attached row and, after commit, its blobs; after applying, more files than maxFiles or no file on a required fileupload answers 422 on that field."
|
||||
- "Per D-09, DELETE `.../files/{field}/{file}` defers a removal (or cancels a pending upload and deletes its row and, after commit, blob), PUT `.../files/{field}/{file}` saves title and description at once when useCaption is set (403 otherwise), and POST `.../files/{field}/reorder` with `{ids}` equal to the visible set assigns the existing sort_order values in the submitted order at once."
|
||||
- "Per D-10, GET `.../files/{field}/{file}/download` and `.../thumb` serve only `is_public=false` rows attached to a parent the admin may load through FormExtendQuery or pending in the admin's own session; anything else is 404; responses carry `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and a sandboxing CSP, and only jpeg, png, gif and webp are served inline (everything else is `application/octet-stream` with `Content-Disposition: attachment`)."
|
||||
- "Per D-08, the upload route wraps the body in `http.MaxBytesReader` sized min(upload_bytes, maxFilesize plus 64 KiB multipart overhead) and answers 413 `payload_too_large` past it; a file over maxFilesize inside the cap, a disallowed type or a failed image guard answers 422 on the field with the `lagoon::validation.*` message; the JSON file routes cap bodies at `http.body_limits.default_bytes` and refuse unknown keys."
|
||||
- "Per D-20, `fields.yaml` accepts `type: datepicker` with exactly mode (date|datetime|time, default datetime), format, minDate, maxDate, yearRange (an integer or a two-year list), firstDay (0-6), twelveHour and ignoreTimezone beside the generic keys; a format token with no SPA equivalent, minDate/maxDate on time mode, ignoreTimezone off datetime mode and any other key stop boot; the served field carries `displayFormat` mapped from the Winter format."
|
||||
- "Per D-19, boot fails when a datepicker's mode does not match its model column's Go type (datetime: time.Time or *time.Time; date: lagoon.Date or *lagoon.Date; time: lagoon.TimeOfDay or *lagoon.TimeOfDay); a datepicker is a writable scalar field whose `required` merges into the save rules."
|
||||
- "Per D-20, a saved value before minDate or after maxDate (calendar date; the UTC date for datetime, the wall-clock date with ignoreTimezone) answers 422 on the field with the `after_or_equal` / `before_or_equal` message."
|
||||
- "Per RESEARCH Pitfall 2 and the UI-SPEC list cells, struct columns that implement sql.Scanner or driver.Valuer (lagoon.Date, lagoon.TimeOfDay) are scalar list columns, not relations, and `columns.yaml` accepts `type: date` and `type: time`."
|
||||
- "Edge (D-04 concurrency): two saves with the same session key serialize on the `FOR UPDATE` binding read, so a binding is applied once."
|
||||
- "Edge (D-02 replay): a session key that belongs to another admin finds no bindings: its uploads are not listed, not committed and not removable (404)."
|
||||
- "Edge (D-09 empty): GET files on a saved record without a session key lists the attached files only; reorder with an id set that differs from the visible set answers 422 on ids."
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_file.go"
|
||||
provides: "fileupload compile and boot checks, fileScope, file list/upload/caption/remove/reorder/download/thumb handlers"
|
||||
contains: "MaxBytesReader"
|
||||
- path: "modules/cabana/field_date.go"
|
||||
provides: "datepicker compile, Go-type check, Winter format token map, min/max check"
|
||||
contains: "displayFormat"
|
||||
- path: "modules/cabana/deferred.go"
|
||||
provides: "SessionKeyHeader, session key parsing, commitDeferred for file bindings"
|
||||
contains: "X-Session-Key"
|
||||
- path: "admin/openapi/admin.json"
|
||||
provides: "the seven file routes and FileItem"
|
||||
contains: "/files/{field}"
|
||||
key_links:
|
||||
- from: "modules/cabana/crud.go"
|
||||
to: "modules/cabana/deferred.go"
|
||||
via: "save calls commitDeferred after syncBelongsToMany and before formAfterCreate/formAfterUpdate"
|
||||
pattern: "commitDeferred"
|
||||
- from: "modules/cabana/field_file.go"
|
||||
to: "modules/lagoon/attach/store.go"
|
||||
via: "upload handler streams the file_data part into attach.Store with the field's Limits"
|
||||
pattern: "attach\\.Store"
|
||||
- from: "modules/cabana/deferred.go"
|
||||
to: "modules/lagoon/deferred.go"
|
||||
via: "lagoon.DeferredBindings / DeferredBind / DeferredUnbind / DeferredForget with a DeferredKey carrying the admin id"
|
||||
pattern: "lagoon\\.Deferred"
|
||||
- from: "modules/cabana/http.go"
|
||||
to: "modules/cabana/field_file.go"
|
||||
via: "nestedGet dispatches segment files to the file list"
|
||||
pattern: "segment == \"files\""
|
||||
prohibitions:
|
||||
- statement: "The protected file routes MUST NOT serve SVG, HTML or any type outside jpeg, png, gif and webp inline, and MUST NOT serve an is_public=true row"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A file list MUST NOT emit a public url or thumb_url for a file of a protected (Public false) relation"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A file route MUST NOT answer 403 for a file of another record; an out-of-scope file is 404"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "fonoteka.go upload code (album photos, collection media, user avatar) MUST NOT change; the fonoteka.go build, vet and admin tests stay green"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Framework READMEs and docs MUST NOT name a consuming application"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
<objective>
|
||||
Extend cabana (summercms.go) with the datepicker and fileupload field types, the seven file routes, session-key parsing and the commit of file bindings in create/update saves; regenerate the admin OpenAPI document and TS types; update the cabana README and the forms, lists and attachments docs.
|
||||
|
||||
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.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md
|
||||
@modules/cabana/form_schema.go
|
||||
@modules/cabana/crud.go
|
||||
@modules/cabana/http.go
|
||||
|
||||
<interfaces>
|
||||
- From plan 01 (lagoon): `DeferredKey{SessionKey string; AdminID uint; MasterType string}`, `DeferredBind(ctx, tx, key, field, slaveType, slaveID string, env *DeferredEnvelope) error`, `DeferredUnbind(ctx, tx, key, field, slaveType, slaveID string) (*DeferredBinding, error)` (returns the cancelled pending bind), `DeferredBindings(ctx, tx, key, fields []string) ([]DeferredBinding, error)` (FOR UPDATE, id order), `DeferredForget(ctx, tx, ids []uint) error`, `DeferredSlaves(tx, key, field, slaveType string, bind bool) *gorm.DB` (subquery of slave_id text), `DeferredFileType = "system_files"`, `MorphType(db, model) (string, error)`, `Transaction`, `AfterCommit`, `Date`, `TimeOfDay`, `ParseDate`.
|
||||
- From plan 01 (attach): `Store(ctx, db, bucket, Upload{FileName, Body, Public}, Limits{MaxBytes, Extensions, MIMETypes, Image}) (*File, error)`, `ErrTooLarge`, `ErrFileType`, `ErrMIMEType`, `ErrNotImage`, `DefaultImageExtensions`, `Relation{Name, Many, Public}`, `HasRelations{AttachRelations() []Relation}`, `Owner{MorphName() string}`, `BlobKeys(f File) []string`, `DeleteKeys(ctx, bucket, keys)`, `(*File).Thumb(ctx, bucket, w, h, mode) (publicURL, error)`, `(*File).ThumbKey(ctx, bucket, w, h, mode) (key, error)`, `(*File).URL()`, `AllowedImageMIMEs`.
|
||||
- cabana (today): `formFieldTypes`, `formFieldKeys`, `compileFieldNode(name, node)`, `compileWidgetKeys(typ, values, field)` (the per-type gating model: "<key> is only valid on type: <type>"), `nodeString/nodeBool/nodeScalar/sequenceValues/unwrapNode`, `bootErr(pluginID, controllerID, file, err)`, `FormField` (flat omitempty Winter keys; `Multiple bool` exists), `scalarFormField(typ)` (crud.go:800), `BindWritableFields(cc)`, `CRUDService.save` (crud.go:290-391; `syncBelongsToMany` at 373, `formAfterCreate/Update` at 376-383), `RecordInput{Body}`, `loadRecord(ctx, tx, cc, dest, pk)` (FormExtendQuery + FOR UPDATE, `recordNotFound` on miss), `writeCRUDError`, `ValidationError{Details}`, `lifecycleFailure`, `withTx`, `newWritableModel(cc)`, `pathID(r)`, `decodeObject(r)`, `service.protect`, `service.operationDeclared(w, r, cc, op)`, `requireAjax`, `nestedGet` (http.go:299), `constrainController/constrainRelation/constrainNested`, `service.db()`, `service.translator()`, `Activate(app, plugins)`, `compileRegistry(items)`, `isListRelation(t)` (list_schema.go:418), `listColumnTypes` (list_schema.go:22), `embeddedStructType` (model_fields.go:70, the Scanner/Valuer exclusion to copy), struct-kind switches at partial_render.go:351,415,454 and query.go:499,516.
|
||||
- Tests: `phase09Routes` (security_coverage_test.go:34) and `phase09ProtectedCalls()` must list every route and handler; `TestPhase09ContractInventory` requires every inventoried route in admin/openapi/admin.json with a 200/201 and, for protected routes, a 401; `TestPhase10OpenAPIConformance` (openapi_conformance_test.go) requires one conformance case per inventoried route against the PostgreSQL fixture `acme.conform` (`conformGadget`, `conformFS()` MapFS, `newConformEnv` publishes the app; body limits are set to 1048576).
|
||||
- surf: `requiredBytes(app, "http.body_limits.upload_bytes")` (unexported; mirror its whole-number handling in cabana); cabana routes are GroupRaw, so surf applies no body limit to them.
|
||||
- Winter sources: `../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php`, `modules/backend/formwidgets/DatePicker.php`, `modules/system/helpers/DateTime.php` (momentFormat token table).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Field types `datepicker`, `fileupload`; FormField keys `mode`, `format`, `displayFormat`, `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour`, `ignoreTimezone`, `fileTypes`, `mimeTypes`, `maxFilesize`, `maxFiles`, `imageWidth`, `imageHeight`, `thumbOptions`, `useCaption`, `prompt`, `protected` (true for a Public false relation; `multiple` is true for attachMany); list column types `date`, `time`.
|
||||
- cabana exports: `SessionKeyHeader = "X-Session-Key"`, `RecordInput.SessionKey`, `FileItem`, `FileMutationResult{Removed int}`, `AdminFileCaptionRequest{Title, Description *string}`, `ThumbOptions{Mode string}`; error code `payload_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 funcs `AdminFileList`, `AdminFileUpload`, `AdminFileUpdate`, `AdminFileRemove`, `AdminFileReorder`, `AdminFileDownload`, `AdminFileThumb`.
|
||||
- Internal: `compiledFile`, `compiledDate`, `fileScope`, `parentFileScope`, `commitDeferred`, `sessionKeyFrom`.
|
||||
|
||||
## Planner assumptions recorded for this plan
|
||||
|
||||
- A1: record id `0` in the existing route patterns means "the record being created in this session".
|
||||
- A2: the session key travels in the `X-Session-Key` header (discretion item); pattern `^[A-Za-z0-9_-]{32,128}$`.
|
||||
- A7: reorder and caption apply immediately (Winter parity); cancel does not revert them.
|
||||
- A10: fileupload `required` is checked at commit as at least one file after applying bindings.
|
||||
- A11: preview thumbnails default to 240x240 with `thumbOptions.mode` or `crop`.
|
||||
- A12: `thumbOptions` accepts only `mode`; A13: Winter keys outside D-08/D-20 (showWeekNumber, attachOnUpload, iconClass, emptyIcon) are boot errors.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: An admin uploads an image to a fileupload field of a record that is not saved yet, and the record's create save attaches it</name>
|
||||
<reversibility rating="reversible">Additive field type, routes and an optional header; the session-key header name is a contract only between this API and the bundled SPA.</reversibility>
|
||||
<files>modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/field_file.go, modules/cabana/deferred.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/contracts.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/fileupload_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/cabana/form_schema.go (whole file), modules/cabana/schema_types.go (FormField), modules/cabana/crud.go (save, RecordInput, loadRecord, writeCRUDError, scalarFormField), modules/cabana/http.go (Activate, mount, nestedGet, create, update, protect, operationDeclared), modules/cabana/registry.go (compileRegistry), modules/cabana/contracts.go (CompiledController), modules/cabana/admin_openapi.go (AdminRelationLink style, Envelope types), modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go (cases, newConformEnv, conformFS, conformGadget), modules/cabana/csrf.go, modules/lagoon/deferred.go, modules/lagoon/attach/store.go, modules/lagoon/attach/relation.go, modules/surf/router.go (requiredBytes), scripts/check-admin-openapi.sh, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php, modules/cabana/README.md, docs/backend/forms.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Patterns 1, 4, 5, 6; Pitfalls 4, 6)</read_first>
|
||||
<action>Per D-02, D-03, D-04, D-06, D-07, D-08 and D-09, wire upload, list and commit end to end for one fileupload field.
|
||||
|
||||
(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 "<key> 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).</action>
|
||||
<verify>
|
||||
<automated>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 ./...</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An admin removes, captions and reorders files on saved and unsaved records, attachOne replaces its file at Save, limits are rechecked at Save, and protected files download only through the admin API</name>
|
||||
<reversibility rating="reversible">Additive routes and commit rules inside the existing save transaction.</reversibility>
|
||||
<files>modules/cabana/field_file.go, modules/cabana/deferred.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/fileupload_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/database/attachments.md</files>
|
||||
<read_first>modules/cabana/field_file.go and modules/cabana/deferred.go (as left by Task 1), modules/cabana/http.go (mount), modules/cabana/csrf.go, modules/lagoon/deferred.go (DeferredUnbind return value), modules/lagoon/attach/file.go (BlobKeys, DeleteKeys), modules/lagoon/attach/thumb.go (ThumbKey), modules/lagoon/attach/static.go (servePublicBlobs headers), modules/lagoon/transaction.go (AfterCommit), ../examples/golem15-wintercms-starter/modules/backend/formwidgets/FileUpload.php (onRemoveAttachment, onSortAttachments, onSaveAttachmentConfig), docs/database/attachments.md</read_first>
|
||||
<action>Per D-04, D-08, D-09 and D-10. Every handler below resolves the same fileScope as Task 1 and finds its file with one parent-scoped query: the `{file}` id must be attached to the scope's owner and field, or bound pending in the scope's key; otherwise 404 (never 403 for a foreign file). `{file}` is constrained to `[0-9]+`.
|
||||
|
||||
(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).</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: An admin saves date, datetime and time values through `type: datepicker`, bounds are enforced on the server, and lists show date and time columns</name>
|
||||
<reversibility rating="reversible">Additive field type, keys, list column types and a narrower relation detection that only stops misclassifying Scanner/Valuer structs.</reversibility>
|
||||
<files>modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/field_date.go, modules/cabana/crud.go, modules/cabana/registry.go, modules/cabana/list_schema.go, modules/cabana/partial_render.go, modules/cabana/query.go, modules/cabana/openapi_conformance_test.go, modules/cabana/datepicker_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/forms.md, docs/backend/lists-and-filters.md</files>
|
||||
<read_first>modules/cabana/form_schema.go, modules/cabana/crud.go (scalarFormField, BindWritableFields, mergedRules, save), modules/cabana/list_schema.go (listColumnTypes, isListRelation, the reflection helpers around line 400), modules/cabana/model_fields.go (embeddedStructType), modules/cabana/partial_render.go (lines 340-460), modules/cabana/query.go (lines 490-520), modules/lagoon/date.go, ../examples/golem15-wintercms-starter/modules/backend/formwidgets/DatePicker.php, ../examples/golem15-wintercms-starter/modules/system/helpers/DateTime.php (momentFormat), docs/backend/forms.md, docs/backend/lists-and-filters.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pitfalls 2, 12, 13)</read_first>
|
||||
<action>Per D-18, D-19 and D-20 and RESEARCH Pitfalls 2, 12 and 13.
|
||||
|
||||
(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 "<key> 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 <field> must be a date after or equal to <min>.").
|
||||
|
||||
(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).</action>
|
||||
<verify>
|
||||
<automated>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</automated>
|
||||
<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>
|
||||
</verify>
|
||||
<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>
|
||||
<done>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.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<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>
|
||||
|
||||
<verification>
|
||||
- summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `scripts/check-admin-openapi.sh --check` clean; `scripts/check-admin-dist.sh` still clean (type-only SPA change); `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green.
|
||||
- fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1` green; no fonoteka.go file changed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `type: fileupload` uploads, 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: datepicker` compiles, 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>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["12.2-02"]
|
||||
files_modified:
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_child.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/deferred.go
|
||||
- modules/cabana/field_file.go
|
||||
- modules/cabana/schema.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/messages.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/openapi_conformance_test.go
|
||||
- modules/cabana/relation_child_smoke_test.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/relation-manager.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 300000
|
||||
raw_tokens: 300000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-13, `cabana.RelationContract` gains `Kind` (empty or `belongsToMany` keeps today's pivot behaviour exactly; `hasMany` is the new kind) and `ForeignKey` (the related model's column pointing at the parent, hasMany only); a hasMany contract with NewPivot, ParentForeignKey, RelatedForeignKey or HookPivotColumns set, a ForeignKey that is not a related-model column, or an unknown Kind stops boot; the framework still never guesses a table or column (P9 D-16)."
|
||||
- "Per D-13 (non-breaking), every existing `AdminRelationContracts()` implementation compiles and behaves unchanged: a contract without Kind is a belongsToMany with the same link, unlink, linked and candidate queries, and the fonoteka.go admin tests stay green without any fonoteka.go change."
|
||||
- "Per D-11 and RESEARCH Open Question 5, the child modal form comes from `config_relation.yaml` `manage.form` (falling back to a top-level `form`), with an optional `view.form` (same fallback), compiled at boot by the typed form pipeline with fail-loud errors naming plugin, controller and file; `$/<vendor>/<plugin>/...` paths resolve inside the same plugin and a path into another plugin stops boot."
|
||||
- "Per D-23, a relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` in a relation form stop boot; a relation form field named like the hasMany ForeignKey stops boot."
|
||||
- "Per D-12 and RESEARCH Open Question 3, view-panel `toolbarButtons` accept `create|update|delete|link|unlink`, unknown buttons stop boot, `create`/`update` without a relation form stop boot, `unlink` on a hasMany with a non-nullable ForeignKey stops boot, and each button is the capability of its routes (403 when undeclared); `update` must be listed explicitly to edit a row (a documented difference from Winter)."
|
||||
- "Per D-12, on hasMany `delete` deletes the child through its model (hooks and soft delete run) and `unlink` sets its foreign key to NULL through the model; on belongsToMany `delete` removes this parent's pivot row and then deletes the related record through its model, and `unlink` removes the pivot row (as today)."
|
||||
- "Per D-14, `pivot.form` (belongsToMany only) is compiled with `pivot[x]` names normalised to `x`; its fields must be pivot columns and must not be the pivot foreign keys, id, timestamps or HookPivotColumns; pivot input is filled through that whitelist on link (`{ids:[one id], pivot:{...}}`) and on `GET`/`PUT .../relations/{name}/pivot/{child}`; `RelationBeforeLink` still stamps the server-owned columns."
|
||||
- "Per D-15, every child, pivot and child-file endpoint loads the parent through FormExtendQuery and finds the child with one query that includes the parent predicate (hasMany: related.ForeignKey = parent key; belongsToMany: a pivot row for the parent; unsaved parent: the admin's session-key bind rows); a child of another parent, a parent hidden by FormExtendQuery and a pending child of another admin all answer 404 `not_found`, never 403."
|
||||
- "Per D-16, child create, update and delete go through the related model's Fill and the merged rules (422 envelope, P9 D-10) and its GORM hooks, and call the controller's optional `pact.RelationBefore/After{Create,Update,Delete}` hooks; a hook error rolls back and answers the opaque lifecycle error."
|
||||
- "Per D-03, child changes are immediate on a saved parent; on an unsaved parent (record id 0 with `X-Session-Key`) create inserts the child with a NULL foreign key (hasMany) or the related row (belongsToMany) and binds it with the D-22 `created` envelope, link binds with the whitelisted pivot values, unlink and delete of a pending child cancel its bind (a created child is deleted), and the linked list shows the bound rows."
|
||||
- "Per D-04, the parent's create save applies relation bindings in the same transaction as file bindings: a hasMany bind sets the foreign key through the child model, a belongsToMany bind re-runs the link eligibility (RelationExtendManageQuery, ExcludedRelatedIDs, not already linked) with the saved parent and writes the pivot row with the stored pivot values and RelationBeforeLink stamps; an ineligible bind answers 422 on the relation-manager field name and the bindings remain."
|
||||
- "Per D-17, `.../relations/{name}/records/{child}/files/{field}` (child 0 for a child not created yet) serves the same seven file operations for a fileupload field of the relation form, keyed by the `X-Child-Session-Key` header and the child's morph type; the child's create or update save commits those file bindings; if the parent is unsaved the child itself is deferred against the parent's key."
|
||||
- "RelationSchema JSON carries `kind`, `deferrable` (belongsToMany always; hasMany only with a nullable ForeignKey), the localized `manageForm`, `viewForm` and `pivotForm` fields and the new message keys; the relation-manager FormField carries `deferrable`, so the SPA knows which managers render on the create screen."
|
||||
- "Per RESEARCH Pitfall 9, boot fails when a deferrable relation that declares `create` points at a related model that no activated plugin lists in `Models()`, because `deferred:purge` could not remove its abandoned children."
|
||||
- "Edge (D-15 adoption, RESEARCH Pitfall 8): hasMany link candidates are related rows with a NULL foreign key, and rows with a live `created` bind of any session are excluded from every candidate list, so another parent cannot adopt a pending child."
|
||||
- "Edge (D-14 bulk): a link with a `pivot` object and more than one id answers 422 on `ids`; an unknown pivot key answers 422 on that key."
|
||||
artifacts:
|
||||
- path: "modules/cabana/relation.go"
|
||||
provides: "RelationContract.Kind/ForeignKey, RelationHasMany, RelationBelongsToMany, kind-aware validation, list and link/unlink queries"
|
||||
contains: "ForeignKey"
|
||||
- path: "modules/cabana/relation_child.go"
|
||||
provides: "child create/show/update/delete handlers and service, loadChild parent scoping, pivot show/update"
|
||||
contains: "loadChild"
|
||||
- path: "modules/cabana/relation_form.go"
|
||||
provides: "manage/view/pivot form compile with D-23 type rules and pivot[x] normalisation"
|
||||
contains: "pivot["
|
||||
- path: "modules/cabana/deferred.go"
|
||||
provides: "ChildSessionKeyHeader, relation binding commit"
|
||||
contains: "X-Child-Session-Key"
|
||||
key_links:
|
||||
- from: "modules/cabana/relation_child.go"
|
||||
to: "modules/cabana/crud.go"
|
||||
via: "loadRecord applies FormExtendQuery to the parent before any child query"
|
||||
pattern: "loadRecord"
|
||||
- from: "modules/cabana/deferred.go"
|
||||
to: "modules/cabana/relation.go"
|
||||
via: "commitDeferred applies relation binds through the shared link helper so eligibility and RelationBeforeLink run with the saved parent"
|
||||
pattern: "RelationBeforeLink"
|
||||
- from: "modules/cabana/field_file.go"
|
||||
to: "modules/cabana/relation_child.go"
|
||||
via: "childFileScope resolves the child with loadChild before any file operation"
|
||||
pattern: "childFileScope"
|
||||
prohibitions:
|
||||
- statement: "A child, pivot or child-file endpoint MUST NOT answer 403 for a child of another parent or a parent hidden by FormExtendQuery; it answers 404"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Pivot input MUST NOT set the pivot foreign keys, id, timestamps, deleted_at or HookPivotColumns; only pivot.form fields are filled from the request"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A request body MUST NOT set a hasMany child's foreign key; the server sets it from the scoped parent"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "An existing AdminRelationContracts implementation MUST NOT need a code change; fonoteka.go is not edited"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Framework READMEs and docs MUST NOT name a consuming application"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## 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 for relation child CRUD on hasMany and belongsToMany (with pivot), every child endpoint scoped to its parent (success criterion 3), and deferral of child changes on an unsaved parent committed in the parent's create transaction (the relation half of criterion 4).
|
||||
|
||||
<objective>
|
||||
Extend cabana's relation manager (summercms.go) with the hasMany contract kind, relation forms (`manage.form`, `view.form`, `pivot.form`), the Winter toolbar buttons, child create/show/update/delete and pivot routes, child file routes, unsaved-parent deferral and the relation commit; regenerate OpenAPI and TS types; update the cabana README and `docs/backend/relation-manager.md`.
|
||||
|
||||
Purpose: plan 04 builds the child, pivot and create-screen UI on these routes; plan 05 adds the full D-15 security suite.
|
||||
Output: relation contract kind, forms, routes, scoping, deferral, commit, OpenAPI, smoke tests, docs, message keys.
|
||||
|
||||
Repo: summercms.go only (fonoteka.go verified, never edited). Neutral names in code, tests and docs. Code and planning docs in separate commits; no co-author tags.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-PATTERNS.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md
|
||||
@modules/cabana/relation.go
|
||||
|
||||
<interfaces>
|
||||
- cabana relation (today): `RelationContract{Name; NewRelated func() any; NewPivot func() any; ParentForeignKey; RelatedForeignKey; Columns map[string]string; HookPivotColumns []string; ExcludedRelatedIDs func(parent any) ([]uint, error)}`, `AdminRelationContractProvider`, `RelationSchema{Name, Label, View, Manage RelationPanel, Messages}` (custom MarshalJSON, `Localize`), `RelationPanel{List, ToolbarButtons, ShowSearch}`, `CompiledRelation{Schema, Contract, RequiredPermissions}`, `relationDocument`/`relationPanelDocument` (decodeStrict, so new YAML keys need struct fields), `compileRelations(pluginID, ctl, fsys, form)`, `compileRelationButtons(raw, view)` (link|unlink only; manage panel cannot declare unlink), `validateRelationContract(ctl, contract)`, `protectedPivotColumn(col, contract)`, `RelationService{Linked, Candidates, Link, Unlink}`, `relationBaseQuery(ctx, tx, cc, cr, parent, candidates)`, `pendingRelationIDs`, `RelationMutationInput{IDs []any}` (decoded with DisallowUnknownFields), `RelationMutationResult{Linked, Removed}`, `relationInvalid(field, msg)`, `relationOf(cc, name)`, `setModelColumn`, `relationMutation` (toolbar capability check, 403).
|
||||
- cabana other: `FieldRelationContract.Kind` uses "belongsTo"/"belongsToMany" (constants `relationKindBelongsTo`, `relationKindBelongsToMany`); `uintLike(t)`, `structFieldByColumn(model, column)`, `modelColumns(model)`, `BindWritableFields`, `ProjectWritableFields`, `mergedRules`, `projectFullRecord`, `loadRecord`, `newWritableModel`, `lifecycleFailure`, `RecordEnvelope`, `BulkResult`, `assetPath(pluginID, ref)` (handles `~/plugins/<vendor>/<plugin>/` and plugin-relative only), `identifier(s)`, `decodeFields(raw)`, `relationMessageKeys`/`RelationMessages`/`relationMessageDefaults` (messages.go), `validateMessageKeys`.
|
||||
- From plan 02: `SessionKeyHeader`, `sessionKeyFrom(r)`, `fileScope`, `parentFileScope`, the file handlers taking a resolved scope, `compiledFile`, `compiledDate`, `commitDeferred(ctx, tx, cc, target, op, in)`, `FormField.Multiple/Protected` and the fileupload/datepicker keys.
|
||||
- From plan 01: `lagoon.DeferredKey`, `DeferredBind`, `DeferredUnbind`, `DeferredBindings`, `DeferredForget`, `DeferredSlaves`, `DeferredEnvelope{Created, Pivot}`, `DeferredBinding` (model, table deferred_bindings, PivotData *string), `MorphType`; `pact.RelationBeforeCreate` .. `RelationAfterDelete` (`(ctx, relation string, parent, child any) error`), `pact.HasModels`.
|
||||
- Winter sources: `../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php` (onRelationManageCreate/Update/Delete, onRelationManagePivotCreate/Update, makeConfigForMode, deferred handling), `vendor/winter/storm/src/Database/Relations/Concerns/DeferOneOrMany.php`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `RelationContract.Kind`, `RelationContract.ForeignKey`, constants `RelationHasMany = "hasMany"`, `RelationBelongsToMany = "belongsToMany"`.
|
||||
- `RelationSchema` JSON keys `kind`, `deferrable`, `manageForm`, `viewForm`, `pivotForm` (localized field lists); `RelationPanel` keeps its shape; `FormField.Deferrable` (`deferrable`, relation-manager fields).
|
||||
- `RelationMutationInput.Pivot map[string]any` (`pivot`), `AdminRelationLinkRequest` (OpenAPI body), `ChildSessionKeyHeader = "X-Child-Session-Key"`.
|
||||
- `RelationMessages` / config_relation.yaml `messages` keys: create, createTitle, updateTitle, previewTitle, created, updated, deleteSelected, deleteConfirm, deleteOneConfirm, deleted, pivotTitle, pivotSaved, editPivot, createSubmit, updateSubmit, pivotSubmit, linkSubmit, with defaults `backend::lang.messages.relation.<snake_case>` in en and pl.
|
||||
- config_relation.yaml keys: top-level `form`, `view.form`, `manage.form`, `pivot.form`; toolbarButtons `create|update|delete|link|unlink`.
|
||||
- Routes (prefix-relative, backend guard, writes under requireAjax): `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records`, `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}`, `PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}`, `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete`, `GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}`, `PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/pivot/{child}`, and the seven child file routes under `/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}` (list, upload, `/{file}` update and delete, `/reorder`, `/{file}/download`, `/{file}/thumb`); swag doc funcs `AdminRelationChildCreate`, `AdminRelationChildShow`, `AdminRelationChildUpdate`, `AdminRelationChildDelete`, `AdminRelationPivotShow`, `AdminRelationPivotUpdate`, `AdminRelationChildFileList`, `AdminRelationChildFileUpload`, `AdminRelationChildFileUpdate`, `AdminRelationChildFileRemove`, `AdminRelationChildFileReorder`, `AdminRelationChildFileDownload`, `AdminRelationChildFileThumb`.
|
||||
- Service methods: `RelationService.CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`, `ShowPivot`, `UpdatePivot`; internal `loadChild`, `childFileScope`, `compileRelationForm`.
|
||||
|
||||
## Assumption-delta decision
|
||||
|
||||
<assumption_delta_decision>
|
||||
Detector fired (pluralization): `RelationContract` moves from one shape (belongsToMany with a pivot) to two kinds (belongsToMany with a pivot, hasMany with a foreign key). Primary noun: the relation kind. Decision: promote. A `Kind` discriminator becomes the primary attribute of the contract and the pivot fields (NewPivot, ParentForeignKey, RelatedForeignKey, HookPivotColumns) become the detail of the belongsToMany variant, with `ForeignKey` the detail of the hasMany variant. Constraint carried with the decision: the zero value of Kind means belongsToMany, so every existing `AdminRelationContracts()` implementation, including the controllers of the shared core plugins in host applications, compiles and behaves exactly as before (core plugin contracts must not break). Rationale: Winter's RelationController is kind-driven, and a discriminator keeps validation, queries and docs branching on one field instead of inferring the kind from which fields happen to be set.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Planner assumptions recorded for this plan
|
||||
|
||||
- RESEARCH Open Question 3: `update` must be listed explicitly to edit a child row (Winter opens the update form on row click regardless); documented as a difference.
|
||||
- RESEARCH Open Question 5: top-level `form` is accepted as Winter's fallback for both `manage.form` and `view.form`.
|
||||
- RESEARCH Open Question 4: no cancel endpoint; abandoned bindings are purged.
|
||||
- Delete of several children is one route, `POST .../relations/{name}/delete` with `{ids}` (all ids must be children of the parent or the whole request is 404 and nothing is deleted); the child modal's delete uses it with one id.
|
||||
- belongsToMany child create writes the related row and its pivot row (RelationBeforeLink stamps, no pivot form values); pivot values are edited through the pivot route afterwards.
|
||||
- The child form's file uploads use their own key in `X-Child-Session-Key` (discretion: transport of the session key), so a child modal on an unsaved parent carries both keys.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: An admin creates a hasMany child of a saved record from the relation manager through the related model's own form</name>
|
||||
<reversibility rating="costly">RelationContract is a public, cross-module contract implemented by host applications; the change is additive and its zero value keeps today's behaviour, so it is flagged, not gated.</reversibility>
|
||||
<files>modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/relation_form.go, modules/cabana/schema.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/contracts.go, modules/cabana/registry.go, modules/cabana/messages.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md</files>
|
||||
<read_first>modules/cabana/relation.go (whole file), modules/cabana/relation_field.go (Kind constants, uintLike, structFieldByColumn), modules/cabana/crud.go (save, BindWritableFields, ProjectWritableFields, mergedRules, loadRecord, projectFullRecord), modules/cabana/schema.go (assetPath), modules/cabana/form_schema.go (decodeFields, compileFieldNode), modules/cabana/messages.go, modules/cabana/http.go (mount, relationMutation), modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go (conformFS config_relation.yaml, conformMember, conformGadgetMember), modules/pact/capabilities.go (Relation hooks from plan 01), modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml (messages.relation), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Copywriting Contract: messages.relation keys with EN/PL text), ../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php, ../fonoteka.go/plugins/golem15/fonoteka/controllers/collections_admin_controller.go (an existing contract that must keep working), docs/backend/relation-manager.md, modules/cabana/README.md</read_first>
|
||||
<action>Per D-11, D-12, D-13, D-15, D-16 and D-23, wire one hasMany child create end to end on a saved parent.
|
||||
|
||||
(1) Contract (D-13): add `Kind string` and `ForeignKey string` to RelationContract (document both; zero Kind means belongsToMany) and exported constants `RelationHasMany` and `RelationBelongsToMany`. `validateRelationContract` branches on the kind: belongsToMany keeps every current check and requires ForeignKey empty; hasMany requires NewPivot nil and ParentForeignKey, RelatedForeignKey and HookPivotColumns empty, ForeignKey an identifier that is a column of the related model with a uint-like or pointer-to-uint-like Go type (`uintLike`), plus the existing Columns and owner-field checks; an unknown kind is an error. Record `deferrable` on CompiledRelation (belongsToMany: true; hasMany: ForeignKey Go type is a pointer).
|
||||
|
||||
(2) Relation forms (D-11, D-23) in modules/cabana/relation_form.go: extend `relationDocument` with top-level `Form string` and `Pivot *struct{ Form string }`, and `relationPanelDocument` with `Form string`; `compileRelationForm(pluginID, ctl, fsys, path, model any, purpose string)` reads the file through `assetPath` and `decodeFields`, refuses `relation`, `relation-manager`, `widget` and `partial` ("type <t> is not supported in a relation form (<purpose>)"), binds writable fields to the related model's columns with the same rules as BindWritableFields, runs the plan-02 datepicker Go-type and fileupload attach checks against the related model, and refuses a field named like the hasMany ForeignKey. The manage form is `manage.form` or the top-level form; the view form is `view.form` or the top-level form. Extend `assetPath` so `$/<vendor>/<plugin>/rest` resolves to `rest` when vendor/plugin is the calling plugin's id and is a boot error ("$/ path names another plugin") otherwise. CompiledRelation keeps the compiled manage form, view form, child writable fields and child compiledFile/compiledDate maps (unexported).
|
||||
|
||||
(3) Toolbar (D-12): `compileRelationButtons` accepts `create|update|delete|link|unlink` on the view panel; the manage panel keeps accepting only `link`; `create` or `update` without a manage form, and `update` or a `view.form` without any form, are boot errors; unknown or duplicate buttons stay errors.
|
||||
|
||||
(4) Linked list for hasMany: `relationBaseQuery` branches on kind: hasMany linked is `related.<ForeignKey> = parentPK` (clause.Eq with quotedIdent), candidates come in Task 2. belongsToMany is unchanged.
|
||||
|
||||
(5) Create on a saved parent: `RelationService.CreateChild(ctx, cc, relation, ownerID, in RecordInput)` and the handler `relationChildCreate` for `POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records` (requireAjax): protect, `create` in view toolbarButtons else 403, body capped at default_bytes and decoded with decodeObject; inside lagoon.Transaction load the parent with loadRecord (FormExtendQuery, 404), build the related model (it must implement lagoon.HasFillable and the Rules interface, checked at boot when create or update is declared), project the body through the child writable fields for op create, lagoon.Fill, BeforeValidate, lagoon.Validate with the child form's merged rules (422 via ValidationError), then `pact.RelationBeforeCreate` (lifecycleFailure on error), set the ForeignKey to the parent key server-side, `tx.Create`, `pact.RelationAfterCreate`; answer 201 RecordEnvelope projected with the child writable fields. Id 0 (unsaved parent) is Task 3; until then id 0 is 404 as today.
|
||||
|
||||
(6) Schema and messages: RelationSchema gains `Kind`, `Deferrable`, and the localized `ManageForm`, `ViewForm`, `PivotForm` field lists (`[]FormField`, omitempty, localized in `Localize` with the related model as DropdownOptionsProvider); the relation-manager FormField gets `Deferrable` from its compiled relation. Add the 17 message keys named in Artifacts to `relationMessageKeys` (YAML camelCase), `RelationMessages` (JSON camelCase) and `relationMessageDefaults` (`backend::lang.messages.relation.<snake_case>`), with en and pl texts copied verbatim from the UI-SPEC Copywriting Contract into lang.yaml (CLDR plural maps for `deleted`: en one/other, pl one/few/many/other); validateMessageKeys must pass.
|
||||
|
||||
(7) OpenAPI and inventories: doc func `AdminRelationChildCreate` (body `AdminRecord`, 201 RecordEnvelope, 401/403/404/422); add the route to phase09Routes and the handler to phase09ProtectedCalls; extend the conformance fixture with a hasMany `parts` relation (`conformPart` with a nullable `gadget_id`, its fields.yaml referenced as `$/acme/conform/models/part/fields.yaml`, view toolbarButtons `create`) and one conformance case; regenerate admin.json and schema.d.ts.
|
||||
|
||||
(8) Smoke test modules/cabana/relation_child_smoke_test.go `TestRelationChildSmokeCreate`: a part created under gadget A carries A's id and appears in A's linked list, not in B's; a body that names `gadget_id` cannot move it (the server sets the key).
|
||||
|
||||
(9) Docs in the same change: docs/backend/relation-manager.md (the hasMany contract, Kind and ForeignKey, relation forms and their fallbacks, `$/` paths, allowed field types, the five toolbar buttons and `update` being explicit, the child create route, the relation hooks), modules/cabana/README.md (routes, RelationContract fields, constants, message keys).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && go test ./modules/phrasebook -count=1 && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeCreate"; check-admin-openapi.sh reports stale output; a fonoteka.go admin test fails (the existing belongsToMany contract changed behaviour); a message-key validation error names a missing backend::lang.messages.relation key.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go doc ./modules/cabana RelationContract.Kind`, `go doc ./modules/cabana RelationContract.ForeignKey` and `go doc ./modules/cabana RelationHasMany` exit 0.
|
||||
- `grep -c 'relations/{name}/records' modules/cabana/security_coverage_test.go` prints at least 1 and `grep -c 'relations/{name}/records' admin/openapi/admin.json` prints at least 1.
|
||||
- `grep -c 'create_title' modules/phrasebook/backend/lang/en/lang.yaml` and `grep -c 'create_title' modules/phrasebook/backend/lang/pl/lang.yaml` each print 1.
|
||||
- `git -C ../fonoteka.go status --porcelain` prints nothing (no application change).
|
||||
- `grep -c 'hasMany' docs/backend/relation-manager.md` prints at least 1 and `grep -c 'manage.form\|manage:' docs/backend/relation-manager.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>A relation manager creates a hasMany child through the related model's own form, the server sets the foreign key, and existing belongsToMany contracts behave as before.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An admin views, edits, deletes, links and unlinks children of hasMany and belongsToMany relations and edits pivot fields, and no endpoint reaches a child of another parent</name>
|
||||
<reversibility rating="reversible">Additive routes, an optional link body key and kind-specific query branches behind the existing service.</reversibility>
|
||||
<files>modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/relation_form.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md</files>
|
||||
<read_first>modules/cabana/relation.go and modules/cabana/relation_child.go (as left by Task 1), modules/cabana/crud.go (deleteRecord, normalizeIDs, lockScoped), modules/cabana/http.go (relationMutation, decodeRelationMutation), ../examples/golem15-wintercms-starter/modules/backend/behaviors/RelationController.php (onRelationManageUpdate, onRelationManageDelete, onRelationManagePivotCreate, onRelationManagePivotUpdate, onRelationButtonUnlink), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Pattern 3, Pitfalls 10 and 15)</read_first>
|
||||
<action>Per D-12, D-14, D-15 and D-16.
|
||||
|
||||
(1) Parent scoping (D-15) in relation_child.go: `loadChild(ctx, tx, cc, cr, parent any, ownerID uint, key *lagoon.DeferredKey, childID uint, lock bool) (any, error)` finds the child with ONE query that carries the parent predicate: hasMany `related.pk = child AND related.<ForeignKey> = parentPK`; belongsToMany a JOIN on the pivot with `p.<ParentForeignKey> = parentPK AND related.pk = child` (reuse relationBaseQuery with candidates false); `FOR UPDATE` when lock; a miss is `recordNotFound{}` (404). The unsaved-parent branch (key set, ownerID 0) is added in Task 3. Every handler below loads the parent with loadRecord first.
|
||||
|
||||
(2) Show and update: `GET .../relations/{name}/records/{child}` (allowed when `update` is declared or a view form exists; projects with the manage form when update is declared, else the view form) and `PUT .../relations/{name}/records/{child}` (requireAjax, `update` declared, body capped at default_bytes): Fill through the child writable fields for op update, Validate with merged rules, `RelationBeforeUpdate`, `tx.Save`, `RelationAfterUpdate`; both answer RecordEnvelope.
|
||||
|
||||
(3) Delete (D-12): `POST .../relations/{name}/delete` with `{ids}` (requireAjax, `delete` declared, normalizeIDs): every id must pass loadChild with lock, else 404 and nothing is deleted; per child `RelationBeforeDelete`, then hasMany deletes the child through `tx.Delete(child)` (hooks, soft delete), belongsToMany deletes this parent's pivot row through the pivot model and then the related record through its model, then `RelationAfterDelete`; answer `Envelope[BulkResult]`.
|
||||
|
||||
(4) Link and unlink by kind: hasMany candidates are related rows whose ForeignKey IS NULL; for both kinds add `NOT EXISTS` over deferred_bindings for a live bind of the related morph type whose pivot_data JSON has created true (Pitfall 8), plus RelationExtendManageQuery and ExcludedRelatedIDs as today. hasMany link loads the eligible candidates FOR UPDATE and sets the ForeignKey to the parent key through the child model's Save; hasMany unlink loads the parent's children among the ids FOR UPDATE and sets the ForeignKey to NULL through Save (boot already refused unlink on a non-nullable key). belongsToMany link and unlink stay as they are, except: `RelationMutationInput` gains `Pivot map[string]any` (json `pivot,omitempty`); a pivot object requires a compiled pivot form and exactly one id (422 on ids otherwise); its keys must be pivot form fields (422 per unknown key); values fill the pivot model through the pivot form whitelist with lagoon.Fill before RelationBeforeLink runs, and the merged pivot rules are validated. Refactor Link into a shared helper that takes the transaction, the loaded parent, the ids and optional pivot values, so Task 3's commit reuses it.
|
||||
|
||||
(5) Pivot form (D-14): compile `pivot.form` for belongsToMany only (a pivot form on hasMany stops boot); normalise `pivot[x]` keys to `x` before the identifier check (accept both spellings, Pitfall 10); fields must be pivot model columns, scalar or datepicker types, and never ParentForeignKey, RelatedForeignKey, id, created_at, updated_at, deleted_at or a HookPivotColumns entry (boot error). Routes `GET` and `PUT .../relations/{name}/pivot/{child}` (PUT under requireAjax, body capped): allowed when a pivot form exists and `link` or `update` is declared (403 otherwise); load the parent, then the pivot row `WHERE ParentForeignKey = parentPK AND RelatedForeignKey = child` FOR UPDATE (miss 404); GET answers `Envelope[AdminRecord]` with the pivot form fields; PUT fills through the whitelist, validates and saves the pivot model.
|
||||
|
||||
(6) OpenAPI and inventories: doc funcs `AdminRelationChildShow`, `AdminRelationChildUpdate`, `AdminRelationChildDelete`, `AdminRelationPivotShow`, `AdminRelationPivotUpdate`, and `AdminRelationLink` documents the body as `AdminRelationLinkRequest` (`ids`, optional `pivot`); add the five routes to phase09Routes and their handlers to phase09ProtectedCalls; extend the fixture (parts toolbar `create|update|delete|link|unlink`; members gets a pivot form with `pivot[note]` and a `note` column on conformGadgetMember) and add one conformance case per route; regenerate admin.json and schema.d.ts.
|
||||
|
||||
(7) Smoke tests: `TestRelationChildSmokeScope` (gadget B's part through gadget A's routes is 404 on GET, PUT, delete and pivot; a FormExtendQuery-hidden parent is 404), `TestRelationChildSmokePivot` (link with a pivot note stores it; a `pivot` key naming the pivot foreign key is refused; RelationBeforeLink still stamps its column).
|
||||
|
||||
(8) Docs: relation-manager.md (update, delete, link and unlink per kind, pivot forms, `pivot[x]` names, the 404 rule for children of other parents), cabana README route rows.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestRelation.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeScope" and "--- PASS: TestRelationChildSmokePivot"; check-admin-openapi.sh reports stale output; a fonoteka.go admin relation test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func loadChild' modules/cabana/relation_child.go` prints 1.
|
||||
- `grep -c 'relations/{name}/pivot/{child}' modules/cabana/security_coverage_test.go` prints at least 2 and `grep -c 'relations/{name}/delete' modules/cabana/security_coverage_test.go` prints at least 1.
|
||||
- `go doc ./modules/cabana RelationMutationInput.Pivot` and `go doc ./modules/cabana AdminRelationLinkRequest` exit 0.
|
||||
- `grep -c 'pivot\[' docs/backend/relation-manager.md` prints at least 1.
|
||||
</acceptance_criteria>
|
||||
<done>Children of both relation kinds can be read, edited, deleted, linked and unlinked, pivot fields are edited through a whitelist, and every child route is parent-scoped.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: On a record that is not saved yet the relation manager creates, links, unlinks and deletes children against the session key, child forms take file uploads, and the parent's create save commits it all</name>
|
||||
<reversibility rating="reversible">Behaviour on record id 0 is new (it answered 404 before); saved-parent behaviour from Tasks 1 and 2 is unchanged.</reversibility>
|
||||
<files>modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/deferred.go, modules/cabana/field_file.go, modules/cabana/crud.go, modules/cabana/http.go, modules/cabana/registry.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/openapi_conformance_test.go, modules/cabana/relation_child_smoke_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/relation-manager.md</files>
|
||||
<read_first>modules/cabana/deferred.go and modules/cabana/field_file.go (plan 02), modules/cabana/relation_child.go and modules/cabana/relation.go (Tasks 1-2), modules/cabana/crud.go (save), modules/cabana/http.go (Activate; plugins are available there), modules/lagoon/deferred.go, ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Traits/DeferredBinding.php (commitDeferred), ../examples/golem15-wintercms-starter/vendor/winter/storm/src/Database/Relations/Concerns/DeferOneOrMany.php, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Patterns 4 and 5, Pitfalls 7, 8, 9, 14)</read_first>
|
||||
<action>Per D-03, D-04, D-15, D-17 and D-22.
|
||||
|
||||
(1) Unsaved parent (D-03): every relation route accepts record id 0 only for a deferrable relation, with a valid `X-Session-Key` and the controller's create operation declared; otherwise 404. The parent key is `lagoon.DeferredKey{key, admin id, controller morph type}` and master_field is the relation name. loadChild's unsaved branch is `CAST(related.pk AS TEXT) IN DeferredSlaves(key, relation, relatedMorph, bind=true)`. Linked list on id 0 lists exactly those rows. Create inserts the child (hasMany with a NULL ForeignKey; belongsToMany the related row) with the same Fill, Validate and hooks as Task 1 (`parent` is a fresh zero-key record) and binds it with `DeferredEnvelope{Created: true}`. Link binds each eligible candidate (eligibility checked now with the zero-key parent and again at commit) with `DeferredEnvelope{Pivot: whitelisted values}` when a pivot object is sent. Unlink and delete call DeferredUnbind for each id; a cancelled bind whose envelope has Created true deletes the child through its model (and, for delete of a linked-only pending record, the record is deleted as on a saved parent). Pivot GET/PUT on id 0 read and write the envelope's pivot values of the pending bind row (update `pivot_data` on the row selected by key, admin, master type, relation and slave id; whitelist and validate exactly as on a saved parent).
|
||||
|
||||
(2) Relation commit (D-04): commitDeferred also reads bindings whose master_field is a deferrable relation-manager relation allowed in the operation's context, and applies them in id order with the files: a hasMany bind loads the child FOR UPDATE, skips it unless its ForeignKey is NULL, and sets the key through the child model's Save; a belongsToMany bind runs the shared link helper with the saved parent and the envelope's pivot values (eligibility and RelationBeforeLink run now); an ineligible bind returns `relationInvalid(<relation-manager field name>, "contains an ineligible record")` so the save is a 422 on that field and the transaction keeps the bindings; an unbind sets the ForeignKey NULL (hasMany) or deletes the pivot row (belongsToMany) when present. Applied rows are deleted with DeferredForget.
|
||||
|
||||
(3) Child file routes (D-17): `ChildSessionKeyHeader = "X-Child-Session-Key"`; `childFileScope(ctx, tx, r, cc)` resolves the relation, the fileupload field of the manage form, and the child: child 0 needs a valid child key and `create` declared; child > 0 must pass loadChild under the parent scope (saved parent, or the parent key's binds when id is 0) and `update` declared for writes; the file key is `{child key, admin, related morph}`. Mount the seven routes under `/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}` on the plan-02 handlers with this scope. Child create and update read `X-Child-Session-Key` and commit the child's own file bindings (the plan-02 file commit run against the child model, its morph type and its compiledFile map) inside the child's transaction.
|
||||
|
||||
(4) Boot (Pitfall 9) in Activate after compileRegistry: for every deferrable relation that declares create, the related model's MorphType must match a model listed by some activated plugin's `pact.HasModels().Models()`; otherwise stop boot naming the plugin, controller, relation and type.
|
||||
|
||||
(5) OpenAPI and inventories: doc funcs for the seven child file routes (headers X-Session-Key and X-Child-Session-Key documented as optional parameters); add the routes to phase09Routes and handlers to phase09ProtectedCalls; give `conformPart` an attach relation `image` (attachOne, Public false) with a fileupload field in its form, list conformPart in the conform plugin's Models(), and add one conformance case per route; regenerate admin.json and schema.d.ts.
|
||||
|
||||
(6) Smoke tests: `TestRelationChildSmokeDeferredCreate` (on id 0 create a part and link a member with a pivot note; create the gadget with the same key; the part carries the gadget id, the member pivot row exists with the note and the RelationBeforeLink stamp, no binding row is left), `TestRelationChildSmokeDeferredRollback` (an ineligible deferred link answers 422 on the relation-manager field and the bindings remain), `TestRelationChildSmokeChildFile` (upload an image to child 0, create the child, the file is attached to the child).
|
||||
|
||||
(7) Docs: relation-manager.md (managers on the create screen: deferrable relations, the session key, commit at the first save, discard and purge, the behaviour change for existing belongsToMany managers without `context: update`, child file uploads and the two headers), cabana README (ChildSessionKeyHeader, routes).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildSmoke.*|TestFileuploadSmoke.*|TestPhase09PermissionMatrix|TestPhase09ContractInventory|TestPhase10OpenAPIConformance)$' && go test ./modules/cabana ./modules/lagoon/... -count=1 && scripts/check-admin-openapi.sh --check && scripts/check-admin-dist.sh && 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</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestRelationChildSmokeDeferredCreate", "--- PASS: TestRelationChildSmokeDeferredRollback" and "--- PASS: TestRelationChildSmokeChildFile"; check-admin-dist.sh reports "modules/boardwalk/dist is stale"; docs:build --check prints a problem line; a fonoteka.go admin test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'X-Child-Session-Key' modules/cabana/deferred.go` prints at least 1 and `go doc ./modules/cabana ChildSessionKeyHeader` exits 0.
|
||||
- `grep -c 'records/{child}/files/{field}' modules/cabana/security_coverage_test.go` prints at least 7.
|
||||
- `grep -c 'childFileScope' modules/cabana/field_file.go modules/cabana/relation_child.go | awk -F: '{s+=$2} END {print s}'` prints at least 2.
|
||||
- `grep -c 'Models()' modules/cabana/http.go modules/cabana/registry.go | awk -F: '{s+=$2} END {print s}'` prints at least 1 (purge resolvability boot check).
|
||||
- `grep -c 'create screen\|unsaved' docs/backend/relation-manager.md` prints at least 1.
|
||||
- `git -C ../fonoteka.go status --porcelain` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A record being created can gain children, links, pivot values and child files before its first save, and that save commits them in one transaction or rejects them with a 422 that keeps the pending work.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| SPA → relation child, pivot and child-file routes | Untrusted child ids, related ids, pivot keys and values, child bodies |
|
||||
| Session keys (parent and child) → deferred_bindings | Client-chosen keys select pending children and files |
|
||||
| config_relation.yaml and relation fields.yaml → boot compile | Trusted plugin config, validated fail-loud, including paths |
|
||||
| Deferred bindings → commit in the parent's save | Stored intent applied with the saved parent's authority |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.2-19 | Information Disclosure / Tampering | child of another parent (IDOR) | high | mitigate | loadChild carries the parent predicate in the same query (FK, pivot or admin's binds); miss is 404 (Task 2, Task 3); plan 05 security suite. |
|
||||
| T-12.2-20 | Tampering | mass assignment of server-owned pivot columns | high | mitigate | pivot.form whitelist; boot refuses pivot keys, timestamps and HookPivotColumns in the form; unknown pivot keys 422; RelationBeforeLink keeps stamping (Task 2). |
|
||||
| T-12.2-21 | Elevation of Privilege | undeclared child operations | high | mitigate | view toolbarButtons are the capability (403), checked before any SQL; create on id 0 also needs the controller's create operation (Tasks 1-3). |
|
||||
| T-12.2-22 | Tampering | adoption of a pending child by another parent | medium | mitigate | Candidates exclude rows with a live created bind; hasMany candidates need a NULL foreign key (Task 2). |
|
||||
| T-12.2-23 | Information Disclosure | parent hidden by FormExtendQuery | high | mitigate | Every child route loads the parent through loadRecord (FormExtendQuery) before the child query (Tasks 1-3). |
|
||||
| T-12.2-24 | Spoofing | another admin's pending children | high | mitigate | Unsaved-parent scope reads binds by key and admin id; id 0 without a valid key is 404 (Task 3). |
|
||||
| T-12.2-25 | Tampering | deferred link eligibility bypass | medium | mitigate | Commit re-runs RelationExtendManageQuery, ExcludedRelatedIDs and the already-linked check with the saved parent; 422 keeps the bindings (Task 3). |
|
||||
| T-12.2-26 | Information Disclosure | cross-plugin YAML read through `$/` | medium | mitigate | `$/` resolves only inside the calling plugin; anything else stops boot (Task 1). |
|
||||
| T-12.2-27 | Information Disclosure | relation pickers and partials inside child forms | medium | mitigate | D-23: relation, relation-manager, widget and partial stop boot in relation forms (Task 1). |
|
||||
| T-12.2-28 | Tampering | child body setting the hasMany foreign key | high | mitigate | ForeignKey is set server-side from the scoped parent; a form field with that name stops boot; ProjectWritableFields drops unknown keys (Task 1). |
|
||||
| T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package added in this plan. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- summercms.go: `go vet ./... && go test ./... -count=1` green with Docker up; `scripts/check-admin-openapi.sh --check` and `scripts/check-admin-dist.sh` clean; `go test ./cmd/summer -run TestDocsTree -count=1` and `go run ./cmd/summer docs:build --check` green.
|
||||
- fonoteka.go: `go -C ../fonoteka.go build ./... && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -run 'Admin' -count=1` green and `git -C ../fonoteka.go status --porcelain` empty.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- hasMany and belongsToMany children are created, read, updated and deleted from relation forms, with pivot editing, through D-11 to D-14 and D-16.
|
||||
- Every child, pivot and child-file endpoint is parent-scoped with 404 on a miss (D-15).
|
||||
- Deferred children, links and child files on an unsaved parent commit in the parent's create transaction (D-03, D-04, D-17, D-22).
|
||||
- Existing relation contracts are unaffected; OpenAPI, TS types, inventory, conformance, messages, README and docs updated in the same change.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,355 @@
|
||||
---
|
||||
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["12.2-03"]
|
||||
files_modified:
|
||||
- admin/src/app/sessionKey.ts
|
||||
- admin/src/app/dateFormat.ts
|
||||
- admin/src/api/files.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/fields/FileuploadField.vue
|
||||
- admin/src/components/form/fields/FileCaptionModal.vue
|
||||
- admin/src/components/form/fields/DatepickerField.vue
|
||||
- admin/src/components/list/CellValue.vue
|
||||
- admin/src/components/relation/RelationManager.vue
|
||||
- admin/src/components/relation/RelationPickerModal.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/src/components/relation/RelationPivotModal.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/tests/smoke/deferred.smoke.test.ts
|
||||
- admin/tests/fixtures/deferred.form-schema.json
|
||||
- admin/package.json
|
||||
- admin/package-lock.json
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/admin-spa.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4]
|
||||
estimate:
|
||||
tokens: 240000
|
||||
raw_tokens: 240000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-02, FormView generates one session key per mount (32 bytes from crypto.getRandomValues, base64url, 43 characters) and sends it as `X-Session-Key` on every upload, file list, file removal, deferred relation call and the final create or update save; a child modal generates its own key and sends it as `X-Child-Session-Key`; a key never appears in a URL."
|
||||
- "Per D-03 and D-09, FileuploadField uploads each chosen file at once (deferred on the server), shows it in place, and marks the form dirty on upload, removal or a cancelled pending upload; reorder and caption edits are saved immediately and do not mark the form dirty; on an update form, files uploaded in this session carry the Unsaved chip."
|
||||
- "Per D-08, the SPA pre-checks extension, maxFilesize and remaining maxFiles before sending (UX only); a file over size or of a wrong type becomes a Failed item and is never sent; files beyond maxFiles are dropped with one fileupload.too_many line; the server's 422 message is shown verbatim under the failed item."
|
||||
- "Per D-10, protected (Public false) thumbnails and downloads are fetched through the admin API with the cookie and the session-key header, rendered from object URLs and revoked on unmount; public files use their public URLs."
|
||||
- "Per D-18, `datetime` shows and edits the value in the browser time zone (parseAbsolute with getLocalTimeZone) and emits RFC 3339 UTC; `ignoreTimezone` emits the wall clock unchanged as YYYY-MM-DDTHH:MM:SSZ with no zone segment; `date` emits YYYY-MM-DD and `time` emits HH:MM:SS without conversion; an emptied optional field emits null."
|
||||
- "Per D-20 and D-21, DatepickerField is built on Reka DatePickerRoot/DatePickerCalendar and TimeFieldRoot with @internationalized/date values: firstDay maps to weekStartsOn (locale default, Monday for pl), minDate/maxDate to minValue/maxValue, yearRange clamps only without explicit bounds, twelveHour to hourCycle 12, `displayFormat` drives the read-only and trigger text only; segment order follows the admin locale; native date inputs are not used."
|
||||
- "Per D-11, D-12, D-14 and D-17, the relation manager renders the declared toolbarButtons in order with exactly one primary button, opens RelationChildModal (manageForm, or viewForm read-only) on row click per the UI-SPEC order, deletes selected children after a danger confirm, links with a pivot step (single select, then the pivotForm) when a pivot form exists, edits pivot values in RelationPivotModal, and shows datepicker and fileupload fields inside the child modal with the child's own key."
|
||||
- "Per D-03, relation managers whose field says `deferrable: true` render on the create screen with the pending note under the header, call every relation route with record id 0 and the form's X-Session-Key, and mark the form dirty; on an update form relation changes are immediate and leave the form clean; a commit-time 422 on the relation-manager field renders on the normal field error line."
|
||||
- "UI (covered): an empty DatepickerField shows the locale's placeholder segments; a read-only empty value shows a muted em dash; a 422 shows the FormField error line, a danger border and aria-invalid; an out-of-range or partly filled segment set keeps aria-invalid and the danger border and never emits a half value; a long time-zone segment truncates before the in-field buttons."
|
||||
- "UI (covered): an empty editable FileuploadField is the dropzone with default_prompt/default_prompt_many (or the field's prompt) and the limits line; read-only shows fileupload.empty; the initial list load shows a 112px skeleton bar; a failed list load shows the role=alert block with fileupload.load_failed and hides the dropzone."
|
||||
- "UI (covered): uploads render per item in place as queued, uploading (spinner, 4px progress bar, percentage), failed (server message for 422, too_large for 413, upload_failed plus Retry for network errors) or done; failed items never block Save; zero files shows only the dropzone, files show the grid or rows above it, and at maxFiles the dropzone hides and the limits line reads Files: max of max; names and captions truncate with the full value in title."
|
||||
- "UI (covered): the child modal shows 6 skeleton field blocks while loading, an alert plus Cancel on load failure, a banner with field errors and focus on the first invalid field (switching tabs) on 422, and on 404 closes, shows relation.child_gone and reloads the list; its body scrolls inside max-h with header and footer fixed; a dirty child, pivot or caption modal asks form.unsaved_confirm before closing."
|
||||
- "UI (covered): toolbar delete is disabled at zero selected, its confirm and toast use CLDR plural :count, its confirm button shows the busy state, and a failure keeps the selection with a danger toast; caption, pivot and link-step saves show form.saving with a disabled footer, 422 errors stay inside the dialog, other failures keep the input and toast form.error_generic, and a link-step 404 returns to step 1 with the list reloaded."
|
||||
- "UI (covered): list cells `type: date` and `type: time` render the stored string (time without seconds) in the datetime cell style and a muted em dash when empty, never through the global Date constructor."
|
||||
- statement: "Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected"
|
||||
verification: backstop
|
||||
- statement: "Datetime round trip in a fixed non-UTC zone shows the local wall clock and emits the UTC string; ignoreTimezone emits the wall clock unchanged"
|
||||
verification: backstop
|
||||
- statement: "Protected thumbnails are requested with X-Session-Key, rendered from an object URL and the URL is revoked on unmount"
|
||||
verification: backstop
|
||||
- statement: "Keyboard reorder: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one debounced reorder request"
|
||||
verification: backstop
|
||||
artifacts:
|
||||
- path: "admin/src/app/sessionKey.ts"
|
||||
provides: "newSessionKey, SESSION_HEADER, CHILD_SESSION_HEADER"
|
||||
contains: "getRandomValues"
|
||||
- path: "admin/src/api/files.ts"
|
||||
provides: "FileRoutes adapters for parent and child file routes, XHR upload with progress"
|
||||
contains: "XMLHttpRequest"
|
||||
- path: "admin/src/components/form/fields/FileuploadField.vue"
|
||||
provides: "fileupload control per UI-SPEC section 3"
|
||||
- path: "admin/src/components/form/fields/DatepickerField.vue"
|
||||
provides: "datepicker control per UI-SPEC section 1"
|
||||
contains: "DatePickerRoot"
|
||||
- path: "admin/src/components/relation/RelationChildModal.vue"
|
||||
provides: "child create/update/preview modal"
|
||||
- path: "admin/src/components/relation/RelationPivotModal.vue"
|
||||
provides: "pivot edit modal"
|
||||
- path: "modules/boardwalk/dist"
|
||||
provides: "rebuilt embedded SPA"
|
||||
key_links:
|
||||
- from: "admin/src/views/FormView.vue"
|
||||
to: "admin/src/app/sessionKey.ts"
|
||||
via: "one key per mount, provided through FORM_SESSION and sent on save"
|
||||
pattern: "newSessionKey"
|
||||
- from: "admin/src/components/form/fields/FileuploadField.vue"
|
||||
to: "admin/src/api/files.ts"
|
||||
via: "upload/list/remove/caption/reorder/download/thumb through the injected FileRoutes"
|
||||
pattern: "FORM_SESSION"
|
||||
- from: "admin/src/components/relation/RelationManager.vue"
|
||||
to: "admin/src/components/relation/RelationChildModal.vue"
|
||||
via: "row click and the create button open the child modal with manageForm/viewForm"
|
||||
pattern: "RelationChildModal"
|
||||
prohibitions:
|
||||
- statement: "New components MUST NOT render server or user strings (file names, captions, messages) through a raw-HTML sink; text interpolation only"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "A session key MUST NOT be sent in a URL or query string"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "DatepickerField MUST NOT use a native date input and MUST NOT construct a global Date for date-only values (D-21, Pitfall 12)"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "No npm package other than @internationalized/date@3.12.4 is added, and it is added only after the user approves it"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "No new design token, colour, font or radius is introduced (UI-SPEC); hard-coded hex values are limited to the inherited #e0b020 selected-option border"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## 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: everything the administrator sees. After it an admin can pick dates, upload, reorder, caption and remove files, and create, edit, delete, link and pivot-edit related records in modals, on new and saved records, in the embedded SPA.
|
||||
|
||||
<objective>
|
||||
Build the admin SPA (summercms.go `admin/`) side of Phase 12.2 on the routes and generated types of plans 02 and 03, following 12.2-UI-SPEC.md exactly, add the SPA strings to the backend lang catalogs, and rebuild the committed `modules/boardwalk/dist`.
|
||||
|
||||
Purpose: success criteria 1-4 become usable by an administrator; plan 05 adds the SPA unit tests and backstops.
|
||||
Output: session key helper, file route adapters, DatepickerField, FileuploadField, caption modal, relation child and pivot modals, create-screen deferral, date/time list cells, lang keys, rebuilt dist, admin-spa docs note.
|
||||
|
||||
Repo: summercms.go only. Neutral names in fixtures (acme). Code and planning docs in separate commits; no co-author tags. Every task rebuilds `modules/boardwalk/dist` with `npm --prefix admin run build` and commits it with its source so `scripts/check-admin-dist.sh` stays clean at every commit.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md
|
||||
@.planning/phases/10-admin-vue-spa/design/README.md
|
||||
@admin/src/views/FormView.vue
|
||||
@admin/src/components/relation/RelationManager.vue
|
||||
|
||||
<interfaces>
|
||||
- `admin/src/api/client.ts`: `api` (openapi-fetch typed by `paths` from schema.d.ts), the `transport` middleware sets `X-Requested-With: XMLHttpRequest` and replays once after a shared `refreshSession()` on 401; `REQUESTED_WITH`; `refreshSession(): Promise<boolean>`. openapi-fetch calls accept `headers` per request.
|
||||
- `admin/src/api/types.ts`: aliases over `components['schemas']` (FormView, FormField, RelationSchema, RecordEnvelope, AdminRecord, ErrorEnvelope, ...). Plans 02/03 add `cabana.FileItem`, `cabana.FileMutationResult`, `cabana.AdminFileCaptionRequest`, `cabana.AdminRelationLinkRequest` and new FormField/RelationSchema keys (`mode`, `displayFormat`, `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour`, `ignoreTimezone`, `fileTypes`, `mimeTypes`, `maxFilesize`, `maxFiles`, `imageWidth`, `imageHeight`, `thumbOptions`, `useCaption`, `prompt`, `multiple`, `protected`, `deferrable`; RelationSchema `kind`, `deferrable`, `manageForm`, `viewForm`, `pivotForm`, new messages keys).
|
||||
- `admin/src/components/form/control.ts`: `FieldControlProps{field, modelValue, controlId, invalid?, describedBy?, labels?, source?, recordId?}`, `controlClass(invalid)`, `controlAttributes(field)`.
|
||||
- `admin/src/components/form/formContext.ts`: injection keys `FORM_VALUES`, `FORM_PATCH`, `FORM_LOCALE`, `FORM_ASSETS`.
|
||||
- `admin/src/components/form/registry.ts`: `renderers` map, `selfLabelled`, `recordBound` (relation-manager), `valueless` (relation-manager, widget, partial), `groupLabelledTypes`, `rendererFor`, `isRegistered`, `needsRecord`, `ownsLabel`, `groupLabelled`.
|
||||
- `admin/src/components/form/FormGrid.vue` props: `fields, values, errors, labels?, source?, recordId?, idPrefix?`; emits `update(name, value)`. FormErrorBanner, FormTabs, FormField reused unchanged in modals.
|
||||
- `admin/src/views/FormView.vue`: `mode` create/update, `recordId`, `fields` computed drops `needsRecord` types on create, `dirty` from `snapshot(editablePayload(...))`, save via `api.POST/PUT`, ConfirmDialog/useConfirm dirty guard, `showToast`.
|
||||
- `admin/src/components/relation/RelationManager.vue` / `RelationPickerModal.vue`: Reka Dialog parts, generation counter for stale responses, toolbar from `schema.view.toolbarButtons`, `pathParams()` with `id: props.recordId`.
|
||||
- `admin/src/components/list/CellValue.vue`: `kind` switch (switch, datetime, text), `datetime()` uses the global Date (keep it for datetime only).
|
||||
- i18n: `t(key)`, `tc`, `message(forms, ...)` from `admin/src/app/i18n.ts`; every `backend::lang.*` literal in admin/src must resolve in pl and en (`TestPhase10SPAKeysResolve` in modules/phrasebook).
|
||||
- Reka 2.9.10 exports used: DatePickerRoot, DatePickerField, DatePickerInput, DatePickerTrigger, DatePickerContent, DatePickerCalendar, DatePickerHeader, DatePickerPrev, DatePickerHeading, DatePickerNext, DatePickerGrid, DatePickerGridHead, DatePickerGridBody, DatePickerGridRow, DatePickerHeadCell, DatePickerCell, DatePickerCellTrigger, TimeFieldRoot, TimeFieldInput, Dialog*, ProgressRoot, ProgressIndicator; `@lucide/vue` icons listed in UI-SPEC.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- `admin/src/app/sessionKey.ts`: `newSessionKey(): string`, `SESSION_HEADER = 'X-Session-Key'`, `CHILD_SESSION_HEADER = 'X-Child-Session-Key'`.
|
||||
- `admin/src/app/dateFormat.ts`: `parseFieldValue(mode, raw, ignoreTimezone)`, `emitFieldValue(mode, value, ignoreTimezone)`, `formatDisplay(value, mode, displayFormat, locale)`, `weekStart(locale, firstDay)`.
|
||||
- `admin/src/api/files.ts`: `FileRoutes` interface (list, upload, update, remove, reorder, downloadUrl/download, thumb) with `parentFileRoutes(...)` and `childFileRoutes(...)`, `uploadWithProgress(...)` on XMLHttpRequest.
|
||||
- `admin/src/components/form/formContext.ts`: `FORM_SESSION` injection key and `FormSession` type (key, child key, file routes factory, record id with 0 for unsaved, `markDirty()`, `pendingChanges` counter).
|
||||
- Components: `DatepickerField.vue`, `FileuploadField.vue`, `FileCaptionModal.vue`, `RelationChildModal.vue`, `RelationPivotModal.vue`; registry entries `datepicker`, `fileupload`.
|
||||
- Lang keys (en, pl): `backend::lang.datepicker.*`, `backend::lang.fileupload.*`, `backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note}` with UI-SPEC copy.
|
||||
- Direct npm dependency `@internationalized/date` 3.12.4 (exact pin, after approval).
|
||||
|
||||
## Planner assumptions recorded for this plan
|
||||
|
||||
- Upload progress needs XMLHttpRequest (fetch has no upload progress); the XHR sends `X-Requested-With`, `X-Session-Key` (and the child key in a child modal), uses same-origin credentials, and on a 401 calls `refreshSession()` once and retries.
|
||||
- The caption dialog is a sibling component, `FileCaptionModal.vue` (UI-SPEC planner's choice).
|
||||
- Manual checks (keyboard and a11y of the calendar, drag reorder and progress feel) are human-check items harvested at the end of the phase (`workflow.human_verify_mode` default end-of-phase).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: On a new record the admin drops an image into a fileupload field, sees it upload, saves, and the saved record shows the file</name>
|
||||
<reversibility rating="reversible">SPA-only code behind the existing build; the header names match plan 02/03 constants.</reversibility>
|
||||
<files>admin/src/app/sessionKey.ts, admin/src/api/files.ts, admin/src/api/types.ts, admin/src/components/form/formContext.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/FileuploadField.vue, admin/src/components/form/fields/FileCaptionModal.vue, admin/src/views/FormView.vue, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist, docs/backend/admin-spa.md</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Copywriting Contract; Component Contracts sections 3 and 5; UI Considerations), admin/src/api/client.ts, admin/src/api/types.ts, admin/src/api/schema.d.ts (FileItem and the /files paths), admin/src/components/form/control.ts, admin/src/components/form/formContext.ts, admin/src/components/form/formState.ts, admin/src/components/form/registry.ts, admin/src/components/form/fields/DropdownField.vue, admin/src/components/form/fields/WidgetField.vue (group-labelled control), admin/src/components/relation/RelationPickerModal.vue (Dialog shell), admin/src/components/ui/Button.vue, admin/src/views/FormView.vue, admin/src/styles/main.css (tokens), admin/tests/helpers.ts, admin/tests/smoke/tracer.smoke.test.ts, modules/phrasebook/backend/lang/en/lang.yaml, docs/backend/admin-spa.md</read_first>
|
||||
<action>Per D-02, D-03, D-08, D-09 and D-10 and UI-SPEC sections 3 and 5.
|
||||
|
||||
(1) sessionKey.ts: `newSessionKey()` fills 32 bytes with `crypto.getRandomValues` and encodes them base64url without padding (43 characters, matching the server pattern); header constants as in Artifacts.
|
||||
|
||||
(2) api/files.ts: a `FileRoutes` interface and two factories. `parentFileRoutes(source, recordId (0 when unsaved), field, sessionKey)` wraps the typed `api` calls for `GET/POST /{vendor}/{plugin}/{controller}/{id}/files/{field}`, `PUT/DELETE .../{file}`, `POST .../reorder`, `GET .../{file}/download` and `.../thumb`, always passing the `X-Session-Key` header (never a query parameter). `childFileRoutes(...)` targets the `/relations/{name}/records/{child}/files/{field}` routes and passes both headers (used in Task 4). `uploadWithProgress` posts multipart `file_data` with XMLHttpRequest (credentials same-origin, `X-Requested-With`, the session headers), reports progress events, supports abort, retries once after `refreshSession()` on 401, and resolves to the FileItem or a typed error (422 message text, 413, network). Protected thumbnail and download use `fetch` with the same headers and return a Blob for an object URL.
|
||||
|
||||
(3) formContext.ts: `FORM_SESSION` (`FormSession`: the key, an optional child key, `routes(field)` returning FileRoutes, the record id with 0 for unsaved, `markDirty()` and a reactive `pendingChanges` count). FormView creates one key on mount, provides FORM_SESSION with parentFileRoutes, adds `pendingChanges > 0` to `dirty`, and sends `X-Session-Key` on the create POST and update PUT (openapi-fetch `headers`). A 422 whose details name a fileupload or relation-manager field lands on that field through the existing fieldErrors mapping and counts in the banner.
|
||||
|
||||
(4) registry.ts and formState.ts: register `fileupload` as FileuploadField; it is valueless (never in the save body) and group-labelled (its label is a span the control's role=group points at). Leave the relation-manager rules for Task 4.
|
||||
|
||||
(5) FileuploadField.vue exactly per UI-SPEC section 3: dropzone (button, hidden file input with `accept` from fileTypes/mimeTypes or image defaults, `multiple` for attachMany, drag-over styling on the zone only), image grid tiles for attachMany image mode, the attachOne image row with Replace and Remove, file-mode rows with Download, Pencil (useCaption) and X, the Unsaved chip on update forms for `pending` items, per-item states (queued, uploading with ProgressRoot, failed with the server message, too_large, upload_failed plus retry, done), uploads one at a time in selection order, client pre-checks (extension, maxFilesize, remaining maxFiles with the single too_many line), deferred remove (no dialog; the item leaves at once, pending uploads are cancelled), protected thumbnails and downloads from object URLs revoked on unmount (public files use `url`/`thumb_url`), the 112px loading bar and the load_failed alert, read-only rendering, reorder by pointer drag and by keyboard (ArrowUp/ArrowLeft earlier, ArrowDown/ArrowRight later, focus stays on the handle, polite aria-live announcing fileupload.moved, one request debounced 400ms, rollback plus reorder_failed toast on failure). FileCaptionModal.vue per the caption modal contract (Reka Dialog, 560px, Title input and Description textarea, Cancel and details_submit, immediate save, details_saved toast, 422 inside the modal). Use only existing tokens and component classes; text interpolation only (no raw-HTML directive). Every string through `t`/`tc`/`message`.
|
||||
|
||||
(6) Lang: add every `backend::lang.fileupload.*` key of the UI-SPEC "All new keys" list to modules/phrasebook/backend/lang/en/lang.yaml and pl/lang.yaml with the UI-SPEC English and Polish texts (CLDR maps for too_many: en one/other, pl one/few/many/other).
|
||||
|
||||
(7) Smoke test admin/tests/smoke/deferred.smoke.test.ts with fixture admin/tests/fixtures/deferred.form-schema.json (an acme form with a `photos` fileupload field, image mode, attachMany): mount FormView in create mode with a mocked XHR and fetch; choose a PNG; assert the upload request targets `/files/photos` with record id 0 and carries `X-Session-Key`, the form becomes dirty, and the create POST carries the same key.
|
||||
|
||||
(8) docs/backend/admin-spa.md "How the SPA talks to the server": one paragraph on the session-key headers and deferred uploads. Rebuild with `npm --prefix admin run build` and commit modules/boardwalk/dist with the source.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found" or the summary lacks the deferred smoke file as passed; TestPhase10SPAKeysResolve prints a line ending in "does not resolve"; check-admin-dist.sh prints "modules/boardwalk/dist is stale".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'getRandomValues' admin/src/app/sessionKey.ts` prints at least 1 and `grep -c 'XMLHttpRequest' admin/src/api/files.ts` prints at least 1.
|
||||
- `grep -rc 'session_key=' admin/src | awk -F: '{s+=$2} END {print s}'` prints 0.
|
||||
- `grep -c 'v-html' admin/src/components/form/fields/FileuploadField.vue` prints 0 and `grep -c 'v-html' admin/src/components/form/fields/FileCaptionModal.vue` prints 0.
|
||||
- `grep -c "\['fileupload', FileuploadField\]" admin/src/components/form/registry.ts` prints 1.
|
||||
- `grep -c 'default_prompt_many' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
- `npm --prefix admin test -- tests/smoke/deferred` reports the smoke file passed.
|
||||
</acceptance_criteria>
|
||||
<done>An administrator can attach files to a record before its first save; the upload, the pending state and the commit at Save all work through the real API contract.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 2: Approve @internationalized/date@3.12.4 as a direct admin dependency</name>
|
||||
<decision>Add `@internationalized/date` 3.12.4 to admin/package.json as an exact-pinned direct dependency (the Phase 10 npm gate requires a checkpoint for any new package).</decision>
|
||||
<context>Reka's DatePicker and TimeField bind `DateValue`/`TimeValue` objects from @internationalized/date (D-21). The package is already in admin/node_modules as a reka-ui dependency (`"@internationalized/date":"^3.5.0"`), so declaring it adds no new code to the tree; it makes the import explicit instead of relying on hoisting. RESEARCH Package Legitimacy Audit: npm, ~19.0M weekly downloads, source github.com/adobe/react-spectrum (packages/@internationalized/date), latest 3.12.4 published 2026-09-01, `postinstall: null`, verdict [OK]. Verify at https://www.npmjs.com/package/@internationalized/date before approving. Undo cost: one dependency line and a lockfile update.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve the exact pin</name>
|
||||
<pros>Explicit, auditable dependency; matches UI-SPEC and RESEARCH; no transitive-import fragility.</pros>
|
||||
<cons>One more line in the 17-pin list.</cons>
|
||||
</option>
|
||||
<option id="transitive">
|
||||
<name>Import it transitively without declaring it</name>
|
||||
<pros>No package.json change.</pros>
|
||||
<cons>Relies on npm hoisting; a reka-ui bump could move or remove it; vue-tsc and vite resolution become accidental.</cons>
|
||||
</option>
|
||||
<option id="refuse">
|
||||
<name>Refuse</name>
|
||||
<pros>No dependency change.</pros>
|
||||
<cons>D-21 cannot be met with Reka DatePicker; the phase would need a new decision.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Standard Stack, Package Legitimacy Audit), admin/package.json, admin/node_modules/@internationalized/date/package.json</read_first>
|
||||
<acceptance_criteria>
|
||||
- The user answered with one of the option ids; the answer is recorded in the plan summary.
|
||||
- Nothing was installed before the answer (`git diff --stat HEAD -- admin/package.json` is empty when the checkpoint is presented).
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Answer approve, transitive or refuse.</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The admin picks a date, a datetime in local time or a time of day, and lists show date and time columns as stored</name>
|
||||
<reversibility rating="reversible">One SPA control, a pure helper module and a list cell branch.</reversibility>
|
||||
<precondition>The user approved option `approve` (or `transitive`) at Task 2.</precondition>
|
||||
<files>admin/package.json, admin/package-lock.json, admin/src/app/dateFormat.ts, admin/src/components/form/fields/DatepickerField.vue, admin/src/components/form/registry.ts, admin/src/components/list/CellValue.vue, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Component Contracts sections 1 and 2), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Reka UI props and the Winter key to Reka prop mapping; Pitfalls 12, 13), admin/node_modules/reka-ui/dist/index4.d.ts (DatePickerRootProps, DateFieldRootProps, TimeFieldRootProps), admin/src/components/form/control.ts, admin/src/components/form/fields/TextField.vue, admin/src/components/list/CellValue.vue, admin/src/app/i18n.ts (currentLocale)</read_first>
|
||||
<action>Per D-18, D-19, D-20 and D-21 and UI-SPEC sections 1 and 2.
|
||||
|
||||
(1) Dependency: with `approve`, run `npm --prefix admin install --save-exact @internationalized/date@3.12.4` and commit package.json and package-lock.json with this task; with `transitive`, import it without changing package.json and note that in the summary.
|
||||
|
||||
(2) dateFormat.ts (pure, unit-testable): `parseFieldValue` (date: `parseDate`; datetime: `parseAbsolute(value, getLocalTimeZone())`, or with ignoreTimezone a `CalendarDateTime` from the UTC wall clock; time: `parseTime`; null or empty gives null), `emitFieldValue` (date `YYYY-MM-DD`; datetime `toAbsoluteString()` in UTC; ignoreTimezone `YYYY-MM-DDTHH:MM:SSZ` from the wall clock; time `HH:MM:SS`; cleared gives null), `formatDisplay` (the server's `displayFormat` tokens DD, D, MM, M, YYYY, YY, HH, H, hh, h, mm, ss, A, a, day and month names in the admin locale; defaults YYYY-MM-DD, YYYY-MM-DD HH:mm, HH:mm), `weekStart(locale, firstDay)` (firstDay when set, else the locale's first day, Monday for pl). Never construct a global Date for date-only values.
|
||||
|
||||
(3) DatepickerField.vue per UI-SPEC section 1: date and datetime modes on DatePickerRoot with DatePickerField/Input segments, the time-zone segment for datetime without ignoreTimezone, the clear button (optional, has value, not read-only) and the calendar trigger (32x32, aria labels datepicker.clear and datepicker.open_calendar), the calendar popover (284px, header with prev/next buttons labelled datepicker.prev_month and next_month, day cell states, closeOnSelect, Esc returns focus to the trigger, picking a day keeps the time or sets 00:00); time mode on TimeFieldRoot with the Clock icon; mapping minDate/maxDate to minValue/maxValue, yearRange clamping without explicit bounds, twelveHour to hourCycle 12, granularity minute for datetime; `:focus-within` ring, focused segment styling, invalid and `data-invalid` danger border with aria-invalid; read-only box with formatDisplay or a muted em dash; label and aria-describedby wiring via controlId. Native date inputs are not used. Register `datepicker` in registry.ts as a value field.
|
||||
|
||||
(4) CellValue.vue: kinds `date` (string as stored) and `time` (HH:mm, seconds dropped) with the datetime cell classes and the muted em dash when empty, never through the global Date constructor.
|
||||
|
||||
(5) Lang: `backend::lang.datepicker.*` keys (open_calendar, clear, prev_month, next_month) in en and pl with the UI-SPEC texts.
|
||||
|
||||
(6) Extend the smoke fixture and test: a datepicker `starts_at` (datetime) and `released_on` (date) field; with the process time zone fixed to Europe/Warsaw, a stored `2026-10-02T10:30:00Z` shows 12:30 and saving it unchanged sends the same UTC string; the date field sends `2026-10-02`. Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh</automated>
|
||||
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale".</fails_when>
|
||||
<human-check>
|
||||
<test>Run `go run ./cmd/summer serve` against a development app with an acme form that has date, datetime and time datepicker fields; open the create screen; Tab into each field, type a value by keyboard, open the calendar with Enter, move with arrow keys, PageUp/PageDown, pick a day, press Esc.</test>
|
||||
<expected>Segments follow the admin locale, the focused segment uses the sel tint, the calendar is navigable by keyboard, Esc returns focus to the trigger, a day outside minDate/maxDate cannot be picked, the datetime shows local time with its zone segment, and the visuals match the UI-SPEC and Direction C.</expected>
|
||||
<why_human>Keyboard flow, focus management and visual fit with Direction C cannot be fully asserted by unit tests.</why_human>
|
||||
</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c '"@internationalized/date": "3.12.4"' admin/package.json` prints 1 when the user chose approve.
|
||||
- `grep -c 'DatePickerRoot' admin/src/components/form/fields/DatepickerField.vue` prints at least 1 and `grep -c 'TimeFieldRoot' admin/src/components/form/fields/DatepickerField.vue` prints at least 1.
|
||||
- `grep -c 'type="date"' admin/src/components/form/fields/DatepickerField.vue` prints 0 and `grep -c 'new Date(' admin/src/components/form/fields/DatepickerField.vue` prints 0.
|
||||
- `grep -c "'date'" admin/src/components/list/CellValue.vue` prints at least 1.
|
||||
- `grep -c 'open_calendar' modules/phrasebook/backend/lang/pl/lang.yaml` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>Date, datetime and time fields are edited with an accessible, locale-aware picker that converts only datetime values, and lists show stored dates and times without shifting them.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: The admin creates, edits, deletes, links and pivot-edits related records in modals, with uploads and dates inside them, on new and saved records</name>
|
||||
<reversibility rating="reversible">SPA components behind the existing relation manager; existing link/unlink visuals and copy are unchanged.</reversibility>
|
||||
<files>admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/relation/RelationPivotModal.vue, admin/src/components/form/registry.ts, admin/src/views/FormView.vue, admin/src/api/types.ts, admin/tests/smoke/deferred.smoke.test.ts, admin/tests/fixtures/deferred.form-schema.json, modules/phrasebook/backend/lang/en/lang.yaml, modules/phrasebook/backend/lang/pl/lang.yaml, modules/boardwalk/dist</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Component Contracts section 4; Destructive actions; Success feedback; UI Considerations rows for the child modal, toolbar, pivot and caption modals), admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationPickerModal.vue, admin/src/components/list/DataTable.vue (variant relation, selection), admin/src/components/form/FormGrid.vue, admin/src/components/form/FormTabs.vue, admin/src/components/form/FormErrorBanner.vue, admin/src/components/ui/ConfirmDialog.vue, admin/src/components/ui/confirm.ts, admin/src/views/FormView.vue, admin/src/api/schema.d.ts (relation routes, RelationSchema), admin/tests/relation/RelationManager.test.ts, admin/tests/relation/RelationPickerModal.test.ts</read_first>
|
||||
<action>Per D-03, D-11, D-12, D-14 and D-17 and UI-SPEC section 4.
|
||||
|
||||
(1) RelationManager.vue: toolbar buttons in declared order, all size sm, exactly one primary (create primary; link primary only without create; delete and unlink danger); row selection checkboxes when delete or unlink is declared; delete selected through ConfirmDialog (messages.relation.delete_selected, delete_confirm, deleted with CLDR :count) calling `POST .../relations/{name}/delete`; row click order (update: child modal in update mode; else belongsToMany with pivotForm: pivot modal; else viewForm: read-only child modal; else not clickable), the first cell a button, the trailing SlidersHorizontal pivot button when update is declared on a belongsToMany with a pivot form. On the create screen (FORM_SESSION record id 0) it uses id 0 and the X-Session-Key header on every call, shows the relation.pending_note line under the header, and calls `markDirty()` after each change; on an update form changes are immediate and the form stays clean.
|
||||
|
||||
(2) RelationChildModal.vue: Reka Dialog per the UI-SPEC child modal (720px, header with create_title/update_title/preview_title and the close button, body with FormErrorBanner, FormTabs when the child fields declare tabs, FormGrid with idPrefix `child-<relation>` over `manageForm` or `viewForm` fields), footer (danger Delete on the left in update mode when delete is declared, ghost Cancel and the primary create_submit/update_submit on the right, form.saving while busy), skeleton loading, load failure alert, 422 banner and focus on the first invalid field, 404 closes with the child_gone toast and reloads, success closes with created/updated toasts, dirty close asks form.unsaved_confirm, focus returns to the create button or the row button. It generates its own key on open and provides its own FORM_SESSION with childFileRoutes (child id 0 on create) so FileuploadField and DatepickerField work inside it; the save sends X-Session-Key (the parent form's key) and X-Child-Session-Key.
|
||||
|
||||
(3) Pivot: RelationPickerModal switches to single select with a Next step when the relation has a pivotForm (step 2 shows the chosen record and a FormGrid of pivotForm, Back keeps search, page and selection, link_submit posts `{ids:[id], pivot:{...}}`); RelationPivotModal.vue edits pivot values (560px, pivot_title, the related record's name, FormGrid, Cancel and pivot_submit, pivot_saved toast) through `GET`/`PUT .../relations/{name}/pivot/{child}`.
|
||||
|
||||
(4) FormView and registry: on create, keep relation-manager fields whose `deferrable` is true (others are dropped as today); everything else of the existing needsRecord rule stays.
|
||||
|
||||
(5) Lang: `backend::lang.relation.{child_load_failed, child_gone, next, back, pending_note}` in en and pl with the UI-SPEC texts. Text interpolation only; existing tokens only.
|
||||
|
||||
(6) Extend the smoke test: on the create screen a deferrable hasMany manager posts `.../{0}/relations/parts/records` with X-Session-Key, the form becomes dirty, and the parent create POST carries the same key. Update existing RelationManager and RelationPickerModal tests only where the new toolbar or single-select behaviour changes their expectations (keep every existing assertion that still describes unchanged behaviour). Rebuild and commit modules/boardwalk/dist.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1 -v && scripts/check-admin-dist.sh && go test ./modules/boardwalk -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; TestPhase10SPAKeysResolve prints "does not resolve"; check-admin-dist.sh prints "is stale"; the boardwalk embed test fails.</fails_when>
|
||||
<human-check>
|
||||
<test>With the development app, open the create screen of an acme controller that has a deferrable hasMany relation manager (with a fileupload and a datepicker in its manage form) and a belongsToMany manager with a pivot form; create two children (one with an uploaded image), link a record with pivot details, upload several images to an attachMany field and drag them into a new order, then Save; reopen the saved record and edit a child, edit pivot details, delete a child.</test>
|
||||
<expected>Children, links, pivot values and files appear after the first save; upload progress and drag reorder feel smooth; each modal matches the UI-SPEC (single primary button, danger delete on the left, focus handling, toasts); nothing appears for another parent.</expected>
|
||||
<why_human>Pointer drag, progress feel and the end-to-end modal flow across saved and unsaved records need a person in the browser.</why_human>
|
||||
</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'RelationChildModal' admin/src/components/relation/RelationManager.vue` prints at least 1 and `grep -c 'RelationPivotModal' admin/src/components/relation/RelationManager.vue` prints at least 1.
|
||||
- `grep -c 'CHILD_SESSION_HEADER\|X-Child-Session-Key' admin/src/components/relation/RelationChildModal.vue admin/src/api/files.ts | awk -F: '{s+=$2} END {print s}'` prints at least 1.
|
||||
- `grep -c 'v-html' admin/src/components/relation/RelationChildModal.vue` prints 0 and `grep -c 'v-html' admin/src/components/relation/RelationPivotModal.vue` prints 0.
|
||||
- `grep -c 'deferrable' admin/src/views/FormView.vue` prints at least 1.
|
||||
- `grep -c 'pending_note' modules/phrasebook/backend/lang/en/lang.yaml` prints 1.
|
||||
</acceptance_criteria>
|
||||
<done>The relation manager offers Winter's create, update, delete, link, unlink and pivot editing in modals on new and saved records, and the administrator sees the same behaviour the server enforces.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Server data (file names, captions, messages, record values) → DOM | Untrusted strings rendered in the admin's browser |
|
||||
| SPA → admin API | Session keys and CSRF header on every write; cookie auth |
|
||||
| npm registry → admin/package.json | A dependency enters the embedded build |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.2-29 | Spoofing | session key generation | medium | mitigate | 32 bytes from crypto.getRandomValues per form mount and per child modal; never Math.random (Task 1). |
|
||||
| T-12.2-30 | Elevation of Privilege | XSS via file names, captions, server messages | high | mitigate | Text interpolation only in every new component; acceptance greps for raw-HTML sinks (Tasks 1, 4). |
|
||||
| T-12.2-31 | Information Disclosure | protected thumbnail object URLs | low | mitigate | Fetched with cookie and session header, revoked on unmount; never a public URL for protected rows (Task 1). |
|
||||
| T-12.2-32 | Tampering | CSRF on XHR uploads | high | mitigate | uploadWithProgress always sets X-Requested-With; the server's requireAjax refuses cookie writes without it (Task 1). |
|
||||
| T-12.2-33 | Information Disclosure | session key in URLs or logs | low | mitigate | Headers only; acceptance grep for a key query parameter (Task 1). |
|
||||
| T-12.2-SC | Tampering | npm install of @internationalized/date | high | mitigate | blocking-human decision checkpoint before install; exact pin 3.12.4; legitimacy audit OK in RESEARCH; package-lock integrity hash committed (Tasks 2, 3). |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `npm --prefix admin run typecheck && npm --prefix admin test` green; `go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1` green; `scripts/check-admin-dist.sh` clean after every task commit; `go test ./modules/boardwalk -count=1` green.
|
||||
- `go vet ./... && go test -short ./...` still green (the lang.yaml edits load in Go).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- DatepickerField, FileuploadField, the caption modal, the child and pivot modals and create-screen deferral match 12.2-UI-SPEC.md and the D-02, D-03, D-08 to D-12, D-14, D-17, D-18, D-20 and D-21 contracts.
|
||||
- Every new string resolves in pl and en; the embedded dist matches a fresh build; the only new npm dependency is the approved exact pin.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
phase: 12.2-admin-form-fields-date-file-upload-relation-editing-with-def
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 5
|
||||
depends_on: ["12.2-04"]
|
||||
files_modified:
|
||||
- modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml
|
||||
- modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml
|
||||
- modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml
|
||||
- modules/cabana/testdata/deferred/models/gadget/fields.yaml
|
||||
- modules/cabana/testdata/deferred/models/gadget/columns.yaml
|
||||
- modules/cabana/testdata/deferred/models/part/fields.yaml
|
||||
- modules/cabana/testdata/deferred/models/member/pivot_fields.yaml
|
||||
- modules/cabana/phase122_fixture_test.go
|
||||
- modules/cabana/relation_child_scope_test.go
|
||||
- modules/cabana/protected_file_test.go
|
||||
- modules/cabana/relation_child_test.go
|
||||
- modules/cabana/fileupload_test.go
|
||||
- modules/cabana/deferred_commit_test.go
|
||||
- modules/cabana/datepicker_test.go
|
||||
- modules/lagoon/date_test.go
|
||||
- modules/lagoon/fill_test.go
|
||||
- modules/lagoon/validate_test.go
|
||||
- modules/lagoon/deferred_test.go
|
||||
- modules/lagoon/purge_test.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/guard_test.go
|
||||
- modules/conga/schedule_test.go
|
||||
- modules/pact/capabilities_test.go
|
||||
- admin/tests/app/sessionKey.test.ts
|
||||
- admin/tests/app/dateFormat.test.ts
|
||||
- admin/tests/form/DatepickerField.test.ts
|
||||
- admin/tests/form/FileuploadField.test.ts
|
||||
- admin/tests/relation/RelationChildModal.test.ts
|
||||
- admin/tests/relation/RelationPivotModal.test.ts
|
||||
- admin/tests/relation/RelationManager.test.ts
|
||||
- admin/tests/list/CellValue.test.ts
|
||||
- admin/tests/fixtures/deferred.files.json
|
||||
- admin/tests/fixtures/deferred.relation-schema.json
|
||||
- scripts/check-phase12.2.sh
|
||||
- .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-SECURITY-REVIEW.md
|
||||
- .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-VALIDATION.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, SC-5]
|
||||
estimate:
|
||||
tokens: 230000
|
||||
raw_tokens: 230000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Per D-15 (success criterion 3), a security suite through the assembled router proves that a child, pivot row or child file of another parent answers 404 `not_found` on every child route (records GET and PUT, delete, pivot GET and PUT, and the seven child file routes), that a parent hidden by FormExtendQuery answers 404, and that no such request changes a row."
|
||||
- "Per D-02 and D-15, the suite proves that record id 0 without a valid `X-Session-Key` is 404, a malformed key is 422 on `session_key`, and another admin's key finds none of the first admin's pending files or children (list empty, remove, caption, download and child routes 404, commit applies nothing)."
|
||||
- "Per D-10, the suite proves the protected download and thumb routes answer 404 for another parent's file, another admin's pending file and any `is_public=true` row, serve only jpeg, png, gif and webp inline, serve everything else (SVG and HTML included) as `application/octet-stream` with `Content-Disposition: attachment`, and always send `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and the sandbox CSP; a file list never carries `url` or `thumb_url` for a protected relation."
|
||||
- "Per D-12 and D-14, tests prove each toolbar button gates its routes (403 when undeclared), hasMany delete runs the child's hooks and soft delete and unlink nulls the foreign key, belongsToMany delete removes the pivot row then the related record, pivot input cannot set the pivot foreign keys, id, timestamps or HookPivotColumns, and RelationBeforeLink still stamps its columns."
|
||||
- "Per D-04, tests prove commit happens inside the create and update transactions after FormBefore* and before FormAfter*, a 422 (maxFiles, required fileupload, ineligible deferred link, datepicker bound) rolls back and keeps every binding, a successful save deletes exactly the applied bindings, bindings of undeclared fields or another controller's morph type are ignored, and two concurrent saves with one key apply each binding once."
|
||||
- "Per D-05 and D-22, tests prove `PurgeDeferred` and `deferred:purge --days=N` remove expired bindings, delete created children and unattached files, delete blobs only after commit, keep linked-only records and attached files, skip rows locked by a running save, report unresolvable slave types as skipped, and that the framework schedule entry exists, uses the configured time and disappears with an empty `purge_at`."
|
||||
- "Per D-07 and D-08, tests prove `attach.Store` enforces MaxBytes at the boundary, extensions, MIME patterns and the image guard (a polyglot, an SVG, a truncated image and an image over the pixel ceiling are refused; webp is accepted), sets sort_order to the id and leaves no blob behind on any failure, and that the upload route answers 413 past the request cap and 422 on the field for limit and type failures."
|
||||
- "Per D-18, D-19 and D-20, tests prove lagoon.Date and lagoon.TimeOfDay round-trip through Postgres DATE and TIME, JSON and text, Fill writes strings into every date type and pointer variant without changing earlier conversions, `required` rejects zero dates only, datepicker compile refuses unknown keys, mode and Go-type mismatches and unmapped format tokens, and minDate/maxDate are enforced on the server."
|
||||
- "Per D-11, D-13, D-16, D-17 and D-23, tests prove relation forms compile with the manage/view/top-level fallbacks and `$/` same-plugin paths, refuse relation, relation-manager, widget and partial fields and a cross-plugin path, the zero-Kind contract keeps belongsToMany behaviour, the relation hooks run in order and roll back on error, and child file uploads commit with the child's save under `X-Child-Session-Key`."
|
||||
- "The SPA unit tests cover sessionKey (length, alphabet, uniqueness), dateFormat (every mode, ignoreTimezone, displayFormat tokens, weekStart), DatepickerField, FileuploadField, RelationChildModal, RelationPivotModal, RelationManager toolbar and create-screen deferral, and CellValue date/time, including the four UI-SPEC backstops."
|
||||
- statement: "Calendar popover: Esc returns focus to the trigger and a disabled day cannot be selected"
|
||||
verification: backstop
|
||||
- statement: "Datetime round trip in a fixed non-UTC zone shows the local wall clock and emits the UTC string; ignoreTimezone emits the wall clock unchanged"
|
||||
verification: backstop
|
||||
- statement: "Protected thumbnails are requested with X-Session-Key, rendered from an object URL and the URL is revoked on unmount"
|
||||
verification: backstop
|
||||
- statement: "Keyboard reorder: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one debounced reorder request"
|
||||
verification: backstop
|
||||
- "Per success criterion 5, the docs checker (`go test ./cmd/summer -run TestDocsTree` and `go run ./cmd/summer docs:build --check`) passes, and `scripts/check-phase12.2.sh` runs the full suite and the named security tests and fails on any missing or skipped named test."
|
||||
- "The v0.1.1 tag is created and pushed only by the user at the final checkpoint; the executor never tags or pushes."
|
||||
artifacts:
|
||||
- path: "modules/cabana/relation_child_scope_test.go"
|
||||
provides: "D-15 security suite"
|
||||
contains: "TestRelationChildScope"
|
||||
- path: "modules/cabana/protected_file_test.go"
|
||||
provides: "D-10 protected file security tests"
|
||||
contains: "nosniff"
|
||||
- path: "modules/cabana/phase122_fixture_test.go"
|
||||
provides: "acme fixture plugin and assembled-router env over testdata/deferred"
|
||||
- path: "scripts/check-phase12.2.sh"
|
||||
provides: "phase gate: full suite, named security tests, drift checks, docs checker, hygiene"
|
||||
contains: "TestRelationChildScope"
|
||||
- path: ".planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-SECURITY-REVIEW.md"
|
||||
provides: "T-12.2 threat to test mapping"
|
||||
key_links:
|
||||
- from: "modules/cabana/phase122_fixture_test.go"
|
||||
to: "modules/cabana/testdata/deferred"
|
||||
via: "the fixture plugin's AdminFS is os.DirFS over testdata/deferred, assembled with surf.Assemble on the cabana Postgres harness"
|
||||
pattern: "testdata/deferred"
|
||||
- from: "scripts/check-phase12.2.sh"
|
||||
to: "modules/cabana/relation_child_scope_test.go"
|
||||
via: "the security stage runs the named tests and refuses a missing or skipped one"
|
||||
pattern: "TestRelationChildScope"
|
||||
prohibitions:
|
||||
- statement: "Test fixtures MUST NOT be registered in production code paths; the fixture plugin lives only in _test.go files and testdata"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "Tests, fixtures, READMEs and docs MUST NOT name a consuming application; neutral acme names only"
|
||||
status: resolved
|
||||
verification: test
|
||||
- statement: "The executor MUST NOT create or push the v0.1.1 tag or push master"
|
||||
status: resolved
|
||||
verification: human
|
||||
- statement: "No production source file is changed in this plan except where a test exposes a defect; such a fix is a separate fix commit named in the summary"
|
||||
status: resolved
|
||||
verification: test
|
||||
---
|
||||
|
||||
## 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: success criterion 5 (unit tests in the phase's last plan, docs checker green) and the evidence for criteria 1-4, led by the D-15 security suite; then the v0.1.1 tag checklist for the user.
|
||||
|
||||
<objective>
|
||||
Bring the Phase 12.2 code (plans 01-04) to full unit, integration and security test coverage following the RESEARCH Validation Architecture test map, add the SPA unit tests and the four UI-SPEC backstops, add a phase gate script, write the security review mapping every T-12.2 threat to a passing test, fill the VALIDATION.md task map, and hand the v0.1.1 tag to the user.
|
||||
|
||||
Purpose: project rule "unit tests are always the last plan of a phase"; the phase touches authorization scoping and file uploads, so the security review is run.
|
||||
Output: Go and SPA tests, a fixture plugin under modules/cabana/testdata/deferred, scripts/check-phase12.2.sh, 12.2-SECURITY-REVIEW.md, an updated 12.2-VALIDATION.md, and a tagging checklist.
|
||||
|
||||
Repos: summercms.go (tests, fixtures, gate script, planning docs); fonoteka.go is only exercised by the gate (build, vet, admin tests), never edited. Test code and planning docs in separate commits; a production fix found by a test is its own `fix(12.2-05)` commit. No co-author tags. Neutral acme names.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-VALIDATION.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md
|
||||
@.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
- Harnesses: `adminGorm(t)` (modules/cabana/auth_test.go: testcontainers Postgres, `lagoon.Migrate(gdb, nil)`, skipped under -short), `newConformEnv`/`conformEnv.send` and the `acme.conform` fixture (modules/cabana/openapi_conformance_test.go), `insertAdmin`, `adminTestSecret`, `adminTestPassword`; lagoon `TestMain` and `dedicatedDB(t, name)` (modules/lagoon/postgres_test.go); attach Postgres helper in modules/lagoon/attach/lifecycle_test.go; conga `schedulePlugins`, `schedulePlugin` (modules/conga/schedule_test.go); SPA `admin/tests/helpers.ts`, `admin/tests/setup.ts`, vitest 3.2.7 with happy-dom and @vue/test-utils 2.4.11.
|
||||
- The smoke tests from plans 01-04 (TestDeferredUploadPurgeTracer, TestStoreSmoke, TestDateSmoke, TestTimeOfDaySmoke, TestFillTextSmoke, TestValidateRequiredZeroDateSmoke, TestFrameworkScheduleSmoke, TestFileuploadSmoke*, TestProtectedFileSmoke, TestDatepickerSmoke*, TestRelationChildSmoke*, admin/tests/smoke/deferred.smoke.test.ts) stay; this plan adds the full tests beside them and may fold a smoke test into its full counterpart when it is strictly covered.
|
||||
- Routes, headers, types and identifiers are those listed in the "Artifacts this phase produces" sections of 12.2-01 to 12.2-04 and in their SUMMARYs (read the SUMMARYs first: names that changed during execution win).
|
||||
- Prior gate scripts to copy the shape from: scripts/check-phase10.sh (stages, named-test refusal, hygiene stage), scripts/check-phase11.1.sh (`--named`).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
## Artifacts this phase produces
|
||||
|
||||
(This plan's share.)
|
||||
|
||||
- Fixture plugin `acme.deferred` (test-only) over `modules/cabana/testdata/deferred/`: controller `acme.deferred.gadgets` with fileupload fields `photos` (attachMany, public, image) and `manual` (attachOne, protected, file), datepicker fields (date, datetime, time), a deferrable hasMany `parts` relation (nullable `gadget_id`, manage form via a `$/acme/deferred/...` path with a fileupload and a datepicker field, toolbar `create|update|delete|link|unlink`), a belongsToMany `members` relation with `pivot.form` (`pivot[note]`) and a RelationBeforeLink stamp column, FormExtendQuery hiding rows marked hidden, and every relation hook recording calls.
|
||||
- Go tests: `TestRelationChildScope*`, `TestProtectedFile*`, `TestRelationChild*`, `TestFileupload*`, `TestDeferredCommit*`, `TestDatepicker*`, `TestDatepickerBounds*`, `TestDate*`, `TestTimeOfDay*`, `TestFillText*`, `TestValidateRequiredZero*`, `TestDeferredStore*`, `TestDeferredMigrations*`, `TestPurgeDeferred*`, `TestStore*`, `TestIsAllowedImage*`, `TestFrameworkSchedule*`, `TestRelationHookInterfaces*`.
|
||||
- SPA tests: `admin/tests/app/sessionKey.test.ts`, `admin/tests/app/dateFormat.test.ts`, `admin/tests/form/DatepickerField.test.ts`, `admin/tests/form/FileuploadField.test.ts`, `admin/tests/relation/RelationChildModal.test.ts`, `admin/tests/relation/RelationPivotModal.test.ts`, extended `RelationManager.test.ts` and `CellValue.test.ts`; fixtures `deferred.files.json`, `deferred.relation-schema.json`.
|
||||
- `scripts/check-phase12.2.sh` with stages `--go`, `--security`, `--spa`, `--openapi`, `--dist`, `--docs`, `--hygiene`, `--app`, `--all` (default `--all`).
|
||||
- Planning docs: `12.2-SECURITY-REVIEW.md`, task map in `12.2-VALIDATION.md` (`nyquist_compliant: true`, `wave_0_complete: true` once every row is green).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: A child, pivot row or file of another parent, or of another admin's unsaved record, is unreachable through every admin route, proven end to end against a fixture plugin</name>
|
||||
<reversibility rating="reversible">Test-only code and testdata.</reversibility>
|
||||
<files>modules/cabana/testdata/deferred/controllers/gadgets/config_form.yaml, modules/cabana/testdata/deferred/controllers/gadgets/config_list.yaml, modules/cabana/testdata/deferred/controllers/gadgets/config_relation.yaml, modules/cabana/testdata/deferred/models/gadget/fields.yaml, modules/cabana/testdata/deferred/models/gadget/columns.yaml, modules/cabana/testdata/deferred/models/part/fields.yaml, modules/cabana/testdata/deferred/models/member/pivot_fields.yaml, modules/cabana/phase122_fixture_test.go, modules/cabana/relation_child_scope_test.go, modules/cabana/protected_file_test.go</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-02-SUMMARY.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-03-SUMMARY.md, modules/cabana/openapi_conformance_test.go (newConformEnv, conformPlugin, conformFS, send), modules/cabana/auth_test.go (adminGorm, insertAdmin), modules/cabana/relation_child.go (loadChild), modules/cabana/field_file.go (parentFileScope, childFileScope, download headers), modules/cabana/deferred.go, modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/relation_child_smoke_test.go, modules/cabana/fileupload_smoke_test.go</read_first>
|
||||
<action>Per D-02, D-10 and D-15 (success criterion 3), with RESEARCH Pattern 3 and the Security Domain table as the checklist.
|
||||
|
||||
(1) Fixture (Wave 0 gap): write the YAML tree under modules/cabana/testdata/deferred/ described in Artifacts, and modules/cabana/phase122_fixture_test.go with the test-only plugin `acme.deferred` (AdminFS = os.DirFS of testdata/deferred, Models() listing every fixture model, permissions and navigation), its models (gadget implementing attach.Owner and attach.HasRelations, part with `*uint` gadget_id and an attach relation, member, gadget-member pivot with `note` and a hook-stamped `role` column, plus the date/time columns), a controller implementing AdminRelationContractProvider (hasMany parts, belongsToMany members), FormExtendQuery (hides rows whose `hidden` column is true), RelationBeforeLink (stamps role) and the six pact relation hooks recording calls, and an env builder like newConformEnv: Postgres via adminGorm, AutoMigrate of the fixture tables only, a `mem://` bucket published with attach.Publish, two admins (A and B) with the controller permission, surf.Assemble, and helpers to send JSON and multipart requests with a bearer token and optional session headers.
|
||||
|
||||
(2) modules/cabana/relation_child_scope_test.go, `TestRelationChildScope*` table-driven over every child route from plan 03 (records GET and PUT, delete, pivot GET and PUT, the seven child file routes): with gadgets G1 and G2, a part and a member pivot of G2 requested through G1 answers 404 `not_found` and leaves the database unchanged (row counts and values compared before and after); the same through a hidden G3 (FormExtendQuery) answers 404; record id 0 without X-Session-Key answers 404 and with a malformed key 422 `session_key`; admin B using admin A's key for id 0 sees an empty linked list and gets 404 on A's pending part on GET, PUT, delete, pivot and child-file routes; an undeclared toolbar button answers 403 before any SQL (use a second fixture controller with a reduced toolbar); a pivot PUT naming `gadget_id`, `member_id`, `id`, `created_at` or `role` answers 422 and leaves the pivot unchanged.
|
||||
|
||||
(3) modules/cabana/protected_file_test.go, `TestProtectedFile*`: G2's protected file through G1's download and thumb routes is 404; admin B cannot download admin A's pending protected file on id 0; a public row on the protected routes is 404; a stored SVG and an HTML file (file mode) download as `application/octet-stream` with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store` and the sandbox CSP; a PNG is served inline as image/png with the same security headers; the file list of the protected `manual` field has no `url` or `thumb_url` keys; parent-route file ids of another gadget answer 404 on caption, remove and reorder.
|
||||
|
||||
(4) Failing-when-broken check: once the suite is green, change the parent predicate in loadChild (relation_child.go) so it is dropped, run `go test ./modules/cabana -run '^TestRelationChildScope' -count=1` and confirm it fails, then restore the file with `git checkout -- modules/cabana/relation_child.go` and confirm `git diff --quiet -- modules/cabana/relation_child.go`; record the observed failure line in the summary. Commit only test files and testdata.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/cabana -count=1 -v -run '^(TestRelationChildScope.*|TestProtectedFile.*)$' && git diff --quiet -- modules/cabana/relation_child.go modules/cabana/field_file.go</automated>
|
||||
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks a "--- PASS: TestRelationChildScope" line and a "--- PASS: TestProtectedFile" line; git diff reports the mutated production file was not restored.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func TestRelationChildScope' modules/cabana/relation_child_scope_test.go` prints at least 1 and `grep -c 'func TestProtectedFile' modules/cabana/protected_file_test.go` prints at least 1.
|
||||
- `grep -c 'records/{child}/files' modules/cabana/relation_child_scope_test.go` prints at least 1 (child file routes are in the table) and `grep -c 'nosniff' modules/cabana/protected_file_test.go` prints at least 1.
|
||||
- `grep -rliE 'fonoteka|p[lł]ytarium' modules/cabana/testdata/deferred modules/cabana/phase122_fixture_test.go modules/cabana/relation_child_scope_test.go modules/cabana/protected_file_test.go` prints nothing.
|
||||
- The summary quotes the failing line observed with the parent predicate removed.
|
||||
</acceptance_criteria>
|
||||
<done>Success criterion 3's scoping promise is proven through the real router, and the proof fails when the scoping is removed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Every Go behaviour of the phase has a test: dates, fill, required, bindings, purge, schedule, upload store and guard, file routes, commit, datepicker and relation child CRUD</name>
|
||||
<reversibility rating="reversible">Test-only code.</reversibility>
|
||||
<files>modules/lagoon/date_test.go, modules/lagoon/fill_test.go, modules/lagoon/validate_test.go, modules/lagoon/deferred_test.go, modules/lagoon/purge_test.go, modules/lagoon/attach/store_test.go, modules/lagoon/attach/guard_test.go, modules/conga/schedule_test.go, modules/pact/capabilities_test.go, modules/cabana/relation_child_test.go, modules/cabana/fileupload_test.go, modules/cabana/deferred_commit_test.go, modules/cabana/datepicker_test.go</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-RESEARCH.md (Validation Architecture: Phase Requirements to Test Map; Pitfalls 1-15), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-01-SUMMARY.md, 12.2-02-SUMMARY.md, 12.2-03-SUMMARY.md, modules/lagoon/date.go, modules/lagoon/fill.go, modules/lagoon/validate.go, modules/lagoon/deferred.go, modules/lagoon/purge.go, modules/lagoon/commands.go, modules/lagoon/schedule.go, modules/lagoon/attach/store.go, modules/lagoon/attach/guard.go, modules/conga/scheduler.go, modules/cabana/field_file.go, modules/cabana/field_date.go, modules/cabana/deferred.go, modules/cabana/relation.go, modules/cabana/relation_child.go, modules/cabana/relation_form.go, modules/cabana/phase122_fixture_test.go (Task 1)</read_first>
|
||||
<action>Per D-01 to D-23, one test group per row of the RESEARCH test map, using the Task 1 fixture for every cabana test that needs the router.
|
||||
|
||||
(1) lagoon (D-01, D-02, D-05, D-19, D-22): `TestDate*`/`TestTimeOfDay*` (constructors, parse errors, JSON/text round trip, Scan from time.Time at UTC midnight and from strings, Value, zero to NULL and back through a real Postgres DATE and TIME column, pointer variants NULL); `TestFillText*` (every date type and pointer variant from strings, RFC 3339 with an offset kept as the instant, garbage is FillTypeError, a table of conversions that worked before the change still produce the same values); `TestValidateRequiredZero*` (zero time.Time, Date, TimeOfDay and non-nil pointers to zero are empty; an int 0, a false bool and a non-date struct keep their old emptiness); `TestDeferredMigrations*` (columns, NOT NULL backend_user_id, six indexes, rollback drops the table, history id summercms.deferred); `TestDeferredStore*` (bind dedupe, bind/unbind cancel returns the cancelled row, admin and master type isolation, refusal of an empty key, zero admin or empty master type, DeferredSlaves bind/unbind subqueries, envelope round trip D-22); `TestPurgeDeferred*` (cut-off boundary, created children deleted through the model with hooks and soft delete, linked-only kept, attached files kept, unattached files and blobs removed only after commit, a rolled-back purge keeps blobs, SKIP LOCKED leaves a row locked by another transaction, unresolvable slave type counted skipped, `deferred:purge --days` parsing and config default, negative days refused).
|
||||
|
||||
(2) attach (D-07, D-08): `TestStore*` (MaxBytes exactly and plus one, MaxBytes 0, extension case and validation, default lists per mode, MIME patterns with `image/*` and bare extensions per A8, content type from the sniff and from the extension fallback, sort_order equals id, disk name shape 22 hex plus extension, no client path in the key, row failure deletes the blob, Public flag stored); `TestIsAllowedImage*` (jpeg, png, gif, webp accepted; SVG, HTML, a GIF header followed by script bytes, a truncated PNG, empty input and an image over 4096 by 4096 refused).
|
||||
|
||||
(3) conga and pact: `TestFrameworkSchedule*` (entry first with id summercms.lagoon[0]:deferred:purge, purge_at parsed, empty disables, malformed is an error, nil config unchanged, forged job args for the entry skipped by runScheduled); `TestRelationHookInterfaces*` (each of the six interfaces is satisfied by a test type with the documented signature).
|
||||
|
||||
(4) cabana (D-04, D-06, D-08 to D-14, D-16, D-17, D-20, D-23): `TestFileupload*` (every compile error: unknown key, datepicker key on fileupload, image-mode fileType, thumbOptions key, missing AttachRelations or Owner, maxFiles on attachOne, maxFilesize above upload_bytes; upload 201 with pending true; 413 past the cap; 422 for size, type, MIME and image guard with the localized message; maxFiles at upload; list order and pending flags; remove of a pending upload deletes row and blob after commit; remove of an attached file is deferred; caption 403 without useCaption and saved at once with it; reorder with a wrong set 422 and the sort_order permutation; attachOne replacement at commit; public list carries url and thumb_url); `TestDeferredCommit*` (order relative to FormBeforeCreate/FormAfterCreate via recording hooks, rollback keeps bindings on maxFiles, required fileupload, ineligible deferred link and datepicker bound failures, success deletes only applied rows, undeclared field and foreign morph type ignored, update-context-only field ignored on create, two concurrent saves with one key apply once); `TestDatepicker*` and `TestDatepickerBounds*` (every compile error from plan 02, displayFormat mapping table, yearRange forms, Go-type mismatch per mode, create/update with date, datetime with offset stored in UTC, time, minDate/maxDate inclusive edges in date and datetime modes, ignoreTimezone date used for bounds, list columns date and time and the Scanner/Valuer relation-detection fix); `TestRelationChild*` (contract validation per kind including the zero-Kind belongsToMany equivalence with today's link/unlink/linked/candidates results, relation form fallbacks and `$/` paths, D-23 refusals, toolbar compile rules, create/show/update/delete for both kinds, hasMany link candidates with NULL key and the live-created exclusion, unlink nulls the key, belongsToMany delete order, pivot link with one id and pivot values, more than one id with pivot 422, pivot GET/PUT whitelist, RelationBeforeLink stamping, hook order and rollback on a hook error, deferred create/link/unlink/delete on id 0, commit of hasMany and belongsToMany binds, child file upload on child 0 committed with the child's create, `deferrable` in the schema and on the FormField, the purge-resolvability boot error).
|
||||
|
||||
(5) If a test exposes a defect in production code, fix it in a separate `fix(12.2-05)` commit with the test that proves it, and list it in the summary. Then run the whole module suites.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test ./modules/lagoon ./modules/lagoon/attach ./modules/conga ./modules/pact ./modules/cabana -count=1 -v -run '^(TestDate.*|TestTimeOfDay.*|TestFillText.*|TestValidateRequiredZero.*|TestDeferred.*|TestPurgeDeferred.*|TestStore.*|TestIsAllowedImage.*|TestFrameworkSchedule.*|TestRelationHookInterfaces.*|TestFileupload.*|TestDatepicker.*|TestRelationChild.*)$' && go test ./... -count=1</automated>
|
||||
<fails_when>Any command exits non-zero; any package line of the verbose run prints "no tests to run"; the output contains "--- FAIL" or "--- SKIP"; the verbose output lacks a "--- PASS" line for TestPurgeDeferred, TestStore, TestFrameworkSchedule, TestDeferredCommit, TestDatepickerBounds and TestRelationChild tests; the full `go test ./...` reports a FAIL line.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'func TestPurgeDeferred' modules/lagoon/purge_test.go`, `grep -c 'func TestDeferredCommit' modules/cabana/deferred_commit_test.go`, `grep -c 'func TestDatepickerBounds' modules/cabana/datepicker_test.go` and `grep -c 'func TestIsAllowedImage' modules/lagoon/attach/guard_test.go` each print at least 1.
|
||||
- `grep -c 'SKIP LOCKED\|locked' modules/lagoon/purge_test.go` prints at least 1 and `grep -c 'concurren' modules/cabana/deferred_commit_test.go` prints at least 1.
|
||||
- Every row of the RESEARCH "Phase Requirements to Test Map" names at least one test function that exists (`go test -list` output for the five packages contains each listed prefix).
|
||||
- `go test ./... -count=1` passes with Docker up.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour of success criteria 1-4 has a test that would fail if it regressed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: The SPA behaviours and UI backstops are unit-tested, one gate script proves the whole phase, and the security review maps every threat to a passing test</name>
|
||||
<reversibility rating="reversible">Tests, a gate script and planning docs.</reversibility>
|
||||
<files>admin/tests/app/sessionKey.test.ts, admin/tests/app/dateFormat.test.ts, admin/tests/form/DatepickerField.test.ts, admin/tests/form/FileuploadField.test.ts, admin/tests/relation/RelationChildModal.test.ts, admin/tests/relation/RelationPivotModal.test.ts, admin/tests/relation/RelationManager.test.ts, admin/tests/list/CellValue.test.ts, admin/tests/fixtures/deferred.files.json, admin/tests/fixtures/deferred.relation-schema.json, scripts/check-phase12.2.sh, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-SECURITY-REVIEW.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-VALIDATION.md</files>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-UI-SPEC.md (Component Contracts, UI Considerations backstop rows), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-04-SUMMARY.md, admin/src/app/sessionKey.ts, admin/src/app/dateFormat.ts, admin/src/api/files.ts, admin/src/components/form/fields/DatepickerField.vue, admin/src/components/form/fields/FileuploadField.vue, admin/src/components/form/fields/FileCaptionModal.vue, admin/src/components/relation/RelationManager.vue, admin/src/components/relation/RelationChildModal.vue, admin/src/components/relation/RelationPivotModal.vue, admin/src/components/list/CellValue.vue, admin/tests/helpers.ts, admin/tests/relation/RelationManager.test.ts, admin/tests/smoke/deferred.smoke.test.ts, scripts/check-phase10.sh, scripts/check-phase11.1.sh, .planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-SECURITY-REVIEW.md (format), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-VALIDATION.md, every 12.2-0N-PLAN.md threat_model</read_first>
|
||||
<action>Per D-02, D-09, D-10, D-17, D-18, D-20 and D-21 and success criterion 5.
|
||||
|
||||
(1) SPA unit tests with the UI-SPEC as the oracle: sessionKey (43 characters, base64url alphabet, crypto source mocked to prove it is used, two calls differ); dateFormat (each mode, ignoreTimezone, null handling, displayFormat tokens, weekStart for pl and en and an explicit firstDay); DatepickerField (backstop 1: Esc returns focus to the trigger and a day outside minDate/maxDate cannot be selected; backstop 2: with the zone fixed to a non-UTC zone the field shows the local wall clock and emits the UTC string, and ignoreTimezone emits the wall clock unchanged; read-only text and the muted em dash; clear button rules; aria-invalid on 422 and on a partly filled segment set; time mode with twelveHour); FileuploadField (dropzone and limits line; client pre-checks for size, type and maxFiles with the too_many line; queued, uploading with progress, failed and retry states; deferred remove marks the form dirty; Unsaved chip only on update forms; backstop 3: a protected thumbnail request carries X-Session-Key, renders from an object URL and the URL is revoked on unmount; backstop 4: ArrowUp/ArrowDown on a handle moves the item, keeps focus on its handle, announces fileupload.moved and sends one request after 400ms; reorder failure restores the order and toasts reorder_failed; caption modal save and 422; read-only mode; load_failed alert); RelationChildModal (create, update and preview titles and submit labels, skeleton, load failure, 422 focus with tab switching, 404 closes with child_gone and reloads, delete confirm, own key and both headers on save, dirty close confirm); RelationPivotModal and the picker pivot step (single select, Back keeps state, link with `{ids:[id], pivot}`, pivot_saved toast, 422 in the dialog); RelationManager (toolbar order and the single primary button, row-click order, pivot button, delete selected with CLDR plurals and kept selection on failure, create-screen rendering only for deferrable fields with the pending note, id 0 and the header, markDirty on create and not on update); CellValue date and time cells. Text assertions use the lang fixtures, never hard-coded copy outside them.
|
||||
|
||||
(2) scripts/check-phase12.2.sh (executable, `set -euo pipefail`, shape of check-phase10.sh): stages `--go` (`go vet ./... && go test ./... -count=1`), `--security` (runs `TestRelationChildScope`, `TestProtectedFile`, `TestDeferredCommit`, `TestFileupload` and `TestRelationChild` with -v and refuses when any named prefix has no `--- PASS` line or shows `--- SKIP`), `--spa` (`npm --prefix admin run typecheck && npm --prefix admin test`), `--openapi` (`scripts/check-admin-openapi.sh --check`), `--dist` (`scripts/check-admin-dist.sh`), `--docs` (`go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && go test ./modules/phrasebook -run '^TestPhase10SPAKeysResolve$' -count=1`), `--hygiene` (no consuming-application name in modules/cabana, modules/lagoon, modules/conga, modules/pact READMEs, docs/, admin/src or the files this phase added), `--app` (`go -C ../fonoteka.go build ./...`, `vet ./...` and `test ./plugins/golem15/fonoteka -run Admin` plus the parity schema and migrate tests), and `--all` running every stage, printing one PASS or FAIL line per stage and exiting non-zero on the first failure.
|
||||
|
||||
(3) 12.2-SECURITY-REVIEW.md in the 09-SECURITY-REVIEW.md format: one row per threat T-12.2-01 to T-12.2-36 and every T-12.2-SC row across the five plans with disposition, the mitigation as built (file and function), and the test that proves it (file and test name), each test re-run in this task; accepted threats keep their rationale; note that the review was performed by the executor when no separate security agent is available, as in Phase 08.
|
||||
|
||||
(4) 12.2-VALIDATION.md: fill the per-task verification map with the real task ids (12.2-01-T1 ...) and test commands, mark rows green, set `nyquist_compliant: true` and `wave_0_complete: true` only when every row is green; keep the manual-only table (calendar keyboard and visual fit, drag reorder and progress feel) for /gsd-verify-work.
|
||||
|
||||
(5) Commits: SPA tests and the gate script together; the two planning docs in a separate docs commit.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && go test ./cmd/summer -run TestDocsTree -count=1 && go run ./cmd/summer docs:build --check && bash scripts/check-phase12.2.sh --all</automated>
|
||||
<fails_when>Any command exits non-zero; vitest prints "FAIL" or "No test files found"; docs:build --check prints a problem line; check-phase12.2.sh prints a "FAIL" stage line or a "missing named test" message, or does not print a PASS line for every stage.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `test -x scripts/check-phase12.2.sh` succeeds and `grep -c 'TestRelationChildScope' scripts/check-phase12.2.sh` prints at least 1.
|
||||
- `grep -c 'revoke' admin/tests/form/FileuploadField.test.ts` and `grep -c 'Escape' admin/tests/form/DatepickerField.test.ts` each print at least 1.
|
||||
- `grep -c 'T-12.2-19' .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-SECURITY-REVIEW.md` prints at least 1 and the file has a row for every T-12.2 id in the five plans.
|
||||
- `grep -c 'nyquist_compliant: true' .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-VALIDATION.md` prints 1.
|
||||
- `bash scripts/check-phase12.2.sh --all` exits 0 with Docker up.
|
||||
</acceptance_criteria>
|
||||
<done>The phase has one command that proves it, the SPA and its backstops are tested, and every threat is tied to a passing test.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 4: The user tags v0.1.1 after reviewing the gate output and the security review</name>
|
||||
<action>The user reviews the gate output and the security review, runs the manual UI checks, and creates and pushes the v0.1.1 tag; the executor does not tag or push (user instruction for this phase).</action>
|
||||
<instructions>
|
||||
Already automated: Phase 12.2 is implemented and tested (datepicker and fileupload fields, relation child CRUD with pivot editing, deferred binding with commit and purge, the SPA controls), `bash scripts/check-phase12.2.sh --all` was run and its stage summary is in 12.2-05-SUMMARY.md, and 12.2-SECURITY-REVIEW.md maps every threat to a passing test. No tag was created and nothing was pushed.
|
||||
1. Read the `check-phase12.2.sh --all` stage summary in 12.2-05-SUMMARY.md (or re-run it): every stage PASS.
|
||||
2. Read 12.2-SECURITY-REVIEW.md: every high threat is mitigated with a named passing test.
|
||||
3. Run `/gsd-verify-work 12.2` for the manual UI checks harvested from plan 04 (calendar keyboard and visual fit, drag reorder and upload progress, the child and pivot modal flow) and resolve anything it reports.
|
||||
4. Note that summercms.go has no tags yet (Phase 11.2 deferred v0.1.0 to the launch step); decide whether v0.1.0 is created first on its intended commit.
|
||||
5. Create and push the tag yourself: `git tag -a v0.1.1 -m "SummerCMS v0.1.1: datepicker, fileupload, relation child CRUD with deferred binding"` on the phase head, then `git push origin master v0.1.1`.
|
||||
6. Tell the downstream project that its `TODO: requires SummerCMS change` items for the date field, the file upload field and creating/editing/deleting related records can be resolved on v0.1.1.
|
||||
</instructions>
|
||||
<verification>After "tagged": `git tag --list 'v0.1.1'` prints v0.1.1 and `git rev-parse v0.1.1^{commit}` equals the phase head; `git ls-remote --tags origin v0.1.1` lists the tag. After "defer tag": STATE.md records the pending release step.</verification>
|
||||
<read_first>.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-05-SUMMARY.md (gate output), .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-SECURITY-REVIEW.md, .planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-CONTEXT.md (Specific Ideas: tag v0.1.1)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The executor stops here and reports the checkpoint; `git tag --list 'v0.1.1'` prints nothing when the checkpoint is presented (the executor did not tag).
|
||||
- The user replies with "tagged" (and the tag exists on the phase head) or "defer tag" (recorded in STATE.md as a pending release step).
|
||||
</acceptance_criteria>
|
||||
<resume-signal>Reply "tagged" after pushing v0.1.1, or "defer tag" to record it as a pending release step.</resume-signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Test fixtures → production binary | Test-only plugin and testdata must never be compiled into or registered by an application |
|
||||
| Gate script → release | The gate decides whether the user tags a release |
|
||||
| Repository → remote | Tags and pushes publish the framework to downstream projects |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-12.2-34 | Elevation of Privilege | fixture plugin `acme.deferred` | low | mitigate | Defined only in `_test.go` files with testdata under modules/cabana/testdata; never registered through party or a generated plugin list (Task 1). |
|
||||
| T-12.2-35 | Tampering | regression of D-15 scoping or D-10 headers | high | mitigate | Named security tests in the `--security` stage refuse missing or skipped tests; the parent predicate removal is shown to fail the suite (Tasks 1, 3). |
|
||||
| T-12.2-36 | Repudiation | a release tagged without a green gate | medium | mitigate | The executor never tags or pushes; the user tags at a blocking-human checkpoint after reading the gate summary and the security review (Task 4). |
|
||||
| T-12.2-SC | Tampering | dependency installs | low | accept | No Go module or npm package is added in this plan; tests use the already pinned vitest, @vue/test-utils, happy-dom, testify and testcontainers-go. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash scripts/check-phase12.2.sh --all` exits 0 with Docker up (Go suite, named security tests, SPA, OpenAPI drift, dist drift, docs checker, hygiene, fonoteka.go build/vet/admin and parity tests).
|
||||
- `git -C ../fonoteka.go status --porcelain` is empty.
|
||||
- 12.2-SECURITY-REVIEW.md covers every T-12.2 id; 12.2-VALIDATION.md is nyquist-compliant.
|
||||
- No tag exists until the user creates it at Task 4.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Success criterion 5: the phase's code has unit, integration and security tests delivered in this last plan, and the docs checker passes.
|
||||
- The D-15 security suite proves criterion 3's parent scoping through the real router and fails when the scoping is removed.
|
||||
- The user decides and performs the v0.1.1 release.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/12.2-admin-form-fields-date-file-upload-relation-editing-with-def/12.2-05-SUMMARY.md` when done.
|
||||
</output>
|
||||
@@ -60,6 +60,11 @@ Out of scope: image cropping, nested relation managers inside a modal, Winter fi
|
||||
- **D-20:** Accepted `datepicker` keys: `mode`, `format` (display only; Winter/moment tokens are mapped to the SPA formatter), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`. Any other key is a boot error. `minDate` / `maxDate` are also validated on the server.
|
||||
- **D-21:** The SPA picker is built on Reka UI DatePicker / Calendar primitives (already in the stack, P10 D-07), styled with Direction C tokens, locale-aware, with keyboard and a11y support. Native `<input type=date>` is not used.
|
||||
|
||||
### Planning checkpoint (2026-10-02)
|
||||
- **D-22:** A child row created under deferral is marked by a framework envelope in the existing `pivot_data` column, `{"created":true,"pivot":{...}}`. No second added column, so D-01's single added column (the admin id) stands. Purge deletes slaves whose binding carries `created: true` and keeps rows that were only linked.
|
||||
- **D-23:** A child modal form (`manage.form` / `view.form`) accepts scalar field types plus `datepicker` and `fileupload`. `relation`, `relation-manager`, `widget` and `partial` are boot errors in this phase. belongsTo pickers inside child forms are deferred.
|
||||
- **D-24 [informational]:** Five sequential plans: (1) foundations in lagoon/attach/conga/pact, (2) cabana datepicker + fileupload + commit, (3) cabana relation child CRUD + deferral, (4) admin SPA, (5) unit and security tests.
|
||||
|
||||
### Claude's Discretion
|
||||
- The names of the new `cabana` routes (upload, file update/reorder/remove, child CRUD, pivot edit, protected file download), provided they sit under the existing `{prefix}/api/v1/{vendor}/{plugin}/{controller}/...` scheme, use `requireAjax` on writes, carry swag annotations, and update the admin OpenAPI document and generated TS types (P10 D-15).
|
||||
- How the session key travels: a header or a body field.
|
||||
@@ -138,6 +143,7 @@ Out of scope: image cropping, nested relation managers inside a modal, Winter fi
|
||||
|
||||
- Image cropping in fileupload (Winter's crop/resize popup): a future admin-fields phase.
|
||||
- A relation manager nested inside a child modal.
|
||||
- `relation` (belongsTo) pickers inside child modal forms (D-23).
|
||||
- Migrating fonoteka.go's hand-rolled upload code onto the new `attach` store helper: optional follow-up in the app repo.
|
||||
|
||||
### Reviewed Todos (not folded)
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
# Phase 12.2: Admin form fields (date, file upload, relation editing with deferred binding) - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-02
|
||||
**Files analyzed:** 26 (new + modified)
|
||||
**Analogs found:** 24 / 26
|
||||
|
||||
All analog paths are git-tracked sources in `summercms.go` (no mirrors).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/lagoon/date.go` (new) | model value type | transform (Scanner/Valuer/JSON/Text) | `modules/lagoon/encrypted.go` | role-match |
|
||||
| `modules/lagoon/deferred.go` (new) | model + store | CRUD | `modules/lagoon/attach/file.go` (File model, DeleteForOwner) | role-match |
|
||||
| `modules/lagoon/deferred_migrations.go` (new) | migration | batch DDL | `modules/lagoon/attach/migrations.go` | exact |
|
||||
| `modules/lagoon/migrations.go` (mod) | migration runner | batch | itself, `Migrate` lines 63-108 | exact |
|
||||
| `modules/lagoon/fill.go` (mod) | utility | transform | itself, `convertValue` lines 112-155, 172-193 | exact |
|
||||
| `modules/lagoon/validate.go` (mod) | utility | transform | itself, `isEmptyValue` line 264 | exact |
|
||||
| `modules/lagoon/commands.go` (mod, `deferred:purge`) | CLI command | batch | itself, `RuntimeCommands` `migrate` entry lines 16-30 | exact |
|
||||
| `modules/lagoon/schedule.go` (new) | config/provider | batch | `modules/conga/scheduler.go` `scheduleEntries` 63-99 + `pact.HasSchedule` | role-match |
|
||||
| `modules/lagoon/attach/store.go` (new) | service | file-I/O | `modules/lagoon/attach/thumb.go` (bucket writes) + `file.go` DeleteKeys | role-match |
|
||||
| `modules/lagoon/attach/guard.go` (new) | utility | transform | none in framework (app `classes/image_guard.go`); thumb pixel guard `attach/thumb.go:24-28` | partial |
|
||||
| `modules/lagoon/attach/relation.go` (new) | contract/interface | n/a | `attach.Owner` in `attach/file.go:17-22` | role-match |
|
||||
| `modules/cabana/form_schema.go` (mod) | config compiler | transform | itself, `compileWidgetKeys` 458-499 | exact |
|
||||
| `modules/cabana/field_date.go` (new) | config compiler | transform | `compileWidgetKeys` (form_schema.go) | role-match |
|
||||
| `modules/cabana/field_file.go` (new) | controller | file-I/O / request-response | `modules/cabana/relation.go` handlers + `Link` 625-692 | role-match |
|
||||
| `modules/cabana/deferred.go` (new) | service | CRUD (tx) | `RelationService.Link` relation.go 625-692; `CRUDService.save` crud.go 310-386 | role-match |
|
||||
| `modules/cabana/relation.go` / `relation_child.go` | service + controller | CRUD | `relation.go` Link/contract/`compileRelationButtons` 314-336 | exact |
|
||||
| `modules/cabana/crud.go` (mod: RecordInput.SessionKey, save commit, scalarFormField) | service | CRUD | itself | exact |
|
||||
| `modules/cabana/list_schema.go` (mod: `isListRelation`, `date`/`time` column types) | config compiler | transform | `model_fields.go:70-82` `embeddedStructType` | exact |
|
||||
| `modules/cabana/schema_types.go` (mod: FormField flat keys) | model (DTO) | n/a | existing `Widget`/`Action`/`Fill`/`Path` fields 197-229 | exact |
|
||||
| `modules/cabana/schema.go` (mod: `assetPath` `$/`) | utility | transform | itself 37-47 | exact |
|
||||
| `modules/cabana/http.go` (mod) | route | request-response | itself, mount 248-259 + `nestedGet` 299-313 | exact |
|
||||
| `modules/cabana/admin_openapi.go` (mod) | docs/annotations | n/a | `AdminRelationLink` 575-591 | exact |
|
||||
| `modules/pact/capabilities.go` (mod) | interface | event-driven hooks | `RelationBeforeLink` 389-392 | exact |
|
||||
| `modules/conga/scheduler.go` (mod) | scheduler | batch | itself `scheduleEntries` 63-99 | exact |
|
||||
| `admin/src/components/form/fields/DatepickerField.vue`, `FileuploadField.vue` (new) + `form/registry.ts` (mod) | component | request-response | `fields/DropdownField.vue` + `registry.ts` 10-41 | role-match |
|
||||
| `admin/src/components/relation/RelationChildModal.vue`, `RelationPivotModal.vue` (new) | component | request-response | `relation/RelationPickerModal.vue` | exact |
|
||||
| `admin/src/app/sessionKey.ts`, `admin/src/app/dateFormat.ts` (new) | utility | transform | `admin/src/app/listQuery.ts`, `winterUrl.ts` (small pure helpers) | partial |
|
||||
| Tests (last plan) | test | — | `modules/cabana/relation_test.go`, `modules/lagoon/attach/migrations`+`file_test.go`, `phase09_contract_test.go` (`TestPhase09PermissionMatrix`) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/lagoon/deferred_migrations.go` (migration)
|
||||
**Analog:** `modules/lagoon/attach/migrations.go` (whole file, 1-48)
|
||||
```go
|
||||
var Migrations = []*gormigrate.Migration{
|
||||
{
|
||||
ID: "202609180001_create_system_files",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
stmts := []string{ `CREATE TABLE system_files (...)`, `CREATE INDEX ...` }
|
||||
for _, stmt := range stmts {
|
||||
if err := tx.Exec(stmt).Error; err != nil { return err }
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP TABLE IF EXISTS system_files").Error
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
Name the var `DeferredBindingMigrations`; ID like `2026100200NN_create_deferred_bindings`; DDL per RESEARCH "Code Examples" (adds `backend_user_id INTEGER NOT NULL`, indexes on master_type/master_field/slave_type/slave_id/session_key).
|
||||
|
||||
### `modules/lagoon/migrations.go` (modify `Migrate`)
|
||||
**Analog:** lines 67-80. Insert after the `summercms.cabana` block, same shape:
|
||||
```go
|
||||
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
||||
if err != nil { return err }
|
||||
if err := admin.Migrate(); err != nil {
|
||||
return fmt.Errorf("lagoon: migrate backend admin: %w", err)
|
||||
}
|
||||
```
|
||||
Use a new history id (e.g. `"summercms.deferred"`); also wire into `RollbackLast`/status lookups that switch on framework ids.
|
||||
|
||||
### `modules/lagoon/date.go` (Date, TimeOfDay)
|
||||
**Analog:** `modules/lagoon/encrypted.go` Scan 62-93 / Value 97-102:
|
||||
```go
|
||||
func (e *Encrypted) Scan(src any) error {
|
||||
if e == nil { return fmt.Errorf("lagoon: encrypted scan on nil receiver") }
|
||||
if src == nil { e.plaintext = nil; e.set = false; return nil }
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string: raw = v
|
||||
case []byte: raw = string(v)
|
||||
default: return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src)
|
||||
}
|
||||
...
|
||||
}
|
||||
func (e Encrypted) Value() (driver.Value, error) { if !e.set { return nil, nil } ... }
|
||||
```
|
||||
Add `time.Time` source case for Date (pgx returns time.Time for DATE), plus `MarshalJSON`/`UnmarshalText`/`MarshalText`/`IsZero`. Error prefix `lagoon: date ...`.
|
||||
|
||||
### `modules/lagoon/fill.go` / `validate.go`
|
||||
Edit in place: `convertValue` (fill.go 112-155, pointer path 172-193) gets an `encoding.TextUnmarshaler` fallback only after `ConvertibleTo` fails and only for string/[]byte sources (RESEARCH Pitfall 1). `isEmptyValue` (validate.go 264-277) gets `interface{ IsZero() bool }` (Pitfall 3). Update `modules/lagoon/README.md`.
|
||||
|
||||
### `modules/lagoon/deferred.go` (model + store)
|
||||
**Analog:** `modules/lagoon/attach/file.go` — `TableName()` (line 47), `DeleteForOwner(tx, owner, ownerID, afterCommit)` (130), `DeleteKeys(ctx, bucket, keys)` (157). Store ops take `*gorm.DB` tx as first arg and defer blob deletes via `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`). Never delete blobs inside the tx.
|
||||
|
||||
### `modules/lagoon/commands.go` (+ `deferred:purge`)
|
||||
**Analog:** `RuntimeCommands` lines 16-30:
|
||||
```go
|
||||
{
|
||||
Name: "migrate",
|
||||
Description: "Run plugin migrations in dependency order",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
return withDB(ctx, app, func(gdb *gorm.DB) error {
|
||||
if err := Migrate(gdb, plugins); err != nil { return err }
|
||||
out.Success("migrations applied")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
```
|
||||
Flags follow `migrate:rollback`'s `[]bonfire.Flag{{Name, Description}}` (lines 33-37). Resolve slave types via `pact.HasModels` over `plugins` (Pitfall 9). Update the "RuntimeCommands returns ..." doc comment and README CLI section; docs checker picks up the name.
|
||||
|
||||
### `modules/lagoon/schedule.go` + `modules/conga/scheduler.go`
|
||||
**Analog:** `conga/scheduler.go` 63-99. Prepend framework entries before the plugin loop using the same compile path:
|
||||
```go
|
||||
sched, period, err := scheduleFor(sc.Cadence, loc)
|
||||
...
|
||||
out = append(out, scheduleEntry{
|
||||
id: fmt.Sprintf("%s[%d]:%s", p.ID(), i, sc.Command),
|
||||
plugin: p.ID(), index: i, cmd: sc, schedule: sched, period: period,
|
||||
})
|
||||
```
|
||||
with plugin id `"summercms.lagoon"`. `FrameworkSchedule(app) []pact.ScheduledCommand` returns `{Command: "deferred:purge", Cadence: pact.DailyAt(h, m)}`.
|
||||
|
||||
### `modules/lagoon/attach/store.go`, `guard.go`, `relation.go`
|
||||
- Body limits: copy `http.MaxBytesReader` usage from `modules/wristband/register.go:67` / `cabana/auth.go:159`; add `io.LimitReader(part, max+1)` per part.
|
||||
- Pixel ceiling: reuse `maxThumbSourcePixels` (`attach/thumb.go:24-28`).
|
||||
- Deletion of blobs/thumbs: `blobKeysFor(f)` (file.go 110) + `DeleteKeys`.
|
||||
- `relation.go`: interface beside `attach.Owner` (file.go 17-22) — `type HasRelations interface { AttachRelations() []Relation }`.
|
||||
- Guard: no framework analog — port from the application's `classes/image_guard.go` per RESEARCH Pitfall 5 (DetectContentType + image.DecodeConfig, fail closed, webp via `golang.org/x/image/webp` only if already a dependency; check go.mod).
|
||||
|
||||
### `modules/cabana/form_schema.go` + `field_date.go` + `field_file.go` (compile)
|
||||
**Analog:** `compileWidgetKeys` 458-499:
|
||||
```go
|
||||
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != "widget" {
|
||||
for _, key := range widgetKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is only valid on type: widget", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tag, err := nodeString(values["widget"])
|
||||
if err != nil || strings.TrimSpace(tag) == "" {
|
||||
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
|
||||
}
|
||||
field.Widget = tag
|
||||
...
|
||||
}
|
||||
```
|
||||
Write `compileDatepickerKeys` / `compileFileuploadKeys` identically (key lists `datepickerKeys`, `fileuploadKeys`; `mode` shared, validated per type). Register types in `formFieldTypes`, keys in `formFieldKeys` (22-42). Add `datepicker` to `scalarFormField` (`crud.go:800`). New flat `omitempty` fields on `FormField` (`schema_types.go:197-229`).
|
||||
|
||||
### `modules/cabana/deferred.go`, `relation_child.go`, `field_file.go` handlers (tx services)
|
||||
**Analog:** `RelationService.Link` (`relation.go` 625-692):
|
||||
```go
|
||||
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
parent, err := newWritableModel(cc)
|
||||
if err != nil { return err }
|
||||
if err := loadRecord(ctx, tx, cc, parent, ownerID); err != nil { return err }
|
||||
ownerPK := pkUint(parent)
|
||||
...
|
||||
q = q.Clauses(clause.Locking{Strength: "UPDATE"}).Where(clause.IN{Column: clause.Column{Table: tableName(target), Name: primaryColumn(target)}, Values: uintValues(pending)})
|
||||
...
|
||||
if hook, ok := cc.Controller.(pact.RelationBeforeLink); ok && hook != nil {
|
||||
if err := hook.RelationBeforeLink(ctx, relation, parent, related, values); err != nil {
|
||||
return lifecycleFailure(cc, err)
|
||||
}
|
||||
}
|
||||
...
|
||||
})
|
||||
```
|
||||
Rules: every child query is parent-scoped (`clause.Eq` + `quotedIdent`), miss returns `recordNotFound{}` → 404 via `writeCRUDError` (crud.go 393-410); validation errors via `relationInvalid(field, msg)`. Commit hook goes in `CRUDService.save` (crud.go 310-386) after `syncBelongsToMany` (~373), before `formAfterCreate/Update` (376-383). Admin id from `bouncer.User(ctx)`.
|
||||
|
||||
**Button compile:** extend `compileRelationButtons` (relation.go 314-336) accepted set to `create|update|delete|link|unlink`. **Contract:** add `Kind`, `ForeignKey` to `RelationContract` (27-36), branch in `validateRelationContract` (338-394).
|
||||
|
||||
### `modules/cabana/http.go` (routes)
|
||||
**Analog:** lines 248-259 and `nestedGet` 299-313:
|
||||
```go
|
||||
g.Post("/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link", requireAjax(s.relationLink))
|
||||
constrainRelation(g)
|
||||
...
|
||||
case segment == "relations":
|
||||
s.relationLinked(w, r)
|
||||
```
|
||||
Each new route: `g.<Method>(path, requireAjax(handler))` for writes, followed by its `constrain*` call; add `case segment == "files": s.fileList(w, r)` to `nestedGet`. ServeMux conflicts panic in `TestPhase09PermissionMatrix`.
|
||||
|
||||
### `modules/cabana/admin_openapi.go`
|
||||
**Analog:** `AdminRelationLink` (575-591): doc func with `@Summary`, `@Tags admin`, `@Security BackendBearer`, `@Param` per path segment, `@Success 200 {object} Envelope[T]`, `@Failure 401/403/422/404 {object} ErrorEnvelope`, `@Router ... [method]`, body `func AdminX() {}`. Uploads use `@Accept multipart/form-data` and `@Param file_data formData file true`. Regenerate via `scripts/check-admin-openapi.sh`.
|
||||
|
||||
### `modules/pact/capabilities.go`
|
||||
**Analog:** lines 389-392:
|
||||
```go
|
||||
// RelationBeforeLink optionally stamps pivot columns before a link insert.
|
||||
type RelationBeforeLink interface {
|
||||
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
|
||||
}
|
||||
```
|
||||
New `Relation{Before,After}{Create,Update,Delete}` follow the same one-method optional-interface shape; call sites type-assert `cc.Controller.(pact.X)` and wrap errors with `lifecycleFailure(cc, err)`.
|
||||
|
||||
### SPA: `DatepickerField.vue`, `FileuploadField.vue`
|
||||
**Analog:** `admin/src/components/form/fields/DropdownField.vue` 1-60:
|
||||
```ts
|
||||
import type { FormOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../control'
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: ...] }>()
|
||||
```
|
||||
Template binds `:id="controlId"`, `:aria-invalid="invalid ? 'true' : undefined"`, `:aria-describedby="describedBy || undefined"`, `:aria-required`. Register in `admin/src/components/form/registry.ts` (imports 10-20, map entries 32-41: `['datepicker', DatepickerField]`, `['fileupload', FileuploadField]`). Visuals per 12.2-UI-SPEC.md; Reka DatePicker/DateField + `@internationalized/date`.
|
||||
|
||||
### SPA: `RelationChildModal.vue`, `RelationPivotModal.vue`
|
||||
**Analog:** `admin/src/components/relation/RelationPickerModal.vue` 1-50:
|
||||
```ts
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||
import { api } from '../../api/client'
|
||||
import type { AdminRecord, ControllerParams, ListMeta, RelationSchema } from '../../api/types'
|
||||
import { message, t } from '../../app/i18n'
|
||||
import { showToast } from '../../state/useToasts'
|
||||
const props = defineProps<{ open: boolean; source: ControllerParams; recordId: number; relation: string; schema: RelationSchema }>()
|
||||
const emit = defineEmits<{ 'update:open': [open: boolean]; linked: [count: number]; closed: [] }>()
|
||||
let generation = 0 // stale-response guard
|
||||
```
|
||||
Wire into `RelationManager.vue`; remove create-screen drop in `FormView.vue:71-77` only for `deferrable` relations. Rebuild `modules/boardwalk/dist` (`scripts/check-admin-dist.sh`).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
- **Transactions:** `lagoon.Transaction` → `withTx` → `loadRecord` (FOR UPDATE, applies `pact.FormExtendQuery`; crud.go 441-461). Apply to all child/file/commit mutations.
|
||||
- **After-commit side effects:** `lagoon.AfterCommit(ctx, db, fn)` (`transaction.go:103`) + `attach.DeleteKeys`. Apply to every blob delete.
|
||||
- **Error envelope:** `recordNotFound{}` → 404, `relationInvalid(field,msg)` → 422, `lifecycleFailure(cc, err)` for hook errors; mapped by `writeCRUDError` (crud.go 393-410). Foreign child = 404, never 403.
|
||||
- **Write routes:** `requireAjax(...)` + `constrain*` after each `g.Post/Put/Delete` (http.go 248-259).
|
||||
- **Body caps:** `http.MaxBytesReader` (cabana raw routes have no surf limit — `surf/bodylimit.go:29-33`).
|
||||
- **Boot-time contract checks:** type-assert capability, fail `cabana.Activate` with descriptive error (relation.go `validateRelationContract` 338-394).
|
||||
- **Docs rule:** every exported API/config/CLI change updates module `README.md` and `docs/` (`docs/backend/relation-manager.md`) in the same change; `go test ./cmd/summer -run TestDocsTree`.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `modules/lagoon/attach/guard.go` | utility | transform | Image guard exists only in an application plugin; port per RESEARCH Pitfall 5 |
|
||||
| `admin/src/app/sessionKey.ts`, `dateFormat.ts` | utility | transform | No crypto/date helpers in SPA; use RESEARCH (crypto.getRandomValues base64url; PHP-format token table, Pitfall 13). Shape like `admin/src/app/listQuery.ts` (pure exported functions) |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** modules/lagoon, modules/lagoon/attach, modules/cabana, modules/conga, modules/pact, modules/surf, modules/wristband, admin/src/components, admin/src/app
|
||||
**Files scanned:** ~30
|
||||
**Pattern extraction date:** 2026-10-02
|
||||
@@ -532,16 +532,21 @@ pgx's `database/sql` driver reports `time.Time` as the scan type for DATE and st
|
||||
| A12 | `thumbOptions` is a mapping accepting only `mode` ∈ {auto, exact, crop, fit}; Winter's `extension` and other keys are boot errors | D-08 | Low |
|
||||
| A13 | Winter's `showWeekNumber` and `iconClass`/`attachOnUpload`/`emptyIcon` are refused (not in D-20/D-08), so Winter YAML using them fails boot with a clear error | Pitfalls | Low; consistent with P9 D-06 |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **A second bucket for protected files?**
|
||||
- What we know: one bucket today, documented as public-only (`static.go:226-237`). Winter keeps protected files on a separate disk.
|
||||
- Unclear: whether hosts serve the bucket directory directly.
|
||||
- Recommendation: no second bucket in v0.1.1. Unguessable disk names, no public URL ever emitted for protected files, and a docs warning to use `StaticHandlerPublic` or disable listing. Add an optional `storage.uploads.protected_bucket_url` later if needed.
|
||||
- RESOLVED: no second bucket in v0.1.1 (planner assumption, 12.2-01-PLAN.md).
|
||||
2. **Field types inside child forms (A6).** Recommend datepicker, fileupload and the scalar types only. Supporting `relation` pickers in child forms needs a child-scoped options route and a contract slot (`RelationContract.FieldRelations`). Ask whether the downstream needs it now.
|
||||
- RESOLVED: D-23 (user decision 2026-10-02): scalars, datepicker and fileupload only; `relation` pickers deferred.
|
||||
3. **Does `create` imply `update` for hasMany row editing (Winter parity), or must `update` be listed?** Recommendation: require `update` explicitly (the capability rule stays literal) and document the difference from Winter.
|
||||
- RESOLVED: `update` must be listed explicitly (12.2-03-PLAN.md).
|
||||
4. **Cancel endpoint.** Winter leaves abandoned bindings to `cleanUp`. Recommendation: no cancel endpoint in v0.1.1; purge handles it. Optionally add a `keepalive` fetch on route leave later.
|
||||
- RESOLVED: no cancel endpoint; purge handles abandoned bindings (12.2-03-PLAN.md).
|
||||
5. **Top-level `form:` in config_relation.yaml.** Winter falls back from `manage.form` to a top-level `form` (`makeConfigForMode`). Recommendation: accept the top-level `form` as Winter's fallback for both panels.
|
||||
- RESOLVED: top-level `form` accepted as the fallback for `manage.form` and `view.form` (12.2-03-PLAN.md).
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: the phase extends SummerCMS's own admin API, form schema and storage; no third-party service is integrated.
|
||||
@@ -68,15 +68,10 @@
|
||||
"name": "summercms.io Alpha 0.1 landing page on SummerCMS",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"number": "11.3",
|
||||
"name": "Newsletter plugin and signup on summercms.io",
|
||||
"status": "pending"
|
||||
},
|
||||
{
|
||||
"number": "12",
|
||||
"name": "Płytarium API — Collections and Albums",
|
||||
"status": "pending"
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"number": "13",
|
||||
@@ -96,8 +91,8 @@
|
||||
],
|
||||
"next": {
|
||||
"command": "/gsd:progress --next",
|
||||
"label": "Advance to the next step (verify)",
|
||||
"reason": "Phase 11.2 of 20 · ready to verify"
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 12.2 of 21 · executing"
|
||||
},
|
||||
"updated_at": "2026-10-01T21:19:02.996Z"
|
||||
"updated_at": "2026-10-02T14:53:28.681Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user