From cec110e0919b9ce5fc591dc4ebd8ac0cf5f7204e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 06:50:27 -0400 Subject: [PATCH 01/16] fix(codegen): generated routes ignore @formExclude and credential fields on write, never return credentials (#484) A field marked @formExclude, or a credential (a field declaring a view.password child), is now ignored by the generated create, PATCH and PUT in every port and a credential is never returned by list, get, write echoes, M:N targets or TPH rows. Generated forms and api docs omit credentials too. Pinned by the new api-contract secret-fields/ sub-corpus (generated lane, all five ports). Also: meta gen / meta verify --codegen refuse an unknown codegen dialect ("d1" points at "sqlite"; meta init --d1 scaffolds "sqlite"), and five docs recipes (drizzle-kit migrations, Workers + D1 per-request example, guarding generated routes, proving a gate fails). Fixes #484 --- .claude/rules/cross-language-porting.md | 2 +- CHANGELOG.md | 24 +++ .../references/typescript.md | 2 +- docs/CONFORMANCE.md | 14 +- docs/README.md | 3 + docs/features/api-contract.md | 23 +++ docs/ports/typescript.md | 25 ++- docs/recipes/cloudflare-workers.md | 113 ++++++++--- docs/recipes/drizzle-kit-migrations.md | 90 +++++++++ docs/recipes/guarding-generated-routes.md | 86 +++++++++ docs/recipes/precommit-meta-verify.md | 2 + docs/recipes/prove-your-gate-fails.md | 61 ++++++ docs/recipes/wiring-generated-queries.md | 8 +- .../codegen/generators/routes.ts | 7 +- .../codegen/runtime/drizzle-fastify/index.ts | 13 +- .../codegen/runtime/wire-format.ts | 37 +++- .../showcase/codegen/generators/routes.ts | 7 +- .../codegen/runtime/drizzle-fastify/index.ts | 13 +- .../showcase/codegen/runtime/wire-format.ts | 37 +++- .../references/typescript.md | 2 +- .../references/typescript.md | 2 +- fixtures/api-contract-conformance/README.md | 5 +- .../secret-fields/README.md | 49 +++++ .../secret-fields/meta.json | 18 ++ .../scenarios/read-omits-credential.yaml | 33 ++++ .../scenarios/write-ignores-excluded.yaml | 72 +++++++ .../secret-fields/seed.json | 6 + .../SecretFieldsCodegenTests.cs | 103 ++++++++++ .../Generators/RoutesGenerator.cs | 156 ++++++++++++--- .../Api/ApiContractCorpusPaths.cs | 6 + .../ApiContractSecretFieldsConformanceTest.cs | 81 ++++++++ .../Api/SecretFieldsFixture.cs | 53 +++++ .../Api/SecretFieldsGeneratedServerFactory.cs | 181 ++++++++++++++++++ .../MetaObjects/Core/Field/FieldConstants.cs | 6 + .../generator/kotlin/KotlinEntityGenerator.kt | 14 +- .../generator/kotlin/KotlinGenUtil.kt | 28 +++ .../generator/kotlin/KotlinM2mSupport.kt | 4 + .../kotlin/KotlinSpringControllerGenerator.kt | 24 ++- .../generator/kotlin/KotlinTphPlan.kt | 3 +- .../kotlin/KotlinSecretFieldsTest.kt | 75 ++++++++ .../spring/SpringControllerGenerator.java | 177 ++++++++++++++--- .../spring/SpringGeneratorSupport.java | 43 +++++ .../spring/SpringSecretFieldsCodegenTest.java | 94 +++++++++ ...eldsGeneratedApiContractConformanceTest.kt | 73 +++++++ .../GeneratedSecretFieldsControllerHarness.kt | 145 ++++++++++++++ ...dsGeneratedApiContractConformanceTest.java | 81 ++++++++ .../GeneratedAccountControllerHarness.java | 173 +++++++++++++++++ .../InMemoryAccountRepositorySource.java | 115 +++++++++++ .../codegen/generators/router_generator.py | 61 +++++- .../codegen/runtime/decimal_wire.py | 16 ++ .../meta/core/field/field_constants.py | 3 + .../codegen/test_router_secret_fields.py | 104 ++++++++++ .../generated_secret_fields_app.py | 87 +++++++++ .../test_api_contract_secret_fields.py | 70 +++++++ .../packages/cli/src/commands/init.ts | 6 +- .../typescript/packages/cli/test/init.test.ts | 5 +- .../verify-gate-not-vacuous.test.ts | 97 ++++++++++ .../src/templates/form-file.ts | 6 +- .../src/templates/form-file.ts | 4 + .../test/form-credential-exclusion.test.ts | 59 ++++++ .../packages/codegen-ts/src/api-surface.ts | 47 +++++ .../src/generators/api-field-shape.ts | 17 +- .../packages/codegen-ts/src/index.ts | 4 +- .../codegen-ts/src/metaobjects-config.ts | 20 ++ .../codegen-ts/src/reference/routes-hono.ts | 3 +- .../codegen-ts/src/reference/routes.ts | 7 +- .../packages/codegen-ts/src/routes-expose.ts | 3 + .../packages/codegen-ts/src/runner.ts | 4 +- .../src/templates/routes-file-hono.ts | 4 +- .../codegen-ts/src/templates/routes-file.ts | 8 +- .../test/codegen-dialect-validation.test.ts | 66 +++++++ ...contract-secret-fields-generated-server.ts | 118 ++++++++++++ .../packages/integration-tests/src/paths.ts | 3 + .../test/api-contract-secret-fields.test.ts | 77 ++++++++ .../runtime-ts/src/drizzle-fastify/index.ts | 13 +- .../packages/runtime-ts/src/hono/index.ts | 13 +- .../packages/runtime-ts/src/wire-format.ts | 37 +++- .../runtime-ts/test/wire-format.test.ts | 28 ++- .../test-generators/src/generators/routes.ts | 7 +- 79 files changed, 3225 insertions(+), 161 deletions(-) create mode 100644 docs/recipes/drizzle-kit-migrations.md create mode 100644 docs/recipes/guarding-generated-routes.md create mode 100644 docs/recipes/prove-your-gate-fails.md create mode 100644 fixtures/api-contract-conformance/secret-fields/README.md create mode 100644 fixtures/api-contract-conformance/secret-fields/meta.json create mode 100644 fixtures/api-contract-conformance/secret-fields/scenarios/read-omits-credential.yaml create mode 100644 fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml create mode 100644 fixtures/api-contract-conformance/secret-fields/seed.json create mode 100644 server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsFixture.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsGeneratedServerFactory.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryAccountRepositorySource.java create mode 100644 server/python/tests/codegen/test_router_secret_fields.py create mode 100644 server/python/tests/integration/generated_secret_fields_app.py create mode 100644 server/python/tests/integration/test_api_contract_secret_fields.py create mode 100644 server/typescript/packages/cli/test/integration/verify-gate-not-vacuous.test.ts create mode 100644 server/typescript/packages/codegen-ts-react/test/form-credential-exclusion.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/codegen-dialect-validation.test.ts create mode 100644 server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts create mode 100644 server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts diff --git a/.claude/rules/cross-language-porting.md b/.claude/rules/cross-language-porting.md index fb479ac9e1..630ae79ac2 100644 --- a/.claude/rules/cross-language-porting.md +++ b/.claude/rules/cross-language-porting.md @@ -48,7 +48,7 @@ Preserve the following contracts exactly across all language ports: - Overlay/merge: same `package` + same object `name` across multiple files → merged. Last-writer-wins on attr conflicts; structural children accumulate. - Default scan path: `metaobjects/**/*.json` (recursive). -**D1 is TS-only.** Cloudflare D1 is a peer of `sqlite`/`postgres` in TS's `dialect` vocabulary. It is SQLite at the SQL level — Java/Python/C# don't have an analogue (Cloudflare Workers run JS). When adding cross-language vocabulary, D1 doesn't constrain anything: its uniqueness is wrangler-CLI transport + Wrangler-native file layout (`migrations/_.sql`), both of which are TS-only concerns. +**D1 is TS-only.** Cloudflare D1 is a peer of `sqlite`/`postgres` in `meta migrate`'s `dialect` vocabulary (`migrate.dialect`, `--dialect d1`); codegen's `dialect` is `sqlite`/`postgres`/`mysql` and D1 uses `sqlite` (`"d1"` there is an error). It is SQLite at the SQL level — Java/Python/C# don't have an analogue (Cloudflare Workers run JS). When adding cross-language vocabulary, D1 doesn't constrain anything: its uniqueness is wrangler-CLI transport + Wrangler-native file layout (`migrations/_.sql`), both of which are TS-only concerns. **Constants discipline:** - TS: named constants, imported from `@metaobjectsdev/metadata/constants` (defined in 16 per-concern `*-constants.ts` modules under `packages/metadata/src/`, e.g. `core/field/field-constants.ts`, `persistence/db/db-constants.ts`). Never inline metamodel strings as literals in code. diff --git a/CHANGELOG.md b/CHANGELOG.md index e265d9bffb..67191cb071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,24 @@ here.** the command and rolls the sync back. A stale `docs/CONFORMANCE.md` cell that claimed Python passed only 2 of the dependency corpus's cases is corrected. +- **Generated routes no longer accept or return fields they must not (#484).** A field marked + `@formExclude`, or a credential (a field that declares a `view.password`), is now **ignored** by + the generated create, PATCH and PUT in every port: the value is dropped, not rejected, so + an edit form that submits every field still saves, and a present `null` no longer clears it. A credential is + also never returned: not by list, get, or the echo of a write. Before this, `@formExclude` kept + a field out of the generated form while the route still wrote it, and a generated read returned password + hashes. Generated forms (React, Angular) and the api docs omit credentials too. Your own code is unaffected: + the entity, DTO and generated queries still carry the field, so you keep writing a hash or token yourself. + A project with neither mark generates byte-identical output. If you relied on a route writing a + `@formExclude` field, write it from your own code. Gated by the new `secret-fields/` sub-corpus + of the api-contract conformance corpus, in the generated lane of all five ports. + +- **`meta gen` and `meta verify --codegen` refuse an unknown codegen `dialect`** instead of silently + generating Postgres code. `dialect: "d1"` gets a message that says to use `"sqlite"` (D1 is + SQLite; `"d1"` names only the `meta migrate` transport). `meta init --d1` now scaffolds + `dialect: "sqlite"` in `metaobjects.config.ts`; it used to write `"d1"`. If your config has `dialect: "d1"`, + change it to `"sqlite"`. + - **A failed `meta deps sync` no longer leaves a lock and snapshot that cannot load.** The command wrote `deps.lock.json` and the `.metaobjects/deps/` snapshot before loading the combined model, and on a load failure it exited 1 but kept the new files. Because the lock @@ -128,6 +146,12 @@ here.** ### Documentation +- **New recipes and a real D1 example.** `docs/recipes/` gains keeping drizzle-kit migrations + (`drizzle-kit-migrations.md`), guarding generated routes with your own auth + (`guarding-generated-routes.md`) and proving your `meta verify` gate can fail + (`prove-your-gate-fails.md`). The Cloudflare Workers recipe now has a per-request `drizzle(env.DB)` + example, and the `drizzle({} as never)` placeholder and the wrong `"d1"` codegen-dialect statements are corrected. + - **Two doc errors corrected against the code.** `docs/recipes/mysql.md` said Python ships a Postgres driver only; it ships SQLite and MySQL drivers beside it. `docs/features/api-contract.md` said only TypeScript enforces the `in`-list cap of 100; every port does. diff --git a/agent-context/skills/metaobjects-audit/references/typescript.md b/agent-context/skills/metaobjects-audit/references/typescript.md index d4b42126a0..3fc99245c4 100644 --- a/agent-context/skills/metaobjects-audit/references/typescript.md +++ b/agent-context/skills/metaobjects-audit/references/typescript.md @@ -199,4 +199,4 @@ TypeScript is the reference implementation — it ships the full feature set: - Output-parser codegen: **complete** (`outputParser()` generator; `parse*`/`safeParse*`/`extract*` emitted as `.response.ts` per **responding `template.prompt`** — one carrying `@responseRef`; ADR-0052). - ObjectManager: **complete** (`@metaobjectsdev/runtime-ts`; `kyselyDriver` / `inMemoryDriver`). - Schema migrate / `meta verify --db`: **complete** (TS owns the shared migrate engine — ADR-0015). -- D1 dialect: **TS-only by design** — `dialect: "d1"` in `metaobjects.config.ts` targets Cloudflare D1 via the Wrangler CLI layout. Do NOT recommend porting D1 support to other languages. +- D1 dialect: **TS-only by design** — `migrate.dialect: "d1"` (and `meta migrate --dialect d1`) targets Cloudflare D1 via the Wrangler CLI layout; codegen's `dialect` in `metaobjects.config.ts` is `"sqlite"` for D1 and `"d1"` there is refused. Do NOT recommend porting D1 support to other languages. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 1a1a0ff34a..52b24e6f52 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -34,7 +34,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 117 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 12 projection + 37 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 119 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | | [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | @@ -350,9 +350,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc `report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates). -### `fixtures/api-contract-conformance/` (117) +### `fixtures/api-contract-conformance/` (119) -All 117 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port +All 119 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD routes answer identically over HTTP — list / get / create / patch+put / delete, plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1` @@ -363,11 +363,11 @@ The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the URL grammar `?filter[][]=` (FR-009). On top of the 31 core -scenarios the corpus carries seven sub-corpora — `tph/` (10, single-table +scenarios the corpus carries eight sub-corpora — `tph/` (10, single-table inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), `jsonb/` (5, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string), -`write-through/` (2, table-write + view-read entities), `projection/` (12, a +`write-through/` (2, table-write + view-read entities), `secret-fields/` (2, #484: a `@formExclude` or credential `view.password` field is ignored on create, PATCH and PUT, and a credential is never returned), `projection/` (12, a read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one @@ -377,7 +377,7 @@ its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` re a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java, Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and the port's own GENERATED API artifact booted over HTTP. `write-through/`, -`projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what +`secret-fields/`, `projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what they test is whether a port's generator emits the routes. The `report/` sub-corpus is the one that reads real views, so it runs full stack on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`, both TypeScript-produced) in every @@ -586,7 +586,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37 -+ render 16 + persistence 59 + api-contract 117 + source-resolution 25 + scope 10 + ++ render 16 + persistence 59 + api-contract 119 + source-resolution 25 + scope 10 + dependency 35) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance` and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/README.md b/docs/README.md index b958e2a88b..570618f065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -83,6 +83,9 @@ this tree is documentation, not the source of truth. | See a worked, non-toy model end to end (projections, value objects, TPH, prompt payloads on one runnable spine) | [`../examples/advanced-modeling/`](../examples/advanced-modeling/) | | Read the canonical spec (target-agnostic) | [`../spec/`](../spec/) | | Find a release recipe (Cloudflare D1, etc.) | [`recipes/`](recipes/) | +| Keep drizzle-kit migrations while MetaObjects owns the model | [`recipes/drizzle-kit-migrations.md`](recipes/drizzle-kit-migrations.md) | +| Put generated routes behind your own auth (Fastify, Hono, `expose`) | [`recipes/guarding-generated-routes.md`](recipes/guarding-generated-routes.md) | +| Show that your `meta verify` CI gate actually fails on drift | [`recipes/prove-your-gate-fails.md`](recipes/prove-your-gate-fails.md) | | See the deeper design rationale for a feature | [`superpowers/specs/`](superpowers/specs/) | ## Conventions used in these docs diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 91cbcf3fe5..5f00481474 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -529,6 +529,29 @@ log-only. | Kotlin | shipped — `metaobjects-codegen-kotlin` `KotlinSpringControllerGenerator` → Spring `@RestController` | One controller per writable entity (`source.rdb @kind="table"`) plus a READ-ONLY one per view-kind `object.projection` (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Filter operators ship via the generated `FilterAllowlist` (`KotlinFilterAllowlistGenerator`) + an inline `parseFilter` helper emitted in the controller. | | Python | shipped — `metaobjects.codegen.generators.router_generator` → FastAPI `APIRouter` | One router per writable entity (`source.rdb @kind="table"`) plus a READ-ONLY one per view-kind `object.projection` (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); `?sort`, `?limit/?offset`, `?withCount=1` envelope, 404 + 400 envelopes per the contract. Consumer wires the repository via FastAPI `app.dependency_overrides`; the generator emits a `Protocol` interface so the persistence layer (SQLAlchemy / asyncpg / etc.) is the consumer's choice. Filter operators ship via the generated `_filter_allowlist.py` (`filter_allowlist_generator.py`) + the shared `filter_parser` helper, wired into the list handler. | +## Credentials and excluded fields + +Two marks narrow what the generated routes accept and send, in every port (#484): + +| Mark | Create, PATCH, PUT | List, get, and the echo of a write | +|---|---|---| +| `@formExclude: true` | the field is **ignored** | returned | +| a child `view.password` (a credential) | the field is **ignored** | **never returned** (the key is absent, not `null`) | + +A value for an ignored field is dropped, never rejected: the request answers `200` or `201` as if the key +were absent, and a present `null` does not clear it. That is the rule for every key outside the settable +set (`@mutability: writeOnce` on PATCH, the TPH discriminator, `@autoSet: onCreate`), and it is load-bearing: +a generated edit form submits every field it was given, so a `400` would fail every save. A required field +that is also ignored can never be created through the API, so mark such a field optional. + +Only the generated HTTP boundary changes. The entity, its DTO and the generated queries still carry the +field, so your own code writes a hash or token and reads it back. Allowlists are unchanged: a field is +filterable or sortable only if you wrote `@filterable` or `@sortable` on it, so do not put those on a credential +(a filter on a secret answers whether a guess is right). To return a credential from a route, hand-write that +route; the generated one will not. Generated forms and the api docs omit both kinds of field as well. + +`fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port. + ## Read-only projections **All five ports serve an `object.projection` whose only source is a read-only diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index b7d4eb8767..cb042a19a3 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -30,6 +30,9 @@ export default defineConfig({ }); ``` +Already on drizzle-kit and want to keep its migrations? Re-export the generated tables from your own +schema file: [drizzle-kit migrations](../recipes/drizzle-kit-migrations.md). + A model of only value objects and sourceless projections needs no `dialect`. A sourceless object generates its type and nothing else: no table, no migration, no routes. See [Own your codegen](../features/own-your-codegen.md) for ejecting a generator, and the @@ -116,7 +119,7 @@ import { entityFile, queriesFile, routesFile, barrel, docsFile, apiDocsFile } fr export default defineConfig({ outDir: "src/generated", - dialect: "postgres", // "postgres" | "sqlite" | "d1" + dialect: "postgres", // "postgres" | "sqlite" | "mysql" (Cloudflare D1: "sqlite") extStyle: "js", // ".js"-extensioned imports — nodenext- AND bundler-safe ("none" to opt out) apiPrefix: "/api", dbImport: "../db", // the module routesFile() imports `db` from — yours to write @@ -683,7 +686,7 @@ import { entityFile, queriesFile, routesFileHono, barrel } from "@metaobjectsdev export default defineConfig({ outDir: "src/generated", - dialect: "sqlite", // or "postgres" / "d1" + dialect: "sqlite", // or "postgres" / "mysql". Cloudflare D1 is "sqlite"; "d1" is refused extStyle: "js", apiPrefix: "/api", generators: [entityFile(), queriesFile(), routesFileHono(), barrel()], @@ -723,7 +726,8 @@ export function registerAuthorRoutes( } ``` -Consumer wiring (Workers example): +Consumer wiring (Workers example). The routes take `db` when they are mounted, and a D1 +binding exists only on `env`, so build the client and mount inside `fetch`: ```ts import { Hono } from "hono"; @@ -732,11 +736,20 @@ import { registerAuthorRoutes } from "./generated/Author.routes.hono.js"; interface Env { DB: D1Database } -const app = new Hono<{ Bindings: Env }>(); -registerAuthorRoutes(app, { db: drizzle({} as never) }); // replace with c.env.DB-derived db at request time -export default app; +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const app = new Hono<{ Bindings: Env }>(); + registerAuthorRoutes(app, { db: drizzle(env.DB) }); // this request's binding + return app.fetch(request, env, ctx); + }, +}; ``` +The full Workers recipe (wrangler binding, queries, why `dialect` is `"sqlite"` for D1) is in +[Cloudflare Workers](../recipes/cloudflare-workers.md#generated-routes-and-queries-on-a-worker-per-request). +To put these routes behind your own authentication, see +[Guarding generated routes](../recipes/guarding-generated-routes.md). + The Hono flavor differs from Fastify in two intentional ways, both reflecting Hono idioms rather than contract drift: diff --git a/docs/recipes/cloudflare-workers.md b/docs/recipes/cloudflare-workers.md index e8264caf52..7243d1a51c 100644 --- a/docs/recipes/cloudflare-workers.md +++ b/docs/recipes/cloudflare-workers.md @@ -17,13 +17,13 @@ is V8-isolate-safe. The table below is the authoritative split: | `@metaobjectsdev/codegen-ts` | Build-time only | Same. | | `@metaobjectsdev/migrate-ts` | Build-time only | Migrations emit SQL, then `wrangler d1 migrations apply` runs them. | | `@metaobjectsdev/render` | ✅ Runtime-safe | Pure JS, no Node-only deps. Used at request time. | -| `@metaobjectsdev/runtime-ts` | ❌ Node-only | Fastify + Kysely drivers. Not for Workers. | +| `@metaobjectsdev/runtime-ts` | ⚠️ Only the `/hono` entry | The Fastify and Kysely entry points are Node-only. The `/hono` entry is what generated Hono routes import. | | `@metaobjectsdev/runtime-web` | ✅ Runtime-safe | Pure browser/edge JS. | | `@metaobjectsdev/react` | ✅ Runtime-safe | Browser; works in Workers if you render React on the edge. | | `@metaobjectsdev/tanstack` | ✅ Runtime-safe | Browser-side. | | Generated `.entity.ts` / `.queries.ts` | ✅ Runtime-safe | The generated code is the runtime; uses `drizzle-orm/d1`. | | Generated `.routes.ts` (Fastify, default) | ❌ Node-only | The handlers are Workers-safe but the Fastify adapter from `runtime-ts` is not. Use the Hono generator instead. | -| Generated Hono routes (`routesFileHono()`) | ✅ Runtime-safe | The opt-in Workers routes generator — emits Hono handlers with no `runtime-ts` dependency. Wire it in `metaobjects.config.ts` in place of `routesFile()`. | +| Generated Hono routes (`routesFileHono()`) | ✅ Runtime-safe | The opt-in Workers routes generator. It imports only the `@metaobjectsdev/runtime-ts/hono` entry point (Hono, Drizzle, Zod; no Fastify, Kysely or `node:` import in that module graph). Wire it in `metaobjects.config.ts` in place of `routesFile()`. | ## Project layout @@ -123,42 +123,101 @@ agree (default `migrations/` matches Wrangler's expectation). For the full D1 dialect documentation, see the [`@metaobjectsdev/migrate-ts` README](../../server/typescript/packages/migrate-ts/README.md). -## Skipping the routes generator +## Codegen dialect is `sqlite`, not `d1` -Workers consumers typically skip the routes generator entirely — Fastify-based -routes don't run in V8 isolates. Hand-write your `fetch` handler against the -generated queries: +D1 is SQLite. In `metaobjects.config.ts` set `dialect: "sqlite"`: the generated tables are +`sqliteTable`, timestamps are ISO strings, and the routes use SQLite `RETURNING`. `"d1"` names +only the **`meta migrate` transport** (`migrate.dialect` in `.metaobjects/config.json`, +`--dialect d1`), which shells out to Wrangler. `meta gen` and `meta verify --codegen` refuse any +codegen dialect other than `sqlite`, `postgres` or `mysql`, and say so for `"d1"`, rather than +quietly generating Postgres. + +## Generated routes and queries on a Worker, per request + +Use `routesFileHono()` (not the Fastify `routesFile()`) and `queriesFile()`: + +```ts +// metaobjects.config.ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { entityFile, queriesFile, routesFileHono, barrel } from "@metaobjectsdev/codegen-ts/generators"; + +export default defineConfig({ + outDir: "src/generated", + dialect: "sqlite", // D1 is SQLite + extStyle: "js", + apiPrefix: "/api", + generators: [entityFile(), queriesFile(), routesFileHono(), barrel()], +}); +``` + +Neither generated module imports a module-level `db`. Both take it as an argument: + +- `Note.queries.ts` exports `listNotes(db, opts?)`, `findNoteById(db, id)`, `createNote(db, ...)` + and so on, each with `db` as the first parameter. +- `Note.routes.hono.ts` exports `registerNoteRoutes(app, { db })`. The `db` is captured when the + routes are **mounted**, not looked up per call. + +A D1 binding only exists on `env`, inside `fetch`, so build the Drizzle client there and mount +the generated routes on a Hono app for that request: ```ts // src/worker.ts +import { Hono } from "hono"; import { drizzle } from "drizzle-orm/d1"; -import { findUserById, listUsers } from "./generated/User.queries"; -import * as schema from "./generated/schema"; +import { registerNoteRoutes } from "./generated/Note.routes.hono.js"; +import { listNotes } from "./generated/Note.queries.js"; + +interface Env { + DB: D1Database; // from @cloudflare/workers-types +} export default { - async fetch(request: Request, env: Env): Promise { - const db = drizzle(env.DB, { schema }); - const url = new URL(request.url); + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const db = drizzle(env.DB); // one client per request - if (url.pathname.startsWith("/api/users/")) { - const id = url.pathname.slice("/api/users/".length); - const user = await findUserById(db, id); - return Response.json(user); - } - if (url.pathname === "/api/users") { - return Response.json(await listUsers(db, {})); + const url = new URL(request.url); + if (url.pathname === "/health") { + return Response.json({ notes: (await listNotes(db, {})).length }); // queries: plain calls } - // Static assets handled by Workers Static Assets binding. - return env.ASSETS.fetch(request); + + const app = new Hono<{ Bindings: Env }>(); + registerNoteRoutes(app, { db }); // routes: mounted with this request's db + return app.fetch(request, env, ctx); }, }; +``` -interface Env { - DB: D1Database; - ASSETS: Fetcher; -} +Registering the five routes is cheap, so doing it per request is fine. If you would rather mount +once, build the app lazily on the first request (`let app: Hono | undefined`) and keep it for the +isolate: the binding on `env` does not change for the life of the deployed version. Do not +mount at module scope, where `env` does not exist yet, and do not pass a placeholder such as +`drizzle({} as never)`: it type-checks and then fails on the first query. + +Add your guard before the mount: [Guarding generated routes](guarding-generated-routes.md). + +Wrangler needs only the binding the code reads: + +```toml +# wrangler.toml +name = "myapp" +main = "./src/worker.ts" +compatibility_date = "2026-01-01" +compatibility_flags = ["nodejs_compat"] + +[[d1_databases]] +binding = "DB" # env.DB above +database_name = "myapp-prod" +database_id = "" +migrations_dir = "migrations" # where `meta migrate --dialect d1` writes ``` -> Note: `findUserById(db, id)` parameter-passing landing in 0.7.0 (FR2). -> If you're on 0.6.x today, the generated queries import a module-level `db` -> from `../db` instead — see the [0.6.0 release notes](../../CHANGELOG.md) for migration guidance. +### What was checked + +Generated from a one-entity model with `dialect: "sqlite"`, then compiled with `tsc --strict` +(`module: nodenext`) together with the `worker.ts` above against the real `drizzle-orm/d1`, +`hono` and `@metaobjectsdev/runtime-ts` types. `D1Database` and `ExecutionContext` came from a +local stub in place of `@cloudflare/workers-types`, which that environment does not install. +The same per-request mount (a new Hono app and `registerNoteRoutes` per request) was run against +an in-memory libSQL database, which shares D1's SQLite dialect: `POST /api/notes` answered 201 +and `GET /api/notes` answered 200 with the row. D1 itself, `wrangler dev` and a deployed Worker +were not exercised. diff --git a/docs/recipes/drizzle-kit-migrations.md b/docs/recipes/drizzle-kit-migrations.md new file mode 100644 index 0000000000..e4d663e70a --- /dev/null +++ b/docs/recipes/drizzle-kit-migrations.md @@ -0,0 +1,90 @@ +# Recipe: keep drizzle-kit as your migration tool + +You already run `drizzle-kit generate` / `drizzle-kit migrate`, and you want MetaObjects to own +the **model** (entities, Zod schemas, queries, routes) without taking over your **migrations**. +That is a supported split: the TypeScript `entity` generator emits ordinary Drizzle tables +(`pgTable`, `sqliteTable` or `mysqlTable`, matching `dialect`), and drizzle-kit only needs a +schema file that exports them. `meta migrate` never has to run. + +## Wire it + +1. Generate as usual. `entityFile()` writes the table beside its Zod schemas: + + ```ts + // metaobjects.config.ts + import { defineConfig } from "@metaobjectsdev/cli"; + import { entityFile, queriesFile, barrel } from "@metaobjectsdev/codegen-ts/generators"; + + export default defineConfig({ + outDir: "src/generated", + dialect: "postgres", // must match the database drizzle-kit migrates + generators: [entityFile(), queriesFile(), barrel()], + }); + ``` + +2. Write **your own** schema file and re-export the generated tables from it. This file is + yours: `meta gen` never touches it, so it is where you also add hand-written tables that are + not in the model. + + ```ts + // src/db/schema.ts + export { customers, orders } from "../generated/Customer.js"; // name the tables you want migrated + // or, to follow the model as it grows: + // export * from "../generated/index.js"; + export * from "./hand-written-tables.js"; + ``` + + `export *` also re-exports Zod schemas and types. drizzle-kit ignores everything that is not + a table, view or enum, so that is harmless; name the tables if you would rather keep the + file explicit. + +3. Point drizzle-kit at that file, not at `src/generated`: + + ```ts + // drizzle.config.ts + import { defineConfig } from "drizzle-kit"; + + export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema.ts", + out: "./drizzle", + }); + ``` + +4. The loop is now: edit `metaobjects/*.json`, run `meta gen`, run `drizzle-kit generate`, + review the SQL, run `drizzle-kit migrate`. + +Keep one tool per database. Do not also run `meta migrate` against the same database: it +diffs the model against the live schema and would write a second, competing migration chain. +`meta verify --db ` is read-only and still useful as a drift check beside drizzle-kit: +it reports where the live database no longer matches the model, whoever applied the DDL. + +## What was checked + +The re-export was run against the real `entityFile()` output (SQLite dialect): both the named +re-export and `export *` from the barrel expose the generated table to Drizzle's own table +detection (`is(value, SQLiteTable)`), with the model's column names. drizzle-kit itself was not +installed in the checking environment, so the `drizzle-kit generate` step is the documented +drizzle-kit contract (it scans the schema file's exports), not something this repository gates. + +## When to prefer `meta migrate` instead + +Choose `meta migrate` (see [migrations and drift](../features/migrations-and-drift.md)) when +you want the schema derived from the model rather than from the generated Drizzle code: + +- **No second source of truth.** With drizzle-kit, the chain of record is the diff of the + generated TypeScript; with `meta migrate` it is the model itself, and `meta verify --db` + and `meta verify --replay` gate the chain against the model. +- **Things the model owns that a table export does not carry.** SQL views for projections and + `object.report` ([reporting](../features/reporting.md)) are created and kept in step by + `meta migrate`, including recreating a view when a table it reads changes. +- **Renames and populated tables.** `meta migrate` resolves renames (`--on-ambiguous`) and + treats destructive changes on populated tables explicitly + ([Renames and populated tables](../features/migrations-and-drift.md#renames-and-populated-tables)). +- **Cloudflare D1.** `meta migrate --dialect d1` writes Wrangler's migration layout and + handles SQLite's table-rebuild rules ([Cloudflare Workers recipe](cloudflare-workers.md)). +- **MySQL is the exception:** MetaObjects does not own a MySQL schema, so drizzle-kit (or + hand-written DDL) is the only option ([MySQL recipe](mysql.md)). + +You can adopt `meta migrate` later without changing the generated code. Baseline from the live +database with `meta migrate baseline --from-db --db ` and stop running drizzle-kit. diff --git a/docs/recipes/guarding-generated-routes.md b/docs/recipes/guarding-generated-routes.md new file mode 100644 index 0000000000..ceb4bbead0 --- /dev/null +++ b/docs/recipes/guarding-generated-routes.md @@ -0,0 +1,86 @@ +# Recipe: mount generated routes inside your own auth scope + +Generated CRUD routes are **unauthenticated**. Nothing in the metamodel says otherwise and +nothing should: whether a caller may act is not derivable from a model, so there is no `@auth` +attribute. You guard the routes with your framework's own composition. No edit to the generated +file is needed. + +This is the same recipe the generated routes file prints in its handler's JSDoc. Both +snippets below are exercised against the real mount helpers by +`server/typescript/packages/runtime-ts/test/route-auth-seam.test.ts`; the text is produced by +`authSeamJsDoc` in `server/typescript/packages/codegen-ts/src/routes-expose.ts`. If you change a recipe's shape, +change that test first. + +## Fastify + +Register the generated handler inside a scope that carries your hook. Fastify hooks are +encapsulated per plugin and inherited by child scopes, so the hook reaches through the generated +handler's own `register(..., { prefix })`: + +```ts +import { noteRoutes } from "./generated/Note.routes.js"; + +app.register(async (s) => { + s.addHook("preHandler", requireAuth); // your hook: 401 without credentials + await noteRoutes(s); // Fastify spells it `Routes` +}); + +app.get("/health", async () => ({ ok: true })); // outside the scope: stays open +``` + +Every verb (list, get, create, update, delete) is guarded. Routes registered outside the scope +are not. Use `preHandler`, not `onRequest`, if your check needs the parsed body. + +## Hono + +Put middleware on the mount path **with a trailing `/*`**, before calling the generated +register function: + +```ts +import { registerNoteRoutes } from "./generated/Note.routes.hono.js"; + +app.use(`/api${Note.$path}/*`, requireAuth); // `${apiPrefix}${Entity.$path}/*` +registerNoteRoutes(app, { db }); +``` + +The wildcard is load-bearing: in Hono `"/api/notes/*"` also matches the collection path +`/api/notes` itself, so one `app.use` covers list and create as well as every `/:id` route. +Without it, `GET /api/notes` and `POST /api/notes` stay open while the recipe looks right. +Order matters: `app.use` must be registered **before** the routes it should guard. + +## Narrow what is mounted with `expose` + +A guard answers "is this caller authenticated?". It cannot answer "may this caller see **this +row**". For an entity holding credentials, per-user rows, or anything a list endpoint would hand +to the wrong person, stock CRUD is not safe at any verb. Hand-write those verbs and narrow the +generated file to what remains: + +```ts +import { routesFile, routesFileHono } from "@metaobjectsdev/codegen-ts/generators"; + +generators: [ + routesFile({ expose: ["list", "get"] }), // Fastify + routesFileHono({ expose: ["list", "get"] }), // Hono +] +``` + +`expose` takes any subset of `"list" | "get" | "create" | "update" | "delete"`, or a function +`(entity) => verbs | undefined` to choose per entity (`undefined` means all five). An unlisted verb +is not mounted at all: with `expose: ["list", "get"]` a `POST /api/notes` answers 404, whatever +the caller's credentials. A discriminated-union base and a read-only projection are already +narrower than the five verbs, and `expose` can only narrow them further, never widen. `expose` +is a generator option, not metadata, because which verbs a deployment serves is a property of +the app and not of the shared model. + +## What was checked + +- The runtime-ts test above asserts, for both frameworks, `401` on all five verbs without the + header, `200` with it, and that a sibling route outside the guarded scope stays open. +- A model generated with `routesFile({ expose: ["list", "get"] })` and + `routesFileHono({ expose: ["list", "get"] })` emits `expose: ["list", "get"]` in the mount call. + Run against in-memory SQLite behind the Hono recipe: `GET /api/notes` answered 401 without + credentials and 200 with them, and `POST /api/notes` and `DELETE /api/notes/1` answered 404 + with them. + +See also: [TypeScript port, Hono variant](../ports/typescript.md#hono-variant-workers--bun--edge), +[Cloudflare Workers](cloudflare-workers.md). diff --git a/docs/recipes/precommit-meta-verify.md b/docs/recipes/precommit-meta-verify.md index ae96613bcf..92d1e9ed9b 100644 --- a/docs/recipes/precommit-meta-verify.md +++ b/docs/recipes/precommit-meta-verify.md @@ -40,6 +40,8 @@ won't block a commit. Silence it with `--no-antipatterns` or `META_NO_ANTIPATTER Text output caps it at 20 lines (raise with `--limit ` / `--limit all`); a `--format json`/`toon` run carries every finding, uncapped. +Before you trust the hook, prove it can fail: [Prove your gate fails](prove-your-gate-fails.md). + ## Choose your hook manager The MetaObjects framework is hook-manager-agnostic. Three common setups: diff --git a/docs/recipes/prove-your-gate-fails.md b/docs/recipes/prove-your-gate-fails.md new file mode 100644 index 0000000000..68b1706bf9 --- /dev/null +++ b/docs/recipes/prove-your-gate-fails.md @@ -0,0 +1,61 @@ +# Recipe: prove your `meta verify` gate can fail + +A drift gate that has never gone red is not yet a gate. `meta verify` exits 0 on a tree it +checked, and also on a tree it checked nothing of: a config with `generators: []`, a wrong +working directory, a gate that was never wired into CI. So seed a deliberate break once, watch +the exit code flip to 1, and keep that test. + +The runnable version, which this repository runs on every change, is +[`verify-gate-not-vacuous.test.ts`](../../server/typescript/packages/cli/test/integration/verify-gate-not-vacuous.test.ts). +It works in a throwaway project and asserts, in this order: + +| Step | State | `meta verify` exit | +|---|---|---| +| 0 | `meta gen` ran, so the gate has generated files to compare | n/a | +| 1 | Clean tree | **0** | +| 2 | A field added to the metadata, `meta gen` not re-run | **1** (codegen gate) | +| 3 | Metadata restored | **0** (the failure was the break, not the harness) | +| 4 | A hand edit inside a generated file | **0** by default, **1** with `meta verify --codegen --forbid-hand-edits` | +| 5 | A generated file deleted | **1** | +| 6 | `meta gen` re-run | **0** | + +Step 4 is deliberate: `meta gen` three-way-merges hand edits, so a hand-edited generated file +is a notice, not drift. If your policy is "generated code is never touched", gate on +`--forbid-hand-edits`. A second test in the same file pins why step 0 exists: a project with no +generators passes `meta verify` with exit 0 having checked nothing. + +## The same check in a shell, for your own CI + +```bash +set -u +meta gen || exit 1 +meta verify || { echo "clean tree must pass"; exit 1; } + +# Seed a break: change the model without regenerating. +cp metaobjects/meta.shop.json /tmp/meta.shop.json.bak +sed -i 's/"name": "email"/"name": "emailAddress"/' metaobjects/meta.shop.json + +if meta verify; then echo "GATE IS VACUOUS: drifted tree passed"; status=1; else status=0; fi +cp /tmp/meta.shop.json.bak metaobjects/meta.shop.json +meta verify || status=1 # restored tree must pass again +exit $status +``` + +Adjust the file and the string to a field in your own model. Run it once when you wire the gate +(and after any change to `generators`, `sources` or the CI job), not on every build. Never leave +the break in the working tree: the restore is part of the check. + +## As a bun:test in your repo + +Copy the integration test above. It needs only the built `meta` binary on `PATH` (or +`node node_modules/@metaobjectsdev/cli/dist/bin/meta.js`), a project with at least one generator +declared, and a way to run `meta` with `cwd` set to a temp copy of the project. Replace its +`newShopProject` helper with a copy of your own fixture directory. + +## What was checked + +`bun test test/integration/verify-gate-not-vacuous.test.ts` in `server/typescript/packages/cli` +against the built CLI: both tests pass (steps 1-6 above, plus the empty-generators case). The +shell snippet is the same sequence with `sed` as the break and was not run separately. + +See also: [pre-commit `meta verify`](precommit-meta-verify.md) for wiring the gate itself. diff --git a/docs/recipes/wiring-generated-queries.md b/docs/recipes/wiring-generated-queries.md index ad15832d74..80262a3a0d 100644 --- a/docs/recipes/wiring-generated-queries.md +++ b/docs/recipes/wiring-generated-queries.md @@ -280,10 +280,10 @@ export default defineConfig({ }); ``` -> **Note for D1 consumers:** codegen-ts only knows `"sqlite" | "postgres"`. D1 is -> SQLite at the SQL level, so codegen uses `dialect: "sqlite"`. The D1-specific -> `--dialect d1` is a `meta migrate` CLI flag (it controls migration transport + -> file layout), not a codegen config value. +> **Note for D1 consumers:** codegen's `dialect` is `"sqlite" | "postgres" | "mysql"`. D1 is +> SQLite at the SQL level, so codegen uses `dialect: "sqlite"`. `--dialect d1` is a +> `meta migrate` CLI flag (migration transport + file layout), not a codegen config value, and +> `dialect: "d1"` in `metaobjects.config.ts` is refused with an error. ## Migration from 0.6.0 diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 067e9460ee..f437a428cb 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -69,6 +69,7 @@ import { isReport, itemRouteField, decimalColumnsLine, + writeDeniedColumnsLine, servesReadApi, formatTs, entityOutputPath, @@ -308,7 +309,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -333,7 +334,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -633,7 +634,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts index 930c44d394..78baf3f97a 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/index.ts @@ -29,7 +29,7 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withWire, type WireOptions } from "../wire-format.js"; +import { stripDeniedColumns, withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; @@ -72,6 +72,13 @@ export interface CrudRoutesOptions extends WireOptions { insertSchema: ZodTypeAny; /** Zod schema for update payloads (typically `UpdateSchema`). */ updateSchema: ZodTypeAny; + /** + * Fields the API never writes (`@formExclude`, or a credential `view.password`). A key + * named here is stripped from a create or update body before validation, so the value never + * reaches the table; it is ignored, not rejected. The generated route passes them; the + * schemas stay whole for the application's own writes. + */ + writeDeniedColumns?: readonly string[]; /** Limit which verbs are mounted. Defaults to all five. */ expose?: readonly CrudVerb[]; /** @@ -274,7 +281,7 @@ export function mountGetRoute(opts: VerbOptions): void { export function mountCreateRoute(opts: VerbOptions): void { const toWire = echoWire(opts); opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => { - const parsed = opts.insertSchema.safeParse(req.body); + const parsed = opts.insertSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } @@ -315,7 +322,7 @@ export function mountUpdateRoute(opts: VerbOptions): void { reply: { code: (n: number) => { send: (b: unknown) => unknown } }, ) => { const { id } = req.params as { id: string }; - const parsed = opts.updateSchema.safeParse(req.body); + const parsed = opts.updateSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } diff --git a/examples/advanced-modeling/codegen/runtime/wire-format.ts b/examples/advanced-modeling/codegen/runtime/wire-format.ts index 086147c555..a24cdcedc5 100644 --- a/examples/advanced-modeling/codegen/runtime/wire-format.ts +++ b/examples/advanced-modeling/codegen/runtime/wire-format.ts @@ -43,6 +43,37 @@ export interface WireOptions { readonly nullMembers?: NullMembersMode; /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ readonly valueObjects?: Readonly>; + /** + * Credential fields (a `view.password` field): removed from every row sent, on list, get, + * the echo of a create or update, and an M:N traversal. The generated route passes them; + * absent, rows are sent as read. + */ + readonly hiddenColumns?: readonly string[]; +} + +/** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ +export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { + if (hidden === undefined || hidden.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const column of hidden) delete out[column]; + return out; + }; +} + +/** + * Remove `denied` keys from a request body, before it is validated. The route's schema is the + * entity's shared `Insert`/`Update` schema, which app code writes through too, so the API + * boundary strips here and the schema stays whole. A key stripped is IGNORED, never rejected + * (the uniform rule for every key outside the settable set, `@mutability: writeOnce` on PATCH + * among them): a generated edit form submits every field it was given. + */ +export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { + if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; + const out = { ...body }; + for (const column of denied) delete out[column]; + return out; } function isPlainObject(v: unknown): v is Record { @@ -95,9 +126,11 @@ export function nullMembersWire( }; } -/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +/** A wire mapper: `base` (timestamps), then the decimal rewrite, the null-member rewrite, then the credential strip. */ export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); const members = nullMembersWire(opts.valueObjects, opts.nullMembers); - return members === undefined ? decimals : (row) => members(decimals(row)); + const hidden = hiddenColumnsWire(opts.hiddenColumns); + const shaped = members === undefined ? decimals : (row: unknown) => members(decimals(row)); + return hidden === undefined ? shaped : (row) => hidden(shaped(row)); } diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 067e9460ee..f437a428cb 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -69,6 +69,7 @@ import { isReport, itemRouteField, decimalColumnsLine, + writeDeniedColumnsLine, servesReadApi, formatTs, entityOutputPath, @@ -308,7 +309,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -333,7 +334,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -633,7 +634,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts index 930c44d394..78baf3f97a 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/index.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/index.ts @@ -29,7 +29,7 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withWire, type WireOptions } from "../wire-format.js"; +import { stripDeniedColumns, withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; @@ -72,6 +72,13 @@ export interface CrudRoutesOptions extends WireOptions { insertSchema: ZodTypeAny; /** Zod schema for update payloads (typically `UpdateSchema`). */ updateSchema: ZodTypeAny; + /** + * Fields the API never writes (`@formExclude`, or a credential `view.password`). A key + * named here is stripped from a create or update body before validation, so the value never + * reaches the table; it is ignored, not rejected. The generated route passes them; the + * schemas stay whole for the application's own writes. + */ + writeDeniedColumns?: readonly string[]; /** Limit which verbs are mounted. Defaults to all five. */ expose?: readonly CrudVerb[]; /** @@ -274,7 +281,7 @@ export function mountGetRoute(opts: VerbOptions): void { export function mountCreateRoute(opts: VerbOptions): void { const toWire = echoWire(opts); opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => { - const parsed = opts.insertSchema.safeParse(req.body); + const parsed = opts.insertSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } @@ -315,7 +322,7 @@ export function mountUpdateRoute(opts: VerbOptions): void { reply: { code: (n: number) => { send: (b: unknown) => unknown } }, ) => { const { id } = req.params as { id: string }; - const parsed = opts.updateSchema.safeParse(req.body); + const parsed = opts.updateSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } diff --git a/examples/showcase/codegen/runtime/wire-format.ts b/examples/showcase/codegen/runtime/wire-format.ts index 086147c555..a24cdcedc5 100644 --- a/examples/showcase/codegen/runtime/wire-format.ts +++ b/examples/showcase/codegen/runtime/wire-format.ts @@ -43,6 +43,37 @@ export interface WireOptions { readonly nullMembers?: NullMembersMode; /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ readonly valueObjects?: Readonly>; + /** + * Credential fields (a `view.password` field): removed from every row sent, on list, get, + * the echo of a create or update, and an M:N traversal. The generated route passes them; + * absent, rows are sent as read. + */ + readonly hiddenColumns?: readonly string[]; +} + +/** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ +export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { + if (hidden === undefined || hidden.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const column of hidden) delete out[column]; + return out; + }; +} + +/** + * Remove `denied` keys from a request body, before it is validated. The route's schema is the + * entity's shared `Insert`/`Update` schema, which app code writes through too, so the API + * boundary strips here and the schema stays whole. A key stripped is IGNORED, never rejected + * (the uniform rule for every key outside the settable set, `@mutability: writeOnce` on PATCH + * among them): a generated edit form submits every field it was given. + */ +export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { + if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; + const out = { ...body }; + for (const column of denied) delete out[column]; + return out; } function isPlainObject(v: unknown): v is Record { @@ -95,9 +126,11 @@ export function nullMembersWire( }; } -/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +/** A wire mapper: `base` (timestamps), then the decimal rewrite, the null-member rewrite, then the credential strip. */ export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); const members = nullMembersWire(opts.valueObjects, opts.nullMembers); - return members === undefined ? decimals : (row) => members(decimals(row)); + const hidden = hiddenColumnsWire(opts.hiddenColumns); + const shaped = members === undefined ? decimals : (row: unknown) => members(decimals(row)); + return hidden === undefined ? shaped : (row) => hidden(shaped(row)); } diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md index d4b42126a0..3fc99245c4 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/typescript.md @@ -199,4 +199,4 @@ TypeScript is the reference implementation — it ships the full feature set: - Output-parser codegen: **complete** (`outputParser()` generator; `parse*`/`safeParse*`/`extract*` emitted as `.response.ts` per **responding `template.prompt`** — one carrying `@responseRef`; ADR-0052). - ObjectManager: **complete** (`@metaobjectsdev/runtime-ts`; `kyselyDriver` / `inMemoryDriver`). - Schema migrate / `meta verify --db`: **complete** (TS owns the shared migrate engine — ADR-0015). -- D1 dialect: **TS-only by design** — `dialect: "d1"` in `metaobjects.config.ts` targets Cloudflare D1 via the Wrangler CLI layout. Do NOT recommend porting D1 support to other languages. +- D1 dialect: **TS-only by design** — `migrate.dialect: "d1"` (and `meta migrate --dialect d1`) targets Cloudflare D1 via the Wrangler CLI layout; codegen's `dialect` in `metaobjects.config.ts` is `"sqlite"` for D1 and `"d1"` there is refused. Do NOT recommend porting D1 support to other languages. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md index d4b42126a0..3fc99245c4 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/typescript.md @@ -199,4 +199,4 @@ TypeScript is the reference implementation — it ships the full feature set: - Output-parser codegen: **complete** (`outputParser()` generator; `parse*`/`safeParse*`/`extract*` emitted as `.response.ts` per **responding `template.prompt`** — one carrying `@responseRef`; ADR-0052). - ObjectManager: **complete** (`@metaobjectsdev/runtime-ts`; `kyselyDriver` / `inMemoryDriver`). - Schema migrate / `meta verify --db`: **complete** (TS owns the shared migrate engine — ADR-0015). -- D1 dialect: **TS-only by design** — `dialect: "d1"` in `metaobjects.config.ts` targets Cloudflare D1 via the Wrangler CLI layout. Do NOT recommend porting D1 support to other languages. +- D1 dialect: **TS-only by design** — `migrate.dialect: "d1"` (and `meta migrate --dialect d1`) targets Cloudflare D1 via the Wrangler CLI layout; codegen's `dialect` in `metaobjects.config.ts` is `"sqlite"` for D1 and `"d1"` there is refused. Do NOT recommend porting D1 support to other languages. diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index cb5eee0703..08171b7d7d 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -52,8 +52,8 @@ fixtures/api-contract-conformance/ └── filter-invalid-value.yaml # uncoercible value → 400; the fourth envelope, previously ungated ``` -Beside the 31 core scenarios above sit seven **sub-corpora**, each a directory with its -own `meta.json`, `seed.json`, `scenarios/` and `README.md` (75 scenarios in all, 106 with the +Beside the 31 core scenarios above sit eight **sub-corpora**, each a directory with its +own `meta.json`, `seed.json`, `scenarios/` and `README.md` (88 scenarios in all, 119 with the core): | Directory | Scenarios | Gates | Lanes | @@ -62,6 +62,7 @@ core): | `m2m/` | 9 | M:N traversal, and TPH x M:N together | both | | `jsonb/` | 4 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both | | `write-through/` | 2 | an entity that writes a table and reads a view | generated only | +| `secret-fields/` | 2 | #484: a `@formExclude` or credential (`view.password`) field is ignored on create, PATCH and PUT, and a credential is never returned | generated only | | `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | | `report/` | 27 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | | `decimal/` | 11 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | diff --git a/fixtures/api-contract-conformance/secret-fields/README.md b/fixtures/api-contract-conformance/secret-fields/README.md new file mode 100644 index 0000000000..6e3326cdf4 --- /dev/null +++ b/fixtures/api-contract-conformance/secret-fields/README.md @@ -0,0 +1,49 @@ +# `api-contract-conformance/secret-fields/` — #484 credentials and excluded fields + +Cross-port REST contract for the fields a generated API must not expose or accept: a +field excluded from forms (`@formExclude`) and a credential (a field that declares a +`view.password`). + +The model is one `Account`: + +| Field | Marked | Generated write routes | Generated read routes | +|-------------|-----------------------------|------------------------|-----------------------| +| `name` | (nothing) | accepted | returned | +| `tokenHash` | `@formExclude: true` | **ignored** | returned | +| `pinHash` | child `view.password` | **ignored** | **never returned** | + +## The contract + +- **A `@formExclude` or credential field is not writable through create, PATCH or PUT.** The + value is ignored, not rejected: the request answers as if the key were absent. That is the rule + for every key outside the settable set (`@mutability: writeOnce` on PATCH, the TPH discriminator, + `@autoSet: onCreate`), and it is load-bearing here too: a generated edit form submits every + field it was given, so a `400` on a present key would fail every save. +- **A present `null` is a write**, so it does not reach a denied column either. +- **A credential is never returned**: not by list, not by get, not in the echo of a create or + an update. The key is absent from the object, not `null`. +- **`@formExclude` alone does not hide a field.** It says how the field is written, and the + field is still part of the resource. +- The application still writes these fields through its own code (the generated queries, the + repository, the ORM). Only the generated HTTP boundary refuses them. + +## Why `pinHash` is not asserted on the way in + +A credential is never read, so no scenario can observe whether a write reached it. Its write +path is the same predicate as `tokenHash`'s (`@formExclude` or credential), and each port's +codegen tests pin that the generated create and update paths name both fields. + +## Files + +``` +secret-fields/ +├── README.md +├── meta.json # Account (accounts table) +├── seed.json # two rows, both secrets set +└── scenarios/ + ├── read-omits-credential.yaml # GET by id and list: pinHash absent, tokenHash present + └── write-ignores-excluded.yaml # POST / PATCH / PUT / present-null leave tokenHash alone +``` + +Generated lane only: what is under test is whether each port's GENERATOR emits routes that +honour the marks, which a hand-rolled reference server would satisfy by construction. diff --git a/fixtures/api-contract-conformance/secret-fields/meta.json b/fixtures/api-contract-conformance/secret-fields/meta.json new file mode 100644 index 0000000000..d47906d8ad --- /dev/null +++ b/fixtures/api-contract-conformance/secret-fields/meta.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "package": "acme::secrets", + "children": [ + { "object.entity": { + "name": "Account", + "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 100, "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "tokenHash", "@maxLength": 100, "@formExclude": true } }, + { "field.string": { "name": "pinHash", "@maxLength": 100, "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + ] + }} + ] + } +} diff --git a/fixtures/api-contract-conformance/secret-fields/scenarios/read-omits-credential.yaml b/fixtures/api-contract-conformance/secret-fields/scenarios/read-omits-credential.yaml new file mode 100644 index 0000000000..92f48eb50f --- /dev/null +++ b/fixtures/api-contract-conformance/secret-fields/scenarios/read-omits-credential.yaml @@ -0,0 +1,33 @@ +name: read-omits-credential +description: > + A credential field (`view.password`, "masked input for sensitive values") is never + returned by a generated read route. `pinHash` is stored on every seeded row and is + absent from GET by id and from the list; `equals` is a deep-equal, so it asserts the + ABSENCE of the key and not merely that the value differs. + + `tokenHash` is `@formExclude` only. Excluding a field from a form is about WRITING it; + the field is still part of the resource and is returned. +requests: + - id: r1 + method: GET + path: /api/accounts/1 + expect: + status: 200 + body: + equals: + id: 1 + name: "Ada Lovelace" + tokenHash: "token-hash-1" + - id: r2 + method: GET + path: /api/accounts?sort=id + expect: + status: 200 + body: + equals: + - id: 1 + name: "Ada Lovelace" + tokenHash: "token-hash-1" + - id: 2 + name: "Alan Turing" + tokenHash: "token-hash-2" diff --git a/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml b/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml new file mode 100644 index 0000000000..bb0762f734 --- /dev/null +++ b/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml @@ -0,0 +1,72 @@ +name: write-ignores-excluded +description: > + A field excluded from forms (`@formExclude`) or marked as a credential (`view.password`) + is not writable through the generated routes: create, PATCH and PUT all leave it as it + was. The value is IGNORED, never rejected (HTTP 200/201, not 400), the same rule as + `@mutability: writeOnce` on PATCH (`writeonce-patch-stripped`), because a generated + edit form submits every field it was given. + + The ordinary `name` rides in each body DELIBERATELY: it is applied, and that pairing is + what makes the assertion about the field and not about the request being ignored whole. + `tokenHash` is the observable one (it is still returned); `pinHash` is hidden on read, + so its write is pinned by the read side (`read-omits-credential`) together with the + per-port codegen tests of the generated write path. +requests: + - id: r1 + method: PATCH + path: /api/accounts/1 + body: + name: "Ada L. (probe)" + tokenHash: "forged" + pinHash: "forged" + expect: + status: 200 + body: + equals: + id: 1 + name: "Ada L. (probe)" + tokenHash: "token-hash-1" + - id: r2 + method: PUT + path: /api/accounts/2 + body: + name: "Alan T. (probe)" + tokenHash: "forged" + pinHash: "forged" + expect: + status: 200 + body: + equals: + id: 2 + name: "Alan T. (probe)" + tokenHash: "token-hash-2" + - id: r3 + method: POST + path: /api/accounts + body: + name: "Grace Hopper" + tokenHash: "forged" + pinHash: "forged" + expect: + status: 201 + body: + row: + name: "Grace Hopper" + # Never written: the column stays null, and `forged` never landed. + tokenHash: null + hasId: true + - id: r4 + method: PATCH + path: /api/accounts/1 + body: + # Clearing is a write too: a present null must not reach a denied column. + name: "Ada L. (probe 2)" + tokenHash: null + pinHash: null + expect: + status: 200 + body: + equals: + id: 1 + name: "Ada L. (probe 2)" + tokenHash: "token-hash-1" diff --git a/fixtures/api-contract-conformance/secret-fields/seed.json b/fixtures/api-contract-conformance/secret-fields/seed.json new file mode 100644 index 0000000000..6697ba1fcb --- /dev/null +++ b/fixtures/api-contract-conformance/secret-fields/seed.json @@ -0,0 +1,6 @@ +{ + "rows": [ + { "id": 1, "name": "Ada Lovelace", "tokenHash": "token-hash-1", "pinHash": "pin-hash-1" }, + { "id": 2, "name": "Alan Turing", "tokenHash": "token-hash-2", "pinHash": "pin-hash-2" } + ] +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs new file mode 100644 index 0000000000..ab51dd4097 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs @@ -0,0 +1,103 @@ +// SecretFieldsCodegenTests — #484: the generated routes ignore a write-denied key (@formExclude +// or a credential) and never send a credential. The HTTP behaviour is gated by the api-contract +// secret-fields corpus; this pins the emission, including the one thing HTTP cannot observe: a +// credential's write path names it exactly as an @formExclude field's. + +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class SecretFieldsCodegenTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@maxLength": 100 } }, + { "field.string": { "name": "tokenHash", "@maxLength": 100, "@formExclude": true } }, + { "field.string": { "name": "pinHash", "@maxLength": 100, "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + // The same entity with an INHERITED credential and an inherited @formExclude (ADR-0039). + private const string InheritedModel = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.string": { "name": "tokenHash", "@maxLength": 100, "@formExclude": true } }, + { "field.string": { "name": "pinHash", "@maxLength": 100, "children": [ { "view.password": {} } ] } } + ]}}, + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "tokenHash", "extends": "Base.tokenHash" } }, + { "field.string": { "name": "pinHash", "extends": "Base.pinHash" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private const string PlainModel = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@maxLength": 100 } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private static string Routes(string model) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "secrets.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), + Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.Literal }, + }; + return new RoutesGenerator().Generate(ctx).Single(f => f.Path == "AccountRoutes.g.cs").Content; + } + + [Theory] + [InlineData(Model)] + [InlineData(InheritedModel)] + public void Write_paths_name_both_denied_fields_and_read_hides_only_the_credential(string model) + { + var src = Routes(model); + + // Create resets both; PATCH / PUT skip both (a present null is a write too). + Assert.Contains("input.TokenHash = default!;", src); + Assert.Contains("input.PinHash = default!;", src); + Assert.Contains("string.Equals(prop.Name, \"tokenHash\", System.StringComparison.OrdinalIgnoreCase)) continue;", src); + Assert.Contains("string.Equals(prop.Name, \"pinHash\", System.StringComparison.OrdinalIgnoreCase)) continue;", src); + + // Only the credential is omitted on read (list, get, create echo, update echo). + Assert.Contains("new string[] { \"pinHash\" }", src); + Assert.DoesNotContain("\"tokenHash\" }", src); + Assert.DoesNotContain("new string[] { \"tokenHash\"", src); + Assert.DoesNotContain("Results.Ok(rows)", src); + Assert.DoesNotContain("Results.Ok(found)", src); + Assert.DoesNotContain("Results.Ok(existing)", src); + Assert.DoesNotContain("Results.Created(", src); + } + + [Fact] + public void A_model_with_neither_mark_is_unchanged() + { + var src = Routes(PlainModel); + Assert.DoesNotContain("CredentialOmittingResult", src); + Assert.DoesNotContain("default!;", src); + Assert.DoesNotContain("formExclude", src, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Results.Ok(rows)", src); + Assert.Contains("Results.Ok(existing)", src); + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index f939ec99ee..af17af9d21 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -229,6 +229,9 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte var autoSetNavs = autoSetFields.Select(a => a.Nav).ToList(); // FR-037 R1 — readOnly / writeOnce are excluded from the PATCH merge. var frozenNavs = FrozenNavs(entity); + // #484 — write-denied (@formExclude or credential) keys are ignored on every write; credentials never leave. + var deniedFields = WriteDeniedFields(entity); + var credentials = CredentialNames(entity); // Physical table + PK column for the post-save array-null clear (EF OwnsMany ToJson // writes [] for a null nav, so a nullable array column is NULLed via raw SQL). Both // are C# EXPRESSIONS — the Names constant when the names artifact is in this @@ -359,13 +362,13 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte sb.AppendLine(" // withCount=1 → { rows, total } envelope; otherwise bare row array."); sb.AppendLine(" var withCount = qs.TryGetValue(\"withCount\", out var wc) && (wc == \"1\" || string.Equals(wc, \"true\", System.StringComparison.OrdinalIgnoreCase));"); if (export is null) - sb.AppendLine(" if (!withCount) return Results.Ok(rows);"); + sb.AppendLine($" if (!withCount) return {OkResult("rows", credentials)};"); else sb.AppendLine($" if (!withCount) return format is null ? Results.Ok(rows) : ReportExportHttp.Json(http, \"{route}\", rows);"); sb.AppendLine($" var totalQ = EfCoreFilterDispatch.ApplyFilter(db.{readDbSet}.AsNoTracking(), filter.Predicates);"); sb.AppendLine(" var total = await totalQ.CountAsync();"); if (export is null) - sb.AppendLine(" return Results.Ok(new { rows, total });"); + sb.AppendLine($" return {OkResult("new { rows, total }", credentials, envelope: true)};"); else sb.AppendLine($" return format is null ? Results.Ok(new {{ rows, total }}) : ReportExportHttp.Json(http, \"{route}\", new {{ rows, total }});"); sb.AppendLine(" });"); @@ -375,7 +378,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte sb.AppendLine(); sb.AppendLine(" app.MapGet(prefix + \"/" + route + "/{id}\", async (" + pkType + " id, AppDbContext db) =>"); sb.AppendLine(" await db." + readDbSet + ".FindAsync(id) is { } found"); - sb.AppendLine(" ? Results.Ok(found)"); + sb.AppendLine(" ? " + OkResult("found", credentials)); sb.AppendLine(" : Results.NotFound(new { error = \"not_found\" }));"); } @@ -391,8 +394,9 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte // the table, then RE-READS the row through the replica view by PK so the returned // body carries the derived (view-computed) fields (read-your-writes). AppendCreateHandler(sb, "/" + route, cls, dbSet, requiredKeys, - "Results.Created(prefix + \"/" + route + "\", input)", voFields, mapVoFields, autoSetFields, - writeThrough ? (readDbSet, pkProp!, route) : null, parseBody: HasDecimalField(entity)); + CreatedResult("prefix + \"/" + route + "\"", "input", credentials), voFields, mapVoFields, autoSetFields, + writeThrough ? (readDbSet, pkProp!, route) : null, parseBody: HasDecimalField(entity), + deniedFields.Select(f => CSharpNaming.Pascal(f.Name)).ToList(), credentials); // PATCH + PUT share the same handler — TS reference exposes both verbs, and both // return the updated row (HTTP 200), matching the cross-port api-contract. @@ -427,7 +431,8 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte sb.AppendLine(" .Value.SerializerOptions;"); sb.AppendLine(" var entry = db.Entry(existing);"); AppendUpdateAutoSet(sb, autoSetFields); - AppendPartialMergeLoop(sb, null, voFields, mapVoFields, autoSetNavs, frozenNavs, HasDecimalField(entity)); + AppendPartialMergeLoop(sb, null, voFields, mapVoFields, autoSetNavs, frozenNavs, HasDecimalField(entity), + deniedFields.Select(f => f.Name).ToList()); AppendSaveChanges(sb); AppendArrayNullClears(sb, voFields, tableRef, pkColumnRef); // #214 — writes target the table; for a write-through entity re-read the row @@ -438,11 +443,11 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte // #214 [2] — a @kind:materializedView (unrefreshed) or filtered replica view may // not surface the just-written row; fall back to the write entity so the body is // never null (degraded — missing only the derived fields; matches the Python port). - sb.AppendLine(" return Results.Ok((object?)__view ?? existing);"); + sb.AppendLine($" return {OkResult("(object?)__view ?? existing", credentials)};"); } else { - sb.AppendLine(" return Results.Ok(existing);"); + sb.AppendLine($" return {OkResult("existing", credentials)};"); } sb.AppendLine(" }"); sb.AppendLine(" app.MapPatch(prefix + \"/" + route + "/{id}\", Update" + cls + ");"); @@ -495,6 +500,7 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte // fields (writable entities only; a read-only projection has no insert path). if (writable && autoSetFields.Count > 0) AppendInsertPreserving(sb, cls, dbSet); + AppendOmitResultClass(sb); sb.AppendLine("}"); return new EmittedFile($"{cls}Routes.g.cs", sb.ToString()); @@ -567,6 +573,11 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t bool hasItem = pkType is not null; var discProp = CSharpNaming.Pascal(tph.DiscriminatorField); + // #484 — the polymorphic base rows carry every subtype's columns, so a credential declared + // on the base OR on any subtype is omitted from them. + var baseCredentials = CredentialNames(baseEntity) + .Concat(tph.Subtypes.SelectMany(st => CredentialNames(st.Entity))) + .Distinct(StringComparer.Ordinal).ToList(); // Sortable base scalar fields (the polymorphic list sorts on base columns). var sortFields = baseEntity.Fields() @@ -646,9 +657,9 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t sb.AppendLine(" if (qs.TryGetValue(\"limit\", out var limRaw) && int.TryParse(limRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var lim) && lim > 0) q = q.Take(lim);"); sb.AppendLine(" var rows = await q.ToListAsync();"); sb.AppendLine(" var withCount = qs.TryGetValue(\"withCount\", out var wc) && (wc == \"1\" || string.Equals(wc, \"true\", System.StringComparison.OrdinalIgnoreCase));"); - sb.AppendLine(" if (!withCount) return Results.Ok(rows);"); + sb.AppendLine($" if (!withCount) return {OkResult("rows", baseCredentials)};"); sb.AppendLine($" var total = await EfCoreFilterDispatch.ApplyFilter(db.{dbSet}.AsNoTracking(), filter.Predicates).CountAsync();"); - sb.AppendLine(" return Results.Ok(new { rows, total });"); + sb.AppendLine($" return {OkResult("new { rows, total }", baseCredentials, envelope: true)};"); sb.AppendLine(" });"); if (hasItem) @@ -657,7 +668,7 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t sb.AppendLine(); sb.AppendLine(" app.MapGet(prefix + \"/" + baseRoute + "/{id}\", async (" + pkType + " id, AppDbContext db) =>"); sb.AppendLine($" await db.{dbSet}.AsNoTracking().FirstOrDefaultAsync(x => x.{pkProp} == id) is {{ }} found"); - sb.AppendLine(" ? Results.Ok(found)"); + sb.AppendLine(" ? " + OkResult("found", baseCredentials)); sb.AppendLine(" : Results.NotFound(new { error = \"not_found\" }));"); // FW-8 — M:N traversal declared on the BASE: every row of the shared table @@ -696,6 +707,7 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t foreach (var st in tph.Subtypes) if (AutoSetFields(st.Entity).Count > 0) AppendInsertPreserving(sb, CSharpNaming.Pascal(st.Entity.Name), dbSet); + AppendOmitResultClass(sb); sb.AppendLine("}"); return new EmittedFile($"{baseCls}Routes.g.cs", sb.ToString()); @@ -751,6 +763,75 @@ private static List FrozenNavs(MetaObject entity) => .Select(f => CSharpNaming.Pascal(f.Name)) .ToList(); + // #484 — a credential is a field that declares (or inherits, ADR-0039) a view.password child. + // A credential is never returned by a generated route; it is also never written by one. + private static bool IsCredentialField(MetaField f) => + f.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD); + + // #484 — write-denied: the generated create / PATCH / PUT IGNORE the key (never a 400): the + // effective @formExclude (resolving) or a credential. + private static bool IsApiWriteDenied(MetaField f) => + f.Attr(FIELD_ATTR_FORM_EXCLUDE) is true || IsCredentialField(f); + + private static List WriteDeniedFields(MetaObject entity) => + entity.Fields().Where(IsApiWriteDenied).ToList(); + + private static List CredentialNames(MetaObject entity) => + entity.Fields().Where(IsCredentialField).Select(f => f.Name).ToList(); + + // A C# string[] expression of the names, spliced into the credential-omitting result. + private static string NamesLiteral(IEnumerable names) => + "new string[] { " + string.Join(", ", names.Select(n => "\"" + n + "\"")) + " }"; + + // The ok / created result for a body that may carry credentials: byte-identical `Results.*` + // when there are none, else the nested CredentialOmittingResult (emitted by AppendOmitResultClass). + private static string OkResult(string value, IReadOnlyList credentials, bool envelope = false) => + credentials.Count == 0 + ? $"Results.Ok({value})" + : $"(IResult)new CredentialOmittingResult({value}, {NamesLiteral(credentials)}{(envelope ? ", envelope: true" : "")})"; + + private static string CreatedResult(string location, string value, IReadOnlyList credentials) => + credentials.Count == 0 + ? $"Results.Created({location}, {value})" + : $"(IResult)new CredentialOmittingResult({value}, {NamesLiteral(credentials)}, status: 201, location: {location})"; + + // Emitted only into a routes class that uses it (never for a model without a credential). + private static void AppendOmitResultClass(StringBuilder sb) + { + if (!sb.ToString().Contains("new CredentialOmittingResult(")) return; + sb.AppendLine(); + sb.AppendLine(" // #484 — a credential (view.password) is never sent: the key is OMITTED from the body, not nulled."); + sb.AppendLine(" // Serializes with the app's configured JSON options, then removes the named keys from each row."); + sb.AppendLine(" private sealed class CredentialOmittingResult : IResult"); + sb.AppendLine(" {"); + sb.AppendLine(" private readonly object _value;"); + sb.AppendLine(" private readonly string[] _names;"); + sb.AppendLine(" private readonly bool _envelope;"); + sb.AppendLine(" private readonly int _status;"); + sb.AppendLine(" private readonly string? _location;"); + sb.AppendLine(" public CredentialOmittingResult(object value, string[] names, bool envelope = false, int status = 200, string? location = null)"); + sb.AppendLine(" { _value = value; _names = names; _envelope = envelope; _status = status; _location = location; }"); + sb.AppendLine(" public async System.Threading.Tasks.Task ExecuteAsync(HttpContext http)"); + sb.AppendLine(" {"); + sb.AppendLine(" var opts = ((Microsoft.Extensions.Options.IOptions)"); + sb.AppendLine(" http.RequestServices.GetService(typeof(Microsoft.Extensions.Options.IOptions))!)"); + sb.AppendLine(" .Value.SerializerOptions;"); + sb.AppendLine(" var node = System.Text.Json.JsonSerializer.SerializeToNode(_value, _value.GetType(), opts);"); + sb.AppendLine(" Strip(_envelope && node is System.Text.Json.Nodes.JsonObject env ? env[\"rows\"] : node);"); + sb.AppendLine(" http.Response.StatusCode = _status;"); + sb.AppendLine(" if (_location is not null) http.Response.Headers.Location = _location;"); + sb.AppendLine(" await http.Response.WriteAsJsonAsync(node, opts);"); + sb.AppendLine(" }"); + sb.AppendLine(" private void Strip(System.Text.Json.Nodes.JsonNode? n)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (n is System.Text.Json.Nodes.JsonArray a) { foreach (var e in a) Strip(e); return; }"); + sb.AppendLine(" if (n is not System.Text.Json.Nodes.JsonObject o) return;"); + sb.AppendLine(" foreach (var key in o.Select(p => p.Key).Where(k => _names.Any(x => string.Equals(x, k, System.StringComparison.OrdinalIgnoreCase))).ToList())"); + sb.AppendLine(" o.Remove(key);"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + private static List AutoSetFields(MetaObject entity) { var list = new List(); @@ -853,7 +934,9 @@ private static void AppendCreateHandler( IReadOnlyList mapVoFields, IReadOnlyList autoSetFields, (string ReadDbSet, string PkProp, string Route)? reRead = null, - bool parseBody = false) + bool parseBody = false, + IReadOnlyList? deniedNavs = null, + IReadOnlyList? credentials = null) { // A decimal field can refuse its value while the body is bound (out of declared range, not a // number). The framework's own binding answers that with a bare 400 and no body, so an entity @@ -862,10 +945,11 @@ private static void AppendCreateHandler( { sb.AppendLine($" app.MapPost(prefix + \"{mapPath}\", async ({cls} input, AppDbContext db) =>"); sb.AppendLine(" {"); + AppendCreateIgnoreDenied(sb, deniedNavs); AppendCreateValidation(sb); AppendCreateVoValidation(sb, voFields, mapVoFields); AppendCreateAutoSet(sb, autoSetFields); - AppendCreatePersist(sb, dbSet, createdExpr, reRead); + AppendCreatePersist(sb, dbSet, createdExpr, reRead, credentials ?? []); sb.AppendLine(" });"); return; } @@ -897,10 +981,11 @@ private static void AppendCreateHandler( sb.AppendLine(" try { input = System.Text.Json.JsonSerializer.Deserialize<" + cls + ">(__body.RootElement.GetRawText(), jsonOpts); }"); sb.AppendLine(" catch (System.Text.Json.JsonException) { return Results.BadRequest(new { error = \"validation\" }); }"); sb.AppendLine(" if (input is null) return Results.BadRequest(new { error = \"validation\" });"); + AppendCreateIgnoreDenied(sb, deniedNavs); AppendCreateValidation(sb); AppendCreateVoValidation(sb, voFields, mapVoFields); AppendCreateAutoSet(sb, autoSetFields); - AppendCreatePersist(sb, dbSet, createdExpr, reRead); + AppendCreatePersist(sb, dbSet, createdExpr, reRead, credentials ?? []); sb.AppendLine(" });"); } @@ -911,7 +996,8 @@ private static void AppendCreateHandler( // carries the derived (view-computed) fields. Assumes `input` + `db` are in scope. private static void AppendCreatePersist( StringBuilder sb, string dbSet, string createdExpr, - (string ReadDbSet, string PkProp, string Route)? reRead) + (string ReadDbSet, string PkProp, string Route)? reRead, + IReadOnlyList credentials) { sb.AppendLine($" db.{dbSet}.Add(input);"); AppendSaveChanges(sb); @@ -921,7 +1007,7 @@ private static void AppendCreatePersist( // #214 [2] — a @kind:materializedView (unrefreshed) or filtered replica view may not // surface the just-inserted row; fall back to the bound write `input` so the 201 body // is never null (degraded — missing only the derived fields; matches the Python port). - sb.AppendLine($" return Results.Created(prefix + \"/{rr.Route}/\" + input.{rr.PkProp}, (object?)__created ?? input);"); + sb.AppendLine($" return {CreatedResult($"prefix + \"/{rr.Route}/\" + input.{rr.PkProp}", "(object?)__created ?? input", credentials)};"); } else { @@ -929,6 +1015,16 @@ private static void AppendCreatePersist( } } + // #484 — a write-denied key (@formExclude or credential) on a create body is IGNORED, never + // rejected: the bound value is reset to the CLR default (unset / null) before validation. + private static void AppendCreateIgnoreDenied(StringBuilder sb, IReadOnlyList? deniedNavs) + { + if (deniedNavs is null || deniedNavs.Count == 0) return; + sb.AppendLine(" // @formExclude / credential: not settable through the generated create; the value is ignored."); + foreach (var nav in deniedNavs) + sb.AppendLine($" input.{nav} = default!;"); + } + // Issue #203 — stamp EVERY @autoSet column (onCreate AND onUpdate) with now() on // insert, overriding whatever the client sent (the model's value is ignored, so a // fresh row's updated-at equals its created-at). Emits nothing when the entity has no @@ -1084,7 +1180,8 @@ private static void AppendCreateValidation(StringBuilder sb) private static void AppendPartialMergeLoop( StringBuilder sb, string? discProp, IReadOnlyList voFields, IReadOnlyList mapVoFields, - IReadOnlyList autoSetNavs, IReadOnlyList frozenNavs, bool hasDecimal) + IReadOnlyList autoSetNavs, IReadOnlyList frozenNavs, bool hasDecimal, + IReadOnlyList deniedKeys) { // A nullable array-of-VO column that a request clears to null (present-null) can NOT be // NULLed by an owned-nav assignment — EF OwnsMany(...).ToJson writes `[]` for a null @@ -1096,6 +1193,10 @@ private static void AppendPartialMergeLoop( sb.AppendLine(" foreach (var prop in body.RootElement.EnumerateObject())"); sb.AppendLine(" {"); + // #484 — @formExclude / credential keys are IGNORED (never a 400), ahead of every arm below + // (a present null is a write too). + foreach (var key in deniedKeys) + sb.AppendLine($" if (string.Equals(prop.Name, \"{key}\", System.StringComparison.OrdinalIgnoreCase)) continue; // @formExclude / credential: not writable"); // Program D — typed value-object arms FIRST (owned-nav columns are invisible to // EF's FindProperty below, so the generic scalar path would silently drop them). // Present value → deserialize + recursive VO validation + assign the CLR nav @@ -1241,6 +1342,9 @@ private static void AppendTphSubtypeRoutes( // FR-037 R1 — readOnly / writeOnce on the SUBTYPE's effective fields (a shared // base's mode is inherited by every subtype), excluded from the PATCH merge. var frozenNavs = FrozenNavs(st.Entity); + // #484 — as on a plain entity: write-denied keys ignored, credentials omitted. + var deniedFields = WriteDeniedFields(st.Entity); + var credentials = CredentialNames(st.Entity); // Per-subtype list: GET // sb.AppendLine(); @@ -1264,16 +1368,16 @@ private static void AppendTphSubtypeRoutes( sb.AppendLine(" if (qs.TryGetValue(\"limit\", out var limRaw) && int.TryParse(limRaw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var lim) && lim > 0) q = q.Take(lim);"); sb.AppendLine(" var rows = await q.ToListAsync();"); sb.AppendLine(" var withCount = qs.TryGetValue(\"withCount\", out var wc) && (wc == \"1\" || string.Equals(wc, \"true\", System.StringComparison.OrdinalIgnoreCase));"); - sb.AppendLine(" if (!withCount) return Results.Ok(rows);"); + sb.AppendLine($" if (!withCount) return {OkResult("rows", credentials)};"); sb.AppendLine($" var total = await EfCoreFilterDispatch.ApplyFilter(db.{dbSet}.OfType<{subCls}>().AsNoTracking(), filter.Predicates).CountAsync();"); - sb.AppendLine(" return Results.Ok(new { rows, total });"); + sb.AppendLine($" return {OkResult("new { rows, total }", credentials, envelope: true)};"); sb.AppendLine(" });"); // Per-subtype get: GET ///{id} — scoped (cross-subtype → 404). sb.AppendLine(); sb.AppendLine(" app.MapGet(prefix + \"/" + subRoute + "/{id}\", async (" + pkType + " id, AppDbContext db) =>"); sb.AppendLine($" await db.{dbSet}.OfType<{subCls}>().AsNoTracking().FirstOrDefaultAsync(x => x.{pkProp} == id) is {{ }} found"); - sb.AppendLine(" ? Results.Ok(found)"); + sb.AppendLine(" ? " + OkResult("found", credentials)); sb.AppendLine(" : Results.NotFound(new { error = \"not_found\" }));"); // Per-subtype create: POST // — discriminator injected by EF via the @@ -1289,8 +1393,9 @@ private static void AppendTphSubtypeRoutes( sb.AppendLine(); // VO columns on a TPH subtype are out of scope (Program D §6) — no typed arms. AppendCreateHandler(sb, "/" + subRoute, subCls, dbSet, requiredKeys, - "Results.Created(prefix + \"/" + subRoute + "/\" + input." + pkProp + ", input)", [], mapVoFields, autoSetFields, - parseBody: HasDecimalField(st.Entity)); + CreatedResult("prefix + \"/" + subRoute + "/\" + input." + pkProp, "input", credentials), [], mapVoFields, autoSetFields, + parseBody: HasDecimalField(st.Entity), + deniedNavs: deniedFields.Select(f => CSharpNaming.Pascal(f.Name)).ToList(), credentials: credentials); // Per-subtype update (PATCH + PUT): partial merge of the JSON body onto the scoped // entity, NEVER touching the PK or the discriminator. Cross-subtype id → 404. @@ -1311,9 +1416,10 @@ private static void AppendTphSubtypeRoutes( AppendUpdateAutoSet(sb, autoSetFields); // ADR-0045 + the 0.19.4 lesson: TPH is a SEPARATE code path per port, so the // per-subtype merge states the same exclusion rather than inheriting it. - AppendPartialMergeLoop(sb, discProp, [], mapVoFields, autoSetNavs, frozenNavs, HasDecimalField(st.Entity)); + AppendPartialMergeLoop(sb, discProp, [], mapVoFields, autoSetNavs, frozenNavs, HasDecimalField(st.Entity), + deniedFields.Select(f => f.Name).ToList()); AppendSaveChanges(sb); - sb.AppendLine(" return Results.Ok(existing);"); + sb.AppendLine($" return {OkResult("existing", credentials)};"); sb.AppendLine(" }"); sb.AppendLine(" app.MapPatch(prefix + \"/" + subRoute + "/{id}\", Update" + subCls + ");"); sb.AppendLine(" app.MapPut(prefix + \"/" + subRoute + "/{id}\", Update" + subCls + ");"); @@ -1449,7 +1555,7 @@ private static void AppendM2mRoute( sb.AppendLine($" var rows = await {targetFrom}.AsNoTracking()"); sb.AppendLine($" .Where(t => relatedIds.Contains(EF.Property<{fkType}>(t, \"{targetPk}\")))"); sb.AppendLine(" .ToListAsync();"); - sb.AppendLine(" return Results.Ok(rows);"); + sb.AppendLine($" return {OkResult("rows", CredentialNames(nav.Target))};"); sb.AppendLine(" });"); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs index 3108d9e6f5..d4ff3e9a5f 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractCorpusPaths.cs @@ -98,4 +98,10 @@ internal static class ApiContractCorpusPaths public static readonly string WriteThroughScenariosDir = Path.Combine(WriteThroughDir, "scenarios"); public static readonly string WriteThroughSeedFile = Path.Combine(WriteThroughDir, "seed.json"); public static readonly string WriteThroughMetaJson = Path.Combine(WriteThroughDir, "meta.json"); + + // #484 secret-fields subcorpus (credentials and @formExclude fields), generated lane only. + public static readonly string SecretFieldsDir = Path.Combine(Corpus, "secret-fields"); + public static readonly string SecretFieldsScenariosDir = Path.Combine(SecretFieldsDir, "scenarios"); + public static readonly string SecretFieldsSeedFile = Path.Combine(SecretFieldsDir, "seed.json"); + public static readonly string SecretFieldsMetaJson = Path.Combine(SecretFieldsDir, "meta.json"); } diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs new file mode 100644 index 0000000000..32934a9588 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs @@ -0,0 +1,81 @@ +// ApiContractSecretFieldsConformanceTest — the C# #484 secret-fields api-contract lane. +// +// Drives the fixtures/api-contract-conformance/secret-fields/ scenarios over HTTP against the +// GENERATED Account routes booted unmodified on Kestrel against a Postgres testcontainer. +// Proves the deployed REST surface ignores writes to @formExclude / credential fields and +// never returns a credential. Generated lane only. + +using System.Text.Json; +using System.Text.Json.Nodes; +using MetaObjects.IntegrationTests.Runner; +using Xunit; + +namespace MetaObjects.IntegrationTests.Api; + +public sealed class ApiContractSecretFieldsConformanceTest +{ + [Theory] + [MemberData(nameof(Scenarios))] + public async Task Api_contract_secret_fields_generated(string scenarioPath) + { + var scenario = ApiContractScenarioLoader.LoadScenario(scenarioPath); + await using var pg = await PostgresContainer.StartAsync(); + await using var server = await SecretFieldsGeneratedServerFactory.StartAsync(pg); + await server.ApplySeedAsync(); + await RunAsync(scenario, server.BaseUrl); + } + + private static async Task RunAsync(ApiScenario scenario, string baseUrl) + { + using var client = new HttpClient { BaseAddress = new Uri(baseUrl) }; + foreach (var req in scenario.Requests) + { + var request = new HttpRequestMessage(new HttpMethod(req.Method), ApiContractWire.VerbatimUri(client, req.Path)); + if (req.Body is not null) + { + string json = JsonSerializer.Serialize(req.Body, JsonOpts); + request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + } + var response = await client.SendAsync(request); + string bodyText = await response.Content.ReadAsStringAsync(); + object? parsed = string.IsNullOrEmpty(bodyText) ? null : ToObject(JsonNode.Parse(bodyText)); + ApiContractAssertions.AssertResponse(scenario.Name, req, (int)response.StatusCode, parsed); + } + } + + public static IEnumerable Scenarios() => + Directory.EnumerateFiles(ApiContractCorpusPaths.SecretFieldsScenariosDir, "*.yaml", SearchOption.TopDirectoryOnly) + .OrderBy(p => p, StringComparer.Ordinal) + .Select(p => new object[] { p }); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never, + }; + + private static object? ToObject(JsonNode? node) + { + if (node is null) return null; + if (node is JsonObject obj) + { + var d = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in obj) d[kvp.Key] = ToObject(kvp.Value); + return d; + } + if (node is JsonArray arr) + { + var l = new List(arr.Count); + foreach (var item in arr) l.Add(ToObject(item)); + return l; + } + if (node is JsonValue jv) + { + if (jv.TryGetValue(out var b)) return b; + if (jv.TryGetValue(out var lv)) return lv; + if (jv.TryGetValue(out var dv)) return dv; // exact: never a double (ADR-0019 Amendment 1) + if (jv.TryGetValue(out var sv)) return sv; + return jv.ToString(); + } + return null; + } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsFixture.cs b/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsFixture.cs new file mode 100644 index 0000000000..2aaf7d81ca --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsFixture.cs @@ -0,0 +1,53 @@ +// SecretFieldsFixture — schema + seed for the #484 secret-fields api-contract GENERATED lane. +using System.Text.Json.Nodes; +using Npgsql; + +namespace MetaObjects.IntegrationTests.Api; + +internal static class SecretFieldsFixture +{ + public const string SchemaDdl = """ + CREATE TABLE "accounts" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "name" VARCHAR(100) NOT NULL, + "tokenHash" VARCHAR(100) NULL, + "pinHash" VARCHAR(100) NULL, + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") + ); + """; + + public static async Task ProvisionSchemaAsync(string connString) + { + await using var c = new NpgsqlConnection(connString); + await c.OpenAsync(); + await using var cmd = c.CreateCommand(); + cmd.CommandText = SchemaDdl; + await cmd.ExecuteNonQueryAsync(); + } + + public static async Task ApplySeedAsync(string connString, string seedPath) + { + var root = JsonNode.Parse(File.ReadAllText(seedPath)) as JsonObject + ?? throw new InvalidOperationException($"{seedPath}: top-level must be an object"); + var cols = new[] { "id", "name", "tokenHash", "pinHash" }; + await using var c = new NpgsqlConnection(connString); + await c.OpenAsync(); + foreach (var rowNode in root["rows"]!.AsArray()) + { + var row = rowNode!.AsObject(); + await using var ins = c.CreateCommand(); + ins.CommandText = "INSERT INTO \"accounts\" (" + string.Join(", ", cols.Select(x => "\"" + x + "\"")) + + ") VALUES (" + string.Join(", ", cols.Select((_, i) => "@p" + i)) + ")"; + for (int i = 0; i < cols.Length; i++) + { + var v = row[cols[i]]; + object val = v is null ? DBNull.Value : cols[i] == "id" ? v.GetValue() : v.GetValue(); + ins.Parameters.AddWithValue("@p" + i, val); + } + await ins.ExecuteNonQueryAsync(); + } + await using var bump = c.CreateCommand(); + bump.CommandText = "SELECT setval(pg_get_serial_sequence('accounts', 'id'), COALESCE((SELECT MAX(id) FROM \"accounts\"), 1))"; + await bump.ExecuteScalarAsync(); + } +} diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsGeneratedServerFactory.cs b/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsGeneratedServerFactory.cs new file mode 100644 index 0000000000..c2ec132342 --- /dev/null +++ b/server/csharp/MetaObjects.IntegrationTests/Api/SecretFieldsGeneratedServerFactory.cs @@ -0,0 +1,181 @@ +// SecretFieldsGeneratedServerFactory — the C# GENERATED-server lane for the #484 secret-fields +// corpus. Runs the real Codegen generators (Entity + DbContext + FilterAllowlist + Routes) on the +// Account model, Roslyn-compiles the output in memory and hosts it on Kestrel against Postgres. + +using MetaObjects.Meta; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.IntegrationTests.Runner; +using MetaObjects.Loader; + +namespace MetaObjects.IntegrationTests.Api; + +internal sealed class SecretFieldsGeneratedServerFactory : IAsyncDisposable +{ + private const string GeneratedNamespace = "MetaObjects.ApiContract.SecretFieldsGenerated"; + + private readonly PostgresContainer _pg; + private readonly WebApplication _app; + + public string BaseUrl { get; } + + private SecretFieldsGeneratedServerFactory(PostgresContainer pg, WebApplication app, string baseUrl) + { + _pg = pg; + _app = app; + BaseUrl = baseUrl; + } + + public static async Task StartAsync(PostgresContainer pg) + { + await SecretFieldsFixture.ProvisionSchemaAsync(pg.ConnectionString); + + var (assembly, entityNames) = CompileGeneratedServer(); + var dbContextType = assembly.GetType($"{GeneratedNamespace}.AppDbContext") + ?? throw new InvalidOperationException("generated AppDbContext type not found"); + + var builder = WebApplication.CreateBuilder(); + LoopbackKestrel.BindAnyPort(builder); + builder.Logging.ClearProviders(); + RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString); + + var app = builder.Build(); + + // Mount each generated MapRoutes(app, "/api"). Both Customer and Order + // emit a routes class; only Order's secret-fields routes are exercised. + foreach (var entity in entityNames) + { + var routesType = assembly.GetType($"{GeneratedNamespace}.{entity}Routes"); + if (routesType is null) continue; + var mapMethod = routesType.GetMethod($"Map{entity}Routes", BindingFlags.Public | BindingFlags.Static); + mapMethod?.Invoke(null, new object[] { app, "/api" }); + } + + await app.StartAsync(); + return new SecretFieldsGeneratedServerFactory(pg, app, LoopbackKestrel.BoundUrl(app)); + } + + public async Task ApplySeedAsync() => + await SecretFieldsFixture.ApplySeedAsync(_pg.ConnectionString, ApiContractCorpusPaths.SecretFieldsSeedFile); + + public async ValueTask DisposeAsync() + { + try { await _app.StopAsync(); } catch { /* ignored */ } + try { await _app.DisposeAsync(); } catch { /* ignored */ } + } + + private static (Assembly Assembly, IReadOnlyList EntityNames) CompileGeneratedServer() + { + var loadResult = new MetaDataLoader().Load([new FileSource(ApiContractCorpusPaths.SecretFieldsMetaJson)]); + // ADR-0058 — the corpus's committed naming rule, not a hardcoded default. + loadResult.Root.PhysicalNaming = ApiContractCorpusPaths.Naming; + loadResult.Root.WireFormat = ApiContractCorpusPaths.Wire; + if (loadResult.Errors.Count != 0) + throw new InvalidOperationException( + "secret-fields corpus metadata failed to load: " + + string.Join("; ", loadResult.Errors.Select(e => e.ToString()))); + + var entityNames = loadResult.Root.Objects() + .Where(o => o.IsEntity()) + .Select(o => CSharpNaming.Pascal(o.Name)) + .ToList(); + + var ctx = new GenContext + { + Entities = loadResult.Root.Objects(), + Root = loadResult.Root, + Config = new GenConfig + { + OutDir = "/unused", + Namespace = GeneratedNamespace, + ColumnNamingStrategy = ApiContractCorpusPaths.Naming.Columns, + EmitAbstractShapes = false, + // GenConfig.IncludeNames defaults to false (§A6 task 4); without it + // here the entity/DbContext output below never references the + // Names constants, and the .Concat(new NamesGenerator()...) + // a few lines down is dead input the compile never depends on. This + // is the only lane that proves an Names.NAME reference + // actually RESOLVES against a real EF Core compile. + IncludeNames = true, + }, + }; + + // §A6 (task 4) -- the entity/DbContext output now references the names artifact. + var files = new EntityGenerator().Generate(ctx) + .Concat(new DbContextGenerator().Generate(ctx)) + .Concat(new FilterAllowlistGenerator().Generate(ctx)) + .Concat(new RoutesGenerator().Generate(ctx)) + .Concat(new NamesGenerator().Generate(ctx)) + .ToList(); + + var trees = files + .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))) + .ToArray(); + + var refs = BuildReferenceSet(); + var comp = CSharpCompilation.Create( + "apicontract_secretfields_generated_" + Guid.NewGuid().ToString("N"), + trees, refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + if (!emit.Success) + { + var errors = emit.Diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToList(); + throw new InvalidOperationException( + "generated secret-fields server failed to compile:\n " + string.Join("\n ", errors)); + } + + ms.Seek(0, SeekOrigin.Begin); + return (Assembly.Load(ms.ToArray()), entityNames); + } + + private static List BuildReferenceSet() + { + var byFileName = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tpa = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? ""; + foreach (var path in tpa.Split(Path.PathSeparator)) + if (path.Length > 0 && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + byFileName[Path.GetFileName(path)] = path; + + var aspNetDir = Path.GetDirectoryName(typeof(WebApplication).Assembly.Location); + if (aspNetDir is not null && Directory.Exists(aspNetDir)) + foreach (var dll in Directory.EnumerateFiles(aspNetDir, "*.dll")) + byFileName[Path.GetFileName(dll)] = dll; + + return byFileName.Values + .Select(loc => (MetadataReference)MetadataReference.CreateFromFile(loc)) + .ToList(); + } + + private static void RegisterGeneratedDbContext( + IServiceCollection services, Type dbContextType, string connString) + { + var addDbContext = typeof(EntityFrameworkServiceCollectionExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .First(m => m.Name == "AddDbContext" + && m.IsGenericMethodDefinition + && m.GetGenericArguments().Length == 1 + && m.GetParameters().Length == 4) + .MakeGenericMethod(dbContextType); + + Action configure = opts => opts.UseNpgsql(connString); + addDbContext.Invoke(null, new object?[] + { + services, configure, ServiceLifetime.Scoped, ServiceLifetime.Scoped, + }); + } +} diff --git a/server/csharp/MetaObjects/Core/Field/FieldConstants.cs b/server/csharp/MetaObjects/Core/Field/FieldConstants.cs index f1d65897dd..5daaedb59a 100644 --- a/server/csharp/MetaObjects/Core/Field/FieldConstants.cs +++ b/server/csharp/MetaObjects/Core/Field/FieldConstants.cs @@ -121,6 +121,12 @@ public static class FieldConstants /// public const string FIELD_ATTR_MUTABILITY = "mutability"; + /// + /// @formExclude — the field is out of the generated form; the generated HTTP write + /// routes ignore it too (#484). + /// + public const string FIELD_ATTR_FORM_EXCLUDE = "formExclude"; + /// readWrite — settable on create, changeable on update (the default). public const string MUTABILITY_READ_WRITE = "readWrite"; diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index 692e16ce49..4da7b81e8e 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -278,7 +278,11 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { // real models have. (A missing `Long` silently binds 0, which is how an increment key // hid it; a `UUID` cannot.) Same relaxation as a derived field on a write-through // entity: nullable, default null, no @field:NotNull. Rows read back always carry it. - val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj) + // #484: a credential is never the caller's to send and is never returned, so it is nullable + + // defaulted like a server-owned field, and its property drops a null on the wire (the read + // routes pass null, which must read as an ABSENT key, not `"pin": null`). + val credential = KotlinGenUtil.isCredentialField(field) + val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj) || credential val nullable = tphBase || derivedReadOnly || serverOwned || KotlinGenUtil.isNullableShapeProperty(field) val propType = if (nullable) baseType.copy(nullable = true) else baseType val propName = field.name @@ -307,6 +311,14 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { propBuilder.addAnnotation(constraint(VALID)) } } + if (credential) { + propBuilder.addAnnotation( + AnnotationSpec.builder(ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) + .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) + .addMember("%T.Include.NON_NULL", ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) + .build(), + ) + } decimalWireAnnotations(field, pkg, declaredRange = obj.subType == MetaObject.SUBTYPE_VALUE) .forEach { propBuilder.addAnnotation(it) } typeBuilder.addProperty(propBuilder.build()) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt index 32a905c2a0..8587f110be 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt @@ -759,6 +759,34 @@ public object KotlinGenUtil { fun isNullableShapeProperty(field: MetaField<*>): Boolean = !isRequiredField(field) && !originGuaranteedNonNull(field) && !isAssignedPrimaryKeyField(field) + /** + * #484 — a credential: the field declares (or inherits, ADR-0039: resolving read) a child + * `view.password`. A credential is never returned by a generated read route and never + * written by a generated write route. + */ + fun isCredentialField(field: MetaField<*>): Boolean = + field.getChildren(com.metaobjects.view.MetaView::class.java, true) + .any { it.subType == com.metaobjects.view.PresentationView.SUBTYPE_PASSWORD } + + /** #484 — `@formExclude: true` (resolving read): the field is not part of the write shape. */ + fun isFormExcluded(field: MetaField<*>): Boolean { + if (!field.hasMetaAttr(ATTR_FORM_EXCLUDE, true)) return false + val raw = runCatching { field.getMetaAttr(ATTR_FORM_EXCLUDE, true).value }.getOrNull() + return when (raw) { + is Boolean -> raw + is String -> raw.equals("true", ignoreCase = true) + else -> false + } + } + + /** + * #484 — true when a generated create / PATCH / PUT route must IGNORE the field (strip it, never + * reject it): `@formExclude` or a credential. The application's own repository still writes it. + */ + fun isApiWriteDenied(field: MetaField<*>): Boolean = isFormExcluded(field) || isCredentialField(field) + + private const val ATTR_FORM_EXCLUDE = "formExclude" + fun isRequiredField(field: MetaField<*>): Boolean { if (!field.hasMetaAttr(MetaField.ATTR_REQUIRED, true)) return false val raw = runCatching { field.getMetaAttr(MetaField.ATTR_REQUIRED, true).value }.getOrNull() diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index 5f15e74ba0..5a3fadce5a 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -86,6 +86,8 @@ object KotlinM2mSupport { val targetDiscriminator: TargetDiscriminator?, val symmetric: Boolean, val junctionPackage: String = "", + /** #484: target fields that are credentials, never returned (mapped to null). */ + val targetCredentialFields: Set = emptySet(), ) /** @@ -199,6 +201,8 @@ object KotlinM2mSupport { targetDiscriminator = targetDiscriminator, symmetric = rel.isSymmetric, junctionPackage = junctionPkg, + targetCredentialFields = (target.metaFields + KotlinTphPlan.collectSubtypeFields(target, loader)) + .filter { KotlinGenUtil.isCredentialField(it) }.map { it.name }.toSet(), ) ) } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index eec9e4f4ed..6c46e0f686 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -274,7 +274,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase scalarFields.none { it.name == name && KotlinGenUtil.isApiWriteDenied(it) } } // FR-035/FR-036 Program B: the per-subtype settable columns = the subtype's EFFECTIVE scalar // fields (base + own, resolved via extends) minus the PK and the discriminator, EXCLUDING @@ -914,7 +920,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase: union ResultRow → data class append("/** GENERATED — map an Exposed ResultRow to the union $shortName data class. */\n") append("private fun rowTo${shortName}(row: ResultRow): $shortName = $shortName(\n") - for (field in scalarFields) append(" ${field.name} = row[$table.${field.name}],\n") + for (field in scalarFields) { + // #484: a credential is never returned. + if (KotlinGenUtil.isCredentialField(field)) append(" ${field.name} = null,\n") + else append(" ${field.name} = row[$table.${field.name}],\n") + } append(")\n\n") append("/**\n") @@ -1832,7 +1842,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase\n") out.append(" $targetType(\n") for (fname in nav.targetScalarFields) { - out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") + // #484: a credential on the target is never returned. + if (fname in nav.targetCredentialFields) out.append(" $fname = null,\n") + else out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") } out.append(" )\n") out.append(" }\n") @@ -1883,7 +1895,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase\n") out.append(" $targetType(\n") for (fname in nav.targetScalarFields) { - out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") + // #484: a credential on the target is never returned. + if (fname in nav.targetCredentialFields) out.append(" $fname = null,\n") + else out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") } out.append(" )\n") out.append(" }\n") @@ -2233,6 +2247,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBaseValidation shape and the settable-name list all read it. !KotlinGenUtil.isReadOnlyMutability(it) && - !KotlinGenUtil.isWriteOnceMutability(it) + !KotlinGenUtil.isWriteOnceMutability(it) && + !KotlinGenUtil.isApiWriteDenied(it) // #484: @formExclude / credential, ignored on every write } } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt new file mode 100644 index 0000000000..f9c4be18c6 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt @@ -0,0 +1,75 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * #484 — the generated HTTP boundary ignores a `@formExclude` or credential (`view.password`) field on + * create / PATCH / PUT and never returns a credential. A credential's write path is not observable over + * HTTP (it is never read back), so the generated source is pinned here; the wire behaviour of both marks is + * gated by the api-contract `secret-fields/` corpus in the integration-tests-kotlin generated lane. + */ +class KotlinSecretFieldsTest { + + private fun model(marked: Boolean) = """{ + "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 100 } }, + { "field.string": { "name": "tokenHash", "@maxLength": 100${if (marked) """, "@formExclude": true""" else ""} } }, + { "field.string": { "name": "pinHash", "@maxLength": 100${if (marked) """, "children": [ { "view.password": {} } ]""" else ""} } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } } + ] } + }""".trimIndent() + + private fun controllerAndEntity(marked: Boolean): Pair { + val out = Files.createTempDirectory("ksecret-") + try { + val loader = loadString("secret-fields-$marked", model(marked)) + KotlinEntityGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) + KotlinSpringControllerGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) + val dir = out.resolve("acme/secrets") + return Files.readString(dir.resolve("AccountController.kt")) to Files.readString(dir.resolve("Account.kt")) + } finally { + out.toFile().deleteRecursively() + } + } + + @Test + fun `create and update paths skip both the formExclude field and the credential`() { + val (controller, _) = controllerAndEntity(marked = true) + // The ordinary field is still written on create and settable on PATCH / PUT ... + assertTrue("it[name] = dto.name" in controller, controller) + assertTrue("listOf(\"name\").any { body.has(it) }" in controller, controller) + // ... and neither denied field reaches a write, on create or on update. + for (f in listOf("tokenHash", "pinHash")) { + assertFalse("it[$f] = dto.$f" in controller, "create writes $f:\n$controller") + assertFalse("val has${f.replaceFirstChar { it.uppercase() }}" in controller, "update binds $f:\n$controller") + assertFalse("AccountTable.$f]" in controller && "it[AccountTable.$f]" in controller, "update sets $f") + } + } + + @Test + fun `only the credential is hidden on read`() { + val (controller, entity) = controllerAndEntity(marked = true) + assertTrue("pinHash = null," in controller, controller) + assertTrue("tokenHash = row[AccountTable.tokenHash]," in controller, controller) + assertTrue("JsonInclude" in entity && "pinHash" in entity, entity) + } + + @Test + fun `a model with neither mark generates the plain write and read paths`() { + val (controller, entity) = controllerAndEntity(marked = false) + for (f in listOf("tokenHash", "pinHash")) { + assertTrue("it[$f] = dto.$f" in controller, controller) + assertTrue("$f = row[AccountTable.$f]," in controller, controller) + } + assertTrue("listOf(\"name\", \"tokenHash\", \"pinHash\").any { body.has(it) }" in controller, controller) + assertFalse("JsonInclude" in entity, entity) + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 42af4a72d9..3a2b6c4ea7 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -301,6 +301,10 @@ protected void emit(MetaObject entity, Path outRoot) { // private to this controller (no cross-file collision risk). appendSortAllowlist(src, entity, sortFields); appendServerOwnedOnCreate(src, entity); + // #484: the credential fields this entity's rows carry, hidden from every response body. + List hidden = SpringGeneratorSupport.credentialNames(entity); + List navs = SpringM2mSupport.resolve(entity, loader); + boolean redacts = !hidden.isEmpty() || navs.stream().anyMatch(n -> !targetCredentials(entity, n).isEmpty()); // Repository wiring — constructor injection (Spring's recommended idiom; avoids // field-injection magic, plays well with final fields + final test seams). The @@ -319,15 +323,16 @@ protected void emit(MetaObject entity, Path outRoot) { src.append(" this.validator = validator;\n"); src.append(" }\n\n"); appendUnreadableBodyHandler(src); + if (redacts) appendRedactHelpers(src); // GET (list) — pagination + sort + withCount + FR-009 filter operators. // HttpServletRequest carries the raw query string so the bracketed // filter[][]= grammar reaches FilterParser intact // (Spring's @RequestParam would collapse same-key occurrences). - appendListHandler(src, shortName, dtoName, repoName); + appendListHandler(src, shortName, dtoName, repoName, hidden); // GET /{id} — single by primary key. - appendGetByIdHandler(src, pkType); + appendGetByIdHandler(src, pkType, hidden); // POST — create. FR-036: bind the raw DTO (no @Valid, whose default 400 body is NOT the // cross-port envelope and does not even fire under the standaloneSetup test harness) and @@ -335,6 +340,9 @@ protected void emit(MetaObject entity, Path outRoot) { // any field-constraint violation BEFORE persisting. src.append(" @PostMapping\n"); src.append(" public ResponseEntity create(@RequestBody ").append(dtoName).append(" dto) {\n"); + // #484: a @formExclude / credential value is IGNORED (never rejected): the DTO is rebuilt + // without it, so the column is left to the repository's default. + appendStripWriteDenied(src, dtoName, SpringGeneratorSupport.dtoComponentFields(entity)); // A caller does not supply server-owned columns, so a @NotNull on one must not reject // the request. The DTO is BOTH the request and the response body, so `id` carries // @NotNull because it is @required in the RESPONSE — while being identity.primary @@ -370,7 +378,8 @@ protected void emit(MetaObject entity, Path outRoot) { ? dtoName + ".stampForInsert(dto)" : "dto"; src.append(" try {\n"); src.append(" ").append(dtoName).append(" saved = repository.create(").append(createArg).append(");\n"); - src.append(" return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n"); + src.append(" return ResponseEntity.status(HttpStatus.CREATED).body(") + .append(redactOne("saved", hidden)).append(");\n"); appendConstraintCatch(src); src.append(" }\n\n"); @@ -420,7 +429,7 @@ protected void emit(MetaObject entity, Path outRoot) { } src.append(" try {\n"); src.append(" return repository.patch(id, patch)\n"); - src.append(" .>map(ResponseEntity::ok)\n"); + src.append(" .>map(").append(okMapper(hidden)).append(")\n"); src.append(" .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(\"error\", \"not_found\")));\n"); appendConstraintCatch(src); src.append(" }\n\n"); @@ -442,13 +451,20 @@ protected void emit(MetaObject entity, Path outRoot) { // pluralized (handled by @RequestMapping above); the relation segment is the // relationship name. Related-row order is not contractual. The repository finder // traverses the junction (hetero / directed self-join / symmetric union-on-read). - for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(entity, loader)) { + for (SpringM2mSupport.M2mNav nav : navs) { String finder = SpringGeneratorSupport.m2mFinderName(nav.relationName()); + String navCall = "repository." + finder + "(id" + SpringGeneratorSupport.targetSubtypeCallArg(nav) + ")"; src.append(" @GetMapping(\"/{id}/").append(nav.relationName()).append("\")\n"); - src.append(" public ResponseEntity> ") - .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); - src.append(" return ResponseEntity.ok(repository.").append(finder) - .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); + if (targetCredentials(entity, nav).isEmpty()) { + src.append(" public ResponseEntity> ") + .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); + src.append(" return ResponseEntity.ok(").append(navCall).append(");\n"); + } else { + src.append(" public ResponseEntity> ") + .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); + src.append(" return ResponseEntity.ok(") + .append(redactAll(navCall, targetCredentials(entity, nav))).append(");\n"); + } src.append(" }\n\n"); } @@ -777,6 +793,11 @@ private static void appendUnreadableBodyHandler(StringBuilder src) { * (Spring's {@code @RequestParam} would collapse same-key occurrences). */ private static void appendListHandler(StringBuilder src, String shortName, String dtoName, String repoName) { + appendListHandler(src, shortName, dtoName, repoName, List.of()); + } + + private static void appendListHandler(StringBuilder src, String shortName, String dtoName, String repoName, + List hidden) { String allowlistName = SpringNaming.filterAllowlistName(shortName); src.append(" @GetMapping\n"); src.append(" public ResponseEntity list(\n"); @@ -808,9 +829,10 @@ private static void appendListHandler(StringBuilder src, String shortName, Strin .append("> rows = repository.list(actualLimit, actualOffset, sortClause, filters);\n"); src.append(" if (withCount != null && withCount == 1) {\n"); src.append(" long total = repository.count(filters);\n"); - src.append(" return ResponseEntity.ok(Map.of(\"rows\", rows, \"total\", total));\n"); + src.append(" return ResponseEntity.ok(Map.of(\"rows\", ").append(redactAll("rows", hidden)) + .append(", \"total\", total));\n"); src.append(" }\n"); - src.append(" return ResponseEntity.ok(rows);\n"); + src.append(" return ResponseEntity.ok(").append(redactAll("rows", hidden)).append(");\n"); src.append(" }\n\n"); } @@ -1015,10 +1037,14 @@ private static void appendReportExportHandler(StringBuilder src, String shortNam /** GET /{id} — single by primary key, 404 envelope on a miss. */ private static void appendGetByIdHandler(StringBuilder src, String pkType) { + appendGetByIdHandler(src, pkType, List.of()); + } + + private static void appendGetByIdHandler(StringBuilder src, String pkType, List hidden) { src.append(" @GetMapping(\"/{id}\")\n"); src.append(" public ResponseEntity get(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" return repository.findById(id)\n"); - src.append(" .>map(ResponseEntity::ok)\n"); + src.append(" .>map(").append(okMapper(hidden)).append(")\n"); src.append(" .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(\"error\", \"not_found\")));\n"); src.append(" }\n\n"); } @@ -1158,6 +1184,18 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" this.validator = validator;\n"); src.append(" }\n\n"); appendUnreadableBodyHandler(src); + // #484: the TPH rows are union-DTO rows, so hide a credential declared on the base or on + // ANY subtype from every response, and ignore a write-denied one on every subtype create. + List unionFields = new ArrayList<>(SpringGeneratorSupport.scalarFields(base)); + for (MetaField f : TphPlan.collectSubtypeFields(base, loader)) { + if (!(f instanceof com.metaobjects.field.ObjectField)) unionFields.add(f); + } + List hidden = SpringGeneratorSupport.credentialNames(unionFields); + List baseNavs = SpringM2mSupport.resolve(base, loader); + boolean redacts = !hidden.isEmpty() || baseNavs.stream().anyMatch(n -> !targetCredentials(base, n).isEmpty()) + || plan.subtypes().stream().anyMatch(st -> SpringM2mSupport.resolve(st.entity(), loader).stream() + .anyMatch(n -> !targetCredentials(st.entity(), n).isEmpty())); + if (redacts) appendRedactHelpers(src); // --- polymorphic collection --- src.append(" // Polymorphic list — the union across all subtypes, each row tagged by its discriminator.\n"); @@ -1181,16 +1219,17 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" List filters = filter.predicates();\n"); src.append(" List<").append(dtoName).append("> rows = repository.list(actualLimit, actualOffset, sortClause, filters);\n"); src.append(" if (withCount != null && withCount == 1) {\n"); - src.append(" return ResponseEntity.ok(Map.of(\"rows\", rows, \"total\", repository.count(filters)));\n"); + src.append(" return ResponseEntity.ok(Map.of(\"rows\", ").append(redactAll("rows", hidden)) + .append(", \"total\", repository.count(filters)));\n"); src.append(" }\n"); - src.append(" return ResponseEntity.ok(rows);\n"); + src.append(" return ResponseEntity.ok(").append(redactAll("rows", hidden)).append(");\n"); src.append(" }\n\n"); src.append(" // Polymorphic get — one row of whatever subtype it is.\n"); src.append(" @GetMapping(\"/{id}\")\n"); src.append(" public ResponseEntity get(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" return repository.findById(id)\n"); - src.append(" .>map(ResponseEntity::ok)\n"); + src.append(" .>map(").append(okMapper(hidden)).append(")\n"); src.append(" .orElseGet(this::notFound);\n"); src.append(" }\n\n"); @@ -1200,13 +1239,20 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // traversal (including this same relationship, inherited) is emitted below, inside // the per-subtype loop — the overlap is deliberate (FR-017 design): a subtype // resource carries the same sub-resources as any other resource. - for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(base, loader)) { + for (SpringM2mSupport.M2mNav nav : baseNavs) { String finder = SpringGeneratorSupport.m2mFinderName(nav.relationName()); + String navCall = "repository." + finder + "(id" + SpringGeneratorSupport.targetSubtypeCallArg(nav) + ")"; src.append(" @GetMapping(\"/{id}/").append(nav.relationName()).append("\")\n"); - src.append(" public ResponseEntity> ") - .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); - src.append(" return ResponseEntity.ok(repository.").append(finder) - .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); + if (targetCredentials(base, nav).isEmpty()) { + src.append(" public ResponseEntity> ") + .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); + src.append(" return ResponseEntity.ok(").append(navCall).append(");\n"); + } else { + src.append(" public ResponseEntity> ") + .append(finder).append("(@PathVariable ").append(pkType).append(" id) {\n"); + src.append(" return ResponseEntity.ok(") + .append(redactAll(navCall, targetCredentials(base, nav))).append(");\n"); + } src.append(" }\n\n"); } @@ -1243,8 +1289,9 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" FilterParseResult filter = FilterParser.parse(request.getQueryString(), ") .append(allowlistName).append(".FIELDS, ").append(allowlistName).append(".OPS_BY_FIELD, ").append(allowlistName).append(".DECIMAL_FIELDS);\n"); src.append(" if (filter.error() != null) return ResponseEntity.badRequest().body(Map.of(\"error\", filter.error(), \"field\", filter.field()));\n"); - src.append(" return ResponseEntity.ok(repository.listByType(\"").append(disc) - .append("\", actualLimit, actualOffset, sortClause, filter.predicates()));\n"); + src.append(" return ResponseEntity.ok(").append(redactAll( + "repository.listByType(\"" + disc + "\", actualLimit, actualOffset, sortClause, filter.predicates())", + hidden)).append(");\n"); src.append(" }\n\n"); // per-subtype get (404 cross-subtype) @@ -1252,7 +1299,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" public ResponseEntity get").append(suffix) .append("(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" return repository.findByIdAndType(id, \"").append(disc).append("\")\n"); - src.append(" .>map(ResponseEntity::ok)\n"); + src.append(" .>map(").append(okMapper(hidden)).append(")\n"); src.append(" .orElseGet(this::notFound);\n"); src.append(" }\n\n"); @@ -1308,12 +1355,15 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // never emitted, producing a non-compiling controller. A TPH subtype declaring its // OWN @autoSet column is a documented non-goal (uniform with the Kotlin and Python // ports, both base-scoped): the column is simply never stamped, not a compile break. + // #484: ignore (never reject) a @formExclude / credential value in the body. + appendStripWriteDenied(src, dtoName, unionFields); String createArg = SpringGeneratorSupport.hasAutoSetFields(base) ? dtoName + ".stampForInsert(dto)" : "dto"; src.append(" try {\n"); src.append(" ").append(dtoName).append(" saved = repository.createWithType(\"").append(disc) .append("\", ").append(createArg).append(");\n"); - src.append(" return ResponseEntity.status(HttpStatus.CREATED).body(saved);\n"); + src.append(" return ResponseEntity.status(HttpStatus.CREATED).body(") + .append(redactOne("saved", hidden)).append(");\n"); appendConstraintCatch(src); src.append(" }\n\n"); @@ -1364,7 +1414,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { src.append(" try {\n"); src.append(" return repository.patchByIdAndType(id, \"").append(disc) .append("\", patch.assignedValues())\n"); - src.append(" .>map(ResponseEntity::ok)\n"); + src.append(" .>map(").append(okMapper(hidden)).append(")\n"); src.append(" .orElseGet(this::notFound);\n"); appendConstraintCatch(src); src.append(" }\n\n"); @@ -1395,13 +1445,15 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { for (SpringM2mSupport.M2mNav nav : SpringM2mSupport.resolve(st.entity(), loader)) { String subFinder = SpringGeneratorSupport.m2mFinderNameForSubtype(nav.relationName(), disc); src.append(" @GetMapping(\"/").append(seg).append("/{id}/").append(nav.relationName()).append("\")\n"); - src.append(" public ResponseEntity> ") + boolean navHides = !targetCredentials(st.entity(), nav).isEmpty(); + src.append(" public ResponseEntity> ") .append(subFinder).append("(@PathVariable ").append(pkType).append(" id) {\n"); src.append(" if (repository.findByIdAndType(id, \"").append(disc).append("\").isEmpty()) {\n"); src.append(" return ResponseEntity.ok(List.of());\n"); src.append(" }\n"); - src.append(" return ResponseEntity.ok(repository.").append(subFinder) - .append("(id").append(SpringGeneratorSupport.targetSubtypeCallArg(nav)).append("));\n"); + src.append(" return ResponseEntity.ok(").append(redactAll( + "repository." + subFinder + "(id" + SpringGeneratorSupport.targetSubtypeCallArg(nav) + ")", + targetCredentials(st.entity(), nav))).append(");\n"); src.append(" }\n\n"); } } @@ -1529,7 +1581,10 @@ private static List serverOwnedOnCreate(MetaObject entity) { List out = new ArrayList<>(); for (MetaField f : SpringGeneratorSupport.scalarFields(entity)) { - if (pkFields.contains(f.getName()) || AutoSetSupport.isAutoSet(f)) { + // #484: a write-denied field is IGNORED on create, so a @NotNull on it describes the + // response shape and must not reject a body that (necessarily) omits it. + if (pkFields.contains(f.getName()) || AutoSetSupport.isAutoSet(f) + || SpringGeneratorSupport.isApiWriteDenied(f)) { String component = SpringNaming.recordComponentName(f.getName()); if (!out.contains(component)) out.add(component); } @@ -1560,6 +1615,70 @@ private static void appendConstraintCatch(StringBuilder src) { src.append(" }\n"); } + /** The credential wire names of the TARGET of {@code source}'s M:N relationship {@code nav} (#484). */ + private static List targetCredentials(MetaObject source, SpringM2mSupport.M2mNav nav) { + for (com.metaobjects.relationship.MetaRelationship rel : source.getRelationships()) { + if (!nav.relationName().equals(rel.getShortName())) continue; + MetaObject target = com.metaobjects.loader.References.refTarget( + rel, com.metaobjects.relationship.MetaRelationship.ATTR_OBJECT_REF); + return target == null ? List.of() : SpringGeneratorSupport.credentialNames(target); + } + return List.of(); + } + + /** The fields of a create body that a generated write route ignores (#484). */ + private static void appendStripWriteDenied(StringBuilder src, String dtoName, List components) { + if (components.stream().noneMatch(SpringGeneratorSupport::isApiWriteDenied)) return; + src.append(" dto = new ").append(dtoName).append("("); + for (int i = 0; i < components.size(); i++) { + MetaField f = components.get(i); + if (i > 0) src.append(", "); + src.append(SpringGeneratorSupport.isApiWriteDenied(f) + ? "null" : "dto." + SpringNaming.recordComponentName(f.getName()) + "()"); + } + src.append(");\n"); + } + + /** Java source for a {@code String...} argument list naming {@code hidden}. */ + private static String hiddenArgs(List hidden) { + StringBuilder b = new StringBuilder(); + for (String h : hidden) b.append(", \"").append(h).append('"'); + return b.toString(); + } + + /** Expression for one response row with {@code hidden} removed (the row itself when none). */ + private static String redactOne(String expr, List hidden) { + return hidden.isEmpty() ? expr : "redact(" + expr + hiddenArgs(hidden) + ")"; + } + + /** Expression for a list of response rows with {@code hidden} removed from each. */ + private static String redactAll(String expr, List hidden) { + return hidden.isEmpty() ? expr : "redactAll(" + expr + hiddenArgs(hidden) + ")"; + } + + /** The {@code Optional.map} argument that answers 200 with a row, minus {@code hidden}. */ + private static String okMapper(List hidden) { + return hidden.isEmpty() ? "ResponseEntity::ok" : "r -> ResponseEntity.ok(" + redactOne("r", hidden) + ")"; + } + + /** + * #484 — the helpers a controller uses to leave credential fields out of response bodies. The + * shared DTO keeps the member (the application's own code reads and writes it); only the + * generated HTTP boundary drops the key, so a credential is ABSENT, not null. + */ + private static void appendRedactHelpers(StringBuilder src) { + src.append(" private Object redact(Object row, String... hidden) {\n"); + src.append(" com.fasterxml.jackson.databind.node.ObjectNode node = objectMapper.valueToTree(row);\n"); + src.append(" node.remove(java.util.Arrays.asList(hidden));\n"); + src.append(" return node;\n"); + src.append(" }\n\n"); + src.append(" private List redactAll(List rows, String... hidden) {\n"); + src.append(" List out = new java.util.ArrayList<>(rows.size());\n"); + src.append(" for (Object row : rows) out.add(redact(row, hidden));\n"); + src.append(" return out;\n"); + src.append(" }\n\n"); + } + private static void appendServerOwnedOnCreate(StringBuilder src, MetaObject entity) { List serverOwned = serverOwnedOnCreate(entity); if (serverOwned.isEmpty()) return; // no constant, and the handler keeps its old shape diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index b15b8ec31a..5df10d80e9 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -78,11 +78,54 @@ public static List settableFields(MetaObject entity) { // create. A value presented for one is STRIPPED (simply not in the settable // set), never 400'd — the uniform behaviour of every excluded key here. if (isNonReadWriteMutability(field)) continue; + // #484: a @formExclude / credential field is not writable through the HTTP boundary. + if (isApiWriteDenied(field)) continue; out.add(field); } return out; } + /** The {@code @formExclude} attribute (boolean): the field is left out of generated forms. */ + public static final String ATTR_FORM_EXCLUDE = "formExclude"; + + /** + * #484 — a credential: the field declares (or inherits, ADR-0039) a child {@code view.password}. + * A credential is never returned by a generated route and never written through one. + */ + public static boolean isCredentialField(MetaField field) { + for (Object child : field.getViews()) { + com.metaobjects.view.MetaView view = (com.metaobjects.view.MetaView) child; + if (com.metaobjects.view.PresentationView.SUBTYPE_PASSWORD.equals(view.getSubType())) return true; + } + return false; + } + + /** + * #484 — true when a generated create / PATCH / PUT route must IGNORE a value for this field + * (strip it, never reject it, so a form that submits every field still saves): its effective + * {@code @formExclude} is true, or it is a {@link #isCredentialField credential}. Resolving + * (ADR-0039): an inherited mark counts. + */ + public static boolean isApiWriteDenied(MetaField field) { + if (field.hasMetaAttr(ATTR_FORM_EXCLUDE, true) + && "true".equalsIgnoreCase(field.getMetaAttr(ATTR_FORM_EXCLUDE, true).getValueAsString())) { + return true; + } + return isCredentialField(field); + } + + /** #484 — the wire names of the credential fields among {@code fields}, in declared order. */ + public static List credentialNames(java.util.Collection fields) { + List out = new ArrayList<>(); + for (MetaField f : fields) if (isCredentialField(f)) out.add(f.getName()); + return out; + } + + /** #484 — the credential wire names of {@code entity}'s DTO components. */ + public static List credentialNames(MetaObject entity) { + return credentialNames(dtoComponentFields(entity)); + } + /** * FR-037 R1 — true when the field's EFFECTIVE {@code @mutability} is anything other * than {@code readWrite}, i.e. it is excluded from the PATCH settable set. Resolving diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java new file mode 100644 index 0000000000..4ca4215b68 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java @@ -0,0 +1,94 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * #484 — a {@code @formExclude} field and a credential ({@code view.password}) are ignored by + * every generated write route, and only the credential is left out of responses. The credential's + * write path is not observable over HTTP, so this pins it in the generated source. + */ +public class SpringSecretFieldsCodegenTest extends SharedRegistryTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String MARKED = """ + { + "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + { "field.string": { "name": "tokenHash", "@formExclude": true } }, + { "field.string": { "name": "pinHash", "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } } + ] } } + ] } + } + """; + + private static final String PLAIN = MARKED + .replace(", \"@formExclude\": true", "") + .replace(", \"children\": [ { \"view.password\": {} } ]", ""); + + private String generate(String name, String fixture, String file, boolean dto) throws Exception { + Path gen = tmp.newFolder("gen-" + name).toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder("ws-" + name).toPath(), name, fixture); + run(new SpringControllerGenerator(), loader, gen); + if (dto) run(new SpringDtoGenerator(), loader, gen); + return Files.readString(gen.resolve("acme/secrets/" + file)); + } + + private static void run(com.metaobjects.generator.direct.MultiFileDirectGeneratorBase g, + MetaDataLoader loader, Path out) { + Map args = new HashMap<>(); + args.put("outputDir", out.toString()); + g.setArgs(args); + g.execute(loader); + } + + @Test + public void createIgnoresBothDeniedFields_andPatchCarriesNeither() throws Exception { + String controller = generate("marked", MARKED, "AccountController.java", true); + assertTrue("create rebuilds the DTO without tokenHash and pinHash; saw:\n" + controller, + controller.contains("dto = new AccountDto(dto.id(), dto.name(), null, null);")); + + Path gen = tmp.getRoot().toPath().resolve("gen-marked"); + String patch = Files.readString(gen.resolve("acme/secrets/AccountPatch.java")); + assertFalse("Patch must not carry tokenHash", patch.contains("tokenHash")); + assertFalse("Patch must not carry pinHash", patch.contains("pinHash")); + assertTrue("Patch keeps the ordinary name", patch.contains("name")); + // the shared DTO keeps both members for the application's own write path + String dto = Files.readString(gen.resolve("acme/secrets/AccountDto.java")); + assertTrue(dto.contains("tokenHash") && dto.contains("pinHash")); + } + + @Test + public void onlyTheCredentialIsHiddenOnRead() throws Exception { + String controller = generate("read", MARKED, "AccountController.java", false); + assertTrue("credential hidden from rows; saw:\n" + controller, + controller.contains("redactAll(rows, \"pinHash\")")); + assertTrue(controller.contains("redact(saved, \"pinHash\")")); + assertFalse("@formExclude alone is still returned", controller.contains("redact(saved, \"pinHash\", \"tokenHash\")")); + assertFalse(controller.contains("\"tokenHash\")")); + } + + @Test + public void unmarkedModelEmitsNoRedactionAndNoStrip() throws Exception { + String controller = generate("plain", PLAIN, "AccountController.java", false); + assertFalse(controller.contains("redact")); + assertFalse(controller.contains("dto = new AccountDto(")); + } +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt new file mode 100644 index 0000000000..c623d965f1 --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt @@ -0,0 +1,73 @@ +package com.metaobjects.integration.kotlin.api.secretfields + +import com.fasterxml.jackson.databind.ObjectMapper +import com.metaobjects.integration.kotlin.api.ApiContractAssertions +import com.metaobjects.integration.kotlin.api.ApiContractScenarioLoader +import com.metaobjects.integration.kotlin.api.ApiContractScenarios.ApiScenario +import com.metaobjects.integration.kotlin.api.secretfields.generated.GeneratedSecretFieldsControllerHarness +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.stream.Stream + +/** + * #484 secret-fields api-contract conformance, Kotlin GENERATED-controller lane: a `@formExclude` or + * credential (`view.password`) field is ignored on every generated write and a credential is never returned. + */ +@DisplayName("API contract secret-fields (#214) — GENERATED Kotlin Spring controller over embedded Tomcat") +internal class SecretFieldsGeneratedApiContractConformanceTest { + + @ParameterizedTest(name = "{0}") + @MethodSource("scenarios") + fun scenario(name: String, scenario: ApiScenario) { + assertDoesNotThrow { + HARNESS.reset() // fresh H2 + seed per scenario (create mutates) + for (req in scenario.requests) { + val res = HARNESS.exchange(req.method, req.path, req.body) + val parsed = HARNESS.parseBody(res.body) + ApiContractAssertions.assertResponse(scenario.name, req, res.status, parsed) + } + } + } + + companion object { + private val MAPPER = ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + + @Suppress("UNCHECKED_CAST") + private val SEED: List> by lazy { + val corpus = ApiContractScenarioLoader.findCorpusRoot() + val text = Files.readString(corpus.resolve("secret-fields/seed.json"), StandardCharsets.UTF_8) + val parsed = MAPPER.readValue(text, Map::class.java) as Map + (parsed["rows"] as? List>) ?: error("secret-fields/seed.json: missing 'rows'") + } + + private lateinit var HARNESS: GeneratedSecretFieldsControllerHarness + + @BeforeAll + @JvmStatic + fun setUp() { + val corpus = ApiContractScenarioLoader.findCorpusRoot() + val genDir = Files.createTempDirectory("issue214-generated-kotlin-secret-fields-controller") + HARNESS = GeneratedSecretFieldsControllerHarness(corpus, genDir, SEED) + } + + @AfterAll + @JvmStatic + fun tearDown() { + if (::HARNESS.isInitialized) HARNESS.close() + } + + @JvmStatic + fun scenarios(): Stream { + val corpus = ApiContractScenarioLoader.findCorpusRoot() + val scenarios = ApiContractScenarioLoader.loadScenarios(corpus.resolve("secret-fields/scenarios")) + return scenarios.stream().map { Arguments.of(it.name, it) } + } + } +} diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt new file mode 100644 index 0000000000..8af7b1212b --- /dev/null +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt @@ -0,0 +1,145 @@ +package com.metaobjects.integration.kotlin.api.secretfields.generated + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.registerKotlinModule +import com.metaobjects.integration.kotlin.api.TomcatHost +import com.metaobjects.generator.kotlin.KotlinEntityGenerator +import com.metaobjects.generator.kotlin.KotlinExposedTableGenerator +import com.metaobjects.generator.kotlin.KotlinFilterAllowlistGenerator +import com.metaobjects.generator.kotlin.KotlinNamesGenerator +import com.metaobjects.generator.kotlin.KotlinSpringControllerGenerator +import com.metaobjects.loader.uri.URIHelper +import com.metaobjects.metadata.ktx.loadUris +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.transactions.transaction +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.atomic.AtomicInteger +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText + +/** + * #484: host the GENERATED Kotlin Spring `AccountController` for the secret-fields corpus over real HTTP + * (embedded Tomcat, in-memory H2). Same generate, compile, serve mechanism as the write-through harness. + */ +@OptIn(org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi::class) +class GeneratedSecretFieldsControllerHarness( + corpusRoot: Path, + genDir: Path, + private val rows: List>, +) : AutoCloseable { + + private val mapper: ObjectMapper = ObjectMapper() + .registerKotlinModule() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + + private val controllerClass: Class<*> + private val accountTable: Table + private val dbSeq = AtomicInteger(0) + private var host: TomcatHost? = null + + init { + val metaJson = corpusRoot.resolve("secret-fields/meta.json") + val uri: URI = URIHelper.toURI("model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/')) + val loader = loadUris("api-contract-secret-fields-generated", listOf(uri)) + // ADR-0058 — this corpus's committed .metaobjects/config.json (at corpusRoot, + // fixtures/api-contract-conformance/) declares naming.columns: literal; every field + // here declares no @column, so the generated columns and the runtime's reads of them + // depend on this. Read with the SAME NeutralConfig reader every port uses. + loader.root.physicalNaming = + com.metaobjects.config.SourceResolver.resolvePhysicalNaming(corpusRoot) + // ADR-0019 Amendment 2: the same file pins wire (decimals: string, nullMembers: omit), so the + // generated serializers write the portable form. Discovered by walking up from the meta file, so a + // sub-corpus directory (jsonb/, m2m/, ...) reads the config at the api-contract corpus root. + loader.root.wireFormat = com.metaobjects.config.SourceResolver + .discoverWireFormat(listOf(java.nio.file.Path.of(corpusRoot.toString(), "meta.json").toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT) + + val srcDir = genDir.resolve("src") + Files.createDirectories(srcDir) + for (g in listOf( + KotlinEntityGenerator(), + KotlinNamesGenerator(), + KotlinExposedTableGenerator(), + KotlinFilterAllowlistGenerator(), + KotlinSpringControllerGenerator(), + )) { + // Task 6 — free compile-level proof that Names const val references + // actually resolve; useNames is ignored by every other generator. This is + // also the write-through lane — the ONE existing corpus fixture that + // exercises Names substitution against emitWriteThrough's TWO Exposed + // objects (OrderTable + OrderView) sharing one entity's column names. + g.setArgs(mapOf("outputDir" to srcDir.toString(), "useNames" to "true")) + g.execute(loader) + } + + val sources = Files.walk(srcDir).use { stream -> + stream.filter { it.isRegularFile() && it.toString().endsWith(".kt") } + .map { path -> SourceFile.kotlin(srcDir.relativize(path).toString().replace('/', '_'), path.readText()) } + .toList() + } + check(sources.isNotEmpty()) { "no generated .kt sources under $srcDir" } + + val result = KotlinCompilation().apply { + this.sources = sources + inheritClassPath = true + messageOutputStream = System.out + }.compile() + check(result.exitCode == KotlinCompilation.ExitCode.OK) { + "generated Kotlin failed to compile:\n${result.messages}" + } + + this.controllerClass = result.classLoader.loadClass(CONTROLLER_FQCN) + this.accountTable = result.classLoader.loadClass(ACCOUNT_TABLE_FQCN).getDeclaredField("INSTANCE").get(null) as Table + } + + /** Rebuild a fresh in-memory H2 + seed + controller + Tomcat. */ + fun reset() { + val dbName = "secret_fields_${dbSeq.incrementAndGet()}" + val db = Database.connect("jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") + transaction(db) { + SchemaUtils.create(accountTable) + // Ids are assigned by the identity column in seed order (1, 2), so a later POST does not collide. + // Mixed-case columns are quoted (naming.columns: literal), as in the write-through harness. + for (r in rows) { + exec("INSERT INTO accounts (\"name\", \"tokenHash\", \"pinHash\") VALUES (${sqlStr(r["name"])}, ${sqlStr(r["tokenHash"])}, ${sqlStr(r["pinHash"])})") + } + } + + val controller = controllerClass + .getDeclaredConstructor(ObjectMapper::class.java, jakarta.validation.Validator::class.java) + .newInstance(mapper, jakarta.validation.Validation.buildDefaultValidatorFactory().validator) + host?.close() + host = TomcatHost.start(mapper, controller) + } + + fun exchange(method: String, path: String, jsonBody: Any?): Response { + val server = host ?: error("reset() must be called before exchange(...)") + val res = server.exchange(method, path, jsonBody?.let { mapper.writeValueAsString(it) }) + return Response(res.status, res.body) + } + + fun parseBody(body: String?): Any? = TomcatHost.parseBody(mapper, body) + + override fun close() { + host?.close() // H2 in-mem is reclaimed at JVM exit (DB_CLOSE_DELAY=-1). + } + + data class Response(val status: Int, val body: String) + + private companion object { + const val ENTITY_PKG = "acme.secrets" + const val CONTROLLER_FQCN = "$ENTITY_PKG.AccountController" + const val ACCOUNT_TABLE_FQCN = "$ENTITY_PKG.AccountTable" + + fun sqlStr(v: Any?): String = "'" + (v as String).replace("'", "''") + "'" + } +} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java new file mode 100644 index 0000000000..24b4b2c25a --- /dev/null +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java @@ -0,0 +1,81 @@ +package com.metaobjects.integration.api; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.integration.api.ApiContractScenarios.ApiRequest; +import com.metaobjects.integration.api.ApiContractScenarios.ApiScenario; +import com.metaobjects.integration.api.generated.GeneratedAccountControllerHarness; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * #484 — the Java GENERATED-controller lane for the {@code secret-fields} api-contract + * sub-corpus: a {@code @formExclude} field is ignored on every generated write route and still + * returned, and a credential ({@code view.password}) is ignored on write and never returned. + * + *

Generated lane ONLY, on every port: what is under test is whether the GENERATOR emits routes + * that honour the marks, which a hand-rolled reference server would satisfy by construction.

+ * + *

Run on-demand: + * {@code mvn -f server/java/integration-tests/pom.xml test -Dtest=SecretFieldsGeneratedApiContractConformanceTest}

+ */ +@DisplayName("API contract secret-fields — GENERATED Spring controller (codegen-spring) over embedded Tomcat") +final class SecretFieldsGeneratedApiContractConformanceTest { + + private static final Path CONFIG_ROOT = ApiContractScenarioLoader.findCorpusRoot(); + private static final Path ROOT = CONFIG_ROOT.resolve("secret-fields"); + private static final List SCENARIOS = + ApiContractScenarioLoader.loadScenarios(ROOT.resolve("scenarios")); + + private static GeneratedAccountControllerHarness HARNESS; + + @SuppressWarnings("unchecked") + private static List> seedRows() throws IOException { + String text = Files.readString(ROOT.resolve("seed.json"), StandardCharsets.UTF_8); + Object rows = new ObjectMapper().readValue(text, Map.class).get("rows"); + if (!(rows instanceof List)) throw new IllegalStateException("secret-fields/seed.json: missing 'rows' array"); + return (List>) rows; + } + + @BeforeAll + static void setUp() throws Exception { + Path genDir = Files.createTempDirectory("secret-fields-generated-controller"); + HARNESS = new GeneratedAccountControllerHarness(ROOT, CONFIG_ROOT, genDir, seedRows()); + } + + @AfterAll + static void tearDown() throws Exception { + if (HARNESS != null) HARNESS.close(); + } + + static Stream scenarios() { + return SCENARIOS.stream().map(s -> Arguments.of(s.name(), s)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("scenarios") + void scenario(String name, ApiScenario scenario) { + assertDoesNotThrow(() -> { + HARNESS.reset(); + for (ApiRequest req : scenario.requests()) { + GeneratedAccountControllerHarness.Response res = + HARNESS.exchange(req.method(), req.path(), req.body()); + Object parsed = HARNESS.parseBody(res.body()); + ApiContractAssertions.assertResponse(scenario.name(), req, res.status(), parsed); + } + }); + } +} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java new file mode 100644 index 0000000000..f22e1ca1da --- /dev/null +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java @@ -0,0 +1,173 @@ +package com.metaobjects.integration.api.generated; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.metaobjects.generator.spring.SpringControllerGenerator; +import com.metaobjects.generator.spring.SpringDtoGenerator; +import com.metaobjects.generator.spring.SpringFilterAllowlistGenerator; +import com.metaobjects.generator.spring.SpringRepositoryGenerator; +import com.metaobjects.integration.api.TomcatHost; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.loader.uri.URIHelper; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; + +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.ToolProvider; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.net.URI; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * #484 — host the GENERATED Spring {@code AccountController} of the {@code secret-fields} + * sub-corpus over real HTTP (embedded Tomcat) and drive its scenarios. Sibling of + * {@link GeneratedAuthorControllerHarness}; the only hand-written piece is + * {@link InMemoryAccountRepositorySource}, the consumer seam. + */ +public final class GeneratedAccountControllerHarness implements AutoCloseable { + + private static final String ENTITY_PKG = "acme.secrets"; + + private final ObjectMapper mapper = new ObjectMapper(); + private final URLClassLoader classLoader; + private final Class dtoClass; + private final Constructor controllerCtor; + private final Constructor repoCtor; + private final List> seedRows; + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + private TomcatHost host; + + /** + * @param corpusRoot {@code fixtures/api-contract-conformance/secret-fields} + * @param configRoot {@code fixtures/api-contract-conformance} (holds the committed config) + */ + public GeneratedAccountControllerHarness(Path corpusRoot, Path configRoot, Path genDir, + List> seedRows) throws Exception { + this.seedRows = seedRows; + Path srcDir = genDir.resolve("src"); + Path classesDir = genDir.resolve("classes"); + Files.createDirectories(srcDir); + Files.createDirectories(classesDir); + + MetaDataLoader loader = loadCorpus(corpusRoot.resolve("meta.json")); + loader.getRoot().setPhysicalNaming( + com.metaobjects.config.SourceResolver.resolvePhysicalNaming(configRoot)); + loader.getRoot().setWireFormat( + com.metaobjects.config.SourceResolver.discoverWireFormat(List.of(configRoot.toUri())) + .orElse(com.metaobjects.config.WireFormat.DEFAULT)); + + runGenerator(new SpringControllerGenerator(), loader, srcDir); + runGenerator(new SpringDtoGenerator(), loader, srcDir); + runGenerator(new SpringRepositoryGenerator(), loader, srcDir); + runGenerator(new SpringFilterAllowlistGenerator(), loader, srcDir); + + Path repoImpl = srcDir.resolve(ENTITY_PKG.replace('.', '/')).resolve("InMemoryAccountRepository.java"); + Files.createDirectories(repoImpl.getParent()); + Files.writeString(repoImpl, InMemoryAccountRepositorySource.SOURCE); + + compile(srcDir, classesDir); + + this.classLoader = new URLClassLoader( + new URL[]{ classesDir.toUri().toURL() }, getClass().getClassLoader()); + this.dtoClass = classLoader.loadClass(ENTITY_PKG + ".AccountDto"); + Class repoInterface = classLoader.loadClass(ENTITY_PKG + ".AccountRepository"); + this.controllerCtor = classLoader.loadClass(ENTITY_PKG + ".AccountController") + .getDeclaredConstructor(repoInterface, ObjectMapper.class, Validator.class); + this.repoCtor = classLoader.loadClass(InMemoryAccountRepositorySource.FQCN) + .getDeclaredConstructor(List.class); + } + + /** Re-seed for a scenario: a fresh repository, controller and Tomcat. */ + public void reset() throws Exception { + List dtos = new ArrayList<>(); + for (Map row : seedRows) dtos.add(mapper.convertValue(row, dtoClass)); + Object controller = controllerCtor.newInstance(repoCtor.newInstance(dtos), mapper, validator); + if (host != null) host.close(); + this.host = TomcatHost.start(mapper, controller); + } + + public Response exchange(String method, String path, Object jsonBody) throws Exception { + TomcatHost.Response res = host.exchange( + method, path, jsonBody == null ? null : mapper.writeValueAsString(jsonBody)); + return new Response(res.status(), res.body()); + } + + public Object parseBody(String body) { + return TomcatHost.parseBody(mapper, body); + } + + @Override + public void close() throws Exception { + if (host != null) host.close(); + classLoader.close(); + } + + public record Response(int status, String body) {} + + private static MetaDataLoader loadCorpus(Path metaJson) { + URI uri = URIHelper.toURI( + "model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/')); + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), + MetaDataLoader.SUBTYPE_MANUAL, + "api-contract-secret-fields-generated"); + loader.setSourceURIs(List.of(uri)); + loader.init(); + return loader; + } + + private static void runGenerator(Object generator, MetaDataLoader loader, Path outDir) { + Map args = new HashMap<>(); + args.put("outputDir", outDir.toString()); + ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase) generator).setArgs(args); + ((com.metaobjects.generator.direct.MultiFileDirectGeneratorBase) generator).execute(loader); + } + + private static void compile(Path srcDir, Path classesDir) throws Exception { + List sources; + try (Stream s = Files.walk(srcDir)) { + sources = s.filter(p -> p.toString().endsWith(".java")) + .map(Path::toFile) + .collect(Collectors.toList()); + } + if (sources.isEmpty()) throw new IllegalStateException("no generated .java sources under " + srcDir); + + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + if (javac == null) { + throw new IllegalStateException("JDK (not JRE) required — getSystemJavaCompiler() returned null"); + } + String cp = System.getProperty("java.class.path"); + DiagnosticCollector diags = new DiagnosticCollector<>(); + var fm = javac.getStandardFileManager(diags, null, StandardCharsets.UTF_8); + // -parameters: the generated controller relies on reflective parameter names, as Spring Boot builds do. + List opts = List.of("-classpath", cp, "-d", classesDir.toString(), "-parameters"); + + if (!javac.getTask(null, fm, diags, opts, null, fm.getJavaFileObjectsFromFiles(sources)).call()) { + StringBuilder sb = new StringBuilder("generated sources failed to compile:\n"); + for (var d : diags.getDiagnostics()) { + sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n'); + if (d.getSource() != null) { + sb.append(" at ").append(d.getSource().getName()) + .append(':').append(d.getLineNumber()).append('\n'); + } + } + throw new IllegalStateException(sb.toString()); + } + } +} diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryAccountRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryAccountRepositorySource.java new file mode 100644 index 0000000000..773413c696 --- /dev/null +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryAccountRepositorySource.java @@ -0,0 +1,115 @@ +package com.metaobjects.integration.api.generated; + +/** + * The Java SOURCE for the in-memory {@code acme.secrets.AccountRepository} impl behind the + * GENERATED {@code AccountController} in the secret-fields sub-corpus (#484). + * + *

Test scaffolding, not a conformance subject. It is written against + * {@code patch.assignedValues()} (a generic map) rather than the {@code has()} accessors on + * purpose: whether a denied field appears on the generated {@code AccountPatch} at all is + * exactly what the corpus is probing, and this seam must compile either way so the failure + * lands as a scenario assertion and not as a javac error.

+ */ +final class InMemoryAccountRepositorySource { + + private InMemoryAccountRepositorySource() {} + + static final String FQCN = "acme.secrets.InMemoryAccountRepository"; + + static final String SOURCE = """ + package acme.secrets; + + import com.metaobjects.generator.spring.runtime.FilterPredicate; + + import java.util.ArrayList; + import java.util.Comparator; + import java.util.List; + import java.util.Map; + import java.util.Optional; + import java.util.concurrent.atomic.AtomicLong; + + public final class InMemoryAccountRepository implements AccountRepository { + + private final List rows = new ArrayList<>(); + private final AtomicLong nextId = new AtomicLong(1); + + public InMemoryAccountRepository(List seed) { + long max = 0; + for (AccountDto a : seed) { + rows.add(a); + if (a.id() != null && a.id() > max) max = a.id(); + } + nextId.set(max + 1); + } + + @Override + public List list(int limit, int offset, SortClause sort, List filters) { + List out = new ArrayList<>(rows); + Comparator c = sort != null && "name".equals(sort.field()) + ? Comparator.comparing(AccountDto::name, Comparator.nullsLast(Comparator.naturalOrder())) + : Comparator.comparing(AccountDto::id, Comparator.nullsLast(Comparator.naturalOrder())); + if (sort != null && "desc".equalsIgnoreCase(sort.direction())) c = c.reversed(); + out.sort(c); + int from = Math.min(offset, out.size()); + int to = Math.min(from + limit, out.size()); + return new ArrayList<>(out.subList(from, to)); + } + + @Override + public long count(List filters) { + return rows.size(); + } + + @Override + public Optional findById(Long id) { + for (AccountDto a : rows) if (id.equals(a.id())) return Optional.of(a); + return Optional.empty(); + } + + @Override + public AccountDto create(AccountDto dto) { + AccountDto saved = new AccountDto(nextId.getAndIncrement(), dto.name(), dto.tokenHash(), dto.pinHash()); + rows.add(saved); + return saved; + } + + @Override + public Optional update(Long id, AccountDto dto) { + for (int i = 0; i < rows.size(); i++) { + AccountDto cur = rows.get(i); + if (id.equals(cur.id())) { + AccountDto merged = new AccountDto(id, + dto.name() != null ? dto.name() : cur.name(), + dto.tokenHash() != null ? dto.tokenHash() : cur.tokenHash(), + dto.pinHash() != null ? dto.pinHash() : cur.pinHash()); + rows.set(i, merged); + return Optional.of(merged); + } + } + return Optional.empty(); + } + + @Override + public Optional patch(Long id, AccountPatch patch) { + Map assigned = patch.assignedValues(); + for (int i = 0; i < rows.size(); i++) { + AccountDto cur = rows.get(i); + if (id.equals(cur.id())) { + AccountDto merged = new AccountDto(id, + assigned.containsKey("name") ? (String) assigned.get("name") : cur.name(), + assigned.containsKey("tokenHash") ? (String) assigned.get("tokenHash") : cur.tokenHash(), + assigned.containsKey("pinHash") ? (String) assigned.get("pinHash") : cur.pinHash()); + rows.set(i, merged); + return Optional.of(merged); + } + } + return Optional.empty(); + } + + @Override + public boolean delete(Long id) { + return rows.removeIf(a -> id.equals(a.id())); + } + } + """; +} diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index fd44da73bb..aabb6c99d8 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -73,7 +73,8 @@ from metaobjects.naming import DEFAULT_COLUMN_NAMING from metaobjects.naming_refs import ref_target from metaobjects.wire import WIRE_NATIVE, WireFormat, wire_of -from metaobjects.shared.base_types import TYPE_IDENTITY +from metaobjects.meta.presentation.view.view_constants import VIEW_SUBTYPE_PASSWORD +from metaobjects.shared.base_types import TYPE_IDENTITY, TYPE_VIEW from ..runtime.fr010_field_mapping import is_required from ..runtime.m2m_codegen import ( @@ -309,6 +310,8 @@ def _wire_lines(entities: list[MetaObject], wire: WireFormat, name: str = "_WIRE args = f'decimals="{wire.decimals}", null_members="{wire.null_members}"' if shapes: args += f", shapes={shapes!r}" + if hidden := _hidden_names(entities): + args += f", hidden={tuple(hidden)!r}" return ["", "", f"{name} = WireOutput({args})"] @@ -372,6 +375,49 @@ def _is_auto_set(field: MetaField) -> bool: ) +def _is_credential(field: MetaField) -> bool: + """#484 — a field declaring (or inheriting) a ``view.password`` child is a credential: + never returned by a generated route, never written by one. RESOLVING ``children()`` + (ADR-0039), so an inherited ``view.password`` counts.""" + return any(c.type == TYPE_VIEW and c.sub_type == VIEW_SUBTYPE_PASSWORD for c in field.children()) + + +def _denied_names(entity: MetaObject) -> list[str]: + """#484 — the fields a generated create / PATCH / PUT ignores: ``@formExclude`` (RESOLVING + read) or a credential. Ignored, never 400'd — a generated edit form submits every field it + was given. Only the HTTP boundary strips them; the entity model and the application's own + repository still carry the members.""" + return [ + f.name + for f in entity.fields() + if f.get_meta_attr(fc.FIELD_ATTR_FORM_EXCLUDE) is True or _is_credential(f) + ] + + +def _hidden_names(entities: list[MetaObject]) -> list[str]: + """#484 — the credential fields of *entities*, which no generated response carries. + ``@formExclude`` alone does not hide a field.""" + return list(dict.fromkeys(f.name for e in entities for f in e.fields() if _is_credential(f))) + + +def _denied_strip_lines(names: list[str], indent: str = " ") -> list[str]: + """The pop-loop for :func:`_denied_names`. Empty when nothing is denied, so output stays + byte-identical for a model with no ``@formExclude`` and no ``view.password``.""" + if not names: + return [] + return [ + f"{indent}# #484: @formExclude / credential fields are IGNORED on write, never 400'd.", + f"{indent}for _denied in _DENIED_FIELDS:", + f"{indent} dto.pop(_denied, None)", + ] + + +def _denied_const_lines(names: list[str]) -> list[str]: + if not names: + return [] + return ["", "", f"_DENIED_FIELDS: frozenset[str] = {_py_set_literal(names, frozen=True)}"] + + def _frozen_names(entity: MetaObject) -> list[str]: """FR-037 R1 — the field names a PATCH must never write: `@mutability` ``readOnly`` (nobody writes it) and ``writeOnce`` (frozen after create). @@ -889,6 +935,7 @@ def _emit_route_handler( create_autoset: list[str] = (), update_autoset: list[str] = (), frozen_strip: list[str] = (), + denied_strip: list[str] = (), report_max_rows: int | None = None, report_export: bool = False, ) -> list[str]: @@ -1025,6 +1072,7 @@ def _emit_route_handler( f" repo: Annotated[{repo_class}, Depends(get_repository)],", ") -> Any:", *_DECIMAL_COERCE, + *denied_strip, " # FR-036: run the generated field constraints (length / pattern /", " # numeric bounds) before persisting; a violation is the cross-port 400.", " try:", @@ -1049,6 +1097,7 @@ def _emit_route_handler( f" repo: Annotated[{repo_class}, Depends(get_repository)],", ") -> Any:", *_DECIMAL_COERCE, + *denied_strip, " # FR-035 PATCH-2: an explicit null on a @required field is a 400 —", " # a present null on a NON-required field falls through and clears it,", " # and an OMITTED required field is untouched (never a 400).", @@ -1284,6 +1333,11 @@ def _render_tph_router( frozen_seen.add(name) frozen_names.append(name) frozen_set_body = _py_set_literal(frozen_names, frozen=True) + # #484 — union across the base AND every subtype, like the frozen set above. + denied_names: list[str] = list(dict.fromkeys( + [*_denied_names(entity), *(n for st in plan.subtypes for n in _denied_names(st.entity))] + )) + denied_strip_tph = _denied_strip_lines(denied_names) frozen_strip_tph = _frozen_strip_lines(entity) if frozen_names else [] if frozen_names and not _frozen_names(entity): # A subtype-only frozen field still needs the strip emitted. @@ -1403,6 +1457,7 @@ def _render_tph_router( # the generated module always carries the seam (an entity that later gains a # writeOnce field changes one literal, not the module's shape). parts.append(f"_FROZEN_FIELDS: frozenset[str] = {frozen_set_body}") + parts.extend(_denied_const_lines(denied_names)) parts.append("") parts.append("") parts.extend(_sort_parse_fn_lines()) @@ -1476,6 +1531,7 @@ def list_sig(fn: str, route: str) -> list[str]: parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],") parts.append(") -> Any:") parts.extend(_DECIMAL_COERCE) + parts.extend(denied_strip_tph) parts.append(" # FR-036: run the subtype's field constraints before persisting.") parts.append(" try:") parts.append(f" {sub}Create(**dto)") @@ -1508,6 +1564,7 @@ def list_sig(fn: str, route: str) -> list[str]: parts.append(f" repo: Annotated[{repo_class}, Depends(get_repository)],") parts.append(") -> Any:") parts.extend(_DECIMAL_COERCE) + parts.extend(denied_strip_tph) parts.append(" # FR-035 PATCH-2: an explicit null on a @required field is a 400.") parts.append(" for _k in _REQUIRED_FIELDS:") parts.append(" if _k in dto and dto[_k] is None:") @@ -1782,6 +1839,7 @@ def render_router( # the generated module always carries the seam (an entity that later gains a # writeOnce field changes one literal, not the module's shape). parts.append(f"_FROZEN_FIELDS: frozenset[str] = {frozen_set_body}") + parts.extend(_denied_const_lines(_denied_names(entity))) parts.append("") parts.append("") parts.extend(_sort_parse_fn_lines()) @@ -1814,6 +1872,7 @@ def render_router( # FastAPI (the handler binds the RAW body dict and hands THAT to the # repository), so the strip has to live here, not in Patch. frozen_strip=_frozen_strip_lines(entity), + denied_strip=_denied_strip_lines(_denied_names(entity)), ) for i, hname in enumerate(("list", "get", "create", "update", "delete")): if i > 0: diff --git a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py index 92b209dfcd..98a468f1f6 100644 --- a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py +++ b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py @@ -288,16 +288,32 @@ class WireOutput: decimals: str = "native" null_members: str = "native" shapes: dict[str, Any] = dataclasses.field(default_factory=dict) + #: #484: credential fields, which no response carries (the key is absent, not null). + hidden: tuple[str, ...] = () def render_wire(value: Any, wire: WireOutput) -> Any: """A response *value* (a row, a list of rows, a Pydantic model) in the form *wire* asks for. Input rules do not depend on *wire*.""" + if wire.hidden: + value = _drop_hidden(value, wire.hidden) if wire.null_members != "native" and wire.shapes: value = _shape_members(value, wire) return to_wire(value) if wire.decimals == "string" else to_native(value) +def _drop_hidden(value: Any, hidden: tuple[str, ...]) -> Any: + """*value* (a row, a list of rows, a Pydantic model) without the *hidden* keys.""" + if isinstance(value, (list, tuple)): + return [_drop_hidden(v, hidden) for v in value] + dump = getattr(value, "model_dump", None) + if callable(dump): + value = dump() + if not isinstance(value, dict): + return value + return {k: v for k, v in value.items() if k not in hidden} + + def _shape_members(value: Any, wire: WireOutput) -> Any: """Apply ``omit`` / ``emit`` to the value-object fields of every row in *value*. A row's other fields (a top-level ``null`` among them) are untouched.""" diff --git a/server/python/src/metaobjects/meta/core/field/field_constants.py b/server/python/src/metaobjects/meta/core/field/field_constants.py index cc1da32575..98f1e6d337 100644 --- a/server/python/src/metaobjects/meta/core/field/field_constants.py +++ b/server/python/src/metaobjects/meta/core/field/field_constants.py @@ -142,6 +142,9 @@ # @format. Concepts WITH a native type/behavior are subtypes instead (uuid → # field.uuid, url/uri → field.uri, ip → field.inet). Mirrors TS FIELD_ATTR_STRING_FORMAT. FIELD_ATTR_STRING_FORMAT = "stringFormat" +# #484: a field excluded from forms; a generated HTTP write route ignores it (a credential is +# a field declaring a ``view.password`` child, see ``codegen.generators.router_generator``). +FIELD_ATTR_FORM_EXCLUDE = "formExclude" STRING_FORMAT_EMAIL = "email" STRING_FORMAT_HOSTNAME = "hostname" STRING_FORMAT_VALUES = (STRING_FORMAT_EMAIL, STRING_FORMAT_HOSTNAME) diff --git a/server/python/tests/codegen/test_router_secret_fields.py b/server/python/tests/codegen/test_router_secret_fields.py new file mode 100644 index 0000000000..0962658762 --- /dev/null +++ b/server/python/tests/codegen/test_router_secret_fields.py @@ -0,0 +1,104 @@ +"""#484 — a generated router ignores ``@formExclude`` / credential fields on write and never +returns a credential. Pins the generated shape; the HTTP behaviour is the api-contract +``secret-fields`` corpus (``tests/integration/test_api_contract_secret_fields.py``).""" +from __future__ import annotations + +import json +import shutil +import tempfile +from pathlib import Path + +from metaobjects import MetaDataLoader +from metaobjects.codegen.generators.router_generator import render_router +from metaobjects.codegen.runtime.decimal_wire import WireOutput, render_wire +from metaobjects.codegen.runtime.m2m_codegen import build_object_index +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.shared.base_types import TYPE_OBJECT + + +def _load(meta: dict) -> dict[str, MetaObject]: + tmp = Path(tempfile.mkdtemp(prefix="router-secret-")) + try: + (tmp / "meta.json").write_text(json.dumps(meta)) + result = MetaDataLoader.from_directory(str(tmp)) + assert not result.errors, "; ".join(f"{e.code}: {e.message}" for e in result.errors) + return { + c.name: c for c in result.root.children() + if c.type == TYPE_OBJECT and isinstance(c, MetaObject) + } + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def _id() -> list[dict]: + return [ + {"source.rdb": {"@table": "t"}}, + {"field.long": {"name": "id"}}, + {"identity.primary": {"name": "id", "@fields": "id", "@generation": "increment"}}, + ] + + +_META = {"metadata.root": {"package": "acme::secrets", "children": [ + {"object.entity": {"name": "Account", "children": [ + *_id(), + {"field.string": {"name": "name", "@maxLength": 100}}, + {"field.string": {"name": "tokenHash", "@maxLength": 100, "@formExclude": True}}, + {"field.string": {"name": "pinHash", "@maxLength": 100, "children": [{"view.password": {}}]}}, + ]}}, + # The credential is inherited from the abstract base: the resolving read must see it. + {"object.entity": {"name": "Base", "abstract": True, "children": [ + {"field.string": {"name": "secret", "@maxLength": 100, "children": [{"view.password": {}}]}}, + ]}}, + {"object.entity": {"name": "Child", "extends": "Base", "children": [ + *_id(), + {"field.string": {"name": "label", "@maxLength": 100}}, + ]}}, + {"object.entity": {"name": "Plain", "children": [ + *_id(), + {"field.string": {"name": "title", "@maxLength": 100}}, + ]}}, +]}} + +_E = _load(_META) +_INDEX = build_object_index(list(_E.values())) + + +def _router(name: str) -> str: + out = render_router(_E[name], _INDEX) + assert out is not None + return out + + +def test_create_and_update_name_both_denied_fields() -> None: + out = _router("Account") + assert '_DENIED_FIELDS: frozenset[str] = frozenset({\n "tokenHash",\n "pinHash",\n})' in out + # The strip sits in create AND update (PATCH + PUT share one handler), before validation. + assert out.count("for _denied in _DENIED_FIELDS:") == 2 + create = out[out.index("def create_account("):out.index("def update_account(")] + assert create.index("_DENIED_FIELDS") < create.index("AccountCreate(**dto)") + update = out[out.index("def update_account("):out.index("def delete_account(")] + assert update.index("_DENIED_FIELDS") < update.index("_REQUIRED_FIELDS") + + +def test_only_the_credential_is_hidden_on_read() -> None: + out = _router("Account") + assert "hidden=('pinHash',)" in out + assert "tokenHash'" not in out.split("WireOutput(")[1].split(")")[0] + + +def test_inherited_credential_counts() -> None: + out = _router("Child") + assert '_DENIED_FIELDS: frozenset[str] = frozenset({\n "secret",\n})' in out + assert "hidden=('secret',)" in out + + +def test_model_with_neither_mark_is_unchanged() -> None: + out = _router("Plain") + assert "_DENIED_FIELDS" not in out + assert "hidden=" not in out + + +def test_render_wire_drops_hidden_key_not_nulls_it() -> None: + wire = WireOutput(hidden=("pinHash",)) + assert render_wire({"id": 1, "pinHash": None}, wire) == {"id": 1} + assert render_wire([{"id": 1, "pinHash": "x"}], wire) == [{"id": 1}] diff --git a/server/python/tests/integration/generated_secret_fields_app.py b/server/python/tests/integration/generated_secret_fields_app.py new file mode 100644 index 0000000000..61d9eb9a4e --- /dev/null +++ b/server/python/tests/integration/generated_secret_fields_app.py @@ -0,0 +1,87 @@ +"""#484 — boot the GENERATED ``Account`` router (credential + ``@formExclude`` fields). + +Peer of ``generated_router_app.py`` for ``fixtures/api-contract-conformance/secret-fields/``. +Runs the real generators, imports the emitted router unmodified and mounts it over an +in-memory repo behind the generated consumer seam. The repo stores whatever the router hands +it, so a column the router failed to strip lands in the row and a column it failed to hide +comes back out. +""" +from __future__ import annotations + +import importlib.util +import shutil +import sys +import tempfile +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI + +from metaobjects import MetaDataLoader +from metaobjects.codegen.generators.entity_model import render_entity_model +from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist +from metaobjects.codegen.generators.router_generator import render_router +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.shared.base_types import TYPE_OBJECT + +from .corpus_config import attach_corpus_wire +from .generated_router_app import InMemoryAuthorRepository + +COLUMNS = ("id", "name", "tokenHash", "pinHash") + + +def load_account(meta_json: Path) -> MetaObject: + tmp = Path(tempfile.mkdtemp(prefix="apic-secret-meta-")) + shutil.copy(meta_json, tmp / "meta.json") + result = MetaDataLoader.from_directory(str(tmp)) + attach_corpus_wire(result.root, meta_json) + if result.errors: + raise RuntimeError("; ".join(f"{e.code}: {e.message}" for e in result.errors)) + for c in result.root.children(): + if c.type == TYPE_OBJECT and isinstance(c, MetaObject) and c.name.split("::")[-1] == "Account": + return c + raise RuntimeError("Account entity not found") + + +class InMemoryAccountRepository(InMemoryAuthorRepository): + """The Author repo, with every column present on a created row (a SQL INSERT leaves an + unset column NULL, so a row that was never given ``tokenHash`` reads it back as null).""" + + def create(self, dto: Any) -> Any: + return super().create({**dict.fromkeys(COLUMNS), **dict(dto)}) + + +def build_secret_fields_app(corpus_root: Path) -> tuple[FastAPI, InMemoryAccountRepository]: + account = load_account(corpus_root / "meta.json") + router_src = render_router(account) + allowlist_src = render_filter_allowlist(account) + if router_src is None or allowlist_src is None: + raise RuntimeError("router_generator / filter_allowlist_generator returned None for Account") + + pkg_name = f"gensecret_{uuid.uuid4().hex[:8]}" + tmp = Path(tempfile.mkdtemp(prefix="apic-secret-gen-")) + pkg_dir = tmp / pkg_name + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "account_filter_allowlist.py").write_text(allowlist_src) + (pkg_dir / "Account.py").write_text(render_entity_model(account)) + (pkg_dir / "account_router.py").write_text(router_src) + + sys.path.insert(0, str(tmp)) + pkg_spec = importlib.util.spec_from_file_location( + pkg_name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)] + ) + pkg_mod = importlib.util.module_from_spec(pkg_spec) + sys.modules[pkg_name] = pkg_mod + pkg_spec.loader.exec_module(pkg_mod) + spec = importlib.util.spec_from_file_location(f"{pkg_name}.account_router", pkg_dir / "account_router.py") + router_mod = importlib.util.module_from_spec(spec) + sys.modules[f"{pkg_name}.account_router"] = router_mod + spec.loader.exec_module(router_mod) + + repo = InMemoryAccountRepository() + app = FastAPI() + app.include_router(router_mod.router) + app.dependency_overrides[router_mod.get_repository] = lambda: repo + return app, repo diff --git a/server/python/tests/integration/test_api_contract_secret_fields.py b/server/python/tests/integration/test_api_contract_secret_fields.py new file mode 100644 index 0000000000..fd2ccf7fcb --- /dev/null +++ b/server/python/tests/integration/test_api_contract_secret_fields.py @@ -0,0 +1,70 @@ +"""#484 — cross-port API contract for credential and ``@formExclude`` fields. + +Drives ``fixtures/api-contract-conformance/secret-fields/`` against the GENERATED ``Account`` +router. Generated lane only, by design: a hand-rolled reference server would honour the marks +by construction, and what is under test is whether the GENERATOR emits routes that do. + + cd server/python + uv run --extra dev pytest tests/integration/test_api_contract_secret_fields.py -v +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +import yaml +from fastapi.testclient import TestClient + +from . import api_contract_assertions +from .generated_secret_fields_app import build_secret_fields_app + + +def _find_corpus_root() -> Path: + cur = Path(__file__).resolve() + while cur != cur.parent: + candidate = cur / "fixtures" / "api-contract-conformance" / "secret-fields" + if candidate.is_dir(): + return candidate + cur = cur.parent + raise RuntimeError("Could not find fixtures/api-contract-conformance/secret-fields") + + +_CORPUS = _find_corpus_root() +_SCENARIOS = [ + (raw["name"], raw) + for raw in (yaml.safe_load(p.read_text()) for p in sorted((_CORPUS / "scenarios").glob("*.yaml"))) +] +_SEED_ROWS = json.loads((_CORPUS / "seed.json").read_text())["rows"] +_APP, _REPO = build_secret_fields_app(_CORPUS) +_CLIENT = TestClient(_APP) + + +def _parse(text: str | None) -> Any: + if not text: + return None + try: + return api_contract_assertions.parse_body(text) + except json.JSONDecodeError: + return text + + +@pytest.mark.parametrize("scenario_name,scenario", _SCENARIOS, ids=[n for n, _ in _SCENARIOS]) +def test_secret_fields_scenario(scenario_name: str, scenario: dict[str, Any]) -> None: + _REPO.reset() + _REPO.seed(_SEED_ROWS) + for req in scenario["requests"]: + kwargs: dict[str, Any] = {} + if req.get("body") is not None: + kwargs["json"] = req["body"] + response = _CLIENT.request(str(req["method"]).upper(), str(req["path"]), **kwargs) + expect = req["expect"] + api_contract_assertions.assert_response( + scenario_name=scenario_name, + request_id=str(req.get("id", "?")), + expect_status=int(expect["status"]), + expect_body=expect.get("body"), + status=response.status_code, + body=_parse(response.text), + ) diff --git a/server/typescript/packages/cli/src/commands/init.ts b/server/typescript/packages/cli/src/commands/init.ts index 96262db78d..cbbce4f0e1 100644 --- a/server/typescript/packages/cli/src/commands/init.ts +++ b/server/typescript/packages/cli/src/commands/init.ts @@ -90,7 +90,9 @@ dist/ *.tsbuildinfo `; -function buildMetaobjectsConfigBody(dialect: "sqlite" | "postgres" | "d1" = "sqlite"): string { +// Codegen dialect only: Cloudflare D1 is SQLite, so `--d1` scaffolds "sqlite" here (the "d1" value +// lives in `migrate.dialect` of .metaobjects/config.json, and codegen refuses it). +function buildMetaobjectsConfigBody(dialect: "sqlite" | "postgres" = "sqlite"): string { return `import { defineConfig } from "@metaobjectsdev/cli"; export default defineConfig({ @@ -682,7 +684,7 @@ export async function init(opts: InitOptions): Promise { // Scaffold metaobjects.config.ts at the project root. Never overwrite if it exists. const forgeConfigPath = join(opts.cwd, "metaobjects.config.ts"); if (!(await fileExists(forgeConfigPath))) { - await writeFile(forgeConfigPath, buildMetaobjectsConfigBody(opts.d1 ? "d1" : "sqlite"), "utf8"); + await writeFile(forgeConfigPath, buildMetaobjectsConfigBody("sqlite"), "utf8"); result.created.push("metaobjects.config.ts"); } diff --git a/server/typescript/packages/cli/test/init.test.ts b/server/typescript/packages/cli/test/init.test.ts index 7a4bcf187e..65021d7aa7 100644 --- a/server/typescript/packages/cli/test/init.test.ts +++ b/server/typescript/packages/cli/test/init.test.ts @@ -604,11 +604,12 @@ describe("init --d1", () => { expect(cfg.migrate.d1).toBeUndefined(); }); - test("scaffolds metaobjects.config.ts with dialect = 'd1' when --d1 is passed", async () => { + test("scaffolds metaobjects.config.ts with dialect = 'sqlite' (never 'd1', which codegen refuses) when --d1 is passed", async () => { const code = await initCommand(["--d1"], cwd); expect(code).toBe(0); const configTs = readFileSync(join(cwd, "metaobjects.config.ts"), "utf8"); - expect(configTs).toContain('dialect: "d1"'); + expect(configTs).toContain('dialect: "sqlite"'); + expect(configTs).not.toContain('"d1"'); }); test("scaffolds metaobjects.config.ts with dialect = 'sqlite' when --d1 is not passed", async () => { diff --git a/server/typescript/packages/cli/test/integration/verify-gate-not-vacuous.test.ts b/server/typescript/packages/cli/test/integration/verify-gate-not-vacuous.test.ts new file mode 100644 index 0000000000..5dd8c77181 --- /dev/null +++ b/server/typescript/packages/cli/test/integration/verify-gate-not-vacuous.test.ts @@ -0,0 +1,97 @@ +/** + * "Prove your gate fails" (docs/recipes/prove-your-gate-fails.md), executed. + * + * A CI gate that has never gone red is not yet a gate: `meta verify` exits 0 on a tree it + * checked and ALSO on a tree it checked nothing of (no generators declared, wrong cwd, a + * `generators: []` config). This test is the shape an adopter copies into their own repo: + * generate, assert the clean tree passes, seed a deliberate break, assert the gate fails + * with exit 1, restore, assert it passes again. If any step is missing the others prove + * nothing, so the clean-tree check comes first and the restore check last. + * + * Runs the BUILT CLI under node, one process per command (the adopter path). + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { meta, requireFreshDist } from "./support/built-cli.js"; +import { newShopProject, SHOP_MODEL, writeModel } from "./support/generator-project.js"; + +beforeAll(requireFreshDist); + +let root = ""; +afterAll(() => { + if (root !== "") rmSync(root, { recursive: true, force: true }); +}); + +describe("meta verify is not a vacuous gate", () => { + test("clean tree exits 0; metadata drift, a forbidden hand edit and a deleted file each exit 1", async () => { + root = await newShopProject("verify-gate-"); + const configPath = join(root, "metaobjects.config.ts"); + writeFileSync( + configPath, + readFileSync(configPath, "utf8") + .replace( + 'import { defineConfig } from "@metaobjectsdev/cli";', + 'import { defineConfig } from "@metaobjectsdev/cli";\nimport { entityFile, queriesFile, barrel } from "@metaobjectsdev/codegen-ts/generators";', + ) + .replace("generators: [],", "generators: [entityFile(), queriesFile(), barrel()],"), + ); + + // 0. The gate must actually have something to check, or exit 0 below means nothing. + expect(await meta(root, "gen")).toMatchObject({ exit: 0 }); + const customer = join(root, "src", "generated", "Customer.ts"); + const pristine = readFileSync(customer, "utf8"); + + // 1. Clean tree: exit 0. + const clean = await meta(root, "verify"); + expect({ step: "clean", exit: clean.exit }).toEqual({ step: "clean", exit: 0 }); + + // 2. Break A: change the model and do NOT regenerate. Code no longer matches metadata. + const model = structuredClone(SHOP_MODEL); + const customerNode = model.metadata.children.find((c) => "object.entity" in c && c["object.entity"].name === "Customer"); + (customerNode as { "object.entity": { children: unknown[] } })["object.entity"].children.push({ + "field.string": { name: "nickname", "@maxLength": 50 }, + }); + writeModel(root, model); + const metadataDrift = await meta(root, "verify"); + expect({ step: "metadata drift", exit: metadataDrift.exit }).toEqual({ step: "metadata drift", exit: 1 }); + expect(metadataDrift.output).toContain("codegen"); + + // 3. Restore the model: green again (the failure was the break, not the harness). + writeModel(root, SHOP_MODEL); + expect((await meta(root, "verify")).exit).toBe(0); + + // 4. A hand edit inside a generated file is NOT drift by default: `meta gen` three-way-merges + // it, so plain `verify` stays green. A team that wants generated code untouchable opts in + // with --forbid-hand-edits; that is the gate to assert if that is your policy. + const edit = pristine.replace(/email: (\w+)\("email"\)/, 'emailAddress: $1("email")'); + expect(edit).not.toBe(pristine); + writeFileSync(customer, edit); + expect((await meta(root, "verify")).exit).toBe(0); + const edited = await meta(root, "verify", "--codegen", "--forbid-hand-edits"); + expect({ step: "edited generated part", exit: edited.exit }).toEqual({ step: "edited generated part", exit: 1 }); + writeFileSync(customer, pristine); + + // 5. Break C: a generated file removed. + unlinkSync(customer); + const deleted = await meta(root, "verify"); + expect({ step: "deleted generated file", exit: deleted.exit }).toEqual({ step: "deleted generated file", exit: 1 }); + + // 6. Regenerate: green. `meta gen` is the fix the failure message points at. + expect(await meta(root, "gen")).toMatchObject({ exit: 0 }); + expect((await meta(root, "verify")).exit).toBe(0); + }, 240_000); + + test("a project that declares no generators passes verify, which is why step 0 above exists", async () => { + const empty = await newShopProject("verify-gate-empty-"); + try { + const gen = await meta(empty, "gen"); + expect(gen.exit).toBe(0); + const verify = await meta(empty, "verify"); + // Nothing declared, nothing checked, nothing to fail. Exit 0 here proves nothing. + expect(verify.exit).toBe(0); + } finally { + rmSync(empty, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts b/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts index 6b87804809..3dacc759ef 100644 --- a/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts +++ b/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts @@ -4,7 +4,7 @@ // // Visible-fields rules match the React generator: // - Primary-identity fields excluded (DB-generated) -// - Fields with @formExclude: true excluded +// - Fields with @formExclude: true excluded, and credential (view.password) fields // - Timestamp fields with default CURRENT_TIMESTAMP excluded (auto-managed) // // Form generation is OPT-IN at PROJECT level: add `angularFormFile()` to @@ -25,7 +25,7 @@ import { FIELD_SUBTYPE_BOOLEAN, } from "@metaobjectsdev/metadata"; import { type RenderContext, GENERATED_HEADER, GENERATED_EDIT_NOTE, entityModuleSpecifier, - effectivePackage, + effectivePackage, isCredentialField, } from "@metaobjectsdev/codegen-ts"; function primaryFieldNames(entity: MetaObject): Set { @@ -56,6 +56,8 @@ function visibleFields(entity: MetaObject): MetaField[] { for (const child of entity.fields()) { // ADR-0039: resolving — a field may inherit @formExclude via extends. if (child.attr("formExclude") === true) continue; + // A credential is never edited through a generated form (see the React generator). + if (isCredentialField(child)) continue; if (pkNames.has(child.name)) continue; if (isAutoManaged(child)) continue; out.push(child); diff --git a/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts b/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts index c074d0ddf2..b2a6d63429 100644 --- a/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts +++ b/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts @@ -60,6 +60,7 @@ import { viewForContext, VIEW_CONTEXT_FORM, effectivePackage, + isCredentialField, } from "@metaobjectsdev/codegen-ts/engine"; import { humanize, valueObjectFor, numericInputControl } from "@metaobjectsdev/codegen-ts/templates/field-meta"; import { tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts/engine"; @@ -97,6 +98,9 @@ function visibleFields(entity: MetaObject, discField?: string): MetaField[] { for (const child of entity.fields()) { // ADR-0039: resolving — a field may inherit @formExclude via extends. if (child.attr(FIELD_ATTR_FORM_EXCLUDE) === true) continue; + // A credential is never edited through a generated form: the generated write route + // strips it, and a form has no way to set one correctly (no hashing seam). + if (isCredentialField(child)) continue; if (pkNames.has(child.name)) continue; if (isAutoManaged(child)) continue; if (discField !== undefined && child.name === discField) continue; diff --git a/server/typescript/packages/codegen-ts-react/test/form-credential-exclusion.test.ts b/server/typescript/packages/codegen-ts-react/test/form-credential-exclusion.test.ts new file mode 100644 index 0000000000..f85d4b92f7 --- /dev/null +++ b/server/typescript/packages/codegen-ts-react/test/form-credential-exclusion.test.ts @@ -0,0 +1,59 @@ +// #484: a credential (a field that declares `view.password`) is never rendered by a generated +// form, exactly like an `@formExclude` field. A generated form has no hashing seam, and the +// generated write route strips the value anyway. +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; +import { renderFormFile } from "../src/templates/form-file.js"; +import { makeRenderContext, buildPkMap, buildRelationMap } from "@metaobjectsdev/codegen-ts"; + +async function loadAccount(): Promise<{ root: MetaRoot; account: MetaObject }> { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource( + JSON.stringify({ + "metadata.root": { + package: "demo", + children: [ + { + "object.entity": { + name: "Account", + children: [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { name: "id" } }, + { "identity.primary": { name: "id", "@fields": "id", "@generation": "increment" } }, + { "field.string": { name: "displayName", "@required": true } }, + { "field.string": { name: "tokenHash", "@formExclude": true } }, + { "field.string": { name: "pinHash", children: [{ "view.password": {} }] } }, + ], + }, + }, + ], + }, + }), + { id: "account.json" }, + ), + ]); + if (errors.length > 0) throw new Error(errors.map((e) => e.message).join("; ")); + return { root, account: root.objects().find((o) => o.name === "Account")! as MetaObject }; +} + +describe("generated form — credential and @formExclude fields", () => { + test("neither is rendered; an ordinary field is", async () => { + const { root, account } = await loadAccount(); + const out = renderFormFile( + account, + makeRenderContext({ + dialect: "postgres", + loadedRoot: root, + outDir: "/x", + dbImport: "../db", + extStyle: "none", + apiPrefix: "/api", + pkMap: buildPkMap(root), + relationMap: buildRelationMap(root), + }), + ); + expect(out).toContain("displayName"); + expect(out).not.toContain("tokenHash"); + expect(out).not.toContain("pinHash"); + }); +}); diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts index bcb1bac2b7..c32e76e06f 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -25,17 +25,20 @@ // a route and a list hook and no grid, form or detail hook. import { + FIELD_ATTR_FORM_EXCLUDE, FIELD_ATTR_OBJECT_REF, FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_OBJECT, OBJECT_SUBTYPE_VALUE, TYPE_FIELD, + VIEW_SUBTYPE_PASSWORD, WIRE_DECIMALS_STRING, WIRE_NULL_MEMBERS_NATIVE, refTarget, wireFormatOf, type MetaData, + type MetaField, type MetaObject, } from "@metaobjectsdev/metadata"; import { isAbstract } from "./instance-artifacts.js"; @@ -144,6 +147,48 @@ export function decimalFieldNames(entity: MetaObject): readonly string[] { return entity.fields().filter((f) => f.subType === FIELD_SUBTYPE_DECIMAL).map((f) => f.name); } +/** + * True when the field is a credential: it declares a `view.password` ("masked input for + * sensitive values"), the one place the metamodel already says a value is secret. + * + * A generated CRUD route can never be a correct writer of a credential (it has no hashing + * seam), nor a safe reader of one, so a credential is never read or written through the + * generated API and is neither filterable nor sortable (a filter on a secret is an oracle + * for it). The application writes it itself, through its own code. + */ +export function isCredentialField(field: MetaField): boolean { + // ADR-0039: resolving. A view may be inherited via extends. + return field.views().some((v) => v.subType === VIEW_SUBTYPE_PASSWORD); +} + +/** + * True when the generated API never writes the field: `@formExclude` (the field is out of the + * generated form, so no form-driven write reaches it either) or a credential. + */ +export function isApiWriteDenied(field: MetaField): boolean { + // ADR-0039: resolving. @formExclude may be inherited via extends. + return field.attr(FIELD_ATTR_FORM_EXCLUDE) === true || isCredentialField(field); +} + +/** The names of an object's credential fields: removed from every row its generated routes send. */ +export function credentialFieldNames(entity: MetaObject): readonly string[] { + return entity.fields().filter(isCredentialField).map((f) => f.name); +} + +/** The names of an object's fields its generated write routes strip from a create or update body. */ +export function apiWriteDeniedFieldNames(entity: MetaObject): readonly string[] { + return entity.fields().filter(isApiWriteDenied).map((f) => f.name); +} + +/** + * The `writeDeniedColumns` line of a mount that accepts writes, or "" when no field is denied + * (the output stays byte-identical for a model with no `@formExclude` or credential field). + */ +export function writeDeniedColumnsLine(entity: MetaObject, indent: string): string { + const names = apiWriteDeniedFieldNames(entity); + return names.length === 0 ? "" : `\n${indent}writeDeniedColumns: ${JSON.stringify(names)},`; +} + /** The declared shape of a value object, as a generated mount takes it (`ValueObjectShape` in runtime-ts). */ interface ValueObjectShapeData { members: string[]; @@ -207,6 +252,8 @@ export function decimalColumnsLine(entity: MetaObject | readonly MetaObject[], i const wire = wireFormatOf(root ?? first); const names = [...new Set(objects.flatMap((o) => decimalFieldNames(o)))]; let lines = ""; + const hidden = [...new Set(objects.flatMap((o) => credentialFieldNames(o)))]; + if (hidden.length > 0) lines += `\n${indent}hiddenColumns: ${JSON.stringify(hidden)},`; if (names.length > 0) { lines += `\n${indent}decimalColumns: ${JSON.stringify(names)},`; if (wire.decimals === WIRE_DECIMALS_STRING) lines += `\n${indent}decimalWire: ${JSON.stringify(wire.decimals)},`; diff --git a/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts b/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts index d56592706c..aee2c50bf5 100644 --- a/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts +++ b/server/typescript/packages/codegen-ts/src/generators/api-field-shape.ts @@ -24,6 +24,7 @@ import { updateSchemaFields, type SchemaFieldShape, } from "../templates/zod-validators.js"; +import { isApiWriteDenied, isCredentialField } from "../api-surface.js"; import { isFieldRequired } from "./docs-data-builder.js"; /** One documented field in a model / payload shape. Structured so BOTH renderers @@ -47,8 +48,13 @@ export interface FieldShape { * Mirrors `InferSelectModel` field-presence; the PK is reported required. */ export function modelFieldShapes(obj: MetaObject): FieldShape[] { + // A credential is not on the wire, so it is not in the documented model either. + return fieldShapesOf(obj, obj.fields().filter((f) => !isCredentialField(f))); +} + +function fieldShapesOf(obj: MetaObject, fields: readonly MetaField[]): FieldShape[] { const pkNames = new Set(obj.primaryIdentity()?.fields ?? []); - return obj.fields().map((field: MetaField): FieldShape => { + return fields.map((field: MetaField): FieldShape => { const isPk = pkNames.has(field.name); const required = isPk || isFieldRequired(field); return { @@ -64,7 +70,12 @@ export function modelFieldShapes(obj: MetaObject): FieldShape[] { * TPH-pinned discriminator field documents the literal as its type. */ function shapesFromSchemaFields(obj: MetaObject, walk: SchemaFieldShape[]): FieldShape[] { const fieldByName = new Map(obj.fields().map((f) => [f.name, f])); - return walk.map((sf): FieldShape => { + // The generated routes strip a write-denied field from the body, so it is not accepted. + const accepted = walk.filter((sf) => { + const f = fieldByName.get(sf.name); + return f === undefined || !isApiWriteDenied(f); + }); + return accepted.map((sf): FieldShape => { const field = fieldByName.get(sf.name); let type = field !== undefined ? fieldTsTypeString(obj.name, field) : "unknown"; let note: string | undefined; @@ -102,7 +113,7 @@ export function updateFieldShapes(obj: MetaObject): FieldShape[] { export function payloadFieldShapes(tmpl: MetaData, attrName: string): FieldShape[] | undefined { const vo = refTarget(tmpl, attrName); if (vo === undefined || !isMetaObject(vo)) return undefined; - return modelFieldShapes(vo); + return fieldShapesOf(vo, vo.fields()); } /** Format a list of field shapes as a compact inline TS object type, e.g. diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 15cbe507f4..016964a5fd 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -55,7 +55,7 @@ export { } from "./catalog-gates.js"; export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, ApiSurface, VerifyConfig } from "./metaobjects-config.js"; -export { defineConfig, normalizeConfig, resolveGenerators, removedGeneratorMessage } from "./metaobjects-config.js"; +export { defineConfig, normalizeConfig, assertCodegenDialect, CODEGEN_DIALECTS, resolveGenerators, removedGeneratorMessage } from "./metaobjects-config.js"; // The dialect every generator falls back to when a config declares none, and where a // project's documentation goes when it has not chosen (`targets.docs`). export { DEFAULT_DIALECT, DEFAULT_DOCS_DIR } from "./metaobjects-config.js"; @@ -142,7 +142,7 @@ export { renderAgentApi, renderApiIndex, renderEntityApiPage } from "./generator export { isProjection, isWriteThrough } from "./projection/projection-detector.js"; export { isAbstract, emitsInstanceArtifacts, emitsWriteArtifacts } from "./instance-artifacts.js"; // The UI tier asks THESE — "is there an endpoint?" — never the storage predicates. -export { hasGeneratedForm, hasItemRoute, decimalColumnsLine, decimalFieldNames, itemRouteField, restPath, servesClientHooks, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js"; +export { hasGeneratedForm, hasItemRoute, decimalColumnsLine, writeDeniedColumnsLine, isCredentialField, isApiWriteDenied, credentialFieldNames, apiWriteDeniedFieldNames, decimalFieldNames, itemRouteField, restPath, servesClientHooks, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js"; // #356 — every emitter selects a field's view by the SURFACE it renders, never by // declaration position. An owned generator (FR-040) composing the render layer must // use this too, or it reinstates the order-dependence in its own copy. diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 38a24fcbc2..66ecf58e90 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -24,6 +24,25 @@ export type GeneratorSpec = Generator | string; * does not own a MySQL schema (ADR-0015), so the adopter writes the DDL. */ export type Dialect = "sqlite" | "postgres" | "mysql"; + +/** The closed set of codegen dialects. `d1` is deliberately absent: it is a `meta migrate` transport. */ +export const CODEGEN_DIALECTS = ["sqlite", "postgres", "mysql"] as const satisfies readonly Dialect[]; + +/** + * Refuse a `dialect` that is not one of {@link CODEGEN_DIALECTS}. Without this an unknown value + * (most often `"d1"`) fell through to the Postgres branch of every dialect switch and emitted + * `pgTable` code that cannot run on the target database, with no diagnostic. + */ +export function assertCodegenDialect(dialect: unknown): asserts dialect is Dialect | undefined { + if (dialect === undefined || (CODEGEN_DIALECTS as readonly unknown[]).includes(dialect)) return; + const valid = CODEGEN_DIALECTS.map((d) => `"${d}"`).join(", "); + const shown = typeof dialect === "string" ? `"${dialect}"` : String(dialect); + const d1Hint = + dialect === "d1" + ? ` Cloudflare D1 is SQLite: set \`dialect: "sqlite"\` in metaobjects.config.ts. "d1" names only the \`meta migrate\` transport (\`migrate.dialect\` in .metaobjects/config.json, \`--dialect d1\`), which drives Wrangler; code generation has no separate D1 output, so a "d1" here would otherwise have been generated as Postgres.` + : ""; + throw new Error(`invalid codegen dialect ${shown} in metaobjects.config.ts; expected one of: ${valid}.${d1Hint}`); +} /** Re-exported from metadata so codegen-ts consumers see one canonical type. */ export type { ColumnNamingStrategy, MetaDataTypeProvider } from "@metaobjectsdev/metadata"; export type { ExtStyle }; @@ -378,6 +397,7 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj if (removedNaming !== undefined) { throw new Error(physicalNamingRemovedMessage("columnNamingStrategy in metaobjects.config.ts", String(removedNaming), physicalNamingGuideUrl())); } + assertCodegenDialect(config.dialect); const dialect = config.dialect ?? DEFAULT_DIALECT; return { ...config, diff --git a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts index e341f0e35e..0af5eeeb0d 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes-hono.ts @@ -59,6 +59,7 @@ import { isReport, itemRouteField, decimalColumnsLine, + writeDeniedColumnsLine, servesReadApi, formatTs, entityOutputPath, @@ -243,7 +244,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineHono} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineHono} }); } `; diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 067e9460ee..f437a428cb 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -69,6 +69,7 @@ import { isReport, itemRouteField, decimalColumnsLine, + writeDeniedColumnsLine, servesReadApi, formatTs, entityOutputPath, @@ -308,7 +309,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -333,7 +334,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -633,7 +634,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/src/routes-expose.ts b/server/typescript/packages/codegen-ts/src/routes-expose.ts index 8c89f9511a..6f9b00c5d2 100644 --- a/server/typescript/packages/codegen-ts/src/routes-expose.ts +++ b/server/typescript/packages/codegen-ts/src/routes-expose.ts @@ -103,6 +103,9 @@ export function exposeLine(verbs: readonly CrudVerb[] | undefined, indent: strin /** * The auth-seam paragraph in a generated routes handler's JSDoc. * + * The long-form version of this recipe, with `expose`, is docs/recipes/guarding-generated-routes.md; + * keep the two in step with `runtime-ts/test/route-auth-seam.test.ts`. + * * Stock CRUD is unauthenticated, and until #367 the only thing a generated routes file * said about that was a header line pointing at `.extra.ts` "(e.g., auth)" — a * sibling module nothing imports. An agent building a real API read it, went looking for diff --git a/server/typescript/packages/codegen-ts/src/runner.ts b/server/typescript/packages/codegen-ts/src/runner.ts index d60656a586..2d7ddd222c 100644 --- a/server/typescript/packages/codegen-ts/src/runner.ts +++ b/server/typescript/packages/codegen-ts/src/runner.ts @@ -18,7 +18,7 @@ import { dbEmittingObjects, missingDialectMessage } from "./db-emitting.js"; import { generatableObjects, hasAnyRdbSource } from "./source-detect.js"; import type { Generator, GenContext, EmittedFile } from "./generator.js"; import type { MetaobjectsGenConfig } from "./metaobjects-config.js"; -import { normalizeConfig, DEFAULT_TARGET_NAME } from "./metaobjects-config.js"; +import { normalizeConfig, assertCodegenDialect, DEFAULT_TARGET_NAME } from "./metaobjects-config.js"; import type { ResolvedTarget } from "./import-path.js"; import { buildPkMap } from "./pk-resolver.js"; import { buildRelationMap } from "./relation-resolver.js"; @@ -515,6 +515,8 @@ export async function runGen(opts: RunGenOpts): Promise { // projects — the error even told them their model "generates database code" // that in fact imported nothing. It is demanded below at the point of USE, by // the generator that reads it, which the runner names in the thrown message. + // An UNKNOWN dialect is refused before the missing-dialect check: it is present but wrong. + assertCodegenDialect(opts.config.dialect); const dbEmitting = dbEmittingObjects(safeEntities); if (dbEmitting.length > 0 && opts.config.dialect === undefined) { throw new Error(missingDialectMessage(dbEmitting)); diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts index 5d524532ab..f828e14e1a 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file-hono.ts @@ -31,7 +31,7 @@ import { entityModuleSpecifier, siblingSpecifier } from "../import-path.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; -import { decimalColumnsLine, itemRouteField } from "../api-surface.js"; +import { decimalColumnsLine, itemRouteField, writeDeniedColumnsLine } from "../api-surface.js"; import { DEFAULT_ID_FIELD } from "../pk-resolver.js"; import { authSeamJsDoc, type CrudVerb, exposeLine } from "../routes-expose.js"; import { effectivePackage } from "../docs-paths.js"; @@ -200,7 +200,7 @@ export function ${handlerName}(app: ${HonoSym}, deps: { db: unkno updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineHono} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineHono} }); } `; diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts index f7af9ca598..3697a879cc 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -29,7 +29,7 @@ import { GENERATED_HEADER, GENERATED_EDIT_NOTE, sidecarLine } from "../constants import { routesHandlerName } from "../naming.js"; import { isProjection, isWriteThrough } from "../projection/projection-detector.js"; import { isReport } from "../source-detect.js"; -import { decimalColumnsLine, itemRouteField } from "../api-surface.js"; +import { decimalColumnsLine, itemRouteField, writeDeniedColumnsLine } from "../api-surface.js"; import { DEFAULT_ID_FIELD, pkInfoOf } from "../pk-resolver.js"; import type { RelationEntry } from "../relation-resolver.js"; import { isTphDiscriminatorBase, tphPlan } from "./tph-discriminator.js"; @@ -246,7 +246,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -271,7 +271,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -563,7 +563,7 @@ function renderTphRoutesFile( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/test/codegen-dialect-validation.test.ts b/server/typescript/packages/codegen-ts/test/codegen-dialect-validation.test.ts new file mode 100644 index 0000000000..9a1b1cf257 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/codegen-dialect-validation.test.ts @@ -0,0 +1,66 @@ +// An unknown codegen `dialect` used to fall through to the Postgres branch of every dialect +// switch and emit `pgTable` code, silently. `"d1"` is the common case: it is a `meta migrate` +// transport, and D1 codegen is `"sqlite"`. Both choke points (normalizeConfig, runGen) refuse it. +import { describe, test, expect } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { runGen } from "../src/runner.js"; +import { normalizeConfig, assertCodegenDialect, CODEGEN_DIALECTS, type MetaobjectsGenConfig } from "../src/metaobjects-config.js"; + +const base = { outDir: "gen", generators: [] } as unknown as MetaobjectsGenConfig; + +describe("assertCodegenDialect", () => { + test("accepts every valid dialect and undefined", () => { + for (const d of CODEGEN_DIALECTS) expect(() => assertCodegenDialect(d)).not.toThrow(); + expect(() => assertCodegenDialect(undefined)).not.toThrow(); + expect([...CODEGEN_DIALECTS]).toEqual(["sqlite", "postgres", "mysql"]); + }); + + test('"d1" is refused with the sqlite instruction and the reason', () => { + expect(() => assertCodegenDialect("d1")).toThrow( + /invalid codegen dialect "d1".*expected one of: "sqlite", "postgres", "mysql".*Cloudflare D1 is SQLite: set `dialect: "sqlite"`.*meta migrate/s, + ); + }); + + test("any other unknown value names the valid values, without the D1 hint", () => { + let message = ""; + try { assertCodegenDialect("postgress"); } catch (e) { message = (e as Error).message; } + expect(message).toContain('invalid codegen dialect "postgress"'); + expect(message).toContain('"sqlite", "postgres", "mysql"'); + expect(message).not.toContain("D1"); + }); + + test("a non-string value is refused too", () => { + expect(() => assertCodegenDialect(5)).toThrow(/invalid codegen dialect 5/); + }); +}); + +describe("normalizeConfig", () => { + test('rejects dialect "d1" instead of defaulting it through to Postgres', () => { + expect(() => normalizeConfig({ ...base, dialect: "d1" } as unknown as MetaobjectsGenConfig)).toThrow(/Cloudflare D1 is SQLite/); + }); + test("keeps valid and omitted dialects", () => { + expect(normalizeConfig({ ...base, dialect: "mysql" }).dialect).toBe("mysql"); + expect(normalizeConfig({ ...base }).dialect).toBe("sqlite"); + }); +}); + +describe("runGen", () => { + test('refuses dialect "d1" for a project with a sourced object, before writing anything', async () => { + const tmp = mkdtempSync(join(tmpdir(), "codegen-dialect-")); + try { + const json = JSON.stringify({ "metadata.root": { package: "app", children: [ + { "object.entity": { name: "Note", children: [ + { "field.string": { name: "title" } }, + { "source.rdb": { "@table": "notes" } }, + ] } }, + ] } }); + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + await expect( + runGen({ metadata: root, projectRoot: tmp, config: { ...base, outDir: "gen", dialect: "d1" } as unknown as MetaobjectsGenConfig }), + ).rejects.toThrow(/invalid codegen dialect "d1"/); + } finally { rmSync(tmp, { recursive: true, force: true }); } + }); +}); diff --git a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts new file mode 100644 index 0000000000..272580d72c --- /dev/null +++ b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts @@ -0,0 +1,118 @@ +// api-contract-secret-fields-generated-server.ts — boots the GENERATED Account routes +// (the deployed artifact) over HTTP and drives them against the #484 secret-fields corpus. +// +// Mirrors api-contract-write-through-generated-server.ts: runGen over the corpus meta into a +// temp dir, provision the table the emitted Drizzle binding reads, import the EMITTED +// Account.routes.ts unmodified and mount it. What is under test is whether the generated +// route strips `@formExclude` / credential keys from a write body and omits a credential +// from every row it sends. + +import Fastify, { type FastifyInstance } from "fastify"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { runGen, defineConfig } from "@metaobjectsdev/codegen-ts"; +import { entityFile, routesFile } from "@metaobjectsdev/test-generators"; +import pg from "pg"; +import { executeSql } from "./postgres-sql.ts"; +import { DEFAULT_PHYSICAL_NAMING } from "@metaobjectsdev/metadata"; +import { loadMetadataFile } from "./load-metadata.ts"; + +export interface SecretFieldsSeed { + rows: Array<{ id: number; name: string; tokenHash: string; pinHash: string }>; +} + +export interface GeneratedSecretFieldsServerHandle { + baseUrl: string; + /** The generated source of Account.routes.ts, for codegen assertions. */ + routesSource: string; + applySeed(seed: SecretFieldsSeed): Promise; + close(): Promise; +} + +export async function startGeneratedSecretFieldsServer( + connectionUri: string, + metaPath: string, +): Promise { + const here = dirname(fileURLToPath(import.meta.url)); + const genTmpRoot = join(here, "..", ".gen-tmp"); + mkdirSync(genTmpRoot, { recursive: true }); + const tmp = mkdtempSync(join(genTmpRoot, "api-contract-secret-fields-")); + + const root = await loadMetadataFile(metaPath); + // The lane provisions its table below in the generated Drizzle table's default spelling. + root.physicalNaming = DEFAULT_PHYSICAL_NAMING; + const lr = await runGen({ + config: defineConfig({ + outDir: tmp, + extStyle: "none", + dbImport: "./db", + dialect: "postgres", + apiPrefix: "/api", + generators: [entityFile(), routesFile()], + }), + metadata: root, + }); + if (lr.warnings.length > 0) { + throw new Error(`codegen produced warnings: ${lr.warnings.join("; ")}`); + } + + const bigintTypesImport = pathToFileURL(join(here, "pg-bigint-number-types.ts")).href; + writeFileSync( + join(tmp, "db.ts"), + ` +import { drizzle } from "drizzle-orm/node-postgres"; +import pg from "pg"; +import { bigintAsNumberTypes } from ${JSON.stringify(bigintTypesImport)}; +export const pool = new pg.Pool({ connectionString: ${JSON.stringify(connectionUri)}, types: bigintAsNumberTypes }); +export const db = drizzle(pool); +`, + "utf8", + ); + + await executeSql( + connectionUri, + `CREATE TABLE IF NOT EXISTS "accounts" ( + "id" bigserial PRIMARY KEY, + "name" varchar(100) NOT NULL, + "token_hash" varchar(100), + "pin_hash" varchar(100) + );`, + ); + + const routesPath = join(tmp, "Account.routes.ts"); + const accountRoutes = (await import(pathToFileURL(routesPath).href)) as { + accountRoutes: (f: FastifyInstance) => Promise; + }; + const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool }; + const routesSource = (await import("node:fs")).readFileSync(routesPath, "utf8"); + + const fastify = Fastify(); + await fastify.register(accountRoutes.accountRoutes); + await fastify.ready(); + const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 }); + + return { + baseUrl, + routesSource, + applySeed: async (seed: SecretFieldsSeed) => { + await executeSql(connectionUri, `TRUNCATE TABLE "accounts" RESTART IDENTITY;`); + for (const r of seed.rows) + await executeSql( + connectionUri, + `INSERT INTO "accounts" ("id","name","token_hash","pin_hash") VALUES (${r.id}, ${str(r.name)}, ${str(r.tokenHash)}, ${str(r.pinHash)})`, + ); + // The seed names ids explicitly; keep the sequence ahead of them for the POST scenarios. + await executeSql(connectionUri, `SELECT setval(pg_get_serial_sequence('accounts','id'), (SELECT MAX("id") FROM "accounts"));`); + }, + close: async () => { + await fastify.close(); + await dbMod.pool.end(); + rmSync(tmp, { recursive: true, force: true }); + }, + }; +} + +function str(v: string): string { + return `'${v.replace(/'/g, "''")}'`; +} diff --git a/server/typescript/packages/integration-tests/src/paths.ts b/server/typescript/packages/integration-tests/src/paths.ts index db7053aa1c..890784201c 100644 --- a/server/typescript/packages/integration-tests/src/paths.ts +++ b/server/typescript/packages/integration-tests/src/paths.ts @@ -43,6 +43,9 @@ export const API_CONTRACT_JSONB_SCENARIOS_DIR = resolve(API_CONTRACT_JSONB_DIR, export const API_CONTRACT_WRITE_THROUGH_DIR = resolve(API_CONTRACT_DIR, "write-through"); export const API_CONTRACT_WRITE_THROUGH_SCENARIOS_DIR = resolve(API_CONTRACT_WRITE_THROUGH_DIR, "scenarios"); // F22 view-only projection subcorpus (GENERATED lane only, every port). +export const API_CONTRACT_SECRET_FIELDS_DIR = resolve(API_CONTRACT_DIR, "secret-fields"); +export const API_CONTRACT_SECRET_FIELDS_SCENARIOS_DIR = resolve(API_CONTRACT_SECRET_FIELDS_DIR, "scenarios"); + export const API_CONTRACT_PROJECTION_DIR = resolve(API_CONTRACT_DIR, "projection"); export const API_CONTRACT_PROJECTION_SCENARIOS_DIR = resolve(API_CONTRACT_PROJECTION_DIR, "scenarios"); // FR-044 view-backed report subcorpus (GENERATED lane only, every port). diff --git a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts new file mode 100644 index 0000000000..0d1bf3bbbd --- /dev/null +++ b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts @@ -0,0 +1,77 @@ +// #484 secret-fields api-contract conformance (GENERATED lane). +// +// Drives fixtures/api-contract-conformance/secret-fields/ over HTTP against the GENERATED +// Account routes: a `@formExclude` or credential (`view.password`) field is not writable +// through create / PATCH / PUT, and a credential is never returned. Generated lane only, +// like write-through/ and projection/ — see the corpus README. One Postgres testcontainer +// per scenario. + +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + API_CONTRACT_SECRET_FIELDS_DIR, + API_CONTRACT_SECRET_FIELDS_SCENARIOS_DIR, +} from "../src/paths.ts"; +import { loadScenarios, assertResponse, type ApiScenario, parseBodyExact } from "../src/api-contract-scenario.ts"; +import { startPostgres } from "../src/postgres-container.ts"; +import { + startGeneratedSecretFieldsServer, + type GeneratedSecretFieldsServerHandle, + type SecretFieldsSeed, +} from "../src/api-contract-secret-fields-generated-server.ts"; + +const SEED = JSON.parse( + readFileSync(join(API_CONTRACT_SECRET_FIELDS_DIR, "seed.json"), "utf8"), +) as SecretFieldsSeed; +const META_PATH = join(API_CONTRACT_SECRET_FIELDS_DIR, "meta.json"); + +describe("api contract secret fields (#484) — GENERATED routes lane", () => { + for (const scenario of loadScenarios(API_CONTRACT_SECRET_FIELDS_SCENARIOS_DIR)) { + test(scenario.name, async () => { + const pg = await startPostgres(); + let server: GeneratedSecretFieldsServerHandle | null = null; + try { + server = await startGeneratedSecretFieldsServer(pg.connectionUri, META_PATH); + await server.applySeed(SEED); + await runScenario(scenario, server); + } finally { + if (server) await server.close(); + await pg.stop(); + } + }, { timeout: 60_000 }); + } + + // The credential's write path cannot be observed over HTTP (a credential is never read), so + // pin it on the emitted source: both marked fields are denied on write, only the credential + // is hidden on read. + test("generated route names the denied and hidden columns", async () => { + const pg = await startPostgres(); + let server: GeneratedSecretFieldsServerHandle | null = null; + try { + server = await startGeneratedSecretFieldsServer(pg.connectionUri, META_PATH); + expect(server.routesSource).toContain('writeDeniedColumns: ["tokenHash", "pinHash"]'); + expect(server.routesSource).toContain('hiddenColumns: ["pinHash"]'); + } finally { + if (server) await server.close(); + await pg.stop(); + } + }, { timeout: 60_000 }); +}); + +async function runScenario(scenario: ApiScenario, server: GeneratedSecretFieldsServerHandle): Promise { + for (const req of scenario.requests) { + const init: RequestInit = { method: req.method }; + if (req.body !== undefined) { + init.body = JSON.stringify(req.body); + init.headers = { "content-type": "application/json" }; + } + const res = await fetch(server.baseUrl + req.path, init); + const bodyText = await res.text(); + let body: unknown = null; + if (bodyText.length > 0) { + try { body = parseBodyExact(bodyText); } catch { body = bodyText; } + } + assertResponse(scenario.name, req, res.status, body); + } +} diff --git a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts index 930c44d394..78baf3f97a 100644 --- a/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts +++ b/server/typescript/packages/runtime-ts/src/drizzle-fastify/index.ts @@ -29,7 +29,7 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js"; import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js"; import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withWire, type WireOptions } from "../wire-format.js"; +import { stripDeniedColumns, withWire, type WireOptions } from "../wire-format.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { withContractErrorHandler } from "./route-error-handler.js"; import { validationErrorBody } from "../route-errors.js"; @@ -72,6 +72,13 @@ export interface CrudRoutesOptions extends WireOptions { insertSchema: ZodTypeAny; /** Zod schema for update payloads (typically `UpdateSchema`). */ updateSchema: ZodTypeAny; + /** + * Fields the API never writes (`@formExclude`, or a credential `view.password`). A key + * named here is stripped from a create or update body before validation, so the value never + * reaches the table; it is ignored, not rejected. The generated route passes them; the + * schemas stay whole for the application's own writes. + */ + writeDeniedColumns?: readonly string[]; /** Limit which verbs are mounted. Defaults to all five. */ expose?: readonly CrudVerb[]; /** @@ -274,7 +281,7 @@ export function mountGetRoute(opts: VerbOptions): void { export function mountCreateRoute(opts: VerbOptions): void { const toWire = echoWire(opts); opts.fastify.post(opts.path, routeOpts(opts), async (req, reply) => { - const parsed = opts.insertSchema.safeParse(req.body); + const parsed = opts.insertSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } @@ -315,7 +322,7 @@ export function mountUpdateRoute(opts: VerbOptions): void { reply: { code: (n: number) => { send: (b: unknown) => unknown } }, ) => { const { id } = req.params as { id: string }; - const parsed = opts.updateSchema.safeParse(req.body); + const parsed = opts.updateSchema.safeParse(stripDeniedColumns(req.body, opts.writeDeniedColumns)); if (!parsed.success) { return reply.code(400).send(validationErrorBody(parsed.error.issues)); } diff --git a/server/typescript/packages/runtime-ts/src/hono/index.ts b/server/typescript/packages/runtime-ts/src/hono/index.ts index 5e5ee6a410..44be10b276 100644 --- a/server/typescript/packages/runtime-ts/src/hono/index.ts +++ b/server/typescript/packages/runtime-ts/src/hono/index.ts @@ -45,7 +45,7 @@ export type { import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js"; import { supportsReturning, type SqlDialect } from "../sql-dialect.js"; import { timestampWire } from "../timestamp-wire.js"; -import { withWire, type WireOptions } from "../wire-format.js"; +import { stripDeniedColumns, withWire, type WireOptions } from "../wire-format.js"; // Every handler below is wrapped in guardRoute (an unexpected error answers // `500 { error: "internal" }`, logged server-side) and reads its body through // readJsonBody (a malformed body answers `400 { error: "invalid_json" }`). @@ -98,6 +98,13 @@ export interface CrudRoutesOptions extends WireOptions { insertSchema: ZodTypeAny; /** Zod schema for update payloads (typically `UpdateSchema`). */ updateSchema: ZodTypeAny; + /** + * Fields the API never writes (`@formExclude`, or a credential `view.password`). A key + * named here is stripped from a create or update body before validation, so the value never + * reaches the table; it is ignored, not rejected. The generated route passes them; the + * schemas stay whole for the application's own writes. + */ + writeDeniedColumns?: readonly string[]; /** Limit which verbs are mounted. Defaults to all five. */ expose?: readonly CrudVerb[]; /** @@ -264,7 +271,7 @@ export function mountCreateRoute(opts: VerbOptions): void { opts.app.post(opts.path, guardRoute(async (c) => { const read = await readJsonBody(c); if (!read.ok) return read.response; - const parsed = opts.insertSchema.safeParse(read.body); + const parsed = opts.insertSchema.safeParse(stripDeniedColumns(read.body, opts.writeDeniedColumns)); if (!parsed.success) { return c.json(validationErrorBody(parsed.error.issues), 400); } @@ -299,7 +306,7 @@ export function mountUpdateRoute(opts: VerbOptions): void { const id = c.req.param("id") ?? ""; const read = await readJsonBody(c); if (!read.ok) return read.response; - const parsed = opts.updateSchema.safeParse(read.body); + const parsed = opts.updateSchema.safeParse(stripDeniedColumns(read.body, opts.writeDeniedColumns)); if (!parsed.success) { return c.json(validationErrorBody(parsed.error.issues), 400); } diff --git a/server/typescript/packages/runtime-ts/src/wire-format.ts b/server/typescript/packages/runtime-ts/src/wire-format.ts index 086147c555..a24cdcedc5 100644 --- a/server/typescript/packages/runtime-ts/src/wire-format.ts +++ b/server/typescript/packages/runtime-ts/src/wire-format.ts @@ -43,6 +43,37 @@ export interface WireOptions { readonly nullMembers?: NullMembersMode; /** The jsonb columns that hold a value object (or an array / map of them), by column name. */ readonly valueObjects?: Readonly>; + /** + * Credential fields (a `view.password` field): removed from every row sent, on list, get, + * the echo of a create or update, and an M:N traversal. The generated route passes them; + * absent, rows are sent as read. + */ + readonly hiddenColumns?: readonly string[]; +} + +/** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ +export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { + if (hidden === undefined || hidden.length === 0) return undefined; + return (row) => { + if (!isPlainObject(row)) return row; + const out = { ...row }; + for (const column of hidden) delete out[column]; + return out; + }; +} + +/** + * Remove `denied` keys from a request body, before it is validated. The route's schema is the + * entity's shared `Insert`/`Update` schema, which app code writes through too, so the API + * boundary strips here and the schema stays whole. A key stripped is IGNORED, never rejected + * (the uniform rule for every key outside the settable set, `@mutability: writeOnce` on PATCH + * among them): a generated edit form submits every field it was given. + */ +export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { + if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; + const out = { ...body }; + for (const column of denied) delete out[column]; + return out; } function isPlainObject(v: unknown): v is Record { @@ -95,9 +126,11 @@ export function nullMembersWire( }; } -/** A wire mapper: `base` (timestamps), then the decimal rewrite, then the null-member rewrite. */ +/** A wire mapper: `base` (timestamps), then the decimal rewrite, the null-member rewrite, then the credential strip. */ export function withWire(base: (row: unknown) => unknown, opts: WireOptions): (row: unknown) => unknown { const decimals = withDecimals(base, opts.decimalColumns, opts.decimalWire); const members = nullMembersWire(opts.valueObjects, opts.nullMembers); - return members === undefined ? decimals : (row) => members(decimals(row)); + const hidden = hiddenColumnsWire(opts.hiddenColumns); + const shaped = members === undefined ? decimals : (row: unknown) => members(decimals(row)); + return hidden === undefined ? shaped : (row) => hidden(shaped(row)); } diff --git a/server/typescript/packages/runtime-ts/test/wire-format.test.ts b/server/typescript/packages/runtime-ts/test/wire-format.test.ts index 940d03abf9..0c31fbf976 100644 --- a/server/typescript/packages/runtime-ts/test/wire-format.test.ts +++ b/server/typescript/packages/runtime-ts/test/wire-format.test.ts @@ -1,7 +1,7 @@ // The null-member rewrite of a value object column (`wire.nullMembers`, ADR-0019 Amendment 2). import { describe, test, expect } from "bun:test"; -import { nullMembersWire, withWire, type ValueObjectShape } from "../src/wire-format.js"; +import { hiddenColumnsWire, nullMembersWire, stripDeniedColumns, withWire, type ValueObjectShape } from "../src/wire-format.js"; const money: ValueObjectShape = { members: ["label", "amount", "note"] }; @@ -63,3 +63,29 @@ describe("withWire", () => { expect(native({ price: "12.50", total: { label: null } })).toEqual({ price: "12.50", total: { label: null } }); }); }); + +describe("hiddenColumnsWire / stripDeniedColumns (#484)", () => { + test("hidden columns are removed from a row and nothing is hidden by default", () => { + expect(hiddenColumnsWire(undefined)).toBeUndefined(); + expect(hiddenColumnsWire([])).toBeUndefined(); + const hide = hiddenColumnsWire(["pinHash"])!; + const row = { id: 1, pinHash: "x", tokenHash: "y" }; + expect(hide(row)).toEqual({ id: 1, tokenHash: "y" }); + expect(row.pinHash).toBe("x"); // the input is not mutated + expect("pinHash" in (hide(row) as object)).toBe(false); + expect(hide(null)).toBeNull(); + }); + + test("withWire strips hidden columns after the other rewrites", () => { + const toWire = withWire((r) => r, { decimalColumns: ["price"], decimalWire: "string", hiddenColumns: ["secret"] }); + expect(toWire({ price: 1.5, secret: "s", name: "n" })).toEqual({ price: "1.5", name: "n" }); + }); + + test("denied columns are stripped from a body, a present null included", () => { + expect(stripDeniedColumns({ name: "a", tokenHash: "forged", pinHash: null }, ["tokenHash", "pinHash"])).toEqual({ name: "a" }); + expect(stripDeniedColumns({ name: "a" }, undefined)).toEqual({ name: "a" }); + expect(stripDeniedColumns({ name: "a" }, [])).toEqual({ name: "a" }); + expect(stripDeniedColumns("not-an-object", ["x"])).toBe("not-an-object"); + expect(stripDeniedColumns(null, ["x"])).toBeNull(); + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index 067e9460ee..f437a428cb 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -69,6 +69,7 @@ import { isReport, itemRouteField, decimalColumnsLine, + writeDeniedColumnsLine, servesReadApi, formatTs, entityOutputPath, @@ -308,7 +309,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLinePrefixed} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLinePrefixed} }); ${m2mMountsPrefixed} }, { prefix: ${JSON.stringify(ctx.apiPrefix)} }); } @@ -333,7 +334,7 @@ export async function ${handlerName}(fastify: ${FastifyInstanceSym}) { updateSchema: ${entityName}UpdateSchema, filterAllowlist: ${entityName}FilterAllowlist, sortAllowlist: ${entityName}SortAllowlist, - dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${exposeLineFlat} + dialect: ${JSON.stringify(ctx.dialect)},${decimalColumnsLine(entity, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(entity, " ")}${exposeLineFlat} }); ${m2mMountsFlat}} `; @@ -633,7 +634,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the From 0053f022b6dae219b6c84ce7eecdb8260ea194be Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 08:19:36 -0400 Subject: [PATCH 02/16] fix(codegen): hide subtype credentials on an M:N onto a TPH base; Python redaction covers dataclass and mapping rows (#484) --- .../codegen/generators/routes.ts | 11 ++- .../showcase/codegen/generators/routes.ts | 11 ++- .../Generators/RoutesGenerator.cs | 13 ++- .../spring/SpringControllerGenerator.java | 9 +- .../codegen/runtime/decimal_wire.py | 5 + .../codegen/test_router_secret_fields.py | 16 +++ .../packages/codegen-ts/src/api-surface.ts | 5 +- .../codegen-ts/src/reference/routes.ts | 11 ++- .../codegen-ts/src/templates/routes-file.ts | 11 ++- .../test/tph-m2m-credential.test.ts | 98 +++++++++++++++++++ .../test-generators/src/generators/routes.ts | 11 ++- 11 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index f437a428cb..999274d14d 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -403,6 +403,15 @@ interface TphM2mSource { value: string; } +/** + * The object a M:N mount reads rows of, plus the subtypes of a TPH base target: its rows are + * shared-table rows of every subtype, so a credential declared by any of them is hidden too. + */ +function m2mTargetObjects(target: MetaObject, ctx: RenderContext): MetaObject[] { + const plan = ctx.loadedRoot === undefined ? null : tphPlan(target, ctx.loadedRoot); + return plan === null ? [target] : [target, ...plan.subtypes.map((st) => st.entity)]; +} + /** * Render one M:N traversal mount. The junction + target Drizzle table consts are * imported from their sibling entity files (imp() lets ts-poet track + emit the @@ -490,7 +499,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(m2mTargetObjects(entry.target, ctx), " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index f437a428cb..999274d14d 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -403,6 +403,15 @@ interface TphM2mSource { value: string; } +/** + * The object a M:N mount reads rows of, plus the subtypes of a TPH base target: its rows are + * shared-table rows of every subtype, so a credential declared by any of them is hidden too. + */ +function m2mTargetObjects(target: MetaObject, ctx: RenderContext): MetaObject[] { + const plan = ctx.loadedRoot === undefined ? null : tphPlan(target, ctx.loadedRoot); + return plan === null ? [target] : [target, ...plan.subtypes.map((st) => st.entity)]; +} + /** * Render one M:N traversal mount. The junction + target Drizzle table consts are * imported from their sibling entity files (imp() lets ts-poet track + emit the @@ -490,7 +499,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(m2mTargetObjects(entry.target, ctx), " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index af17af9d21..f6ec78f405 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -776,6 +776,17 @@ private static bool IsApiWriteDenied(MetaField f) => private static List WriteDeniedFields(MetaObject entity) => entity.Fields().Where(IsApiWriteDenied).ToList(); + /// + /// An entity's credential names plus, for a TPH base, those of every subtype: its rows are + /// shared-table rows of every subtype (#484). + /// + private static List TphUnionCredentialNames(MetaObject entity, MetaRoot root) => + CredentialNames(entity) + .Concat(TphPlanBuilder.For(entity, root) is { } tph + ? tph.Subtypes.SelectMany(st => CredentialNames(st.Entity)) + : Enumerable.Empty()) + .Distinct(StringComparer.Ordinal).ToList(); + private static List CredentialNames(MetaObject entity) => entity.Fields().Where(IsCredentialField).Select(f => f.Name).ToList(); @@ -1555,7 +1566,7 @@ private static void AppendM2mRoute( sb.AppendLine($" var rows = await {targetFrom}.AsNoTracking()"); sb.AppendLine($" .Where(t => relatedIds.Contains(EF.Property<{fkType}>(t, \"{targetPk}\")))"); sb.AppendLine(" .ToListAsync();"); - sb.AppendLine($" return {OkResult("rows", CredentialNames(nav.Target))};"); + sb.AppendLine($" return {OkResult("rows", TphUnionCredentialNames(nav.Target, root))};"); sb.AppendLine(" });"); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 3a2b6c4ea7..b0292f7c9a 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -1621,7 +1621,14 @@ private static List targetCredentials(MetaObject source, SpringM2mSuppor if (!nav.relationName().equals(rel.getShortName())) continue; MetaObject target = com.metaobjects.loader.References.refTarget( rel, com.metaobjects.relationship.MetaRelationship.ATTR_OBJECT_REF); - return target == null ? List.of() : SpringGeneratorSupport.credentialNames(target); + if (target == null) return List.of(); + // A TPH base target's rows are shared-table rows of every subtype, so a credential + // declared on any subtype is hidden too. + List fields = new ArrayList<>(SpringGeneratorSupport.dtoComponentFields(target)); + for (MetaField f : TphPlan.collectSubtypeFields(target, source.getLoader())) { + if (!(f instanceof ObjectField)) fields.add(f); + } + return SpringGeneratorSupport.credentialNames(fields); } return List.of(); } diff --git a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py index 98a468f1f6..3131576954 100644 --- a/server/python/src/metaobjects/codegen/runtime/decimal_wire.py +++ b/server/python/src/metaobjects/codegen/runtime/decimal_wire.py @@ -22,6 +22,7 @@ import json import re from decimal import Context, Decimal, InvalidOperation +from collections.abc import Mapping from typing import Any, Callable, Iterable #: A string body value and a filter operand: the JSON-number grammar. An exponent is allowed on @@ -309,6 +310,10 @@ def _drop_hidden(value: Any, hidden: tuple[str, ...]) -> Any: dump = getattr(value, "model_dump", None) if callable(dump): value = dump() + elif dataclasses.is_dataclass(value) and not isinstance(value, type): + value = dataclasses.asdict(value) + elif isinstance(value, Mapping): + value = dict(value) if not isinstance(value, dict): return value return {k: v for k, v in value.items() if k not in hidden} diff --git a/server/python/tests/codegen/test_router_secret_fields.py b/server/python/tests/codegen/test_router_secret_fields.py index 0962658762..a1886eaef6 100644 --- a/server/python/tests/codegen/test_router_secret_fields.py +++ b/server/python/tests/codegen/test_router_secret_fields.py @@ -102,3 +102,19 @@ def test_render_wire_drops_hidden_key_not_nulls_it() -> None: wire = WireOutput(hidden=("pinHash",)) assert render_wire({"id": 1, "pinHash": None}, wire) == {"id": 1} assert render_wire([{"id": 1, "pinHash": "x"}], wire) == [{"id": 1}] + + +def test_render_wire_hides_a_credential_of_a_dataclass_or_mapping_row() -> None: + import dataclasses + from types import MappingProxyType + + from metaobjects.codegen.runtime.decimal_wire import WireOutput, render_wire + + @dataclasses.dataclass + class Row: + id: int + pinHash: str + + wire = WireOutput(hidden=("pinHash",)) + assert render_wire(Row(1, "x"), wire) == {"id": 1} + assert render_wire(MappingProxyType({"id": 1, "pinHash": "x"}), wire) == {"id": 1} diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts index c32e76e06f..fd894d4bd8 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -153,8 +153,9 @@ export function decimalFieldNames(entity: MetaObject): readonly string[] { * * A generated CRUD route can never be a correct writer of a credential (it has no hashing * seam), nor a safe reader of one, so a credential is never read or written through the - * generated API and is neither filterable nor sortable (a filter on a secret is an oracle - * for it). The application writes it itself, through its own code. + * generated API. Filter and sort allowlists are unchanged (`@filterable` and `@sortable` stay + * explicit opt-ins), so do not put them on a credential: a filter on a secret is an oracle for + * it. The application writes it itself, through its own code. */ export function isCredentialField(field: MetaField): boolean { // ADR-0039: resolving. A view may be inherited via extends. diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index f437a428cb..999274d14d 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -403,6 +403,15 @@ interface TphM2mSource { value: string; } +/** + * The object a M:N mount reads rows of, plus the subtypes of a TPH base target: its rows are + * shared-table rows of every subtype, so a credential declared by any of them is hidden too. + */ +function m2mTargetObjects(target: MetaObject, ctx: RenderContext): MetaObject[] { + const plan = ctx.loadedRoot === undefined ? null : tphPlan(target, ctx.loadedRoot); + return plan === null ? [target] : [target, ...plan.subtypes.map((st) => st.entity)]; +} + /** * Render one M:N traversal mount. The junction + target Drizzle table consts are * imported from their sibling entity files (imp() lets ts-poet track + emit the @@ -490,7 +499,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(m2mTargetObjects(entry.target, ctx), " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts index 3697a879cc..0b590739d1 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -340,6 +340,15 @@ interface TphM2mSource { value: string; } +/** + * The object a M:N mount reads rows of, plus the subtypes of a TPH base target: its rows are + * shared-table rows of every subtype, so a credential declared by any of them is hidden too. + */ +function m2mTargetObjects(target: MetaObject, ctx: RenderContext): MetaObject[] { + const plan = ctx.loadedRoot === undefined ? null : tphPlan(target, ctx.loadedRoot); + return plan === null ? [target] : [target, ...plan.subtypes.map((st) => st.entity)]; +} + /** * Render one M:N traversal mount. The junction + target Drizzle table consts are * imported from their sibling entity files (imp() lets ts-poet track + emit the @@ -422,7 +431,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(m2mTargetObjects(entry.target, ctx), " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } diff --git a/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts b/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts new file mode 100644 index 0000000000..c15da5c0a0 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts @@ -0,0 +1,98 @@ +// #484: an M:N whose TARGET is a TPH base serves shared-table rows of every subtype, so a +// credential declared by a SUBTYPE is hidden from the traversal too (the route-level option +// is the only thing between the column and the wire). + +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; +import { routesFile } from "../src/generators/routes-file.js"; +import { makeRenderContext } from "../src/render-context.js"; +import { buildPkMap } from "../src/pk-resolver.js"; +import { buildRelationMap } from "../src/relation-resolver.js"; +import type { EmittedFile, GenContext } from "../src/generator.js"; + +const MODEL = { + "metadata.root": { + package: "acme::auth", + children: [ + { "object.entity": { name: "Payer", children: [ + { "source.rdb": { "@table": "payers" } }, + { "field.long": { name: "id" } }, + { "relationship.association": { name: "auths", "@cardinality": "many", "@objectRef": "Auth", "@through": "PayerAuth" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ]}}, + { "object.entity": { name: "Auth", "@discriminator": "type", children: [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "type", "@values": ["Bridge", "Copay"] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ]}}, + { "object.entity": { name: "BridgeAuth", extends: "Auth", "@discriminatorValue": "Bridge", children: [ + { "field.string": { name: "pinHash", children: [{ "view.password": {} }] } }, + ]}}, + { "object.entity": { name: "CopayAuth", extends: "Auth", "@discriminatorValue": "Copay", children: [ + { "field.int": { name: "copayCents" } }, + ]}}, + { "object.entity": { name: "PayerAuth", children: [ + { "source.rdb": { "@table": "payer_auths" } }, + { "field.long": { name: "payerId", "@required": true } }, + { "field.long": { name: "authId", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": ["payerId", "authId"] } }, + { "identity.reference": { name: "fkPayer", "@fields": "payerId", "@references": "Payer" } }, + { "identity.reference": { name: "fkAuth", "@fields": "authId", "@references": "Auth" } }, + ]}}, + ], + }, +}; + +async function loadModel(): Promise { + const result = await new MetaDataLoader({ strict: true }).load([ + new InMemoryStringSource(JSON.stringify(MODEL), { id: "tph-m2m-credential.json" }), + ]); + expect(result.errors.map((e) => e.message)).toEqual([]); + return result.root; +} + +async function generate(root: MetaRoot): Promise { + const renderContext = makeRenderContext({ + dialect: "postgres", + loadedRoot: root, + outDir: "/x", + dbImport: "./db", + pkMap: buildPkMap(root), + relationMap: buildRelationMap(root), + includeNames: true, + }); + const gen = routesFile(); + const genCtx: GenContext = { + entities: root.objects(), + loadedRoot: root, + matches: (e: MetaObject) => gen.filter?.(e) ?? true, + projectRoot: "/x", + config: { outDir: "/x", extStyle: "none", dbImport: "./db", dialect: "postgres" } as never, + renderContext, + warn: () => {}, + }; + return await gen.generate(genCtx); +} + +/** An emitted file with its whitespace collapsed, so assertions survive the formatter. */ +function fileNamed(files: EmittedFile[], path: string): string { + const f = files.find((x) => x.path === path); + if (f === undefined) throw new Error(`no emitted file ${path}; got ${files.map((x) => x.path).join(", ")}`); + return f.content.replace(/\s+/g, " "); +} + +/** One `mountM2mRoute({...})` call, selected by its relationName. */ +function m2mMount(src: string, relationName: string): string { + const m = new RegExp(`mountM2mRoute\\(\\{(?:(?!mountM2mRoute).)*?relationName: "${relationName}"(?:(?!mountM2mRoute).)*?\\}\\);`).exec(src); + if (m === null) throw new Error(`no mountM2mRoute for "${relationName}" in: ${src.slice(0, 400)}`); + return m[0]; +} + + +describe("M:N onto a TPH base target (#484)", () => { + test("the traversal hides a credential declared on a subtype of the target", async () => { + const routes = fileNamed(await generate(await loadModel()), "Payer.routes.ts"); + expect(m2mMount(routes, "auths")).toContain('hiddenColumns: ["pinHash"]'); + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index f437a428cb..999274d14d 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -403,6 +403,15 @@ interface TphM2mSource { value: string; } +/** + * The object a M:N mount reads rows of, plus the subtypes of a TPH base target: its rows are + * shared-table rows of every subtype, so a credential declared by any of them is hidden too. + */ +function m2mTargetObjects(target: MetaObject, ctx: RenderContext): MetaObject[] { + const plan = ctx.loadedRoot === undefined ? null : tphPlan(target, ctx.loadedRoot); + return plan === null ? [target] : [target, ...plan.subtypes.map((st) => st.entity)]; +} + /** * Render one M:N traversal mount. The junction + target Drizzle table consts are * imported from their sibling entity files (imp() lets ts-poet track + emit the @@ -490,7 +499,7 @@ function renderM2mMount( sourceColumn: ${sourceColumn}, targetColumn: ${targetColumn}, targetPkColumn: ${targetPkColumn}, - symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(entry.target, " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} + symmetric: ${entry.symmetric ? "true" : "false"},${decimalColumnsLine(m2mTargetObjects(entry.target, ctx), " ", ctx.loadedRoot)}${discriminatorLine}${sourceDiscriminatorLine} });`; } From 186ec1dd27e87cb605e3e1da5937a23c77b36600 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 08:30:01 -0400 Subject: [PATCH 03/16] refactor: simplify the #484 changes --- .../codegen/runtime/wire-format.ts | 18 +++++++++--------- .../showcase/codegen/runtime/wire-format.ts | 18 +++++++++--------- .../generator/kotlin/KotlinGenUtil.kt | 19 +++++++------------ .../spring/SpringControllerGenerator.java | 12 ++---------- .../codegen/generators/router_generator.py | 5 +++-- .../src/templates/form-file.ts | 3 ++- .../codegen-ts/src/metaobjects-config.ts | 8 ++++---- .../packages/runtime-ts/src/wire-format.ts | 18 +++++++++--------- 8 files changed, 45 insertions(+), 56 deletions(-) diff --git a/examples/advanced-modeling/codegen/runtime/wire-format.ts b/examples/advanced-modeling/codegen/runtime/wire-format.ts index a24cdcedc5..b21817d409 100644 --- a/examples/advanced-modeling/codegen/runtime/wire-format.ts +++ b/examples/advanced-modeling/codegen/runtime/wire-format.ts @@ -54,12 +54,7 @@ export interface WireOptions { /** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { if (hidden === undefined || hidden.length === 0) return undefined; - return (row) => { - if (!isPlainObject(row)) return row; - const out = { ...row }; - for (const column of hidden) delete out[column]; - return out; - }; + return (row) => (isPlainObject(row) ? omitKeys(row, hidden) : row); } /** @@ -71,15 +66,20 @@ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: */ export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; - const out = { ...body }; - for (const column of denied) delete out[column]; - return out; + return omitKeys(body, denied); } function isPlainObject(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } +/** A shallow copy of `obj` without the named keys. */ +function omitKeys(obj: Record, keys: readonly string[]): Record { + const out = { ...obj }; + for (const key of keys) delete out[key]; + return out; +} + function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { const out: Record = {}; if (mode === "emit") { diff --git a/examples/showcase/codegen/runtime/wire-format.ts b/examples/showcase/codegen/runtime/wire-format.ts index a24cdcedc5..b21817d409 100644 --- a/examples/showcase/codegen/runtime/wire-format.ts +++ b/examples/showcase/codegen/runtime/wire-format.ts @@ -54,12 +54,7 @@ export interface WireOptions { /** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { if (hidden === undefined || hidden.length === 0) return undefined; - return (row) => { - if (!isPlainObject(row)) return row; - const out = { ...row }; - for (const column of hidden) delete out[column]; - return out; - }; + return (row) => (isPlainObject(row) ? omitKeys(row, hidden) : row); } /** @@ -71,15 +66,20 @@ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: */ export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; - const out = { ...body }; - for (const column of denied) delete out[column]; - return out; + return omitKeys(body, denied); } function isPlainObject(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } +/** A shallow copy of `obj` without the named keys. */ +function omitKeys(obj: Record, keys: readonly string[]): Record { + const out = { ...obj }; + for (const key of keys) delete out[key]; + return out; +} + function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { const out: Record = {}; if (mode === "emit") { diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt index 8587f110be..6413f3e1ae 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt @@ -769,9 +769,12 @@ public object KotlinGenUtil { .any { it.subType == com.metaobjects.view.PresentationView.SUBTYPE_PASSWORD } /** #484 — `@formExclude: true` (resolving read): the field is not part of the write shape. */ - fun isFormExcluded(field: MetaField<*>): Boolean { - if (!field.hasMetaAttr(ATTR_FORM_EXCLUDE, true)) return false - val raw = runCatching { field.getMetaAttr(ATTR_FORM_EXCLUDE, true).value }.getOrNull() + fun isFormExcluded(field: MetaField<*>): Boolean = resolvedBooleanAttr(field, ATTR_FORM_EXCLUDE) + + /** Resolving boolean read: a Boolean value, or the string "true" in any case. */ + private fun resolvedBooleanAttr(field: MetaField<*>, name: String): Boolean { + if (!field.hasMetaAttr(name, true)) return false + val raw = runCatching { field.getMetaAttr(name, true).value }.getOrNull() return when (raw) { is Boolean -> raw is String -> raw.equals("true", ignoreCase = true) @@ -787,15 +790,7 @@ public object KotlinGenUtil { private const val ATTR_FORM_EXCLUDE = "formExclude" - fun isRequiredField(field: MetaField<*>): Boolean { - if (!field.hasMetaAttr(MetaField.ATTR_REQUIRED, true)) return false - val raw = runCatching { field.getMetaAttr(MetaField.ATTR_REQUIRED, true).value }.getOrNull() - return when (raw) { - is Boolean -> raw - is String -> raw.equals("true", ignoreCase = true) - else -> false - } - } + fun isRequiredField(field: MetaField<*>): Boolean = resolvedBooleanAttr(field, MetaField.ATTR_REQUIRED) /** * The `@autoSet` policy of [field] — [AUTO_SET_ON_CREATE] / [AUTO_SET_ON_UPDATE] — or null diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index b0292f7c9a..00cb142d0d 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -684,10 +684,10 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) { if (com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) { appendReportListHandler(src, shortName, dtoName, repoName, reportMaxRows, reportExport, routeBase); } else { - appendListHandler(src, shortName, dtoName, repoName); + appendListHandler(src, shortName, dtoName, repoName, List.of()); } if (hasItem) { - appendGetByIdHandler(src, pkType); + appendGetByIdHandler(src, pkType, List.of()); } src.append(" @PostMapping\n"); @@ -792,10 +792,6 @@ private static void appendUnreadableBodyHandler(StringBuilder src) { * filter[<field>][<op>]=<value> grammar reaches FilterParser intact * (Spring's {@code @RequestParam} would collapse same-key occurrences). */ - private static void appendListHandler(StringBuilder src, String shortName, String dtoName, String repoName) { - appendListHandler(src, shortName, dtoName, repoName, List.of()); - } - private static void appendListHandler(StringBuilder src, String shortName, String dtoName, String repoName, List hidden) { String allowlistName = SpringNaming.filterAllowlistName(shortName); @@ -1036,10 +1032,6 @@ private static void appendReportExportHandler(StringBuilder src, String shortNam } /** GET /{id} — single by primary key, 404 envelope on a miss. */ - private static void appendGetByIdHandler(StringBuilder src, String pkType) { - appendGetByIdHandler(src, pkType, List.of()); - } - private static void appendGetByIdHandler(StringBuilder src, String pkType, List hidden) { src.append(" @GetMapping(\"/{id}\")\n"); src.append(" public ResponseEntity get(@PathVariable ").append(pkType).append(" id) {\n"); diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index aabb6c99d8..3986ad8f1f 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -1839,7 +1839,8 @@ def render_router( # the generated module always carries the seam (an entity that later gains a # writeOnce field changes one literal, not the module's shape). parts.append(f"_FROZEN_FIELDS: frozenset[str] = {frozen_set_body}") - parts.extend(_denied_const_lines(_denied_names(entity))) + denied_names = _denied_names(entity) + parts.extend(_denied_const_lines(denied_names)) parts.append("") parts.append("") parts.extend(_sort_parse_fn_lines()) @@ -1872,7 +1873,7 @@ def render_router( # FastAPI (the handler binds the RAW body dict and hands THAT to the # repository), so the strip has to live here, not in Patch. frozen_strip=_frozen_strip_lines(entity), - denied_strip=_denied_strip_lines(_denied_names(entity)), + denied_strip=_denied_strip_lines(denied_names), ) for i, hname in enumerate(("list", "get", "create", "update", "delete")): if i > 0: diff --git a/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts b/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts index 3dacc759ef..434235b9f8 100644 --- a/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts +++ b/server/typescript/packages/codegen-ts-angular/src/templates/form-file.ts @@ -20,6 +20,7 @@ import { IDENTITY_SUBTYPE_PRIMARY, IDENTITY_ATTR_FIELDS, FIELD_ATTR_DEFAULT, + FIELD_ATTR_FORM_EXCLUDE, FIELD_ATTR_MAX_LENGTH, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_BOOLEAN, @@ -55,7 +56,7 @@ function visibleFields(entity: MetaObject): MetaField[] { const out: MetaField[] = []; for (const child of entity.fields()) { // ADR-0039: resolving — a field may inherit @formExclude via extends. - if (child.attr("formExclude") === true) continue; + if (child.attr(FIELD_ATTR_FORM_EXCLUDE) === true) continue; // A credential is never edited through a generated form (see the React generator). if (isCredentialField(child)) continue; if (pkNames.has(child.name)) continue; diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 66ecf58e90..614eb798ed 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -19,14 +19,14 @@ import { normalizeTimestampMode } from "./dialect-module.js"; */ export type GeneratorSpec = Generator | string; +/** The closed set of codegen dialects. `d1` is deliberately absent: it is a `meta migrate` transport. */ +export const CODEGEN_DIALECTS = ["sqlite", "postgres", "mysql"] as const; + /** * The SQL dialect generated code targets. `mysql` is codegen + runtime only: `meta migrate` * does not own a MySQL schema (ADR-0015), so the adopter writes the DDL. */ -export type Dialect = "sqlite" | "postgres" | "mysql"; - -/** The closed set of codegen dialects. `d1` is deliberately absent: it is a `meta migrate` transport. */ -export const CODEGEN_DIALECTS = ["sqlite", "postgres", "mysql"] as const satisfies readonly Dialect[]; +export type Dialect = (typeof CODEGEN_DIALECTS)[number]; /** * Refuse a `dialect` that is not one of {@link CODEGEN_DIALECTS}. Without this an unknown value diff --git a/server/typescript/packages/runtime-ts/src/wire-format.ts b/server/typescript/packages/runtime-ts/src/wire-format.ts index a24cdcedc5..b21817d409 100644 --- a/server/typescript/packages/runtime-ts/src/wire-format.ts +++ b/server/typescript/packages/runtime-ts/src/wire-format.ts @@ -54,12 +54,7 @@ export interface WireOptions { /** Remove `hidden` keys from a row. `undefined` when there is nothing to hide. */ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: unknown) => unknown) | undefined { if (hidden === undefined || hidden.length === 0) return undefined; - return (row) => { - if (!isPlainObject(row)) return row; - const out = { ...row }; - for (const column of hidden) delete out[column]; - return out; - }; + return (row) => (isPlainObject(row) ? omitKeys(row, hidden) : row); } /** @@ -71,15 +66,20 @@ export function hiddenColumnsWire(hidden: readonly string[] | undefined): ((row: */ export function stripDeniedColumns(body: unknown, denied: readonly string[] | undefined): unknown { if (denied === undefined || denied.length === 0 || !isPlainObject(body)) return body; - const out = { ...body }; - for (const column of denied) delete out[column]; - return out; + return omitKeys(body, denied); } function isPlainObject(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } +/** A shallow copy of `obj` without the named keys. */ +function omitKeys(obj: Record, keys: readonly string[]): Record { + const out = { ...obj }; + for (const key of keys) delete out[key]; + return out; +} + function shapeMember(value: Record, shape: ValueObjectShape, mode: NullMembersMode): Record { const out: Record = {}; if (mode === "emit") { From 82c82d0b61bf5d209e4b0eb284111ffe06b12170 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 09:19:36 -0400 Subject: [PATCH 04/16] fix(codegen): redact Java read-only mounts and Kotlin at the controller boundary, prove denied writes on stored rows (#484) --- docs/recipes/guarding-generated-routes.md | 1 + .../ApiContractSecretFieldsConformanceTest.cs | 35 +++++ .../generator/kotlin/KotlinEntityGenerator.kt | 14 +- .../kotlin/KotlinSpringControllerGenerator.kt | 141 +++++++++++------- .../kotlin/KotlinSecretFieldsTest.kt | 48 +++--- .../spring/SpringControllerGenerator.java | 24 ++- .../spring/SpringSecretFieldsCodegenTest.java | 39 +++++ ...eldsGeneratedApiContractConformanceTest.kt | 17 +++ .../GeneratedSecretFieldsControllerHarness.kt | 16 ++ ...dsGeneratedApiContractConformanceTest.java | 19 +++ .../GeneratedAccountControllerHarness.java | 25 +++- .../test_api_contract_secret_fields.py | 21 +++ ...contract-secret-fields-generated-server.ts | 8 + .../test/api-contract-secret-fields.test.ts | 21 ++- 14 files changed, 333 insertions(+), 96 deletions(-) diff --git a/docs/recipes/guarding-generated-routes.md b/docs/recipes/guarding-generated-routes.md index ceb4bbead0..2eeed7bcee 100644 --- a/docs/recipes/guarding-generated-routes.md +++ b/docs/recipes/guarding-generated-routes.md @@ -37,6 +37,7 @@ Put middleware on the mount path **with a trailing `/*`**, before calling the ge register function: ```ts +import { Note } from "./generated/Note.js"; import { registerNoteRoutes } from "./generated/Note.routes.hono.js"; app.use(`/api${Note.$path}/*`, requireAuth); // `${apiPrefix}${Entity.$path}/*` diff --git a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs index 32934a9588..9314afaa29 100644 --- a/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs +++ b/server/csharp/MetaObjects.IntegrationTests/Api/ApiContractSecretFieldsConformanceTest.cs @@ -23,6 +23,41 @@ public async Task Api_contract_secret_fields_generated(string scenarioPath) await using var server = await SecretFieldsGeneratedServerFactory.StartAsync(pg); await server.ApplySeedAsync(); await RunAsync(scenario, server.BaseUrl); + await AssertStoredSecretsUntouchedAsync(pg.ConnectionString); + } + + // Behavioural proof of write-denial, read straight from the database (not through the API): + // every seeded row keeps its seeded tokenHash and pinHash, and a row a scenario created holds + // null in both, so neither a forged value nor a present null ever reached a denied column. + private static async Task AssertStoredSecretsUntouchedAsync(string connString) + { + var seed = JsonNode.Parse(File.ReadAllText(ApiContractCorpusPaths.SecretFieldsSeedFile))!["rows"]!.AsArray() + .ToDictionary(r => r!["id"]!.GetValue(), r => r!.AsObject()); + + await using var c = new Npgsql.NpgsqlConnection(connString); + await c.OpenAsync(); + await using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT \"id\", \"tokenHash\", \"pinHash\" FROM \"accounts\" ORDER BY \"id\""; + await using var reader = await cmd.ExecuteReaderAsync(); + var seen = new HashSet(); + while (await reader.ReadAsync()) + { + long id = reader.GetInt64(0); + string? token = reader.IsDBNull(1) ? null : reader.GetString(1); + string? pin = reader.IsDBNull(2) ? null : reader.GetString(2); + seen.Add(id); + if (seed.TryGetValue(id, out var row)) + { + Assert.Equal(row["tokenHash"]!.GetValue(), token); + Assert.Equal(row["pinHash"]!.GetValue(), pin); + } + else + { + Assert.Null(token); + Assert.Null(pin); + } + } + Assert.True(seed.Keys.All(seen.Contains), "a seeded row went missing"); } private static async Task RunAsync(ApiScenario scenario, string baseUrl) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index 4da7b81e8e..692e16ce49 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -278,11 +278,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { // real models have. (A missing `Long` silently binds 0, which is how an increment key // hid it; a `UUID` cannot.) Same relaxation as a derived field on a write-through // entity: nullable, default null, no @field:NotNull. Rows read back always carry it. - // #484: a credential is never the caller's to send and is never returned, so it is nullable + - // defaulted like a server-owned field, and its property drops a null on the wire (the read - // routes pass null, which must read as an ABSENT key, not `"pin": null`). - val credential = KotlinGenUtil.isCredentialField(field) - val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj) || credential + val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj) val nullable = tphBase || derivedReadOnly || serverOwned || KotlinGenUtil.isNullableShapeProperty(field) val propType = if (nullable) baseType.copy(nullable = true) else baseType val propName = field.name @@ -311,14 +307,6 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { propBuilder.addAnnotation(constraint(VALID)) } } - if (credential) { - propBuilder.addAnnotation( - AnnotationSpec.builder(ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) - .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) - .addMember("%T.Include.NON_NULL", ClassName("com.fasterxml.jackson.annotation", "JsonInclude")) - .build(), - ) - } decimalWireAnnotations(field, pkg, declaredRange = obj.subType == MetaObject.SUBTYPE_VALUE) .forEach { propBuilder.addAnnotation(it) } typeBuilder.addProperty(propBuilder.build()) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 6c46e0f686..560aa5bb5c 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -216,6 +216,25 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase, arg: String): String = + if (creds.isEmpty()) "rowTo$shortName($arg)" else "redact$shortName(objectMapper, rowTo$shortName($arg))" + + /** + * #484: a credential is never returned. The shared data class keeps its declared types, so the key is + * dropped from the serialized tree at the HTTP boundary (absent, not null), through the same mapper + * (and so the same wire annotations) the response would use. + */ + private fun appendRedactor(sb: StringBuilder, shortName: String, creds: Collection) { + if (creds.isEmpty()) return + sb.append("/** GENERATED — the ${shortName} response value with its credential field(s) removed. */\n") + sb.append("private fun redact${shortName}(mapper: ObjectMapper, row: ${shortName}): Any =\n") + sb.append(" mapper.valueToTree(row).apply { remove(listOf(${creds.joinToString(", ") { "\"$it\"" }})) }\n\n") + } + + private fun credentialNames(fields: Collection>): List = + fields.filter { KotlinGenUtil.isCredentialField(it) }.map { it.name } + protected open fun emit(entity: MetaObject, outRoot: Path, loader: MetaDataLoader) { val api = exposedApi() val (pkg, shortName) = PackageMapping.splitFqn(entity.name) @@ -279,6 +298,10 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase navs.any { it.targetCredentialFields.isNotEmpty() } } val src = buildString { if (pkg.isNotEmpty()) append("package $pkg\n\n") @@ -834,6 +861,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase: union ResultRow → data class append("/** GENERATED — map an Exposed ResultRow to the union $shortName data class. */\n") append("private fun rowTo${shortName}(row: ResultRow): $shortName = $shortName(\n") - for (field in scalarFields) { - // #484: a credential is never returned. - if (KotlinGenUtil.isCredentialField(field)) append(" ${field.name} = null,\n") - else append(" ${field.name} = row[$table.${field.name}],\n") - } + for (field in scalarFields) append(" ${field.name} = row[$table.${field.name}],\n") append(")\n\n") + appendRedactor(this, shortName, creds) append("/**\n") append(" * GENERATED — TPH discriminator-base controller for $shortName (polymorphic + per-subtype CRUD).\n") @@ -943,6 +969,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase = transaction {\n") append(" val row = $table.selectAll().where { $table.$pkFieldName eq id }.singleOrNull()\n") append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") - append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" ResponseEntity.ok(${wireRow(shortName, creds, "row")} as Any)\n") append(" }\n\n") // Base-declared M:N — unscoped, same shape emitM2mEndpoint gives a vanilla entity. @@ -1034,9 +1062,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase = transaction {\n") append(" val row = $table.selectAll().where { ($table.$pkFieldName eq id) and ($table.${plan.discriminatorField} eq $disc) }.singleOrNull()\n") append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") - append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" ResponseEntity.ok(${wireRow(shortName, creds, "row")} as Any)\n") append(" }\n\n") // per-subtype create (discriminator injected from URL). FR-036: validate each body @@ -1086,7 +1114,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase> = transaction {\n") - out.append(" val rows = $sourceTable.$queryFnName(id).map { row ->\n") + out.append(" fun ${nav.relationName}(@PathVariable id: $pkParamType): ResponseEntity> = transaction {\n") + appendM2mRows(out, nav, targetType, "$sourceTable.$queryFnName(id)") + out.append(" }\n") + } + + /** #484: the element type an M:N traversal answers — `Any` (a redacted tree) when the target has a credential. */ + private fun m2mReturnElement(nav: KotlinM2mSupport.M2mNav, targetType: String): String = + if (nav.targetCredentialFields.isEmpty()) targetType else "Any" + + /** + * The shared body of an M:N traversal: map each joined row to the target data class and, when the target + * declares credentials (#484), drop those keys from the serialized value (the source controller holds an + * `objectMapper` whenever one of its targets does). + */ + private fun appendM2mRows(out: StringBuilder, nav: KotlinM2mSupport.M2mNav, targetType: String, query: String) { + val creds = nav.targetCredentialFields + out.append(" val rows${if (creds.isNotEmpty()) ": List" else ""} = $query.map { row ->\n") + if (creds.isNotEmpty()) out.append(" objectMapper.valueToTree(\n") out.append(" $targetType(\n") for (fname in nav.targetScalarFields) { - // #484: a credential on the target is never returned. - if (fname in nav.targetCredentialFields) out.append(" $fname = null,\n") - else out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") + out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") } out.append(" )\n") + if (creds.isNotEmpty()) { + out.append(" ).apply { remove(listOf(${creds.joinToString(", ") { "\"$it\"" }})) }\n") + } out.append(" }\n") out.append(" ResponseEntity.ok(rows)\n") - out.append(" }\n") } /** @@ -1888,20 +1932,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase> = transaction {\n") + val elem = m2mReturnElement(nav, targetType) + out.append(" fun ${nav.relationName}$sfx(@PathVariable id: $pkParamType): ResponseEntity> = transaction {\n") out.append(" if ($table.selectAll().where { ($table.$pkFieldName eq id) and ($table.$discField eq $discExpr) }.singleOrNull() == null) {\n") - out.append(" return@transaction ResponseEntity.ok(emptyList<$targetType>())\n") + out.append(" return@transaction ResponseEntity.ok(emptyList<$elem>())\n") out.append(" }\n") - out.append(" val rows = $table.$queryFnName(id).map { row ->\n") - out.append(" $targetType(\n") - for (fname in nav.targetScalarFields) { - // #484: a credential on the target is never returned. - if (fname in nav.targetCredentialFields) out.append(" $fname = null,\n") - else out.append(" $fname = row[${nav.targetTableObj}.$fname],\n") - } - out.append(" )\n") - out.append(" }\n") - out.append(" ResponseEntity.ok(rows)\n") + appendM2mRows(out, nav, targetType, "$table.$queryFnName(id)") out.append(" }\n") } @@ -2060,6 +2096,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase + sb: StringBuilder, shortName: String, readObj: String, sortFields: List, + creds: Collection = emptyList(), ) = with(sb) { val api = exposedApi() // List handler — pagination + sort + withCount + FR-009 filter operators. @@ -2290,9 +2330,9 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase = emptyList(), ) = with(sb) { // GET /{id} append(" @GetMapping(\"/{id}\")\n") append(" fun get(@PathVariable id: $pkParamType): ResponseEntity = transaction {\n") append(" val row = ${readObj}.selectAll().where { ${readObj}.${pkFieldName} eq id }.singleOrNull()\n") append(" ?: return@transaction ResponseEntity.status(HttpStatus.NOT_FOUND).body(mapOf(\"error\" to \"not_found\") as Any)\n") - append(" ResponseEntity.ok(rowTo${shortName}(row) as Any)\n") + append(" ResponseEntity.ok(${wireRow(shortName, creds, "row")} as Any)\n") append(" }\n\n") } diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt index f9c4be18c6..fc2ab0c762 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt @@ -7,30 +7,31 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * #484 — the generated HTTP boundary ignores a `@formExclude` or credential (`view.password`) field on - * create / PATCH / PUT and never returns a credential. A credential's write path is not observable over - * HTTP (it is never read back), so the generated source is pinned here; the wire behaviour of both marks is - * gated by the api-contract `secret-fields/` corpus in the integration-tests-kotlin generated lane. + * #484 — the generated HTTP boundary never returns a credential (`view.password`) and ignores a + * `@formExclude` or credential field on writes. The wire and storage behaviour of both marks is gated + * behaviourally by the api-contract `secret-fields/` corpus in the integration-tests-kotlin generated lane + * (responses, and the stored rows after every write). These tests pin the generator's design: the entity + * keeps its declared types and the controller redacts at the boundary. */ class KotlinSecretFieldsTest { - private fun model(marked: Boolean) = """{ + private fun model(marked: Boolean, requiredCredential: Boolean = false) = """{ "metadata.root": { "package": "acme::secrets", "children": [ { "object.entity": { "name": "Account", "children": [ { "source.rdb": { "@table": "accounts" } }, { "field.long": { "name": "id" } }, { "field.string": { "name": "name", "@required": true, "@maxLength": 100 } }, { "field.string": { "name": "tokenHash", "@maxLength": 100${if (marked) """, "@formExclude": true""" else ""} } }, - { "field.string": { "name": "pinHash", "@maxLength": 100${if (marked) """, "children": [ { "view.password": {} } ]""" else ""} } }, + { "field.string": { "name": "pinHash", "@maxLength": 100${if (requiredCredential) """, "@required": true""" else ""}${if (marked) """, "children": [ { "view.password": {} } ]""" else ""} } }, { "identity.primary": { "@fields": "id", "@generation": "increment" } } ] } } ] } }""".trimIndent() - private fun controllerAndEntity(marked: Boolean): Pair { + private fun controllerAndEntity(marked: Boolean, requiredCredential: Boolean = false): Pair { val out = Files.createTempDirectory("ksecret-") try { - val loader = loadString("secret-fields-$marked", model(marked)) + val loader = loadString("secret-fields-$marked-$requiredCredential", model(marked, requiredCredential)) KotlinEntityGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) KotlinSpringControllerGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) val dir = out.resolve("acme/secrets") @@ -41,25 +42,23 @@ class KotlinSecretFieldsTest { } @Test - fun `create and update paths skip both the formExclude field and the credential`() { - val (controller, _) = controllerAndEntity(marked = true) - // The ordinary field is still written on create and settable on PATCH / PUT ... - assertTrue("it[name] = dto.name" in controller, controller) - assertTrue("listOf(\"name\").any { body.has(it) }" in controller, controller) - // ... and neither denied field reaches a write, on create or on update. - for (f in listOf("tokenHash", "pinHash")) { - assertFalse("it[$f] = dto.$f" in controller, "create writes $f:\n$controller") - assertFalse("val has${f.replaceFirstChar { it.uppercase() }}" in controller, "update binds $f:\n$controller") - assertFalse("AccountTable.$f]" in controller && "it[AccountTable.$f]" in controller, "update sets $f") - } + fun `only the credential is redacted on read and the entity keeps its types`() { + val (controller, entity) = controllerAndEntity(marked = true) + assertTrue("remove(listOf(\"pinHash\"))" in controller, controller) + assertFalse("tokenHash\"" in controller.substringAfter("private fun redactAccount").substringBefore("\n\n"), controller) + assertTrue("redactAccount(objectMapper, rowToAccount(it))" in controller, controller) + assertTrue("tokenHash = row[AccountTable.tokenHash]," in controller, controller) + assertTrue("pinHash = row[AccountTable.pinHash]," in controller, controller) + assertFalse("JsonInclude" in entity, entity) } @Test - fun `only the credential is hidden on read`() { - val (controller, entity) = controllerAndEntity(marked = true) - assertTrue("pinHash = null," in controller, controller) - assertTrue("tokenHash = row[AccountTable.tokenHash]," in controller, controller) - assertTrue("JsonInclude" in entity && "pinHash" in entity, entity) + fun `a required credential stays a non-null property with no JsonInclude`() { + val (controller, entity) = controllerAndEntity(marked = true, requiredCredential = true) + assertTrue(Regex("val pinHash: String[,\\s)]").containsMatchIn(entity), entity) + assertFalse("val pinHash: String?" in entity, entity) + assertFalse("JsonInclude" in entity, entity) + assertTrue("remove(listOf(\"pinHash\"))" in controller, controller) } @Test @@ -70,6 +69,7 @@ class KotlinSecretFieldsTest { assertTrue("$f = row[AccountTable.$f]," in controller, controller) } assertTrue("listOf(\"name\", \"tokenHash\", \"pinHash\").any { body.has(it) }" in controller, controller) + assertFalse("redactAccount" in controller, controller) assertFalse("JsonInclude" in entity, entity) } } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 00cb142d0d..0d61150508 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -675,19 +675,33 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) { // Repository wiring — constructor injection, the read-only seam only. No // ObjectMapper and no Validator: nothing here binds a request body, so injecting // either would make a consumer wire beans this controller can never use. - src.append(" private final ").append(repoName).append(" repository;\n\n"); - src.append(" public ").append(controllerName).append("(").append(repoName) - .append(" repository) {\n"); + // #484: a projection field that inherits a credential (view.password) is hidden from its + // rows. Redaction needs the ObjectMapper, so it is injected ONLY then; otherwise the + // constructor and the whole controller stay as they were. + List hidden = com.metaobjects.generator.util.GeneratorUtil.isReport(entity) + ? List.of() : SpringGeneratorSupport.credentialNames(entity); + src.append(" private final ").append(repoName).append(" repository;\n"); + if (hidden.isEmpty()) { + src.append("\n"); + src.append(" public ").append(controllerName).append("(").append(repoName) + .append(" repository) {\n"); + } else { + src.append(" private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;\n\n"); + src.append(" public ").append(controllerName).append("(").append(repoName) + .append(" repository, com.fasterxml.jackson.databind.ObjectMapper objectMapper) {\n"); + src.append(" this.objectMapper = objectMapper;\n"); + } src.append(" this.repository = repository;\n"); src.append(" }\n\n"); + if (!hidden.isEmpty()) appendRedactHelpers(src); if (com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) { appendReportListHandler(src, shortName, dtoName, repoName, reportMaxRows, reportExport, routeBase); } else { - appendListHandler(src, shortName, dtoName, repoName, List.of()); + appendListHandler(src, shortName, dtoName, repoName, hidden); } if (hasItem) { - appendGetByIdHandler(src, pkType, List.of()); + appendGetByIdHandler(src, pkType, hidden); } src.append(" @PostMapping\n"); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java index 4ca4215b68..dfe8b6fe8a 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java @@ -91,4 +91,43 @@ public void unmarkedModelEmitsNoRedactionAndNoStrip() throws Exception { assertFalse(controller.contains("redact")); assertFalse(controller.contains("dto = new AccountDto(")); } + + private static final String PROJECTION_HEAD = + "{ \"object.projection\": { \"name\": \"AccountView\", \"children\": [\n" + + " { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_account\" } },\n" + + " { \"field.long\": { \"name\": \"id\", \"extends\": \"Account.id\" } },\n" + + " { \"field.string\": { \"name\": \"name\", \"extends\": \"Account.name\" } },\n"; + private static final String PROJECTION_TAIL = + " { \"identity.primary\": { \"name\": \"pk\", \"extends\": \"Account.pk\" } }\n" + + "] } }"; + + /** MARKED plus an AccountView projection carrying {@code extra} (a field declaration). */ + private static String projection(String extra) { + String body = PROJECTION_HEAD + " " + extra + "\n" + PROJECTION_TAIL; + int end = MARKED.lastIndexOf("] }\n"); + int entityEnd = MARKED.lastIndexOf("} }", end); + return MARKED.substring(0, entityEnd + 3) + ",\n" + body + "\n" + MARKED.substring(entityEnd + 3); + } + + @Test + public void projectionInheritingACredentialHidesItOnListAndGet() throws Exception { + String controller = generate("proj", projection( + "{ \"field.string\": { \"name\": \"pinHash\", \"extends\": \"Account.pinHash\" } },"), + "AccountViewController.java", false); + assertTrue("list hides the credential; saw:\n" + controller, + controller.contains("redactAll(rows, \"pinHash\")")); + assertTrue("get hides the credential", controller.contains("redact(r, \"pinHash\")")); + assertTrue("the mapper is injected only for this case", + controller.contains("ObjectMapper objectMapper) {")); + } + + @Test + public void projectionWithoutACredentialIsUnchanged() throws Exception { + String controller = generate("projplain", projection( + "{ \"field.string\": { \"name\": \"tokenHash\", \"extends\": \"Account.tokenHash\" } },"), + "AccountViewController.java", false); + assertFalse(controller.contains("redact")); + assertFalse(controller.contains("ObjectMapper")); + assertTrue(controller.contains("AccountViewController(AccountViewRepository repository) {")); + } } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt index c623d965f1..92cf42d370 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/SecretFieldsGeneratedApiContractConformanceTest.kt @@ -33,6 +33,23 @@ internal class SecretFieldsGeneratedApiContractConformanceTest { val parsed = HARNESS.parseBody(res.body) ApiContractAssertions.assertResponse(scenario.name, req, res.status, parsed) } + assertDeniedColumnsUntouched(scenario.name) + } + } + + /** + * The write side of #484, observed on the STORED row (the repository), not on the response: a seeded row's + * `tokenHash` and `pinHash` are exactly as seeded after every forged create / PATCH / PUT / present-null, and + * a row the scenario created holds null in both. This is the only place `pinHash`'s write path is observable. + */ + private fun assertDeniedColumnsUntouched(scenarioName: String) { + val seeded = SEED.mapIndexed { i, r -> (i + 1).toLong() to r }.toMap() + for (row in HARNESS.storedRows()) { + val seed = seeded[row["id"] as Long] + for (col in listOf("tokenHash", "pinHash")) { + val expected = seed?.get(col) + kotlin.test.assertEquals(expected, row[col], "$scenarioName: stored $col of row ${row["id"]}") + } } } diff --git a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt index 8af7b1212b..a0492ebdd7 100644 --- a/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt +++ b/server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/secretfields/generated/GeneratedSecretFieldsControllerHarness.kt @@ -45,6 +45,7 @@ class GeneratedSecretFieldsControllerHarness( private val accountTable: Table private val dbSeq = AtomicInteger(0) private var host: TomcatHost? = null + private var db: Database? = null init { val metaJson = corpusRoot.resolve("secret-fields/meta.json") @@ -105,6 +106,7 @@ class GeneratedSecretFieldsControllerHarness( fun reset() { val dbName = "secret_fields_${dbSeq.incrementAndGet()}" val db = Database.connect("jdbc:h2:mem:$dbName;DB_CLOSE_DELAY=-1;MODE=PostgreSQL", driver = "org.h2.Driver") + this.db = db transaction(db) { SchemaUtils.create(accountTable) // Ids are assigned by the identity column in seed order (1, 2), so a later POST does not collide. @@ -121,6 +123,20 @@ class GeneratedSecretFieldsControllerHarness( host = TomcatHost.start(mapper, controller) } + /** The stored rows as the repository sees them (raw SQL, so the generated HTTP boundary is bypassed). */ + fun storedRows(): List> { + val database = db ?: error("reset() must be called before storedRows()") + return transaction(database) { + val out = mutableListOf>() + exec("SELECT id, \"tokenHash\", \"pinHash\" FROM accounts ORDER BY id") { rs -> + while (rs.next()) { + out += mapOf("id" to rs.getLong(1), "tokenHash" to rs.getString(2), "pinHash" to rs.getString(3)) + } + } + out + } + } + fun exchange(method: String, path: String, jsonBody: Any?): Response { val server = host ?: error("reset() must be called before exchange(...)") val res = server.exchange(method, path, jsonBody?.let { mapper.writeValueAsString(it) }) diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java index 24b4b2c25a..1401006d60 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java @@ -76,6 +76,25 @@ void scenario(String name, ApiScenario scenario) { Object parsed = HARNESS.parseBody(res.body()); ApiContractAssertions.assertResponse(scenario.name(), req, res.status(), parsed); } + assertDeniedColumnsNeverWritten(scenario.name()); }); } + + /** + * The stored rows, read from the repository seam: a seeded row still holds its seed + * {@code tokenHash} and {@code pinHash} (neither was writable), and a row the scenario created + * holds null in both. This is what pins {@code pinHash}'s write path, which no response shows. + */ + private static void assertDeniedColumnsNeverWritten(String scenario) throws Exception { + Map> seeded = new java.util.HashMap<>(); + for (Map row : seedRows()) seeded.put(((Number) row.get("id")).longValue(), row); + for (Map stored : HARNESS.storedRows()) { + Map seed = seeded.get(stored.get("id")); + for (String col : List.of("tokenHash", "pinHash")) { + Object want = seed == null ? null : seed.get(col); + org.junit.jupiter.api.Assertions.assertEquals(want, stored.get(col), + scenario + ": stored " + col + " of row " + stored.get("id") + " must be untouched"); + } + } + } } diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java index f22e1ca1da..f11f4184be 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java @@ -52,6 +52,7 @@ public final class GeneratedAccountControllerHarness implements AutoCloseable { private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); private TomcatHost host; + private Object repo; /** * @param corpusRoot {@code fixtures/api-contract-conformance/secret-fields} @@ -97,7 +98,8 @@ public GeneratedAccountControllerHarness(Path corpusRoot, Path configRoot, Path public void reset() throws Exception { List dtos = new ArrayList<>(); for (Map row : seedRows) dtos.add(mapper.convertValue(row, dtoClass)); - Object controller = controllerCtor.newInstance(repoCtor.newInstance(dtos), mapper, validator); + this.repo = repoCtor.newInstance(dtos); + Object controller = controllerCtor.newInstance(repo, mapper, validator); if (host != null) host.close(); this.host = TomcatHost.start(mapper, controller); } @@ -108,6 +110,27 @@ public Response exchange(String method, String path, Object jsonBody) throws Exc return new Response(res.status(), res.body()); } + /** + * The rows as the repository holds them now, read straight from the in-memory seam (not over + * HTTP, which hides {@code pinHash}): one map per row with every DTO component by name. + */ + public List> storedRows() throws Exception { + java.lang.reflect.Method list = null; + for (java.lang.reflect.Method m : repo.getClass().getMethods()) { + if (m.getName().equals("list") && m.getParameterCount() == 4) list = m; + } + List rows = (List) list.invoke(repo, 1000, 0, null, null); + List> out = new ArrayList<>(); + for (Object row : rows) { + Map m = new java.util.LinkedHashMap<>(); + for (java.lang.reflect.RecordComponent c : dtoClass.getRecordComponents()) { + m.put(c.getName(), c.getAccessor().invoke(row)); + } + out.add(m); + } + return out; + } + public Object parseBody(String body) { return TomcatHost.parseBody(mapper, body); } diff --git a/server/python/tests/integration/test_api_contract_secret_fields.py b/server/python/tests/integration/test_api_contract_secret_fields.py index fd2ccf7fcb..ed3c0da4fb 100644 --- a/server/python/tests/integration/test_api_contract_secret_fields.py +++ b/server/python/tests/integration/test_api_contract_secret_fields.py @@ -68,3 +68,24 @@ def test_secret_fields_scenario(scenario_name: str, scenario: dict[str, Any]) -> status=response.status_code, body=_parse(response.text), ) + + +def test_denied_columns_never_reach_the_stored_rows() -> None: + """Behavioural proof for ``pinHash`` too: it is never readable over HTTP, so read the + rows the generated router left in the repository after the write scenarios ran.""" + _REPO.reset() + _REPO.seed(_SEED_ROWS) + for _name, scenario in _SCENARIOS: + if scenario["name"] != "write-ignores-excluded": + continue + for req in scenario["requests"]: + _CLIENT.request(str(req["method"]).upper(), str(req["path"]), json=req.get("body")) + stored = {r["id"]: r for r in _REPO._rows} + for seed in _SEED_ROWS: + row = stored[seed["id"]] + assert row["pinHash"] == seed["pinHash"], f"pinHash written on row {seed['id']}" + assert row["tokenHash"] == seed["tokenHash"], f"tokenHash written on row {seed['id']}" + created = [r for r in _REPO._rows if r["id"] not in {s["id"] for s in _SEED_ROWS}] + assert [r["name"] for r in created] == ["Grace Hopper"] + assert created[0].get("pinHash") is None and created[0].get("tokenHash") is None + assert {r["name"] for r in _REPO._rows if r["id"] in (1, 2)} == {"Ada L. (probe 2)", "Alan T. (probe)"} diff --git a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts index 272580d72c..b02cf11bfe 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts @@ -27,6 +27,8 @@ export interface GeneratedSecretFieldsServerHandle { /** The generated source of Account.routes.ts, for codegen assertions. */ routesSource: string; applySeed(seed: SecretFieldsSeed): Promise; + /** The stored accounts, read straight from the table (the HTTP API never returns a credential). */ + storedRows(): Promise>; close(): Promise; } @@ -105,6 +107,12 @@ export const db = drizzle(pool); // The seed names ids explicitly; keep the sequence ahead of them for the POST scenarios. await executeSql(connectionUri, `SELECT setval(pg_get_serial_sequence('accounts','id'), (SELECT MAX("id") FROM "accounts"));`); }, + storedRows: async () => { + const res = await dbMod.pool.query<{ id: number; token_hash: string | null; pin_hash: string | null }>( + `SELECT "id", "token_hash", "pin_hash" FROM "accounts" ORDER BY "id"`, + ); + return res.rows.map((r) => ({ id: Number(r.id), tokenHash: r.token_hash, pinHash: r.pin_hash })); + }, close: async () => { await fastify.close(); await dbMod.pool.end(); diff --git a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts index 0d1bf3bbbd..f0023bc41b 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts @@ -35,6 +35,7 @@ describe("api contract secret fields (#484) — GENERATED routes lane", () => { server = await startGeneratedSecretFieldsServer(pg.connectionUri, META_PATH); await server.applySeed(SEED); await runScenario(scenario, server); + if (scenario.name === "write-ignores-excluded") await expectDeniedColumnsUntouched(server); } finally { if (server) await server.close(); await pg.stop(); @@ -42,9 +43,8 @@ describe("api contract secret fields (#484) — GENERATED routes lane", () => { }, { timeout: 60_000 }); } - // The credential's write path cannot be observed over HTTP (a credential is never read), so - // pin it on the emitted source: both marked fields are denied on write, only the credential - // is hidden on read. + // Supplements the stored-row read-back above: both marked fields are denied on write, only the + // credential is hidden on read. test("generated route names the denied and hidden columns", async () => { const pg = await startPostgres(); let server: GeneratedSecretFieldsServerHandle | null = null; @@ -75,3 +75,18 @@ async function runScenario(scenario: ApiScenario, server: GeneratedSecretFieldsS assertResponse(scenario.name, req, res.status, body); } } + +/** A credential is never read over HTTP, so prove the write was ignored on the stored rows. */ +async function expectDeniedColumnsUntouched(server: GeneratedSecretFieldsServerHandle): Promise { + const stored = await server.storedRows(); + for (const seeded of SEED.rows) { + expect(stored.find((r) => r.id === seeded.id)).toEqual({ + id: seeded.id, + tokenHash: seeded.tokenHash, + pinHash: seeded.pinHash, + }); + } + const created = stored.filter((r) => !SEED.rows.some((s) => s.id === r.id)); + expect(created.length).toBeGreaterThan(0); + for (const row of created) expect(row).toMatchObject({ tokenHash: null, pinHash: null }); +} From 16f44a62b2bc4c2f86160d073d6ca433cba83257 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:00:40 -0400 Subject: [PATCH 05/16] no-mistakes(review): apply reviewer inline fixes --- docs/recipes/drizzle-kit-migrations.md | 3 ++- .../api-contract-conformance/secret-fields/README.md | 9 +++++---- .../secret-fields/scenarios/write-ignores-excluded.yaml | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/recipes/drizzle-kit-migrations.md b/docs/recipes/drizzle-kit-migrations.md index e4d663e70a..b13bb998f5 100644 --- a/docs/recipes/drizzle-kit-migrations.md +++ b/docs/recipes/drizzle-kit-migrations.md @@ -28,7 +28,8 @@ schema file that exports them. `meta migrate` never has to run. ```ts // src/db/schema.ts - export { customers, orders } from "../generated/Customer.js"; // name the tables you want migrated + export { customers } from "../generated/Customer.js"; // name the tables you want migrated + export { orders } from "../generated/Order.js"; // or, to follow the model as it grows: // export * from "../generated/index.js"; export * from "./hand-written-tables.js"; diff --git a/fixtures/api-contract-conformance/secret-fields/README.md b/fixtures/api-contract-conformance/secret-fields/README.md index 6e3326cdf4..29bc632038 100644 --- a/fixtures/api-contract-conformance/secret-fields/README.md +++ b/fixtures/api-contract-conformance/secret-fields/README.md @@ -27,11 +27,12 @@ The model is one `Account`: - The application still writes these fields through its own code (the generated queries, the repository, the ORM). Only the generated HTTP boundary refuses them. -## Why `pinHash` is not asserted on the way in +## How `pinHash`'s write is asserted -A credential is never read, so no scenario can observe whether a write reached it. Its write -path is the same predicate as `tokenHash`'s (`@formExclude` or credential), and each port's -codegen tests pin that the generated create and update paths name both fields. +A credential is never read, so no scenario can observe over HTTP whether a write reached it. +Instead, each port's runner reads the stored rows back after `write-ignores-excluded` (from the +database, or from the in-memory repository behind the generated controller): the seeded rows +still hold their seeded `tokenHash` and `pinHash`, and the created row holds `null` in both. ## Files diff --git a/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml b/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml index bb0762f734..05f5e36306 100644 --- a/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml +++ b/fixtures/api-contract-conformance/secret-fields/scenarios/write-ignores-excluded.yaml @@ -9,8 +9,8 @@ description: > The ordinary `name` rides in each body DELIBERATELY: it is applied, and that pairing is what makes the assertion about the field and not about the request being ignored whole. `tokenHash` is the observable one (it is still returned); `pinHash` is hidden on read, - so its write is pinned by the read side (`read-omits-credential`) together with the - per-port codegen tests of the generated write path. + so each port's runner pins its write by reading the stored rows back after this + scenario (seeded values unchanged, null on the created row). requests: - id: r1 method: PATCH From 525bb5c35d903f02baf2e730c8dc11b8f183e727 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:07:51 -0400 Subject: [PATCH 06/16] no-mistakes(review): Trim codegen-ts barrel, note required-denied create, behavioral projection test --- CHANGELOG.md | 5 +- .../spring/SpringSecretFieldsCodegenTest.java | 12 ---- ...dsGeneratedApiContractConformanceTest.java | 25 ++++++++ .../GeneratedAccountControllerHarness.java | 58 ++++++++++++++++--- .../InMemoryProjectionRepositorySource.java | 18 ++++-- .../packages/codegen-ts/src/index.ts | 4 +- ...contract-secret-fields-generated-server.ts | 4 -- .../test/api-contract-secret-fields.test.ts | 15 ----- 8 files changed, 95 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67191cb071..cd55e91f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,7 +107,10 @@ here.** hashes. Generated forms (React, Angular) and the api docs omit credentials too. Your own code is unaffected: the entity, DTO and generated queries still carry the field, so you keep writing a hash or token yourself. A project with neither mark generates byte-identical output. If you relied on a route writing a - `@formExclude` field, write it from your own code. Gated by the new `secret-fields/` sub-corpus + `@formExclude` field, write it from your own code. A field that is `@required` and also + `@formExclude` or a credential now makes the generated create route fail, because the route + can no longer supply its value: make the field optional, or create that entity from your own + code (see "Credentials and excluded fields" in `docs/features/api-contract.md`). Gated by the new `secret-fields/` sub-corpus of the api-contract conformance corpus, in the generated lane of all five ports. - **`meta gen` and `meta verify --codegen` refuse an unknown codegen `dialect`** instead of silently diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java index dfe8b6fe8a..5ff75c4a75 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java @@ -109,18 +109,6 @@ private static String projection(String extra) { return MARKED.substring(0, entityEnd + 3) + ",\n" + body + "\n" + MARKED.substring(entityEnd + 3); } - @Test - public void projectionInheritingACredentialHidesItOnListAndGet() throws Exception { - String controller = generate("proj", projection( - "{ \"field.string\": { \"name\": \"pinHash\", \"extends\": \"Account.pinHash\" } },"), - "AccountViewController.java", false); - assertTrue("list hides the credential; saw:\n" + controller, - controller.contains("redactAll(rows, \"pinHash\")")); - assertTrue("get hides the credential", controller.contains("redact(r, \"pinHash\")")); - assertTrue("the mapper is injected only for this case", - controller.contains("ObjectMapper objectMapper) {")); - } - @Test public void projectionWithoutACredentialIsUnchanged() throws Exception { String controller = generate("projplain", projection( diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java index 1401006d60..b8200e5b7e 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/SecretFieldsGeneratedApiContractConformanceTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -20,6 +21,8 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; /** * #484 — the Java GENERATED-controller lane for the {@code secret-fields} api-contract @@ -80,6 +83,28 @@ void scenario(String name, ApiScenario scenario) { }); } + /** + * A read-only projection field that extends {@code Account.pinHash} inherits the credential, so + * the projection's generated list and get never return it either (the corpus README: a + * credential is never returned). The other fields still are. + */ + @Test + @SuppressWarnings("unchecked") + void projectionInheritingACredentialNeverReturnsIt() throws Exception { + HARNESS.reset(); + GeneratedAccountControllerHarness.Response list = HARNESS.exchange("GET", "/api/account_views", null); + assertEquals(200, list.status(), list.body()); + List> rows = (List>) HARNESS.parseBody(list.body()); + assertEquals(List.of("Ada Lovelace", "Alan Turing"), rows.stream().map(r -> r.get("name")).toList()); + for (Map row : rows) assertFalse(row.containsKey("pinHash"), list.body()); + + GeneratedAccountControllerHarness.Response one = HARNESS.exchange("GET", "/api/account_views/1", null); + assertEquals(200, one.status(), one.body()); + Map row = (Map) HARNESS.parseBody(one.body()); + assertEquals("Ada Lovelace", row.get("name")); + assertFalse(row.containsKey("pinHash"), one.body()); + } + /** * The stored rows, read from the repository seam: a seeded row still holds its seed * {@code tokenHash} and {@code pinHash} (neither was writable), and a row the scenario created diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java index f11f4184be..36925cef6e 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedAccountControllerHarness.java @@ -36,18 +36,39 @@ /** * #484 — host the GENERATED Spring {@code AccountController} of the {@code secret-fields} * sub-corpus over real HTTP (embedded Tomcat) and drive its scenarios. Sibling of - * {@link GeneratedAuthorControllerHarness}; the only hand-written piece is - * {@link InMemoryAccountRepositorySource}, the consumer seam. + * {@link GeneratedAuthorControllerHarness}; the only hand-written pieces are + * {@link InMemoryAccountRepositorySource} and {@link InMemoryProjectionRepositorySource}, the consumer seams. + * + *

Beside the corpus model it loads {@link #ACCOUNT_VIEW}, a read-only projection whose + * {@code pinHash} extends {@code Account.pinHash}, and mounts its generated controller too, so the + * read-only path's credential redaction is driven over HTTP as well.

*/ public final class GeneratedAccountControllerHarness implements AutoCloseable { private static final String ENTITY_PKG = "acme.secrets"; + private static final String VIEW_NAME = "AccountView"; + + /** A view-backed projection of {@code Account} that inherits the credential. */ + private static final String ACCOUNT_VIEW = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.projection": { "name": "AccountView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_account" } }, + { "field.long": { "name": "id", "extends": "Account.id" } }, + { "field.string": { "name": "name", "extends": "Account.name" } }, + { "field.string": { "name": "pinHash", "extends": "Account.pinHash" } }, + { "identity.primary": { "name": "id", "extends": "Account.id" } } + ] } } + ] } } + """; private final ObjectMapper mapper = new ObjectMapper(); private final URLClassLoader classLoader; private final Class dtoClass; private final Constructor controllerCtor; private final Constructor repoCtor; + private final Class viewDtoClass; + private final Constructor viewControllerCtor; + private final Constructor viewRepoCtor; private final List> seedRows; private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); @@ -66,7 +87,9 @@ public GeneratedAccountControllerHarness(Path corpusRoot, Path configRoot, Path Files.createDirectories(srcDir); Files.createDirectories(classesDir); - MetaDataLoader loader = loadCorpus(corpusRoot.resolve("meta.json")); + Path viewModel = genDir.resolve("account-view.json"); + Files.writeString(viewModel, ACCOUNT_VIEW); + MetaDataLoader loader = loadCorpus(corpusRoot.resolve("meta.json"), viewModel); loader.getRoot().setPhysicalNaming( com.metaobjects.config.SourceResolver.resolvePhysicalNaming(configRoot)); loader.getRoot().setWireFormat( @@ -81,6 +104,8 @@ public GeneratedAccountControllerHarness(Path corpusRoot, Path configRoot, Path Path repoImpl = srcDir.resolve(ENTITY_PKG.replace('.', '/')).resolve("InMemoryAccountRepository.java"); Files.createDirectories(repoImpl.getParent()); Files.writeString(repoImpl, InMemoryAccountRepositorySource.SOURCE); + Files.writeString(repoImpl.resolveSibling(InMemoryProjectionRepositorySource.simpleName(VIEW_NAME) + ".java"), + InMemoryProjectionRepositorySource.source(ENTITY_PKG, VIEW_NAME, "id", "Long")); compile(srcDir, classesDir); @@ -92,6 +117,11 @@ public GeneratedAccountControllerHarness(Path corpusRoot, Path configRoot, Path .getDeclaredConstructor(repoInterface, ObjectMapper.class, Validator.class); this.repoCtor = classLoader.loadClass(InMemoryAccountRepositorySource.FQCN) .getDeclaredConstructor(List.class); + this.viewDtoClass = classLoader.loadClass(ENTITY_PKG + "." + VIEW_NAME + "Dto"); + this.viewControllerCtor = classLoader.loadClass(ENTITY_PKG + "." + VIEW_NAME + "Controller") + .getDeclaredConstructors()[0]; + this.viewRepoCtor = classLoader.loadClass(InMemoryProjectionRepositorySource.fqcn(ENTITY_PKG, VIEW_NAME)) + .getDeclaredConstructor(List.class); } /** Re-seed for a scenario: a fresh repository, controller and Tomcat. */ @@ -100,8 +130,18 @@ public void reset() throws Exception { for (Map row : seedRows) dtos.add(mapper.convertValue(row, dtoClass)); this.repo = repoCtor.newInstance(dtos); Object controller = controllerCtor.newInstance(repo, mapper, validator); + List viewDtos = new ArrayList<>(); + for (Map row : seedRows) { + Map view = new java.util.LinkedHashMap<>(); + for (String col : List.of("id", "name", "pinHash")) view.put(col, row.get(col)); + viewDtos.add(mapper.convertValue(view, viewDtoClass)); + } + Object viewRepo = viewRepoCtor.newInstance(viewDtos); + Object viewController = viewControllerCtor.getParameterCount() == 1 + ? viewControllerCtor.newInstance(viewRepo) + : viewControllerCtor.newInstance(viewRepo, mapper); if (host != null) host.close(); - this.host = TomcatHost.start(mapper, controller); + this.host = TomcatHost.start(mapper, controller, viewController); } public Response exchange(String method, String path, Object jsonBody) throws Exception { @@ -143,14 +183,16 @@ public void close() throws Exception { public record Response(int status, String body) {} - private static MetaDataLoader loadCorpus(Path metaJson) { - URI uri = URIHelper.toURI( - "model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/')); + private static MetaDataLoader loadCorpus(Path... metaJsons) { + List uris = new ArrayList<>(); + for (Path metaJson : metaJsons) { + uris.add(URIHelper.toURI("model:file:" + metaJson.toAbsolutePath().toString().replace('\\', '/'))); + } MetaDataLoader loader = new MetaDataLoader( LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "api-contract-secret-fields-generated"); - loader.setSourceURIs(List.of(uri)); + loader.setSourceURIs(uris); loader.init(); return loader; } diff --git a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java index 7c2e5f5a15..40fd9a3df3 100644 --- a/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java +++ b/server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/InMemoryProjectionRepositorySource.java @@ -1,7 +1,7 @@ package com.metaobjects.integration.api.generated; /** - * The Java SOURCE for an in-memory {@code acme.sales.Repository} impl: ONE generic + * The Java SOURCE for an in-memory {@code .Repository} impl: ONE generic * template, instantiated once per mounted projection by substituting its name. Emitted * alongside the GENERATED controller / DTO / interface so it compiles against them, then * loaded and instantiated reflectively by {@link GeneratedProjectionControllerHarness}. @@ -38,7 +38,12 @@ static String simpleName(String projection) { /** Fully-qualified name of the emitted impl for {@code projection}. */ static String fqcn(String projection) { - return PKG + "." + simpleName(projection); + return fqcn(PKG, projection); + } + + /** Fully-qualified name of the emitted impl for {@code projection} in package {@code pkg}. */ + static String fqcn(String pkg, String projection) { + return pkg + "." + simpleName(projection); } /** @@ -47,6 +52,11 @@ static String fqcn(String projection) { * for a keyless projection, whose repository has no {@code findById} to implement. */ static String source(String projection, String keyComponent, String keyType) { + return source(PKG, projection, keyComponent, keyType); + } + + /** {@link #source(String, String, String)} for a projection generated into package {@code pkg}. */ + static String source(String pkg, String projection, String keyComponent, String keyType) { String findById = keyComponent == null ? "" : """ @Override @@ -55,11 +65,11 @@ public Optional<__NAME__Dto> findById(__KEY_TYPE__ id) { return Optional.empty(); } """.replace("__KEY__", keyComponent).replace("__KEY_TYPE__", keyType); - return TEMPLATE.replace(FIND_BY_ID, findById).replace(NAME, projection); + return TEMPLATE.replace(FIND_BY_ID, findById).replace(NAME, projection).replace("__PKG__", pkg); } private static final String TEMPLATE = """ - package acme.sales; + package __PKG__; import com.metaobjects.generator.spring.runtime.FilterPredicate; diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 016964a5fd..b5218a4d83 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -55,7 +55,7 @@ export { } from "./catalog-gates.js"; export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, ApiSurface, VerifyConfig } from "./metaobjects-config.js"; -export { defineConfig, normalizeConfig, assertCodegenDialect, CODEGEN_DIALECTS, resolveGenerators, removedGeneratorMessage } from "./metaobjects-config.js"; +export { defineConfig, normalizeConfig, resolveGenerators, removedGeneratorMessage } from "./metaobjects-config.js"; // The dialect every generator falls back to when a config declares none, and where a // project's documentation goes when it has not chosen (`targets.docs`). export { DEFAULT_DIALECT, DEFAULT_DOCS_DIR } from "./metaobjects-config.js"; @@ -142,7 +142,7 @@ export { renderAgentApi, renderApiIndex, renderEntityApiPage } from "./generator export { isProjection, isWriteThrough } from "./projection/projection-detector.js"; export { isAbstract, emitsInstanceArtifacts, emitsWriteArtifacts } from "./instance-artifacts.js"; // The UI tier asks THESE — "is there an endpoint?" — never the storage predicates. -export { hasGeneratedForm, hasItemRoute, decimalColumnsLine, writeDeniedColumnsLine, isCredentialField, isApiWriteDenied, credentialFieldNames, apiWriteDeniedFieldNames, decimalFieldNames, itemRouteField, restPath, servesClientHooks, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js"; +export { hasGeneratedForm, hasItemRoute, decimalColumnsLine, writeDeniedColumnsLine, isCredentialField, decimalFieldNames, itemRouteField, restPath, servesClientHooks, servesClientTier, servesReadApi, servesWriteApi } from "./api-surface.js"; // #356 — every emitter selects a field's view by the SURFACE it renders, never by // declaration position. An owned generator (FR-040) composing the render layer must // use this too, or it reinstates the order-dependence in its own copy. diff --git a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts index b02cf11bfe..7ac74bf7f4 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts @@ -24,8 +24,6 @@ export interface SecretFieldsSeed { export interface GeneratedSecretFieldsServerHandle { baseUrl: string; - /** The generated source of Account.routes.ts, for codegen assertions. */ - routesSource: string; applySeed(seed: SecretFieldsSeed): Promise; /** The stored accounts, read straight from the table (the HTTP API never returns a credential). */ storedRows(): Promise>; @@ -87,7 +85,6 @@ export const db = drizzle(pool); accountRoutes: (f: FastifyInstance) => Promise; }; const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool }; - const routesSource = (await import("node:fs")).readFileSync(routesPath, "utf8"); const fastify = Fastify(); await fastify.register(accountRoutes.accountRoutes); @@ -96,7 +93,6 @@ export const db = drizzle(pool); return { baseUrl, - routesSource, applySeed: async (seed: SecretFieldsSeed) => { await executeSql(connectionUri, `TRUNCATE TABLE "accounts" RESTART IDENTITY;`); for (const r of seed.rows) diff --git a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts index f0023bc41b..f94480020c 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts @@ -42,21 +42,6 @@ describe("api contract secret fields (#484) — GENERATED routes lane", () => { } }, { timeout: 60_000 }); } - - // Supplements the stored-row read-back above: both marked fields are denied on write, only the - // credential is hidden on read. - test("generated route names the denied and hidden columns", async () => { - const pg = await startPostgres(); - let server: GeneratedSecretFieldsServerHandle | null = null; - try { - server = await startGeneratedSecretFieldsServer(pg.connectionUri, META_PATH); - expect(server.routesSource).toContain('writeDeniedColumns: ["tokenHash", "pinHash"]'); - expect(server.routesSource).toContain('hiddenColumns: ["pinHash"]'); - } finally { - if (server) await server.close(); - await pg.stop(); - } - }, { timeout: 60_000 }); }); async function runScenario(scenario: ApiScenario, server: GeneratedSecretFieldsServerHandle): Promise { From 63df902053e5fd8c9ac78638e9fd30d59004a010 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:14:24 -0400 Subject: [PATCH 07/16] no-mistakes(review): Add behavioral credential checks for TS/Python projection and TPH M:N --- .../test_m2m_target_tph_generated.py | 18 ++++- ...contract-secret-fields-generated-server.ts | 81 +++++++++++++++++-- .../integration-tests/src/load-metadata.ts | 7 +- .../test/api-contract-secret-fields.test.ts | 33 ++++++++ 4 files changed, 128 insertions(+), 11 deletions(-) diff --git a/server/python/tests/integration/test_m2m_target_tph_generated.py b/server/python/tests/integration/test_m2m_target_tph_generated.py index c4427c067f..95c0bef69c 100644 --- a/server/python/tests/integration/test_m2m_target_tph_generated.py +++ b/server/python/tests/integration/test_m2m_target_tph_generated.py @@ -63,6 +63,8 @@ "@discriminatorValue": "Bridge", "children": [ {"field.int": {"name": "quantity", "@required": True}}, + {"field.string": {"name": "pinHash", "@maxLength": 80, + "children": [{"view.password": {}}]}}, ], }}, {"object.entity": { @@ -241,7 +243,7 @@ def _seed(repo: _SponsorRepo) -> None: repo.seed( sponsors=[{"id": 1, "name": "Acme"}], auths=[ - {"id": 10, "type": "Bridge", "reference": "b1", "quantity": 3}, + {"id": 10, "type": "Bridge", "reference": "b1", "quantity": 3, "pinHash": "bridge-pin"}, {"id": 11, "type": "Copay", "reference": "c1", "payer": "acme"}, ], sponsor_auths=[(1, 10), (1, 11)], @@ -262,6 +264,20 @@ def test_compliant_repo_filters_target_to_the_resolved_discriminator() -> None: assert [r["id"] for r in resp.json()] == [10] +def test_target_subtype_credential_is_never_returned() -> None: + # #484: BridgeAuth declares pinHash as a credential (view.password). The repo hands the + # router the stored row with the hash in it; the traversal must leave the key out + # entirely (the secret-fields corpus contract: a credential is never returned). + app, repo = _build_app(_SponsorRepo) + _seed(repo) + with TestClient(app) as client: + resp = client.get("/api/sponsors/1/bridgeAuths") + assert resp.status_code == 200 + rows = resp.json() + assert [(r["id"], r["quantity"]) for r in rows] == [(10, 3)] + assert all("pinHash" not in r for r in rows), rows + + def test_router_threads_the_correct_literal_even_when_repo_ignores_it() -> None: # Demonstrates the boundary explicitly: _HostileSponsorRepo ignores # target_subtype and returns BOTH joined rows (10 AND 11) — the router has no diff --git a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts index 7ac74bf7f4..59885dde10 100644 --- a/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts +++ b/server/typescript/packages/integration-tests/src/api-contract-secret-fields-generated-server.ts @@ -6,6 +6,11 @@ // Account.routes.ts unmodified and mount it. What is under test is whether the generated // route strips `@formExclude` / credential keys from a write body and omits a credential // from every row it sends. +// +// Beside the corpus model it loads READ_PATHS: a view projection whose `pinHash` extends +// `Account.pinHash`, and an M:N from Payer onto the TPH base Auth whose subtype BridgeAuth +// declares a credential. Their emitted routes are mounted too, so the credential redaction of +// the projection and the M:N traversal is driven over HTTP as well. import Fastify, { type FastifyInstance } from "fastify"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -18,6 +23,47 @@ import { executeSql } from "./postgres-sql.ts"; import { DEFAULT_PHYSICAL_NAMING } from "@metaobjectsdev/metadata"; import { loadMetadataFile } from "./load-metadata.ts"; +const READ_PATHS = { + "metadata.root": { + package: "acme::secrets", + children: [ + { "object.projection": { name: "AccountView", children: [ + { "source.rdb": { "@kind": "view", "@view": "v_account" } }, + { "field.long": { name: "id", extends: "Account.id" } }, + { "field.string": { name: "name", extends: "Account.name" } }, + { "field.string": { name: "pinHash", extends: "Account.pinHash" } }, + { "identity.primary": { name: "id", extends: "Account.id" } }, + ] } }, + { "object.entity": { name: "Payer", children: [ + { "source.rdb": { "@table": "payers" } }, + { "field.long": { name: "id" } }, + { "relationship.association": { name: "auths", "@cardinality": "many", "@objectRef": "Auth", "@through": "PayerAuth" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Auth", "@discriminator": "type", children: [ + { "source.rdb": { "@table": "auths" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "type", "@values": ["Bridge", "Copay"] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "BridgeAuth", extends: "Auth", "@discriminatorValue": "Bridge", children: [ + { "field.string": { name: "pinHash", "@maxLength": 100, children: [{ "view.password": {} }] } }, + ] } }, + { "object.entity": { name: "CopayAuth", extends: "Auth", "@discriminatorValue": "Copay", children: [ + { "field.int": { name: "copayCents" } }, + ] } }, + { "object.entity": { name: "PayerAuth", children: [ + { "source.rdb": { "@table": "payer_auths" } }, + { "field.long": { name: "payerId", "@required": true } }, + { "field.long": { name: "authId", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": ["payerId", "authId"] } }, + { "identity.reference": { name: "fkPayer", "@fields": "payerId", "@references": "Payer" } }, + { "identity.reference": { name: "fkAuth", "@fields": "authId", "@references": "Auth" } }, + ] } }, + ], + }, +}; + export interface SecretFieldsSeed { rows: Array<{ id: number; name: string; tokenHash: string; pinHash: string }>; } @@ -39,7 +85,9 @@ export async function startGeneratedSecretFieldsServer( mkdirSync(genTmpRoot, { recursive: true }); const tmp = mkdtempSync(join(genTmpRoot, "api-contract-secret-fields-")); - const root = await loadMetadataFile(metaPath); + const readPaths = join(tmp, "read-paths.meta.json"); + writeFileSync(readPaths, JSON.stringify(READ_PATHS), "utf8"); + const root = await loadMetadataFile(metaPath, readPaths); // The lane provisions its table below in the generated Drizzle table's default spelling. root.physicalNaming = DEFAULT_PHYSICAL_NAMING; const lr = await runGen({ @@ -77,17 +125,36 @@ export const db = drizzle(pool); "name" varchar(100) NOT NULL, "token_hash" varchar(100), "pin_hash" varchar(100) - );`, + ); + CREATE OR REPLACE VIEW "v_account" AS SELECT "id", "name", "pin_hash" FROM "accounts"; + CREATE TABLE IF NOT EXISTS "payers" ("id" bigserial PRIMARY KEY); + CREATE TABLE IF NOT EXISTS "auths" ( + "id" bigserial PRIMARY KEY, + "type" varchar(16) NOT NULL, + "pin_hash" varchar(100), + "copay_cents" integer + ); + CREATE TABLE IF NOT EXISTS "payer_auths" ( + "payer_id" bigint NOT NULL, + "auth_id" bigint NOT NULL, + PRIMARY KEY ("payer_id", "auth_id") + ); + INSERT INTO "payers" ("id") VALUES (1); + INSERT INTO "auths" ("id", "type", "pin_hash", "copay_cents") VALUES (1, 'Bridge', 'auth-pin-1', NULL), (2, 'Copay', NULL, 500); + INSERT INTO "payer_auths" ("payer_id", "auth_id") VALUES (1, 1), (1, 2);`, ); - const routesPath = join(tmp, "Account.routes.ts"); - const accountRoutes = (await import(pathToFileURL(routesPath).href)) as { - accountRoutes: (f: FastifyInstance) => Promise; - }; const dbMod = (await import(pathToFileURL(join(tmp, "db.ts")).href)) as { pool: pg.Pool }; const fastify = Fastify(); - await fastify.register(accountRoutes.accountRoutes); + for (const [file, plugin] of [ + ["Account.routes.ts", "accountRoutes"], + ["AccountView.routes.ts", "accountViewRoutes"], + ["Payer.routes.ts", "payerRoutes"], + ] as const) { + const mod = (await import(pathToFileURL(join(tmp, file)).href)) as Record Promise>; + await fastify.register(mod[plugin]!); + } await fastify.ready(); const baseUrl = await fastify.listen({ host: "127.0.0.1", port: 0 }); diff --git a/server/typescript/packages/integration-tests/src/load-metadata.ts b/server/typescript/packages/integration-tests/src/load-metadata.ts index 197aeb18b3..cb4d6c4ecb 100644 --- a/server/typescript/packages/integration-tests/src/load-metadata.ts +++ b/server/typescript/packages/integration-tests/src/load-metadata.ts @@ -52,13 +52,14 @@ export async function loadMetadataDir(dir: string): Promise { } /** - * Load a single metadata file (URI) through the standard loader. Use when + * Load a metadata file (URI), plus any `extra` files merged into the same root, through the + * standard loader. Use when * the surrounding directory contains non-metadata files (scenario YAML, * seed JSON) that the directory loader would mistakenly try to parse. */ -export async function loadMetadataFile(path: string): Promise { +export async function loadMetadataFile(path: string, ...extra: string[]): Promise { // loadUris requires a URI scheme; convert the absolute path to file://. - const result = await loadUris([pathToFileURL(path).href]); + const result = await loadUris([path, ...extra].map((p) => pathToFileURL(p).href)); if (result.errors.length > 0) { const summary = result.errors .map((e) => `${(e as { code?: string }).code ?? "ERROR"}: ${e.message}`) diff --git a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts index f94480020c..e9d9f3827b 100644 --- a/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts +++ b/server/typescript/packages/integration-tests/test/api-contract-secret-fields.test.ts @@ -42,8 +42,41 @@ describe("api contract secret fields (#484) — GENERATED routes lane", () => { } }, { timeout: 60_000 }); } + + // The corpus README contract (a credential is never returned) on the two read paths the + // corpus's Account routes do not reach: a projection field that extends `Account.pinHash`, and + // an M:N traversal onto a TPH base whose subtype declares a credential. + test("projection and M:N-onto-TPH reads never return an inherited credential", async () => { + const pg = await startPostgres(); + let server: GeneratedSecretFieldsServerHandle | null = null; + try { + server = await startGeneratedSecretFieldsServer(pg.connectionUri, META_PATH); + await server.applySeed(SEED); + + const list = await getJson(server, "/api/account_views"); + expect(list.map((r) => r.name)).toEqual(["Ada Lovelace", "Alan Turing"]); + for (const row of list) expect(Object.keys(row)).not.toContain("pinHash"); + const one = (await getJson(server, "/api/account_views/1")) as unknown as Record; + expect(one.name).toBe("Ada Lovelace"); + expect(Object.keys(one)).not.toContain("pinHash"); + + const auths = await getJson(server, "/api/payers/1/auths"); + expect(auths.map((r) => [r.id, r.type]).sort()).toEqual([[1, "Bridge"], [2, "Copay"]]); + for (const row of auths) expect(Object.keys(row)).not.toContain("pinHash"); + } finally { + if (server) await server.close(); + await pg.stop(); + } + }, { timeout: 60_000 }); }); +async function getJson(server: GeneratedSecretFieldsServerHandle, path: string): Promise>> { + const res = await fetch(server.baseUrl + path); + const text = await res.text(); + expect(res.status, text).toBe(200); + return JSON.parse(text) as Array>; +} + async function runScenario(scenario: ApiScenario, server: GeneratedSecretFieldsServerHandle): Promise { for (const req of scenario.requests) { const init: RequestInit = { method: req.method }; From ffcc8e93a886f71f1be6660817fca82027761327 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:23:35 -0400 Subject: [PATCH 08/16] no-mistakes(review): Count TPH credential and denied marks on every subtype --- .../generator/kotlin/KotlinM2mSupport.kt | 2 +- .../kotlin/KotlinSpringControllerGenerator.kt | 8 +++-- .../generator/kotlin/KotlinTphPlan.kt | 13 +++++++ .../kotlin/KotlinSecretFieldsTest.kt | 35 +++++++++++++++++++ .../spring/SpringControllerGenerator.java | 22 +++++++----- .../spring/SpringGeneratorSupport.java | 11 ++++-- .../metaobjects/generator/spring/TphPlan.java | 13 +++++++ .../spring/SpringSecretFieldsCodegenTest.java | 35 +++++++++++++++++++ 8 files changed, 124 insertions(+), 15 deletions(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index 5a3fadce5a..5a992ba815 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -201,7 +201,7 @@ object KotlinM2mSupport { targetDiscriminator = targetDiscriminator, symmetric = rel.isSymmetric, junctionPackage = junctionPkg, - targetCredentialFields = (target.metaFields + KotlinTphPlan.collectSubtypeFields(target, loader)) + targetCredentialFields = (target.metaFields + KotlinTphPlan.everySubtypeField(target, loader)) .filter { KotlinGenUtil.isCredentialField(it) }.map { it.name }.toSet(), ) ) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 560aa5bb5c..81e7ad54e6 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -233,7 +233,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase>): List = - fields.filter { KotlinGenUtil.isCredentialField(it) }.map { it.name } + fields.filter { KotlinGenUtil.isCredentialField(it) }.map { it.name }.distinct() protected open fun emit(entity: MetaObject, outRoot: Path, loader: MetaDataLoader) { val api = exposedApi() @@ -788,11 +788,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase scalarFields.none { it.name == name && KotlinGenUtil.isApiWriteDenied(it) } } + .filter { name -> markFields.none { it.name == name && KotlinGenUtil.isApiWriteDenied(it) } } // FR-035/FR-036 Program B: the per-subtype settable columns = the subtype's EFFECTIVE scalar // fields (base + own, resolved via extends) minus the PK and the discriminator, EXCLUDING @@ -826,7 +828,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase navs.any { it.targetCredentialFields.isNotEmpty() } } diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt index d679d2fb0d..b24b5b59fb 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt @@ -193,6 +193,19 @@ object KotlinTphPlan { return collectSubtypeFields(base, plan) } + /** + * #484: every field of every subtype, base columns included and NOT deduplicated by name, so a + * credential or `@formExclude` on any one declaration of a shared column counts. Empty when + * [base] is not a TPH base. + */ + fun everySubtypeField(base: MetaObject, loader: MetaDataLoader): List> { + val plan = planFor(base, loader) ?: return emptyList() + return everySubtypeField(plan) + } + + /** [everySubtypeField] from an already-resolved [Plan]. */ + fun everySubtypeField(plan: Plan): List> = plan.subtypes.flatMap { it.entity.metaFields } + /** [collectSubtypeFields] from an already-resolved [Plan] (the generators that hold one). */ fun collectSubtypeFields(base: MetaObject, plan: Plan): List> { val baseNames = base.metaFields.map { it.name }.toHashSet() diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt index fc2ab0c762..78adc1f81d 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt @@ -61,6 +61,41 @@ class KotlinSecretFieldsTest { assertTrue("remove(listOf(\"pinHash\"))" in controller, controller) } + /** A TPH base whose subtypes share `token` and `memo`: plain on AThing (first in subtype order), marked on BThing. */ + private val tphSharedColumn = """{ + "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Thing", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "things" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["A", "B"] } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ] } }, + { "object.entity": { "name": "AThing", "extends": "Thing", "@discriminatorValue": "A", "children": [ + { "field.string": { "name": "token", "@maxLength": 100 } }, + { "field.string": { "name": "memo", "@maxLength": 100 } } + ] } }, + { "object.entity": { "name": "BThing", "extends": "Thing", "@discriminatorValue": "B", "children": [ + { "field.string": { "name": "token", "@maxLength": 100, "children": [ { "view.password": {} } ] } }, + { "field.string": { "name": "memo", "@maxLength": 100, "@formExclude": true } } + ] } } + ] } + }""".trimIndent() + + @Test + fun `a TPH column marked on a later subtype is hidden and never bound on create`() { + val out = Files.createTempDirectory("ksecret-tph-") + try { + val loader = loadString("secret-fields-tph-shared", tphSharedColumn) + KotlinSpringControllerGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) + val controller = Files.readString(out.resolve("acme/secrets/ThingController.kt")) + assertTrue("remove(listOf(\"token\"))" in controller, controller) + assertFalse("it[token] = dto.token" in controller, controller) + assertFalse("it[memo] = dto.memo" in controller, controller) + } finally { + out.toFile().deleteRecursively() + } + } + @Test fun `a model with neither mark generates the plain write and read paths`() { val (controller, entity) = controllerAndEntity(marked = false) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 0d61150508..86249961db 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -36,6 +36,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.metaobjects.generator.util.GeneratedFileWriter; @@ -342,7 +343,8 @@ protected void emit(MetaObject entity, Path outRoot) { src.append(" public ResponseEntity create(@RequestBody ").append(dtoName).append(" dto) {\n"); // #484: a @formExclude / credential value is IGNORED (never rejected): the DTO is rebuilt // without it, so the column is left to the repository's default. - appendStripWriteDenied(src, dtoName, SpringGeneratorSupport.dtoComponentFields(entity)); + List components = SpringGeneratorSupport.dtoComponentFields(entity); + appendStripWriteDenied(src, dtoName, components, SpringGeneratorSupport.writeDeniedNames(components)); // A caller does not supply server-owned columns, so a @NotNull on one must not reject // the request. The DTO is BOTH the request and the response body, so `id` carries // @NotNull because it is @required in the RESPONSE — while being identity.primary @@ -1196,7 +1198,10 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { for (MetaField f : TphPlan.collectSubtypeFields(base, loader)) { if (!(f instanceof com.metaobjects.field.ObjectField)) unionFields.add(f); } - List hidden = SpringGeneratorSupport.credentialNames(unionFields); + List markFields = new ArrayList<>(unionFields); + markFields.addAll(TphPlan.everySubtypeField(base, loader)); + List hidden = SpringGeneratorSupport.credentialNames(markFields); + Set writeDenied = SpringGeneratorSupport.writeDeniedNames(markFields); List baseNavs = SpringM2mSupport.resolve(base, loader); boolean redacts = !hidden.isEmpty() || baseNavs.stream().anyMatch(n -> !targetCredentials(base, n).isEmpty()) || plan.subtypes().stream().anyMatch(st -> SpringM2mSupport.resolve(st.entity(), loader).stream() @@ -1362,7 +1367,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { // OWN @autoSet column is a documented non-goal (uniform with the Kotlin and Python // ports, both base-scoped): the column is simply never stamped, not a compile break. // #484: ignore (never reject) a @formExclude / credential value in the body. - appendStripWriteDenied(src, dtoName, unionFields); + appendStripWriteDenied(src, dtoName, unionFields, writeDenied); String createArg = SpringGeneratorSupport.hasAutoSetFields(base) ? dtoName + ".stampForInsert(dto)" : "dto"; src.append(" try {\n"); @@ -1631,22 +1636,21 @@ private static List targetCredentials(MetaObject source, SpringM2mSuppor // A TPH base target's rows are shared-table rows of every subtype, so a credential // declared on any subtype is hidden too. List fields = new ArrayList<>(SpringGeneratorSupport.dtoComponentFields(target)); - for (MetaField f : TphPlan.collectSubtypeFields(target, source.getLoader())) { - if (!(f instanceof ObjectField)) fields.add(f); - } + fields.addAll(TphPlan.everySubtypeField(target, source.getLoader())); return SpringGeneratorSupport.credentialNames(fields); } return List.of(); } /** The fields of a create body that a generated write route ignores (#484). */ - private static void appendStripWriteDenied(StringBuilder src, String dtoName, List components) { - if (components.stream().noneMatch(SpringGeneratorSupport::isApiWriteDenied)) return; + private static void appendStripWriteDenied(StringBuilder src, String dtoName, List components, + Set writeDenied) { + if (components.stream().noneMatch(f -> writeDenied.contains(f.getName()))) return; src.append(" dto = new ").append(dtoName).append("("); for (int i = 0; i < components.size(); i++) { MetaField f = components.get(i); if (i > 0) src.append(", "); - src.append(SpringGeneratorSupport.isApiWriteDenied(f) + src.append(writeDenied.contains(f.getName()) ? "null" : "dto." + SpringNaming.recordComponentName(f.getName()) + "()"); } src.append(");\n"); diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 5df10d80e9..26283d5204 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -114,10 +114,17 @@ public static boolean isApiWriteDenied(MetaField field) { return isCredentialField(field); } - /** #484 — the wire names of the credential fields among {@code fields}, in declared order. */ + /** #484 — the distinct wire names of the credential fields among {@code fields}, in declared order. */ public static List credentialNames(java.util.Collection fields) { - List out = new ArrayList<>(); + java.util.Set out = new java.util.LinkedHashSet<>(); for (MetaField f : fields) if (isCredentialField(f)) out.add(f.getName()); + return new ArrayList<>(out); + } + + /** #484 — the distinct wire names of the write-denied fields among {@code fields}. */ + public static java.util.Set writeDeniedNames(java.util.Collection fields) { + java.util.Set out = new java.util.LinkedHashSet<>(); + for (MetaField f : fields) if (isApiWriteDenied(f)) out.add(f.getName()); return out; } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java index 817dd2ce69..e3de1cb0dc 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java @@ -125,6 +125,19 @@ public static boolean isTphBase(MetaObject obj, MetaDataLoader loader) { return planFor(obj, loader) != null; } + /** + * #484 — every field of every subtype, base columns included and NOT deduplicated by name, so + * a credential or {@code @formExclude} on any one declaration of a shared column counts. Empty + * when {@code base} is not a TPH base. + */ + public static List everySubtypeField(MetaObject base, MetaDataLoader loader) { + Plan plan = planFor(base, loader); + if (plan == null) return List.of(); + List out = new ArrayList<>(); + for (Subtype st : plan.subtypes()) out.addAll(st.entity().getMetaFields()); + return out; + } + /** * The subtype-only fields folded into the base's single TPH table, deduplicated by name * across subtypes in name-sorted subtype order. Each is emitted by the caller as a nullable diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java index 5ff75c4a75..c15f34fd63 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java @@ -92,6 +92,41 @@ public void unmarkedModelEmitsNoRedactionAndNoStrip() throws Exception { assertFalse(controller.contains("dto = new AccountDto(")); } + /** + * A TPH base whose subtypes share the {@code token} and {@code memo} columns: plain on + * {@code AThing} (first in subtype order) and marked on {@code BThing}. + */ + private static final String TPH_SHARED_COLUMN = """ + { + "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Thing", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "things" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["A", "B"] } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } } + ] } }, + { "object.entity": { "name": "AThing", "extends": "Thing", "@discriminatorValue": "A", "children": [ + { "field.string": { "name": "token" } }, + { "field.string": { "name": "memo" } } + ] } }, + { "object.entity": { "name": "BThing", "extends": "Thing", "@discriminatorValue": "B", "children": [ + { "field.string": { "name": "token", "children": [ { "view.password": {} } ] } }, + { "field.string": { "name": "memo", "@formExclude": true } } + ] } } + ] } + } + """; + + @Test + public void tphSharedColumnMarkedOnALaterSubtypeIsHiddenAndIgnored() throws Exception { + String controller = generate("tphshared", TPH_SHARED_COLUMN, "ThingController.java", false); + assertTrue("the union list hides the credential; saw:\n" + controller, + controller.contains("redactAll(rows, \"token\")")); + assertTrue("every subtype create ignores both marked columns; saw:\n" + controller, + controller.contains("dto = new ThingDto(dto.id(), dto.kind(), null, null);")); + assertFalse("memo (@formExclude only) is still returned", controller.contains("\"memo\")")); + } + private static final String PROJECTION_HEAD = "{ \"object.projection\": { \"name\": \"AccountView\", \"children\": [\n" + " { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_account\" } },\n" From 20d3243e4c9fa4a77484d310dc9709b8669ecd97 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:29:32 -0400 Subject: [PATCH 09/16] no-mistakes(review): Deny hierarchy-marked TPH columns on every subtype PATCH --- .../kotlin/KotlinSpringControllerGenerator.kt | 3 ++- .../generator/kotlin/KotlinTphPlan.kt | 8 +++++++- .../generator/kotlin/KotlinSecretFieldsTest.kt | 16 ++++++++++++++++ .../spring/SpringControllerGenerator.java | 5 +++-- .../spring/SpringGeneratorSupport.java | 17 +++++++++++++++++ .../metaobjects/generator/spring/TphPlan.java | 2 +- .../spring/SpringSecretFieldsCodegenTest.java | 10 ++++++++++ 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 81e7ad54e6..7bd2f0ba6a 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -790,11 +790,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase markFields.none { it.name == name && KotlinGenUtil.isApiWriteDenied(it) } } + .filter { it !in writeDenied } // FR-035/FR-036 Program B: the per-subtype settable columns = the subtype's EFFECTIVE scalar // fields (base + own, resolved via extends) minus the PK and the discriminator, EXCLUDING diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt index b24b5b59fb..fb700ebd1a 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinTphPlan.kt @@ -163,6 +163,7 @@ object KotlinTphPlan { val base = discriminatorBaseOf(subtype) ?: return emptyList() val discField = base.getMetaAttr(MetaObject.ATTR_DISCRIMINATOR, false).valueAsString val pk = primaryKeyFieldName(base) + val denied = hierarchyWriteDeniedNames(base, base.loader) return subtype.metaFields.filterNot { it is ObjectField || it is MapField || KotlinTypeMapper.isJsonbOpenBag(it) }.filter { @@ -175,7 +176,7 @@ object KotlinTphPlan { // the Validation shape and the settable-name list all read it. !KotlinGenUtil.isReadOnlyMutability(it) && !KotlinGenUtil.isWriteOnceMutability(it) && - !KotlinGenUtil.isApiWriteDenied(it) // #484: @formExclude / credential, ignored on every write + it.name !in denied // #484: @formExclude / credential on any declaration, ignored on every write } } @@ -206,6 +207,11 @@ object KotlinTphPlan { /** [everySubtypeField] from an already-resolved [Plan]. */ fun everySubtypeField(plan: Plan): List> = plan.subtypes.flatMap { it.entity.metaFields } + /** #484: the names write-denied on ANY declaration in [base]'s hierarchy (the base and every subtype). */ + fun hierarchyWriteDeniedNames(base: MetaObject, loader: MetaDataLoader): Set = + (base.metaFields + everySubtypeField(base, loader)) + .filter { KotlinGenUtil.isApiWriteDenied(it) }.map { it.name }.toSet() + /** [collectSubtypeFields] from an already-resolved [Plan] (the generators that hold one). */ fun collectSubtypeFields(base: MetaObject, plan: Plan): List> { val baseNames = base.metaFields.map { it.name }.toHashSet() diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt index 78adc1f81d..f0190864ec 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinSecretFieldsTest.kt @@ -96,6 +96,22 @@ class KotlinSecretFieldsTest { } } + @Test + fun `a TPH column marked on a later subtype is never written by any subtype PATCH`() { + val out = Files.createTempDirectory("ksecret-tph-patch-") + try { + val loader = loadString("secret-fields-tph-patch", tphSharedColumn) + KotlinSpringControllerGenerator().apply { setArgs(mapOf("outputDir" to out.toString())) }.execute(loader) + val controller = Files.readString(out.resolve("acme/secrets/ThingController.kt")) + for (f in listOf("token", "memo")) { + assertFalse("body.has(\"$f\")" in controller, controller) + assertFalse("it[ThingTable.$f]" in controller, controller) + } + } finally { + out.toFile().deleteRecursively() + } + } + @Test fun `a model with neither mark generates the plain write and read paths`() { val (controller, entity) = controllerAndEntity(marked = false) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 86249961db..5e517e4532 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -1201,7 +1201,7 @@ protected void emitTph(MetaObject base, TphPlan.Plan plan, Path outRoot) { List markFields = new ArrayList<>(unionFields); markFields.addAll(TphPlan.everySubtypeField(base, loader)); List hidden = SpringGeneratorSupport.credentialNames(markFields); - Set writeDenied = SpringGeneratorSupport.writeDeniedNames(markFields); + Set writeDenied = SpringGeneratorSupport.hierarchyWriteDeniedNames(base); List baseNavs = SpringM2mSupport.resolve(base, loader); boolean redacts = !hidden.isEmpty() || baseNavs.stream().anyMatch(n -> !targetCredentials(base, n).isEmpty()) || plan.subtypes().stream().anyMatch(st -> SpringM2mSupport.resolve(st.entity(), loader).stream() @@ -1590,12 +1590,13 @@ private static List serverOwnedOnCreate(MetaObject entity) { .map(com.metaobjects.identity.MetaIdentity::getFields) .orElse(List.of()); + java.util.Set denied = SpringGeneratorSupport.hierarchyWriteDeniedNames(entity); List out = new ArrayList<>(); for (MetaField f : SpringGeneratorSupport.scalarFields(entity)) { // #484: a write-denied field is IGNORED on create, so a @NotNull on it describes the // response shape and must not reject a body that (necessarily) omits it. if (pkFields.contains(f.getName()) || AutoSetSupport.isAutoSet(f) - || SpringGeneratorSupport.isApiWriteDenied(f)) { + || denied.contains(f.getName())) { String component = SpringNaming.recordComponentName(f.getName()); if (!out.contains(component)) out.add(component); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 26283d5204..63f2d10e6d 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -128,6 +128,20 @@ public static java.util.Set writeDeniedNames(java.util.Collection hierarchyWriteDeniedNames(MetaObject entity) { + List fields = new ArrayList<>(entity.getMetaFields()); + MetaObject root = TphPlan.discriminatorRoot(entity); + if (root != null) { + fields.addAll(root.getMetaFields()); + fields.addAll(TphPlan.everySubtypeField(root, root.getLoader())); + } + return writeDeniedNames(fields); + } + /** #484 — the credential wire names of {@code entity}'s DTO components. */ public static List credentialNames(MetaObject entity) { return credentialNames(dtoComponentFields(entity)); @@ -159,6 +173,7 @@ public static boolean isNonReadWriteMutability(MetaField field) { * validated from the body.

*/ public static List settableFields(MetaObject entity, String alsoExclude) { + java.util.Set denied = hierarchyWriteDeniedNames(entity); List out = new ArrayList<>(); for (MetaField field : minusPk(entity, scalarFields(entity), alsoExclude)) { // ADR-0045 (#203/#229): @autoSet is server-owned — never a caller-settable TPH @@ -174,6 +189,8 @@ public static List settableFields(MetaObject entity, String alsoExclu // a separate code path (the 0.19.4 lesson), so it is stated here too rather // than assumed to fall out of the vanilla walk. if (isNonReadWriteMutability(field)) continue; + // #484: a @formExclude / credential column, marked on any declaration in the hierarchy. + if (denied.contains(field.getName())) continue; out.add(field); } return out; diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java index e3de1cb0dc..ad156e2b13 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java @@ -51,7 +51,7 @@ public static String routeSegment(String discriminatorValue) { * {@code @discriminatorValue} are declaration-layer TPH markers (never inherited into * an effective form), so each hop is read own-only while the loop does the resolving. */ - private static MetaObject discriminatorRoot(MetaObject obj) { + static MetaObject discriminatorRoot(MetaObject obj) { for (MetaObject cursor = obj; cursor != null; cursor = cursor.getSuperObject()) { if (cursor.hasMetaAttr(MetaObject.ATTR_DISCRIMINATOR, false)) return cursor; } diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java index c15f34fd63..4856d29870 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringSecretFieldsCodegenTest.java @@ -127,6 +127,16 @@ public void tphSharedColumnMarkedOnALaterSubtypeIsHiddenAndIgnored() throws Exce assertFalse("memo (@formExclude only) is still returned", controller.contains("\"memo\")")); } + @Test + public void tphSharedColumnMarkedOnALaterSubtypeIsLeftOutOfEverySubtypePatch() throws Exception { + String aPatch = generate("tphpatch", TPH_SHARED_COLUMN, "AThingPatch.java", true); + String bPatch = Files.readString(tmp.getRoot().toPath().resolve("gen-tphpatch/acme/secrets/BThingPatch.java")); + for (String patch : new String[] { aPatch, bPatch }) { + assertFalse("token is a credential on BThing; saw:\n" + patch, patch.contains("token")); + assertFalse("memo is @formExclude on BThing; saw:\n" + patch, patch.contains("memo")); + } + } + private static final String PROJECTION_HEAD = "{ \"object.projection\": { \"name\": \"AccountView\", \"children\": [\n" + " { \"source.rdb\": { \"@kind\": \"view\", \"@view\": \"v_account\" } },\n" From 8e09a6e0c8f5400e3ece366483ba40e4a1470a2f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:34:16 -0400 Subject: [PATCH 10/16] no-mistakes(review): Fix stale KDoc on Kotlin M:N credential fields --- .../kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt index 5a992ba815..d45460a02f 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinM2mSupport.kt @@ -86,7 +86,7 @@ object KotlinM2mSupport { val targetDiscriminator: TargetDiscriminator?, val symmetric: Boolean, val junctionPackage: String = "", - /** #484: target fields that are credentials, never returned (mapped to null). */ + /** #484: target fields that are credentials, never returned (their keys are omitted from the serialized row). */ val targetCredentialFields: Set = emptySet(), ) From 9f71c47149aa68bb19dd96f165c965b1a47b7c9d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 10:45:57 -0400 Subject: [PATCH 11/16] no-mistakes(review): Widen TS and C# TPH subtype marks to hierarchy --- .../codegen/generators/routes.ts | 5 ++- .../showcase/codegen/generators/routes.ts | 5 ++- fixtures/api-contract-conformance/README.md | 4 +- .../SecretFieldsCodegenTests.cs | 41 ++++++++++++++++++- .../Generators/RoutesGenerator.cs | 25 ++++++++--- .../packages/codegen-ts/src/api-surface.ts | 37 +++++++++++------ .../codegen-ts/src/reference/routes.ts | 5 ++- .../codegen-ts/src/templates/routes-file.ts | 5 ++- .../test/tph-m2m-credential.test.ts | 40 +++++++++++++++++- .../test-generators/src/generators/routes.ts | 5 ++- 10 files changed, 138 insertions(+), 34 deletions(-) diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 999274d14d..56a79d85b6 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -590,6 +590,7 @@ function renderTphRoutes( // single writable shape — so an author-supplied `expose` intersects with that fixed set // rather than replacing it: it may narrow to just `list`, never widen to `create`. const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " "); + const hierarchy = [base, ...plan.subtypes.map((st) => st.entity)]; const polymorphic = code` ${mountCrudRoutesSym}({ fastify: ${fastifyRef}, @@ -600,7 +601,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine(hierarchy, " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -643,7 +644,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot, hierarchy)}${writeDeniedColumnsLine(sub, " ", hierarchy)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 999274d14d..56a79d85b6 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -590,6 +590,7 @@ function renderTphRoutes( // single writable shape — so an author-supplied `expose` intersects with that fixed set // rather than replacing it: it may narrow to just `list`, never widen to `create`. const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " "); + const hierarchy = [base, ...plan.subtypes.map((st) => st.entity)]; const polymorphic = code` ${mountCrudRoutesSym}({ fastify: ${fastifyRef}, @@ -600,7 +601,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine(hierarchy, " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -643,7 +644,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot, hierarchy)}${writeDeniedColumnsLine(sub, " ", hierarchy)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index 08171b7d7d..7574beeab5 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -60,11 +60,11 @@ core): |---|---|---|---| | `tph/` | 10 | single-table inheritance | both | | `m2m/` | 9 | M:N traversal, and TPH x M:N together | both | -| `jsonb/` | 4 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both | +| `jsonb/` | 5 | typed value-object columns, and the numbers inside them (a double stays a JSON number; a decimal is a canonical string, option A of the 1.1.0 review) | both | | `write-through/` | 2 | an entity that writes a table and reads a view | generated only | | `secret-fields/` | 2 | #484: a `@formExclude` or credential (`view.password`) field is ignored on create, PATCH and PUT, and a credential is never returned | generated only | | `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | -| `report/` | 27 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | +| `report/` | 37 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | | `decimal/` | 11 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | `meta.json` declares a single canonical `Author` entity in the `acme::blog` diff --git a/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs index ab51dd4097..e84bc9e601 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/SecretFieldsCodegenTests.cs @@ -54,7 +54,27 @@ public class SecretFieldsCodegenTests ]}} """; - private static string Routes(string model) + // A TPH base whose subtypes share the token and memo columns: plain on AThing, marked on BThing. + private const string TphSharedColumnModel = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Thing", "@discriminator": "kind", "children": [ + { "source.rdb": { "@table": "things" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["A", "B"] } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ]}}, + { "object.entity": { "name": "AThing", "extends": "Thing", "@discriminatorValue": "A", "children": [ + { "field.string": { "name": "token", "@maxLength": 100 } }, + { "field.string": { "name": "memo", "@maxLength": 100 } } + ]}}, + { "object.entity": { "name": "BThing", "extends": "Thing", "@discriminatorValue": "B", "children": [ + { "field.string": { "name": "token", "@maxLength": 100, "children": [ { "view.password": {} } ] } }, + { "field.string": { "name": "memo", "@maxLength": 100, "@formExclude": true } } + ]}} + ]}} + """; + + private static string Routes(string model, string file = "AccountRoutes.g.cs") { var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "secrets.json")]); Assert.Empty(r.Errors); @@ -64,7 +84,24 @@ private static string Routes(string model) Root = r.Root, Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.Literal }, }; - return new RoutesGenerator().Generate(ctx).Single(f => f.Path == "AccountRoutes.g.cs").Content; + return new RoutesGenerator().Generate(ctx).Single(f => f.Path == file).Content; + } + + [Fact] + public void A_tph_column_marked_on_another_subtype_is_hidden_and_ignored_on_this_one() + { + var src = Routes(TphSharedColumnModel, "ThingRoutes.g.cs"); + var start = src.IndexOf("\"/things/a\"", StringComparison.Ordinal); + var end = src.IndexOf("\"/things/b\"", StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, src); + var aRoutes = src[start..end]; + + Assert.Contains("new string[] { \"token\" }", aRoutes); + Assert.Contains("input.Token = default!;", aRoutes); + Assert.Contains("input.Memo = default!;", aRoutes); + Assert.Contains("string.Equals(prop.Name, \"token\", System.StringComparison.OrdinalIgnoreCase)) continue;", aRoutes); + Assert.Contains("string.Equals(prop.Name, \"memo\", System.StringComparison.OrdinalIgnoreCase)) continue;", aRoutes); + Assert.DoesNotContain("\"memo\" }", aRoutes); } [Theory] diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index f6ec78f405..0e451ba302 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -681,8 +681,9 @@ protected virtual EmittedFile GenerateTphRoutes(MetaObject baseEntity, TphPlan t AppendM2mRoute(sb, nav, baseRoute, pkType!, ctx.Root); // --- Per-subtype CRUD sets --- + var hierarchy = tph.Subtypes.Select(s => s.Entity).Prepend(baseEntity).ToList(); foreach (var st in tph.Subtypes) - AppendTphSubtypeRoutes(sb, st, ctx.Root, baseRoute, baseCls, dbSet, pkType!, pkProp!, discProp, ctx.Warn); + AppendTphSubtypeRoutes(sb, st, hierarchy, ctx.Root, baseRoute, baseCls, dbSet, pkType!, pkProp!, discProp, ctx.Warn); } sb.AppendLine(); @@ -787,6 +788,19 @@ private static List TphUnionCredentialNames(MetaObject entity, MetaRoot : Enumerable.Empty()) .Distinct(StringComparer.Ordinal).ToList(); + /// + /// The fields of a TPH subtype that holds for on ANY declaration in + /// its (the base and every subtype): a single-table column declared + /// by several subtypes is one column (#484). + /// + private static List HierarchyMarkedFields( + MetaObject subtype, IEnumerable hierarchy, Func isMarked) + { + var marked = hierarchy.SelectMany(o => o.Fields()).Where(isMarked) + .Select(f => f.Name).ToHashSet(StringComparer.Ordinal); + return subtype.Fields().Where(f => marked.Contains(f.Name)).ToList(); + } + private static List CredentialNames(MetaObject entity) => entity.Fields().Where(IsCredentialField).Select(f => f.Name).ToList(); @@ -1334,7 +1348,7 @@ private static void AppendPartialMergeLoop( // subtype CLR type (discriminator injected by EF on Add); update is a partial merge // that never touches the PK or the discriminator. private static void AppendTphSubtypeRoutes( - StringBuilder sb, TphSubtypePlan st, MetaRoot root, string baseRoute, string baseCls, string dbSet, + StringBuilder sb, TphSubtypePlan st, IReadOnlyList hierarchy, MetaRoot root, string baseRoute, string baseCls, string dbSet, string pkType, string pkProp, string discProp, Action onWarn) { // #362 — field.object columns on a TPH subtype stay out of scope (Program D §6), but a @@ -1353,9 +1367,10 @@ private static void AppendTphSubtypeRoutes( // FR-037 R1 — readOnly / writeOnce on the SUBTYPE's effective fields (a shared // base's mode is inherited by every subtype), excluded from the PATCH merge. var frozenNavs = FrozenNavs(st.Entity); - // #484 — as on a plain entity: write-denied keys ignored, credentials omitted. - var deniedFields = WriteDeniedFields(st.Entity); - var credentials = CredentialNames(st.Entity); + // #484 — as on a plain entity: write-denied keys ignored, credentials omitted. One shared + // column may be declared by several subtypes, so a mark on any declaration counts. + var deniedFields = HierarchyMarkedFields(st.Entity, hierarchy, IsApiWriteDenied); + var credentials = HierarchyMarkedFields(st.Entity, hierarchy, IsCredentialField).Select(f => f.Name).ToList(); // Per-subtype list: GET // sb.AppendLine(); diff --git a/server/typescript/packages/codegen-ts/src/api-surface.ts b/server/typescript/packages/codegen-ts/src/api-surface.ts index fd894d4bd8..6a7ec0d6b5 100644 --- a/server/typescript/packages/codegen-ts/src/api-surface.ts +++ b/server/typescript/packages/codegen-ts/src/api-surface.ts @@ -171,22 +171,27 @@ export function isApiWriteDenied(field: MetaField): boolean { return field.attr(FIELD_ATTR_FORM_EXCLUDE) === true || isCredentialField(field); } -/** The names of an object's credential fields: removed from every row its generated routes send. */ -export function credentialFieldNames(entity: MetaObject): readonly string[] { - return entity.fields().filter(isCredentialField).map((f) => f.name); -} - -/** The names of an object's fields its generated write routes strip from a create or update body. */ -export function apiWriteDeniedFieldNames(entity: MetaObject): readonly string[] { - return entity.fields().filter(isApiWriteDenied).map((f) => f.name); +/** + * The names of the fields of `objects` that `isMarked` holds for on ANY declaration in + * `hierarchy`, in field order. A single-table (TPH) column declared by several subtypes is one + * column, so a credential or `@formExclude` on one declaration counts on every subtype that has it. + */ +function markedFieldNames( + objects: readonly MetaObject[], + hierarchy: readonly MetaObject[], + isMarked: (f: MetaField) => boolean, +): string[] { + const marked = new Set(hierarchy.flatMap((o) => o.fields().filter(isMarked).map((f) => f.name))); + return [...new Set(objects.flatMap((o) => o.fields().map((f) => f.name)))].filter((n) => marked.has(n)); } /** * The `writeDeniedColumns` line of a mount that accepts writes, or "" when no field is denied * (the output stays byte-identical for a model with no `@formExclude` or credential field). + * `hierarchy` is a TPH subtype's base and every subtype, when the mount serves one. */ -export function writeDeniedColumnsLine(entity: MetaObject, indent: string): string { - const names = apiWriteDeniedFieldNames(entity); +export function writeDeniedColumnsLine(entity: MetaObject, indent: string, hierarchy: readonly MetaObject[] = [entity]): string { + const names = markedFieldNames([entity], hierarchy, isApiWriteDenied); return names.length === 0 ? "" : `\n${indent}writeDeniedColumns: ${JSON.stringify(names)},`; } @@ -242,9 +247,15 @@ export function valueObjectShapes(entity: MetaObject): Record decimalFieldNames(o)))]; let lines = ""; - const hidden = [...new Set(objects.flatMap((o) => credentialFieldNames(o)))]; + const hidden = markedFieldNames(objects, hierarchy ?? objects, isCredentialField); if (hidden.length > 0) lines += `\n${indent}hiddenColumns: ${JSON.stringify(hidden)},`; if (names.length > 0) { lines += `\n${indent}decimalColumns: ${JSON.stringify(names)},`; diff --git a/server/typescript/packages/codegen-ts/src/reference/routes.ts b/server/typescript/packages/codegen-ts/src/reference/routes.ts index 999274d14d..56a79d85b6 100644 --- a/server/typescript/packages/codegen-ts/src/reference/routes.ts +++ b/server/typescript/packages/codegen-ts/src/reference/routes.ts @@ -590,6 +590,7 @@ function renderTphRoutes( // single writable shape — so an author-supplied `expose` intersects with that fixed set // rather than replacing it: it may narrow to just `list`, never widen to `create`. const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " "); + const hierarchy = [base, ...plan.subtypes.map((st) => st.entity)]; const polymorphic = code` ${mountCrudRoutesSym}({ fastify: ${fastifyRef}, @@ -600,7 +601,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine(hierarchy, " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -643,7 +644,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot, hierarchy)}${writeDeniedColumnsLine(sub, " ", hierarchy)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts index 0b590739d1..247844f84c 100644 --- a/server/typescript/packages/codegen-ts/src/templates/routes-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/routes-file.ts @@ -519,6 +519,7 @@ function renderTphRoutesFile( // single writable shape — so an author-supplied `expose` intersects with that fixed set // rather than replacing it: it may narrow to just `list`, never widen to `create`. const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " "); + const hierarchy = [base, ...plan.subtypes.map((st) => st.entity)]; const polymorphic = code` ${mountCrudRoutesSym}({ fastify: ${fastifyRef}, @@ -529,7 +530,7 @@ function renderTphRoutesFile( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine(hierarchy, " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -572,7 +573,7 @@ function renderTphRoutesFile( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot, hierarchy)}${writeDeniedColumnsLine(sub, " ", hierarchy)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the diff --git a/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts b/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts index c15da5c0a0..b9c501e622 100644 --- a/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts +++ b/server/typescript/packages/codegen-ts/test/tph-m2m-credential.test.ts @@ -44,9 +44,32 @@ const MODEL = { }, }; -async function loadModel(): Promise { +// A TPH base whose subtypes share `token` and `memo`: plain on AThing, marked on BThing. +const SHARED_COLUMN_MODEL = { + "metadata.root": { + package: "acme::things", + children: [ + { "object.entity": { name: "Thing", "@discriminator": "kind", children: [ + { "source.rdb": { "@table": "things" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "kind", "@values": ["A", "B"] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ]}}, + { "object.entity": { name: "AThing", extends: "Thing", "@discriminatorValue": "A", children: [ + { "field.string": { name: "token" } }, + { "field.string": { name: "memo" } }, + ]}}, + { "object.entity": { name: "BThing", extends: "Thing", "@discriminatorValue": "B", children: [ + { "field.string": { name: "token", children: [{ "view.password": {} }] } }, + { "field.string": { name: "memo", "@formExclude": true } }, + ]}}, + ], + }, +}; + +async function loadModel(model: object = MODEL): Promise { const result = await new MetaDataLoader({ strict: true }).load([ - new InMemoryStringSource(JSON.stringify(MODEL), { id: "tph-m2m-credential.json" }), + new InMemoryStringSource(JSON.stringify(model), { id: "tph-m2m-credential.json" }), ]); expect(result.errors.map((e) => e.message)).toEqual([]); return result.root; @@ -96,3 +119,16 @@ describe("M:N onto a TPH base target (#484)", () => { expect(m2mMount(routes, "auths")).toContain('hiddenColumns: ["pinHash"]'); }); }); + +describe("a TPH column marked on one subtype only (#484)", () => { + test("every subtype mount hides and ignores it", async () => { + const routes = fileNamed(await generate(await loadModel(SHARED_COLUMN_MODEL)), "Thing.routes.ts"); + for (const segment of ["/a", "/b"]) { + const start = routes.indexOf(`path: Thing.$path + "${segment}"`); + expect(start).toBeGreaterThan(-1); + const mount = routes.slice(start, routes.indexOf("});", start)); + expect(mount).toContain('hiddenColumns: ["token"]'); + expect(mount).toContain('writeDeniedColumns: ["token", "memo"]'); + } + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/routes.ts b/server/typescript/packages/test-generators/src/generators/routes.ts index 999274d14d..56a79d85b6 100644 --- a/server/typescript/packages/test-generators/src/generators/routes.ts +++ b/server/typescript/packages/test-generators/src/generators/routes.ts @@ -590,6 +590,7 @@ function renderTphRoutes( // single writable shape — so an author-supplied `expose` intersects with that fixed set // rather than replacing it: it may narrow to just `list`, never widen to `create`. const polymorphicExposeLine = exposeLine(intersectExpose([...TPH_POLYMORPHIC_VERBS], expose), " "); + const hierarchy = [base, ...plan.subtypes.map((st) => st.entity)]; const polymorphic = code` ${mountCrudRoutesSym}({ fastify: ${fastifyRef}, @@ -600,7 +601,7 @@ function renderTphRoutes( updateSchema: ${baseUpdateSym}, filterAllowlist: ${baseFilterSym}, sortAllowlist: ${baseSortSym}, - dialect: ${dialectLit},${decimalColumnsLine([base, ...plan.subtypes.map((st) => st.entity)], " ", ctx.loadedRoot)}${polymorphicExposeLine} + dialect: ${dialectLit},${decimalColumnsLine(hierarchy, " ", ctx.loadedRoot)}${polymorphicExposeLine} });`; // FR-018 x FR-017 — M:N traversal inside a TPH hierarchy. This file never consulted @@ -643,7 +644,7 @@ function renderTphRoutes( updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, - dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot)}${writeDeniedColumnsLine(sub, " ")} + dialect: ${dialectLit},${decimalColumnsLine(sub, " ", ctx.loadedRoot, hierarchy)}${writeDeniedColumnsLine(sub, " ", hierarchy)} discriminator: { column: ${JSON.stringify(discField)}, value: ${JSON.stringify(value)} },${exposeLine(expose, " ")} });`; // This subtype's own M:N navigations, mounted beneath its segment and gated on the From 0d4d866fe12bd0c5d8c2a8a7d941ebbf90169035 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 12:05:11 -0400 Subject: [PATCH 12/16] no-mistakes(test): Import TphPlan in ejectable SpringGeneratorSupport, widen discriminatorRoot --- .../metaobjects/generator/spring/SpringGeneratorSupport.java | 1 + .../src/main/java/com/metaobjects/generator/spring/TphPlan.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index 63f2d10e6d..ed0e111eb5 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -8,6 +8,7 @@ import com.metaobjects.generator.spring.SpringM2mSupport; import com.metaobjects.generator.spring.SpringNaming; import com.metaobjects.generator.spring.SpringTypeMapper; +import com.metaobjects.generator.spring.TphPlan; import com.metaobjects.MetaData; import com.metaobjects.config.WireFormat; diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java index ad156e2b13..3d45060758 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/TphPlan.java @@ -51,7 +51,7 @@ public static String routeSegment(String discriminatorValue) { * {@code @discriminatorValue} are declaration-layer TPH markers (never inherited into * an effective form), so each hop is read own-only while the loop does the resolving. */ - static MetaObject discriminatorRoot(MetaObject obj) { + public static MetaObject discriminatorRoot(MetaObject obj) { for (MetaObject cursor = obj; cursor != null; cursor = cursor.getSuperObject()) { if (cursor.hasMetaAttr(MetaObject.ATTR_DISCRIMINATOR, false)) return cursor; } From 50dac8ad3c0443033005e3b2b7b5d3c06b40c730 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 12:30:30 -0400 Subject: [PATCH 13/16] no-mistakes(document): Docs current; fixed capability checklist claim that view.password is TS-only --- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- .../metaobjects-audit/references/capability-checklist.md | 6 ++++-- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/agent-context/skills/metaobjects-audit/references/capability-checklist.md b/agent-context/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/agent-context/skills/metaobjects-audit/references/capability-checklist.md +++ b/agent-context/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index 39d9a51cdd..e145d1ccf6 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -198,8 +198,10 @@ soft-delete / status / type view it models. and are NOT in the cross-port registry — `view.text`, `view.textarea`, `view.date`, `view.month`, `view.hotlink`, `view.image`, `view.dropdown`, `view.radio`, `view.checkbox`, `view.number`, `view.password`, `view.hidden`, `view.web`. Every port LOADS them, so shared metadata that - carries them is valid in a backend port; nothing outside TS/web reads them. **Audit these - only for TS adopters.** Only + carries them is valid in a backend port. The one exception is `view.password`: every port's generated + routes read it as a credential, so it is never returned and never written (see + "Credentials and excluded fields" in `docs/features/api-contract.md`). + Audit the other widgets only for TS adopters. Only `view.base` / `view.currency` are cross-port-gated. ## Template — `template.*` (prompt pillar) From 73b19a8ac16d584c892ba3dd5b5eaf4efb6c5655 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 14:30:43 -0400 Subject: [PATCH 14/16] fix(codegen): a served report hides a credential column in every port (#484) A report column is derived from its @of field and lost its view.password, so a report that groups by a credential, or takes its min or max, served it from the list route and the export. A derived field over a credential is now a credential in every port: the list route omits the key and an export names no column for it. Also documents it in the CHANGELOG entry for #484, the api-contract and reporting guides. --- CHANGELOG.md | 4 +- docs/features/api-contract.md | 5 + docs/features/reporting.md | 4 +- .../ReportCredentialCodegenTests.cs | 135 ++++++++++++++++++ .../Generators/ReportExportGenerator.cs | 3 +- .../Generators/RoutesGenerator.cs | 10 +- .../csharp/MetaObjects.Codegen/ReportRows.cs | 7 + .../Runtime/ReportExportHttp.cs | 22 ++- .../generator/kotlin/KotlinGenUtil.kt | 14 ++ .../kotlin/KotlinReportExportGenerator.kt | 4 +- .../kotlin/KotlinSpringControllerGenerator.kt | 20 +-- .../kotlin/KotlinReportCredentialTest.kt | 99 +++++++++++++ .../spring/SpringControllerGenerator.java | 26 ++-- .../spring/SpringGeneratorSupport.java | 11 ++ .../spring/SpringReportExportGenerator.java | 7 +- .../spring/SpringReportCredentialTest.java | 101 +++++++++++++ .../generators/report_export_generator.py | 2 + .../codegen/generators/router_generator.py | 6 +- .../metaobjects/meta/core/field/credential.py | 16 +++ .../meta/core/reporting/report_read_model.py | 10 +- .../tests/codegen/test_report_credential.py | 126 ++++++++++++++++ .../codegen-ts/src/reference/report-export.ts | 8 +- .../src/templates/report-export-file.ts | 8 +- .../codegen-ts/test/report-credential.test.ts | 98 +++++++++++++ .../src/core/reporting/report-read-model.ts | 13 +- .../test/report-hidden-column.test.ts | 67 +++++++++ .../src/generators/report-export.ts | 8 +- 27 files changed, 792 insertions(+), 42 deletions(-) create mode 100644 server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportCredentialTest.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportCredentialTest.java create mode 100644 server/python/src/metaobjects/meta/core/field/credential.py create mode 100644 server/python/tests/codegen/test_report_credential.py create mode 100644 server/typescript/packages/codegen-ts/test/report-credential.test.ts create mode 100644 server/typescript/packages/runtime-ts/test/report-hidden-column.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb2846d5f..49d8822a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,7 +176,9 @@ here.** `@formExclude`, or a credential (a field that declares a `view.password`), is now **ignored** by the generated create, PATCH and PUT in every port: the value is dropped, not rejected, so an edit form that submits every field still saves, and a present `null` no longer clears it. A credential is - also never returned: not by list, get, or the echo of a write. Before this, `@formExclude` kept + also never returned: not by list, get, or the echo of a write, or by a served report that groups by + it or takes its `min` or `max` (a report column is derived from its `@of` field, and it keeps the + credential mark; a report export names no column for it). Before this, `@formExclude` kept a field out of the generated form while the route still wrote it, and a generated read returned password hashes. Generated forms (React, Angular) and the api docs omit credentials too. Your own code is unaffected: the entity, DTO and generated queries still carry the field, so you keep writing a hash or token yourself. diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 5f00481474..71ac8a75fd 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -550,6 +550,11 @@ filterable or sortable only if you wrote `@filterable` or `@sortable` on it, so (a filter on a secret answers whether a guess is right). To return a credential from a route, hand-write that route; the generated one will not. Generated forms and the api docs omit both kinds of field as well. +A served report is covered too. A report column is derived from its `@of` field, and a derived column keeps +that field's credential mark, so a report that groups by a credential (or takes its `min` or `max`) never +returns the column, and its `report-export` output has no column for it. `count` over a credential is a +number and is returned. + `fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port. ## Read-only projections diff --git a/docs/features/reporting.md b/docs/features/reporting.md index fa235d796c..d166d0ba2d 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -299,7 +299,9 @@ A derived column carries the type-shaping attributes of its `@of` field where th (`@currency`, `@values`, `@intValueMap`, `@maxLength`, `@precision`, `@scale`, `@localTime`, `@objectRef`, `@storage`, `@dbColumnType`, `isArray`) and nothing else: no `@column`, no `@required` beyond the rules above, no `@default` (a measure's `@default` is applied in the -view, not carried), no validators. +view, not carried), no validators. The one view it does carry is a credential: a column whose +`@of` field declares or inherits `view.password` is itself a credential, so the served list route +omits it and an export has no column for it (#484). ### Measures diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs new file mode 100644 index 0000000000..d540e99045 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs @@ -0,0 +1,135 @@ +// ReportCredentialCodegenTests — #484 follow-up: a served report hides a credential. +// +// A report column is DERIVED from its `@of` field and a derived field keeps only type-shaping +// attrs, so a dimension (or a min/max measure) over a field that declares a view.password used to +// serve the credential. The row model now marks such a derived field as a credential, and the +// routes and the export treat it exactly as they treat an entity's. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class ReportCredentialCodegenTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme::secrets", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id", "@required": true } }, + { "field.string": { "name": "name", "@maxLength": 100 } }, + { "field.string": { "name": "pinHash", "@maxLength": 100, "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "name", "@of": "Account.name" } }, + { "dimension.attribute": { "name": "pinHash", "@of": "Account.pinHash" } }, + { "measure.aggregate": { "name": "accounts", "@agg": "count", "@of": "Account.id" } }, + { "measure.aggregate": { "name": "firstPin", "@agg": "min", "@of": "Account.pinHash" } } + ] } }, + { "object.report": { "name": "AccountsByPin", "@from": "Account", "@dimensions": ["pinHash"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_pin" } } ] } }, + { "object.report": { "name": "AccountPinRange", "@from": "Account", "@dimensions": ["name"], "@measures": ["firstPin"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_account_pin_range" } } ] } }, + { "object.report": { "name": "AccountsByName", "@from": "Account", "@dimensions": ["name"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_name" } } ] } } + ] } } + """; + + private static MetaRoot Load() + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "meta.secrets.json")]); + Assert.True(r.Errors.Count == 0, string.Join("\n", r.Errors.Select(e => e.Message))); + return r.Root; + } + + private static GenContext Ctx(MetaRoot root) => new() + { + Entities = root.Objects(), + Root = root, + Config = new GenConfig { OutDir = "/unused", Namespace = "Acme.Gen", ColumnNamingStrategy = ColumnNamingStrategy.Literal, IncludeNames = true }, + Generators = [new RoutesGenerator(), new ReportExportGenerator { MaxRows = 60 }], + }; + + private static string Routes(GenContext ctx, string report) => + new RoutesGenerator().Generate(ctx).Single(f => f.Path == report + "Routes.g.cs").Content; + + private static string Export(GenContext ctx, string report) => + new ReportExportGenerator { MaxRows = 60 }.Generate(ctx).Single(f => f.Path == report + "Export.g.cs").Content; + + [Fact] + public void The_row_model_marks_a_derived_credential() + { + var root = Load(); + var pin = ReportRows.RowModel(root.FindObject("AccountsByPin")!, root).FindField("pinHash")!; + Assert.True(RoutesGenerator.IsCredentialField(pin)); + var range = ReportRows.RowModel(root.FindObject("AccountPinRange")!, root).FindField("firstPin")!; + Assert.True(RoutesGenerator.IsCredentialField(range)); + var plain = ReportRows.RowModel(root.FindObject("AccountsByName")!, root); + Assert.All(plain.Fields(), f => Assert.False(RoutesGenerator.IsCredentialField(f))); + } + + [Theory] + [InlineData("AccountsByPin", "pinHash")] + [InlineData("AccountPinRange", "firstPin")] + public void A_report_over_a_credential_omits_it_from_rows_and_export(string report, string column) + { + var ctx = Ctx(Load()); + var routes = Routes(ctx, report); + Assert.Contains($"new string[] {{ \"{column}\" }}", routes); + Assert.DoesNotContain("Results.Ok(rows)", routes); + // The JSON export strips it too; the CSV / XML column list names no credential. + Assert.Contains($"rows, new string[] {{ \"{column}\" }}", routes); + var export = Export(ctx, report); + Assert.DoesNotContain($"\"{column}\"", export); + Assert.DoesNotContain($"row.{char.ToUpperInvariant(column[0])}{column[1..]}", export); + } + + [Fact] + public void A_plain_report_is_unchanged() + { + var ctx = Ctx(Load()); + var routes = Routes(ctx, "AccountsByName"); + Assert.DoesNotContain("CredentialOmittingResult", routes); + Assert.Contains("Results.Ok(rows)", routes); + Assert.Contains("ReportExportHttp.Json(http, ", routes); + Assert.Contains("\", rows);", routes); + Assert.Contains("\"name\"", Export(ctx, "AccountsByName")); + } + + [Fact] + public void The_credential_report_routes_and_export_compile() + { + var ctx = Ctx(Load()); + var files = new IGenerator[] + { + new EntityGenerator(), new DbContextGenerator(), new NamesGenerator(), + new FilterAllowlistGenerator(), new RoutesGenerator(), new ReportExportGenerator { MaxRows = 60 }, + } + .SelectMany(g => g.Generate(ctx)).ToList(); + + var paths = DbContextCompileTests.BuildReferences() + .OfType().Select(r => r.FilePath!) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + string aspNetDir = Path.GetDirectoryName(typeof(Microsoft.AspNetCore.Http.IQueryCollection).Assembly.Location)!; + foreach (var dll in Directory.GetFiles(aspNetDir, "*.dll")) paths.Add(dll); + paths.Add(typeof(Microsoft.AspNetCore.Builder.WebApplication).Assembly.Location); + paths.Add(typeof(Microsoft.AspNetCore.Http.Results).Assembly.Location); + paths.Add(typeof(RoutesGenerator).Assembly.Location); + + var trees = files + .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)) + .ToList(); + var comp = CSharpCompilation.Create( + "report_credential_" + Guid.NewGuid().ToString("N"), trees, + paths.Where(File.Exists).Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + var errors = comp.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Location.GetLineSpan().Path}: {d.Id}: {d.GetMessage()}") + .ToList(); + Assert.True(errors.Count == 0, string.Join("\n", errors)); + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs index f9b770af29..60fdca3d03 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/ReportExportGenerator.cs @@ -90,7 +90,8 @@ public IEnumerable Generate(GenContext ctx) private string ReportModule(MetaObject report, GenContext ctx) { var cls = CSharpNaming.Pascal(report.Name); - var fields = report.Fields().ToList(); + // A credential column (a derived field over a view.password field) is never exported. + var fields = report.Fields().Where(f => !f.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD)).ToList(); var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("// Generated by MetaObjects report-export. Do not edit by hand."); diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index 0e451ba302..40643a2932 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -364,13 +364,13 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte if (export is null) sb.AppendLine($" if (!withCount) return {OkResult("rows", credentials)};"); else - sb.AppendLine($" if (!withCount) return format is null ? Results.Ok(rows) : ReportExportHttp.Json(http, \"{route}\", rows);"); + sb.AppendLine($" if (!withCount) return format is null ? {OkResult("rows", credentials)} : ReportExportHttp.Json(http, \"{route}\", rows{ExportOmit(credentials, false)});"); sb.AppendLine($" var totalQ = EfCoreFilterDispatch.ApplyFilter(db.{readDbSet}.AsNoTracking(), filter.Predicates);"); sb.AppendLine(" var total = await totalQ.CountAsync();"); if (export is null) sb.AppendLine($" return {OkResult("new { rows, total }", credentials, envelope: true)};"); else - sb.AppendLine($" return format is null ? Results.Ok(new {{ rows, total }}) : ReportExportHttp.Json(http, \"{route}\", new {{ rows, total }});"); + sb.AppendLine($" return format is null ? {OkResult("new { rows, total }", credentials, envelope: true)} : ReportExportHttp.Json(http, \"{route}\", new {{ rows, total }}{ExportOmit(credentials, true)});"); sb.AppendLine(" });"); if (hasItem) @@ -766,7 +766,7 @@ private static List FrozenNavs(MetaObject entity) => // #484 — a credential is a field that declares (or inherits, ADR-0039) a view.password child. // A credential is never returned by a generated route; it is also never written by one. - private static bool IsCredentialField(MetaField f) => + public static bool IsCredentialField(MetaField f) => f.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD); // #484 — write-denied: the generated create / PATCH / PUT IGNORE the key (never a 400): the @@ -815,6 +815,10 @@ private static string OkResult(string value, IReadOnlyList credentials, ? $"Results.Ok({value})" : $"(IResult)new CredentialOmittingResult({value}, {NamesLiteral(credentials)}{(envelope ? ", envelope: true" : "")})"; + // The credential arguments of a JSON report export (ReportExportHttp.Json), "" without a credential. + private static string ExportOmit(IReadOnlyList credentials, bool envelope) => + credentials.Count == 0 ? "" : $", {NamesLiteral(credentials)}{(envelope ? ", envelope: true" : "")}"; + private static string CreatedResult(string location, string value, IReadOnlyList credentials) => credentials.Count == 0 ? $"Results.Created({location}, {value})" diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs index f2ee3b67ed..a4dc08800a 100644 --- a/server/csharp/MetaObjects.Codegen/ReportRows.cs +++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs @@ -173,6 +173,13 @@ private static MetaField DerivedField(ReportField f) // `isArray` is a native flag, not an attr; ResolvedIsArray() is its resolving read. if (src.ResolvedIsArray()) field.SetIsArray(true); } + // A credential stays a credential when read through a report: a derived field whose type + // source declares or inherits a view.password carries one, so every generator and runtime + // that reads the row model (routes, export) hides it exactly as it hides an entity's. + // ADR-0039: resolving (Views()), an inherited view.password counts. + if (f.TypeSource is { } cred + && cred.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD)) + field.AddChild(new MetaView(new TypeId(TYPE_VIEW, MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD), "password")); // Table C: every derived field whose type has a filter band is filterable. Asked // last, of the finished field, because an int-backed enum's band depends on a // carried attr. A report author cannot narrow this set: there is no node to put diff --git a/server/csharp/MetaObjects.Codegen/Runtime/ReportExportHttp.cs b/server/csharp/MetaObjects.Codegen/Runtime/ReportExportHttp.cs index 82bd486aa2..4c655bce8d 100644 --- a/server/csharp/MetaObjects.Codegen/Runtime/ReportExportHttp.cs +++ b/server/csharp/MetaObjects.Codegen/Runtime/ReportExportHttp.cs @@ -14,6 +14,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; @@ -61,7 +62,13 @@ public static IResult TooLarge(int maxRows) => /// configured JSON options (so a wire setting applies exactly as on the list route), as /// application/json, with the attachment header. /// - public static IResult Json(HttpContext http, string segment, T body) + public static IResult Json(HttpContext http, string segment, T body) => Json(http, segment, body, [], false); + + /// + /// The JSON export with credential columns (a derived field over a view.password field) OMITTED from + /// every row; says the body is { rows, total } rather than the rows. + /// + public static IResult Json(HttpContext http, string segment, T body, string[] omit, bool envelope = false) { Attach(http, segment, "json"); var options = http.RequestServices @@ -69,7 +76,18 @@ public static IResult Json(HttpContext http, string segment, T body) is Microsoft.Extensions.Options.IOptions configured ? configured.Value.SerializerOptions : null; - return Results.Json(body, options, "application/json"); + if (omit.Length == 0) return Results.Json(body, options, "application/json"); + var node = System.Text.Json.JsonSerializer.SerializeToNode(body, body!.GetType(), options); + Strip(envelope && node is System.Text.Json.Nodes.JsonObject env ? env["rows"] : node, omit); + return Results.Json(node, options, "application/json"); + } + + private static void Strip(System.Text.Json.Nodes.JsonNode? n, string[] omit) + { + if (n is System.Text.Json.Nodes.JsonArray a) { foreach (var e in a) Strip(e, omit); return; } + if (n is not System.Text.Json.Nodes.JsonObject o) return; + foreach (var key in o.Select(p => p.Key).Where(k => omit.Any(x => string.Equals(x, k, StringComparison.OrdinalIgnoreCase))).ToList()) + o.Remove(key); } /// The CSV export: fills a UTF-8 (no BOM of the encoder's own) body. diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt index 6413f3e1ae..62b74b74fc 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinGenUtil.kt @@ -768,6 +768,20 @@ public object KotlinGenUtil { field.getChildren(com.metaobjects.view.MetaView::class.java, true) .any { it.subType == com.metaobjects.view.PresentationView.SUBTYPE_PASSWORD } + /** + * #484 — the field names of a served report that are credentials. A report column is DERIVED from its + * `@of` field (a dimension attribute, or a min/max measure over it), and the derived field carries + * only type-shaping attrs, so it loses the source's `view.password`; the credential is read off the + * type source instead ([com.metaobjects.reporting.ReportShape.Field.typeSource], resolving accessors). + * Takes the declared `object.report` or its read model. + */ + fun reportCredentialNames(report: MetaObject): List { + val declared = (report as? com.metaobjects.reporting.ReportReadModel)?.report() ?: report + return com.metaobjects.reporting.ReportShape.of(declared).fields() + .filter { it.typeSource() != null && isCredentialField(it.typeSource()) } + .map { it.name() } + } + /** #484 — `@formExclude: true` (resolving read): the field is not part of the write shape. */ fun isFormExcluded(field: MetaField<*>): Boolean = resolvedBooleanAttr(field, ATTR_FORM_EXCLUDE) diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGenerator.kt index 0686e582ab..88361a7b38 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinReportExportGenerator.kt @@ -125,7 +125,9 @@ open class KotlinReportExportGenerator : MultiFileDirectGeneratorBase, maxRows: Int, - export: Boolean, + export: Boolean, creds: Collection = emptyList(), ) = with(sb) { val api = exposedApi() // With the export branch wired, no answer of the list route consults Accept (the selector @@ -2412,15 +2413,15 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase maxRows) return@transaction tooLarge\n") append(" if ($withCountOne) ${okBody}mapOf(\"rows\" to rows, \"total\" to total) as Any)\n") append(" else ${okBody}rows as Any)\n") append(" }\n\n") - if (export) appendReportExportHandler(this, shortName, readObj, sortFields) + if (export) appendReportExportHandler(this, shortName, readObj, sortFields, creds) } /** @@ -2435,6 +2436,7 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase, + creds: Collection = emptyList(), ) = with(sb) { val api = exposedApi() val x = "${shortName}Export" @@ -2473,13 +2475,15 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase maxRows) return@transaction jsonBadRequest($x.tooLarge())\n") + // #484: csv and xml name no credential column (the export's own FIELDS); the json body drops the key. + val jsonRows = if (creds.isEmpty()) "rows" else "rows.map { redact$shortName(objectMapper, it) }" append(" val ok = ResponseEntity.ok()\n") append(" .header(\"Content-Disposition\", $x.contentDisposition(format))\n") append(" .header(\"Content-Type\", $x.contentType(format))\n") append(" when (format) {\n") append(" \"csv\" -> ok.body($x.csv(rows) as Any)\n") append(" \"xml\" -> ok.body($x.xml(rows) as Any)\n") - append(" else -> if (withCount == \"1\") ok.body(mapOf(\"rows\" to rows, \"total\" to total) as Any) else ok.body(rows as Any)\n") + append(" else -> if (withCount == \"1\") ok.body(mapOf(\"rows\" to $jsonRows, \"total\" to total) as Any) else ok.body($jsonRows as Any)\n") append(" }\n") append(" }\n\n") append(" private fun jsonBadRequest(body: Any): ResponseEntity =\n") diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportCredentialTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportCredentialTest.kt new file mode 100644 index 0000000000..61d5db387f --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinReportCredentialTest.kt @@ -0,0 +1,99 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.generator.Generator +import com.metaobjects.generator.RunAware +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import java.util.TreeMap +import kotlin.io.path.isRegularFile +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * #484: a report column is DERIVED from its `@of` field, so it would lose the field's `view.password` and be + * served. A grouped or min/max-ed credential stays a credential: the report's list route omits the key and + * its export names no column for it, as an entity's routes do. A report with no credential source is unchanged. + */ +class KotlinReportCredentialTest { + + private val model = """{ + "metadata.root": { "package": "acme::acct", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 40 } }, + { "field.string": { "name": "pinHash", "@maxLength": 80, "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { "name": "name", "@of": "Account.name" } }, + { "dimension.attribute": { "name": "pinHash", "@of": "Account.pinHash" } }, + { "measure.aggregate": { "name": "accounts", "@agg": "count", "@of": "Account.id" } }, + { "measure.aggregate": { "name": "firstPin", "@agg": "min", "@of": "Account.pinHash" } } + ] } }, + { "object.report": { "name": "AccountsByPin", "@from": "Account", "@dimensions": ["pinHash"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_pin" } } ] } }, + { "object.report": { "name": "AccountPinRange", "@from": "Account", "@dimensions": ["name"], "@measures": ["firstPin"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_account_pin_range" } } ] } }, + { "object.report": { "name": "AccountsByName", "@from": "Account", "@dimensions": ["name"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_name" } } ] } } + ] } + }""".trimIndent() + + private fun generate(): Map { + val outDir = Files.createTempDirectory("report-credential-") + try { + val loader = loadString("report-credential", model) + val run: List = listOf( + KotlinEntityGenerator(), KotlinExposedTableGenerator(), KotlinFilterAllowlistGenerator(), + KotlinSpringControllerGenerator(), KotlinReportExportGenerator(), + ) + for (gen in run) { + if (gen is RunAware) gen.setRun(run) + gen.setArgs(mapOf("outputDir" to outDir.toString())) + gen.execute(loader) + } + val files = TreeMap() + Files.walk(outDir).use { s -> + s.filter { it.isRegularFile() }.forEach { files[outDir.relativize(it).toString()] = it.readText() } + } + return files + } finally { + outDir.toFile().deleteRecursively() + } + } + + private fun file(files: Map, suffix: String): String = + files.entries.firstOrNull { it.key.endsWith(suffix) }?.value ?: error("no $suffix in ${files.keys}") + + @Test + fun `a report over a credential dimension omits it from its rows and export`() { + val files = generate() + val controller = file(files, "AccountsByPinController.kt") + assertTrue("remove(listOf(\"pinHash\"))" in controller, controller) + assertTrue("redactAccountsByPin(objectMapper, rowToAccountsByPin(it))" in controller, controller) + assertTrue("rows.map { redactAccountsByPin(objectMapper, it) }" in controller, controller) + val export = file(files, "AccountsByPinExport.kt") + assertFalse("pinHash" in export, export) + assertTrue("accounts" in export, export) + } + + @Test + fun `a min measure over a credential is a credential too`() { + val files = generate() + val controller = file(files, "AccountPinRangeController.kt") + assertTrue("remove(listOf(\"firstPin\"))" in controller, controller) + val export = file(files, "AccountPinRangeExport.kt") + assertFalse("firstPin" in export, export) + assertTrue("\"name\"" in export, export) + } + + @Test + fun `a report with no credential source is served unredacted`() { + val files = generate() + val controller = file(files, "AccountsByNameController.kt") + assertFalse("redact" in controller, controller) + assertFalse("ObjectMapper" in controller, controller) + assertTrue("\"name\"" in file(files, "AccountsByNameExport.kt")) + } +} diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java index 5e517e4532..ac0096e4b6 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java @@ -680,8 +680,8 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) { // #484: a projection field that inherits a credential (view.password) is hidden from its // rows. Redaction needs the ObjectMapper, so it is injected ONLY then; otherwise the // constructor and the whole controller stay as they were. - List hidden = com.metaobjects.generator.util.GeneratorUtil.isReport(entity) - ? List.of() : SpringGeneratorSupport.credentialNames(entity); + // A served report is covered too: a derived column over a credential is a credential. + List hidden = SpringGeneratorSupport.credentialNames(entity); src.append(" private final ").append(repoName).append(" repository;\n"); if (hidden.isEmpty()) { src.append("\n"); @@ -698,7 +698,7 @@ protected void emitReadOnly(MetaObject entity, Path outRoot) { if (!hidden.isEmpty()) appendRedactHelpers(src); if (com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) { - appendReportListHandler(src, shortName, dtoName, repoName, reportMaxRows, reportExport, routeBase); + appendReportListHandler(src, shortName, dtoName, repoName, reportMaxRows, reportExport, routeBase, hidden); } else { appendListHandler(src, shortName, dtoName, repoName, hidden); } @@ -860,9 +860,9 @@ private static void appendListHandler(StringBuilder src, String shortName, Strin */ private static void appendReportListHandler(StringBuilder out, String shortName, String dtoName, String repoName, int maxRows, boolean export, - String routeBase) { + String routeBase, List hidden) { StringBuilder src = new StringBuilder(); - appendReportListHandlerBody(src, shortName, dtoName, repoName, maxRows, export, routeBase); + appendReportListHandlerBody(src, shortName, dtoName, repoName, maxRows, export, routeBase, hidden); String handler = src.toString(); if (export) { // With the export branch wired, no answer of the list route consults Accept (the @@ -884,7 +884,7 @@ private static void appendReportListHandler(StringBuilder out, String shortName, private static void appendReportListHandlerBody(StringBuilder src, String shortName, String dtoName, String repoName, int maxRows, boolean export, - String routeBase) { + String routeBase, List hidden) { String allowlistName = SpringNaming.filterAllowlistName(shortName); src.append(" @GetMapping\n"); src.append(" public ResponseEntity list(\n"); @@ -950,12 +950,13 @@ private static void appendReportListHandlerBody(StringBuilder src, String shortN src.append(" if (withCount != null && withCount == 1) {\n"); } src.append(" long total = repository.count(filters);\n"); - src.append(" return ResponseEntity.ok(Map.of(\"rows\", rows, \"total\", total));\n"); + src.append(" return ResponseEntity.ok(Map.of(\"rows\", ").append(redactAll("rows", hidden)) + .append(", \"total\", total));\n"); src.append(" }\n"); - src.append(" return ResponseEntity.ok(rows);\n"); + src.append(" return ResponseEntity.ok(").append(redactAll("rows", hidden)).append(");\n"); src.append(" }\n\n"); if (export) { - appendReportExportHandler(src, shortName, dtoName, repoName, routeBase); + appendReportExportHandler(src, shortName, dtoName, repoName, routeBase, hidden); } src.append(" /** A page bound as an integer of at least {@code min}, or null when it is not one. */\n"); src.append(" private static Long parseBound(String raw, long min) {\n"); @@ -980,7 +981,7 @@ private static void appendReportListHandlerBody(StringBuilder src, String shortN * {@code { rows, total }} envelope. {@code Accept} is never read. */ private static void appendReportExportHandler(StringBuilder src, String shortName, String dtoName, - String repoName, String routeBase) { + String repoName, String routeBase, List hidden) { String allowlistName = SpringNaming.filterAllowlistName(shortName); String exportName = shortName + "Export"; src.append(" private ResponseEntity export(String format, String limit, String offset, String sort,\n"); @@ -1040,9 +1041,10 @@ private static void appendReportExportHandler(StringBuilder src, String shortNam src.append(" return ok.body(").append(exportName).append(".xml(rows));\n"); src.append(" default:\n"); src.append(" if (\"1\".equals(withCount)) {\n"); - src.append(" return ok.body(Map.of(\"rows\", rows, \"total\", repository.count(filter.predicates())));\n"); + src.append(" return ok.body(Map.of(\"rows\", ").append(redactAll("rows", hidden)) + .append(", \"total\", repository.count(filter.predicates())));\n"); src.append(" }\n"); - src.append(" return ok.body(rows);\n"); + src.append(" return ok.body(").append(redactAll("rows", hidden)).append(");\n"); src.append(" }\n"); src.append(" }\n\n"); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index ed0e111eb5..8d5bdf62dd 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -145,6 +145,17 @@ public static java.util.Set hierarchyWriteDeniedNames(MetaObject entity) /** #484 — the credential wire names of {@code entity}'s DTO components. */ public static List credentialNames(MetaObject entity) { + // A served report's columns are DERIVED from their @of fields and carry only type-shaping + // attrs, so a view.password on the source does not reach the derived field. The column is a + // credential when its type source is (resolving, ADR-0039). + if (entity instanceof com.metaobjects.reporting.ReportReadModel model) { + List out = new ArrayList<>(); + for (com.metaobjects.reporting.ReportShape.Field f + : com.metaobjects.reporting.ReportShape.of(model.report()).fields()) { + if (f.typeSource() != null && isCredentialField(f.typeSource())) out.add(f.name()); + } + return out; + } return credentialNames(dtoComponentFields(entity)); } diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java index 1eecffbda5..561f915a4a 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringReportExportGenerator.java @@ -204,7 +204,12 @@ private static String exportSource(MetaObject entity, String pkg, String shortNa String writerPackage, int maxRows) { String dto = SpringNaming.dtoName(shortName); String className = shortName + "Export"; - List fields = SpringGeneratorSupport.dtoComponentFields(entity); + // A credential column is never exported: no header, no cell. + List credentials = SpringGeneratorSupport.credentialNames(entity); + List fields = new java.util.ArrayList<>(); + for (MetaField f : SpringGeneratorSupport.dtoComponentFields(entity)) { + if (!credentials.contains(f.getName())) fields.add(f); + } StringBuilder src = new StringBuilder(); if (!pkg.isEmpty()) src.append("package ").append(pkg).append(";\n\n"); if (!writerPackage.equals(pkg)) { diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportCredentialTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportCredentialTest.java new file mode 100644 index 0000000000..a8b83ac266 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportCredentialTest.java @@ -0,0 +1,101 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * #484 — a served report's column is DERIVED from its {@code @of} field and so loses the field's + * {@code view.password}. A column over a credential (grouped, or min/max-ed) is still a credential: + * the report's list route (and its json export) omits it and the csv/xml export names no column for + * it. A report with no credential source generates no redaction. + */ +public class SpringReportCredentialTest extends SharedRegistryTestBase { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static final String MODEL = """ + { + "metadata.root": { "package": "acme::acct", "children": [ + { "object.entity": { "name": "Account", "children": [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true, "@maxLength": 40 } }, + { "field.string": { "name": "pinHash", "@maxLength": 80, "children": [ { "view.password": {} } ] } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { "name": "name", "@of": "Account.name" } }, + { "dimension.attribute": { "name": "pinHash", "@of": "Account.pinHash" } }, + { "measure.aggregate": { "name": "accounts", "@agg": "count", "@of": "Account.id" } }, + { "measure.aggregate": { "name": "firstPin", "@agg": "min", "@of": "Account.pinHash" } } + ] } }, + { "object.report": { "name": "AccountsByPin", "@from": "Account", "@dimensions": ["pinHash"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_pin" } } ] } }, + { "object.report": { "name": "AccountPinRange", "@from": "Account", "@dimensions": ["name"], "@measures": ["firstPin"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_account_pin_range" } } ] } }, + { "object.report": { "name": "AccountsByName", "@from": "Account", "@dimensions": ["name"], "@measures": ["accounts"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_accounts_by_name" } } ] } } + ] } + } + """; + + private Path gen; + + private void generate() throws Exception { + gen = tmp.newFolder("gen").toPath(); + MetaDataLoader loader = SpringTestFixtures.loadFixture(tmp.newFolder("ws").toPath(), "report-credential", MODEL); + Map args = new HashMap<>(); + args.put("outputDir", gen.toString()); + for (com.metaobjects.generator.direct.MultiFileDirectGeneratorBase g : java.util.List.of( + new SpringDtoGenerator(), new SpringRepositoryGenerator(), + new SpringControllerGenerator(), new SpringFilterAllowlistGenerator(), + new SpringReportExportGenerator())) { + g.setArgs(args); + g.execute(loader); + } + } + + private String read(String name) throws Exception { + return Files.readString(gen.resolve("acme/acct/" + name + ".java")); + } + + @Test + public void groupedCredentialIsHiddenFromTheListAndExport() throws Exception { + generate(); + String controller = read("AccountsByPinController"); + assertTrue(controller.contains("redactAll(rows, \"pinHash\")")); + assertTrue("the mapper is injected for redaction", controller.contains("ObjectMapper objectMapper) {")); + String export = read("AccountsByPinExport"); + assertFalse("no credential column in the export; saw:\n" + export, export.contains("pinHash")); + assertTrue(export.contains("\"accounts\"")); + } + + @Test + public void minOverACredentialIsHidden() throws Exception { + generate(); + assertTrue(read("AccountPinRangeController").contains("redactAll(rows, \"firstPin\")")); + String export = read("AccountPinRangeExport"); + assertFalse(export.contains("firstPin")); + assertTrue(export.contains("\"name\"")); + } + + @Test + public void plainReportIsUnchanged() throws Exception { + generate(); + String controller = read("AccountsByNameController"); + assertFalse(controller.contains("redact")); + assertFalse(controller.contains("ObjectMapper")); + String export = read("AccountsByNameExport"); + assertTrue(export.contains("\"name\"") && export.contains("\"accounts\"")); + } +} diff --git a/server/python/src/metaobjects/codegen/generators/report_export_generator.py b/server/python/src/metaobjects/codegen/generators/report_export_generator.py index 9025c0b8cd..71e87c5b8b 100644 --- a/server/python/src/metaobjects/codegen/generators/report_export_generator.py +++ b/server/python/src/metaobjects/codegen/generators/report_export_generator.py @@ -42,6 +42,7 @@ from metaobjects.codegen.generator import EmittedFile, GenContext, Generator from metaobjects.codegen.instance_artifacts import is_served_report from metaobjects.codegen.type_map import field_is_array +from metaobjects.meta.core.field.credential import is_credential_field from metaobjects.meta.core.field import field_constants as fc from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject @@ -191,6 +192,7 @@ def render_report(self, report: MetaObject) -> str: """The export module of one served report: its columns in derived-field order.""" cols = ",\n".join( f' ExportColumn("{f.name}", "{_column_kind(f)}")' for f in report.fields() + if not is_credential_field(f) # #484: an export names no credential column ) return ( generated_header(report.name, report.resolution_key()) diff --git a/server/python/src/metaobjects/codegen/generators/router_generator.py b/server/python/src/metaobjects/codegen/generators/router_generator.py index 3986ad8f1f..8fc998fd87 100644 --- a/server/python/src/metaobjects/codegen/generators/router_generator.py +++ b/server/python/src/metaobjects/codegen/generators/router_generator.py @@ -58,6 +58,7 @@ is_read_only_mutability, is_write_once_mutability, ) +from metaobjects.meta.core.field.credential import is_credential_field from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.persistence.db import db_constants as dbc from metaobjects.meta.core.identity.identity_constants import ( @@ -73,8 +74,7 @@ from metaobjects.naming import DEFAULT_COLUMN_NAMING from metaobjects.naming_refs import ref_target from metaobjects.wire import WIRE_NATIVE, WireFormat, wire_of -from metaobjects.meta.presentation.view.view_constants import VIEW_SUBTYPE_PASSWORD -from metaobjects.shared.base_types import TYPE_IDENTITY, TYPE_VIEW +from metaobjects.shared.base_types import TYPE_IDENTITY from ..runtime.fr010_field_mapping import is_required from ..runtime.m2m_codegen import ( @@ -379,7 +379,7 @@ def _is_credential(field: MetaField) -> bool: """#484 — a field declaring (or inheriting) a ``view.password`` child is a credential: never returned by a generated route, never written by one. RESOLVING ``children()`` (ADR-0039), so an inherited ``view.password`` counts.""" - return any(c.type == TYPE_VIEW and c.sub_type == VIEW_SUBTYPE_PASSWORD for c in field.children()) + return is_credential_field(field) def _denied_names(entity: MetaObject) -> list[str]: diff --git a/server/python/src/metaobjects/meta/core/field/credential.py b/server/python/src/metaobjects/meta/core/field/credential.py new file mode 100644 index 0000000000..761b298565 --- /dev/null +++ b/server/python/src/metaobjects/meta/core/field/credential.py @@ -0,0 +1,16 @@ +"""Credential fields (#484): a field declaring or inheriting a ``view.password`` child.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...presentation.view.view_constants import VIEW_SUBTYPE_PASSWORD +from ....shared.base_types import TYPE_VIEW + +if TYPE_CHECKING: + from .meta_field import MetaField + + +def is_credential_field(field: "MetaField") -> bool: + """True iff *field* declares (or inherits) a ``view.password`` child. RESOLVING + ``children()`` (ADR-0039), so an inherited ``view.password`` counts.""" + return any(c.type == TYPE_VIEW and c.sub_type == VIEW_SUBTYPE_PASSWORD for c in field.children()) diff --git a/server/python/src/metaobjects/meta/core/reporting/report_read_model.py b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py index 437a373cac..2c9a9d8b66 100644 --- a/server/python/src/metaobjects/meta/core/reporting/report_read_model.py +++ b/server/python/src/metaobjects/meta/core/reporting/report_read_model.py @@ -30,7 +30,7 @@ from weakref import WeakKeyDictionary -from ....shared.base_types import TYPE_FIELD +from ....shared.base_types import TYPE_FIELD, TYPE_VIEW from ...meta_root import MetaRoot from ...persistence.db.db_constants import FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME from ...persistence.source.meta_source import MetaSource @@ -47,6 +47,9 @@ FIELD_ATTR_STORAGE, FIELD_ATTR_VALUES, ) +from ...presentation.view.meta_view import MetaView +from ...presentation.view.view_constants import VIEW_SUBTYPE_PASSWORD +from ..field.credential import is_credential_field from ..field.meta_field import MetaField from ..object.meta_object import MetaObject from .report_shape import ReportField, report_shape @@ -94,6 +97,11 @@ def _derived_field(f: ReportField) -> MetaField: # ``is_array`` is a native flag, not an attr; ``resolved_is_array()`` is its resolving read. if src.resolved_is_array(): field.is_array = True + # #484: a credential stays one through derivation (resolving read: an inherited + # ``view.password`` counts), so every generator and runtime that reads this model + # hides the column like an entity credential. + if is_credential_field(src): + field.add_child(MetaView(TYPE_VIEW, VIEW_SUBTYPE_PASSWORD, VIEW_SUBTYPE_PASSWORD)) # Table C (Plan 3): a report author has no node to put @filterable on, so every # derived field that has a filter band is filterable. Set on this detached model # only; no vocabulary is added and the declared tree is not touched. Imported here diff --git a/server/python/tests/codegen/test_report_credential.py b/server/python/tests/codegen/test_report_credential.py new file mode 100644 index 0000000000..3668d27e71 --- /dev/null +++ b/server/python/tests/codegen/test_report_credential.py @@ -0,0 +1,126 @@ +"""#484 — a report column is DERIVED from its ``@of`` field, so it used to lose the field's +``view.password`` and be served. A grouped or min-ed credential stays a credential: the +report's list route and export hide it, as an entity's do.""" +from __future__ import annotations + +import importlib.util +import json +import re +import shutil +import sys +import tempfile +import uuid +from pathlib import Path +from typing import Any + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from metaobjects import MetaDataLoader +from metaobjects.codegen.generators.filter_allowlist_generator import render_filter_allowlist +from metaobjects.codegen.generators.report_export_generator import ReportExportGenerator +from metaobjects.codegen.generators.router_generator import render_router +from metaobjects.meta.core.field.credential import is_credential_field +from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.core.reporting.report_read_model import report_read_model +from metaobjects.shared.base_types import TYPE_OBJECT + +_MODEL = {"metadata.root": {"package": "acme::acct", "children": [ + {"object.entity": {"name": "Account", "children": [ + {"source.rdb": {"@table": "accounts"}}, + {"field.long": {"name": "id"}}, + {"field.string": {"name": "name", "@required": True, "@maxLength": 40}}, + {"field.string": {"name": "pinHash", "@maxLength": 80, "children": [{"view.password": {}}]}}, + {"identity.primary": {"name": "pk", "@fields": "id", "@generation": "increment"}}, + {"dimension.attribute": {"name": "name", "@of": "Account.name"}}, + {"dimension.attribute": {"name": "pinHash", "@of": "Account.pinHash"}}, + {"measure.aggregate": {"name": "accounts", "@agg": "count", "@of": "Account.id"}}, + {"measure.aggregate": {"name": "firstPin", "@agg": "min", "@of": "Account.pinHash"}}, + ]}}, + {"object.report": {"name": "AccountsByPin", "@from": "Account", "@dimensions": ["pinHash"], + "@measures": ["accounts"], "children": [{"source.rdb": {"@kind": "view", "@view": "v_by_pin"}}]}}, + {"object.report": {"name": "AccountPinRange", "@from": "Account", "@dimensions": ["name"], + "@measures": ["firstPin"], "children": [{"source.rdb": {"@kind": "view", "@view": "v_pin_range"}}]}}, + {"object.report": {"name": "AccountsByName", "@from": "Account", "@dimensions": ["name"], + "@measures": ["accounts"], "children": [{"source.rdb": {"@kind": "view", "@view": "v_by_name"}}]}}, +]}} + + +def _models() -> dict[str, MetaObject]: + tmp = Path(tempfile.mkdtemp(prefix="report-credential-")) + try: + (tmp / "meta.json").write_text(json.dumps(_MODEL)) + result = MetaDataLoader.from_directory(str(tmp)) + assert not result.errors, [e.message for e in result.errors] + return { + c.name: report_read_model(c, result.root) + for c in result.root.children() + if c.type == TYPE_OBJECT and isinstance(c, MetaObject) and c.sub_type == "report" + } + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +_R = _models() + + +def _field(report: str, name: str): + return next(f for f in _R[report].fields() if f.name == name) + + +def test_derived_fields_over_a_credential_are_credentials() -> None: + assert is_credential_field(_field("AccountsByPin", "pinHash")) + assert is_credential_field(_field("AccountPinRange", "firstPin")) + assert not is_credential_field(_field("AccountPinRange", "name")) + assert not is_credential_field(_field("AccountsByPin", "accounts")) + + +def test_router_hides_only_the_credential_columns() -> None: + assert "hidden=('pinHash',)" in render_router(_R["AccountsByPin"]) + assert "hidden=('firstPin',)" in render_router(_R["AccountPinRange"]) + assert "hidden=" not in render_router(_R["AccountsByName"]) + + +def test_export_names_no_credential_column() -> None: + gen = ReportExportGenerator() + assert 'ExportColumn("pinHash"' not in gen.render_report(_R["AccountsByPin"]) + assert 'ExportColumn("accounts"' in gen.render_report(_R["AccountsByPin"]) + assert 'ExportColumn("firstPin"' not in gen.render_report(_R["AccountPinRange"]) + assert 'ExportColumn("name"' in gen.render_report(_R["AccountsByName"]) + + +class _Repo: + def __init__(self, rows: list[dict[str, Any]]) -> None: + self._rows = rows + + def list(self, limit: int, offset: int, sort: Any, filters: list[Any]) -> list[Any]: + return [dict(r) for r in self._rows] + + def count(self, filters: list[Any]) -> int: + return len(self._rows) + + +def test_served_report_list_omits_the_credential_key_over_http() -> None: + report = _R["AccountsByPin"] + pkg = f"genrep_{uuid.uuid4().hex[:8]}" + pkg_dir = Path(tempfile.mkdtemp(prefix="report-credential-gen-")) / pkg + pkg_dir.mkdir() + (pkg_dir / "__init__.py").write_text("") + (pkg_dir / "accounts_by_pin_filter_allowlist.py").write_text(render_filter_allowlist(report)) + (pkg_dir / "accounts_by_pin_router.py").write_text(render_router(report)) + sys.path.insert(0, str(pkg_dir.parent)) + for name, init in ((pkg, "__init__.py"), (f"{pkg}.accounts_by_pin_router", "accounts_by_pin_router.py")): + spec = importlib.util.spec_from_file_location( + name, pkg_dir / init, submodule_search_locations=[str(pkg_dir)] if init == "__init__.py" else None + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + app = FastAPI() + app.include_router(mod.router) + app.dependency_overrides[mod.get_repository] = lambda: _Repo( + [{"pinHash": "pin-hash-1", "accounts": 1}] + ) + prefix = re.search(r'APIRouter\(prefix="([^"]+)"', render_router(report)).group(1) + body = TestClient(app).get(prefix).json() + assert body == [{"accounts": 1}] diff --git a/server/typescript/packages/codegen-ts/src/reference/report-export.ts b/server/typescript/packages/codegen-ts/src/reference/report-export.ts index 0e43463a00..63310482ac 100644 --- a/server/typescript/packages/codegen-ts/src/reference/report-export.ts +++ b/server/typescript/packages/codegen-ts/src/reference/report-export.ts @@ -29,6 +29,7 @@ import { effectivePackage, entityOutputPath, formatTs, + isCredentialField, isReport, relativeModuleSpecifier, type EmittedFile, @@ -152,9 +153,12 @@ export function writeXml(root: string, columns: readonly ExportColumn[], rows: r `; } -/** The report's exported fields, in derived-field order (the read model's declared order). */ +/** + * The report's exported fields, in derived-field order (the read model's declared order). A + * credential (#484) is not a column: the list route never returns it, and an export names none. + */ export function exportColumns(report: MetaObject): { name: string; guard: boolean }[] { - return report.fields().map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); + return report.fields().filter((f) => !isCredentialField(f)).map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); } /** The per-report module: its column list and the CSV and XML writers, in the shape a mount takes. */ diff --git a/server/typescript/packages/codegen-ts/src/templates/report-export-file.ts b/server/typescript/packages/codegen-ts/src/templates/report-export-file.ts index ae89939a60..ddb05fad3d 100644 --- a/server/typescript/packages/codegen-ts/src/templates/report-export-file.ts +++ b/server/typescript/packages/codegen-ts/src/templates/report-export-file.ts @@ -7,6 +7,7 @@ // `wire` setting, booleans) is already decided by the route; the writers only spell it. import type { MetaObject } from "@metaobjectsdev/metadata"; +import { isCredentialField } from "../api-surface.js"; import { GENERATED_HEADER, GENERATED_EDIT_NOTE } from "../constants.js"; import { relativeModuleSpecifier, type ResolvedTarget } from "../import-path.js"; import type { ExtStyle } from "../render-context.js"; @@ -124,9 +125,12 @@ export function writeXml(root: string, columns: readonly ExportColumn[], rows: r `; } -/** The report's exported fields, in derived-field order (the read model's declared order). */ +/** + * The report's exported fields, in derived-field order (the read model's declared order). A + * credential (#484) is not a column: the list route never returns it, and an export names none. + */ export function exportColumns(report: MetaObject): { name: string; guard: boolean }[] { - return report.fields().map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); + return report.fields().filter((f) => !isCredentialField(f)).map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); } /** The per-report module: its column list and the CSV and XML writers, in the shape a mount takes. */ diff --git a/server/typescript/packages/codegen-ts/test/report-credential.test.ts b/server/typescript/packages/codegen-ts/test/report-credential.test.ts new file mode 100644 index 0000000000..0b549097d8 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/report-credential.test.ts @@ -0,0 +1,98 @@ +// #484: a report column is DERIVED from its `@of` field, so it would lose the field's `view.password` +// and be served. A grouped or min/max-ed credential stays a credential: the report's list route +// hides it, as an entity's does. + +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, reportReadModel, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; +import { routesFile } from "../src/generators/routes-file.js"; +import { routesFileHono } from "../src/generators/routes-file-hono.js"; +import { makeRenderContext } from "../src/render-context.js"; +import { buildPkMap } from "../src/pk-resolver.js"; +import { buildRelationMap } from "../src/relation-resolver.js"; +import { isCredentialField } from "../src/api-surface.js"; +import { generatableObjects } from "../src/source-detect.js"; +import { exportColumns } from "../src/templates/report-export-file.js"; +import type { EmittedFile, Generator, GenContext } from "../src/generator.js"; + +const MODEL = { + "metadata.root": { + package: "acme::acct", + children: [ + { "object.entity": { name: "Account", children: [ + { "source.rdb": { "@table": "accounts" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "name", "@required": true, "@maxLength": 40 } }, + { "field.string": { name: "pinHash", "@maxLength": 80, children: [{ "view.password": {} }] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "dimension.attribute": { name: "name", "@of": "Account.name" } }, + { "dimension.attribute": { name: "pinHash", "@of": "Account.pinHash" } }, + { "measure.aggregate": { name: "accounts", "@agg": "count", "@of": "Account.id" } }, + { "measure.aggregate": { name: "firstPin", "@agg": "min", "@of": "Account.pinHash" } }, + ]}}, + { "object.report": { name: "AccountsByPin", "@from": "Account", "@dimensions": ["pinHash"], "@measures": ["accounts"], + children: [{ "source.rdb": { "@kind": "view", "@view": "v_accounts_by_pin" } }] } }, + { "object.report": { name: "AccountPinRange", "@from": "Account", "@dimensions": ["name"], "@measures": ["firstPin"], + children: [{ "source.rdb": { "@kind": "view", "@view": "v_account_pin_range" } }] } }, + { "object.report": { name: "AccountsByName", "@from": "Account", "@dimensions": ["name"], "@measures": ["accounts"], + children: [{ "source.rdb": { "@kind": "view", "@view": "v_accounts_by_name" } }] } }, + ], + }, +}; + +async function load(): Promise { + const r = await new MetaDataLoader({ strict: true }).load([new InMemoryStringSource(JSON.stringify(MODEL), { id: "report-credential.json" })]); + expect(r.errors.map((e) => e.message)).toEqual([]); + return r.root; +} + +async function generate(gen: Generator, root: MetaRoot): Promise { + const renderContext = makeRenderContext({ + dialect: "postgres", loadedRoot: root, outDir: "/x", dbImport: "./db", + pkMap: buildPkMap(root), relationMap: buildRelationMap(root), includeNames: true, + }); + const ctx: GenContext = { + // runGen hands a generator the served reports as their read models. + entities: generatableObjects(root.objects(), root), loadedRoot: root, matches: (e: MetaObject) => gen.filter?.(e) ?? true, + projectRoot: "/x", config: { outDir: "/x", extStyle: "none", dbImport: "./db", dialect: "postgres" } as never, + renderContext, warn: () => {}, + }; + return await gen.generate(ctx); +} + +const routeOf = (files: EmittedFile[], name: string): string => { + const f = files.find((x) => x.path.startsWith(`${name}.routes`)); + if (f === undefined) throw new Error(`no routes for ${name}: ${files.map((x) => x.path).join(", ")}`); + return f.content.replace(/\s+/g, " "); +}; + +describe("a report over a credential field (#484)", () => { + test("the read model's derived field is a credential, a plain one is not", async () => { + const root = await load(); + const byPin = reportReadModel(root.objects().find((o) => o.name === "AccountsByPin")!, root); + expect(isCredentialField(byPin.fields().find((f) => f.name === "pinHash")!)).toBe(true); + const range = reportReadModel(root.objects().find((o) => o.name === "AccountPinRange")!, root); + expect(isCredentialField(range.fields().find((f) => f.name === "firstPin")!)).toBe(true); + expect(isCredentialField(range.fields().find((f) => f.name === "name")!)).toBe(false); + }); + + test("the Fastify report route hides a grouped or min-ed credential and nothing else", async () => { + const files = await generate(routesFile(), await load()); + expect(routeOf(files, "AccountsByPin")).toContain('hiddenColumns: ["pinHash"]'); + expect(routeOf(files, "AccountPinRange")).toContain('hiddenColumns: ["firstPin"]'); + expect(routeOf(files, "AccountsByName")).not.toContain("hiddenColumns"); + }); + + test("the Hono report route hides it too", async () => { + const files = await generate(routesFileHono(), await load()); + expect(routeOf(files, "AccountsByPin")).toContain('hiddenColumns: ["pinHash"]'); + expect(routeOf(files, "AccountsByName")).not.toContain("hiddenColumns"); + }); + + test("a report export names no column for a credential", async () => { + const root = await load(); + const names = (n: string) => exportColumns(reportReadModel(root.objects().find((o) => o.name === n)!, root)).map((c) => c.name); + expect(names("AccountsByPin")).toEqual(["accounts"]); + expect(names("AccountPinRange")).toEqual(["name"]); + expect(names("AccountsByName")).toEqual(["name", "accounts"]); + }); +}); diff --git a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts index 0e079bc831..7818d1ef53 100644 --- a/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts +++ b/server/typescript/packages/metadata/src/core/reporting/report-read-model.ts @@ -23,7 +23,7 @@ // holding it can still tell it is a report (no identity, read-only). import { TypeId } from "../../registry.js"; -import { TYPE_FIELD } from "../../shared/base-types.js"; +import { TYPE_FIELD, TYPE_VIEW } from "../../shared/base-types.js"; import type { MetaRoot } from "../../shared/meta-root.js"; import { isReadOnlySource } from "../../shared/node-guards.js"; import { MetaSource } from "../../persistence/source/meta-source.js"; @@ -31,6 +31,8 @@ import { SOURCE_ATTR_ROLE, SOURCE_ROLE_PRIMARY } from "../../persistence/source/ import { FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME } from "../../persistence/db/db-constants.js"; import { MetaObject } from "../object/meta-object.js"; import { MetaField } from "../field/meta-field.js"; +import { MetaView } from "../../presentation/view/meta-view.js"; +import { VIEW_SUBTYPE_PASSWORD } from "../../presentation/view/view-constants.js"; import { FIELD_ATTR_CURRENCY, FIELD_ATTR_FILTERABLE, @@ -51,7 +53,7 @@ import { reportShape, type ReportField } from "./report-shape.js"; * read with the RESOLVING accessor (ADR-0039) so a value the `@of` field inherits * through `extends` is carried too. `@dbColumnType` and `isArray` are handled * separately below. Nothing else is carried: no `@column`, `@required`, - * `@default`, validators or views. + * `@default` or validators. A view is carried only for a credential (below). */ const CARRIED_ATTRS = [ FIELD_ATTR_CURRENCY, @@ -91,6 +93,13 @@ function derivedField(f: ReportField): MetaField { if (dbColumnType !== undefined) field.setAttr(FIELD_ATTR_DB_COLUMN_TYPE, dbColumnType); // `isArray` is a native flag, not an attr; resolvedIsArray() is its resolving read. if (src.resolvedIsArray()) field.setIsArray(true); + // #484: a derived field is a copy of its source's value, so a credential (`view.password`, + // ADR-0039: resolving, it may be inherited) stays one. The read model is the only place a + // report's fields exist, so this is what hides a grouped or min/max-ed secret from the + // report's list route in every generator and runtime that reads the model. + if (src.views().some((v) => v.subType === VIEW_SUBTYPE_PASSWORD)) { + field.addChild(new MetaView(new TypeId(TYPE_VIEW, VIEW_SUBTYPE_PASSWORD), VIEW_SUBTYPE_PASSWORD)); + } } // Table C (Plan 3): a report author has no node to put @filterable on, so every // derived field that has a filter band is filterable, and (the sort allowlist follows diff --git a/server/typescript/packages/runtime-ts/test/report-hidden-column.test.ts b/server/typescript/packages/runtime-ts/test/report-hidden-column.test.ts new file mode 100644 index 0000000000..d8406d930e --- /dev/null +++ b/server/typescript/packages/runtime-ts/test/report-hidden-column.test.ts @@ -0,0 +1,67 @@ +// #484: a report that groups by a credential column is served with `hiddenColumns` (the generator +// derives it from the read model), so the list route over HTTP omits the key, on both adapters. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import Fastify, { type FastifyInstance } from "fastify"; +import { Hono } from "hono"; +import { createClient } from "@libsql/client"; +import { drizzle } from "drizzle-orm/libsql"; +import { sqliteView, integer, text } from "drizzle-orm/sqlite-core"; +import { mountReadOnlyCrudRoutes as mountFastify } from "../src/drizzle-fastify/mount-read-only.js"; +import { mountReadOnlyCrudRoutes as mountHono } from "../src/hono/mount-read-only.js"; + +describe("a report list route hides a credential column", () => { + let client: ReturnType; + let fastify: FastifyInstance; + let plain: FastifyInstance; + let hono: Hono; + + const view = () => sqliteView("v_accounts_by_pin", { + pinHash: text("pin_hash"), + accounts: integer("accounts").notNull(), + }).existing(); + + beforeAll(async () => { + client = createClient({ url: ":memory:" }); + await client.execute(`CREATE TABLE accounts (id INTEGER PRIMARY KEY, pin_hash TEXT)`); + await client.execute(`INSERT INTO accounts (pin_hash) VALUES ('h1'), ('h1'), ('h2')`); + await client.execute(`CREATE VIEW v_accounts_by_pin AS SELECT pin_hash, COUNT(*) AS accounts FROM accounts GROUP BY pin_hash`); + const base = { path: "/accounts_by_pins", db: drizzle(client), view: view(), filterAllowlist: {}, sortAllowlist: {}, dialect: "sqlite" as const, itemRoutes: false, resource: "report" as const }; + + fastify = Fastify(); + mountFastify({ fastify, ...base, hiddenColumns: ["pinHash"] }); + await fastify.ready(); + + plain = Fastify(); + mountFastify({ fastify: plain, ...base }); + await plain.ready(); + + hono = new Hono(); + mountHono({ app: hono, ...base, hiddenColumns: ["pinHash"] }); + }); + + afterAll(async () => { + await fastify.close(); + await plain.close(); + client.close(); + }); + + const sorted = (rows: Record[]) => [...rows].sort((a, b) => Number(a["accounts"]) - Number(b["accounts"])); + + test("Fastify: every row lacks the key, the measure stays", async () => { + const res = await fastify.inject({ url: "/accounts_by_pins" }); + expect(res.statusCode).toBe(200); + expect(sorted(res.json())).toEqual([{ accounts: 1 }, { accounts: 2 }]); + }); + + test("Hono: every row lacks the key, the measure stays", async () => { + const res = await hono.request("/accounts_by_pins"); + expect(res.status).toBe(200); + expect(sorted((await res.json()) as Record[])).toEqual([{ accounts: 1 }, { accounts: 2 }]); + }); + + test("without hiddenColumns the same report serves the column (the exposure this closes)", async () => { + const res = await plain.inject({ url: "/accounts_by_pins" }); + expect(sorted(res.json())).toEqual([{ pinHash: "h2", accounts: 1 }, { pinHash: "h1", accounts: 2 }]); + }); +}); diff --git a/server/typescript/packages/test-generators/src/generators/report-export.ts b/server/typescript/packages/test-generators/src/generators/report-export.ts index 0e43463a00..63310482ac 100644 --- a/server/typescript/packages/test-generators/src/generators/report-export.ts +++ b/server/typescript/packages/test-generators/src/generators/report-export.ts @@ -29,6 +29,7 @@ import { effectivePackage, entityOutputPath, formatTs, + isCredentialField, isReport, relativeModuleSpecifier, type EmittedFile, @@ -152,9 +153,12 @@ export function writeXml(root: string, columns: readonly ExportColumn[], rows: r `; } -/** The report's exported fields, in derived-field order (the read model's declared order). */ +/** + * The report's exported fields, in derived-field order (the read model's declared order). A + * credential (#484) is not a column: the list route never returns it, and an export names none. + */ export function exportColumns(report: MetaObject): { name: string; guard: boolean }[] { - return report.fields().map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); + return report.fields().filter((f) => !isCredentialField(f)).map((f) => ({ name: f.name, guard: GUARDED_SUBTYPES.has(f.subType) })); } /** The per-report module: its column list and the CSV and XML writers, in the shape a mount takes. */ From 862eb88eb09ebb2876a5bd44198675e07b867d2e Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 28 Oct 2026 20:01:17 -0400 Subject: [PATCH 15/16] fix(codegen): a credential report column is neither filterable nor sortable (#484) A served report marks every derived field filterable and sortable, so a column hidden from the response could still be probed with a like-prefix filter or a sort, recovering the secret one prefix at a time. A derived field over a credential now carries no filter or sort entry in every port. Also documents it in the CHANGELOG, api-contract and reporting guides. --- CHANGELOG.md | 2 +- docs/features/api-contract.md | 4 +-- docs/features/reporting.md | 2 +- .../ReportCredentialCodegenTests.cs | 30 +++++++++++++++++++ .../Generators/RoutesGenerator.cs | 3 +- .../csharp/MetaObjects.Codegen/ReportRows.cs | 9 ++++-- .../kotlin/KotlinFilterAllowlistGenerator.kt | 10 +++++-- .../kotlin/KotlinSpringControllerGenerator.kt | 2 ++ .../kotlin/KotlinReportCredentialTest.kt | 18 +++++++++++ .../spring/SpringControllerGenerator.java | 4 +++ .../SpringFilterAllowlistGenerator.java | 12 ++++++++ .../spring/SpringReportCredentialTest.java | 24 +++++++++++++++ .../codegen/generators/router_generator.py | 2 ++ .../meta/core/reporting/report_read_model.py | 4 ++- .../tests/codegen/test_report_credential.py | 17 +++++++++++ .../codegen-ts/test/report-credential.test.ts | 13 ++++++++ .../src/core/reporting/report-read-model.ts | 11 +++++-- 17 files changed, 154 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8f188df8..e75772e92f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,7 +241,7 @@ here.** an edit form that submits every field still saves, and a present `null` no longer clears it. A credential is also never returned: not by list, get, or the echo of a write, or by a served report that groups by it or takes its `min` or `max` (a report column is derived from its `@of` field, and it keeps the - credential mark; a report export names no column for it). Before this, `@formExclude` kept + credential mark; a report export names no column for it, and that column is not filterable or sortable). Before this, `@formExclude` kept a field out of the generated form while the route still wrote it, and a generated read returned password hashes. Generated forms (React, Angular) and the api docs omit credentials too. Your own code is unaffected: the entity, DTO and generated queries still carry the field, so you keep writing a hash or token yourself. diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 71ac8a75fd..4f3f91f9f3 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -552,8 +552,8 @@ route; the generated one will not. Generated forms and the api docs omit both ki A served report is covered too. A report column is derived from its `@of` field, and a derived column keeps that field's credential mark, so a report that groups by a credential (or takes its `min` or `max`) never -returns the column, and its `report-export` output has no column for it. `count` over a credential is a -number and is returned. +returns the column, its `report-export` output has no column for it, and it is neither filterable nor sortable +(a report's other columns all are, so this one is taken out). `count` over a credential is a number and is returned. `fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port. diff --git a/docs/features/reporting.md b/docs/features/reporting.md index d166d0ba2d..ab2140965b 100644 --- a/docs/features/reporting.md +++ b/docs/features/reporting.md @@ -301,7 +301,7 @@ A derived column carries the type-shaping attributes of its `@of` field where th `@required` beyond the rules above, no `@default` (a measure's `@default` is applied in the view, not carried), no validators. The one view it does carry is a credential: a column whose `@of` field declares or inherits `view.password` is itself a credential, so the served list route -omits it and an export has no column for it (#484). +omits it, an export has no column for it, and it is not filterable or sortable (#484). ### Measures diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs index d540e99045..6f23085912 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportCredentialCodegenTests.cs @@ -99,6 +99,36 @@ public void A_plain_report_is_unchanged() Assert.Contains("\"name\"", Export(ctx, "AccountsByName")); } + // A hidden column that can still narrow or order the rows is an oracle: `?filter[pinHash][like]=ab%` + // or `?sort=pinHash` would recover the secret a prefix at a time. + [Theory] + [InlineData("AccountsByPin", "pinHash", "accounts")] + [InlineData("AccountPinRange", "firstPin", "name")] + public void A_credential_column_is_neither_filterable_nor_sortable(string report, string column, string kept) + { + var ctx = Ctx(Load()); + var allowlist = new FilterAllowlistGenerator().Generate(ctx).Single(f => f.Path == report + "FilterAllowlist.g.cs").Content; + Assert.DoesNotContain($"\"{column}\"", allowlist); + Assert.Contains($"\"{kept}\"", allowlist); + + var routes = Routes(ctx, report); + var pascal = char.ToUpperInvariant(column[0]) + column[1..]; + Assert.DoesNotContain($"\"{pascal}\"", routes); + Assert.Contains($"\"{char.ToUpperInvariant(kept[0])}{kept[1..]}\"", routes); + } + + [Fact] + public void A_plain_reports_allowlists_keep_every_column() + { + var ctx = Ctx(Load()); + var allowlist = new FilterAllowlistGenerator().Generate(ctx).Single(f => f.Path == "AccountsByNameFilterAllowlist.g.cs").Content; + Assert.Contains("\"name\"", allowlist); + Assert.Contains("\"accounts\"", allowlist); + var routes = Routes(ctx, "AccountsByName"); + Assert.Contains("\"Name\"", routes); + Assert.Contains("\"Accounts\"", routes); + } + [Fact] public void The_credential_report_routes_and_export_compile() { diff --git a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs index 40643a2932..12f9b08bc5 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs @@ -261,7 +261,8 @@ protected virtual EmittedFile GenerateStandardRoutes(MetaObject entity, GenConte var sortFields = entity.Fields() .Where(f => (CSharpNaming.ScalarFor(f.SubType) is not null || (isReport && f.SubType == FIELD_SUBTYPE_ENUM)) - && !f.ResolvedIsArray()) + && !f.ResolvedIsArray() + && !(isReport && IsCredentialField(f))) // a credential column would be a sort oracle .Select(f => CSharpNaming.Pascal(f.Name)) .ToList(); diff --git a/server/csharp/MetaObjects.Codegen/ReportRows.cs b/server/csharp/MetaObjects.Codegen/ReportRows.cs index a4dc08800a..afe8752bdc 100644 --- a/server/csharp/MetaObjects.Codegen/ReportRows.cs +++ b/server/csharp/MetaObjects.Codegen/ReportRows.cs @@ -177,8 +177,9 @@ private static MetaField DerivedField(ReportField f) // source declares or inherits a view.password carries one, so every generator and runtime // that reads the row model (routes, export) hides it exactly as it hides an entity's. // ADR-0039: resolving (Views()), an inherited view.password counts. - if (f.TypeSource is { } cred - && cred.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD)) + bool credential = f.TypeSource is { } cred + && cred.Views().Any(v => v.SubType == MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD); + if (credential) field.AddChild(new MetaView(new TypeId(TYPE_VIEW, MetaObjects.Presentation.View.ViewConstants.VIEW_SUBTYPE_PASSWORD), "password")); // Table C: every derived field whose type has a filter band is filterable. Asked // last, of the finished field, because an int-backed enum's band depends on a @@ -186,7 +187,9 @@ private static MetaField DerivedField(ReportField f) // `@filterable` on. // An array or a map has no scalar operator and no ordering, so a dimension over one is // neither filterable nor (see RoutesGenerator's sort list) sortable. - if (!field.ResolvedIsArray() && field.SubType != FIELD_SUBTYPE_MAP + // A credential is neither filterable nor sortable: a hidden column that still orders or + // narrows the rows is an oracle (`?filter[pin][like]=ab%` recovers it a prefix at a time). + if (!credential && !field.ResolvedIsArray() && field.SubType != FIELD_SUBTYPE_MAP && QueryConstants.OpsForField(field).Length > 0) field.SetAttr(FIELD_ATTR_FILTERABLE, true); return field; diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt index 98d4d6bfb6..baddea1d3e 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinFilterAllowlistGenerator.kt @@ -175,8 +175,14 @@ open class KotlinFilterAllowlistGenerator : MultiFileDirectGeneratorBase> = - computeFilterableOps(entity.metaFields) + fun computeFilterableOps(entity: MetaObject): Map> { + val ops = computeFilterableOps(entity.metaFields) + if (!com.metaobjects.generator.util.GeneratorUtil.isReport(entity)) return ops + // #484: a report's credential column is hidden from the response, so it must not be filterable + // either: a filter would let a caller recover the hidden value a prefix at a time. + val credentials = KotlinGenUtil.reportCredentialNames(entity).toSet() + return ops.filterKeys { it !in credentials } + } /** * [computeFilterableOps] over an explicit field list — used for a TPH base, whose allowlist diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt index 91792278a7..c691f0b6f2 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt @@ -2080,9 +2080,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase