@cosmicdrift/kumiko-framework 0.164.0 → 0.165.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 (160) hide show
  1. package/package.json +5 -3
  2. package/src/__tests__/consumer-cli.integration.test.ts +62 -0
  3. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  4. package/src/api/__tests__/api.test.ts +326 -18
  5. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  6. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  7. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +18 -1
  8. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  9. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +146 -0
  10. package/src/api/__tests__/batch.integration.test.ts +21 -2
  11. package/src/api/__tests__/jwt.test.ts +52 -2
  12. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +27 -18
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +117 -30
  20. package/src/api/server.ts +39 -7
  21. package/src/api/sse-broker.ts +39 -0
  22. package/src/bun-db/connection.ts +3 -3
  23. package/src/bun-db/index.ts +1 -0
  24. package/src/bun-db/query.ts +12 -3
  25. package/src/consumer-cli.ts +60 -13
  26. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +19 -13
  27. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  28. package/src/db/__tests__/migrate-runner.test.ts +61 -0
  29. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  30. package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
  31. package/src/db/api.ts +2 -2
  32. package/src/db/bun-provider.ts +2 -2
  33. package/src/db/connection.ts +6 -3
  34. package/src/db/dialect.ts +1 -6
  35. package/src/db/entity-table-meta-types.ts +1 -1
  36. package/src/db/event-store-executor-context.ts +2 -3
  37. package/src/db/event-store-executor-read.ts +2 -3
  38. package/src/db/event-store-executor-write.ts +8 -0
  39. package/src/db/index.ts +8 -3
  40. package/src/db/located-timestamp.ts +4 -0
  41. package/src/db/migrate-runner.ts +107 -11
  42. package/src/db/pg-error.ts +8 -0
  43. package/src/db/postgres-provider.ts +2 -2
  44. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  45. package/src/db/queries/ddl.ts +45 -0
  46. package/src/db/queries/event-store.ts +97 -21
  47. package/src/db/queries/test-stack.ts +4 -30
  48. package/src/db/reference-data.ts +2 -3
  49. package/src/db/replay-migration-sql.ts +114 -12
  50. package/src/db/tenant-db.ts +2 -4
  51. package/src/engine/__tests__/boot-validator.test.ts +14 -0
  52. package/src/engine/__tests__/engine.test.ts +30 -0
  53. package/src/engine/__tests__/registry.test.ts +36 -0
  54. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  55. package/src/engine/__tests__/store-table.test.ts +2 -2
  56. package/src/engine/boot-validator/entity-handler.ts +14 -25
  57. package/src/engine/boot-validator/nav.ts +5 -0
  58. package/src/engine/constants.ts +32 -6
  59. package/src/engine/create-app.ts +11 -0
  60. package/src/engine/effective-features.ts +12 -2
  61. package/src/engine/extensions/user-data.ts +12 -4
  62. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +4 -0
  63. package/src/engine/feature-ast/extractors/handlers.ts +13 -18
  64. package/src/engine/feature-ui-extensions.ts +2 -2
  65. package/src/engine/hook-helpers.ts +3 -1
  66. package/src/engine/index.ts +1 -1
  67. package/src/engine/ownership.ts +4 -3
  68. package/src/engine/registry-ingest.ts +14 -14
  69. package/src/engine/registry-state.ts +4 -1
  70. package/src/engine/registry-validate.ts +7 -2
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/index.ts +0 -2
  85. package/src/engine/types/nav.ts +1 -1
  86. package/src/engine/types/ownership.ts +1 -1
  87. package/src/engine/types/projection.ts +1 -1
  88. package/src/engine/types/relations.ts +1 -1
  89. package/src/engine/types/screen.ts +1 -1
  90. package/src/engine/types/step.ts +1 -1
  91. package/src/engine/types/target-ref.ts +1 -1
  92. package/src/engine/types/tree-node.ts +1 -1
  93. package/src/engine/types/workspace.ts +1 -1
  94. package/src/engine/validate-projection-allowlist.ts +5 -5
  95. package/src/errors/classes.ts +21 -0
  96. package/src/errors/index.ts +1 -0
  97. package/src/errors/write-error-info.ts +6 -2
  98. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  99. package/src/event-store/__tests__/event-store.integration.test.ts +60 -14
  100. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +27 -12
  101. package/src/event-store/admin-api.ts +11 -4
  102. package/src/event-store/event-store.ts +20 -21
  103. package/src/event-store/index.ts +0 -1
  104. package/src/event-store/types.ts +1 -1
  105. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  106. package/src/files/__tests__/local-provider.test.ts +31 -0
  107. package/src/files/__tests__/write-stream.test.ts +3 -3
  108. package/src/files/index.ts +1 -1
  109. package/src/files/local-provider.ts +6 -1
  110. package/src/files/types.ts +8 -1
  111. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  112. package/src/jobs/job-runner.ts +41 -11
  113. package/src/logging/types.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +203 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +106 -0
  124. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +111 -88
  125. package/src/pipeline/dispatch-shared.ts +59 -6
  126. package/src/pipeline/dispatch-stream.ts +44 -17
  127. package/src/pipeline/dispatcher.ts +7 -1
  128. package/src/pipeline/event-consumer-state.ts +16 -13
  129. package/src/pipeline/event-dispatcher-delivery.ts +22 -7
  130. package/src/pipeline/event-dispatcher.ts +22 -0
  131. package/src/pipeline/index.ts +2 -0
  132. package/src/pipeline/system-hooks.ts +137 -1
  133. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  134. package/src/rate-limit/resolver.ts +6 -2
  135. package/src/schema-cli.ts +24 -12
  136. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  137. package/src/search/reindex-entity.ts +31 -2
  138. package/src/search/types.ts +1 -1
  139. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  140. package/src/stack/db.ts +2 -1
  141. package/src/stack/push-entity-projection-tables.ts +2 -1
  142. package/src/stack/request-helper.ts +20 -1
  143. package/src/stack/table-helpers.ts +6 -4
  144. package/src/stack/test-stack.ts +18 -15
  145. package/src/testing/__tests__/late-bound.test.ts +7 -0
  146. package/src/testing/__tests__/wait-for.test.ts +6 -0
  147. package/src/testing/file-provider-contract.ts +26 -6
  148. package/src/testing/index.ts +1 -0
  149. package/src/testing/late-bound.ts +5 -3
  150. package/src/testing/wait-for.ts +3 -0
  151. package/src/testing/without-ambient-temporal.ts +14 -0
  152. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  153. package/src/time/geo-tz.ts +1 -1
  154. package/src/time/polyfill.ts +28 -39
  155. package/src/time/tz-context.ts +30 -24
  156. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  157. package/src/utils/safe-json.ts +3 -2
  158. package/src/db/__tests__/encryption.test.ts +0 -39
  159. package/src/db/encryption.ts +0 -45
  160. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -12,6 +12,23 @@
