Files
summercms/.planning/phases/05-data-layer-full-fidelity/05-03-PLAN.md
2026-09-18 18:06:55 +02:00

28 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-data-layer-full-fidelity 03 execute 3
05-01
05-02
summercms.go/lagoon/encrypted.go
summercms.go/lagoon/encrypted_test.go
summercms.go/lagoon/keygen.go
summercms.go/lagoon/keygen_test.go
summercms.go/lagoon/commands.go
summercms.go/lagoon/laravel_decrypt.go
summercms.go/lagoon/laravel_decrypt_test.go
fonoteka.go/config/app.yaml
fonoteka.go/plugins/golem15/user/updates/10_organisations.go
fonoteka.go/plugins/golem15/user/models/organisation.go
fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go
fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go
fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
fonoteka.go/plugins/golem15/fonoteka/models/oauth_refresh_token.go
fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go
fonoteka.go/plugins/golem15/fonoteka/models/org_ai_credential.go
fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go
fonoteka.go/plugins/golem15/fonoteka/models/org_discogs_credential.go
true
DATA-07
DATA-09
truths artifacts key_links
lagoon.Encrypted round-trips AES-256-GCM ciphertext with a column key HKDF-derived from app.key under a fixed label using the Go 1.27 standard library's crypto/hkdf (no new dependency); a missing, short, or undecodable app.key fails boot with a named actionable error and no default (D-10, D-11)
Ciphertext is versioned (format/key-id prefix + nonce + ciphertext+tag), and app.previous_keys is a decrypt-only fallback list, mirroring Laravel's APP_PREVIOUS_KEYS (D-12)
lagoon.Encrypted.MarshalJSON/.String()/.GoString() always emit a redaction; plaintext is reachable only through an explicit .Reveal() call, greppable across the codebase (D-13)
summer key:generate prints a fresh 32-byte base64 key and performs no other side effect (D-11)
A standalone helper decrypts a Laravel AES-256-CBC+HMAC 'encrypted' payload for the future cutover import, and is never called from lagoon.Encrypted's live Scan/Value path (D-10)
golem15_user_organisations (structure only) is created by the user plugin's own migration, not fonoteka's; Phase 5 ships no widen_users migration at all — a deliberate deviation from D-03's literal wording, confirmed by the user at plan time
api_tokens, the 3 OAuth tables, and the 4 credential tables exist with token_hash/code_hash/client_secret_hash columns Hidden and api_key/token columns typed lagoon.Encrypted and Hidden (DATA-07, DATA-09)
path provides
summercms.go/lagoon/encrypted.go Encrypted Scanner/Valuer + Reveal() + versioned AES-256-GCM format
path provides
summercms.go/lagoon/keygen.go key:generate bonfire.Command
path provides
fonoteka.go/plugins/golem15/user/updates/10_organisations.go create_organisations (structure only)
path provides
fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go create_api_tokens, create_oauth_tables, create_credentials_tables
from to via pattern
fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go summercms.go/lagoon/encrypted.go APIKey lagoon.Encrypted `gorm:"column:api_key"` lagoon.Encrypted
from to via pattern
summercms.go/lagoon/encrypted.go fonoteka.go/config/app.yaml app.key read at column-key derivation time, fails boot if empty/short app.key|SUMMER_APP__KEY
Ship the encrypted-at-rest cast and the tables/models that need it: the AES-256-GCM `lagoon.Encrypted` type with HKDF key derivation (Go 1.27 standard library `crypto/hkdf`, no new dependency) and versioned ciphertext (D-10..D-13), the `summer key:generate` command, a standalone (unwired) Laravel-payload decrypt helper for the future cutover import, `golem15_user_organisations` (structure only, in the user plugin per the user's confirmed deviation from D-03's wording), and the fonoteka `api_tokens`/OAuth/credential tables and models.

Purpose: this is the phase's other named security-load-bearing surface (mass assignment was Plan 02) — encryption key handling, redaction discipline, and the fact that Organisations land in the user plugin's migration set even though only Fonoteka's credential tables reference them this phase. Output: lagoon.Encrypted, lagoon.KeyGenerateCommand, a Laravel-decrypt helper; golem15_user_organisations; ApiToken, OAuthClient, OAuthAuthCode, OAuthRefreshToken, UserAiCredential, OrgAiCredential, UserDiscogsCredential, OrgDiscogsCredential models and their 3 migrations.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md @.planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md @.planning/phases/05-data-layer-full-fidelity/05-01-SUMMARY.md

