You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 1dfb18a
Browse filesBrowse the repository at this point in the historyBrowse files
fix: keep credential and excluded fields out of generated routes, forms and reports (#503)
* 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
* fix(codegen): hide subtype credentials on an M:N onto a TPH base; Python redaction covers dataclass and mapping rows (#484)
* refactor: simplify the #484 changes
* fix(codegen): redact Java read-only mounts and Kotlin at the controller boundary, prove denied writes on stored rows (#484)
* no-mistakes(review): apply reviewer inline fixes
* no-mistakes(review): Trim codegen-ts barrel, note required-denied create, behavioral projection test
* no-mistakes(review): Add behavioral credential checks for TS/Python projection and TPH M:N
* no-mistakes(review): Count TPH credential and denied marks on every subtype
* no-mistakes(review): Deny hierarchy-marked TPH columns on every subtype PATCH
* no-mistakes(review): Fix stale KDoc on Kotlin M:N credential fields
* no-mistakes(review): Widen TS and C# TPH subtype marks to hierarchy
* no-mistakes(test): Import TphPlan in ejectable SpringGeneratorSupport, widen discriminatorRoot
* no-mistakes(document): Docs current; fixed capability checklist claim that view.password is TS-only
* 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.
* 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.
* no-mistakes(review): Test C# report JSON export omits credential keys
Copy file name to clipboardExpand all lines: .claude/rules/cross-language-porting.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -48,7 +48,7 @@ Preserve the following contracts exactly across all language ports:
48
48
- Overlay/merge: same `package` + same object `name` across multiple files → merged. Last-writer-wins on attr conflicts; structural children accumulate.
**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/<seq>_<slug>.sql`), both of which are TS-only concerns.
51
+
**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/<seq>_<slug>.sql`), both of which are TS-only concerns.
52
52
53
53
**Constants discipline:**
54
54
- 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.
- 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.
202
+
- 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.
|[`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) |
39
-
|[`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 |
39
+
|[`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 |
@@ -365,9 +365,9 @@ REAL, a MySQL `DECIMAL` keeps its scale, a MySQL `DATETIME` is the UTC wall cloc
365
365
`report-relative-date` carries a per-engine seed (`seed-data-engine`). `op: relate` and the
366
366
write ops stay Postgres-only. See [features/reporting.md](features/reporting.md#what-the-corpus-gates).
367
367
368
-
### `fixtures/api-contract-conformance/` (117)
368
+
### `fixtures/api-contract-conformance/` (119)
369
369
370
-
All 117 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
370
+
All 119 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
371
371
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
372
372
routes answer identically over HTTP — list / get / create / patch+put / delete,
373
373
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
@@ -378,11 +378,11 @@ The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`,
378
378
`gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator
379
379
and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the
380
380
URL grammar `?filter[<field>][<op>]=<value>` (FR-009). On top of the 31 core
381
-
scenarios the corpus carries seven sub-corpora — `tph/` (10, single-table
381
+
scenarios the corpus carries eight sub-corpora — `tph/` (10, single-table
382
382
inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination
383
383
each corpus alone could not reach, and 1 pinning the collection-URL spelling),
384
384
`jsonb/` (5, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string),
385
-
`write-through/` (2, table-write + view-read entities), `projection/` (12, a
385
+
`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
386
386
read-only view answers reads and refuses writes with 405; a projection with no declared
387
387
identity has no item route; a projection keyed on a field not named `id` is addressed by
388
388
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
@@ -392,7 +392,7 @@ its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` re
392
392
a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java,
393
393
Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and
394
394
the port's own GENERATED API artifact booted over HTTP. `write-through/`,
395
-
`projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what
395
+
`secret-fields/`, `projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what
396
396
they test is whether a port's generator emits the routes. The `report/` sub-corpus is the one
397
397
that reads real views, so it runs full stack on Postgres, SQLite and MySQL
398
398
(`report/schema.sqlite.sql`, `report/schema.mysql.sql`, both TypeScript-produced) in every
@@ -606,7 +606,7 @@ files into a real Cube.
606
606
## Orphaned fixtures (tested but not yet documented)
607
607
608
608
The fixtures in the nine corpora mapped above (metamodel 413 + yaml 16 + verify 37
Copy file name to clipboardExpand all lines: docs/README.md
+3Lines changed: 3 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -84,6 +84,9 @@ this tree is documentation, not the source of truth.
84
84
| 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/)|
85
85
| Read the canonical spec (target-agnostic) |[`../spec/`](../spec/)|
86
86
| Find a release recipe (Cloudflare D1, etc.) |[`recipes/`](recipes/)|
87
+
| Keep drizzle-kit migrations while MetaObjects owns the model |[`recipes/drizzle-kit-migrations.md`](recipes/drizzle-kit-migrations.md)|
88
+
| Put generated routes behind your own auth (Fastify, Hono, `expose`) |[`recipes/guarding-generated-routes.md`](recipes/guarding-generated-routes.md)|
89
+
| Show that your `meta verify` CI gate actually fails on drift |[`recipes/prove-your-gate-fails.md`](recipes/prove-your-gate-fails.md)|
87
90
| See the deeper design rationale for a feature |[`superpowers/specs/`](superpowers/specs/)|
Copy file name to clipboardExpand all lines: docs/features/api-contract.md
+28Lines changed: 28 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -529,6 +529,34 @@ log-only.
529
529
| 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 `<Entity>FilterAllowlist` (`KotlinFilterAllowlistGenerator`) + an inline `parse<Entity>Filter` helper emitted in the controller. |
530
530
| 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 `<entity>_filter_allowlist.py` (`filter_allowlist_generator.py`) + the shared `filter_parser` helper, wired into the list handler. |
531
531
532
+
## Credentials and excluded fields
533
+
534
+
Two marks narrow what the generated routes accept and send, in every port (#484):
535
+
536
+
| Mark | Create, PATCH, PUT | List, get, and the echo of a write |
537
+
|---|---|---|
538
+
| `@formExclude: true` | the field is **ignored** | returned |
539
+
| a child `view.password` (a credential) | the field is **ignored** | **never returned** (the key is absent, not `null`) |
540
+
541
+
A value for an ignored field is dropped, never rejected: the request answers `200` or `201` as if the key
542
+
were absent, and a present `null` does not clear it. That is the rule for every key outside the settable
543
+
set (`@mutability: writeOnce` on PATCH, the TPH discriminator, `@autoSet: onCreate`), and it is load-bearing:
544
+
a generated edit form submits every field it was given, so a `400` would fail every save. A required field
545
+
that is also ignored can never be created through the API, so mark such a field optional.
546
+
547
+
Only the generated HTTP boundary changes. The entity, its DTO and the generated queries still carry the
548
+
field, so your own code writes a hash or token and reads it back. Allowlists are unchanged: a field is
549
+
filterable or sortable only if you wrote `@filterable` or `@sortable` on it, so do not put those on a credential
550
+
(a filter on a secret answers whether a guess is right). To return a credential from a route, hand-write that
551
+
route; the generated one will not. Generated forms and the api docs omit both kinds of field as well.
552
+
553
+
A served report is covered too. A report column is derived from its `@of` field, and a derived column keeps
554
+
that field's credential mark, so a report that groups by a credential (or takes its `min` or `max`) never
555
+
returns the column, its `report-export` output has no column for it, and it is neither filterable nor sortable
556
+
(a report's other columns all are, so this one is taken out). `count` over a credential is a number and is returned.
557
+
558
+
`fixtures/api-contract-conformance/secret-fields/` pins this in the generated lane of every port.
559
+
532
560
## Read-only projections
533
561
534
562
**All five ports serve an `object.projection` whose only source is a read-only
0 commit comments