12
12
  import type { Snapshot } from "./migrate-generator";
13
13
  import { loadMigrationsFromDir } from "./migrate-runner";
14
14
 
15
+ // Migration files comment out destructive ops (DROP TABLE/COLUMN) as
16
+ // `-- DESTRUCTIVE: <stmt>; -- uncomment + ensure backup` so the real
17
+ // migrate-runner never executes them unattended. For replay purposes the
18
+ // snapshot represents the INTENDED end state, so a commented-out drop must
19
+ // still count as applied here — otherwise a table/column the snapshot
20
+ // already omits shows up as "unexpected" forever. splitSqlStatements (the
21
+ // real runner's splitter) strips `--`-comments outright, which would erase
22
+ // these markers before they ever reach applyStatement.
23
+ const DESTRUCTIVE_MARKER = /^--\s*DESTRUCTIVE:\s*(.+?;)/i;
24
+
25
+ function expandDestructiveMarkers(sqlText: string): string {
26
+ return sqlText
27
+ .split("\n")
28
+ .map((line) => DESTRUCTIVE_MARKER.exec(line.trim())?.[1] ?? line)
29
+ .join("\n");
30
+ }
31
+
15
32
  export type ReplayedTable = {
16
33
  readonly columns: ReadonlySet<string>;
17
34
  };
@@ -39,28 +56,67 @@ function splitTopLevel(body: string): readonly string[] {
39
56
  return parts;
40
57
  }
41
58
 
59
+ // Identifiers in hand-written migrations aren't always quoted (the generator
60
+ // always quotes, but the header explicitly invites hand-editing) — optional
61
+ // quotes so `CREATE TABLE foo (...)` parses the same as `CREATE TABLE "foo" (...)`.
62
+ const IDENT = `"?([^"\\s(;,.]+)"?`;
63
+
42
64
  function parseColumnNames(body: string): Set<string> {
43
65
  const columns = new Set<string>();
44
66
  for (const part of splitTopLevel(body)) {
45
67
  const trimmed = part.trim();
46
- if (/^CONSTRAINT\b/i.test(trimmed)) continue; // composite-PK line, not a column
47
- const match = trimmed.match(/^"([^"]+)"/);
68
+ if (/^(CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN|EXCLUDE|LIKE)\b/i.test(trimmed)) continue; // table-constraint or LIKE clause, not a column
69
+ const match = trimmed.match(new RegExp(`^${IDENT}`));
48
70
  if (match?.[1] !== undefined) columns.add(match[1]);
49
71
  }
50
72
  return columns;
51
73
  }
52
74
 
