@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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.
Files changed (85) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/pat-scope.test.ts +36 -0
  8. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  9. package/src/api/auth-middleware.ts +65 -1
  10. package/src/api/auth-routes.ts +11 -0
  11. package/src/api/index.ts +3 -1
  12. package/src/api/jwt.ts +1 -4
  13. package/src/api/pat-scope.ts +14 -0
  14. package/src/api/request-context.ts +3 -0
  15. package/src/api/request-id-middleware.ts +2 -0
  16. package/src/api/routes.ts +22 -0
  17. package/src/api/server.ts +29 -1
  18. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  19. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  20. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  21. package/src/bun-db/query.ts +40 -9
  22. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  23. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  25. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  26. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  27. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  28. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  29. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  31. package/src/db/apply-entity-event.ts +19 -8
  32. package/src/db/event-store-executor.ts +91 -8
  33. package/src/db/queries/shadow-swap.ts +1 -1
  34. package/src/db/query.ts +1 -0
  35. package/src/db/table-builder.ts +23 -1
  36. package/src/db/tenant-db.ts +6 -0
  37. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  38. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  39. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  40. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  41. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  42. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  43. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  44. package/src/engine/boot-validator/entity-handler.ts +45 -0
  45. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  46. package/src/engine/boot-validator/index.ts +14 -1
  47. package/src/engine/boot-validator/screens-nav.ts +90 -6
  48. package/src/engine/build-app-schema.ts +15 -7
  49. package/src/engine/define-feature.ts +17 -0
  50. package/src/engine/define-handler.ts +16 -2
  51. package/src/engine/entity-handlers.ts +32 -13
  52. package/src/engine/index.ts +6 -1
  53. package/src/engine/projection-helpers.ts +8 -5
  54. package/src/engine/registry.ts +47 -2
  55. package/src/engine/schema-builder.ts +3 -1
  56. package/src/engine/soft-delete-cleanup.ts +41 -4
  57. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  58. package/src/engine/tier-resolver-extension.ts +11 -0
  59. package/src/engine/types/feature.ts +29 -21
  60. package/src/engine/types/fields.ts +12 -0
  61. package/src/engine/types/handlers.ts +13 -0
  62. package/src/engine/types/index.ts +2 -0
  63. package/src/engine/types/projection.ts +33 -5
  64. package/src/event-store/index.ts +8 -0
  65. package/src/event-store/rebuild-dead-letter.ts +111 -0
  66. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  67. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  68. package/src/pipeline/dispatcher.ts +4 -10
  69. package/src/pipeline/msp-rebuild.ts +36 -3
  70. package/src/pipeline/projection-rebuild.ts +49 -3
  71. package/src/pipeline/projections-runner.ts +1 -1
  72. package/src/schema-cli.ts +24 -15
  73. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  74. package/src/secrets/types.ts +8 -1
  75. package/src/testing/db-cleanup.ts +4 -1
  76. package/src/testing/index.ts +1 -0
  77. package/src/testing/seed.ts +50 -0
  78. package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
  79. package/src/time/__tests__/geo-tz.test.ts +9 -3
  80. package/src/time/__tests__/iana.test.ts +9 -0
  81. package/src/time/boot-tz-warning.ts +5 -1
  82. package/src/time/iana.ts +17 -15
  83. package/src/time/tz-context.ts +6 -1
  84. package/src/utils/__tests__/serialization.test.ts +6 -0
  85. package/src/utils/serialization.ts +10 -3
