@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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- 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
|
-
|
|
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?:
|
|
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?:
|
|
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
|
|
420
|
-
// `rateLimit` rules
|
|
421
|
-
// case as
|
|
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?:
|
|
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 } &
|
|
232
|
+
| ({ readonly name: string; readonly entity: NameOrRef } & object),
|
|
233
233
|
entityRef?: NameOrRef,
|
|
234
|
-
options?:
|
|
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).
|
|
246
|
-
//
|
|
247
|
-
|
|
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:
|
|
265
|
+
options: optionsBag,
|
|
265
266
|
});
|
|
266
267
|
},
|
|
267
268
|
extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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) {
|
package/src/engine/registry.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
104
|
-
|
|
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
|
});
|
package/src/env/dry-run.ts
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
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 === "
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
|
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
|
+
});
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -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.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -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.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -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];
|