@cosmicdrift/kumiko-framework 0.158.2 → 0.159.1

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.
Files changed (109) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  7. package/src/api/api-constants.ts +4 -0
  8. package/src/api/auth-middleware.ts +48 -59
  9. package/src/api/auth-routes.ts +51 -17
  10. package/src/api/index.ts +3 -3
  11. package/src/api/jwt.ts +148 -7
  12. package/src/api/pii-leak-guard.ts +5 -2
  13. package/src/api/server.ts +19 -5
  14. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  15. package/src/bun-db/query.ts +34 -2
  16. package/src/consumer-cli.ts +87 -0
  17. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  18. package/src/crypto/blind-index.ts +8 -4
  19. package/src/crypto/event-pii.ts +1 -0
  20. package/src/crypto/pii-field-encryption.ts +49 -15
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  22. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  23. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  24. package/src/db/blind-index-cleanup.ts +3 -1
  25. package/src/db/connection.ts +3 -11
  26. package/src/db/encryption.ts +2 -3
  27. package/src/db/entity-table-meta-types.ts +92 -0
  28. package/src/db/entity-table-meta.ts +16 -90
  29. package/src/db/queries/backfill-pii.ts +1 -0
  30. package/src/db/queries/event-consumer.ts +35 -2
  31. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  32. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  33. package/src/engine/__tests__/define-roles.test.ts +21 -0
  34. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  35. package/src/engine/__tests__/store-table.test.ts +12 -0
  36. package/src/engine/boot-validator/action-wiring.ts +1 -1
  37. package/src/engine/boot-validator/boot-check.ts +21 -0
  38. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  39. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  40. package/src/engine/boot-validator/index.ts +3 -9
  41. package/src/engine/boot-validator/screens.ts +1 -1
  42. package/src/engine/define-feature.ts +1 -0
  43. package/src/engine/define-handler.ts +10 -91
  44. package/src/engine/entity-handlers.ts +15 -27
  45. package/src/engine/feature-builder-state.ts +3 -0
  46. package/src/engine/feature-config-events-jobs.ts +1 -1
  47. package/src/engine/feature-entity-handlers.ts +1 -1
  48. package/src/engine/feature-ui-extensions.ts +5 -1
  49. package/src/engine/field-helpers.ts +31 -0
  50. package/src/engine/handler-helpers.ts +26 -0
  51. package/src/engine/hook-helpers.ts +14 -0
  52. package/src/engine/index.ts +2 -2
  53. package/src/engine/ownership.ts +22 -76
  54. package/src/engine/registry-validate.ts +1 -1
  55. package/src/engine/screen-helpers.ts +54 -0
  56. package/src/engine/tier-resolver-extension.ts +3 -2
  57. package/src/engine/types/define-handler.ts +94 -0
  58. package/src/engine/types/entity-handlers.ts +30 -0
  59. package/src/engine/types/event-type-map.ts +1 -37
  60. package/src/engine/types/feature.ts +45 -0
  61. package/src/engine/types/fields.ts +19 -31
  62. package/src/engine/types/handlers.ts +7 -26
  63. package/src/engine/types/hooks.ts +1 -15
  64. package/src/engine/types/http-route.ts +1 -72
  65. package/src/engine/types/identifiers.ts +1 -47
  66. package/src/engine/types/index.ts +34 -9
  67. package/src/engine/types/ownership.ts +83 -0
  68. package/src/engine/types/relations.ts +1 -51
  69. package/src/engine/types/screen.ts +0 -46
  70. package/src/engine/types/target-ref.ts +1 -21
  71. package/src/engine/types/tree-node.ts +1 -129
  72. package/src/entrypoint/index.ts +2 -2
  73. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  74. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  75. package/src/event-store/event-store.ts +28 -32
  76. package/src/event-store/events-schema.ts +1 -10
  77. package/src/event-store/index.ts +3 -2
  78. package/src/event-store/types.ts +22 -0
  79. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  80. package/src/files/file-handle.ts +2 -19
  81. package/src/i18n/required-surface-keys.ts +1 -1
  82. package/src/logging/types.ts +1 -7
  83. package/src/observability/types/index.ts +1 -29
  84. package/src/observability/types/metric.ts +1 -56
  85. package/src/observability/types/provider.ts +1 -32
  86. package/src/observability/types/span.ts +1 -58
  87. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  88. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  89. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  90. package/src/pipeline/dispatch-shared.ts +12 -2
  91. package/src/pipeline/entity-cache.ts +2 -33
  92. package/src/pipeline/event-consumer-state.ts +28 -3
  93. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  94. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  95. package/src/pipeline/event-dispatcher.ts +27 -1
  96. package/src/pipeline/system-hooks.ts +7 -0
  97. package/src/search/types.ts +1 -39
  98. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  99. package/src/secrets/__tests__/envelope.test.ts +1 -1
  100. package/src/secrets/envelope-cipher.ts +13 -39
  101. package/src/stack/__tests__/event-collector.test.ts +42 -0
  102. package/src/testing/__tests__/late-bound.test.ts +25 -0
  103. package/src/testing/__tests__/wait-for.test.ts +53 -0
  104. package/src/testing/boot-validator-fixture.ts +1 -1
  105. package/src/testing/file-provider-contract.ts +84 -0
  106. package/src/testing/handler-context.ts +1 -1
  107. package/src/testing/index.ts +1 -0
  108. package/src/time/geo-tz.ts +1 -32
  109. package/src/ui-types/index.ts +7 -7