53
- function applyStatement(schema: Map<string, { columns: Set<string> }>, statement: string): void {
75
+ // Clauses that can appear inside an ALTER TABLE body which do NOT change the
76
+ // table/column shape this replay tracks (presence only, not types/
77
+ // constraints/RLS/ownership) — recognized explicitly so they don't fall
78
+ // through to the fail-loud check as "unparsed". Deliberately does NOT
79
+ // include RENAME TO / RENAME COLUMN: those DO change identity in a way this
80
+ // replay can't track, so they must keep failing loud.
81
+ const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp(
82
+ [
83
+ `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
84
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
85
+ `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
86
+ `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
87
+ `\\b(ENABLE|DISABLE)\\s+ROW LEVEL SECURITY\\b`,
88
+ `^OWNER TO\\b`,
89
+ ].join("|"),
90
+ "gi",
91
+ );
92
+
93
+ function applyStatement(
94
+ schema: Map<string, { columns: Set<string> }>,
95
+ statement: string,
96
+ context: { readonly file: string },
97
+ ): void {
54
98
  const create = statement.match(
55
- /^CREATE TABLE\s+(?:IF NOT EXISTS\s+)?"([^"]+)"\s*\(([\s\S]*)\);?\s*$/i,
99
+ new RegExp(`^CREATE TABLE\\s+(IF NOT EXISTS\\s+)?${IDENT}\\s*\\(([\\s\\S]*)\\);?\\s*$`, "i"),
56
100
  );
57
- if (create?.[1] !== undefined && create[2] !== undefined) {
58
- schema.set(create[1], { columns: parseColumnNames(create[2]) });
101
+ if (create?.[2] !== undefined && create[3] !== undefined) {
102
+ const hasIfNotExists = create[1] !== undefined;
103
+ // A real Postgres CREATE TABLE IF NOT EXISTS is a no-op when the table
104
+ // already exists — treating it as an overwrite here loses any columns
105
+ // an earlier ALTER TABLE added in between (0001 CREATE, 0002 ALTER ADD
106
+ // COLUMN, 0003 an accidental copy-paste of 0001) and reports a false
107
+ // column-drift for exactly the copy-paste bug this replay exists to
108
+ // catch. A bare CREATE TABLE (no IF NOT EXISTS) still overwrites — the
109
+ // explicit recreate path (migrate-generator.ts) always DROPs first, so
110
+ // reaching a second CREATE for the same name there is itself already
111
+ // the bug the replay should surface via the resulting drift.
112
+ if (!(hasIfNotExists && schema.has(create[2]))) {
113
+ schema.set(create[2], { columns: parseColumnNames(create[3]) });
114
+ }
59
115
  // skip: CREATE TABLE fully handled above, no other clause can also match
60
116
  return;
61
117
  }
62
118
 
63
- const dropTable = statement.match(/^DROP TABLE\s+(?:IF EXISTS\s+)?"([^"]+)"/i);
119
+ const dropTable = statement.match(new RegExp(`^DROP TABLE\\s+(?:IF EXISTS\\s+)?${IDENT}`, "i"));
64
120
  if (dropTable?.[1] !== undefined) {
65
121
  schema.delete(dropTable[1]);
66
122
  // skip: DROP TABLE fully handled above, no other clause can also match
@@ -73,29 +129,75 @@ function applyStatement(schema: Map<string, { columns: Set<string> }>, statement
73
129
  // instead of matching only the first clause, in statement order so an
74
130
  // add-then-drop of the same column (unusual, but not impossible) resolves
75
131
  // correctly.
76
- const alterTable = statement.match(/^ALTER TABLE\s+"([^"]+)"\s+([\s\S]*?);?\s*$/i);
132
+ const alterTable = statement.match(
133
+ new RegExp(`^ALTER TABLE\\s+${IDENT}\\s+([\\s\\S]*?);?\\s*$`, "i"),
134
+ );
77
135
  const alterTableName = alterTable?.[1];
78
136
  const alterBody = alterTable?.[2];
79
137
  if (alterTableName !== undefined && alterBody !== undefined) {
80
138
  const table = schema.get(alterTableName) ?? { columns: new Set<string>() };
81
139
  schema.set(alterTableName, table);
82
- const clauseRe = /(ADD|DROP)\s+COLUMN\s+(?:IF (?:NOT )?EXISTS\s+)?"([^"]+)"/gi;
140
+ const clauseRe = new RegExp(
141
+ `(ADD|DROP)\\s+COLUMN\\s+(?:IF (?:NOT )?EXISTS\\s+)?${IDENT}`,
142
+ "gi",
143
+ );
144
+ let matchedAClause = false;
83
145
  for (const [, verb, name] of alterBody.matchAll(clauseRe)) {
84
146
  if (verb === undefined || name === undefined) continue;
147
+ matchedAClause = true;
85
148
  if (verb.toUpperCase() === "ADD") table.columns.add(name);
86
149
  else table.columns.delete(name);
87
150
  }
151
+ // Shape-neutral clauses (ALTER COLUMN TYPE, SET/DROP NOT NULL, SET/DROP
152
+ // DEFAULT, ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER
153
+ // TO) — none add/remove/rename a column, so this replay (which only
154
+ // tracks column presence) correctly has nothing to do for them.
155
+ if (alterBody.match(SHAPE_NEUTRAL_ALTER_CLAUSE_RE)) matchedAClause = true;
156
+ // An ALTER TABLE that matched the outer "ALTER TABLE <name> <body>" shape
157
+ // but whose body contains no recognized clause (e.g. RENAME TO/RENAME
158
+ // COLUMN, which DO change identity) would otherwise silently no-op here
159
+ // — fall through to the fail-loud check below instead of returning, so
160
+ // it's reported rather than vanishing.
161
+ // skip: at least one recognized clause matched — this ALTER TABLE is
162
+ // fully handled, nothing left to do.
163
+ if (matchedAClause) return;
88
164
  }
89
165
  // else: CREATE INDEX and everything else don't change the table/column
90
- // shape this replay tracks.
166
+ // shape this replay tracks — but a statement that clearly INTENDED to
167
+ // touch a table's shape (starts with CREATE/ALTER/DROP TABLE) and matched
168
+ // none of the recognized patterns above must fail loud, not vanish
169
+ // silently. Concretely this is RENAME TO / RENAME COLUMN (identity change
170
+ // this replay can't track) or genuinely unparsed hand-written DDL — either
171
+ // way a misleading missing-table/column-drift report is worse than
172
+ // pointing at the actual unparsed statement.
173
+ if (/^(CREATE|ALTER|DROP)\s+TABLE\b/i.test(statement)) {
174
+ const prefix = statement.slice(0, 200).replace(/\s+/g, " ").trim();
175
+ throw new Error(
176
+ `replayMigrationsDir: unparsed table-DDL statement in ${context.file} — ` +
177
+ `starts with CREATE/ALTER/DROP TABLE but matched none of the replay's ` +
178
+ `recognized patterns (CREATE TABLE, DROP TABLE, ALTER TABLE ADD/DROP ` +
179
+ `COLUMN, ALTER COLUMN ... TYPE, SET/DROP NOT NULL, SET/DROP DEFAULT, ` +
180
+ `ADD/DROP CONSTRAINT, ENABLE/DISABLE ROW LEVEL SECURITY, OWNER TO — ` +
181
+ `optionally-quoted identifiers). Likely RENAME TO/RENAME COLUMN (real ` +
182
+ `identity change, not trackable here) or genuinely unparsed hand-written ` +
183
+ `DDL. Statement: ${prefix}${statement.length > 200 ? "…" : ""}`,
184
+ );
185
+ }
91
186
  }
