@cosmicdrift/kumiko-framework 0.352.0 → 0.353.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 (49) hide show
  1. package/dist/api/pii-leak-guard.js +3 -0
  2. package/dist/api/route-registrars.d.ts +2 -1
  3. package/dist/api/route-registrars.js +8 -0
  4. package/dist/api/server.js +11 -3
  5. package/dist/api/sse-route.js +11 -1
  6. package/dist/bun-db/query.js +7 -2
  7. package/dist/compliance/sub-processors.js +5 -0
  8. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  9. package/dist/crypto/pii-field-encryption.js +2 -3
  10. package/dist/db/entity-table-meta.js +11 -0
  11. package/dist/db/event-store-executor-context.js +20 -25
  12. package/dist/db/migrate-generator.js +9 -1
  13. package/dist/db/queries/backfill-pii.d.ts +1 -0
  14. package/dist/db/queries/backfill-pii.js +13 -0
  15. package/dist/db/queries/shadow-swap.js +5 -2
  16. package/dist/engine/boot-validator/access-declarations.js +16 -1
  17. package/dist/engine/boot-validator/entity-handler.js +13 -0
  18. package/dist/engine/boot-validator/screens.js +45 -15
  19. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  20. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  21. package/dist/engine/build-app-schema.js +30 -2
  22. package/dist/engine/build-config-feature-schema.js +6 -0
  23. package/dist/engine/dedupe-features.js +17 -1
  24. package/dist/engine/entity-handlers.js +15 -6
  25. package/dist/engine/feature-builder-state.d.ts +2 -7
  26. package/dist/engine/feature-config-events-jobs.js +3 -0
  27. package/dist/engine/feature-entity-handlers.js +1 -1
  28. package/dist/engine/i18n-key.js +14 -3
  29. package/dist/engine/index.d.ts +1 -1
  30. package/dist/engine/index.js +1 -1
  31. package/dist/engine/pipeline.js +18 -1
  32. package/dist/engine/project-app-schema-for-roles.js +8 -2
  33. package/dist/engine/role-assignment.d.ts +2 -0
  34. package/dist/engine/role-assignment.js +2 -0
  35. package/dist/engine/schema-builder.js +8 -0
  36. package/dist/engine/where-rule-lint.js +6 -3
  37. package/dist/env/index.d.ts +4 -0
  38. package/dist/env/index.js +4 -0
  39. package/dist/es-ops/context.js +19 -1
  40. package/dist/files/file-ref-entity.d.ts +6 -0
  41. package/dist/files/file-ref-table.d.ts +6 -0
  42. package/dist/files/file-routes.js +3 -3
  43. package/dist/files/types.js +21 -1
  44. package/dist/observability/metrics-wiring.d.ts +1 -1
  45. package/dist/observability/metrics-wiring.js +2 -2
  46. package/dist/pipeline/member-reader.js +1 -0
  47. package/dist/testing/shared-entities.d.ts +10 -0
  48. package/package.json +4 -4
  49. package/src/changes.json +30 -0
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
415
415
  // need a writable local type; each entry is still a fresh, never-mutated object.
416
416
  const translations = {};
417
417
  const sections = [];
418
+ const seenFieldIds = new Map();
418
419
  for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
419
420
  const fieldIds = [];
420
421
  for (const s of secrets.filter((v) => v.feature === feature)) {
421
422
  const fieldId = `${s.feature}-${s.shortKey}`;
423
+ const prevQn = seenFieldIds.get(fieldId);
424
+ if (prevQn !== undefined) {
425
+ throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
426
+ }
427
+ seenFieldIds.set(fieldId, s.qn);
422
428
  fieldIds.push(fieldId);
423
429
  secretKeys[fieldId] = s.qn;
424
430
  const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
@@ -6,15 +6,31 @@ function dedupeOptionsShallowEqual(a, b) {
6
6
  }
7
7
  return true;
8
8
  }
