@cosmicdrift/kumiko-framework 0.245.0 → 0.247.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.245.0",
3
+ "version": "0.247.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.245.0",
201
+ "@cosmicdrift/kumiko-types": "0.247.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.245.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.247.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -4,9 +4,10 @@
4
4
  // (userCanCreateFieldRow / userCanWriteFieldRow); there is no SQL layer on the
5
5
  // write path to run a where-rule against, and on create there is not even a
6
6
  // row yet. Boot validation rejects the shape outright
7
- // (boot-validator/ownership.ts), but setupTestStack wires the registry without
8
- // validateBoot, so this suite still reaches the runtime path the boot guard
9
- // protects and pins it to a fail-closed deny instead of a 500.
7
+ // (boot-validator/ownership.ts) and setupTestStack now runs that guard too, so
8
+ // the rule is installed after the stack is up — that is the only way left to
9
+ // reach the runtime path behind the guard and pin it to a fail-closed deny
10
+ // instead of a 500.
10
11
 
11
12
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
13
  import { asRawClient } from "../db/query";
@@ -17,7 +18,7 @@ import {
17
18
  defineEntityWriteHandler,
18
19
  defineFeature,
19
20
  } from "../engine";
20
- import type { SessionUser, WhereRule } from "../engine/types";
21
+ import type { OwnershipRule, SessionUser, WhereRule } from "../engine/types";
21
22
  import {
22
23
  createTestUser,
23
24
  setupTestStack,
@@ -36,6 +37,10 @@ const ownerWhereRule: WhereRule = {
36
37
  }),
37
38
  };
38
39
 
