@cosmicdrift/kumiko-framework 0.291.0 → 0.293.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.291.0",
3
+ "version": "0.293.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.291.0",
202
- "@cosmicdrift/kumiko-types": "0.291.0",
201
+ "@cosmicdrift/kumiko-http": "0.293.0",
202
+ "@cosmicdrift/kumiko-types": "0.293.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.291.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.293.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.293.0",
4
+ "type": "improvement",
5
+ "title": "Resolve the blind-index key from a Key Manager ciphertext",
6
+ "detail": "resolvePlatformKeks walks an allowlist of slots (PLATFORM_KEK, PLATFORM_KEK_PREVIOUS, KUMIKO_BLIND_INDEX_KEY) instead of two hardcoded KEK slots, so each slot's <SLOT>_CIPHERTEXT is unwrapped at boot. The blind-index key can leave the pod env in the clear with no app-code change, because resolveKmsWiringAsync already resolves before its trio check. Ciphertexts outside the allowlist are ignored rather than resolved: a foreign *_CIPHERTEXT must never fail boot."
7
+ },
8
+ {
9
+ "version": "0.292.0",
10
+ "type": "fix",
11
+ "title": "Validate changeset folding in PR CI",
12
+ "detail": "The release-time `changes fold` now also runs as a dry run on every PR, so a changeset with an unresolvable feature fails its own PR instead of the next release."
13
+ },
14
+ {
15
+ "version": "0.292.0",
16
+ "type": "improvement",
17
+ "title": "A projectionList can declare a time-range filter",
18
+ "detail": "A list bound to a query that already accepts time bounds had no way to expose them: `ListFacetSpec` knew `select`, `boolean` and `reference`, so every list with a timestamp — which, through `createdAt`, is practically every list — could be searched but not narrowed to \"the week the incident happened\". The audit log shipped a `description` promising date filters that no control backed.\n`{ type: \"dateRange\", field, label, params: { from, to } }` closes that. The renderer maps it to two native `<input type=\"date\">` next to the facet dropdowns (no date dependency; the browser supplies the calendar, the locale and the keyboard handling) and sends the picked bounds as the two query params the facet names — explicit rather than a `from`/`to` convention, since a query is free to call them anything, and checked against the handler's Zod schema at boot. Filtering stays server-side; either bound alone is a valid open interval; changing the range resets the page like every other facet; an inverted range is clamped in the UI instead of reaching the handler's `from <= to` refine.\nA calendar date covers a whole day in the viewer's time zone: \"to the 14th\" includes everything through the last instant of the 14th, computed across DST boundaries rather than by adding 24 hours. `audit:screen:audit-log` now declares the facet on `createdAt`, so its description holds."
19
+ },
2
20
  {
3
21
  "version": "0.291.0",
4
22
  "type": "breaking",
@@ -295,4 +295,65 @@ describe("resolvePlatformKeks", () => {
295
295
 
296
296
  expect(lines).toEqual([]);
297
297
  });
298
+
299
+ test("decrypts KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT into KUMIKO_BLIND_INDEX_KEY", async () => {
300
+ const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
301
+ const env: KekSourceEnv = {
302
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
303
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
304
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
305
+ };
306
+
307
+ const result = await resolvePlatformKeks(env, { fetch });
308
+
309
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe(PLAINTEXT_A);
310
+ expect(calls.length).toBe(1);
311
+ });
312
+
313
+ test("a plaintext KUMIKO_BLIND_INDEX_KEY wins over its ciphertext, no fetch", async () => {
314
+ const { fetch, calls } = trackedFetch([]);
315
+ const env: KekSourceEnv = {
316
+ KUMIKO_BLIND_INDEX_KEY: "blind-index-plaintext",
317
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
318
+ };
319
+
320
+ const result = await resolvePlatformKeks(env, { fetch });
321
+
322
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
323
+ expect(calls.length).toBe(0);
324
+ });
325
+
326
+ test("ignores a ciphertext outside the allowlist entirely", async () => {
327
+ const { fetch, calls } = trackedFetch([]);
328
+ const env: KekSourceEnv = {
329
+ FOO_CIPHERTEXT: CIPHERTEXT_A,
330
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
331
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
332
+ };
333
+
334
+ const result = await resolvePlatformKeks(env, { fetch });
335
+
336
+ expect(calls.length).toBe(0);
337
+ expect(result["FOO"]).toBeUndefined();
338
+ expect(result).toBe(env);
339
+ });
340
+
341
+ test("decrypts PLATFORM_KEK and KUMIKO_BLIND_INDEX_KEY ciphertexts together", async () => {
342
+ const { fetch, calls } = trackedFetch([
343
+ jsonResponse(200, { plaintext: "active-plaintext" }),
344
+ jsonResponse(200, { plaintext: "blind-index-plaintext" }),
345
+ ]);
346
+ const env: KekSourceEnv = {
347
+ PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
348
+ KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_B,
349
+ PLATFORM_KEK_KMS_KEY_ID: "key-1",
350
+ PLATFORM_KEK_KMS_TOKEN: TOKEN,
351
+ };
352
+
353
+ const result = await resolvePlatformKeks(env, { fetch });
354
+
355
+ expect(result.PLATFORM_KEK).toBe("active-plaintext");
356
+ expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
357
+ expect(calls.length).toBe(2);
358
+ });
298
359
  });