@@ -47,6 +47,7 @@ export type ConsumerDeliveryOutcome = {
47
47
  readonly attempts: number;
48
48
  readonly lastError: string | null;
49
49
  readonly deadLettered: boolean;
50
+ readonly processed: number;
50
51
  };
51
52
 
52
53
  export async function updateConsumerDeliveryOutcome(
@@ -55,19 +56,26 @@ export async function updateConsumerDeliveryOutcome(
55
56
  instanceId: string,
56
57
  outcome: ConsumerDeliveryOutcome,
57
58
  ): Promise<void> {
59
+ // Advancing the cursor proves this pass wasn't poison — reset the re-arm
60
+ // budget so an unrelated failure down the line gets its own fresh 3
61
+ // chances instead of inheriting a partially-spent counter from a past,
62
+ // already-resolved outage.
63
+ const resetRearmCount = outcome.processed > 0;
58
64
  await asRawClient(db).unsafe(
59
65
  `UPDATE "kumiko_event_consumers" SET
60
66
  "last_processed_event_id" = $1,
61
67
  "attempts" = $2,
62
68
  "status" = $3,
63
69
  "last_error" = $4,
70
+ "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
64
71
  "updated_at" = now()
65
- WHERE "name" = $5 AND "instance_id" = $6`,
72
+ WHERE "name" = $6 AND "instance_id" = $7`,
66
73
  [
67
74
  outcome.cursor,
68
75
  outcome.attempts,
69
76
  outcome.deadLettered ? "dead" : "idle",
70
77
  outcome.lastError,
78
+ resetRearmCount,
71
79
  name,
72
80
  instanceId,
73
81
  ],
@@ -81,7 +89,7 @@ export async function updateConsumerStatusReturning(
81
89
  status: "idle" | "disabled",
82
90
  ): Promise<Record<string, unknown> | undefined> {
83
91
  const rows = (await asRawClient(db).unsafe(
84
- `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "updated_at" = now()
92
+ `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
85
93
  WHERE "name" = $2 AND "instance_id" = $3
86
94
  RETURNING *`,
87
95
  [status, name, instanceId],
@@ -101,6 +109,7 @@ export async function advanceConsumerPastEventReturning(
101
109
  "status" = 'idle',
102
110
  "attempts" = 0,
103
111
  "last_error" = NULL,
112
+ "rearm_count" = 0,
104
113
  "updated_at" = now()
105
114
  WHERE "name" = $2 AND "instance_id" = $3
106
115
  RETURNING *`,
@@ -168,3 +177,27 @@ export async function markConsumerRebuildFailed(
168
177
  [errorMessage, name, instanceId],
169
178
  );
170
179
  }
180
+
181
+ // Auto-revive a dead consumer once its cooldown has elapsed (called from
182
+ // acquireConsumerState — not an ops action, so no "requireConsumerRow"
183
+ // precondition like restartConsumer/enableConsumer). Increments rearm_count
184
+ // so the caller can enforce a lifetime cap on automatic revivals; manual
185
+ // restartConsumer()/enableConsumer() reset it back to 0.
186
+ export async function rearmDeadConsumer(
187
+ db: AnyDb,
188
+ name: string,
189
+ instanceId: string,
190
+ ): Promise<Record<string, unknown> | undefined> {
191
+ const rows = (await asRawClient(db).unsafe(
192
+ `UPDATE "kumiko_event_consumers" SET
193
+ "status" = 'idle',
194
+ "attempts" = 0,
195
+ "last_error" = NULL,
196
+ "rearm_count" = "rearm_count" + 1,
197
+ "updated_at" = now()
198
+ WHERE "name" = $1 AND "instance_id" = $2
199
+ RETURNING *`,
200
+ [name, instanceId],
201
+ )) as ReadonlyArray<Record<string, unknown>>;
202
+ return rows[0];
203
+ }
@@ -0,0 +1,99 @@
1
+ // r.bootCheck(fn) — feature-declared mount invariants, checked at boot.
2
+ // Mirrors the prompt-store trap (kumiko-enterprise#229): a feature with
3
+ // PII-annotated fields was mounted without its required companion feature,
4
+ // and nothing caught it at boot. The conditional-invariant tests below
5
+ // reproduce that shape: the check only fails when the feature actually has
6
+ // PII fields AND the companion is missing — a bare "is X mounted" check
7
+ // would already be covered by r.requires and wouldn't justify this API.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { validateFeatureBootChecks } from "../boot-validator/boot-check";
11
+ import { defineFeature } from "../define-feature";
12
+ import { createEntity, createTextField } from "../factories";
13
+
14
+ function catchMessage(fn: () => void): string {
15
+ try {
16
+ fn();
17
+ } catch (e) {
18
+ return e instanceof Error ? e.message : String(e);
19
+ }
20
+ throw new Error("expected function to throw, but it did not");
21
+ }
22
+
23
+ const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
24
+ features.some((f) => f.name === "user-data-hook");
25
+
26
+ const promptStore = () =>
27
+ defineFeature("prompt-store", (r) => {
28
+ const promptFields = { text: createTextField({ pii: true }) };
29
+ r.entity("prompt", createEntity({ fields: promptFields }));
30
+ r.bootCheck(({ features }) => {
31
+ // Conditional on this feature's own shape (has a pii field), closed
32
+ // over from setup — r.requires("user-data-hook") can't express that.
33
+ const hasPiiField = Object.values(promptFields).some((field) => field.pii);
34
+ if (hasPiiField && !requiresUserDataHook(features)) {
35
+ throw new Error("prompt-store has PII fields but no user-data-hook feature is mounted");
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("r.bootCheck / validateFeatureBootChecks", () => {
41
+ test("no bootChecks registered → no-op", () => {
42
+ const noop = defineFeature("noop", () => {});
43
+ expect(() => validateFeatureBootChecks([noop])).not.toThrow();
44
+ });
45
+
46
+ test("conditional invariant satisfied (companion mounted) → boot succeeds", () => {
47
+ const userDataHook = defineFeature("user-data-hook", () => {});
48
+ expect(() => validateFeatureBootChecks([userDataHook, promptStore()])).not.toThrow();
49
+ });
50
+
51
+ test("conditional invariant violated (PII field, no companion) → boot fails with feature-prefixed message", () => {
52
+ const message = catchMessage(() => validateFeatureBootChecks([promptStore()]));
53
+ expect(message).toContain("[Feature prompt-store]");
54
+ expect(message).toContain(
55
+ "prompt-store has PII fields but no user-data-hook feature is mounted",
56
+ );
57
+ });
58
+
59
+ test("no PII field → boot succeeds even without the companion", () => {
60
+ const noPiiFields = { text: createTextField() };
61
+ const noPii = defineFeature("prompt-store-no-pii", (r) => {
62
+ r.entity("note", createEntity({ fields: noPiiFields }));
63
+ r.bootCheck(({ features }) => {
64
+ const hasPiiField = Object.values(noPiiFields).some((field) => field.pii);
65
+ if (hasPiiField && !requiresUserDataHook(features)) {
66
+ throw new Error("unreachable in this test");
67
+ }
68
+ });
69
+ });
70
+ expect(() => validateFeatureBootChecks([noPii])).not.toThrow();
71
+ });
72
+
73
+ test("multiple bootChecks on one feature all run in order until one throws", () => {
74
+ const calls: string[] = [];
75
+ const feature = defineFeature("multi-check", (r) => {
76
+ r.bootCheck(() => {
77
+ calls.push("first");
78
+ });
79
+ r.bootCheck(() => {
80
+ calls.push("second");
81
+ throw new Error("second check failed");
82
+ });
83
+ });
84
+ expect(() => validateFeatureBootChecks([feature])).toThrow("second check failed");
85
+ expect(calls).toEqual(["first", "second"]);
86
+ });
87
+
88
+ test("ctx.features exposes every mounted feature, not just the declaring one", () => {
89
+ const a = defineFeature("feature-a", () => {});
90
+ let seenNames: string[] = [];
91
+ const b = defineFeature("feature-b", (r) => {
92
+ r.bootCheck(({ features }) => {
93
+ seenNames = features.map((f) => f.name);
94
+ });
95
+ });
96
+ validateFeatureBootChecks([a, b]);
97
+ expect(seenNames).toEqual(["feature-a", "feature-b"]);
98
+ });
99
+ });
@@ -1,23 +1,16 @@
1
1
  // V1 — GDPR storage-persistence boot guard. Catches the prod failure class:
2
2
  // user-data-rights mounted but exports land in an ephemeral / missing store,
3
3
  // and s3-env selected as the GDPR store without its env vars set.
4
- // V2 — export-without-erase guard. Catches features that register an export
5
- // hook but no delete hook (Art.17 violation).
6
- // V3 — PII-entity-without-hook guard. Catches entities with pii/userOwned
7
- // fields that no feature registers an EXT_USER_DATA hook for.
8
- // V4tenantOwned-entity-without-hook guard. Mirrors V3 for EXT_TENANT_DATA.
4
+ //
5
+ // V2-V4 (export-without-erase / PII-entity-without-hook / tenantOwned-entity-
6
+ // without-hook) moved off this framework-internal validator onto
7
+ // `r.bootCheck()` calls declared by user-data-rights / user-data-rights-
8
+ // defaults (#1314) see boot-checks.test.ts in the bundled-features package
9
+ // for their coverage.
9
10
 
10
11
  import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
11
- import { integer, type SchemaTable, table, uuid } from "../../db/dialect";
12
- import {
13
- validateGdprHookCompleteness,
14
- validateGdprPiiHookCoverage,
15
- validateGdprStoragePersistence,
16
- validateTenantDataHookCoverage,
17
- } from "../boot-validator/gdpr-storage";
12
+ import { validateGdprStoragePersistence } from "../boot-validator/gdpr-storage";
18
13
  import { defineFeature } from "../define-feature";
19
- import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
20
- import { createEntity, createLongTextField, createTextField } from "../factories";
21
14
 
22
15
  const udr = () => defineFeature("user-data-rights", () => {});
23
16
  const fileProvider = (name: string) =>
@@ -25,26 +18,8 @@ const fileProvider = (name: string) =>
25
18
  r.useExtension("fileProvider", name);
26
19
  });
27
20
 
28
- // Throwaway Drizzle table for r.projection() registrations — the projection
29
- // runtime behaviour isn't under test here, only the guard's field scan.
30
- const testProjectionTable = table("test_projection", {
31
- id: uuid("id").primaryKey(),
32
- count: integer("count").notNull().default(0),
33
- }) as unknown as SchemaTable;
34
-
35
21
  const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
36
22
 
37
- // V2/V3 are hard boot gates now — capture the thrown message so a single case
38
- // can assert several substrings (feature, entity, article).
39
- function catchMessage(fn: () => void): string {
40
- try {
41
- fn();
42
- } catch (e) {
43
- return e instanceof Error ? e.message : String(e);
44
- }
45
- throw new Error("expected function to throw, but it did not");
46
- }
47
-
48
23
  describe("validateGdprStoragePersistence (V1)", () => {
49
24
  let warnSpy: ReturnType<typeof spyOn>;
50
25
  let savedEnv: Array<readonly [string, string | undefined]>;
@@ -103,204 +78,3 @@ describe("validateGdprStoragePersistence (V1)", () => {
103
78
  expect(warnSpy).not.toHaveBeenCalled();
104
79
  });
105
80
  });
106
-
107
- describe("validateGdprHookCompleteness (V2)", () => {
108
- const exportFn = async () => null;
109
- const deleteFn = async () => {};
110
-
111
- test("export + delete hooks → no throw", () => {
112
- const f = defineFeature("my-feature", (r) => {
113
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn, delete: deleteFn });
114
- });
115
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
116
- });
117
-
118
- test("export hook without delete hook → Art.17 throw", () => {
119
- const f = defineFeature("my-feature", (r) => {
120
- r.useExtension(EXT_USER_DATA, "myEntity", { export: exportFn });
121
- });
122
- const msg = catchMessage(() => validateGdprHookCompleteness([f]));
123
- expect(msg).toContain("my-feature");
124
- expect(msg).toContain("myEntity");
125
- expect(msg).toContain("Art.17");
126
- });
127
-
128
- test("delete hook only (no export) → no throw", () => {
129
- const f = defineFeature("my-feature", (r) => {
130
- r.useExtension(EXT_USER_DATA, "myEntity", { delete: deleteFn });
131
- });
132
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
133
- });
134
-
135
- test("no EXT_USER_DATA hooks at all → no throw", () => {
136
- const f = defineFeature("my-feature", (r) => {
137
- r.useExtension("fileProvider", "s3");
138
- });
139
- expect(() => validateGdprHookCompleteness([f])).not.toThrow();
140
- });
141
-
142
- test("multiple features, one missing delete → throws naming the offender", () => {
143
- const good = defineFeature("good", (r) => {
144
- r.useExtension(EXT_USER_DATA, "entityA", { export: exportFn, delete: deleteFn });
145
- });
146
- const bad = defineFeature("bad", (r) => {
147
- r.useExtension(EXT_USER_DATA, "entityB", { export: exportFn });
148
- });
149
- const msg = catchMessage(() => validateGdprHookCompleteness([good, bad]));
150
- expect(msg).toContain("entityB");
151
- });
152
- });
153
-
154
- describe("validateGdprPiiHookCoverage (V3)", () => {
155
- const exportFn = async () => null;
156
- const deleteFn = async () => {};
157
-
158
- const piiFeature = () =>
159
- defineFeature("crm", (r) => {
160
- r.entity(
161
- "contact",
162
- createEntity({
163
- fields: {
164
- email: createTextField({ pii: true }),
165
- note: createLongTextField({ userOwned: { ownerField: "authorId" } }),
166
- authorId: { type: "reference", entity: "user" },
167
- },
168
- }),
169
- );
170
- });
171
-
172
- test("user-data-rights not mounted → no throw", () => {
173
- expect(() => validateGdprPiiHookCoverage([piiFeature()])).not.toThrow();
174
- });
175
-
176
- test("pii entity without any EXT_USER_DATA hook → throws naming entity and fields", () => {
177
- const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), piiFeature()]));
178
- expect(msg).toContain('"contact"');
179
- expect(msg).toContain("email");
180
- expect(msg).toContain("note");
181
- expect(msg).toContain("Art.17");
182
- });
183
-
184
- test("pii entity with hook registered by another feature → no throw", () => {
185
- const hooks = defineFeature("crm-user-data", (r) => {
186
- r.useExtension(EXT_USER_DATA, "contact", { export: exportFn, delete: deleteFn });
187
- });
188
- expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
189
- });
190
-
191
- test("no-op hook is the intentional escape hatch → no throw", () => {
192
- const hooks = defineFeature("crm-user-data", (r) => {
193
- // Escape hatch: erasure handled elsewhere (crypto-shredding key-erase),
194
- // so the pipeline hook is a deliberate no-op.
195
- r.useExtension(EXT_USER_DATA, "contact", {
196
- export: async () => null,
197
- delete: async () => {},
198
- });
199
- });
200
- expect(() => validateGdprPiiHookCoverage([udr(), piiFeature(), hooks])).not.toThrow();
201
- });
202
-
203
- test("entity without subject annotations → no throw", () => {
204
- const plain = defineFeature("catalog", (r) => {
205
- r.entity(
206
- "product",
207
- createEntity({
208
- fields: { sku: createTextField({ allowPlaintext: "is-business-data" }) },
209
- }),
210
- );
211
- });
212
- expect(() => validateGdprPiiHookCoverage([udr(), plain])).not.toThrow();
213
- });
214
-
215
- test("userOwned field alone counts as user-subject data → throws", () => {
216
- const f = defineFeature("notes", (r) => {
217
- r.entity(
218
- "note",
219
- createEntity({
220
- fields: {
221
- body: createLongTextField({ userOwned: { ownerField: "authorId" } }),
222
- authorId: { type: "reference", entity: "user" },
223
- },
224
- }),
225
- );
226
- });
227
- const msg = catchMessage(() => validateGdprPiiHookCoverage([udr(), f]));
228
- expect(msg).toContain('"note"');
229
- });
230
- });
231
-
232
- describe("validateTenantDataHookCoverage (V4)", () => {
233
- const destroyFn = async () => {};
234
- const tenantLifecycle = () => defineFeature("tenant-lifecycle", () => {});
235
-
236
- const tenantEntityFeature = () =>
237
- defineFeature("billing", (r) => {
238
- r.entity(
239
- "subscription",
240
- createEntity({
241
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
242
- }),
243
- );
244
- });
245
-
246
- test("tenant-lifecycle not mounted → no throw", () => {
247
- expect(() => validateTenantDataHookCoverage([tenantEntityFeature()])).not.toThrow();
248
- });
249
-
250
- test("tenantOwned entity without any EXT_TENANT_DATA hook → throws naming entity and field", () => {
251
- const msg = catchMessage(() =>
252
- validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature()]),
253
- );
254
- expect(msg).toContain('"subscription"');
255
- expect(msg).toContain("providerCustomerId");
256
- });
257
-
258
- test("tenantOwned entity with hook registered → no throw", () => {
259
- const hooks = defineFeature("billing-hooks", (r) => {
260
- r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
261
- });
262
- expect(() =>
263
- validateTenantDataHookCoverage([tenantLifecycle(), tenantEntityFeature(), hooks]),
264
- ).not.toThrow();
265
- });
266
-
267
- // Regression: billing-foundation's real `subscription` shape — r.projection()
268
- // (no executor, no r.entity) carrying an `entity` reference for its
269
- // tenantOwned fields. Before this fix, feature.entities-only scanning made
270
- // this shape invisible to the guard.
271
- test("tenantOwned field on a projection-only entity (no r.entity) is still caught", () => {
272
- const projectionFeature = defineFeature("billing", (r) => {
273
- r.projection({
274
- name: "subscription",
275
- source: "subscription",
276
- table: testProjectionTable,
277
- entity: createEntity({
278
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
279
- }),
280
- apply: {},
281
- });
282
- });
283
- const msg = catchMessage(() =>
284
- validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
285
- );
286
- expect(msg).toContain("providerCustomerId");
287
- });
288
-
289
- test("tenantOwned field on a projection-only entity WITH a hook → no throw", () => {
290
- const projectionFeature = defineFeature("billing", (r) => {
291
- r.projection({
292
- name: "subscription",
293
- source: "subscription",
294
- table: testProjectionTable,
295
- entity: createEntity({
296
- fields: { providerCustomerId: createTextField({ tenantOwned: true }) },
297
- }),
298
- apply: {},
299
- });
300
- r.useExtension(EXT_TENANT_DATA, "subscription", { destroy: destroyFn });
301
- });
302
- expect(() =>
303
- validateTenantDataHookCoverage([tenantLifecycle(), projectionFeature]),
304
- ).not.toThrow();
305
- });
306
- });
@@ -0,0 +1,21 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineRoles } from "../define-roles";
3
+
4
+ describe("defineRoles", () => {
5
+ test("maps each role name to itself", () => {
6
+ const roles = defineRoles(["Admin", "SystemAdmin", "Driver"] as const);
7
+ expect(roles.Admin).toBe("Admin");
8
+ expect(roles.SystemAdmin).toBe("SystemAdmin");
9
+ expect(roles.Driver).toBe("Driver");
10
+ });
11
+
12
+ test("returns an object with exactly the given keys", () => {
13
+ const roles = defineRoles(["A", "B"] as const);
14
+ expect(Object.keys(roles).sort()).toEqual(["A", "B"]);
15
+ });
16
+
17
+ test("an empty role list returns an empty object", () => {
18
+ const roles = defineRoles([] as const);
19
+ expect(roles).toEqual({});
20
+ });
21
+ });
@@ -0,0 +1,24 @@
1
+ // Type-only fixture for the KumikoEventTypeMap declaration-merging channel
2
+ // (#1394). event-type-map.ts moved the marker interfaces behind a re-export
3
+ // chain (kumiko-types -> engine/types/event-type-map.ts shim -> engine
4
+ // barrel), while the codegen augmentation channel
5
+ // (dev-server/src/codegen/render.ts) still targets
6
+ // `declare module "@cosmicdrift/kumiko-framework/engine"`. If a future
7
+ // change breaks that re-export chain, augmentation silently stops merging
8
+ // and every event payload falls back to `unknown` with no compile error —
9
+ // this test only catches it because tsc checks the `declare module` below
10
+ // against the SAME specifier the codegen emits.
11
+ import { expectTypeOf, test } from "bun:test";
12
+ import type { KumikoEventTypeMap } from "@cosmicdrift/kumiko-framework/engine";
13
+
14
+ declare module "@cosmicdrift/kumiko-framework/engine" {
15
+ interface KumikoEventTypeMap {
16
+ "event-type-map-fixture:probe.created": { readonly probe: true };
17
+ }
18
+ }
19
+
20
+ test("augmenting KumikoEventTypeMap via the engine specifier merges into the relocated interface", () => {
21
+ expectTypeOf<KumikoEventTypeMap["event-type-map-fixture:probe.created"]>().toEqualTypeOf<{
22
+ readonly probe: true;
23
+ }>();
24
+ });
@@ -75,6 +75,18 @@ describe("r.storeTable — declaration", () => {
75
75
  ).toThrow(/requires source: "unmanaged"/);
76
76
  });
77
77
 
78
+ test("rejects a table name with the reserved read_ prefix (#1220)", () => {
79
+ const readPrefixed = defineUnmanagedTable({
80
+ tableName: "read_rt_probe",
81
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
82
+ });
83
+ expect(() =>
84
+ defineFeature("probe", (r) => {
85
+ r.storeTable(readPrefixed, { reason: "test" });
86
+ }),
87
+ ).toThrow(/the "read_" prefix is reserved/);
88
+ });
89
+
78
90
  test("accepts valid registration and stores meta + reason", () => {
79
91
  const feature = defineFeature("probe", (r) => {
80
92
  r.storeTable(probeMeta, {
@@ -1,3 +1,4 @@
1
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
1
2
  import type {
2
3
  EditFieldSpec,
3
4
  EditLayout,
@@ -6,7 +7,6 @@ import type {
6
7
  RowAction,
7
8
  ToolbarAction,
8
9
  } from "../types";
9
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
10
10
 
11
11
  const FUNCTION_DROPPED_HINT =
12
12
  "functions are dropped by JSON.stringify when the screen config reaches the client " +
@@ -0,0 +1,21 @@
1
+ import type { BootCheckContext, FeatureDefinition } from "../types";
2
+
3
+ // r.bootCheck(fn) lets a feature declare its own mount-invariant instead of
4
+ // relying on framework-internal knowledge (the gdpr-storage.ts guards are
5
+ // the framework-owned version of this same idea). Each registered fn gets
6
+ // the full mounted-feature set and throws to fail the boot; we wrap the
7
+ // message with the owning feature's name so the error text points back at
8
+ // the feature that declared the check.
9
+ export function validateFeatureBootChecks(features: readonly FeatureDefinition[]): void {
10
+ const ctx: BootCheckContext = { features };
11
+ for (const feature of features) {
12
+ for (const check of feature.bootChecks) {
13
+ try {
14
+ check(ctx);
15
+ } catch (err) {
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ throw new Error(`[Feature ${feature.name}] r.bootCheck failed: ${message}`);
18
+ }
19
+ }
20
+ }
21
+ }
@@ -1,5 +1,5 @@
1
+ import { normalizeListColumn } from "../screen-helpers";
1
2
  import type { EntityListScreenDefinition, FeatureDefinition } from "../types";
2
- import { normalizeListColumn } from "../types/screen";
3
3
 
4
4
  /** Operator lists default searchable; low-cardinality audit trails stay opt-out. */
5
5
  export const SEARCHABLE_FALSE_WHITELIST = new Set(["download-attempt-list"]);
@@ -1,22 +1,4 @@
1
- import { EXT_TENANT_DATA, EXT_USER_DATA } from "../extension-names";
2
1
  import type { FeatureDefinition } from "../types";
3
- import type { EntityDefinition, PiiAnnotations } from "../types/fields";
4
-
5
- // r.entity(...) is not the only way a feature exposes an entity shape:
6
- // r.projection(...) can carry an optional `entity` too (raw read-models with
7
- // no executor, e.g. billing-foundation's subscription table). Both V3 and V4
8
- // below need every entity a feature declares, not just the r.entity ones, or
9
- // a tenantOwned/pii field on a projection-only entity is invisible to the
10
- // guard it was annotated for.
11
- function entitiesOf(
12
- feature: FeatureDefinition,
13
- ): ReadonlyArray<readonly [string, EntityDefinition]> {
14
- const fromEntities = Object.entries(feature.entities ?? {});
15
- const fromProjections = Object.values(feature.projections ?? {})
16
- .filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
17
- .map((p) => [p.name, p.entity] as const);
18
- return [...fromEntities, ...fromProjections];
19
- }
20
2
 
21
3
  // Providers whose bytes do not survive a process restart. Only "inmemory"
22
4
  // today; extend if another ephemeral bundled provider lands.
@@ -75,97 +57,3 @@ export function validateGdprStoragePersistence(features: readonly FeatureDefinit
75
57
  }
76
58
  }
77
59
  }
78
-
79
- // V2: export-without-erase gate. A feature that registers an EXT_USER_DATA
80
- // export hook without a matching delete hook exports data under Art.20 but
81
- // never erases it on forget — an Art.17 violation. Hard boot failure: no app
82
- // should ship a GDPR export path with no erase path. Registry-level signal
83
- // only; runtime no-ops (a delete hook that silently skips) are not detectable
84
- // here — those are covered by the export/forget integration tests.
85
- export function validateGdprHookCompleteness(features: readonly FeatureDefinition[]): void {
86
- for (const feature of features) {
87
- for (const usage of feature.extensionUsages) {
88
- if (usage.extensionName !== EXT_USER_DATA) continue;
89
- const hasExport = typeof usage.options?.["export"] === "function";
90
- const hasDelete = typeof usage.options?.["delete"] === "function";
91
- if (hasExport && !hasDelete) {
92
- throw new Error(
93
- `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
94
- );
95
- }
96
- }
97
- }
98
- }
99
-
100
- // V3: PII-entity-without-hook gate. V2 checks registered hooks for
101
- // completeness; V3 catches the entity nobody registered at all — fields
102
- // annotated as user-subject data (pii / userOwned) yet invisible to the
103
- // Art.15/20 export and Art.17 forget pipeline. Hard boot failure once
104
- // user-data-rights is mounted: a subject-data entity that skips the pipeline
105
- // is exactly the "feature built past GDPR" leak this gate exists to stop.
106
- // Matching is by entity name across all features (usage.entityName is
107
- // unqualified); a same-named entity in another feature can mask a gap —
108
- // accepted, as the common case is a distinct entity name.
109
- export function validateGdprPiiHookCoverage(features: readonly FeatureDefinition[]): void {
110
- const featureNames = new Set(features.map((f) => f.name));
111
- if (!featureNames.has("user-data-rights")) {
112
- // skip: this guard only applies to apps that mount user-data-rights
113
- return;
114
- }
115
-
116
- const hookedEntities = new Set<string>();
117
- for (const f of features) {
118
- for (const usage of f.extensionUsages) {
119
- if (usage.extensionName === EXT_USER_DATA) hookedEntities.add(usage.entityName);
120
- }
121
- }
122
-
123
- for (const feature of features) {
124
- for (const [entityName, entity] of entitiesOf(feature)) {
125
- if (hookedEntities.has(entityName)) continue;
126
- const subjectFields = Object.entries(entity.fields)
127
- .filter(([, field]) => {
128
- const annot = field as PiiAnnotations; // @cast-boundary schema-walk
129
- return Boolean(annot.pii) || Boolean(annot.userOwned);
130
- })
131
- .map(([name]) => name);
132
- if (subjectFields.length === 0) continue;
133
- throw new Error(
134
- `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has user-subject fields (${subjectFields.join(", ")}) but no feature registers an EXT_USER_DATA hook for it — the data never appears in Art.15/20 exports and is never erased on forget (Art.17 gap). Register r.useExtension(EXT_USER_DATA, "${entityName}", { export, delete }) in the owning feature or a defaults feature. If this entity is intentionally out of the pipeline (e.g. crypto-shredding key-erase covers it), register a no-op hook { export: async () => null, delete: async () => {} } with a comment explaining why.`,
135
- );
136
- }
137
- }
138
- }
139
-
140
- // V4: tenantOwned-entity-without-hook gate. Mirrors validateGdprPiiHookCoverage
141
- // but for EXT_TENANT_DATA when tenant-lifecycle is mounted.
142
- export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
143
- const featureNames = new Set(features.map((f) => f.name));
144
- if (!featureNames.has("tenant-lifecycle")) {
145
- // skip: this guard only applies to apps that mount tenant-lifecycle
146
- return;
147
- }
148
-
149
- const hookedEntities = new Set<string>();
150
- for (const f of features) {
151
- for (const usage of f.extensionUsages) {
152
- if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
153
- }
154
- }
155
-
156
- for (const feature of features) {
157
- for (const [entityName, entity] of entitiesOf(feature)) {
158
- if (hookedEntities.has(entityName)) continue;
159
- const tenantSubjectFields = Object.entries(entity.fields)
160
- .filter(([, field]) => {
161
- const annot = field as PiiAnnotations;
162
- return Boolean(annot.tenantOwned);
163
- })
164
- .map(([name]) => name);
165
- if (tenantSubjectFields.length === 0) continue;
166
- throw new Error(
167
- `[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
168
- );
169
- }
170
- }
171
- }