From ea3329679954994f9ce8cb6bff8645bfdea554f0 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 19:22:22 +0200 Subject: [PATCH] feat(12.2-04): add the fileupload field with deferred uploads on the form session key - sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers - api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry - FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews - FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update - fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist --- .../13-VALIDATION.md | 81 ++ admin/src/api/client.ts | 8 + admin/src/api/files.ts | 312 +++++ admin/src/api/types.ts | 10 + admin/src/app/sessionKey.ts | 28 + .../form/fields/FileCaptionModal.vue | 241 ++++ .../form/fields/FileuploadField.vue | 1213 +++++++++++++++++ admin/src/components/form/formContext.ts | 29 + admin/src/components/form/registry.ts | 9 +- admin/src/views/FormView.vue | 42 +- .../tests/fixtures/deferred.form-schema.json | 68 + admin/tests/fixtures/typed.ts | 3 + admin/tests/form/registry.test.ts | 2 +- admin/tests/smoke/deferred.smoke.test.ts | 212 +++ docs/backend/admin-spa.md | 2 + .../boardwalk/dist/assets/index-BPPmlB5x.js | 9 + .../boardwalk/dist/assets/index-C8GNzEPt.css | 1 + .../boardwalk/dist/assets/index-CfeX_snf.css | 1 - .../boardwalk/dist/assets/index-J-FCndLr.js | 4 - modules/boardwalk/dist/index.html | 4 +- modules/phrasebook/backend/lang/en/lang.yaml | 36 + modules/phrasebook/backend/lang/pl/lang.yaml | 38 + 22 files changed, 2338 insertions(+), 15 deletions(-) create mode 100644 .planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md create mode 100644 admin/src/api/files.ts create mode 100644 admin/src/app/sessionKey.ts create mode 100644 admin/src/components/form/fields/FileCaptionModal.vue create mode 100644 admin/src/components/form/fields/FileuploadField.vue create mode 100644 admin/tests/fixtures/deferred.form-schema.json create mode 100644 admin/tests/smoke/deferred.smoke.test.ts create mode 100644 modules/boardwalk/dist/assets/index-BPPmlB5x.js create mode 100644 modules/boardwalk/dist/assets/index-C8GNzEPt.css delete mode 100644 modules/boardwalk/dist/assets/index-CfeX_snf.css delete mode 100644 modules/boardwalk/dist/assets/index-J-FCndLr.js diff --git a/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md new file mode 100644 index 0000000..9ad7874 --- /dev/null +++ b/.planning/phases/13-p-ytarium-api-wishlist-notifications-csv-credentials-public/13-VALIDATION.md @@ -0,0 +1,81 @@ +--- +phase: "13" +slug: "p-ytarium-api-wishlist-notifications-csv-credentials-public" +# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) +# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) +status: draft +nyquist_compliant: false +wave_0_complete: false +created: "2026-10-02" +--- + +# Phase 13 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Go `testing` (+ testify, Go fuzzing), testcontainers Postgres | +| **Config file** | none — `fonoteka.go/parity/parity_test.go` TestMain starts Postgres | +| **Quick run command** | `go -C ../fonoteka.go test ./plugins/golem15/fonoteka/... ./plugins/golem15/user/... -short -count=1` | +| **Full suite command** | `go vet ./... && go test ./... -count=1 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` | +| **Parity command** | `go -C ../fonoteka.go test ./parity -run 'TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows' -count=1` | +| **Phase gate** | `../fonoteka.go/scripts/check-phase13.sh --self-test && ../fonoteka.go/scripts/check-phase13.sh --all` | +| **Estimated runtime** | ~180 seconds (full suite with testcontainers) | + +--- + +## Sampling Rate + +- **After every task commit:** Run the quick run command plus `go vet` in the touched repo +- **After every plan wave:** Run the full suite command, the parity command and the corpus check +- **Before `/gsd-verify-work`:** `scripts/check-phase13.sh --all` must be green +- **Max feedback latency:** 180 seconds + +--- + +## Per-Task Verification Map + +Filled by the planner per task; the requirement → test map lives in `13-RESEARCH.md` § Validation Architecture. + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 13-01-01 | 01 | 1 | (framework) | T-13-23 | Overlapping constrained routes dispatch to the right handler | unit | `go test ./modules/surf -run TestOverlappingConstrainedRoutes -count=1` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `surf` constraint-aware overlap dispatch, plus a test (blocks every wishlist route) +- [ ] `conga` unregistered-kind insert while a worker runs, plus a test +- [ ] `lagoon` `prohibited` rule; tide Content-Disposition date and notification publication masks +- [ ] `php_parity.sh` `QUEUE_CONNECTION` override; `capture-rules.yaml` `share:wishlist` capture +- [ ] `fonoteka_reset.php` + `seedFonotekaCase` states: `wishlist`, `csv`, `credentials`, `empty`, `invite-for-register` +- [ ] `scripts/check-phase13.sh` (copy of the check-phase12 structure) + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Re-recording PHP fixtures against the isolated PHP instance | API-03..API-07 | Needs the local PHP stack running | Run `php_parity.sh` recordings per D-12 with the database queue override | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 180s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts index b833b0d..4d1f9eb 100644 --- a/admin/src/api/client.ts +++ b/admin/src/api/client.ts @@ -26,6 +26,14 @@ export function onUnauthorized(handler: UnauthorizedHandler | null): void { unauthorizedHandler = handler } +/** + * Reports a session that could not be recovered, for requests that do not + * go through the typed client (the upload XMLHttpRequest). + */ +export function reportUnauthorized(): void { + unauthorizedHandler?.() +} + /** Registers a listener for successful refreshes (the new access lifetime). */ export function onRefreshed(handler: RefreshedHandler | null): void { refreshedHandler = handler diff --git a/admin/src/api/files.ts b/admin/src/api/files.ts new file mode 100644 index 0000000..78dc3aa --- /dev/null +++ b/admin/src/api/files.ts @@ -0,0 +1,312 @@ +// File routes of a fileupload field (D-02, D-09, D-10). FileuploadField talks +// to one FileRoutes object and never knows whether it edits a record (the +// record file routes, keyed by X-Session-Key) or a related record inside a +// relation child modal (the child file routes, which also carry +// X-Child-Session-Key). Session keys travel in headers only, never in a URL. +// +// Uploads use XMLHttpRequest, the only browser API that reports upload +// progress. The request carries the same X-Requested-With header and +// same-origin cookie as the typed client, and a 401 refreshes the session once +// and sends the file again. Protected thumbnails and downloads are fetched as +// blobs through the typed client, so the SPA can show them from object URLs. +import { REQUESTED_WITH, api, refreshSession, reportUnauthorized } from './client' +import type { AdminFileCaptionRequest, ControllerParams, ErrorBody, FileItem, FileMutationResult } from './types' +import { runtime } from '../app/runtime' +import { CHILD_SESSION_HEADER, SESSION_HEADER } from '../app/sessionKey' + +/** The outcome of one JSON file call: data on success, else the error body. */ +export interface FileCallResult { + data: T | null + status: number + error: ErrorBody | null +} + +/** Why an upload did not store a file. */ +export type UploadFailure = 'invalid' | 'too_large' | 'network' | 'failed' | 'aborted' + +export type UploadResult = + | { ok: true; item: FileItem } + | { ok: false; reason: UploadFailure; status: number; message: string } + +/** A running upload: its result and a way to cancel it. */ +export interface UploadHandle { + promise: Promise + abort(): void +} + +export type UploadProgress = (loaded: number, total: number) => void + +/** Everything a fileupload control does with its files. */ +export interface FileRoutes { + list(): Promise> + upload(file: File, onProgress?: UploadProgress): UploadHandle + update(file: number, body: AdminFileCaptionRequest): Promise> + remove(file: number): Promise> + reorder(ids: number[]): Promise> + /** A protected file's bytes, or null when they could not be loaded. */ + download(file: number): Promise + /** A protected image's preview thumbnail, or null. */ + thumb(file: number): Promise +} + +interface TypedResult { + data?: { data: T } + error?: { error: ErrorBody } + response: Response +} + +async function settle(call: () => Promise>): Promise> { + try { + const result = await call() + return { + data: result.data ? result.data.data : null, + status: result.response.status, + error: result.error?.error ?? null, + } + } catch { + return { data: null, status: 0, error: null } + } +} + +async function blob(call: () => Promise<{ data?: Blob; response: Response }>): Promise { + try { + const result = await call() + return result.response.ok && result.data instanceof Blob ? result.data : null + } catch { + return null + } +} + +function segment(value: string | number): string { + return encodeURIComponent(String(value)) +} + +function controllerUrl(source: ControllerParams): string { + return `${runtime.api}/${segment(source.vendor)}/${segment(source.plugin)}/${segment(source.controller)}` +} + +function parse(text: string): unknown { + try { + return text === '' ? null : JSON.parse(text) + } catch { + return null + } +} + +/** The first message of a 422: the field's detail, else the error message. */ +export function errorMessage(error: ErrorBody | null | undefined): string { + if (!error) { + return '' + } + for (const value of Object.values(error.details ?? {})) { + const first = Array.isArray(value) ? value.find((item) => typeof item === 'string' && item !== '') : value + if (typeof first === 'string' && first !== '') { + return first + } + } + return error.message ?? '' +} + +function classify(status: number, body: unknown): UploadResult { + const envelope = body as { data?: FileItem; error?: ErrorBody } | null + if ((status === 200 || status === 201) && envelope?.data) { + return { ok: true, item: envelope.data } + } + const message = errorMessage(envelope?.error) + if (status === 413) { + return { ok: false, reason: 'too_large', status, message } + } + if (status === 422) { + return { ok: false, reason: 'invalid', status, message } + } + if (status === 0) { + return { ok: false, reason: 'network', status, message } + } + return { ok: false, reason: 'failed', status, message } +} + +type Sent = { status: number; body: unknown } | 'network' | 'aborted' + +/** + * Posts one file as the multipart part `file_data` with progress events. + * The XMLHttpRequest sends X-Requested-With and the given session headers + * with the same-origin cookie; a 401 refreshes the session once and retries. + */ +export function uploadWithProgress( + url: string, + file: File, + headers: Record, + onProgress?: UploadProgress, +): UploadHandle { + let current: XMLHttpRequest | null = null + let aborted = false + + const send = () => + new Promise((resolve) => { + const request = new XMLHttpRequest() + current = request + request.open('POST', url) + request.setRequestHeader('X-Requested-With', REQUESTED_WITH) + request.setRequestHeader('Accept', 'application/json') + for (const [name, value] of Object.entries(headers)) { + request.setRequestHeader(name, value) + } + request.upload.onprogress = (event: ProgressEvent) => { + if (event.lengthComputable) { + onProgress?.(event.loaded, event.total) + } + } + request.onload = () => resolve({ status: request.status, body: parse(request.responseText) }) + request.onerror = () => resolve('network') + request.ontimeout = () => resolve('network') + request.onabort = () => resolve('aborted') + const form = new FormData() + form.append('file_data', file, file.name) + request.send(form) + }) + + const promise = (async (): Promise => { + let sent = await send() + if (typeof sent === 'object' && sent.status === 401 && !aborted) { + if (await refreshSession()) { + sent = aborted ? 'aborted' : await send() + } + if (typeof sent === 'object' && sent.status === 401) { + reportUnauthorized() + } + } + if (sent === 'aborted' || aborted) { + return { ok: false, reason: 'aborted', status: 0, message: '' } + } + if (sent === 'network') { + return { ok: false, reason: 'network', status: 0, message: '' } + } + return classify(sent.status, sent.body) + })() + + return { + promise, + abort() { + aborted = true + current?.abort() + }, + } +} + +/** + * The record file routes of one field. recordId is 0 for the record being + * created; the server then holds every file against sessionKey. + */ +export function parentFileRoutes( + source: ControllerParams, + recordId: number, + field: string, + sessionKey: string, +): FileRoutes { + const path = { ...source, id: recordId, field } + const header = { [SESSION_HEADER]: sessionKey } as { 'X-Session-Key': string } + const url = `${controllerUrl(source)}/${segment(recordId)}/files/${segment(field)}` + return { + list: () => settle(() => api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}', { params: { path, header } })), + upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress), + update: (file, body) => + settle(() => + api.PUT('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', { + params: { path: { ...path, file }, header }, + body, + }), + ), + remove: (file) => + settle(() => + api.DELETE('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}', { + params: { path: { ...path, file }, header }, + }), + ), + reorder: (ids) => + settle(() => + api.POST('/{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder', { + params: { path, header }, + body: { ids }, + }), + ), + download: (file) => + blob(() => + api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download', { + params: { path: { ...path, file }, header }, + parseAs: 'blob', + }), + ), + thumb: (file) => + blob(() => + api.GET('/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb', { + params: { path: { ...path, file }, header }, + parseAs: 'blob', + }), + ), + } +} + +/** + * The file routes of a related record's field inside a relation child modal. + * childId is 0 for the child being created. Both keys travel: the record + * form's key scopes an unsaved parent, the child modal's key holds the files. + */ +export function childFileRoutes( + source: ControllerParams, + recordId: number, + relation: string, + childId: number, + field: string, + sessionKey: string, + childKey: string, +): FileRoutes { + const path = { ...source, id: recordId, name: relation, child: childId, field } + const header = { [SESSION_HEADER]: sessionKey, [CHILD_SESSION_HEADER]: childKey } as { + 'X-Session-Key': string + 'X-Child-Session-Key': string + } + const url = `${controllerUrl(source)}/${segment(recordId)}/relations/${segment(relation)}/records/${segment(childId)}/files/${segment(field)}` + return { + list: () => + settle(() => + api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}', { + params: { path, header }, + }), + ), + upload: (file, onProgress) => uploadWithProgress(url, file, { ...header }, onProgress), + update: (file, body) => + settle(() => + api.PUT('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', { + params: { path: { ...path, file }, header }, + body, + }), + ), + remove: (file) => + settle(() => + api.DELETE('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}', { + params: { path: { ...path, file }, header }, + }), + ), + reorder: (ids) => + settle(() => + api.POST('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/reorder', { + params: { path, header }, + body: { ids }, + }), + ), + download: (file) => + blob(() => + api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/download', { + params: { path: { ...path, file }, header }, + parseAs: 'blob', + }), + ), + thumb: (file) => + blob(() => + api.GET('/{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}/thumb', { + params: { path: { ...path, file }, header }, + parseAs: 'blob', + }), + ), + } +} diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index 2db99e0..fe1f7d7 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -51,6 +51,16 @@ export type MessageForms = Schemas['cabana.MessageForms'] /** The public backend::lang bundle: full key to CLDR forms (D-20). */ export type LangBundle = Schemas['cabana.LangBundle'] export type ErrorEnvelope = Schemas['cabana.ErrorEnvelope'] +/** One file of a fileupload field; `pending` while it waits for the save. */ +export type FileItem = Schemas['cabana.FileItem'] +/** Result of a file removal: how many files were removed. */ +export type FileMutationResult = Schemas['cabana.FileMutationResult'] +/** Body of a caption save: title and description, omitted keys unchanged. */ +export type AdminFileCaptionRequest = Schemas['cabana.AdminFileCaptionRequest'] +/** Body of a relation link: the ids and, for one id, its pivot values. */ +export type AdminRelationLinkRequest = Schemas['cabana.AdminRelationLinkRequest'] +/** A fileupload field's preview thumbnail mode. */ +export type ThumbOptions = Schemas['cabana.ThumbOptions'] /** One record: a string-keyed map read through its list or form schema. */ export type AdminRecord = Schemas['cabana.AdminRecord'] export type ErrorBody = Schemas['cabana.ErrorBody'] diff --git a/admin/src/app/sessionKey.ts b/admin/src/app/sessionKey.ts new file mode 100644 index 0000000..8c73341 --- /dev/null +++ b/admin/src/app/sessionKey.ts @@ -0,0 +1,28 @@ +// Form session keys (D-02). A record form makes one key when it mounts and a +// relation child modal makes its own when it opens. Uploads, file removals, +// deferred relation calls and the final save carry the key in a header, so +// the server can hold the work against it and commit it with the save. A key +// never appears in a URL: it would end up in logs and the browser history. + +/** Header of the record form's key. */ +export const SESSION_HEADER = 'X-Session-Key' + +/** Header of a relation child modal's own key. */ +export const CHILD_SESSION_HEADER = 'X-Child-Session-Key' + +/** Random bytes per key: 256 bits, above D-02's 128-bit floor. */ +const KEY_BYTES = 32 + +/** + * A new key: 32 bytes from crypto.getRandomValues encoded as unpadded + * base64url, 43 characters of A-Z a-z 0-9 _ - (the server's key pattern). + */ +export function newSessionKey(): string { + const bytes = new Uint8Array(KEY_BYTES) + globalThis.crypto.getRandomValues(bytes) + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/admin/src/components/form/fields/FileCaptionModal.vue b/admin/src/components/form/fields/FileCaptionModal.vue new file mode 100644 index 0000000..7284fda --- /dev/null +++ b/admin/src/components/form/fields/FileCaptionModal.vue @@ -0,0 +1,241 @@ + + +