40
+ // Admin keeps a plain rule so the suite can seed rows over real HTTP; Member
41
+ // is the shape under test and is swapped in after boot (see beforeAll).
42
+ const memoWriteAccess: Record<string, OwnershipRule> = { Admin: "all", Member: "all" };
43
+
39
44
  const memoEntity = createEntity({
40
45
  table: "fw2626_memos",
41
46
  softDelete: true,
@@ -45,9 +50,7 @@ const memoEntity = createEntity({
45
50
  },
46
51
  access: {
47
52
  read: { Admin: "all", Member: "all" },
48
- // Admin keeps a plain rule so the suite can seed rows over real HTTP;
49
- // Member is the shape under test.
50
- write: { Admin: "all", Member: ownerWhereRule },
53
+ write: memoWriteAccess,
51
54
  },
52
55
  });
53
56
 
@@ -81,6 +84,7 @@ let seeded: { id: string; version: number };
81
84
  beforeAll(async () => {
82
85
  stack = await setupTestStack({ features: [memosFeature] });
83
86
  await unsafeCreateEntityTable(stack.db, memoEntity, "memo");
87
+ memoWriteAccess["Member"] = ownerWhereRule;
84
88
  });
85
89
 
86
90
  afterAll(async () => {
@@ -14,15 +14,14 @@
14
14
  // (DEK erased → Wert unlesbar, bidx NULL). Load-bearing Test:
15
15
  // db/__tests__/implicit-projection-equivalence.integration.test.ts.
16
16
  //
17
- // Tenant-Isolation: applyEntityEvent erwartet einen rohen DbRunner (TX
18
- // oder pool), KEINEN TenantDb-Wrapper. Schutz kommt aus zwei Quellen:
19
- // 1. Live-Pfad ruft VOR der Schreibung loadById (tenant-scoped) für
20
- // update/delete/restore — die aggregateId ist also schon tenant-
21
- // validiert bevor wir hier ankommen.
22
- // 2. Bei create wird tenantId explizit aus event.tenantId gesetzt, also
23
- // nie über den TenantDb-Wrapper-Default abgeleitet.
24
- // Damit ist der TenantDb-Wrapper-Loss in dieser Funktion funktional ohne
25
- // Sicherheitslücke.
17
+ // Tenant isolation: applyEntityEvent expects a raw DbRunner (tx or pool), NOT
18
+ // a TenantDb wrapper. Two sources keep it safe:
19
+ // 1. The live path reads the target row through the tenant-scoped TenantDb
20
+ // before writing — loadById() for update/delete/forget, db.fetchOne() for
21
+ // restore — so the aggregateId is already tenant-validated on arrival.
22
+ // 2. create sets tenantId explicitly from event.tenantId, never derived from
23
+ // the TenantDb wrapper default.
24
+ // The missing TenantDb wrapper in this function is therefore not a hole.
26
25
  //
27
26
  // Auto-Verben:
28
27
  // <entity>.created → INSERT
@@ -27,7 +27,7 @@ import {
27
27
  entityEventName,
28
28
  tryMapUniqueViolation,
29
29
  } from "./event-store-executor-context";
30
- import { runInSavepointIfSupported, selectMany } from "./query";
30
+ import { runInSavepointIfSupported } from "./query";
31
31
 
32
32
  // Art. 17 erasure runs as the framework operator, not as a row owner; a
33
33
  // per-role ownership map can never cover it, and a silent deny means the
@@ -714,7 +714,12 @@ export function createWriteVerbs(
714
714
  );
715
715
  }
716
716
 
717
- const [row] = await selectMany(db.raw, table, { id: payload.id });
717
+ // Tenant boundary: db.fetchOne applies TenantDb's tenant predicate,
718
+ // selectMany(db.raw, ...) did not — any caller could un-delete a foreign
719
+ // tenant's row by id. "system"-mode dbs (r.systemScope() / crossTenant
720
+ // handlers) still read unfiltered. No isDeleted filter here: restore
721
+ // targets exactly the soft-deleted row.
722
+ const row = await db.fetchOne(table, { id: payload.id });
718
723
  if (!row) return writeFailure(new NotFoundError(entityName, payload.id));
719
724
  const data = row as DbRow;
720
725
  if (!data["isDeleted"]) {
@@ -783,9 +788,9 @@ export function createWriteVerbs(
783
788
  throw e;
784
789
  }
785
790
 
786
- // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
787
- // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
788
- // kein payload-override nötig.
791
+ // Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
792
+ // plus the version bump, so there is no sensitive-field drift and no
793
+ // payload override is needed.
789
794
  const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
790
795
  applyEntityEvent(event, table, entity, sp),
791
796
  );
@@ -798,11 +803,11 @@ export function createWriteVerbs(
798
803
  await entityCache.del(user.tenantId, entityName, payload.id);
799
804
  }
800
805
 
801
- // Read-Side Auto-Convert für Compound-Types (parallel zu update/list).
806
+ // Read-side auto-convert for compound types, same as update/list.
802
807
  // decryptForRead matches create/update/list/detail: the caller-facing
803
808
  // row and `previous` snapshot must be plaintext for `encrypted` fields,
804
809
  // same as every other executor method — `data`/`restored` are raw rows
805
- // (selectMany / applyEntityEvent), never decrypted before this point.
810
+ // (db.fetchOne / applyEntityEvent), never decrypted before this point.
806
811
  const restoredHydrated = await decryptForRead(
807
812
  rehydrateCompoundTypes(restored as DbRow, entity) as DbRow,
808
813
  );
@@ -1266,7 +1266,7 @@ describe("boot-validator", () => {
1266
1266
  // Apps running only on openToAll / system handlers have no corpus
1267
1267
  // of known roles beyond "all"/"system" — validator must not flag
1268
1268
  // their OwnershipMap roles as unknown. This is the regression test
1269
- // for the shouldValidateRoles gate.
1269
+ // for the canValidateRoles gate.
1270
1270
  const features = [
1271
1271
  defineFeature("orders", (r) => {
1272
1272
  r.entity(
@@ -3851,6 +3851,83 @@ describe("boot-validator", () => {
3851
3851
  /rowAction "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
3852
3852
  );
3853
3853
  });
3854
+
3855
+ test("projectionDetail relatedList defaultSort.field not a listed column → throw", () => {
3856
+ const feature = defineFeature("shop", (r) => {
3857
+ r.screen({
3858
+ id: "order-detail",
3859
+ type: "projectionDetail",
3860
+ query: "shop:query:order-detail",
3861
+ layout: {
3862
+ sections: [
3863
+ {
3864
+ kind: "relatedList",
3865
+ title: "Positions",
3866
+ query: "shop:query:order-positions",
3867
+ columns: [{ field: "name", sortable: true }],
3868
+ defaultSort: { field: "price", dir: "asc" },
3869
+ },
3870
+ ],
3871
+ },
3872
+ });
3873
+ });
3874
+ expect(() => validateBoot([feature])).toThrow(
3875
+ /defaultSort\.field "price" is not a listed column/,
3876
+ );
3877
+ });
3878
+
3879
+ test("projectionDetail relatedList defaultSort.field not sortable → throw", () => {
3880
+ const feature = defineFeature("shop", (r) => {
3881
+ r.screen({
3882
+ id: "order-detail",
3883
+ type: "projectionDetail",
3884
+ query: "shop:query:order-detail",
3885
+ layout: {
3886
+ sections: [
3887
+ {
3888
+ kind: "relatedList",
3889
+ title: "Positions",
3890
+ query: "shop:query:order-positions",
3891
+ columns: [{ field: "name" }],
3892
+ defaultSort: { field: "name", dir: "asc" },
3893
+ },
3894
+ ],
3895
+ },
3896
+ });
3897
+ });
3898
+ expect(() => validateBoot([feature])).toThrow(/defaultSort\.field "name" is not sortable/);
3899
+ });
3900
+
3901
+ test("projectionDetail relatedList defaultSort.field sortable → kein Throw", () => {
3902
+ const feature = defineFeature("shop", (r) => {
3903
+ r.queryHandler("order-detail", z.object({}), async () => ({ id: "o1" }), {
3904
+ access: { openToAll: true },
3905
+ });
3906
+ r.queryHandler(
3907
+ "order-positions",
3908
+ z.object({}),
3909
+ async () => ({ rows: [], nextCursor: null }),
3910
+ { access: { openToAll: true } },
3911
+ );
3912
+ r.screen({
3913
+ id: "order-detail",
3914
+ type: "projectionDetail",
3915
+ query: "shop:query:order-detail",
3916
+ layout: {
3917
+ sections: [
3918
+ {
3919
+ kind: "relatedList",
3920
+ title: "Positions",
3921
+ query: "shop:query:order-positions",
3922
+ columns: [{ field: "name", sortable: true }],
3923
+ defaultSort: { field: "name", dir: "asc" },
3924
+ },
3925
+ ],
3926
+ },
3927
+ });
3928
+ });
3929
+ expect(() => validateBoot([feature])).not.toThrow();
3930
+ });
3854
3931
  });
3855
3932
 
3856
3933
  // --- Screen short-id collision across features ---
@@ -0,0 +1,143 @@
1
+ // restore() used to load its target row with selectMany(db.raw, ...) — a raw,
2
+ // unfiltered read. Any caller holding the write role could therefore un-delete
3
+ // a soft-deleted row belonging to another tenant and got the decrypted row back
4
+ // in the response. delete() never had that hole (it goes through the
5
+ // tenant-scoped loadById). These tests pin both halves: the tenant-scoped
6
+ // handler must not reach across, and the crossTenant handler must still be able
7
+ // to.
8
+ //
9
+ // Two stacks on the SAME Postgres database: one registers the restore handler
10
+ // without `crossTenant`, the other with it — a clean A/B on the one flag.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { selectMany } from "../../db/query";
14
+ import { buildEntityTable } from "../../db/table-builder";
15
+ import {
16
+ setupTestStack,
17
+ type TestStack,
18
+ TestUsers,
19
+ testTenantId,
20
+ unsafeCreateEntityTable,
21
+ } from "../../stack";
22
+ import {
23
+ createEntity,
24
+ createTextField,
25
+ defineEntityCreateHandler,
26
+ defineEntityDeleteHandler,
27
+ defineEntityRestoreHandler,
28
+ defineFeature,
29
+ from,
30
+ } from "../index";
31
+
32
+ const thingEntity = createEntity({
33
+ table: "ctrestore_things",
34
+ softDelete: true,
35
+ fields: {
36
+ label: createTextField({ required: true }),
37
+ },
38
+ access: { write: { Admin: "all", SystemAdmin: from("user:tenantId", "tenantId") } },
39
+ });
40
+ const thingTable = buildEntityTable("thing", thingEntity);
41
+
42
+ function buildFeature(crossTenant: boolean) {
43
+ return defineFeature("ctrestore", (r) => {
44
+ r.entity("thing", thingEntity);
45
+ r.writeHandler(
46
+ defineEntityCreateHandler("thing", thingEntity, {
47
+ access: { roles: ["Admin", "SystemAdmin"] },
48
+ }),
49
+ );
50
+ r.writeHandler(
51
+ defineEntityDeleteHandler("thing", thingEntity, {
52
+ access: { roles: ["Admin", "SystemAdmin"] },
53
+ }),
54
+ );
55
+ r.writeHandler(
56
+ defineEntityRestoreHandler("thing", thingEntity, {
57
+ access: { roles: ["Admin", "SystemAdmin"] },
58
+ ...(crossTenant && { crossTenant: true }),
59
+ }),
60
+ );
61
+ });
62
+ }
63
+
64
+ const dbName = `kumiko_test_ctrestore_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
65
+
66
+ let tenantScopedStack: TestStack;
67
+ let crossTenantStack: TestStack;
68
+
69
+ beforeAll(async () => {
70
+ tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
71
+ await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
72
+ crossTenantStack = await setupTestStack({
73
+ features: [buildFeature(true)],
74
+ dbName,
75
+ persistentDb: true,
76
+ });
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await crossTenantStack.cleanup();
81
+ await tenantScopedStack.cleanup();
82
+ });
83
+
84
+ async function createDeletedThing(label: string): Promise<string> {
85
+ const created = await tenantScopedStack.http.writeOk<{ id: string }>(
86
+ "ctrestore:write:thing:create",
87
+ { label },
88
+ TestUsers.otherTenant,
89
+ );
90
+ await tenantScopedStack.http.writeOk(
91
+ "ctrestore:write:thing:delete",
92
+ { id: created.id },
93
+ TestUsers.otherTenant,
94
+ );
95
+
96
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
97
+ expect(rows[0]?.["isDeleted"]).toBe(true);
98
+
99
+ return created.id;
100
+ }
101
+
102
+ describe("entity restore: tenant isolation", () => {
103
+ test("tenant-scoped restore cannot un-delete a foreign tenant's row", async () => {
104
+ const id = await createDeletedThing("foreign-deleted");
105
+
106
+ const err = await tenantScopedStack.http.writeErr(
107
+ "ctrestore:write:thing:restore",
108
+ { id },
109
+ TestUsers.admin,
110
+ );
111
+ expect(err.code).toBe("not_found");
112
+
113
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
114
+ expect(rows[0]?.["isDeleted"]).toBe(true);
115
+ });
116
+
117
+ test("own-tenant restore still works", async () => {
118
+ const id = await createDeletedThing("own-restore");
119
+
120
+ await tenantScopedStack.http.writeOk(
121
+ "ctrestore:write:thing:restore",
122
+ { id },
123
+ TestUsers.otherTenant,
124
+ );
125
+
126
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
127
+ expect(rows[0]?.["isDeleted"]).toBe(false);
128
+ });
129
+
130
+ test("crossTenant restore still reaches a foreign tenant's row", async () => {
131
+ const id = await createDeletedThing("crosstenant-restore");
132
+
133
+ await crossTenantStack.http.writeOk(
134
+ "ctrestore:write:thing:restore",
135
+ { id },
136
+ TestUsers.systemAdmin,
137
+ );
138
+
139
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id });
140
+ expect(rows[0]?.["isDeleted"]).toBe(false);
141
+ expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
142
+ });
143
+ });
@@ -11,7 +11,7 @@ import type { OwnershipMap, WhereRule } from "../../types/ownership";
11
11
  import { validateOwnershipRules } from "../ownership";
12
12
 
13
13
  const NO_CLAIMS: ReadonlyMap<string, ClaimKeyDefinition> = new Map();
14
- // Empty corpus keeps shouldValidateRoles() false, so role-existence checks
14
+ // Empty corpus keeps canValidateRoles() false, so role-existence checks
15
15
  // stay out of the way of the where-rule assertions below.
16
16
  const NO_ROLES: ReadonlySet<string> = new Set();
17
17
 
@@ -1,6 +1,6 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
- import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
3
+ import type { FeatureDefinition } from "../types";
4
4
  import { warnOnUniqueAccessRoles } from "./access-roles";
5
5
  import { validateActionWiring, validateFieldWiring } from "./action-wiring";
6
6
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
@@ -44,7 +44,7 @@ import {
44
44
  validateNavs,
45
45
  warnOnNavAccessInversion,
46
46
  } from "./nav";
47
- import { validateOwnershipRules } from "./ownership";
47
+ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
48
  import { validatePiiAndRetention } from "./pii-retention";
49
49
  import { validateProjectionListScreens } from "./projection-list-screens";
50
50
  import { validateQueryOutputColumns } from "./query-output-columns";
@@ -119,12 +119,7 @@ export function validateBoot(
119
119
  // `from("claim:<feature>:<key>")` strings against this map. Qualified name
120
120
  // is how the resolver / readClaim / ownership system all reference claims,
121
121
  // so we key on the qualifiedName here too.
122
- const allClaimKeys = new Map<string, ClaimKeyDefinition>();
123
- for (const f of features) {
124
- for (const def of Object.values(f.claimKeys)) {
125
- allClaimKeys.set(def.qualifiedName, def);
126
- }
127
- }
122
+ const allClaimKeys = collectClaimKeys(features);
128
123
 
129
124
  // Cross-feature role set — derived from handler-access rules + framework
130
125
  // built-ins ("all", "system"). We don't have a dedicated role-registry
@@ -44,10 +44,40 @@ export type WhereRuleProbe = {
44
44
  // SQL, so it is the only path on which a where-rule is admissible.
45
45
  export type AccessPath = "read" | "write";
46
46
 
47
+ // Qualified-name → definition map of every claim key in the mounted feature
48
+ // set. `from("claim:<feature>:<key>")` rules resolve against exactly this.
49
+ export function collectClaimKeys(
50
+ features: readonly FeatureDefinition[],
51
+ ): Map<string, ClaimKeyDefinition> {
52
+ const claimKeys = new Map<string, ClaimKeyDefinition>();
53
+ for (const f of features) {
54
+ for (const def of Object.values(f.claimKeys)) {
55
+ claimKeys.set(def.qualifiedName, def);
56
+ }
57
+ }
58
+ return claimKeys;
59
+ }
60
+
61
+ // Subset-safe ownership validation for callers that mount a deliberate slice
62
+ // of the app rather than all of it (setupTestStack). Both cross-feature
63
+ // corpora go in as undefined — a three-feature stack genuinely does not know
64
+ // which roles and claims the whole app declares, and answering that question
65
+ // from a slice produces false "unknown role"/"unknown claim" errors. The
66
+ // subset-invariant checks (#2639 where-lint, #2626 where-on-write, unknown
67
+ // column) run in full: those hold or fail per feature, whatever else is
68
+ // mounted.
69
+ export function validateOwnershipBoot(features: readonly FeatureDefinition[]): void {
70
+ for (const feature of features) {
71
+ validateOwnershipRules(feature, undefined, undefined);
72
+ }
73
+ }
74
+
75
+ // undefined corpus = "this caller cannot answer that question", not "skip the
76
+ // check" — see validateOwnershipBoot. validateBoot passes the real corpora.
47
77
  export function validateOwnershipRules(
48
78
  feature: FeatureDefinition,
49
- allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>,
50
- knownRoles: ReadonlySet<string>,
79
+ allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined,
80
+ knownRoles: ReadonlySet<string> | undefined,
51
81
  ): void {
52
82
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
53
83
  const columnNames = new Set<string>(Object.keys(entity.fields));
@@ -126,8 +156,8 @@ export function validateOwnershipRules(
126
156
  export function checkFieldAccess(args: {
127
157
  readonly access: OwnershipMap | readonly string[] | undefined;
128
158
  readonly columnNames: ReadonlySet<string>;
129
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
130
- readonly knownRoles: ReadonlySet<string>;
159
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
160
+ readonly knownRoles: ReadonlySet<string> | undefined;
131
161
  readonly scope: string;
132
162
  readonly featureName: string;
133
163
  readonly probe?: WhereRuleProbe;
@@ -162,12 +192,12 @@ export function checkFieldAccess(args: {
162
192
 
163
193
  export function checkLegacyRoleList(
164
194
  roles: readonly string[],
165
- knownRoles: ReadonlySet<string>,
195
+ knownRoles: ReadonlySet<string> | undefined,
166
196
  scope: string,
167
197
  featureName: string,
168
198
  ): void {
169
- // skip: no handler-declared roles in this app, role-validation disabled
170
- if (!shouldValidateRoles(knownRoles)) return;
199
+ // skip: corpus cannot answer "is this role real?" — see canValidateRoles
200
+ if (!canValidateRoles(knownRoles)) return;
171
201
  for (const roleName of roles) {
172
202
  if (!knownRoles.has(roleName)) {
173
203
  throw new Error(buildUnknownRoleMessage(roleName, knownRoles, scope, featureName));
@@ -175,13 +205,15 @@ export function checkLegacyRoleList(
175
205
  }
176
206
  }
177
207
 
178
- // Only validate role-existence when at least one handler in the system has
179
- // declared a non-builtin role. Apps that run entirely on openToAll +
180
- // system-role handlers don't benefit from role-typo detection and would
181
- // otherwise get false-positive errors on every OwnershipMap — their
182
- // knownRoles corpus is empty beyond "all"/"system", so any app-defined
183
- // role would flag as unknown.
184
- export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
208
+ // A role corpus can only answer "is this role real?" when it actually holds
209
+ // app-defined roles. undefined means the caller could not derive one at all
210
+ // (a test stack mounts a deliberate subset of the app), and a corpus of
211
+ // nothing but "all"/"system" belongs to an app whose handlers never declare
212
+ // a role — in both cases every app-defined role would flag as unknown.
213
+ export function canValidateRoles(
214
+ knownRoles: ReadonlySet<string> | undefined,
215
+ ): knownRoles is ReadonlySet<string> {
216
+ if (!knownRoles) return false;
185
217
  for (const r of knownRoles) {
186
218
  if (r !== "all" && r !== "system") return true;
187
219
  }
@@ -191,8 +223,8 @@ export function shouldValidateRoles(knownRoles: ReadonlySet<string>): boolean {
191
223
  export function checkOwnershipMap(args: {
192
224
  readonly map: OwnershipMap;
193
225
  readonly columnNames: ReadonlySet<string>;
194
- readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>;
195
- readonly knownRoles: ReadonlySet<string>;
226
+ readonly allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition> | undefined;
227
+ readonly knownRoles: ReadonlySet<string> | undefined;
196
228
  readonly scope: string;
197
229
  readonly featureName: string;
198
230
  readonly probe?: WhereRuleProbe;
@@ -201,9 +233,8 @@ export function checkOwnershipMap(args: {
201
233
  for (const [roleName, rawRule] of Object.entries(args.map)) {
202
234
  // Role-existence check — typos like `{"Admi": "all"}` where no handler
203
235
  // or other map mentions "Admi" would otherwise silently grant nothing.
204
- // Skip when no app-defined roles exist anywhere (handler-less or
205
- // system-only apps — shouldValidateRoles returns false there).
206
- if (shouldValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
236
+ // Cross-feature, so it needs a corpus that can answer the question.
237
+ if (canValidateRoles(args.knownRoles) && !args.knownRoles.has(roleName)) {
207
238
  throw new Error(
208
239
  buildUnknownRoleMessage(roleName, args.knownRoles, args.scope, args.featureName),
209
240
  );
@@ -216,12 +247,14 @@ export function checkOwnershipMap(args: {
216
247
  if (args.path === "write") {
217
248
  throw new Error(buildWhereOnWritePathMessage(roleName, args.scope, args.featureName));
218
249
  }
219
- probeWhereRule(rule, roleName, args.probe, args.scope);
250
+ probeWhereRule(rule, roleName, args.probe, args.scope, args.featureName);
220
251
  continue; // escape hatch — feature author owns the SQL
221
252
  }
222
253
 
223
254
  // FromRule — validate ref + column.
224
- if (rule.refKind === "claim") {
255
+ // Claim-existence is cross-feature: only the whole-app corpus can tell a
256
+ // typo from a claim declared in a feature this caller did not mount.
257
+ if (rule.refKind === "claim" && args.allClaimKeys) {
225
258
  // refPath is the qualified claim name ("feature:shortName").
226
259
  const claim = args.allClaimKeys.get(rule.refPath);
227
260
  if (!claim) {
@@ -258,6 +291,7 @@ export function probeWhereRule(
258
291
  roleName: string,
259
292
  probe: WhereRuleProbe | undefined,
260
293
  scope: string,
294
+ featureName: string,
261
295
  ): void {
262
296
  // skip: no probe means the entity's column set could not be derived above;
263
297
  // without it there is nothing to check the rule's SQL against, so the
@@ -277,7 +311,11 @@ export function probeWhereRule(
277
311
  // skip: a rule producing no SQL fragment for the probe user has nothing to
278
312
  // lint here; ruleToFragment still validates it against a real request.
279
313
  if (typeof fragment?.sqlText !== "string") return;
280
- assertQualifiedWhereFragment(fragment.sqlText, probe.columns, `${scope} (role "${roleName}")`);
314
+ assertQualifiedWhereFragment(
315
+ fragment.sqlText,
316
+ probe.columns,
317
+ `${scope} (role "${roleName}", feature: "${featureName}")`,
318
+ );
281
319
  }
282
320
 
283
321
  export function buildWhereOnWritePathMessage(
@@ -846,6 +846,23 @@ export function validateScreens(
846
846
  );
847
847
  }
848
848
  }
849
+ if (section.defaultSort !== undefined) {
850
+ const sortField = section.defaultSort.field;
851
+ const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
852
+ if (col === undefined) {
853
+ throw new Error(
854
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
855
+ `(relatedList) defaultSort.field "${sortField}" is not a listed column.`,
856
+ );
857
+ }
858
+ if (normalizeListColumn(col).sortable !== true) {
859
+ throw new Error(
860
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
861
+ `(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
862
+ `the column or pick another field.`,
863
+ );
864
+ }
865
+ }
849
866
  continue;
850
867
  }
851
868
  if (isWriteFormEditSection(section)) {
@@ -0,0 +1,108 @@
1
+ // setupTestStack runs the ownership boot-guards (fw#2639 unqualified
2
+ // where-fragments, fw#2626 where-rules on the write path). Before this, only
3
+ // the prod boot ran validateBoot, so a feature with a broken access map came
4
+ // through every test suite green and failed on the first real deploy.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import {
8
+ createEntity,
9
+ createTextField,
10
+ defineEntityQueryHandler,
11
+ defineFeature,
12
+ from,
13
+ } from "../../engine";
14
+ import type { WhereRule } from "../../engine/types";
15
+ import { setupTestStack } from "../test-stack";
16
+
17
+ // Bare `owner_id` inside the subquery binds to fwbootguard_shares, not to the
18
+ // outer memo table — the predicate collapses into a tautology and grants every
19
+ // row. This is the exact fail-open shape fw#2639 lints for.
20
+ const unqualifiedReadRule: WhereRule = {
21
+ kind: "where",
22
+ where: (user, ctx) => ({
23
+ sqlText: `${ctx.tableName}.id IN (SELECT memo_id FROM fwbootguard_shares WHERE owner_id = $${ctx.paramStart})`,
24
+ params: [user.id],
25
+ }),
26
+ };
27
+
28
+ const qualifiedWriteRule: WhereRule = {
29
+ kind: "where",
30
+ where: (user, ctx) => ({
31
+ sqlText: `${ctx.tableName}.owner_id = $${ctx.paramStart}`,
32
+ params: [user.id],
33
+ }),
34
+ };
35
+
36
+ function memoEntity(access: Parameters<typeof createEntity>[0]["access"]) {
37
+ return createEntity({
38
+ table: "fwbootguard_memos",
39
+ fields: {
40
+ ownerId: createTextField({ required: true }),
41
+ title: createTextField({ required: true }),
42
+ },
43
+ ...(access ? { access } : {}),
44
+ });
45
+ }
46
+
47
+ function featureWith(access: Parameters<typeof createEntity>[0]["access"]) {
48
+ const entity = memoEntity(access);
49
+ return defineFeature("fwbootguard", (r) => {
50
+ r.entity("memo", entity);
51
+ r.queryHandler(
52
+ defineEntityQueryHandler("memo:detail", entity, { access: { roles: ["Member"] } }),
53
+ );
54
+ });
55
+ }
56
+
57
+ describe("setupTestStack — ownership boot-guards", () => {
58
+ test("rejects an unqualified where-fragment on access.read (fw#2639)", async () => {
59
+ const promise = setupTestStack({
60
+ features: [featureWith({ read: { Member: unqualifiedReadRule } })],
61
+ });
62
+ await expect(promise).rejects.toThrow(
63
+ /references column "owner_id" unqualified inside a subquery/,
64
+ );
65
+ await expect(promise).rejects.toThrow(/entity "memo"\.access\.read/);
66
+ await expect(promise).rejects.toThrow(/role "Member", feature: "fwbootguard"/);
67
+ });
68
+
69
+ test("rejects a where-rule on access.write (fw#2626)", async () => {
70
+ const promise = setupTestStack({
71
+ features: [featureWith({ read: { Member: "all" }, write: { Member: qualifiedWriteRule } })],
72
+ });
73
+ await expect(promise).rejects.toThrow(/entity "memo"\.access\.write/);
74
+ await expect(promise).rejects.toThrow(/feature: "fwbootguard"/);
75
+ });
76
+
77
+ test("a qualified read-rule still boots", async () => {
78
+ const stack = await setupTestStack({
79
+ features: [featureWith({ read: { Member: qualifiedWriteRule } })],
80
+ });
81
+ expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
82
+ await stack.cleanup();
83
+ });
84
+
85
+ // The role and claim corpora are whole-app facts. A stack that mounts a
86
+ // deliberate subset cannot answer them, so those two checks stay out of the
87
+ // test-stack path while the subset-invariant where-lint keeps firing on the
88
+ // very same map. Without this test, the next "fix" to collectKnownRoles
89
+ // quietly reinstates the false positives it removed.
90
+ test("a subset stack skips the cross-feature checks but still lints where-fragments", async () => {
91
+ const undeclaredRefs = {
92
+ Auditor: "all",
93
+ Reviewer: from("claim:fwbootguardelsewhere:teamId", "ownerId"),
94
+ } as const;
95
+
96
+ const stack = await setupTestStack({
97
+ features: [featureWith({ read: { ...undeclaredRefs, Member: "all" } })],
98
+ });
99
+ expect(stack.registry.getEntity("memo")?.table).toBe("fwbootguard_memos");
100
+ await stack.cleanup();
101
+
102
+ await expect(
103
+ setupTestStack({
104
+ features: [featureWith({ read: { ...undeclaredRefs, Member: unqualifiedReadRule } })],
105
+ }),
106
+ ).rejects.toThrow(/references column "owner_id" unqualified inside a subquery/);
107
+ });
108
+ });
@@ -5,6 +5,7 @@ import { buildServer } from "../api/server";
5
5
  import { createSseBroker, type SseBroker } from "../api/sse-broker";
6
6
  import type { PgClient } from "../db/connection";
7
7
  import { extractTableInfo } from "../db/query";
8
+ import { validateOwnershipBoot } from "../engine/boot-validator/ownership";
8
9
  import { createRegistry } from "../engine/registry";
9
10
  import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
10
11
  import { createArchivedStreamsTable, createEventsTable } from "../event-store";
@@ -180,6 +181,14 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
180
181
  const { ensureTemporalPolyfill } = await import("../time/polyfill");
181
182
  await ensureTemporalPolyfill();
182
183
 
184
+ // Ownership boot-guards (fw#2639 unqualified where-fragments, fw#2626
185
+ // where-rules on the write path) otherwise run only in the prod boot via
186
+ // validateBoot, leaving every test stack blind to a broken access map.
187
+ // Runs after the polyfill (buildEntityTable needs it to derive the probe
188
+ // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
189
+ // a database that nothing will clean up.
190
+ validateOwnershipBoot(options.features);
191
+
183
192
  // Forward db-name/persistent-flag through to createTestDb. The
184
193
  // defaults (undefined dbName, persistent:false) keep the legacy
185
194
  // test contract: fresh kumiko_test_<random> DB per setup, dropped
@@ -85,6 +85,7 @@ export type {
85
85
  FormWidth,
86
86
  ListColumnSpec,
87
87
  ListFacetSpec,
88
+ ListSortSpec,
88
89
  PlatformComponent,
89
90
  ProjectionDetailScreenDefinition,
90
91
  ProjectionListScreenDefinition,