9
+ function sameKeys(a, b) {
10
+ const aKeys = Object.keys(a ?? {});
11
+ const bKeys = new Set(Object.keys(b ?? {}));
12
+ return aKeys.length === bKeys.size && aKeys.every((key) => bKeys.has(key));
13
+ }
14
+ // dedupeOptions is a declaration, not proof: two same-named features that both
15
+ // carry `{}` but register different surface must still clash, not silently drop one.
16
+ function sameRegisteredSurface(a, b) {
17
+ return (sameKeys(a.entities, b.entities) &&
18
+ sameKeys(a.writeHandlers, b.writeHandlers) &&
19
+ sameKeys(a.queryHandlers, b.queryHandlers) &&
20
+ sameKeys(a.streamHandlers, b.streamHandlers));
21
+ }
9
22
  function isInterchangeable(existing, next) {
10
23
  if (existing === next)
11
24
  return true;
12
25
  if (existing.dedupeOptions !== undefined && next.dedupeOptions !== undefined) {
13
- return dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions);
26
+ return (dedupeOptionsShallowEqual(existing.dedupeOptions, next.dedupeOptions) &&
27
+ sameRegisteredSurface(existing, next));
14
28
  }
15
29
  return false;
16
30
  }
17
31
  // First occurrence wins: runtimes late-bind closure state (sessions' autoRevoke) on the kept instance.