@@ -1,12 +1,20 @@
1
- // Resolves PLATFORM_KEK / PLATFORM_KEK_PREVIOUS from a Key Manager ciphertext
2
- // when no plaintext is set, so the KEK need not sit in the pod env in the
3
- // clear. `PLATFORM_KEK` stays the source of truth: a plaintext value always
4
- // wins over its ciphertext sibling, with no request made at all.
1
+ // Resolves an allowlisted set of secrets (RESOLVABLE_SLOTS) from a Key
2
+ // Manager ciphertext when no plaintext is set, so they need not sit in the
3
+ // pod env in the clear. A slot's plaintext always wins over its ciphertext
4
+ // sibling, with no request made at all. Any other `*_CIPHERTEXT` in the env
5
+ // is outside the allowlist and is ignored — a foreign ciphertext must never
6
+ // fail boot.
5
7
 
6
8
  const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
7
9
  const DEFAULT_REGION = "fr-par";
8
10
  const DECRYPT_TIMEOUT_MS = 5_000;
9
11
  const RETRY_DELAYS_MS = [200, 800];
12
+ const RESOLVABLE_SLOTS = [
13
+ "PLATFORM_KEK",
14
+ "PLATFORM_KEK_PREVIOUS",
15
+ "KUMIKO_BLIND_INDEX_KEY",
16
+ ] as const;
17
+ type ResolvableSlot = (typeof RESOLVABLE_SLOTS)[number];
10
18
 
