- new note .planning/notes/core-plugins-own-repos.md: shared core plugins live in sm-<name>-plugin repos mounted as submodules - 01-CONTEXT deferral points to the note; PROJECT constraint and Key Decisions row - ROADMAP Phase 12 repos and the 12-01 entry, and Phase 12 plans 12-01, 12-02, 12-05 name sm-user-plugin and the submodule commit workflow
363 lines
48 KiB
Markdown
363 lines
48 KiB
Markdown
---
|
|
phase: 12-p-ytarium-api-collections-and-albums
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- modules/lagoon/validate.go
|
|
- modules/lagoon/validate_request.go
|
|
- modules/lagoon/validate_rules.go
|
|
- modules/lagoon/validate_request_test.go
|
|
- modules/lagoon/validate_test.go
|
|
- modules/lagoon/README.md
|
|
- modules/phrasebook/lang/pl/validation.yaml
|
|
- modules/phrasebook/lang/en/validation.yaml
|
|
- modules/phrasebook/README.md
|
|
- docs/database/casts-and-validation.md
|
|
- modules/lagoon/attach/bucket.go
|
|
- modules/lagoon/attach/thumb.go
|
|
- modules/lagoon/attach/file.go
|
|
- modules/lagoon/attach/url_test.go
|
|
- docs/database/attachments.md
|
|
- docs/services/storage.md
|
|
- modules/tide/flow.go
|
|
- modules/tide/record.go
|
|
- modules/tide/replay.go
|
|
- modules/tide/normalize.go
|
|
- modules/tide/centrifugo_golden.go
|
|
- modules/tide/multipart.go
|
|
- modules/tide/multipart_test.go
|
|
- modules/tide/README.md
|
|
- docs/services/parity-testing.md
|
|
- modules/beachcomber/searchable.go
|
|
- modules/beachcomber/engines.go
|
|
- modules/beachcomber/typesense/engine.go
|
|
- modules/beachcomber/searchpage_test.go
|
|
- modules/beachcomber/typesense/searchpage_test.go
|
|
- modules/beachcomber/README.md
|
|
- docs/services/search.md
|
|
- go.mod
|
|
- go.sum
|
|
- ../fonoteka.go/plugins/golem15/user/models/user_group.go
|
|
- ../fonoteka.go/plugins/golem15/user/models/user.go
|
|
- ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go
|
|
- ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go
|
|
- ../fonoteka.go/plugins/golem15/user/classes/user_groups.go
|
|
- ../fonoteka.go/parity/schema_diff_test.go
|
|
- ../fonoteka.go/go.mod
|
|
- ../fonoteka.go/go.sum
|
|
- ../fonoteka.go/go.work.sum
|
|
- ../fonoteka.go/plugins/golem15/user/go.mod
|
|
- ../fonoteka.go/plugins/golem15/user/go.sum
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/go.mod
|
|
- ../fonoteka.go/plugins/golem15/fonoteka/go.sum
|
|
- .planning/ROADMAP.md
|
|
- .planning/REQUIREMENTS.md
|
|
- .planning/todos/pending/lagoon-validate-min-message.md
|
|
- .planning/todos/done/lagoon-validate-min-message.md
|
|
autonomous: true
|
|
requirements: [API-01, API-02]
|
|
estimate:
|
|
tokens: 260000
|
|
raw_tokens: 260000
|
|
tasks: 4
|
|
confidence: low
|
|
must_haves:
|
|
truths:
|
|
- "Per D-21 and RESEARCH Finding 5, `lagoon.ValidateRequest` reproduces Laravel 9 request-validation semantics: an attribute stops after a failed implicit rule (required), non-implicit rules skip absent, null-with-nullable and blank-string values, `sometimes` skips an absent key, and `{\"albums\":[]}` against `required|array|min:1` yields exactly one message, `Pole albums jest wymagane.` in pl."
|
|
- "`lagoon.ValidateRequest` supports array, `*` wildcard expansion with attributes named like `albums.0.name`, string, integer, numeric, boolean, url, date, after_or_equal, before_or_equal (including `tomorrow`), exists:table,column, regex, image, mimes, in, email, min, max, between and closure rules (`lagoon.CustomRule`), and picks the size message by value type (`max.string`, `max.numeric`, `max.array`, `max.file`)."
|
|
- "The pl and en catalogs `lagoon::validation.*` are ports of Winter's `modules/system/lang/{pl,en}/validation.php`; a key absent from pl (after_or_equal, before_or_equal) falls back to en exactly as Winter does."
|
|
- "The `lagoon-validate-min-message` todo is folded: `lagoon.Validate` answers a min failure with the min message and a numeric between failure with the between message, and every recorded user-plugin 422 body still replays byte-identically (core plugin contract unchanged)."
|
|
- "Per D-22, `(*attach.File).URL()` returns the public URL of the original file and `attach.PublicURL(key)` the URL of any blob key; with `storage.uploads.bucket_url: file://./storage/app/uploads/public` and `public_path_prefix: /storage/app/uploads/public` an original resolves to `/storage/app/uploads/public/<partition>/<disk_name>` and a 200x200 crop thumb to `/storage/app/uploads/public/<partition>/thumb_<id>_200_200_0_0_crop.<ext>`, matching Winter's `File::getPath()`/`getThumb()`."
|
|
- "Per D-24, `golang.org/x/image` is a direct dependency at v0.46.0 and `golang.org/x/image/webp` is registered, so `image.DecodeConfig` and `(*File).Thumb` accept a .webp original; a webp thumb is JPEG bytes under the original extension (imaging cannot encode webp) and that is documented."
|
|
- "Per D-11, a tide request can carry a multipart body described by parts (field values and files stored beside the fixture with a sha256), recorded and replayed with one fixed boundary so PHP and Go receive byte-identical bodies; a part file whose sha256 differs fails the load."
|
|
- "tide masks the random partition and disk name of upload URLs under `url`/`thumb_url` keys while still failing on a wrong prefix, partition shape or thumb suffix, and `NormalizePublications` masks Carbon `created_at`/`updated_at` (and other `*_at`) values inside the published album subtree, so the created/updated broadcast goldens can become assertions."
|
|
- "Per D-19, beachcomber exposes the engine's `found` count through the optional `beachcomber.PageSearcher` interface (`SearchPage`) without changing `Engine.SearchIDs`, and `beachcomber.Query.QueryByWeights` is sent to Typesense as `query_by_weights`; an engine without SearchPage falls back to `Found = len(ids)`."
|
|
- "Per D-25, the Go user plugin gains Winter's `user_groups` and `users_groups` tables (with the Guest and Registered seed rows), `models.UserGroup`, a `Groups` relation on `models.User` that is never serialized, and `classes.UserGroupCodes`; the user API payloads and every user-api parity fixture stay byte-identical."
|
|
- "Per D-03, D-04, D-06 and D-20, ROADMAP Phase 12 criteria 1-3 and goal, Phase 13, Phase 14, and REQUIREMENTS API-01, API-02, API-03, API-07 and INTG-01 are reworded in a planning-docs-only commit."
|
|
- "Edge (API-01 encoding): string size rules count characters (Unicode code points, as PHP mb_strlen), not bytes: a 255-character Polish name passes max:255 and 256 characters fail with the max.string message."
|
|
- "Edge (API-02 boundary): between:1889,2100 on an integer accepts 1889 and 2100 and rejects 1888 and 2101 with the between.numeric message; max:10240 on a file accepts exactly 10240 KB and rejects 10240 KB plus one byte with the max.file message."
|
|
- "Edge (API-02 precision): numeric min:0 and max:999999.9999 compare decimal strings exactly (big.Rat), so 999999.9999 passes and 1000000 fails; no float64 rounding decides a bound."
|
|
artifacts:
|
|
- path: "modules/lagoon/validate_request.go"
|
|
provides: "ValidateRequest, RequestRule, Rule, ParseRules, CustomRule, UploadedFile"
|
|
contains: "func ValidateRequest("
|
|
- path: "modules/phrasebook/lang/pl/validation.yaml"
|
|
provides: "Polish Laravel/Winter validation catalog"
|
|
contains: "Pole :attribute jest wymagane."
|
|
- path: "modules/lagoon/attach/thumb.go"
|
|
provides: "PublicURL, File.URL, webp decoder registration"
|
|
contains: "golang.org/x/image/webp"
|
|
- path: "modules/tide/multipart.go"
|
|
provides: "multipart request parts, fixed boundary encoder"
|
|
- path: "modules/beachcomber/searchable.go"
|
|
provides: "PageSearcher, SearchResult, Query.QueryByWeights"
|
|
contains: "PageSearcher"
|
|
- path: "../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go"
|
|
provides: "user_groups and users_groups migration with seed rows"
|
|
- path: "../fonoteka.go/plugins/golem15/user/classes/user_groups.go"
|
|
provides: "UserGroupCodes, HasGroupCode"
|
|
key_links:
|
|
- from: "modules/lagoon/validate_request.go"
|
|
to: "modules/phrasebook/lang/pl/validation.yaml"
|
|
via: "translator lookup lagoon::validation.<rule>[.<type>] in the request locale"
|
|
pattern: "lagoon::validation\\."
|
|
- from: "modules/tide/replay.go"
|
|
to: "modules/tide/multipart.go"
|
|
via: "Request.Parts encoded with the fixed boundary before sending"
|
|
pattern: "Parts"
|
|
- from: "modules/beachcomber/typesense/engine.go"
|
|
to: "modules/beachcomber/searchable.go"
|
|
via: "Engine implements PageSearcher and sends query_by_weights"
|
|
pattern: "query_by_weights"
|
|
- from: "../fonoteka.go/plugins/golem15/user/classes/user_groups.go"
|
|
to: "../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go"
|
|
via: "joins users_groups to user_groups by user_group_id"
|
|
pattern: "users_groups"
|
|
prohibitions:
|
|
- requirement_id: API-01
|
|
category: safety
|
|
statement: "The user-groups change MUST NOT alter any existing user-plugin response, route, rule or column; every recorded user-api fixture replays unchanged"
|
|
status: resolved
|
|
verification: test
|
|
- requirement_id: API-02
|
|
category: transparency
|
|
statement: "A validation message MUST NOT be invented or paraphrased; every message text comes from the ported Winter catalog or verbatim from the PHP closure rule"
|
|
status: resolved
|
|
verification: test
|
|
---
|
|
|
|
## Phase Goal
|
|
|
|
ROADMAP Phase 12 goal (verbatim, not in user-story form; MVP precedent of Phases 11 and 11.2 is to quote it): Collections and Albums endpoints are ported with byte-compatible request/response shapes, including active-context switching, editor invitations, ratings, reservations, cover handling and search.
|
|
|
|
This plan's slice: the framework can produce every Laravel 422 body, record and replay multipart uploads, emit Winter-shaped upload URLs, decode webp, report the search engine's `found` count and rank fields by weight; the user plugin knows user groups; and the roadmap and requirements say what Phase 12 actually ships. Nothing here is user-visible on its own: plans 12-02 to 12-04 build every endpoint on these pieces.
|
|
|
|
<objective>
|
|
Close the framework gaps RESEARCH Finding 5 lists (summercms.go), add user groups to the Go user plugin (repo sm-user-plugin, module `git.golem15.com/golem15/sm-user-plugin`, mounted as a git submodule at `../fonoteka.go/plugins/golem15/user`), and correct the planning-doc wording.
|
|
|
|
Purpose: every Phase 12 endpoint needs Laravel-exact 422 bodies (D-21), multipart recordings (D-11), PHP upload URLs (D-22), webp (D-24), a re-gated search total (D-19) and the site-admin predicate (D-25).
|
|
Output: `lagoon.ValidateRequest` with pl/en catalogs; attach `URL`/`PublicURL` and webp; tide multipart and upload/publication masks; beachcomber `PageSearcher` and weights; user groups; README and docs updates; reworded ROADMAP/REQUIREMENTS.
|
|
|
|
Repos: summercms.go (framework and planning docs), sm-user-plugin (user groups, committed inside the submodule and pushed to its origin master first) and fonoteka.go (parity schema allow-list plus the bumped submodule pointer). Framework code, tests, READMEs and docs use neutral names (acme, blog, posts) 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/REQUIREMENTS.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md
|
|
@.planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md
|
|
@.planning/todos/pending/lagoon-validate-min-message.md
|
|
@modules/lagoon/validate.go
|
|
@modules/lagoon/attach/bucket.go
|
|
@modules/lagoon/attach/thumb.go
|
|
@modules/tide/flow.go
|
|
@modules/tide/normalize.go
|
|
@modules/beachcomber/searchable.go
|
|
|
|
<interfaces>
|
|
- lagoon (today): `Validate(ctx, tx *gorm.DB, model any, rules map[string]string, values map[string]any, tr *phrasebook.Translator) (map[string][]string, error)`; messages via `validateMessage` with key `lagoon::validate.<rule>` and English fallbacks; `laravelAttribute(field)`, `isLaravelBoolean`, `isEmptyValue`, `numericString`, `moneyInRange` (big.Rat), `uniqueOK`. Callers: modules/cabana/crud.go:342, modules/cabana/settings.go:158, the user plugin controllers (seven calls in ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go) and ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go:94. Their output must not change except the folded min/between message fix.
|
|
- phrasebook: files `modules/phrasebook/lang/<locale>/<group>.yaml` load as `lagoon::<group>.*`; nested YAML maps flatten to dotted keys; `(*Translator).Get(ctx, key, params)` uses the ctx locale with fallback requested, parent, app.fallback_locale, raw key.
|
|
- attach: `const defaultPublicPathPrefix = "/storage/uploads"`, `PublicPathPrefix()`, unexported `publicURL(key string) string`, `BlobKey(diskName)`, `PartitionDirectory(diskName)`, `ThumbFilename(id, w, h, offX, offY, mode, ext)`, `(*File).Thumb(ctx, bucket, w, h, mode) (string, error)`; thumb.go blank-imports image/gif, image/jpeg, image/png; `defaultEncodeImage` writes JPEG for unknown extensions.
|
|
- tide: `Request{Method, Path, Query, Headers, Body Body}`, `Response{Status, Headers, Body, BodyFile, SHA256}`, `Step`, `Flow`, `RecordFlow`, `ReplayFlow(ctx, flow, ReplayConfig{Target, Store, BaseDir})`, normalizer `maskLeaf` (masks id/date/collection_key/client_id), `NormalizePublications` (masks data.timestamp, payload.timestamp, actor, captured ids).
|
|
- beachcomber: `Engine{Name, Configured, Upsert, Delete, Flush, SearchIDs(ctx, index, Query) ([]string, error)}`, `Query{Q, QueryBy []string, FilterBy, SortBy, Page, PerPage}`, `RegisterEngine(name, EngineFactory)`, nullEngine in engines.go, typesense `(*Engine).SearchIDs` at typesense/engine.go:231 decoding `searchAnswer`.
|
|
- User plugin: `models.User` (users table, no groups), migrations registered through `updates.Register(...)` in timestamp-named files (`202609220007_create_jwt_blacklist.go` is the latest), `classes/` holds queries (models stay a leaf). PHP source: /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/create_user_groups_table.php, seed_user_groups_table.php, v2.8.0/add_permissions_to_user_groups.php, models/UserGroup.php, models/User.php:50.
|
|
- Laravel 9 validator source (contract): /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php (isValidatable, presentOrRuleIsImplicit, shouldStopValidating, implicitRules), Concerns/ValidatesAttributes.php, Concerns/FormatsMessages.php (getSizeMessage, getDisplayableAttribute), Concerns/ReplacesAttributes.php; catalogs /media/nvme/dev/golem15/fonoteka/modules/system/lang/{pl,en}/validation.php.
|
|
</interfaces>
|
|
</context>
|
|
|
|
## Artifacts this phase produces
|
|
|
|
(This plan's share.)
|
|
|
|
- lagoon: `ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error)`, `RequestRule{Field string; Rules []Rule}`, `Rule` (a parsed token or a custom func), `ParseRules(spec string) []Rule`, `CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule`, `UploadedFile{Filename string; Size int64; Header textproto.MIMEHeader; Open func() (io.ReadCloser, error)}` with `UploadedFileFromHeader(*multipart.FileHeader) UploadedFile`, `ErrorKeys(errs map[string][]string) []string` (rule-declaration order); catalog namespace `lagoon::validation.*` (pl, en).
|
|
- attach: `PublicURL(key string) string`, `(*File).URL() string`, webp decoding.
|
|
- tide: `Request.Parts []Part`, `Part{Name, Value, File, Filename, ContentType, SHA256}`, `MultipartBoundary` (fixed), upload-URL masking for `url`/`thumb_url`, album-date masking in `NormalizePublications`.
|
|
- beachcomber: `PageSearcher{SearchPage(ctx, index string, q Query) (SearchResult, error)}`, `SearchResult{IDs []string; Found int}`, `SearchPage(ctx, e Engine, index string, q Query) (SearchResult, error)` (fallback helper), `Query.QueryByWeights []int`.
|
|
- User plugin (fonoteka.go): tables `user_groups`, `users_groups`; `models.UserGroup`; `models.User.Groups`; `classes.UserGroupCodes(ctx, db, userID) ([]string, error)`, `classes.HasGroupCode(ctx, db, userID, code) (bool, error)`.
|
|
- Dependency: `golang.org/x/image v0.46.0` (direct, summercms.go).
|
|
|
|
## Assumption-delta decision
|
|
|
|
<assumption_delta_decision>
|
|
Detector run on the Phase 12 ROADMAP section: detected=false. Considered D-25 (user groups) by hand: it adds a membership relation that a single predicate (site admin = group code `admin`) reads; the user identity, its primary key and the JWT principal stay the only identity model. Noun primary: User. Decision: no-change. Rationale: groups are an attribute of a user, not a second identity or tenant.
|
|
</assumption_delta_decision>
|
|
|
|
## Flagged assumptions (edge probe)
|
|
|
|
- API-01 adjacency/empty/ordering and API-02 concurrency are not framework concerns; plans 12-02 to 12-04 resolve them. This plan resolves API-01 encoding (character counting) and API-02 boundary and precision for the validator.
|
|
|
|
<tasks>
|
|
|
|
<task type="tracer">
|
|
<name>Task 1: A Laravel-shaped Polish 422 body comes out of one request validator for a wildcard array request</name>
|
|
<reversibility rating="reversible">New entry point beside lagoon.Validate; existing callers keep their function and catalog keys.</reversibility>
|
|
<files>modules/lagoon/validate_request.go, modules/lagoon/validate_rules.go, modules/lagoon/validate.go, modules/lagoon/validate_request_test.go, modules/lagoon/validate_test.go, modules/phrasebook/lang/pl/validation.yaml, modules/phrasebook/lang/en/validation.yaml, modules/lagoon/README.md, modules/phrasebook/README.md, docs/database/casts-and-validation.md</files>
|
|
<read_first>modules/lagoon/validate.go (whole file), modules/phrasebook/lang.go, modules/phrasebook/loader.go (flatten), modules/phrasebook/translator.go (Get, fallbackChain), modules/phrasebook/lang/pl/validate.yaml, modules/phrasebook/lang/en/validate.yaml, .planning/todos/pending/lagoon-validate-min-message.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Validator.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ValidatesAttributes.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/FormatsMessages.php, /media/nvme/dev/golem15/fonoteka/vendor/laravel/framework/src/Illuminate/Validation/Concerns/ReplacesAttributes.php, /media/nvme/dev/golem15/fonoteka/modules/system/lang/pl/validation.php, /media/nvme/dev/golem15/fonoteka/modules/system/lang/en/validation.php, ../fonoteka.go/parity/fixtures/routes/POST___fonoteka_api_v1_albums_bulk_jwt.yaml (recorded 422 body), docs/database/casts-and-validation.md, modules/lagoon/README.md</read_first>
|
|
<action>Per D-21 and RESEARCH Finding 5 / Pitfall 3, add a request validator with Laravel 9 semantics next to the existing model validator.
|
|
|
|
(1) Catalogs: create modules/phrasebook/lang/pl/validation.yaml and en/validation.yaml as faithful ports of Winter's system lang validation.php files (every key, nested size maps `between`, `gt`, `gte`, `lt`, `lte`, `max`, `min`, `size` with `numeric`, `file`, `string`, `array` children, plus `custom` and `attributes` maps if present). Text is copied verbatim; only PHP quoting is converted. They load as `lagoon::validation.*` beside the untouched `lagoon::validate.*` group. Keys missing from pl (after_or_equal, before_or_equal and any other) are left missing so the translator falls back to en, as Winter does.
|
|
|
|
(2) validate_request.go and validate_rules.go: exported `RequestRule{Field string; Rules []Rule}`, `Rule` (token name, args, implicit flag, or a custom func), `ParseRules(spec string) []Rule` (pipe split, `regex:` arguments kept whole even when they contain a pipe or comma, `in:` args split on commas), `CustomRule(fn)` (non-implicit, runs in declaration position, its returned message is used verbatim), `UploadedFile` with `UploadedFileFromHeader`, and `ValidateRequest(ctx, tx, input, rules, tr)`. Semantics to port from Validator.php: expand `*` segments against the decoded input (each array index becomes `.0`, `.1` ...; an absent parent expands to the literal attribute so `required` still fires); per attribute run rules in order; a rule is validatable only when the value is present or the rule is implicit (required, required_* family, accepted, present), a blank string after trim counts as absent for non-implicit rules, `nullable` with a null value skips the rest, `sometimes` skips an absent key; stop the attribute after a failed implicit rule (shouldStopValidating) and after `bail`. Rules: required, nullable, sometimes, bail, array, string, integer (int types, json.Number without fraction, numeric strings without fraction as filter_var FILTER_VALIDATE_INT), numeric, boolean (true, false, 0, 1, "0", "1"), email (port FILTER_VALIDATE_EMAIL-compatible check used by Laravel's default `email` rule, record the boundary cases you choose in the test), url (Laravel 9 validateUrl regex), date (strtotime-compatible: accept the ISO and Y-m-d shapes the recorded fixtures use; document the accepted set), after_or_equal and before_or_equal (argument is a date or a relative word: today, tomorrow, yesterday, now), exists:table,column (identName-checked identifiers, deleted_at IS NULL is NOT added because Laravel's exists does not add it), regex:/pattern/ (PCRE delimiters stripped; reject patterns Go RE2 cannot compile with a boot-time error, never at request time), in, mimes and image (sniff with http.DetectContentType plus extension mapping as Laravel's guessExtension; image = jpg, jpeg, png, gif, bmp, svg, webp), min, max, between, size, same as Laravel getSize: string length in Unicode code points (utf8.RuneCountInString, PHP mb_strlen), array element count, numeric value when the attribute also has numeric or integer, file size in kilobytes (bytes/1024). Messages: key `lagoon::validation.<rule>`, or `lagoon::validation.<rule>.<numeric|file|string|array>` for size rules picked by the same type order Laravel uses; replacements :attribute (Laravel getDisplayableAttribute: custom attribute name if the catalog defines one, else snake case with underscores turned into spaces; wildcard attributes keep their dotted index), :min, :max, :size, :values (comma-joined), :date, :other, :format. Return the Laravel errors object (attribute to ordered messages) plus `ErrorKeys` giving attribute order by first rule failure so callers can emit keys in PHP order where order is ever compared byte-wise.
|
|
|
|
(3) Fold the min-message todo in validate.go: pick the message by which bound failed (min below the lower bound, max above the upper, between when both bounds came from between) for integer and numeric fields; keep the existing `lagoon::validate.*` texts and every other branch byte-identical. Before changing, grep the user-api fixtures for `may not be greater than` and `must be at least` and keep any recorded text green.
|
|
|
|
(4) Smoke tests (full coverage comes in 12-05): validate_request_test.go with neutral posts/tags examples including `{"posts":[]}` under `required|array|min:1` producing exactly `Pole posts jest wymagane.` in pl and `The posts field is required.` in en; `posts.*.title` wildcard naming `posts.0.title`; a 255 versus 256 character Polish string under max:255; between:1889,2100 at 1888, 1889, 2100, 2101; numeric max:999999.9999 at 999999.9999 and 1000000; a pl-missing key falling back to en; validate_test.go cases for the min/between message fix.
|
|
|
|
(5) Docs in the same change (CLAUDE.md): modules/lagoon/README.md (API reference for ValidateRequest, RequestRule, Rule, ParseRules, CustomRule, UploadedFile, the supported rule list, the implicit-stop semantics), modules/phrasebook/README.md (the new `lagoon::validation.*` namespace beside `lagoon::validate.*`), docs/database/casts-and-validation.md (a "Request validation" section in prose; no hand-written Go fences, follow the 11.1 docs rules, use src= only for compiled examples). Every identifier named must exist.</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/lagoon -run '^(TestValidateRequest.*|TestValidate.*Message.*)$' -count=1 -v && go test ./modules/phrasebook -count=1 && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go test ./plugins/golem15/user/... -count=1</automated>
|
|
<fails_when>Any command exits non-zero; the verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS: TestValidateRequest"; TestDocsTree reports an unknown identifier or broken link; any user plugin test fails (core plugin regression).</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `go doc ./modules/lagoon ValidateRequest`, `go doc ./modules/lagoon ParseRules`, `go doc ./modules/lagoon CustomRule` and `go doc ./modules/lagoon UploadedFileFromHeader` exit 0.
|
|
- `grep -c 'Pole :attribute jest wymagane.' modules/phrasebook/lang/pl/validation.yaml` prints 1 and `grep -c 'max:' modules/phrasebook/lang/pl/validation.yaml` prints at least 1.
|
|
- The en catalog holds `after_or_equal` and the pl catalog does not (`grep -c '^after_or_equal' modules/phrasebook/lang/pl/validation.yaml` prints 0), matching Winter's pl file.
|
|
- `grep -c 'ValidateRequest' modules/lagoon/README.md` and `grep -c 'lagoon::validation' modules/phrasebook/README.md` each print at least 1.
|
|
- A test asserts the exact pl string `Pole posts jest wymagane.` as the only message for `{"posts":[]}`.
|
|
- A validate_test.go case asserts that `min:0` with -1 on an integer field answers with the min message carrying `0`, not the max message.
|
|
</acceptance_criteria>
|
|
<done>Any handler can hand decoded request input and PHP-ordered rules to one validator and get Laravel's exact errors object in the request locale; the old model validator keeps its contract with the min/between message fixed.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: An uploaded photo can be recorded, replayed and addressed exactly as Winter does, webp included</name>
|
|
<reversibility rating="reversible">Additive exports and masks; the framework default public_path_prefix stays /storage/uploads, so only an app that opts in by config changes its URLs.</reversibility>
|
|
<files>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/file.go, modules/lagoon/attach/url_test.go, modules/tide/flow.go, modules/tide/record.go, modules/tide/replay.go, modules/tide/multipart.go, modules/tide/multipart_test.go, modules/tide/normalize.go, modules/tide/centrifugo_golden.go, modules/tide/README.md, modules/lagoon/README.md, docs/database/attachments.md, docs/services/storage.md, docs/services/parity-testing.md, go.mod, go.sum, ../fonoteka.go/go.mod, ../fonoteka.go/go.sum, ../fonoteka.go/go.work.sum, ../fonoteka.go/plugins/golem15/user/go.mod, ../fonoteka.go/plugins/golem15/user/go.sum, ../fonoteka.go/plugins/golem15/fonoteka/go.mod, ../fonoteka.go/plugins/golem15/fonoteka/go.sum</files>
|
|
<read_first>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/file.go, modules/lagoon/attach/static.go, modules/lagoon/attach/thumb_test.go, /media/nvme/dev/golem15/fonoteka/modules/system/models/File.php (getPublicPath, getDiskName, getPartitionDirectory), /media/nvme/dev/golem15/fonoteka/vendor/winter/storm/src/Database/Attach/File.php (getThumb, getThumbFilename, getDiskName), /media/nvme/dev/golem15/fonoteka/config/cms.php (storage.uploads), modules/tide/flow.go, modules/tide/record.go, modules/tide/replay.go, modules/tide/fixture.go, modules/tide/normalize.go, modules/tide/centrifugo_golden.go, modules/tide/README.md, ../fonoteka.go/parity/fixtures/broadcasts/created.yaml (album subtree dates), ../fonoteka.go/parity/README.md (Broadcast goldens), docs/services/parity-testing.md, docs/services/storage.md, docs/database/attachments.md</read_first>
|
|
<action>(1) attach, per D-22: export `PublicURL(key string) string` (the current publicURL body: PublicPathPrefix joined with the key by exactly one slash) and keep a lowercase alias only if other files need it; add `(*File).URL() string` returning `PublicURL(BlobKey(f.DiskName))`. Do not change defaultPublicPathPrefix. Add url_test.go pinning, under a config with bucket `mem://` and public_path_prefix `/storage/app/uploads/public`, that URL() is `/storage/app/uploads/public/<PartitionDirectory(disk)>/<disk>` and a Thumb(200, 200, crop) URL is `/storage/app/uploads/public/<partition>/thumb_<id>_200_200_0_0_crop.<ext>` (compare with Winter getThumbFilename read from the vendor source).
|
|
|
|
(2) webp, per D-24: `go get golang.org/x/image@v0.46.0` in summercms.go (becomes a direct requirement), blank-import `golang.org/x/image/webp` in thumb.go beside the gif/jpeg/png decoders, and add a test decoding a small webp fixture with image.DecodeConfig and producing a crop thumb whose bytes are JPEG under the .webp name (document this in the attach section of modules/lagoon/README.md and docs/database/attachments.md). Then in ../fonoteka.go run `go work sync` and `go mod tidy` in the root module and both plugin modules so all go.sum and go.work.sum files list v0.46.0; `go -C ../fonoteka.go build ./...` must pass.
|
|
|
|
(3) tide multipart, per D-11: add `Parts []Part` to Request (yaml `parts,omitempty`), `Part{Name, Value, File, Filename, ContentType, SHA256 string}` where File is a path relative to the fixture directory (fixtures store upload bytes as files, e.g. `files/cover.png`), and a fixed exported boundary constant `MultipartBoundary`. multipart.go encodes parts in declaration order with mime/multipart using that boundary and sets `Content-Type: multipart/form-data; boundary=...` (overriding any recorded Content-Type header value only when it is a multipart type). Loading a flow checks every part file exists under BaseDir and its sha256 matches (mismatch is an error naming the part); a request with both Body and Parts is an error. RecordFlow and ReplayFlow send the same encoded bytes; variable substitution applies to Value and Path, never to file bytes. The recorder never inlines file bytes into YAML.
|
|
|
|
(4) Upload URL masking: in normalize.go, for leaf keys `url` and `thumb_url` whose string value starts with an uploads prefix, assert the shape `<prefix>/<3 hex>/<3 hex>/<3 hex>/<disk>` for originals and `.../thumb_<digits>_<w>_<h>_0_0_<mode>.<ext>` for thumbs (derive the partition and disk-name pattern from attach.PartitionDirectory and Winter getDiskName as read from vendor), then mask the partition and disk stem (keeping prefix, size, mode and extension visible); a wrong prefix, a missing partition or a different thumb size reports a Diff at the path, like the Carbon date check. Values that are not upload-shaped stay untouched. The prefix to accept is configurable on the normalizer (default `/storage/app/uploads/public`); never hard-code an application name.
|
|
|
|
(5) Publications: extend NormalizePublications so Carbon `+00:00` values under `*_at` keys anywhere inside `data.payload.album` are masked with the same shape assertion as response bodies (A5 in RESEARCH: confirm first whether they are already masked; if they are, add only the test).
|
|
|
|
(6) Tests: multipart_test.go (round trip against an httptest server capturing the raw body: two recordings of the same flow produce identical bytes; a tampered part file fails load; Body plus Parts fails), normalize tests for upload URLs and publication dates. Docs in the same change: modules/tide/README.md (parts, boundary, upload masks, publication date masks), docs/services/parity-testing.md, docs/services/storage.md (the Winter layout recipe: bucket rooted at uploads/public with prefix /storage/app/uploads/public, and URL/PublicURL), docs/database/attachments.md (URL, webp).</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/lagoon/attach ./modules/tide -count=1 -v -run '^(TestFileURLWinterLayout|TestThumbWebP|TestMultipart.*|TestNormalizeUploadURL.*|TestNormalizePublication.*)$' && 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 a "--- PASS" line for TestFileURLWinterLayout, TestThumbWebP and a TestMultipart test; the fonoteka.go build fails on a go.sum mismatch for golang.org/x/image.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c 'golang.org/x/image v0.46.0' go.mod` prints 1 and the line has no `// indirect` comment.
|
|
- `grep -c 'golang.org/x/image/webp' modules/lagoon/attach/thumb.go` prints 1.
|
|
- `go doc ./modules/lagoon/attach PublicURL` and `go doc ./modules/lagoon/attach File.URL` exit 0.
|
|
- `go doc ./modules/tide Part` and `go doc ./modules/tide MultipartBoundary` exit 0.
|
|
- `grep -c 'Parts' modules/tide/README.md` prints at least 1 and `grep -c 'storage/app/uploads/public' docs/services/storage.md` prints at least 1.
|
|
- A tide test asserts that a thumb_url with a 100x100 size against a 200x200 expectation is reported as a Diff (masking never hides a wrong size).
|
|
</acceptance_criteria>
|
|
<done>A multipart upload can be recorded from PHP and replayed against Go byte for byte, upload URLs are compared by shape without their random parts, the attach package emits Winter's URLs under the Winter layout config, and webp images decode.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Search reports how many documents matched with PHP's field weights, and the user plugin knows which groups a user is in</name>
|
|
<reversibility rating="costly">D-25 changes the core user plugin schema (two new tables). It is additive with a down migration and the user signed off on 2026-10-02, so it is flagged without a checkpoint.</reversibility>
|
|
<files>modules/beachcomber/searchable.go, modules/beachcomber/engines.go, modules/beachcomber/typesense/engine.go, modules/beachcomber/searchpage_test.go, modules/beachcomber/typesense/searchpage_test.go, modules/beachcomber/README.md, docs/services/search.md, ../fonoteka.go/plugins/golem15/user/models/user_group.go, ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/updates/202610020001_create_user_groups.go, ../fonoteka.go/plugins/golem15/user/updates/user_groups_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_groups.go, ../fonoteka.go/parity/schema_diff_test.go</files>
|
|
<read_first>modules/beachcomber/searchable.go, modules/beachcomber/engines.go, modules/beachcomber/beachcomber.go, modules/beachcomber/typesense/engine.go (SearchIDs, searchAnswer), modules/beachcomber/README.md, docs/services/search.md, /media/nvme/dev/golem15/fonoteka/vendor/laravel/scout/src/Engines/TypesenseEngine.php (search params, found, maxPerPage), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/AlbumSearchService.php (lines 40-60 and 170-200), ../fonoteka.go/plugins/golem15/user/models/user.go, ../fonoteka.go/plugins/golem15/user/models/registry.go, ../fonoteka.go/plugins/golem15/user/updates/202609220007_create_jwt_blacklist.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go, ../fonoteka.go/plugins/golem15/user/updates/organisations_test.go, ../fonoteka.go/plugins/golem15/user/classes/user_lookup.go, ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (user payload around line 800-830: groups stays []), ../fonoteka.go/parity/schema_diff_test.go (allowedDiffs), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/create_user_groups_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v1.1.1/seed_user_groups_table.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v2.8.0/add_permissions_to_user_groups.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/models/UserGroup.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/classes/OrgAccess.php</read_first>
|
|
<action>(1) beachcomber, per D-19: add `QueryByWeights []int` to Query; add `SearchResult{IDs []string; Found int}`, the optional interface `PageSearcher` with `SearchPage(ctx, index string, q Query) (SearchResult, error)`, and the helper `SearchPage(ctx, e Engine, index string, q Query) (SearchResult, error)` that type-asserts PageSearcher and otherwise calls SearchIDs and sets Found to len(ids). Engine.SearchIDs keeps its signature (other engines and test fakes stay valid). nullEngine implements SearchPage returning an empty result. typesense Engine implements SearchPage on the same `/collections/{index}/documents/search` request as SearchIDs (share one internal function), decoding `found`, and sends `query_by_weights` as the comma-joined weights when QueryByWeights is non-empty; a QueryByWeights length different from QueryBy is an error before any request. Reject PerPage above 250 with an error (Typesense's limit; callers page). Test with an httptest Typesense double: found and ids decode, the weights parameter arrives, the fallback helper works on an engine without SearchPage.
|
|
|
|
(2) Docs in the same change: modules/beachcomber/README.md and docs/services/search.md (PageSearcher, SearchResult, the helper, QueryByWeights, the 250 per-page limit, and that ids remain candidates only that callers re-gate in SQL).
|
|
|
|
(3) User groups, per D-25 (sm-user-plugin, additive): models/user_group.go `UserGroup{ID uint; Name string; Code *string; Description *string; Permissions *string; CreatedAt, UpdatedAt *time.Time}` with TableName `user_groups`, registered in the models registry like the other models; add `Groups []UserGroup` to models.User with tag `gorm:"many2many:users_groups;joinForeignKey:user_id;joinReferences:user_group_id"` and `json:"-"` (GORM never writes it unless a caller associates groups; nothing in the user plugin does). Migration file updates/202610020001_create_user_groups.go (ID `202610020001_create_user_groups`): create `user_groups` (id serial primary key, name varchar(255) not null, code varchar(255) null with an index, description text null, permissions text null, created_at and updated_at timestamp null) and `users_groups` (user_id integer not null, user_group_id integer not null, primary key (user_id, user_group_id) named user_group), then insert the Guest/guest and Registered/registered rows with PHP's descriptions; Rollback drops both tables. classes/user_groups.go: `UserGroupCodes(ctx, db, userID uint) ([]string, error)` (codes of the user's groups ordered by user_groups.id, null codes skipped) and `HasGroupCode(ctx, db, userID uint, code string) (bool, error)`. The user API payload keeps `"groups":[]` exactly as today (D-25: contract unchanged); do not read the new table in any user-plugin handler.
|
|
|
|
(4) parity/schema_diff_test.go: add allowedDiffs entries `user_groups` and `users_groups` with a reason naming D-25 and that the frozen PHP snapshot predates the user-plugin dump (the same justification as user_throttle). Keep every other entry.
|
|
|
|
(5) Tests: updates/user_groups_test.go on the plugin's Postgres harness: migrate up creates both tables and the two seed rows, RollbackLast drops them, re-up works; UserGroupCodes returns `admin` for a user linked to a group with code admin and an empty slice for a user with no groups.
|
|
|
|
(6) Commits: commit the user-plugin files inside the submodule (`git -C ../fonoteka.go/plugins/golem15/user`), push its master, then commit parity/schema_diff_test.go together with the bumped submodule pointer in fonoteka.go, no co-author tags.</action>
|
|
<verify>
|
|
<automated>go vet ./... && go test ./modules/beachcomber/... -count=1 -v -run '^(TestSearchPage.*|TestTypesenseSearchPage.*)$' && go test ./cmd/summer -run TestDocsTree -count=1 && go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./plugins/golem15/user/updates -count=1 -v -run '^(TestUserGroups.*)$' && go -C ../fonoteka.go test ./parity -run '^(TestSchemaMatchesPHPSnapshot|TestUserAPINuxtFlows)$' -count=1</automated>
|
|
<fails_when>Any command exits non-zero; a verbose run prints "no tests to run", "--- FAIL" or "--- SKIP", or lacks "--- PASS" for a TestSearchPage test and a TestUserGroups test; TestSchemaMatchesPHPSnapshot reports an extra Go table; TestUserAPINuxtFlows fails (user payload changed).</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `go doc ./modules/beachcomber PageSearcher`, `go doc ./modules/beachcomber SearchPage` and `go doc ./modules/beachcomber Query.QueryByWeights` exit 0.
|
|
- `grep -c 'query_by_weights' modules/beachcomber/typesense/engine.go` prints at least 1.
|
|
- `grep -c 'PageSearcher' modules/beachcomber/README.md` and `grep -c 'PageSearcher' docs/services/search.md` each print at least 1.
|
|
- `grep -c '"user_groups"' ../fonoteka.go/parity/schema_diff_test.go` and `grep -c '"users_groups"' ../fonoteka.go/parity/schema_diff_test.go` each print 1.
|
|
- `grep -c 'json:"-"' ../fonoteka.go/plugins/golem15/user/models/user.go` increases by one relative to HEAD (the Groups field is never serialized).
|
|
- `grep '"groups":' ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go | grep -c '\[\]any{}'` prints 1, and this task's sm-user-plugin commit touches no file under `controllers/` (payload untouched).
|
|
</acceptance_criteria>
|
|
<done>Callers can ask the search engine for one page of candidate ids and the total it found, ranked with explicit weights, and any plugin can ask which group codes a user has without the user API changing.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 4: The roadmap and requirements describe what Phase 12 ships (planning docs only)</name>
|
|
<files>.planning/ROADMAP.md, .planning/REQUIREMENTS.md, .planning/todos/pending/lagoon-validate-min-message.md, .planning/todos/done/lagoon-validate-min-message.md</files>
|
|
<read_first>.planning/ROADMAP.md (Phase 12, 13, 14 sections), .planning/REQUIREMENTS.md (API-01, API-02, API-03, API-07, INTG-01), .planning/phases/12-p-ytarium-api-collections-and-albums/12-CONTEXT.md (D-03, D-04, D-06, D-19, D-20), .planning/phases/12-p-ytarium-api-collections-and-albums/12-RESEARCH.md (Findings 1 and 2)</read_first>
|
|
<action>Use Edit (scoped replacements), never a whole-file Write. Per D-03, D-04, D-06, D-19 and D-20:
|
|
- ROADMAP Phase 12 Goal: drop "reservations" and say cover import and manual cover URL instead of "cover handling" where it adds precision; keep one line.
|
|
- Phase 12 success criterion 1: Collections CRUD with photos and image, the `collections/{id}/switch` and `me/context` flags, the opaque channel name from `GET realtime/channels`, editor invitation/acceptance and members, and the owner-only `collection/share` show/update/regenerate pass the parity diff (anonymous public token views moved to Phase 13).
|
|
- Criterion 2: Albums CRUD, ratings, photo upload, manual cover URL and Discogs cover import on create/bulk (`cover_urls`), plus sync/stats/value/missing/bulk pass the parity diff (reservations moved to Phase 13, the Discogs cover-price route to Phase 14).
|
|
- Criterion 3: the search total is the re-gated SQL count over at most 1000 engine ids (Scout v10.25.0), and the leak test also asserts the total never counts a leaked row.
|
|
- Phase 13: add wishlist reservations (`wishlist/albums/{id}/reserve|reveal`) to the wishlist criterion and the anonymous `public/{token}`, `public/{token}/albums`, `public/{token}/albums/{id}` views to the public routes criterion.
|
|
- Phase 14: name the `albums/{id}/cover-price/discogs` route in the Discogs criterion.
|
|
- REQUIREMENTS: API-01 reads "me/context flags plus the opaque channel name from realtime/channels" and drops public token views (owner-only share surface stays); API-02 drops reservations and Discogs cover price and says "Discogs cover import (cover_urls)" and that both search items and the total are re-gated in SQL; API-03 gains reservations; API-07 gains the anonymous collection public-token views; INTG-01 gains the Discogs cover-price route. Leave every status column and the traceability table untouched.
|
|
Also `git mv .planning/todos/pending/lagoon-validate-min-message.md .planning/todos/done/` (Task 1 folded it), following the `done/` precedent of verify-models-leaf-rule.md. Commit these planning files alone as a docs commit (no code in the same commit).</action>
|
|
<verify>
|
|
<automated>grep -q 'realtime/channels' .planning/ROADMAP.md && grep -q 'realtime/channels' .planning/REQUIREMENTS.md && grep -m1 '\*\*API-02\*\*' .planning/REQUIREMENTS.md | grep -vq 'reservations' && grep -m1 '\*\*API-03\*\*' .planning/REQUIREMENTS.md | grep -q 'reserv'</automated>
|
|
<fails_when>Non-zero exit: realtime/channels is missing from either file, the API-02 requirement line still mentions reservations, or the API-03 line carries no reservation wording.</fails_when>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -A8 '### Phase 12:' .planning/ROADMAP.md | grep -c 'reservations'` prints 0.
|
|
- `grep -c 'cover-price/discogs' .planning/ROADMAP.md` prints at least 1.
|
|
- `grep -c 'public/{token}' .planning/ROADMAP.md` prints at least 1 inside the Phase 13 section.
|
|
- The REQUIREMENTS.md traceability table rows for API-01 and API-02 still read `Phase 12 | Pending`.
|
|
- `git log -1 --stat` for this commit lists only .planning files.
|
|
- `test -f .planning/todos/done/lagoon-validate-min-message.md` succeeds and the pending copy is gone.
|
|
</acceptance_criteria>
|
|
<done>Anyone reading the roadmap or requirements sees the Phase 12 boundary the user locked in CONTEXT.md, and the moved surfaces are owned by Phases 13 and 14.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| HTTP request body → request validator | Untrusted JSON and multipart input reaches rule evaluation, regex matching and DB `exists` lookups |
|
|
| Uploaded bytes → image decoders | Untrusted image bytes reach the gif/jpeg/png/webp decoders and the thumbnailer |
|
|
| Parity fixtures (git) → tide replay | Committed fixtures and part files drive requests; secrets must stay in the 0600 vars file |
|
|
| Search engine → application | Engine ids and counts are candidates, not authorization |
|
|
| Module proxy → go.mod | A dependency bump enters the build |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-12-14 | Tampering | lagoon `exists:` and regex rules | high | mitigate | Table and column names pass identName; regex patterns come only from code (ParseRules at registration), never from input; an RE2-incompatible pattern fails at boot (Task 1). |
|
|
| T-12-15 | Denial of Service | wildcard expansion and size rules | medium | mitigate | Expansion is bounded by the decoded body, which surf caps by http.body_limits; size counts are O(n) over already-decoded values; no backtracking regex engine (RE2) (Task 1). |
|
|
| T-12-16 | Denial of Service | webp/png/jpeg decode in Thumb and DecodeConfig | medium | mitigate | DecodeConfig reads headers only; Thumb runs only on files that passed the 10240 KB cap in the callers (12-02/12-04); x/image v0.46.0 is the current upstream with its fixes (Task 2). |
|
|
| T-12-17 | Information Disclosure | tide multipart part files | medium | mitigate | Part bytes live as committed fixture files that are test images only; check_corpus --check-secrets still scans every YAML; substitution never touches file bytes (Task 2). |
|
|
| T-12-28 | Information Disclosure | beachcomber SearchPage found count | high | mitigate | Found is exposed to callers only as an input to a SQL recount (D-19, enforced in 12-04 and tested in 12-05); README states ids and counts are candidates (Task 3). |
|
|
| T-12-18 | Elevation of Privilege | user groups table | medium | mitigate | No route writes users_groups; Groups is never serialized; the site-admin predicate reads group codes server-side only (Task 3, consumed in 12-02). |
|
|
| T-12-SC | Tampering | Go module installs (golang.org/x/image v0.46.0) | high | mitigate | Official Go sub-repository already in the module graph, verified with `go list -m` against proxy.golang.org (RESEARCH Package Legitimacy Audit: OK); go.sum pins the hash; named by D-24 as the phase decision authorizing the bump. No npm/pip/cargo installs. |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- summercms.go: `go vet ./... && go test ./... -count=1` green; `go test ./cmd/summer -run TestDocsTree -count=1` green.
|
|
- fonoteka.go: `go -C ../fonoteka.go vet ./... && go -C ../fonoteka.go test ./... -count=1` green (user-api fixtures unchanged, schema diff green with the two new allow-list entries).
|
|
- Planning docs commit contains only ROADMAP.md, REQUIREMENTS.md and the todo moved to `.planning/todos/done/`.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `lagoon.ValidateRequest` and the pl/en `lagoon::validation.*` catalogs exist and reproduce the recorded Polish 422 shape; lagoon.Validate callers are unchanged except the fixed min/between message.
|
|
- attach exports URL helpers, decodes webp; tide records and replays multipart bodies and masks upload URLs and publication dates.
|
|
- beachcomber exposes found and weights; the user plugin has user groups with an unchanged user API.
|
|
- ROADMAP and REQUIREMENTS carry the D-03/D-04/D-06/D-19/D-20 wording.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/12-p-ytarium-api-collections-and-albums/12-01-SUMMARY.md` when done.
|
|
</output>
|