92
187
 
93
188
  // Reads `<migrationsDir>/*.sql` in sequence order and replays every
94
189
  // CREATE/ALTER/DROP TABLE statement to reconstruct the resulting schema.
190
+ // Reuses the real runner's file-discovery + statement-splitting
191
+ // (loadMigrationsFromDir) instead of a second copy, so any future change to
192
+ // sub-directory handling, numeric sort order, or the .sql filter can't
193
+ // silently drift between the runner and this replay (#1522/9).
95
194
  export function replayMigrationsDir(migrationsDir: string): ReplayedSchema {
96
195
  const schema = new Map<string, { columns: Set<string> }>();
97
- for (const migration of loadMigrationsFromDir(migrationsDir)) {
98
- for (const statement of migration.statements) applyStatement(schema, statement);
196
+ const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
197
+ for (const migration of migrations) {
198
+ for (const statement of migration.statements) {
199
+ applyStatement(schema, statement, { file: migration.id });
200
+ }
99
201
  }
100
202
  return schema;
101
203
  }
@@ -1,4 +1,4 @@
1
- import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
1
+ import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
3
  import {
4
4
  asEntityTableMeta,
@@ -24,10 +24,8 @@ export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): rea
24
24
  return rows as unknown as readonly T[];
25
25
  }
26
26
 