11
19
  export type KekSourceEnv = {
12
20
  readonly PLATFORM_KEK?: string | undefined;
@@ -17,6 +25,8 @@ export type KekSourceEnv = {
17
25
  readonly PLATFORM_KEK_KMS_KEY_ID?: string | undefined;
18
26
  readonly PLATFORM_KEK_KMS_TOKEN?: string | undefined;
19
27
  readonly PLATFORM_KEK_KMS_REGION?: string | undefined;
28
+ readonly KUMIKO_BLIND_INDEX_KEY?: string | undefined;
29
+ readonly KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT?: string | undefined;
20
30
  readonly [key: string]: string | undefined;
21
31
  };
22
32
 
@@ -97,13 +107,14 @@ async function decryptCiphertext(
97
107
  }
98
108
 
99
109
  async function resolveSlot(
100
- plaintext: string | undefined,
101
- ciphertext: string | undefined,
110
+ name: ResolvableSlot,
102
111
  env: KekSourceEnv,
103
112
  options: KekSourceOptions,
104
113
  fetchImpl: typeof globalThis.fetch,
105
114
  ): Promise<string | undefined> {
115
+ const plaintext = env[name];
106
116
  if (plaintext) return plaintext;
117
+ const ciphertext = env[`${name}_CIPHERTEXT`];
107
118
  if (!ciphertext) return undefined;
108
119
 
109
120
  const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
@@ -111,7 +122,7 @@ async function resolveSlot(
111
122
  if (!keyId || !token) {
112
123
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
113
124
  throw new Error(
114
- `${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext — a partial set means the KMS wiring is broken.`,
125
+ `${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext (slot ${name}) — a partial set means the KMS wiring is broken.`,
115
126
  );
116
127
  }
117
128
  const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
@@ -121,12 +132,9 @@ async function resolveSlot(
121
132
  // A leftover plaintext beside a ciphertext boots green while nothing was
122
133
  // migrated, which is indistinguishable from a finished cutover unless the
123
134
  // boot says which source won. Never carries a key value, only its origin.
124
- function describeKekSource(
125
- name: string,
126
- plaintext: string | undefined,
127
- ciphertext: string | undefined,
128
- env: KekSourceEnv,
129
- ): string | undefined {
135
+ function describeKekSource(name: ResolvableSlot, env: KekSourceEnv): string | undefined {
136
+ const plaintext = env[name];
137
+ const ciphertext = env[`${name}_CIPHERTEXT`];
130
138
  if (plaintext) {
131
139
  return ciphertext
132
140
  ? `${name} source=plaintext-env (ciphertext present and ignored)`
@@ -138,52 +146,35 @@ function describeKekSource(
138
146
  }
139
147
 
140
148
  // Each slot resolves independently so a rollback that clears one slot's
141
- // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks the
142
- // other slot's fallback path — the trio check downstream still applies.
149
+ // plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
150
+ // another slot's fallback path — the trio check downstream still applies.
143
151
  export async function resolvePlatformKeks(
144
152
  env: KekSourceEnv,
145
153
  options: KekSourceOptions = {},
146
154
  ): Promise<KekSourceEnv> {
147
155
  const fetchImpl = options.fetch ?? globalThis.fetch;
148
156
 
149
- const active = await resolveSlot(
150
- env.PLATFORM_KEK,
151
- env.PLATFORM_KEK_CIPHERTEXT,
152
- env,
153
- options,
154
- fetchImpl,
155
- );
156
- const previous = await resolveSlot(
157
- env.PLATFORM_KEK_PREVIOUS,
158
- env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
159
- env,
160
- options,
161
- fetchImpl,
162
- );
157
+ const resolved: Partial<Record<ResolvableSlot, string | undefined>> = {};
158
+ for (const name of RESOLVABLE_SLOTS) {
159
+ resolved[name] = await resolveSlot(name, env, options, fetchImpl);
160
+ }
163
161
 
164
162
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
165
163
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
166
164
  const log = options.log ?? console.info;
167
- for (const line of [
168
- describeKekSource("PLATFORM_KEK", env.PLATFORM_KEK, env.PLATFORM_KEK_CIPHERTEXT, env),
169
- describeKekSource(
170
- "PLATFORM_KEK_PREVIOUS",
171
- env.PLATFORM_KEK_PREVIOUS,
172
- env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
173
- env,
174
- ),
175
- ]) {
165
+ for (const name of RESOLVABLE_SLOTS) {
166
+ const line = describeKekSource(name, env);
176
167
  if (line) log(`${prefix}${line}`);
177
168
  }
178
169
 
179
- if (previous && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
170
+ if (resolved.PLATFORM_KEK_PREVIOUS && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
180
171
  throw new Error(
181
172
  `${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
182
173
  );
183
174
  }
184
175
 
185
- if (active === env.PLATFORM_KEK && previous === env.PLATFORM_KEK_PREVIOUS) {
186
- return env;
187
- }
188
- return { ...env, PLATFORM_KEK: active, PLATFORM_KEK_PREVIOUS: previous };
176
+ const changed = RESOLVABLE_SLOTS.some((name) => resolved[name] !== env[name]);
177
+ if (!changed) return env;
178
+
179
+ return { ...env, ...resolved };
189
180
  }
@@ -443,6 +443,99 @@ describe("validateBoot — projectionList screens", () => {
443
443
  expect(() => validateBoot([feature])).not.toThrow();
444
444
  });
445
445
 
446
+ // fw#3104: a dateRange facet sends its two bounds as the top-level params
447
+ // it names, so `filters` is the wrong thing to require — the declared
448
+ // param names are.
449
+ test("a dateRange facet on a query that can't narrow by time is rejected at boot", () => {
450
+ const feature = defineFeature("ledger", (r) => {
451
+ r.queryHandler(
452
+ "schedule:list",
453
+ z.object({ from: z.iso.datetime().optional() }),
454
+ async () => ({ rows: [], nextCursor: null }),
455
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
456
+ );
457
+ r.screen({
458
+ id: "schedule-list",
459
+ type: "projectionList",
460
+ query: "ledger:query:schedule:list",
461
+ columns: ["dueAt"],
462
+ facets: [
463
+ {
464
+ field: "dueAt",
465
+ type: "dateRange",
466
+ label: "Due",
467
+ params: { from: "from", to: "to" },
468
+ },
469
+ ],
470
+ });
471
+ r.translations({
472
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
473
+ });
474
+ });
475
+ expect(() => validateBoot([feature])).toThrow(/no "to" parameter/);
476
+ });
477
+
478
+ test('a dateRange facet does NOT require a "filters" parameter', () => {
479
+ const feature = defineFeature("ledger", (r) => {
480
+ r.queryHandler(
481
+ "schedule:list",
482
+ z.object({
483
+ since: z.iso.datetime().optional(),
484
+ until: z.iso.datetime().optional(),
485
+ }),
486
+ async () => ({ rows: [], nextCursor: null }),
487
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
488
+ );
489
+ r.screen({
490
+ id: "schedule-list",
491
+ type: "projectionList",
492
+ query: "ledger:query:schedule:list",
493
+ columns: ["dueAt"],
494
+ facets: [
495
+ {
496
+ field: "dueAt",
497
+ type: "dateRange",
498
+ label: "Due",
499
+ params: { from: "since", to: "until" },
500
+ },
501
+ ],
502
+ });
503
+ r.translations({
504
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
505
+ });
506
+ });
507
+ expect(() => validateBoot([feature])).not.toThrow();
508
+ });
509
+
510
+ test("a dateRange facet naming a reserved list-payload key is rejected", () => {
511
+ const feature = defineFeature("ledger", (r) => {
512
+ r.queryHandler(
513
+ "schedule:list",
514
+ z.object({ limit: z.number().optional(), to: z.iso.datetime().optional() }),
515
+ async () => ({ rows: [], nextCursor: null }),
516
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
517
+ );
518
+ r.screen({
519
+ id: "schedule-list",
520
+ type: "projectionList",
521
+ query: "ledger:query:schedule:list",
522
+ columns: ["dueAt"],
523
+ facets: [
524
+ {
525
+ field: "dueAt",
526
+ type: "dateRange",
527
+ label: "Due",
528
+ params: { from: "limit", to: "to" },
529
+ },
530
+ ],
531
+ });
532
+ r.translations({
533
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
534
+ });
535
+ });
536
+ expect(() => validateBoot([feature])).toThrow(/reserved list-payload key/);
537
+ });
538
+
446
539
  test("a reference facet targeting an unknown entity is rejected", () => {
447
540
  const feature = defineFeature("ledger", (r) => {
448
541
  r.queryHandler(
@@ -1,5 +1,6 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { dedupeFeatures } from "../dedupe-features";
3
+ import { FILE_STORAGE_PROVIDER_ENV } from "../extension-names";
3
4
  import { QnTypes, qualifyEntityName } from "../qualified-name";
4
5
  import type { FeatureDefinition } from "../types";
5
6
  import { validateAccessDeclarations } from "./access-declarations";
@@ -278,9 +279,9 @@ export function validateBoot(
278
279
  validateEntityFieldEncryptionAvailable();
279
280
  }
280
281
 
281
- if (hasFileFields && !process.env["FILE_STORAGE_PROVIDER"]) {
282
+ if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
282
283
  throw new Error(
283
- "FILE_STORAGE_PROVIDER environment variable is required (file/image fields in use)",
284
+ `${FILE_STORAGE_PROVIDER_ENV} environment variable is required (file/image fields in use)`,
284
285
  );
285
286
  }
286
287
 
@@ -105,13 +105,63 @@ function validateProjectionListFilterSchemaAcceptance(
105
105
  // (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
106
106
  // handler's own Zod schema, so a declared facet would 422 on every query
107
107
  // unless the author added `filters` themselves.
108
+ // A dateRange facet (fw#3104) sends its two bounds as the top-level payload
109
+ // keys it names in `params`, not as a `filters` entry — so the query has to
110
+ // accept exactly those keys. Catches the facet pointed at a field the query
111
+ // can't narrow by, which would otherwise 422 on the first date the user picks.
112
+ // Keys buildListQueryPayload owns — a facet param naming one of them would
113
+ // silently replace the list's own paging/sorting on every pick.
114
+ const RESERVED_LIST_PAYLOAD_KEYS: ReadonlySet<string> = new Set([
115
+ "limit",
116
+ "search",
117
+ "sort",
118
+ "sortDirection",
119
+ "offset",
120
+ "totalCount",
121
+ "cursor",
122
+ "filter",
123
+ "filters",
124
+ ]);
125
+
126
+ function validateProjectionListDateRangeFacets(
127
+ prefix: string,
128
+ screen: ProjectionListScreenDefinition,
129
+ schema: QueryHandlerDef["schema"] | undefined,
130
+ ): void {
131
+ for (const facet of screen.facets ?? []) {
132
+ if (facet.type !== "dateRange") continue;
133
+ if (facet.params.from === facet.params.to) {
134
+ throw new Error(
135
+ `${prefix}: dateRange facet on "${facet.field}" names the same param ` +
136
+ `"${facet.params.from}" for both bounds.`,
137
+ );
138
+ }
139
+ for (const param of [facet.params.from, facet.params.to]) {
140
+ if (RESERVED_LIST_PAYLOAD_KEYS.has(param)) {
141
+ throw new Error(
142
+ `${prefix}: dateRange facet on "${facet.field}" names "${param}" as a bound, ` +
143
+ `which is a reserved list-payload key — pick the query's own time-bound param names.`,
144
+ );
145
+ }
146
+ if (schemaAccepts(schema, param)) continue;
147
+ throw new Error(
148
+ `${prefix}: dateRange facet on "${facet.field}" sends "${param}" but query ` +
149
+ `"${screen.query}" has no "${param}" parameter in its Zod schema — add ` +
150
+ `${param}: z.iso.datetime().optional() to the handler's schema, or point ` +
151
+ `params at the keys it already accepts.`,
152
+ );
153
+ }
154
+ }
155
+ }
156
+
108
157
  function validateProjectionListFacetsSchemaAcceptance(
109
158
  prefix: string,
110
159
  screen: ProjectionListScreenDefinition,
111
160
  schema: QueryHandlerDef["schema"] | undefined,
112
161
  ): void {
113
- // skip: no facets declared — nothing to reject.
114
- if (screen.facets === undefined || screen.facets.length === 0) return;
162
+ validateProjectionListDateRangeFacets(prefix, screen, schema);
163
+ // skip: no facets that travel via `filters` — nothing to reject.
164
+ if (screen.facets === undefined || !screen.facets.some((f) => f.type !== "dateRange")) return;
115
165
  // skip: the schema already accepts filters — nothing to reject.
116
166
  if (schemaAccepts(schema, "filters")) return;
117
167
  throw new Error(
@@ -87,6 +87,16 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
87
87
  // des file-foundation-Features MUSS diese Konstante mitziehen.
88
88
  export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
89
89
 
90
+ // Two roles: boot gate (validateBoot requires its presence once file/image
91
+ // fields are in use) AND the ENV source of the config key above, bridged via
92
+ // keyDef.env. Tenant rows keep overriding the bridged value.
93
+ export const FILE_STORAGE_PROVIDER_ENV = "FILE_STORAGE_PROVIDER" as const;
94
+
95
+ // Presence placeholder runDevApp writes when an explicitly wired provider
96
+ // (options.files) already satisfies the boot gate. It names no plugin, so
97
+ // provider resolution treats it like an unset key.
98
+ export const FILE_STORAGE_PROVIDER_BOOT_SENTINEL = "configured" as const;
99
+
90
100
  /**
91
101
  * `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
92
102
  * (file-derivatives).
@@ -113,6 +113,8 @@ export {
113
113
  EXT_USER_DATA,
114
114
  EXT_USER_DATA_ORDER,
115
115
  FILE_PROVIDER_CONFIG_KEY,
116
+ FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
117
+ FILE_STORAGE_PROVIDER_ENV,
116
118
  TENANT_MEMBERSHIPS_QUERY,
117
119
  } from "./extension-names";
118
120
  export { extensionUsageEscapeHatchReason } from "./extensions/escape-hatch-usage";
@@ -1,10 +1,8 @@
1
1
  import type { SessionUser } from "./types";
2
- import type { TenantId } from "./types/identifiers";
2
+ import { SYSTEM_USER_ID, type TenantId } from "./types/identifiers";
3
+
4
+ export { SYSTEM_USER_ID };
3
5
 
4
- // Stringified so it round-trips through SessionUser.id (string UUID-shape).
5
- // Not a real UUID — SYSTEM acts as an alias for "no human caller" and event-
6
- // store createdBy is text, so the literal suffices.
7
- export const SYSTEM_USER_ID = "00000000-0000-0000-0000-000000000000";
8
6
  export const SYSTEM_ROLE = "system" as const;
9
7
 
10
8
  // extraRoles: hasAccess kennt keinen System-Bypass — Handler gaten auf
@@ -15,7 +15,11 @@
15
15
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
16
16
  import type { DbConnection } from "../db/connection";
17
17
  import type { TenantDb } from "../db/tenant-db";
18
- import { EXT_FILE_PROVIDER, FILE_PROVIDER_CONFIG_KEY } from "../engine/extension-names";
18
+ import {
19
+ EXT_FILE_PROVIDER,
20
+ FILE_PROVIDER_CONFIG_KEY,
21
+ FILE_STORAGE_PROVIDER_BOOT_SENTINEL,
22
+ } from "../engine/extension-names";
19
23
  import { SYSTEM_USER_ID } from "../engine/system-user";
20
24
  import type { ConfigAccessor, ConfigAccessorFactory, Registry } from "../engine/types";
21
25
  import type { SecretsContext } from "../secrets";
@@ -93,7 +97,10 @@ export async function createFileProviderForTenant(
93
97
 
94
98
  const raw = await ctxConfig(FILE_PROVIDER_CONFIG_KEY);
95
99
  const provider = typeof raw === "string" ? raw : raw == null ? "" : String(raw);
96
- if (provider.length === 0) {
100
+ // The boot-gate placeholder names no plugin: treated as a provider name it
101
+ // would let FILE_STORAGE_PROVIDER=configured fake a selection through the
102
+ // ENV bridge.
103
+ if (provider.length === 0 || provider === FILE_STORAGE_PROVIDER_BOOT_SENTINEL) {
97
104
  const usages = ctx.registry.getExtensionUsages(EXT_FILE_PROVIDER);
98
105
  const known = usages.map((u) => u.entityName).join(", ") || "<none>";
99
106
  throw new Error(
@@ -12,7 +12,7 @@
12
12
  // deletedById) — this is also the SAME set the boot-validator's entityList
13
13
  // column checks accept, so a softDelete column stays a boot-time error
14
14
  // instead of a renderer-side throw (see screens.ts / entity-list-screens.ts).
15
- import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
15
+ import { SYSTEM_TENANT_ID, SYSTEM_USER_ID } from "../engine/types/identifiers";
16
16
 
17
17
  export type ListRowMetaColumnType = "text" | "number" | "timestamp";
18
18
 
@@ -56,4 +56,7 @@ export type SystemReferenceLabel = {
56
56
  // entity, not just delivery-log (fw#2662).
57
57
  export const SYSTEM_REFERENCE_LABELS: Readonly<Record<string, SystemReferenceLabel>> = {
58
58
  "tenant:tenant": { id: SYSTEM_TENANT_ID, labelKey: "kumiko.reference.system-tenant" },
59
+ // createdBy on a system write (fw#3103) — SYSTEM_USER_ID is an alias for
60
+ // "no human caller", so read_users never holds a matching row.
61
+ "user:user": { id: SYSTEM_USER_ID, labelKey: "kumiko.reference.system-user" },
59
62
  };