From summercms.go/lagoon/fill.go (Plan 05-01):

type HasHidden interface { Hidden() []string }

From fonoteka.go/plugins/golem15/fonoteka/models/registry.go and updates/registry.go, and the equivalent in plugins/golem15/user (Plan 05-01):

func Register(models ...any)               // package models
func All() []any                            // package models
func Register(ms ...*gormigrate.Migration)  // package updates
func All() []*gormigrate.Migration          // package updates

From summercms.go/bouncer/jwt.go Verify (the fail-loud-on-empty-secret shape to copy for app.key):

func Verify(tokenString, secret string) (string, error) {
	if strings.TrimSpace(secret) == "" {
		return "", fmt.Errorf("bouncer: jwt secret is empty")
	}
	...
}

From summercms.go/lagoon/commands.go RuntimeCommands (the command-trio shape key:generate joins):

func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
	return []bonfire.Command{ {Name: "migrate", ...}, {Name: "migrate:rollback", ...}, {Name: "migrate:status", ...} }
}

From summercms.go/compass/config.go (dot-path config access app.key/app.previous_keys will use):

func (c *Config) String(path string) string
func (c *Config) Lookup(path string) (any, bool)
Task 1 (summercms.go): lagoon.Encrypted — AES-256-GCM, stdlib HKDF key derivation, versioned ciphertext, key:generate, Laravel decrypt-only helper summercms.go/lagoon/encrypted.go, summercms.go/lagoon/encrypted_test.go, summercms.go/lagoon/keygen.go, summercms.go/lagoon/keygen_test.go, summercms.go/lagoon/commands.go, summercms.go/lagoon/laravel_decrypt.go, summercms.go/lagoon/laravel_decrypt_test.go summercms.go/bouncer/jwt.go (Verify's fail-loud-on-empty-secret shape, lines 67-70) summercms.go/lagoon/connection.go (checkLocale's maximally-actionable-error-message shape, lines 129-136) summercms.go/lagoon/commands.go (RuntimeCommands trio shape to extend) summercms.go/compass/config.go (String/Lookup dot-path access) .planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("Pattern: Encrypted-at-rest cast (D-10..D-13)" section; Sources list's `laravel.com/docs/11.x/encryption` citation for the CBC+HMAC payload shape) .planning/research/PITFALLS.md (Security Mistakes section) - `Encrypted.Scan` then `.Reveal()` round-trips arbitrary plaintext bytes through `Value()`/`Scan()` using a key derived from a fixed test `app.key`. - Two `Encrypted` values holding the same plaintext produce different ciphertext bytes (fresh nonce per encrypt call). - `json.Marshal(encryptedValue)`, `encryptedValue.String()`, and `fmt.Sprintf("%#v", encryptedValue)` never contain the plaintext substring. - A ciphertext produced under key-id 1, then decrypted after `app.previous_keys` gains key-id 1 as a decrypt-only fallback and the primary key rotates to key-id 2, still decrypts correctly. - Deriving the column key from an empty, too-short (fewer than 32 decoded bytes), or non-base64 `app.key` returns a named, actionable error mentioning `SUMMER_APP__KEY` — never a zero-value/default key. - `key:generate`'s command output is a valid base64 string that decodes to exactly 32 bytes. - `DecryptLaravelPayload` correctly decrypts a real Laravel `encrypted` cast payload (base64 JSON `{iv,value,mac}`, AES-256-CBC + HMAC-SHA256 under the raw `APP_KEY` bytes) fixture, and `grep -rn "DecryptLaravelPayload" summercms.go/lagoon/*.go` shows it is called only from its own file and test — never from `encrypted.go`'s `Scan`/`Value`. In `lagoon/encrypted.go`: implement `type Encrypted struct { plaintext []byte; set bool }` (unexported fields so nothing outside `.Reveal()`/`.Value()`/internal marshal code can read `plaintext` directly) with `func (e *Encrypted) Scan(src any) error` (decode the versioned format below and store plaintext internally, `set=true`), `func (e Encrypted) Value() (driver.Value, error)` (re-encrypt current plaintext under the current primary key, return the versioned ciphertext string), `func (e Encrypted) Reveal() string` (the one explicit, greppable plaintext accessor — return `""` if `!e.set`), `func (e Encrypted) MarshalJSON() ([]byte, error)` returning `json.Marshal("[redacted]")` unconditionally, `func (e Encrypted) String() string` and `func (e Encrypted) GoString() string` both returning a fixed redaction literal (never plaintext, never even a length hint). Column key derivation: `func deriveColumnKey(appKey []byte) ([]byte, error)` using the Go 1.27 **standard library** `crypto/hkdf` package (`hkdf.Key(sha256.New, appKey, salt, info, 32)`, or the `Extract`/`Expand` pair — Go 1.24+ ships HKDF in stdlib, so this introduces **no new dependency**; do not add `golang.org/x/crypto` or run `go get` for this) with SHA-256, a fixed info label string like `"summercms.lagoon.encrypted.v1"`, producing a 32-byte AES-256 key; `appKey` itself is loaded once at first use from `app.key` (base64-decoded, must decode to exactly 32 raw bytes) via a package-level function `LoadAppKey(cfg *compass.Config) ([]byte, []byte, error)` returning `(primaryKey, previousKeysConcat, error)` — reads `app.key` and `app.previous_keys` (a YAML list of base64 strings) from `cfg`, fails with `fmt.Errorf("lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)")`-shaped errors on empty/short/undecodable, following `bouncer.Verify`'s and `lagoon.checkLocale`'s fail-loud verbosity exactly. Ciphertext format: a single byte format/key-id version prefix, a 12-byte GCM nonce, then GCM-sealed ciphertext+tag, the whole thing base64-encoded for storage in the `text` column (matches RESEARCH.md's confirmed `text` column type for `api_key`/`token`). `Scan` tries the primary derived key first, then each of `previousKeys` in order, returning a decrypt error only if none match (D-12's decrypt-only fallback). Publish the loaded keys once per app boot via `backpack.App.Publish`, looked up by `Scan`/`Value` — do not re-read config on every row; document the exact wiring call site (`lagoon.PublishEncryptionKeys(app, primaryKey, previousKeys)`, called from wherever `lagoon.OpenFromApp`/`Publish` already runs) in a doc comment.
In `lagoon/keygen.go`: implement `func KeyGenerateCommand() bonfire.Command` returning `bonfire.Command{Name: "key:generate", Description: "Print a fresh 32-byte base64 app.key", Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { ... }}` using `crypto/rand.Read` on a 32-byte buffer, `out.Success(base64.StdEncoding.EncodeToString(key))` — never `math/rand`. Append `KeyGenerateCommand()` to the slice `RuntimeCommands` returns in `lagoon/commands.go` (one new line in the existing slice literal — a framework-owned file no other plan in this phase touches).

In `lagoon/laravel_decrypt.go`: implement `func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error)` — base64-JSON-decode `{iv, value, mac}` per Laravel's `encrypted` cast format, verify the HMAC-SHA256 `mac` over `iv+value` using `appKey` (raw bytes — Laravel uses the raw `APP_KEY` directly for both cipher and MAC key, verify against the RESEARCH.md-cited Laravel docs), then AES-256-CBC-decrypt `value` with `iv`, unpadding PKCS#7. Doc-comment it "cutover-import-only; never called from `Encrypted`'s live Scan/Value path" (D-10).
cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted - All seven `Encrypted`/`key:generate`/`DecryptLaravelPayload` behaviors above pass as automated tests. - `grep -rn "DecryptLaravelPayload("` shows zero call sites outside its own file and test. - `grep -rn "golang.org/x/crypto" summercms.go/lagoon/encrypted.go summercms.go/go.mod` returns no matches (stdlib `crypto/hkdf` only, no new dependency). - `go vet ./lagoon/...` is clean; `grep -rn "\"math/rand\"" summercms.go/lagoon/keygen.go` returns no matches. `lagoon.Encrypted`, key derivation/rotation, `key:generate`, and the standalone Laravel decrypt helper exist, are fully tested, redaction is provably unbypassable via marshal/String/GoString, and HKDF derivation uses only the Go 1.27 standard library. Task 2 (fonoteka.go): create_organisations (structure only) in the user plugin — no widen_users migration this phase fonoteka.go/plugins/golem15/user/updates/10_organisations.go, fonoteka.go/plugins/golem15/user/models/organisation.go, fonoteka.go/config/app.yaml /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.2.0/create_organisations_table.php (confirm the exact filename via a directory listing if the version directory differs) fonoteka.go/plugins/golem15/user/updates/00_base.go (moved by Plan 05-01 — the exact ID-naming and Migrate/Rollback shape to follow) .planning/phases/05-data-layer-full-fidelity/05-CONTEXT.md (user_confirmed_decisions #2) .planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md ("widen_users finding", "Squashed Migration List" row 2, the anti-pattern note on organisations table ownership) Create `updates/10_organisations.go` (package `updates`, in `plugins/golem15/user`) with a doc comment stating it folds `updates/v3.2.0/create_organisations_table.php` and explicitly noting: "Phase 5 ships no widen_users migration — deferred to Phase 7's AUTH-02 per 05-CONTEXT.md user-confirmed decision; this migration only creates the FK target `golem15_user_organisations` table that Fonoteka's org credential tables reference." Read the PHP migration file directly for its exact column list; at minimum expect `id SERIAL PRIMARY KEY`, `name TEXT NOT NULL`, `created_at`/`updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. Do not add `organisation_id`/`organisation_role` columns to `users`, and do not add membership/role tables even if the PHP version directory bundles more into the same migration — split them out, porting only the bare table this phase. `var organisationsMigrations = []*gormigrate.Migration{{ID: "_create_organisations", Migrate: ..., Rollback: func(tx *gorm.DB) error { return tx.Exec("DROP TABLE IF EXISTS golem15_user_organisations").Error }}}`, with `func init() { updates.Register(organisationsMigrations...) }`.
Create `models/organisation.go` (package `models`, in `plugins/golem15/user`) with `type Organisation struct { ID uint; Name string; CreatedAt time.Time; UpdatedAt time.Time }`, `func (Organisation) TableName() string { return "golem15_user_organisations" }`, `func init() { models.Register(Organisation{}) }` — structure only, no `Fillable`/`Rules`/relations beyond the FK target Fonoteka's credential models need (Phase 7 owns roles/membership behavior).

Add an `app.key: ""` entry to `fonoteka.go/config/app.yaml` as a documented placeholder — following `config/database.yaml`'s "Set SUMMER_..." comment-header convention: "# Set SUMMER_APP__KEY to a 32-byte base64 value (summer key:generate). Empty fails boot once any encrypted-cast model runs." Do not set a real value.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... - `golem15_user_organisations` is created by a testcontainers-backed migration test in the `user` plugin's own module, not fonoteka's. - `grep -rn "organisation_id\|organisation_role" fonoteka.go/plugins/golem15/user/updates/10_organisations.go` returns no matches on the `users` table (only the new table's own columns exist). - `grep -rln "widen_users" fonoteka.go/plugins/golem15/user/updates` returns no matches (no such migration exists). `golem15_user_organisations` exists as a structure-only table owned by the user plugin's migration set; no widen_users migration ships this phase. Task 3 (fonoteka.go): secrets-slice migrations and models — api_tokens, OAuth tables, credential tables, all with Hidden hash/secret columns and lagoon.Encrypted credential columns fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go, fonoteka.go/plugins/golem15/fonoteka/models/api_token.go, models/oauth_client.go, models/oauth_auth_code.go, models/oauth_refresh_token.go, fonoteka.go/plugins/golem15/fonoteka/models/user_ai_credential.go, models/org_ai_credential.go, models/user_discogs_credential.go, models/org_discogs_credential.go /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.3/create_fonoteka_api_tokens_table.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.6/add_area_ids_to_api_tokens.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/create_oauth_tables.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.7/add_oauth_client_id_to_api_tokens.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.9/add_scope_ceiling_to_oauth_clients.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.4/create_user_ai_credentials_table.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.5/create_user_discogs_credentials_table.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.3/create_org_credentials_tables.php /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/UserAiCredential.php, OrgAiCredential.php, UserDiscogsCredential.php, OrgDiscogsCredential.php (exact $casts/$hidden confirmation) .planning/phases/05-data-layer-full-fidelity/05-RESEARCH.md (Full Per-Model Inventory rows 4, 13, 14, 15, 16, 17, 21, 23; "Pattern: Encrypted-at-rest cast" code example) Create `updates/11_secrets_slice.go` (package `updates`) with three migrations, IDs continuing Plan 05-02's `updates/10_album_slice.go` date/sequence (Wave 2 already shipped that file; this plan is Wave 3, no shared line edited): 1. `create_api_tokens` — `golem15_fonoteka_api_tokens (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT, token_hash TEXT NOT NULL UNIQUE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, last_used_at TIMESTAMPTZ, last_used_ip TEXT, oauth_client_id TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify exact types against the two folded files; `scopes`/`collection_ids` are `text` jsonable columns per RESEARCH.md row 4, not native `jsonb`). 2. `create_oauth_tables` — `golem15_fonoteka_oauth_clients (id SERIAL PRIMARY KEY, client_id TEXT NOT NULL UNIQUE, client_secret_hash TEXT NOT NULL, client_name TEXT, redirect_uris TEXT, grant_types TEXT, token_endpoint_auth_method TEXT, registration_ip TEXT, consented_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, scope_ceiling TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_auth_codes (id SERIAL PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, code_hash TEXT NOT NULL UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, redirect_uri TEXT, scopes TEXT, collection_ids TEXT, code_challenge TEXT, code_challenge_method TEXT, resource TEXT, state TEXT, expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_oauth_refresh_tokens (id SERIAL PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, api_token_id INTEGER REFERENCES golem15_fonoteka_api_tokens(id) ON DELETE CASCADE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, scopes TEXT, collection_ids TEXT, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, rotated_to_id INTEGER, offline_access BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())` (verify against both folded files including `scope_ceiling`). 3. `create_credentials_tables` — `golem15_fonoteka_user_ai_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, provider TEXT NOT NULL, api_key TEXT NOT NULL, model TEXT, base_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_ai_credentials` (same shape, `organisation_id INTEGER NOT NULL UNIQUE REFERENCES golem15_user_organisations(id) ON DELETE CASCADE` in place of `user_id`), `golem15_fonoteka_user_discogs_credentials (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, token TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`, `golem15_fonoteka_org_discogs_credentials` (same shape with `organisation_id`) — `api_key`/`token` are `text` columns (never `varchar`, confirmed live per RESEARCH.md). This migration depends on `create_organisations` (Task 2) for the FK target; since gormigrate runs each plugin's set independently in `party.Activate` dependency order and `golem15.fonoteka` `Requires()` `golem15.user`, this ordering is already guaranteed — do not add an explicit cross-plugin migration dependency mechanism. End the file with `func init() { updates.Register(apiTokenMigration, oauthTablesMigration, credentialsTablesMigration) }` (or a single slice var, matching `00_base.go`'s style).
Create the 8 model files (package `models`), each following the exact struct/tag shape from RESEARCH.md's per-model inventory rows and the D-10..D-13 code example: `ApiToken` (row 4: `UserID`, `Name *string`, `TokenHash string` `json:"-"`, `Scopes lagoon.Jsonable[[]string]`, `CollectionIDs lagoon.Jsonable[[]uint]`, `ExpiresAt`/`RevokedAt`/`LastUsedAt *time.Time`, `LastUsedIP *string`, `OAuthClientID *string`; `Hidden()` returns `{"token_hash"}`); `OAuthClient` (row 14, `ClientSecretHash string` `json:"-"`, `Hidden()` `{"client_secret_hash"}`); `OAuthAuthCode` (row 13, `CodeHash string` `json:"-"`, `Hidden()` `{"code_hash"}`); `OAuthRefreshToken` (row 15, `TokenHash string` `json:"-"`, `Hidden()` `{"token_hash"}`); `UserAiCredential`/`OrgAiCredential` (rows 21/16, `APIKey lagoon.Encrypted` `gorm:"column:api_key"` `json:"-"`, `Hidden()` `{"api_key"}`, exactly matching the code example in RESEARCH.md's "Pattern: Encrypted-at-rest cast" section); `UserDiscogsCredential`/`OrgDiscogsCredential` (rows 23/17, `Token lagoon.Encrypted` `gorm:"column:token"` `json:"-"`, `Hidden()` `{"token"}`). Every file's `init()` calls `models.Register(TheType{})`.
cd /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go && go build ./... && go vet ./... && go test ./... -run TestMigrateSeedsCanonicalGenres - A testcontainers-backed migration test migrates `updates.All()` for `golem15.fonoteka` and asserts all 6 new tables exist (`golem15_fonoteka_api_tokens`, the 3 OAuth tables, the 4 credential tables — 8 total across the 3 migrations) and roll each back individually. - `grep -rn "json:\"-\"" fonoteka.go/plugins/golem15/fonoteka/models/{api_token,oauth_client,oauth_auth_code,oauth_refresh_token,user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows every hash/secret/credential column tagged. - `grep -rn "lagoon.Encrypted" fonoteka.go/plugins/golem15/fonoteka/models/{user_ai_credential,org_ai_credential,user_discogs_credential,org_discogs_credential}.go` shows exactly one match per file, on the `api_key`/`token` field. 3 migrations create the api_tokens/OAuth/credentials tables; 8 models exist with correct Hidden()/json:"-" and lagoon.Encrypted-typed secret columns, self-registered.

<threat_model>

Trust Boundaries

Boundary Description
app.key config -> Encrypted column key a misconfigured or absent key must never silently fall back to a weak/default key
stored ciphertext -> old key holder key rotation must not lock out legitimately-encrypted-under-the-old-key rows, nor allow decrypting with a key that was never a real primary

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-05-08 Information Disclosure lagoon.Encrypted accidental serialization mitigate json:"-" backstop plus MarshalJSON/String/GoString all hardcoded to redact; Hidden() on every credential model names the same column (D-13)
T-05-09 Information Disclosure / Denial of Service key rotation mitigate Versioned ciphertext (key-id prefix) plus app.previous_keys decrypt-only fallback prevents both silent data loss and indefinite reuse of a retired key (D-12)
T-05-10 Tampering app.key missing/weak mitigate LoadAppKey fails boot loudly on empty/short/undecodable input, no default value path exists (D-11)
T-05-11 Tampering (supply chain) HKDF key derivation for lagoon.Encrypted accept Uses the Go 1.27 standard library crypto/hkdf package — no new dependency is introduced (this corrects an earlier draft of this plan that proposed golang.org/x/crypto/hkdf; Go 1.24+ ships HKDF in stdlib, so CLAUDE.md's stdlib-first rule applies directly and there is nothing for a package-legitimacy checkpoint to review)
T-05-12 Repudiation api_tokens/oauth_refresh_tokens store only *_hash columns, never raw secrets accept Matches PHP's own design; raw tokens are never persisted anywhere, hashed only, consistent with the Hidden() backstop
</threat_model>
`cd summercms.go && go vet ./lagoon/... && go test ./lagoon/... -run TestEncrypted` then full `go test ./lagoon/...`; `cd ../fonoteka.go && go vet ./... && go test ./...` (testcontainers Postgres) covering the new user-plugin and fonoteka-plugin migrations and models.

<success_criteria>

  • lagoon.Encrypted round-trips, redacts on every marshal path, supports key rotation via app.previous_keys, fails boot loudly on a bad app.key, and derives its column key using only the Go 1.27 standard library (no new dependency).
  • key:generate and the standalone Laravel decrypt helper exist and are tested.
  • golem15_user_organisations exists, owned by the user plugin; no widen_users migration ships this phase.
  • All 3 secrets-slice migrations and 8 models exist, self-register, and have correct Hidden()/json:"-"/lagoon.Encrypted typing. </success_criteria>
Create `.planning/phases/05-data-layer-full-fidelity/05-03-SUMMARY.md` when done