27
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
28
-
29
27
  function tableNameOf(table: Table): string {
30
- const sym = (table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
28
+ const sym = table[KUMIKO_NAME_SYMBOL];
31
29
  return typeof sym === "string" ? sym : "<unknown>";
32
30
  }
33
31
 
@@ -599,6 +599,20 @@ describe("boot-validator", () => {
599
599
  expect(() => validateBoot(features)).toThrow(/a:stream:chat:complete.*missing an access rule/i);
600
600
  });
601
601
 
602
+ test("object-form streamHandler with access rule passes boot", () => {
603
+ const features = [
604
+ defineFeature("a", (r) => {
605
+ r.streamHandler({
606
+ name: "chat:complete",
607
+ schema: z.object({}),
608
+ handler: async function* () {},
609
+ access: { roles: ["User"] },
610
+ });
611
+ }),
612
+ ];
613
+ expect(() => validateBoot(features)).not.toThrow();
614
+ });
615
+
602
616
  test("accepts openToAll access rule on a stream handler", () => {
603
617
  const features = [
604
618
  defineFeature("a", (r) => {
@@ -739,6 +739,36 @@ describe("createApp", () => {
739
739
  ).not.toThrow();
740
740
  });
741
741
 
742
+ test("validates stream handler roles against app-defined roles (#1442)", () => {
743
+ const feature = defineFeature("admin", (r) => {
744
+ r.streamHandler("admin.tail", z.object({}), async function* () {}, {
745
+ access: { roles: ["SuperAdmin"] },
746
+ });
747
+ });
748
+
749
+ expect(() =>
750
+ createApp({
751
+ roles: ["Admin", "User"] as const,
752
+ features: [feature],
753
+ }),
754
+ ).toThrow(/unknown role.*SuperAdmin/i);
755
+ });
756
+
757
+ test("passes when stream handler roles are valid (#1442)", () => {
758
+ const feature = defineFeature("admin", (r) => {
759
+ r.streamHandler("admin.tail", z.object({}), async function* () {}, {
760
+ access: { roles: ["Admin"] },
761
+ });
762
+ });
763
+
764
+ expect(() =>
765
+ createApp({
766
+ roles: ["Admin", "User"] as const,
767
+ features: [feature],
768
+ }),
769
+ ).not.toThrow();
770
+ });
771
+
742
772
  test("createApp returns registry", () => {
743
773
  const feature = defineFeature("test", () => {});
744
774
  const app = createApp({ roles: ["Admin"] as const, features: [feature] });
@@ -139,6 +139,42 @@ describe("getAllStreamHandlers", () => {
139
139
  });
140
140
  expect(() => createRegistry([aiFeature, otherFeature])).not.toThrow();
141
141
  });
142
+
143
+ test("two distinct feature names that kebab-collide throw on the qualified stream-handler clash", () => {
144
+ // "registry-test-kebab-dup" and "registryTestKebabDup" are different raw
145
+ // feature.name values (so the earlier Duplicate-feature guard doesn't
146
+ // fire) but toKebab() collapses both to the same qualified name.
147
+ const featureA = defineFeature("registry-test-kebab-dup", (r) => {
148
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
149
+ });
150
+ const featureB = defineFeature("registryTestKebabDup", (r) => {
151
+ r.streamHandler("chat:complete", z.object({}), async function* () {});
152
+ });
153
+ expect(() => createRegistry([featureA, featureB])).toThrow(/Duplicate stream handler/);
154
+ });
155
+
156
+ test("object-form streamHandler registration preserves schema/access/rateLimit", () => {
157
+ const schema = z.object({ prompt: z.string() });
158
+ const handlerFn = async function* () {};
159
+ const feature = defineFeature("registry-test-object-form", (r) => {
160
+ r.streamHandler({
161
+ name: "chat:complete",
162
+ schema,
163
+ handler: handlerFn,
164
+ access: { openToAll: true },
165
+ rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 60 },
166
+ });
167
+ });
168
+
169
+ const registry = createRegistry([feature]);
170
+ const registered = registry.getStreamHandler("registry-test-object-form:stream:chat:complete");
171
+
172
+ expect(registered).toBeDefined();
173
+ expect(registered?.schema).toBe(schema);
174
+ expect(registered?.handler).toBe(handlerFn);
175
+ expect(registered?.access).toEqual({ openToAll: true });
176
+ expect(registered?.rateLimit).toEqual({ per: "ip+handler", limit: 5, windowSeconds: 60 });
177
+ });
142
178
  });
143
179
 
144
180
  describe("extensionSelector boot-validation", () => {
@@ -113,6 +113,24 @@ describe("buildInsertSchema", () => {
113
113
  valid: { age: 25 },
114
114
  invalid: { age: "old" },
115
115
  },
116
+ {
117
+ name: "number field rejects below min",
118
+ fields: { age: createNumberField({ min: 0 }) },
119
+ valid: { age: 0 },
120
+ invalid: { age: -1 },
121
+ },
122
+ {
123
+ name: "number field rejects above max",
124
+ fields: { age: createNumberField({ max: 100 }) },
125
+ valid: { age: 100 },
126
+ invalid: { age: 101 },
127
+ },
128
+ {
129
+ name: "number field min+max bounds",
130
+ fields: { age: createNumberField({ min: 1, max: 10 }) },
131
+ valid: { age: 5 },
132
+ invalid: { age: 11 },
133
+ },
116
134
  {
117
135
  name: "date field",
118
136
  fields: { born: createDateField() },
@@ -142,7 +142,7 @@ describe("createRegistry — storeTable aggregation", () => {
142
142
  r.storeTable(probeMeta, { reason: "second" });
143
143
  });
144
144
  expect(() => createRegistry([featA, featB])).toThrow(
145
- /Raw-table "rt_probe" registered by both feature "a" and "b"/,
145
+ /Store-table "rt_probe" registered by both feature "a" and "b"/,
146
146
  );
147
147
  });
148
148
 
@@ -177,7 +177,7 @@ describe("createRegistry — storeTable aggregation", () => {
177
177
 
178
178
  // Entity registered first, then the colliding store table.
179
179
  expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
180
- new RegExp(`Raw-table "${physical}".*collides with the physical table of entity "widget"`),
180
+ new RegExp(`Store-table "${physical}".*collides with the physical table of entity "widget"`),
181
181
  );
182
182
 
183
183
  // Order-independent: store table registered first, then the entity.
@@ -101,32 +101,21 @@ const USER_BUCKETED_RATE_LIMIT_PER: ReadonlySet<string> = new Set(["user", "user
101
101
  // at runtime, but we fail at boot to turn an easy-to-miss security regression
102
102
  // into a loud configuration error.
103
103
  export function validateHandlerAccess(feature: FeatureDefinition): void {
104
- for (const [name, handler] of Object.entries(feature.writeHandlers)) {
105
- if (!handler.access) {
106
- throw new Error(
107
- `Write handler "${feature.name}:write:${name}" is missing an access rule. ` +
108
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
109
- );
110
- }
111
- validateAnonymousRateLimit(feature.name, "write", name, handler.access, handler.rateLimit);
112
- }
113
- for (const [name, handler] of Object.entries(feature.queryHandlers)) {
114
- if (!handler.access) {
115
- throw new Error(
116
- `Query handler "${feature.name}:query:${name}" is missing an access rule. ` +
117
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
118
- );
119
- }
120
- validateAnonymousRateLimit(feature.name, "query", name, handler.access, handler.rateLimit);
121
- }
122
- for (const [name, handler] of Object.entries(feature.streamHandlers)) {
123
- if (!handler.access) {
124
- throw new Error(
125
- `Stream handler "${feature.name}:stream:${name}" is missing an access rule. ` +
126
- `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
127
- );
104
+ const kinds = [
105
+ { kind: "write" as const, label: "Write", handlers: feature.writeHandlers },
106
+ { kind: "query" as const, label: "Query", handlers: feature.queryHandlers },
107
+ { kind: "stream" as const, label: "Stream", handlers: feature.streamHandlers },
108
+ ];
109
+ for (const { kind, label, handlers } of kinds) {
110
+ for (const [name, handler] of Object.entries(handlers)) {
111
+ if (!handler.access) {
112
+ throw new Error(
113
+ `${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
114
+ `Set { roles: [...] } for role-based access, or { openToAll: true } for any authenticated user.`,
115
+ );
116
+ }
117
+ validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
128
118
  }
129
- validateAnonymousRateLimit(feature.name, "stream", name, handler.access, handler.rateLimit);
130
119
  }
131
120
  }
132
121
 
@@ -115,6 +115,11 @@ export function collectKnownRoles(features: readonly FeatureDefinition[]): Set<s
115
115
  for (const r of def.access.roles) roles.add(r);
116
116
  }
117
117
  }
118
+ for (const def of Object.values(f.streamHandlers)) {
119
+ if (def.access && "roles" in def.access) {
120
+ for (const r of def.access.roles) roles.add(r);
121
+ }
122
+ }
118
123
  }
119
124
  return roles;
120
125
  }
@@ -1,3 +1,6 @@
1
+ import type { ConcurrencyMode } from "@cosmicdrift/kumiko-types/concurrency-mode";
2
+ import type { ConfigScope } from "@cosmicdrift/kumiko-types/config-scope";
3
+ import type { OnDeleteStrategy as OnDeleteStrategyType } from "@cosmicdrift/kumiko-types/relations";
1
4
  import type { TenantId } from "./types/identifiers";
2
5
 
3
6
  // All framework constants as `as const` objects with inferred union types.
@@ -47,14 +50,23 @@ export const LifecycleHookTypes = {
47
50
  export type LifecycleHookType = (typeof LifecycleHookTypes)[keyof typeof LifecycleHookTypes];
48
51
 
49
52
  // --- Config Scopes ---
53
+ // Value object satisfies the canonical ConfigScope union from kumiko-types —
54
+ // a scope added here without a matching kumiko-types member is now a
55
+ // compile error instead of silent drift (#1423/#1439).
50
56
 
51
57
  export const ConfigScopes = {
52
58
  system: "system",
53
59
  tenant: "tenant",
54
60
  user: "user",
55
- } as const;
61
+ } as const satisfies Record<string, ConfigScope>;
62
+
63
+ export type { ConfigScope };
56
64
 
57
- export type ConfigScope = (typeof ConfigScopes)[keyof typeof ConfigScopes];
65
+ // Reverse direction: a member added only to kumiko-types' ConfigScope
66
+ // (not to ConfigScopes above) would otherwise pass the `satisfies` check
67
+ // above unnoticed — this line forces exhaustiveness the other way too.
68
+ const _configScopeExhaustive: Record<ConfigScope, unknown> = ConfigScopes;
69
+ void _configScopeExhaustive;
58
70
 
59
71
  // --- On Delete Strategies ---
60
72
 
@@ -63,11 +75,13 @@ export const OnDeleteStrategies = {
63
75
  restrict: "restrict",
64
76
  setNull: "setNull",
65
77
  nothing: "nothing",
66
- } as const;
78
+ } as const satisfies Record<OnDeleteStrategyType, OnDeleteStrategyType>;
67
79
 
68
- export type OnDeleteStrategy = (typeof OnDeleteStrategies)[keyof typeof OnDeleteStrategies];
80
+ export type OnDeleteStrategy = OnDeleteStrategyType;
69
81
 
70
82
  // --- Concurrency Modes ---
83
+ // Value object satisfies the canonical ConcurrencyMode union from
84
+ // kumiko-types — same drift-guard as ConfigScopes above.
71
85
 
72
86
  export const ConcurrencyModes = {
73
87
  parallel: "parallel",
@@ -75,12 +89,24 @@ export const ConcurrencyModes = {
75
89
  replace: "replace",
76
90
  sequential: "sequential",
77
91
  debounce: "debounce",
78
- } as const;
92
+ } as const satisfies Record<string, ConcurrencyMode>;
79
93
 
80
- export type ConcurrencyMode = (typeof ConcurrencyModes)[keyof typeof ConcurrencyModes];
94
+ export type { ConcurrencyMode };
95
+
96
+ // Reverse-direction exhaustiveness guard — see the ConfigScope one above.
97
+ const _concurrencyModeExhaustive: Record<ConcurrencyMode, unknown> = ConcurrencyModes;
98
+ void _concurrencyModeExhaustive;
81
99
 
82
100
  // --- SSE Channels ---
83
101
 
84
102
  export function tenantChannel(tenantId: TenantId): string {
85
103
  return `tenant:${tenantId}`;
86
104
  }
105
+
106
+ // Access-invalidation channel key for a single user's live streams. Both
107
+ // the subscribe side (dispatch-stream.ts) and the publish side (session-
108
+ // revoke / tenant-membership consumers, issue #1559/#1560) must derive the
109
+ // key through this helper — never build the string inline on either side.
110
+ export function userAccessChannel(userId: string): string {
111
+ return `user:${userId}:access`;
112
+ }
@@ -53,6 +53,17 @@ export function createApp(config: AppConfig): App {
53
53
  }
54
54
  }
55
55
  }
