@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -71,7 +71,7 @@ import type {
71
71
  AgentHandlerHints,
72
72
  ClaimKeyType,
73
73
  EscapeHatchDeclaration,
74
- RateLimitOption,
74
+ RateLimitDeclaration,
75
75
  } from "../types/handlers";
76
76
  import type { HookPhase } from "../types/hooks";
77
77
  import type { HttpRouteMethod } from "../types/http-route";
@@ -81,6 +81,7 @@ import type { RelationDefinition } from "../types/relations";
81
81
  import type { ScreenDefinition } from "../types/screen";
82
82
  import type { TreeActionDef } from "../types/tree-node";
83
83
  import type { WorkspaceDefinition } from "../types/workspace";
84
+ import type { RawRefSentinel } from "./extractors/shared";
84
85
  import type { SourceLocation } from "./source-location";
85
86
 
86
87
  // =============================================================================
@@ -367,6 +368,10 @@ export type ScreenPattern = {
367
368
  // overrides that derivation explicitly (force-show or force-hide);
368
369
  // `agent.risk` ("low" | "mid" | "high") classifies the action's blast
369
370
  // radius, defaulting to "mid" for a write handler.
371
+ //
372
+ // access/rateLimit/escapeHatch/agent additionally accept a RawRefSentinel
373
+ // for a non-literal value (imported/same-file const, or a sub-value like
374
+ // `personalData: PD`) that would otherwise lose the reference on render.
370
375
  export type WriteHandlerPattern = {
371
376
  readonly kind: "writeHandler";
372
377
  readonly source: SourceLocation;
@@ -384,12 +389,12 @@ export type WriteHandlerPattern = {
384
389
  // handlerBody: the closure body as source text. Always opaque — AI
385
390
  // generates raw TypeScript, no DSL interpretation.
386
391
  readonly handlerBody?: SourceLocation;
387
- readonly access?: AccessRule;
392
+ readonly access?: AccessRule | RawRefSentinel;
388
393
  readonly description?: string;
389
- readonly agent?: AgentHandlerHints;
390
- readonly rateLimit?: RateLimitOption;
394
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
395
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
391
396
  readonly unsafeSkipTransitionGuard?: boolean;
392
- readonly escapeHatch?: EscapeHatchDeclaration;
397
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
393
398
  };
394
399
 
395
400
  // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
@@ -408,25 +413,27 @@ export type QueryHandlerPattern = {
408
413
  readonly handlerName?: string;
409
414
  readonly schemaSource?: SourceLocation;
410
415
  readonly handlerBody?: SourceLocation;
411
- readonly access?: AccessRule;
416
+ readonly access?: AccessRule | RawRefSentinel;
412
417
  readonly description?: string;
413
- readonly agent?: AgentHandlerHints;
414
- readonly rateLimit?: RateLimitOption;
415
- readonly escapeHatch?: EscapeHatchDeclaration;
418
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
419
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
420
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
416
421
  };
417
422
 
418
423
  // `r.streamHandler(...)` — registers a streaming read handler: name, Zod
419
- // input schema, async-generator handler closure, plus optional `access` and
420
- // `rateLimit` rules. Same header/body split and opaque single-reference
421
- // case as QueryHandlerPattern.
424
+ // input schema, async-generator handler closure, plus optional `access`,
425
+ // `rateLimit`, and `escapeHatch` rules (StreamHandlerDef carries all three
426
+ // at runtime). Same header/body split and opaque single-reference case as
427
+ // QueryHandlerPattern.
422
428
  export type StreamHandlerPattern = {
423
429
  readonly kind: "streamHandler";
424
430
  readonly source: SourceLocation;
425
431
  readonly handlerName?: string;
426
432
  readonly schemaSource?: SourceLocation;
427
433
  readonly handlerBody?: SourceLocation;
428
- readonly access?: AccessRule;
429
- readonly rateLimit?: RateLimitOption;
434
+ readonly access?: AccessRule | RawRefSentinel;
435
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
436
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
430
437
  };
431
438
 
432
439
  // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
@@ -447,7 +454,7 @@ export type HookPattern = {
447
454
  readonly target: string | readonly string[] | { readonly allOf: string };
448
455
  readonly fnBody: SourceLocation;
449
456
  readonly phase?: HookPhase;
450
- readonly escapeHatch?: EscapeHatchDeclaration;
457
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
451
458
  };
452
459
 
453
460
  // `r.job(name, options, handler)` — registers a background job, qualified
@@ -436,6 +436,7 @@ function renderStreamHandler(p: StreamHandlerPattern): string {
436
436
  lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
437
437
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
438
438
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
439
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
439
440
  lines.push("});");
440
441
  return lines.join("\n");
441
442
  }
@@ -229,9 +229,9 @@ export function buildUiExtensionsMethods<TName extends string>(
229
229
  useExtension(
230
230
  extensionNameOrDefinition:
231
231
  | string
232
- | ({ readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>),
232
+ | ({ readonly name: string; readonly entity: NameOrRef } & object),
233
233
  entityRef?: NameOrRef,
234
- options?: Record<string, unknown>,
234
+ options?: object,
235
235
  ): void {
236
236
  const [extensionName, resolvedEntityRef, resolvedOptions] =
237
237
  typeof extensionNameOrDefinition === "string"
@@ -241,11 +241,12 @@ export function buildUiExtensionsMethods<TName extends string>(
241
241
  return [name, entity, rest] as const;
242
242
  })();
243
243
  const resolvedEntityName = resolveName(resolvedEntityRef);
244
+ // @cast-boundary engine-bridge — typed per-extension options → erased registration bag
245
+ const optionsBag = resolvedOptions as Record<string, unknown> | undefined;
244
246
  // fw#2914 — cross-cutting escapeHatch convention for hook-context db
245
- // access (mirrors r.hook's validation above). Not part of a typed
246
- // per-extension options shape: useExtension's bag stays generic, but
247
- // this one key is validated for every extension the same way.
248
- const escapeHatch = resolvedOptions?.["escapeHatch"];
247
+ // access (mirrors r.hook's validation above). Validated the same way
248
+ // for every extension regardless of its typed hook shape.
249
+ const escapeHatch = optionsBag?.["escapeHatch"];
249
250
  if (escapeHatch !== undefined) {
250
251
  const reason =
251
252
  typeof escapeHatch === "object" && escapeHatch !== null
@@ -261,7 +262,7 @@ export function buildUiExtensionsMethods<TName extends string>(
261
262
  state.extensionUsages.push({
262
263
  extensionName,
263
264
  entityName: resolvedEntityName,
264
- options: resolvedOptions,
265
+ options: optionsBag,
265
266
  });
266
267
  },
267
268
  extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
@@ -98,7 +98,7 @@ export {
98
98
  export { declareEscapeHatch } from "./escape-hatch-declaration";
99
99
  export type { EmitCtx } from "./event-helpers";
100
100
  export { emitEvent, typedPayload } from "./event-helpers";
101
- export type { KumikoExtensionName } from "./extension-names";
101
+ export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names";
102
102
  export {
103
103
  EXT_DERIVATIVE_OVERLAY_RESOLVER,
104
104
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
@@ -124,11 +124,18 @@ export type {
124
124
  StorageProviderExtensionHooks,
125
125
  StorageProviderHookCtx,
126
126
  } from "./extensions/storage-provider";
127
- export type {
128
- TenantDataDestroyHook,
129
- TenantDataExtensionHooks,
130
- TenantDataHookCtx,
127
+ export {
128
+ isTenantDataExtensionHooks,
129
+ type TenantDataDestroyHook,
130
+ type TenantDataExtensionHooks,
131
+ type TenantDataHookCtx,
131
132
  } from "./extensions/tenant-data";
133
+ export {
134
+ isTenantResourceExtensionHooks,
135
+ type TenantResourceDestroyHook,
136
+ type TenantResourceExtensionHooks,
137
+ type TenantResourceHookCtx,
138
+ } from "./extensions/tenant-resource";
132
139
  export type {
133
140
  TenantUserModel,
134
141
  UserDataDeleteHook,
@@ -136,6 +143,7 @@ export type {
136
143
  UserDataExportHook,
137
144
  UserDataExportSnippet,
138
145
  UserDataExtensionHooks,
146
+ UserDataExtensionOptions,
139
147
  UserDataHookCtx,
140
148
  UserDataStorageProvider,
141
149
  } from "./extensions/user-data";
@@ -178,10 +186,13 @@ export type {
178
186
  FormFieldLabel,
179
187
  FormFieldSpec,
180
188
  FormInputType,
189
+ HandlerHeaderUpdate,
181
190
  ParseError,
182
191
  ParseResult,
183
192
  PatternCategory,
184
193
  PatternChange,
194
+ PatternChangeIssue,
195
+ PatternChangesParseResult,
185
196
  PatternFormSchema,
186
197
  PatternId,
187
198
  RenderFeatureFileInput,
@@ -195,11 +206,13 @@ export {
195
206
  groupByCategory,
196
207
  PATTERN_LIBRARY,
197
208
  parseFeatureFile,
209
+ parsePatternChanges,
198
210
  parseSourceFile,
199
211
  removePattern,
200
212
  renderFeatureFile,
201
213
  renderPattern,
202
214
  replacePattern,
215
+ updatePattern,
203
216
  VERSION_HEADER,
204
217
  } from "./feature-ast";
205
218
  export {
@@ -392,6 +405,8 @@ export type {
392
405
  EscapeHatchTarget,
393
406
  EscapeHatchUseEvent,
394
407
  EventDef,
408
+ ExtensionOptionsArgs,
409
+ ExtensionOptionsFor,
395
410
  FeatureDefinition,
396
411
  FeatureRegistrar,
397
412
  FieldAccess,
@@ -410,6 +425,8 @@ export type {
410
425
  HookMap,
411
426
  ImageFieldDef,
412
427
  ImagesFieldDef,
428
+ JobBackoff,
429
+ JobBackoffStrategy,
413
430
  JobContext,
414
431
  JobDefinition,
415
432
  JobHandlerFn,
@@ -417,6 +434,7 @@ export type {
417
434
  JsonbFieldDef,
418
435
  KumikoEntityTypeMap,
419
436
  KumikoEventTypeMap,
437
+ KumikoExtensionOptionsMap,
420
438
  KumikoHandlerPayloadMap,
421
439
  KumikoHandlerResultMap,
422
440
  LifecycleHookType,
@@ -0,0 +1,66 @@
1
+ import { ANONYMOUS_ROLE } from "./system-user";
2
+ import type { AccessRule, OwnershipMap, OwnershipRule } from "./types";
3
+ import type { EntityDefinition, ResolvedPiiFlags } from "./types/fields";
4
+
5
+ // Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
6
+ // minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
7
+ // sense the openToAll / public-intake personal-data checks are guarding against.
8
+ function isPersonalDataField(field: unknown): boolean {
9
+ const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
10
+ return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
11
+ }
12
+
13
+ function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
14
+ if (rule === "all" || rule.kind !== "from") return false;
15
+ return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
16
+ }
17
+
18
+ // The executor checks access.write against every created/updated row; one "all" role
19
+ // or an empty map (= public) lets a caller write rows owned by someone else.
20
+ function writeMapBindsRowsToCaller(
21
+ writeMap: OwnershipMap | undefined,
22
+ ownerColumn: string,
23
+ ): boolean {
24
+ const rules = Object.values(writeMap ?? {});
25
+ return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
26
+ }
27
+
28
+ const ROW_ID_COLUMN = "id";
29
+
30
+ // A self/record-owned field's subject is the row itself, so only from("user:id", "id")
31
+ // makes that row the caller — on any other entity "self" names a third party.
32
+ function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
33
+ if (annot.userOwned) return annot.userOwned.ownerField;
34
+ if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
35
+ return undefined;
36
+ }
37
+
38
+ function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
39
+ const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
40
+ return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
41
+ }
42
+
43
+ export function personalFieldNames(
44
+ entity: EntityDefinition,
45
+ honorOwnerBinding: boolean,
46
+ ): ReadonlySet<string> {
47
+ const names = new Set<string>();
48
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
49
+ const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
50
+ if (isPersonalDataField(field) && !exempt) names.add(fieldName);
51
+ }
52
+ return names;
53
+ }
54
+
55
+ // Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
56
+ export function declaredPersonalData(access: AccessRule): unknown {
57
+ if (!("openToAll" in access)) return access.personalData;
58
+ const openToAll: unknown = access.openToAll;
59
+ if (typeof openToAll !== "object" || openToAll === null) return undefined;
60
+ return "personalData" in openToAll ? openToAll.personalData : undefined;
61
+ }
62
+
63
+ export function accessAllowsAnonymous(access: AccessRule): boolean {
64
+ if ("openToAll" in access) return false;
65
+ return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
66
+ }
@@ -729,6 +729,21 @@ export function validateBootGates(state: RegistryState): void {
729
729
  }
730
730
  }
731
731
 
732
+ export function validateJobBackoff(state: RegistryState): void {
733
+ // Object-form backoff carries an explicit delayMs base — catch a bad value
734
+ // at boot instead of letting BullMQ silently compute NaN/undefined delays.
735
+ for (const [jobName, jobDef] of state.jobMap) {
736
+ if (typeof jobDef.backoff !== "object") continue;
737
+ const { delayMs } = jobDef.backoff;
738
+ if (delayMs === undefined) continue;
739
+ if (!Number.isInteger(delayMs) || delayMs <= 0) {
740
+ throw new Error(
741
+ `Job "${jobName}" backoff.delayMs must be a positive integer (got ${delayMs})`,
742
+ );
743
+ }
744
+ }
745
+ }
746
+
732
747
  export function validateExtensionUsageTargets(state: RegistryState): void {
733
748
  // Validate: extension usages must reference existing extensions
734
749
  for (const usage of state.extensionUsages) {
@@ -34,6 +34,7 @@ import {
34
34
  validateExtensionSelectors,
35
35
  validateExtensionUsageTargets,
36
36
  validateFieldAccessHandlersAreEntityMapped,
37
+ validateJobBackoff,
37
38
  validateJobTriggers,
38
39
  validateLifecycleHookTargets,
39
40
  validateProjectionApplyKeys,
@@ -83,6 +84,7 @@ export function createRegistry(rawFeatures: readonly FeatureDefinition[]): Regis
83
84
  validateEntityHookTargets(state, features);
84
85
  validateJobTriggers(state);
85
86
  validateBootGates(state);
87
+ validateJobBackoff(state);
86
88
  validateExtensionUsageTargets(state);
87
89
  computeHasRateLimitedHandler(state);
88
90
  publishEventPiiCatalog(state);
@@ -0,0 +1 @@
1
+ export type * from "@cosmicdrift/kumiko-types/extension-options-map";
@@ -32,6 +32,8 @@ export type {
32
32
  CreateTenantSeedOptions,
33
33
  CreateUserSeedOptions,
34
34
  ExtensionSelectorDef,
35
+ JobBackoff,
36
+ JobBackoffStrategy,
35
37
  JobDefinition,
36
38
  JobHandlerFn,
37
39
  JobRunIn,
@@ -70,6 +72,12 @@ export type {
70
72
  KumikoHandlerPayloadMap,
71
73
  KumikoHandlerResultMap,
72
74
  } from "@cosmicdrift/kumiko-types/event-type-map";
75
+ // Cross-Feature Compile-Time-Type-Map for r.useExtension options — mirrors KumikoEventTypeMap above.
76
+ export type {
77
+ ExtensionOptionsArgs,
78
+ ExtensionOptionsFor,
79
+ KumikoExtensionOptionsMap,
80
+ } from "@cosmicdrift/kumiko-types/extension-options-map";
73
81
  export type {
74
82
  BootCheckContext,
75
83
  BootCheckFn,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { defineFeature } from "../../engine/define-feature";
4
+ import { prometheusMetricsEnvSchema } from "../../observability/metrics-wiring";
4
5
  import { renderDryRun } from "../dry-run";
5
6
  import { composeEnvSchema } from "../index";
6
7
 
@@ -90,7 +91,7 @@ describe("renderDryRun", () => {
90
91
  expect(ver?.default).toBe("1");
91
92
  });
92
93
 
93
- it("pulumi mode emits `pulumi config set` lines, omitting optional+defaulted", () => {
94
+ it("pulumi mode emits required lines, optional ones commented out, omits defaulted", () => {
94
95
  const composed = buildComposed();
95
96
  const out = renderDryRun(composed, "pulumi", { pulumiPrefix: "studio" });
96
97
  expect(out).toContain(
@@ -100,9 +101,36 @@ describe("renderDryRun", () => {
100
101
  'pulumi config set --secret studioSecretsMasterKey "$(openssl rand -base64 32)"',
101
102
  );
102
103
  expect(out).toContain('pulumi config set studioStudioAdminEmail "<set-me>"');
103
- // Optional + default skipped:
104
- expect(out).not.toContain("SMTP_HOST");
104
+ expect(out).toContain(
105
+ '# pulumi config set studioSmtpHost "<set-me>" # SMTP_HOST (channel-email-smtp): Outbound SMTP host',
106
+ );
107
+ expect(out).not.toMatch(/^pulumi config set.*SMTP_HOST/m);
105
108
  expect(out).not.toContain("CURRENT_VERSION");
109
+ const requiredLineIndex = out.lastIndexOf("pulumi config set studioStudioAdminEmail");
110
+ const optionalHeaderIndex = out.indexOf("# Optional (uncomment and set to enable):");
111
+ expect(requiredLineIndex).toBeGreaterThanOrEqual(0);
112
+ expect(optionalHeaderIndex).toBeGreaterThan(requiredLineIndex);
113
+ });
114
+
115
+ it("pulumi mode lists an optional secret field (PROMETHEUS_METRICS_TOKEN) commented out with generator", () => {
116
+ const composed = composeEnvSchema({
117
+ features: [],
118
+ extend: z.object({ FOO: z.string() }).extend(prometheusMetricsEnvSchema.shape),
119
+ });
120
+ const out = renderDryRun(composed, "pulumi");
121
+ expect(out).toContain(
122
+ '# pulumi config set --secret prometheusMetricsToken "$(openssl rand -base64 32)" # PROMETHEUS_METRICS_TOKEN (app): Bearer token for /metrics; unset keeps the endpoint off.',
123
+ );
124
+ expect(out).not.toMatch(/^pulumi config set.*PROMETHEUS_METRICS_TOKEN/m);
125
+ });
126
+
127
+ it("pulumi mode omits the Optional header when there are no optional fields", () => {
128
+ const composed = composeEnvSchema({
129
+ features: [],
130
+ extend: z.object({ FOO: z.string() }),
131
+ });
132
+ const out = renderDryRun(composed, "pulumi");
133
+ expect(out).not.toContain("# Optional");
106
134
  });
107
135
 
108
136
  it("k8s mode emits a Secret manifest", () => {
@@ -117,5 +145,17 @@ describe("renderDryRun", () => {
117
145
  expect(out).toContain("namespace: studio");
118
146
  expect(out).toContain('JWT_SECRET: "<set-me>"');
119
147
  expect(out).toContain('KUMIKO_SECRETS_MASTER_KEY_V1: "<set-me>"');
148
+ expect(out).toContain(' # SMTP_HOST: "<set-me>" # (channel-email-smtp): Outbound SMTP host');
149
+ expect(out).not.toMatch(/^ {2}SMTP_HOST:/m);
150
+ expect(out).not.toContain("CURRENT_VERSION");
151
+ });
152
+
153
+ it("k8s mode omits the Optional header when there are no optional fields", () => {
154
+ const composed = composeEnvSchema({
155
+ features: [],
156
+ extend: z.object({ FOO: z.string() }),
157
+ });
158
+ const out = renderDryRun(composed, "k8s");
159
+ expect(out).not.toContain("# Optional");
120
160
  });
121
161
  });
@@ -152,20 +152,27 @@ function renderJson(fields: readonly EnvField[], options: DryRunOptions): string
152
152
  )}\n`;
153
153
  }
154
154
 
155
+ function pulumiConfigSetLine(f: EnvField, options: DryRunOptions): string {
156
+ const meta = readKumikoMeta(f.field);
157
+ const key = pulumiConfigKey(f.name, f.field, options.pulumiPrefix);
158
+ const secretFlag = meta.pulumi?.secret ? " --secret" : "";
159
+ const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
160
+ const comment = f.description
161
+ ? ` # ${f.name} (${f.source}): ${f.description}`
162
+ : ` # ${f.name} (${f.source})`;
163
+ return `pulumi config set${secretFlag} ${key} ${value}${comment}`;
164
+ }
165
+
155
166
  function renderPulumi(fields: readonly EnvField[], options: DryRunOptions): string {
156
167
  // Defaulted vars are skipped — the framework provides them, ops doesn't.
157
- const lines: string[] = [];
158
- for (const f of fields) {
159
- if (f.klass === "withDefault") continue;
160
- if (f.klass === "optional") continue;
161
- const meta = readKumikoMeta(f.field);
162
- const key = pulumiConfigKey(f.name, f.field, options.pulumiPrefix);
163
- const secretFlag = meta.pulumi?.secret ? " --secret" : "";
164
- const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
165
- const comment = f.description
166
- ? ` # ${f.name} (${f.source}): ${f.description}`
167
- : ` # ${f.name} (${f.source})`;
168
- lines.push(`pulumi config set${secretFlag} ${key} ${value}${comment}`);
168
+ // Optional vars stay commented out: setting one turns its feature on.
169
+ const required = fields.filter((f) => f.klass === "required");
170
+ const optional = fields.filter((f) => f.klass === "optional");
171
+ const lines: string[] = required.map((f) => pulumiConfigSetLine(f, options));
172
+ if (optional.length > 0) {
173
+ if (lines.length > 0) lines.push("");
174
+ lines.push("# Optional (uncomment and set to enable):");
175
+ for (const f of optional) lines.push(`# ${pulumiConfigSetLine(f, options)}`);
169
176
  }
170
177
  return `${lines.join("\n")}\n`;
171
178
  }
@@ -183,9 +190,15 @@ function renderK8s(fields: readonly EnvField[], options: DryRunOptions): string
183
190
  "stringData:",
184
191
  ];
185
192
  for (const f of fields) {
186
- if (f.klass === "withDefault") continue;
187
- if (f.klass === "optional") continue;
188
- lines.push(` ${f.name}: "<set-me>"`);
193
+ if (f.klass === "required") lines.push(` ${f.name}: "<set-me>"`);
194
+ }
195
+ const optional = fields.filter((f) => f.klass === "optional");
196
+ if (optional.length > 0) {
197
+ lines.push(" # Optional (uncomment and set to enable):");
198
+ for (const f of optional) {
199
+ const comment = f.description ? ` # (${f.source}): ${f.description}` : ` # (${f.source})`;
200
+ lines.push(` # ${f.name}: "<set-me>"${comment}`);
201
+ }
189
202
  }
190
203
  return `${lines.join("\n")}\n`;
191
204
  }
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { failNotFound, failTransition, failUnprocessable } from "../write-error-info";
2
+ import { InternalError, NotFoundError } from "../classes";
3
+ import {
4
+ failNotFound,
5
+ failTransition,
6
+ failUnprocessable,
7
+ reraiseAsKumikoError,
8
+ toWriteErrorInfo,
9
+ } from "../write-error-info";
3
10
 
4
11
  describe("failNotFound", () => {
5
12
  test("baut WriteFailure mit reason=not_found + entity-id-details", () => {
@@ -73,7 +80,6 @@ describe("failTransition", () => {
73
80
  describe("toWriteErrorInfo — dev cause-snapshot", () => {
74
81
  test("InternalError mit cause exposed cause-Snapshot in details (dev)", async () => {
75
82
  const { toWriteErrorInfo } = await import("../write-error-info");
76
- const { InternalError } = await import("../classes");
77
83
  const previous = process.env["NODE_ENV"];
78
84
  process.env["NODE_ENV"] = "development";
79
85
  try {
@@ -93,7 +99,6 @@ describe("toWriteErrorInfo — dev cause-snapshot", () => {
93
99
 
94
100
  test("Production: InternalError lässt details undefined (kein Stack-Leak)", async () => {
95
101
  const { toWriteErrorInfo } = await import("../write-error-info");
96
- const { InternalError } = await import("../classes");
97
102
  const previous = process.env["NODE_ENV"];
98
103
  process.env["NODE_ENV"] = "production";
99
104
  try {
@@ -107,7 +112,6 @@ describe("toWriteErrorInfo — dev cause-snapshot", () => {
107
112
 
108
113
  test("InternalError MIT bereits gesetztem details → Author-details gewinnt (kein Overwrite)", async () => {
109
114
  const { toWriteErrorInfo } = await import("../write-error-info");
110
- const { InternalError } = await import("../classes");
111
115
  const previous = process.env["NODE_ENV"];
112
116
  process.env["NODE_ENV"] = "development";
113
117
  try {
@@ -122,3 +126,42 @@ describe("toWriteErrorInfo — dev cause-snapshot", () => {
122
126
  }
123
127
  });
124
128
  });
129
+
130
+ // The cause must reach routes.ts's logServerFault via reraiseAsKumikoError,
131
+ // but never the wire body or the idempotency cache (both serialize the info).
132
+ describe("toWriteErrorInfo / reraiseAsKumikoError — cause round-trip", () => {
133
+ test("cause survives toWriteErrorInfo → reraiseAsKumikoError without appearing on the info object", () => {
134
+ const boom = new Error("connection was closed");
135
+ const info = toWriteErrorInfo(new InternalError({ cause: boom }));
136
+ expect(reraiseAsKumikoError(info).cause).toBe(boom);
137
+ expect(Object.keys(info)).not.toContain("cause");
138
+ });
139
+
140
+ test("production: cause still reaches reraise even though details/message stay sanitized", () => {
141
+ const previous = process.env["NODE_ENV"];
142
+ process.env["NODE_ENV"] = "production";
143
+ try {
144
+ const boom = new Error("connection was closed");
145
+ const info = toWriteErrorInfo(new InternalError({ cause: boom }));
146
+ expect(info.details).toBeUndefined();
147
+ const serialized = JSON.stringify(info);
148
+ expect(serialized).not.toContain("connection was closed");
149
+ expect(serialized).not.toContain("cause");
150
+ expect(reraiseAsKumikoError(info).cause).toBe(boom);
151
+ } finally {
152
+ if (previous === undefined) delete process.env["NODE_ENV"];
153
+ else process.env["NODE_ENV"] = previous;
154
+ }
155
+ });
156
+
157
+ test("KumikoError without a cause → reraised error has no cause", () => {
158
+ const info = toWriteErrorInfo(new NotFoundError("invoice", "inv-1"));
159
+ expect(reraiseAsKumikoError(info).cause).toBeUndefined();
160
+ });
161
+
162
+ test("a plain object with the same shape (simulated idempotency-cache replay) carries no cause", () => {
163
+ const info = toWriteErrorInfo(new InternalError({ cause: new Error("connection was closed") }));
164
+ const replayed = { ...info };
165
+ expect(reraiseAsKumikoError(replayed).cause).toBeUndefined();
166
+ });
167
+ });
@@ -147,6 +147,18 @@ member_resolution_read_only:
147
147
  `jobRunner`/...). Aufgelöste Member-Principals sind nur lesend — es gibt
148
148
  kein `writeAsMember`.
149
149
 
150
+ public_intake_required:
151
+ endUser: |
152
+ Diese Aktion ist nicht erlaubt.
153
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
154
+ developer: |
155
+ `AccessDeniedError`: ein Write unter einem anonymen Wurzel-Handler hat ein
156
+ Personendaten-Feld (`pii` / `userOwned` / `recordOwned`) geschrieben, direkt
157
+ oder über `ctx.write`/`writeAs`/`queryAs` oder einen Hook. `details` nennen
158
+ `rootHandler`, `target` und `fields`. Ist der anonyme Intake gewollt, am
159
+ Wurzel-Handler `access: { roles: [..., "anonymous"], personalData: "public-intake" }`
160
+ deklarieren.
161
+
150
162
  agent.tool_not_allowed:
151
163
  endUser: |
152
164
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -141,6 +141,18 @@ member_resolution_read_only:
141
141
  to write (`ctx.write`/`writeAs`/`appendEvent`/`jobRunner`/...). Resolved
142
142
  member principals are read-only — there is no `writeAsMember`.
143
143
 
144
+ public_intake_required:
145
+ endUser: |
146
+ This action isn't allowed.
147
+ Please contact an administrator if you believe this is a mistake.
148
+ developer: |
149
+ `AccessDeniedError`: a write under an anonymous root handler touched a
150
+ personal-data field (`pii` / `userOwned` / `recordOwned`), directly or via
151
+ `ctx.write`/`writeAs`/`queryAs` or a hook. `details` name `rootHandler`,
152
+ `target` and `fields`. Declare
153
+ `access: { roles: [..., "anonymous"], personalData: "public-intake" }` on
154
+ the root handler if the anonymous intake is intended.
155
+
144
156
  agent.tool_not_allowed:
145
157
  endUser: |
146
158
  This tool is not available in the current mode.
@@ -59,6 +59,10 @@ export const FrameworkReasons = {
59
59
  // AccessDeniedError: a query handler invoked via ctx.queryAsMember tried to
60
60
  // write — a resolved member principal is read-only by construction.
61
61
  memberResolutionReadOnly: "member_resolution_read_only",
62
+
63
+ // AccessDeniedError: a write under an anonymous root touched a personal-data field
64
+ // without the root handler declaring access.personalData: "public-intake".
65
+ publicIntakeRequired: "public_intake_required",
62
66
  } as const;
63
67
 
64
68
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];