32
+ // An app holding its own handle to a later, dropped instance would bind state the registry never
33
+ // runs — resolve the kept instance from the composed list (`features.find(name)`), not a local handle.
18
34
  export function dedupeFeatures(features) {
19
35
  const byName = new Map();
20
36
  const result = [];
@@ -198,11 +198,14 @@ export function defineEntityWriteHandler(name, entity, options) {
198
198
  // query-handler branch below: this ONE write handler reads/writes across
199
199
  // every tenant without making the whole feature r.systemScope().
200
200
  const crossTenantReason = resolveCrossTenantReason(name, options);
201
+ // create always writes into the acting user's own tenant, so its FK,
202
+ // unique and preSave lookups must stay tenant-filtered even when the
203
+ // handler declares cross-tenant access for the row-addressing verbs.
201
204
  const dbFor = (ctx) => {
202
205
  if (ctx.systemDb) {
203
206
  return ctx.systemDb.acknowledgeCrossTenant(`entity convention handler for r.systemScope() feature (${name})`);
204
207
  }
205
- return crossTenantReason !== undefined
208
+ return crossTenantReason !== undefined && verb !== "create"
206
209
  ? acknowledgeConventionCrossTenant(ctx.db, crossTenantReason)
207
210
  : ctx.db;
208
211
  };
@@ -539,18 +542,24 @@ export function registerEntityCrud(r, entityName, entity, options) {
539
542
  };
540
543
  const resolveWriteOpts = (verb) => {
541
544
  const excludeFields = verb === "create" || verb === "update" ? options?.excludeFields?.[verb] : undefined;
545
+ const verbAgent = options?.agents?.[verb];
542
546
  return {
543
547
  ...writeOpts,
544
548
  access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
545
549
  description: options?.descriptions?.[verb] ?? writeOpts?.description,
550
+ ...(verbAgent !== undefined && { agent: verbAgent }),
546
551
  ...(excludeFields !== undefined && { excludeFields }),
547
552
  };
548
553
  };
549
- const resolveReadOpts = (verb) => ({
550
- ...readOpts,
551
- access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
552
- description: options?.descriptions?.[verb] ?? readOpts?.description,
553
- });
554
+ const resolveReadOpts = (verb) => {
555
+ const verbAgent = options?.agents?.[verb];
556
+ return {
557
+ ...readOpts,
558
+ access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
559
+ description: options?.descriptions?.[verb] ?? readOpts?.description,
560
+ ...(verbAgent !== undefined && { agent: verbAgent }),
561
+ };
562
+ };
554
563
  if (verbs.create) {
555
564
  r.writeHandler(defineEntityCreateHandler(entityName, entity, resolveWriteOpts("create")));
556
565
  }
@@ -1,8 +1,7 @@
1
1
  import type { WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websocket-route";
2
2
  import type * as z from "zod";
3
- import type { ZodType } from "zod";
4
3
  import type { HttpRouteDefinition } from "./types/http-route.js";
5
- import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
4
+ import type { AuthClaimsFn, BootCheckFn, ClaimKeyDefinition, ConfigKeyDefinition, ConfigSeedDef, EntityDefinition, EntityProjectionExtension, EventDef, EventMigrationDef, ExtensionSelectorDef, FeatureMetricDef, JobDefinition, LifecycleHookFn, MultiStreamProjectionDefinition, NotificationDefinition, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, ProjectionDefinition, QueryHandlerDef, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionRegistration, RelationDefinition, SearchPayloadContributorFn, SecretKeyDefinition, SecretNamespaceDefinition, StoreTableEntry, StreamHandlerDef, TranslationKeys, TreeActionDef, UiHints, ValidationHookFn, WriteHandlerDef } from "./types/index.js";
6
5
  import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
7
6
  import type { ScreenDefinition } from "./types/screen.js";
8
7
  import type { WorkspaceDefinition } from "./types/workspace.js";
@@ -23,11 +22,7 @@ export type FeatureBuilderState = {
23
22
  configKeys: Record<string, ConfigKeyDefinition>;
24
23
  configSeeds: ConfigSeedDef[];
25
24
  jobs: Record<string, JobDefinition>;
26
- events: Record<string, {
27
- name: string;
28
- schema: ZodType;
29
- version: number;
30
- }>;
25
+ events: Record<string, EventDef>;
31
26
  eventMigrations: Record<string, EventMigrationDef[]>;
32
27
  configReads: string[];
33
28
  entityPostSave: Record<string, PhasedHook<PostSaveHookFn>[]>;
@@ -73,6 +73,9 @@ export function buildConfigEventsJobsMethods(state, name) {
73
73
  if (field === normalized.ownerField) {
74
74
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`);
75
75
  }
76
+ if (normalized.ownerField === "id") {
77
+ throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" uses owner field "id" — on entities \`of: "id"\` means record-owned, but on events it would encrypt under a phantom user key that a forget never reaches. Use \`personal: "self"\` for a record-owned event field.`);
78
+ }
76
79
  for (const required of [field, normalized.ownerField]) {
77
80
  if (shape && !(required in shape)) {
78
81
  throw new Error(`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`);
@@ -55,7 +55,7 @@ function agentSlots(source) {
55
55
  };
56
56
  }
57
57
  function requireInlineHandlerArgs(kind, schema, handler, options) {
58
- if (!schema || !handler || !options) {
58
+ if (!schema || !handler || !options?.access) {
59
59
  throw new Error(`${kind} inline form requires schema + handler + options.access`);
60
60
  }
61
61
  return { schema, handler, options };
@@ -1,10 +1,21 @@
1
1
  // Dot-form labels are indistinguishable from literal display text ("actions.open"),
2
2
  // so authors mark the i18n ones explicitly; the registry is what boot validation reads.
3
- const explicitDotFormKeys = new Set();
3
+ // Marking is by string value and therefore global across features. The registry sits on
4
+ // globalThis so a second installed copy of the framework still reads the marks the
5
+ // other copy wrote; otherwise they are silently ignored and #2313 reopens.
6
+ const REGISTRY_KEY = Symbol.for("kumiko.i18n.explicitDotFormKeys");
7
+ function explicitDotFormKeys() {
8
+ const existing = Reflect.get(globalThis, REGISTRY_KEY);
9
+ if (existing instanceof Set)
10
+ return existing;
11
+ const created = new Set();
12
+ Reflect.set(globalThis, REGISTRY_KEY, created);
13
+ return created;
14
+ }
4
15
  export function i18nKey(value) {
5
- explicitDotFormKeys.add(value);
16
+ explicitDotFormKeys().add(value);
6
17
  return value;
7
18
  }
8
19
  export function isExplicitDotFormKey(value) {
9
- return explicitDotFormKeys.has(value);
20
+ return explicitDotFormKeys().has(value);
10
21
  }
@@ -59,7 +59,7 @@ export { createRegistry } from "./registry.js";
59
59
  export type { ClampInfo, ResolveOptions } from "./resolve-config-or-param.js";
60
60
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
61
61
  export type { AssignableAppRoles, AssignableFromRole } from "./role-assignment.js";
62
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
62
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
63
63
  export { runsInLane } from "./run-in.js";
64
64
  export type { StepListOutcome } from "./run-pipeline.js";
65
65
  export { runPipeline, runStepList } from "./run-pipeline.js";
@@ -55,7 +55,7 @@ export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
55
55
  export { readClaim } from "./read-claim.js";
56
56
  export { createRegistry } from "./registry.js";
57
57
  export { resolveConfigOrParam } from "./resolve-config-or-param.js";
58
- export { assignableAppRolesFromUsages, assignableAppRolesOf, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
58
+ export { assignableAppRolesFromUsages, assignableAppRolesOf, BUILT_IN_MEMBERSHIP_ROLES, canActorAssignRole, findForbiddenRoleAssignment, isAssignableByRole, mergeAssignedRoles, } from "./role-assignment.js";
59
59
  export { runsInLane } from "./run-in.js";
60
60
  export { runPipeline, runStepList } from "./run-pipeline.js";
61
61
  export { buildInsertSchema, buildUpdateSchema, fieldToZod, SYSTEM_ONLY_JSON_SCHEMA_KEY, } from "./schema-builder.js";
@@ -79,9 +79,26 @@ export function stepsPipeline(closure) {
79
79
  // carried in (D4, workflow-resume-loop.md), never an unchecked external
80
80
  // value.
81
81
  export function buildPipelineSteps(pipelineDef, event) {
82
- return pipelineDef.build({
82
+ const steps = pipelineDef.build({
83
83
  event,
84
84
  r: stepBuilder,
85
85
  awaits: (pipelineDef.awaits ?? {}),
86
86
  });
87
+ assertUniqueWaitForEventTypes(steps);
88
+ return steps;
89
+ }
90
+ // waitForEvent's result key is the awaited event type, so a second wait on the
91
+ // same type would overwrite the first one's resumed payload.
92
+ function assertUniqueWaitForEventTypes(steps) {
93
+ const seen = new Set();
94
+ for (const step of steps) {
95
+ if (step.kind !== "workflow.waitForEvent")
96
+ continue;
97
+ const { event } = step.args; // @cast-boundary engine-payload
98
+ if (seen.has(event)) {
99
+ throw new Error(`r.step.waitForEvent: event "${String(event)}" is awaited more than once in one workflow — ` +
100
+ "each awaits entry can back only one waitForEvent step.");
101
+ }
102
+ seen.add(event);
103
+ }
87
104
  }
@@ -530,20 +530,26 @@ function projectActionFormScreen(screen, feature, indices, keptScreenQns) {
530
530
  };
531
531
  }
532
532
  function projectSecretMintScreen(screen, feature, indices, keptScreenQns) {
533
- const { redirect, cancelTarget, layout, listScreenId, ...rest } = screen;
533
+ const { redirect, cancelTarget, layout, listScreenId, confirm, ...rest } = screen;
534
534
  const nextRedirect = projectRedirect(feature.featureName, redirect, keptScreenQns);
535
535
  const nextCancelTarget = projectCancelTarget(feature.featureName, cancelTarget, keptScreenQns);
536
536
  const nextLayout = projectEditLayout(layout, feature, indices, keptScreenQns);
537
537
  const nextListScreenId = projectListScreenId(listScreenId, indices, keptScreenQns);
538
+ const nextConfirmLayout = confirm === undefined
539
+ ? undefined
540
+ : projectEditLayout(confirm.layout, feature, indices, keptScreenQns);
538
541
  const unchanged = nextRedirect === redirect &&
539
542
  nextCancelTarget === cancelTarget &&
540
543
  nextLayout === layout &&
541
- nextListScreenId === listScreenId;
544
+ nextListScreenId === listScreenId &&
545
+ nextConfirmLayout === confirm?.layout;
542
546
  if (unchanged)
543
547
  return screen;
544
548
  return {
545
549
  ...rest,
546
550
  layout: nextLayout,
551
+ ...(confirm !== undefined &&
552
+ nextConfirmLayout !== undefined && { confirm: { ...confirm, layout: nextConfirmLayout } }),
547
553
  ...(nextRedirect !== undefined && { redirect: nextRedirect }),
548
554
  ...(nextCancelTarget !== undefined && { cancelTarget: nextCancelTarget }),
549
555
  ...(nextListScreenId !== undefined && { listScreenId: nextListScreenId }),
@@ -1,4 +1,6 @@
1
1
  import type { Registry } from "./types/feature.js";
2
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
3
+ export declare const BUILT_IN_MEMBERSHIP_ROLES: readonly string[];
2
4
  declare const ASSIGNABLE_FROM_ROLE_LIST: readonly ["User", "Member", "Editor", "Admin", "TenantAdmin", "SystemAdmin"];
3
5
  export type AssignableFromRole = (typeof ASSIGNABLE_FROM_ROLE_LIST)[number];
4
6
  /** App role → lowest built-in role allowed to assign it. */
@@ -16,6 +16,8 @@ const ROLE_RANKS = new Map([
16
16
  ["SystemAdmin", 4],
17
17
  ["system", 5],
18
18
  ]);
19
+ /** Every membership role the engine ranks, excluding the internal `system` actor. */
20
+ export const BUILT_IN_MEMBERSHIP_ROLES = [...ROLE_RANKS.keys()].filter((role) => role !== "system");
19
21
  const ASSIGNABLE_FROM_ROLE_LIST = [
20
22
  "User",
21
23
  "Member",
@@ -87,6 +87,14 @@ export function fieldToZod(field, currencies, opts = {}) {
87
87
  let schema = z.string();
88
88
  if (field.maxLength)
89
89
  schema = schema.max(field.maxLength);
90
+ if (field.minLength) {
91
+ // "" must stay valid for optional fields and writeOnly updates (""
92
+ // = unchanged); `required` rejects it separately via min(1) below.
93
+ const minLength = field.minLength;
94
+ schema = schema.refine((value) => value.length === 0 || value.length >= minLength, {
95
+ message: `at least ${minLength} characters`,
96
+ });
97
+ }
90
98
  if (field.writeOnly === true) {
91
99
  // Update: "" = unchanged (dropped before the executor), null = clear,
92
100
  // unless required. Create: "" would leave a required field unset.
@@ -17,9 +17,9 @@ const SELF_COMPARISON_RE = new RegExp(`(${QUALIFIED_SRC})\\s*(?<![!<>=])=(?!=)\\
17
17
  const QUALIFIED_PARSE_RE = new RegExp(`^(${IDENT_SRC})(?:\\.(${IDENT_SRC}))?$`);
18
18
  // Resolve the SQL column names a table exposes, so the lint can tell a
19
19
  // real column reference apart from a table alias / function name / SQL
20
- // keyword. Returns an empty set when nothing is derivable; the column-aware
21
- // check then finds no bare column and is effectively skipped (only the
22
- // self-comparison check still applies).
20
+ // keyword. Returns an empty set when nothing is derivable; callers that need
21
+ // the column-aware check must treat that as "unknown" (see
22
+ // assertQualifiedWhereFragment, which fails closed on it).
23
23
  export function tableColumnSqlNames(table) {
24
24
  if (table === null || typeof table !== "object")
25
25
  return new Set();
@@ -182,6 +182,9 @@ export function assertQualifiedWhereFragment(sqlText, columnSqlNames, scope) {
182
182
  // the fail-open shape this lint targets is impossible here.
183
183
  if (!/\bSELECT\b/i.test(cleaned))
184
184
  return;
185
+ if (columnSqlNames.size === 0) {
186
+ throw new Error(`[Kumiko Ownership] ${scope}: cannot verify where-rule SQL containing a subquery — the table's column set is unknown, so unqualified column references cannot be detected.`);
187
+ }
185
188
  const unqualified = findUnqualifiedColumnReference(cleaned, columnSqlNames);
186
189
  if (unqualified !== null) {
187
190
  throw new Error(`[Kumiko Ownership] ${scope}: where-rule SQL references column "${unqualified}" unqualified inside a subquery. Postgres binds an unqualified name to the innermost table first, which silently turns the ownership predicate into a tautology (fail-open). Qualify it with the outer table, e.g. \`\${ctx.tableName}.${unqualified}\`.`);
@@ -88,6 +88,10 @@ export type ParseEnvOptions = {
88
88
  /** When set, error suggestions include `pulumi config set <prefix>...`. */
89
89
  readonly pulumiPrefix?: string;
90
90
  };
91
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
92
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
93
+ * is `undefined` in the result despite its `string` type — it is only set once
94
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
91
95
  export declare function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(schema: S, env: Record<string, string | undefined>, options?: ParseEnvOptions): z.infer<S>;
92
96
  /** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
93
97
  * Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
package/dist/env/index.js CHANGED
@@ -259,6 +259,10 @@ function safeParseReportingThrows(schema, input) {
259
259
  ]);
260
260
  }
261
261
  }
262
+ /** Validates `env` against `schema` and throws KumikoBootError on any issue.
263
+ * A `kms: true` field delivered only as `<NAME>_CIPHERTEXT` passes validation but
264
+ * is `undefined` in the result despite its `string` type — it is only set once
265
+ * the boot-time decrypt (resolvePlatformKeks) has run. */
262
266
  export function parseEnv(schema, env, options = {}) {
263
267
  // Filter undefined values to keep Zod's required-vs-invalid signal clean.
264
268
  // (process.env returns string|undefined; passing undefined would parse
@@ -6,6 +6,7 @@
6
6
  // SystemUser bypassed Access-Checks (Standard-Seed-Pattern, siehe
7
7
  // config-seed.ts:40). Events haben createdBy = SYSTEM_TENANT_ID-User
8
8
  // → audit-fähig.
9
+ import { configuredPiiSubjectKms, decryptPiiFieldValues } from "../crypto/index.js";
9
10
  import { selectAllTenants, selectMembershipsOfUser, selectTemplateResources, selectUserByEmail, } from "../db/queries/seed-context.js";
10
11
  import { createSystemUser, SYSTEM_TENANT_ID } from "../engine/index.js";
11
12
  import { parseStringArrayJson } from "../utils/parse-string-array-json.js";
@@ -63,7 +64,24 @@ export function createSeedMigrationContext(args) {
63
64
  },
64
65
  findTenants: async () => {
65
66
  const rows = await selectAllTenants(args.dbRunner);
66
- return rows.map((r) => ({ id: r.id, name: r.name, tenantKey: r.tenant_key }));
67
+ const kms = configuredPiiSubjectKms();
68
+ // Sequential on purpose: each decrypt borrows from the KMS adapter's small pool.
69
+ const tenants = [];
70
+ for (const r of rows) {
71
+ // tenant.name is ciphertext at rest under an active KMS
72
+ const decrypted = kms
73
+ ? await decryptPiiFieldValues({ name: r.name }, ["name"], kms, {
74
+ requestId: "es-ops:find-tenants",
75
+ })
76
+ : { name: r.name };
77
+ const name = decrypted["name"];
78
+ tenants.push({
79
+ id: r.id,
80
+ name: typeof name === "string" ? name : r.name,
81
+ tenantKey: r.tenant_key,
82
+ });
83
+ }
84
+ return tenants;
67
85
  },
68
86
  findTemplateResources: async (filter) => {
69
87
  const rows = await selectTemplateResources(args.dbRunner, {
@@ -3,6 +3,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -3,6 +3,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -24,6 +25,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
24
25
  readonly type: "text";
25
26
  readonly description?: string;
26
27
  readonly maxLength?: number;
28
+ readonly minLength?: number;
27
29
  readonly required?: boolean;
28
30
  readonly searchable?: boolean;
29
31
  readonly sortable?: boolean;
@@ -45,6 +47,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
45
47
  readonly type: "text";
46
48
  readonly description?: string;
47
49
  readonly maxLength?: number;
50
+ readonly minLength?: number;
48
51
  readonly required?: boolean;
49
52
  readonly searchable?: boolean;
50
53
  readonly sortable?: boolean;
@@ -78,6 +81,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
78
81
  readonly type: "text";
79
82
  readonly description?: string;
80
83
  readonly maxLength?: number;
84
+ readonly minLength?: number;
81
85
  readonly required?: boolean;
82
86
  readonly searchable?: boolean;
83
87
  readonly sortable?: boolean;
@@ -99,6 +103,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
99
103
  readonly type: "text";
100
104
  readonly description?: string;
101
105
  readonly maxLength?: number;
106
+ readonly minLength?: number;
102
107
  readonly required?: boolean;
103
108
  readonly searchable?: boolean;
104
109
  readonly sortable?: boolean;
@@ -120,6 +125,7 @@ export declare const fileRefsTable: import("../db/table-builder.js").EntityTable
120
125
  readonly type: "text";
121
126
  readonly description?: string;
122
127
  readonly maxLength?: number;
128
+ readonly minLength?: number;
123
129
  readonly required?: boolean;
124
130
  readonly searchable?: boolean;
125
131
  readonly sortable?: boolean;
@@ -47,7 +47,7 @@ function resolveAttachedField(registry, entityType, fieldName) {
47
47
  return { kind: "unattached" };
48
48
  const entity = entityType !== undefined ? registry?.getEntity(entityType) : undefined;
49
49
  const fieldDef = entity && fieldName !== undefined ? entity.fields[fieldName] : undefined;
50
- if (!entityType || !fieldName || !entity || !fieldDef)
50
+ if (!entityType || !fieldName || !entity || !fieldDef || !isFileField(fieldDef))
51
51
  return { kind: "unresolvable" };
52
52
  return { kind: "resolved", fieldDef };
53
53
  }
@@ -168,10 +168,10 @@ export function createFileRoutes(options) {
168
168
  const attachedField = resolveAttachedField(options.registry, entityType, fieldName);
169
169
  if (attachedField.kind === "unresolvable") {
170
170
  return c.json({
171
- error: "unresolvable_field: entityType/fieldName must resolve to a registered entity field, or both must be omitted for an unattached upload",
171
+ error: "unresolvable_field: entityType/fieldName must resolve to a registered file field, or both must be omitted for an unattached upload",
172
172
  }, 400);
173
173
  }
174
- if (attachedField.kind === "resolved" && isFileField(attachedField.fieldDef)) {
174
+ if (attachedField.kind === "resolved") {
175
175
  if (attachedField.fieldDef.maxSize)
176
176
  maxSize = attachedField.fieldDef.maxSize;
177
177
  if (attachedField.fieldDef.accept)
@@ -75,6 +75,7 @@ function extensionsForMimeType(mimeType) {
75
75
  // application/octet-stream in resolveServedContentType below — this also
76
76
  // guarantees text/html and image/svg+xml are never served inline, even for
77
77
  // an honestly-declared upload.
78
+ const DOCX_MIME_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
78
79
  const MAGIC_BYTE_SIGNATURES = [
79
80
  {
80
81
  mimeType: "image/png",
@@ -99,10 +100,22 @@ const MAGIC_BYTE_SIGNATURES = [
99
100
  // ZIP local-file-header signature — .docx/.xlsx/.pptx/plain .zip all share
100
101
  // it; same caveat as OLE above.
101
102
  {
102
- mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
103
+ mimeType: DOCX_MIME_TYPE,
103
104
  matches: (bytes) => startsWithBytes(bytes, [0x50, 0x4b, 0x03, 0x04]),
104
105
  },
105
106
  ];
107
+ // ZIP entry names are stored uncompressed in the local and central headers,
108
+ // so a byte search finds the docx part directory without unzipping. The main
109
+ // part is resolved via _rels/.rels and may be named e.g. word/document2.xml.
110
+ const DOCX_PART_PREFIX = "word/";
111
+ function containsAscii(bytes, ascii) {
112
+ const first = ascii.charCodeAt(0);
113
+ for (let i = bytes.indexOf(first); i !== -1; i = bytes.indexOf(first, i + 1)) {
114
+ if (startsWithAscii(bytes, ascii, i))
115
+ return true;
116
+ }
117
+ return false;
118
+ }
106
119
  function startsWithBytes(bytes, signature) {
107
120
  if (bytes.length < signature.length)
108
121
  return false;
@@ -220,6 +233,13 @@ export function validateFileContent(fileName, content, accept) {
220
233
  if (signatureMimeTypes.length === 0)
221
234
  return { kind: "ok" };
222
235
  const sniffed = sniffMimeType(content);
236
+ // sniffMimeType only sees the ZIP container; any .xlsx/.pptx/.zip shares it.
237
+ if (sniffed === DOCX_MIME_TYPE && !containsAscii(content, DOCX_PART_PREFIX)) {
238
+ return {
239
+ kind: "rejected",
240
+ error: `content_mismatch: ".${ext}" upload bytes are a ZIP archive without ${DOCX_PART_PREFIX} entries, not a docx`,
241
+ };
242
+ }
223
243
  if (sniffed && signatureMimeTypes.includes(sniffed))
224
244
  return { kind: "ok" };
225
245
  if (sniffed) {
@@ -2,7 +2,7 @@ import * as z from "zod";
2
2
  import { type PrometheusMeter } from "./prometheus-meter.js";
3
3
  import type { ObservabilityProvider } from "./types/index.js";
4
4
  export declare const prometheusMetricsEnvSchema: z.ZodObject<{
5
- PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodString>;
5
+ PROMETHEUS_METRICS_TOKEN: z.ZodOptional<z.ZodUnion<readonly [z.ZodPipe<z.ZodLiteral<"">, z.ZodTransform<undefined, "">>, z.ZodString]>>;
6
6
  }, z.core.$strip>;
7
7
  type PrometheusObservabilityProvider = ObservabilityProvider & {
8
8
  readonly meter: PrometheusMeter;
@@ -2,9 +2,9 @@ import * as z from "zod";
2
2
  import { createNoopProvider } from "./noop-provider.js";
3
3
  import { createPrometheusMeter } from "./prometheus-meter.js";
4
4
  export const prometheusMetricsEnvSchema = z.object({
5
+ // An empty value (blank .env line, empty k8s secret) means "unset", not "invalid".
5
6
  PROMETHEUS_METRICS_TOKEN: z
6
- .string()
7
- .min(32)
7
+ .union([z.literal("").transform(() => undefined), z.string().min(32)])
8
8
  .optional()
9
9
  .describe("Bearer token for /metrics; unset keeps the endpoint off.")
10
10
  .meta({ kumiko: { pulumi: { secret: true, generator: "openssl rand -base64 32" } } }),
@@ -67,6 +67,7 @@ export function createMemberReaderFn(ctx, tenantId, tx) {
67
67
  }
68
68
  // Only successful resolutions are cached — a rejected/failed resolution
69
69
  // deletes its own entry so a transient error can't poison the rest of the run.
70
+ // No TTL: a membership revoked mid-run is only noticed by the next reader.
70
71
  const cache = new Map();
71
72
  function resolve(userId) {
72
73
  const cached = cache.get(userId);
@@ -3,6 +3,7 @@ export declare const sharedWidgetEntity: import("@cosmicdrift/kumiko-types/field
3
3
  readonly type: "text";
4
4
  readonly description?: string;
5
5
  readonly maxLength?: number;
6
+ readonly minLength?: number;
6
7
  readonly required?: boolean;
7
8
  readonly searchable?: boolean;
8
9
  readonly sortable?: boolean;
@@ -26,6 +27,7 @@ export declare const sharedWidgetTable: import("../db/table-builder.js").EntityT
26
27
  readonly type: "text";
27
28
  readonly description?: string;
28
29
  readonly maxLength?: number;
30
+ readonly minLength?: number;
29
31
  readonly required?: boolean;
30
32
  readonly searchable?: boolean;
31
33
  readonly sortable?: boolean;
@@ -49,6 +51,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
49
51
  readonly type: "text";
50
52
  readonly description?: string;
51
53
  readonly maxLength?: number;
54
+ readonly minLength?: number;
52
55
  readonly required?: boolean;
53
56
  readonly searchable?: boolean;
54
57
  readonly sortable?: boolean;
@@ -70,6 +73,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
70
73
  readonly type: "text";
71
74
  readonly description?: string;
72
75
  readonly maxLength?: number;
76
+ readonly minLength?: number;
73
77
  readonly required?: boolean;
74
78
  readonly searchable?: boolean;
75
79
  readonly sortable?: boolean;
@@ -91,6 +95,7 @@ export declare const sharedUserEntity: import("@cosmicdrift/kumiko-types/fields"
91
95
  readonly type: "text";
92
96
  readonly description?: string;
93
97
  readonly maxLength?: number;
98
+ readonly minLength?: number;
94
99
  readonly required?: boolean;
95
100
  readonly searchable?: boolean;
96
101
  readonly sortable?: boolean;
@@ -117,6 +122,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
117
122
  readonly type: "text";
118
123
  readonly description?: string;
119
124
  readonly maxLength?: number;
125
+ readonly minLength?: number;
120
126
  readonly required?: boolean;
121
127
  readonly searchable?: boolean;
122
128
  readonly sortable?: boolean;
@@ -138,6 +144,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
138
144
  readonly type: "text";
139
145
  readonly description?: string;
140
146
  readonly maxLength?: number;
147
+ readonly minLength?: number;
141
148
  readonly required?: boolean;
142
149
  readonly searchable?: boolean;
143
150
  readonly sortable?: boolean;
@@ -159,6 +166,7 @@ export declare const sharedUserTable: import("../db/table-builder.js").EntityTab
159
166
  readonly type: "text";
160
167
  readonly description?: string;
161
168
  readonly maxLength?: number;
169
+ readonly minLength?: number;
162
170
  readonly required?: boolean;
163
171
  readonly searchable?: boolean;
164
172
  readonly sortable?: boolean;
@@ -185,6 +193,7 @@ export declare const sharedItemEntity: import("@cosmicdrift/kumiko-types/fields"
185
193
  readonly type: "text";
186
194
  readonly description?: string;
187
195
  readonly maxLength?: number;
196
+ readonly minLength?: number;
188
197
  readonly required?: boolean;
189
198
  readonly searchable?: boolean;
190
199
  readonly sortable?: boolean;
@@ -227,6 +236,7 @@ export declare const sharedItemTable: import("../db/table-builder.js").EntityTab
227
236
  readonly type: "text";
228
237
  readonly description?: string;
229
238
  readonly maxLength?: number;
239
+ readonly minLength?: number;
230
240
  readonly required?: boolean;
231
241
  readonly searchable?: boolean;
232
242
  readonly sortable?: boolean;