56
+ for (const handler of Object.values(feature.streamHandlers)) {
57
+ if (handler.access && "roles" in handler.access) {
58
+ for (const role of handler.access.roles) {
59
+ if (!validRoles.has(role)) {
60
+ throw new Error(
61
+ `Unknown role "${role}" in stream handler "${handler.name}" of feature "${feature.name}". Valid roles: ${config.roles.join(", ")}`,
62
+ );
63
+ }
64
+ }
65
+ }
66
+ }
56
67
  for (const [key, keyDef] of Object.entries(feature.configKeys)) {
57
68
  for (const role of [...keyDef.access.read, ...keyDef.access.write]) {
58
69
  if (!systemRoles.has(role) && !validRoles.has(role)) {
@@ -1,4 +1,4 @@
1
- import type { Registry } from "./types";
1
+ import type { FeatureDefinition, Registry } from "./types";
2
2
 
3
3
  // Callback that returns the current global-toggle override for a feature.
4
4
  // `true` = explicit global row says enabled.
@@ -11,6 +11,16 @@ import type { Registry } from "./types";
11
11
  // a Map lookup to keep compute() allocation-light.
12
12
  export type ToggleReader = (featureName: string) => boolean | undefined;
13
13
 
14
+ // A feature is toggleable iff it declared r.toggleable() at define-time
15
+ // (toggleableDefault set). Non-toggleable features are always-on and ignore
16
+ // overrides entirely — used here, by compose-tier-resolver, and by
17
+ // tier-engine's always-on set to agree on the same rule.
18
+ export function isToggleableFeature(
19
+ feature: FeatureDefinition,
20
+ ): feature is FeatureDefinition & { toggleableDefault: boolean } {
21
+ return feature.toggleableDefault !== undefined;
22
+ }
23
+
14
24
  // Compute the set of effectively-enabled features for the current call.
15
25
  //
16
26
  // Rules (AND-combined, any false wins):
@@ -35,7 +45,7 @@ export function computeEffectiveFeatures(
35
45
  // Raw enablement, before cascade.
36
46
  const raw = new Map<string, boolean>();
37
47
  for (const feature of registry.features.values()) {
38
- if (feature.toggleableDefault === undefined) {
48
+ if (!isToggleableFeature(feature)) {
39
49
  raw.set(feature.name, true);
40
50
  continue;
41
51
  }
@@ -150,14 +150,22 @@ export type UserDataExportHook = (ctx: UserDataHookCtx) => Promise<UserDataExpor
150
150
  * `{status:"incomplete", reason}` to signal a partial success to the
151
151
  * cleanup runner (e.g. an external provider call failed) without
152
152
  * throwing/rolling back the sub-transaction. Existing `void` returners
153
- * stay valid unchanged.
153
+ * stay valid unchanged — including ones explicitly typed
154
+ * `Promise<void>` (an explicitly-annotated hook, unlike a contextually-
155
+ * typed arrow literal, needs `void` in the union itself: `void` is not
156
+ * assignable to `undefined`).
154
157
  */
158
+ // Intentional: void (not undefined) is required here so an explicitly-typed
159
+ // `Promise<void>` hook stays assignable (see UserDataDeleteHook's doc
160
+ // comment above).
161
+ export type UserDataDeleteHookResult =
162
+ // biome-ignore lint/suspicious/noConfusingVoidType: see comment above
163
+ void | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string };
164
+
155
165
  export type UserDataDeleteHook = (
156
166
  ctx: UserDataHookCtx,
157
167
  strategy: UserDataDeleteStrategy,
158
- ) => Promise<
159
- undefined | { readonly status: "ok" } | { readonly status: "incomplete"; readonly reason: string }
160
- >;
168
+ ) => Promise<UserDataDeleteHookResult>;
161
169
 
162
170
  /**
163
171
  * Komplette Hook-Tafel für EXT_USER_DATA. Sprint 2 user-data-rights
@@ -258,6 +258,7 @@ describe("render → parse roundtrip — mixed patterns (header data + opaque bo
258
258
  // from the parsed FeaturePattern shape alone.
259
259
  const RAW_REF_FEATURE = `
260
260
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
261
+ import { chatCompleteHandler } from "./handlers";
261
262
 
262
263
  const eventEntity = {
263
264
  fields: { name: { type: "text", required: true } },
@@ -284,6 +285,7 @@ defineFeature("refs", (r) => {
284
285
  r.entity("event", eventEntity);
285
286
  r.entity("task", { fields: buildFields() });
286
287
  r.writeHandler(makeHandler());
288
+ r.streamHandler(chatCompleteHandler);
287
289
  r.screen(eventListScreen);
288
290
  });
289
291
  `;
@@ -297,6 +299,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
297
299
  { kind: "entity", entityName: "event", definition: { __raw: "eventEntity" } },
298
300
  { kind: "entity", entityName: "task", definition: { fields: { __raw: "buildFields()" } } },
299
301
  { kind: "writeHandler", handlerName: undefined },
302
+ { kind: "streamHandler", handlerName: undefined },
300
303
  { kind: "screen", definition: { __raw: "eventListScreen" } },
301
304
  ]);
302
305
  });
@@ -310,6 +313,7 @@ describe("render → parse roundtrip — unresolved references (raw-ref sentinel
310
313
  expect(rendered).toContain("eventEntity");
311
314
  expect(rendered).toContain("buildFields()");
312
315
  expect(rendered).toContain("r.writeHandler(makeHandler())");
316
+ expect(rendered).toContain("r.streamHandler(chatCompleteHandler)");
313
317
  expect(rendered).toContain("r.screen(eventListScreen);");
314
318
  // Would only appear if buildFields()'s return value got inlined.
315
319
  expect(rendered).not.toContain("title:");
@@ -204,21 +204,24 @@ export function extractWriteHandler(
204
204
  });
205
205
  }
206
206
 
207
- export function extractQueryHandler(
208
- call: CallExpression,
209
- sourceFile: SourceFile,
210
- ): ExtractOutput<QueryHandlerPattern> {
211
- const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
212
- if (parsed.kind === "error") return parsed;
213
- return ok({
214
- kind: "queryHandler",
207
+ function readHandlerFields(parsed: Extract<ExtractOutput<ParsedHandlerCall>, { kind: "pattern" }>) {
208
+ return {
215
209
  source: parsed.pattern.source,
216
210
  handlerName: parsed.pattern.handlerName,
217
211
  schemaSource: parsed.pattern.schemaSource,
218
212
  handlerBody: parsed.pattern.handlerBody,
219
213
  ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
220
214
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
221
- });
215
+ };
216
+ }
217
+
218
+ export function extractQueryHandler(
219
+ call: CallExpression,
220
+ sourceFile: SourceFile,
221
+ ): ExtractOutput<QueryHandlerPattern> {
222
+ const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
223
+ if (parsed.kind === "error") return parsed;
224
+ return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
222
225
  }
223
226
 
224
227
  export function extractStreamHandler(
@@ -227,13 +230,5 @@ export function extractStreamHandler(
227
230
  ): ExtractOutput<StreamHandlerPattern> {
228
231
  const parsed = parseHandlerCall(call, sourceFile, "streamHandler");
229
232
  if (parsed.kind === "error") return parsed;
230
- return ok({
231
- kind: "streamHandler",
232
- source: parsed.pattern.source,
233
- handlerName: parsed.pattern.handlerName,
234
- schemaSource: parsed.pattern.schemaSource,
235
- handlerBody: parsed.pattern.handlerBody,
236
- ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
237
- ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
238
- });
233
+ return ok({ kind: "streamHandler", ...readHandlerFields(parsed) });
239
234
  }