@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.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- 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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
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 =
|
|
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({
|
package/src/secrets/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/testing/index.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
+
try {
|
|
13
|
+
return (
|
|
14
|
+
new Intl.DateTimeFormat(undefined, { timeZone: value }).resolvedOptions().timeZone === value
|
|
15
|
+
);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
17
19
|
}
|
package/src/time/tz-context.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
}
|