package/src/schema-cli.ts CHANGED
@@ -44,17 +44,28 @@ export type SchemaCliOut = {
44
44
 
45
45
  const SNAPSHOT_FILENAME = ".snapshot.json";
46
46
 
47
- async function loadEntityMetasFromApp(
48
- schemaFile: string,
49
- ): Promise<Parameters<typeof renderTablesDdl>[0]> {
47
+ type SchemaMod = {
48
+ readonly entityMetas: Parameters<typeof renderTablesDdl>[0];
49
+ readonly features?: unknown;
50
+ };
51
+
52
+ // Single load-path for kumiko/schema.ts (512/3) — `generate` and `validate`
53
+ // both need ENTITY_METAS (and validate additionally wants FEATURES); this is
54
+ // the one place that enforces "ENTITY_METAS must be an array" so the two
55
+ // commands can't drift on the check or its wording. Throws — callers that
56
+ // want a graceful exit code (validate) catch it themselves.
57
+ async function loadSchemaModFromApp(schemaFile: string): Promise<SchemaMod> {
50
58
  // bun imports TS directly — no spawn needed.
51
- const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
59
+ const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown; FEATURES?: unknown };
52
60
  if (!Array.isArray(mod.ENTITY_METAS)) {
53
61
  throw new Error(
54
62
  `Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
55
63
  );
56
64
  }
57
- return mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
65
+ return {
66
+ entityMetas: mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0],
67
+ features: mod.FEATURES,
68
+ };
58
69
  }
59
70
 
60
71
  function nextSequenceNumber(migrationsDir: string): number {
@@ -132,7 +143,7 @@ export async function runSchemaCli(
132
143
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
133
144
  return 1;
134
145
  }
135
- const metas = await loadEntityMetasFromApp(schemaFile);
146
+ const { entityMetas: metas } = await loadSchemaModFromApp(schemaFile);
136
147
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
137
148
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
138
149
  const result = generateMigration({
@@ -191,20 +202,18 @@ export async function runSchemaCli(
191
202
  out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
192
203
  return 1;
193
204
  }
194
- const mod = (await import(schemaFile)) as {
195
- ENTITY_METAS?: unknown;
196
- FEATURES?: unknown;
197
- };
198
- if (!Array.isArray(mod.ENTITY_METAS)) {
199
- out.err(
200
- ` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
201
- );
205
+ let schemaMod: SchemaMod;
206
+ try {
207
+ schemaMod = await loadSchemaModFromApp(schemaFile);
208
+ } catch (e) {
209
+ out.err(` ${e instanceof Error ? e.message : String(e)}`);
202
210
  return 1;
203
211
  }
212
+ const mod = { FEATURES: schemaMod.features };
204
213
  let ok = true;
205
214
 
206
215
  // 1. Schema drift — compute the diff, never write.
207
- const metas = mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
216
+ const metas = schemaMod.entityMetas;
208
217
  const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
209
218
  const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
210
219
  const drift = generateMigration({
@@ -32,6 +32,18 @@ export type _R6TypeAssertions = [
32
32
  // the backstop. This is what keeps generic-over-response handlers compiling.
33
33
  Expect<Equal<ContainsSecret<unknown>, false>>,
34
34
  Expect<Equal<ContainsSecret<never>, false>>,
35
+ // 556/3: ContainsSecret<A | B> distributes over the union (naked type
36
+ // param) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`, not the
37
+ // literal `true` — pinned here so a future change to this predicate can't
38
+ // silently regress it. The actual leak-guard in define-handler.ts compares
39
+ // via `true extends ContainsSecret<TData>` (556/1) instead of `ContainsSecret<
40
+ // TData> extends true`, which is fail-closed for `boolean` either way.
41
+ Expect<Equal<ContainsSecret<{ ok: true } | { s: Secret<string> }>, boolean>>,
42
+ // 556/2: Map/Set are explicit SafeLeaf members now — pins that the runtime
43
+ // guard's "walk Map/Set entries separately" intent is mirrored, not an
44
+ // accidental compile-time blind spot.
45
+ Expect<Equal<ContainsSecret<Map<string, Secret<string>>>, false>>,
46
+ Expect<Equal<ContainsSecret<Set<Secret<string>>>, false>>,
35
47
  ];
36
48
 
37
49
  const schema = z.object({ q: z.string() });
@@ -57,6 +69,28 @@ function genericResponseHandler<K extends string>(kind: K) {
57
69
  }
58
70
  void genericResponseHandler;
59
71
 
72
+ // 556/1: ContainsSecret<A | B> distributes over the union (naked type param
73
+ // in the condition) to `ContainsSecret<A> | ContainsSecret<B>` = `boolean`,
74
+ // not the literal `true` — a check written as `ContainsSecret<TData> extends
75
+ // true` silently passes a union with a leak hidden in ONE branch. Both
76
+ // assertions below must hold: the guard rejects the leaking union AND still
77
+ // accepts the clean one, proving the fix (membership form on the phantom
78
+ // param) is fail-closed for unions, not just for a bare Secret<>.
79
+ declare const leakingUnion: { ok: true } | { apiKey: Secret<string> };
80
+ // @ts-expect-error — R6: a Secret<> hidden in one union branch must still leak-guard
81
+ defineQueryHandler({
82
+ name: "t:query:union-leak",
83
+ schema,
84
+ handler: async () => leakingUnion,
85
+ });
86
+
87
+ declare const cleanUnion: { ok: true } | { id: string };
88
+ defineQueryHandler({
89
+ name: "t:query:union-clean",
90
+ schema,
91
+ handler: async () => cleanUnion,
92
+ });
93
+
60
94
  describe("R6 ContainsSecret", () => {
61
95
  test("a clean query handler still builds — the guard param is invisible", () => {
62
96
  const def = defineQueryHandler({
@@ -56,6 +56,11 @@ type Primitive = string | number | boolean | bigint | symbol | null | undefined;
56
56
 
57
57
  // Opaque built-in leaves a response legitimately carries; never recurse into
58
58
  // them. Extend when the bundled-features tsc sweep surfaces a real leaf type.
59
+ // Map/Set (556/2): `keyof Map<K,V>` yields method names, not V, so these
60
+ // already fell through to `false` via the object-mapped-type branch — listed
61
+ // explicitly here so the compile-time treatment matches leak-guard.ts's
62
+ // runtime `instanceof Map`/`instanceof Set` branch (walk entries separately)
63
+ // instead of looking like an oversight.
59
64
  type SafeLeaf =
60
65
  | Date
61
66
  | RegExp
@@ -66,7 +71,9 @@ type SafeLeaf =
66
71
  | Temporal.PlainTime
67
72
  | Temporal.PlainYearMonth
68
73
  | Temporal.PlainMonthDay
69
- | Temporal.Duration;
74
+ | Temporal.Duration
75
+ | Map<unknown, unknown>
76
+ | Set<unknown>;
70
77
 
71
78
  export type ContainsSecret<T> = [T] extends [never]
72
79
  ? false
@@ -32,7 +32,10 @@ function resolveClearableTable(table: ClearableTable): unknown {
32
32
  /** Delete all rows from each table (order preserved — FK-sensitive callers order explicitly). */
33
33
  export async function clearTables(db: AnyDb, tables: readonly ClearableTable[]): Promise<void> {
34
34
  for (const table of tables) {
35
- await deleteMany(db, resolveClearableTable(table), {});
35
+ // Test-teardown truncate: resolveClearableTable returns `unknown` (string
36
+ // name or table object). The brand-strip cast is the sanctioned bypass —
37
+ // clearing a managed projection between tests is not a production write.
38
+ await deleteMany(db, resolveClearableTable(table) as EntityTableMeta, {});
36
39
  }
37
40
  }
38
41
 
@@ -31,6 +31,7 @@ export {
31
31
  createRecordingProvider,
32
32
  type RecordingProvider,
33
33
  } from "./observability-recorder";
34
+ export { deleteRows, seedRow, seedRows, updateRows } from "./seed";
34
35
  export {
35
36
  sharedItemEntity,
36
37
  sharedItemTable,
@@ -0,0 +1,50 @@
1
+ // Test-only ES-bypass: seed / mutate a managed projection directly, skipping
2
+ // the event log. Production code CANNOT do this — the write helpers reject a
3
+ // branded EntityTable at compile time (#742). Tests seed read-model state to
4
+ // arrange a scenario without driving the full event path; a projection rebuild
5
+ // is not part of the test lifecycle, so eventless writes are safe here. The
6
+ // `as EntityTableMeta` strips the executor-only brand at this single sanctioned
7
+ // seam — the signatures mirror the query helpers so a call-site migration is a
8
+ // plain identifier rename.
9
+
10
+ import {
11
+ type AnyDb,
12
+ deleteMany,
13
+ insertMany,
14
+ insertOne,
15
+ updateMany,
16
+ type WhereObject,
17
+ } from "../bun-db/query";
18
+ import type { EntityTableMeta } from "../db/entity-table-meta";
19
+ import type { EntityTable } from "../db/table-builder";
20
+
21
+ type SeedTable = EntityTable | EntityTableMeta;
22
+
23
+ export function seedRow<TRow = unknown>(
24
+ db: AnyDb,
25
+ table: SeedTable,
26
+ values: Record<string, unknown>,
27
+ ): Promise<TRow | undefined> {
28
+ return insertOne<TRow>(db, table as EntityTableMeta, values);
29
+ }
30
+
31
+ export function seedRows<TRow = unknown>(
32
+ db: AnyDb,
33
+ table: SeedTable,
34
+ rows: ReadonlyArray<Record<string, unknown>>,
35
+ ): Promise<readonly TRow[]> {
36
+ return insertMany<TRow>(db, table as EntityTableMeta, rows);
37
+ }
38
+
39
+ export function updateRows<TRow = unknown>(
40
+ db: AnyDb,
41
+ table: SeedTable,
42
+ set: Record<string, unknown>,
43
+ where: WhereObject,
44
+ ): Promise<readonly TRow[]> {
45
+ return updateMany<TRow>(db, table as EntityTableMeta, set, where);
46
+ }
47
+
48
+ export function deleteRows(db: AnyDb, table: SeedTable, where: WhereObject): Promise<void> {
49
+ return deleteMany(db, table as EntityTableMeta, where);
50
+ }
@@ -9,6 +9,13 @@ describe("warnIfNonUtcServerTimeZone", () => {
9
9
  expect(messages).toHaveLength(0);
10
10
  });
11
11
 
12
+ test.each(["GMT", "Etc/UTC"])("warnt nicht bei UTC-äquivalenter Zone %s", (zone) => {
13
+ const messages: string[] = [];
14
+ const warned = warnIfNonUtcServerTimeZone(zone, (m) => messages.push(m));
15
+ expect(warned).toBe(false);
16
+ expect(messages).toHaveLength(0);
17
+ });
18
+
12
19
  test("warnt bei nicht-UTC Zone, nennt Zone + UTC-Hinweis", () => {
13
20
  const messages: string[] = [];
14
21
  const warned = warnIfNonUtcServerTimeZone("Europe/Berlin", (m) => messages.push(m));
@@ -55,11 +55,17 @@ describe("ctx.tz — GeoTzProvider seam", () => {
55
55
  test("fromAddress wirft wenn der Provider kein fromAddress hat (offline lat/lng)", async () => {
56
56
  const provider: GeoTzProvider = { fromCoordinates: () => "UTC" };
57
57
  const tz = createTzContext({ geoTz: provider });
58
- await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/fromAddress/);
58
+ // Regression (680/2): this message must say the PROVIDER lacks
59
+ // fromAddress, not the misleading "no provider configured" message.
60
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
61
+ /GeoTzProvider that implements fromAddress/,
62
+ );
59
63
  });
60
64
 
61
- test("fromAddress wirft ohne Provider", async () => {
65
+ test("fromAddress wirft ohne Provider — eigene Message, nicht die Provider-ohne-fromAddress-Message", async () => {
62
66
  const tz = createTzContext();
63
- await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/GeoTzProvider/);
67
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(
68
+ /requires a GeoTzProvider — inject one/,
69
+ );
64
70
  });
65
71
  });
@@ -25,6 +25,15 @@ describe("isValidIanaTimeZone", () => {
25
25
  expect(isValidIanaTimeZone(value)).toBe(false);
26
26
  });
27
27
 
28
+ // Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
29
+ // IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
30
+ // sie klaglos akzeptieren. Ein valider Alias darf hier nicht als "invalid"
31
+ // rejected werden (stiller Breaking-Change für Consumer, die Alias-Zonen
32
+ // speichern).
33
+ test.each(["US/Pacific", "GMT", "Etc/UTC"])("akzeptiert gültigen IANA-Alias %s", (zone) => {
34
+ expect(isValidIanaTimeZone(zone)).toBe(true);
35
+ });
36
+
28
37
  test("liefert über mehrere Aufrufe konsistent (lazy Set gecacht)", () => {
29
38
  expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
30
39
  expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
@@ -17,7 +17,11 @@ export function warnIfNonUtcServerTimeZone(
17
17
  // biome-ignore lint/suspicious/noConsole: boot-time warning, no logger wired this early
18
18
  warn: (message: string) => void = console.warn,
19
19
  ): boolean {
20
- if (resolvedTimeZone === "UTC") return false;
20
+ // GMT and Etc/UTC are UTC-equivalent (no offset, no DST) TZ=GMT is a
21
+ // legitimate way to pin a process to UTC and must not trip this warning.
22
+ if (resolvedTimeZone === "UTC" || resolvedTimeZone === "GMT" || resolvedTimeZone === "Etc/UTC") {
23
+ return false;
24
+ }
21
25
  warn(
22
26
  `[kumiko] Server time zone is "${resolvedTimeZone}" — the framework assumes UTC. ` +
23
27
  "Set TZ=UTC for the server process to avoid time-zone-dependent bugs.",
package/src/time/iana.ts CHANGED
@@ -1,17 +1,19 @@
1
- // IANA-Zonenname-Validierung gegen die Runtime-eigene Zonenliste
2
- // (Intl.supportedValuesOf). Das Set wird lazy einmal gebaut der Aufbau ist
3
- // der teure Teil (~400 kanonische Zonen), das Lookup danach O(1).
4
-
5
- let supportedZones: ReadonlySet<string> | undefined;
6
-
7
- function ianaZoneSet(): ReadonlySet<string> {
8
- if (supportedZones === undefined) {
9
- supportedZones = new Set(Intl.supportedValuesOf("timeZone"));
10
- }
11
- return supportedZones;
12
- }
13
-
14
- /** True wenn `value` ein gültiger kanonischer IANA-Zonenname ist (z.B. "Europe/Berlin", "UTC"). */
1
+ // IANA-Zonenname-Validierung. Intl.supportedValuesOf("timeZone") liefert nur
2
+ // KANONISCHE Namen gültige Aliase wie "US/Pacific", "GMT", "Etc/UTC" fehlen
3
+ // darin, obwohl Intl.DateTimeFormat, Temporal und ctx.tz.parse sie alle
4
+ // klaglos akzeptieren. Intl.DateTimeFormat selbst ist aber case-INSENSITIVE
5
+ // ("europe/berlin" resolved klaglos zu "Europe/Berlin") — ein reines
6
+ // try/catch würde also Tippfehler in der Groß-/Kleinschreibung durchlassen.
7
+ // Der exakte Vergleich mit resolvedOptions().timeZone schließt das: ein
8
+ // gültiger kanonischer Name ODER Alias resolved IMMER zu sich selbst
9
+ // (empirisch verifiziert für US/Pacific, GMT, Etc/UTC), ein falsch-gecasteter
10
+ // String resolved zur kanonischen Form und matcht damit nicht mehr exakt.
15
11
  export function isValidIanaTimeZone(value: string): boolean {
16
- return ianaZoneSet().has(value);
12
+ try {
13
+ return (
14
+ new Intl.DateTimeFormat(undefined, { timeZone: value }).resolvedOptions().timeZone === value
15
+ );
16
+ } catch {
17
+ return false;
18
+ }
17
19
  }
@@ -115,7 +115,12 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
115
115
  return geoTz.fromCoordinates(coords);
116
116
  },
117
117
  fromAddress: async (address) => {
118
- if (geoTz?.fromAddress === undefined) {
118
+ if (geoTz === undefined) {
119
+ throw new Error(
120
+ "ctx.tz.fromAddress requires a GeoTzProvider — inject one via the app context (e.g. buildServer({ context: { geoTzProvider } }) or runProdApp({ extraContext: { geoTzProvider } })) or install a provider package.",
121
+ );
122
+ }
123
+ if (geoTz.fromAddress === undefined) {
119
124
  throw new Error(
120
125
  "ctx.tz.fromAddress requires a GeoTzProvider that implements fromAddress (geocoding). Offline lat/lng providers only support fromCoordinates.",
121
126
  );
@@ -21,4 +21,10 @@ describe("parseRoles", () => {
21
21
  expect(parseRoles(42)).toEqual([]);
22
22
  expect(parseRoles({ roles: ["Admin"] })).toEqual([]);
23
23
  });
24
+
25
+ test("filters out non-string entries instead of returning a type-lying array (517/1)", () => {
26
+ expect(parseRoles([42, "Admin", true, null])).toEqual(["Admin"]);
27
+ expect(parseRoles("[42, true]")).toEqual([]);
28
+ expect(parseRoles('[42, "Admin"]')).toEqual(["Admin"]);
29
+ });
24
30
  });
@@ -1,7 +1,14 @@
1
1
  import { parseJsonSafe } from "./safe-json";
2
2
 
3
+ function toStringArray(value: unknown): string[] {
4
+ return Array.isArray(value) ? value.filter((e): e is string => typeof e === "string") : [];
5
+ }
6
+
3
7
  export function parseRoles(raw: unknown): string[] {
4
- if (typeof raw === "string") return parseJsonSafe<string[]>(raw, []);
5
- if (Array.isArray(raw)) return raw;
6
- return [];
8
+ // parseJsonSafe<string[]> is a cast, not a runtime check — `'[42, true]'`
9
+ // would otherwise come back as `[42, true]` typed as string[]. Filter to
10
+ // actual strings on both the parsed-JSON path and the already-an-array
11
+ // path, so a non-string entry never survives as a "role".
12
+ if (typeof raw === "string") return toStringArray(parseJsonSafe<unknown>(raw, []));
13
+ return toStringArray(raw);
7
14
  }