@cosmicdrift/kumiko-framework 0.260.0 → 0.261.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 +3 -3
- package/src/changes.json +13 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +97 -0
- package/src/db/__tests__/tenant-db-global.test.ts +170 -0
- package/src/db/entity-table-meta.ts +8 -2
- package/src/db/table-builder.ts +37 -2
- package/src/db/tenant-db.ts +86 -4
- package/src/engine/__tests__/boot-validator.test.ts +16 -15
- package/src/engine/__tests__/build-app-schema.test.ts +14 -5
- package/src/engine/__tests__/engine.test.ts +10 -0
- package/src/engine/__tests__/entity-handlers.test.ts +48 -36
- package/src/engine/__tests__/extends-registrar.test.ts +27 -12
- package/src/engine/__tests__/registry.test.ts +16 -5
- package/src/engine/access.ts +2 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +200 -0
- package/src/engine/boot-validator/access-declarations.ts +141 -0
- package/src/engine/boot-validator/entity-handler.ts +1 -1
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/entity-handlers.ts +29 -19
- package/src/engine/factories.ts +5 -3
- package/src/engine/feature-ast/extractors/hooks.ts +5 -1
- package/src/engine/feature-entity-handlers.ts +40 -27
- package/src/engine/index.ts +4 -0
- package/src/engine/types/handlers.ts +1 -0
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +93 -0
- package/src/pipeline/dispatch-shared.ts +3 -0
- package/src/secrets/__tests__/contains-secret.test.ts +5 -0
- package/src/testing/__tests__/e2e-generator.test.ts +11 -5
- package/src/ui-types/index.ts +6 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AccessRule, FeatureDefinition, WriteHandlerDef } from "../types";
|
|
2
|
+
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
3
|
+
import { getZodObjectShape } from "./zod-shape";
|
|
4
|
+
|
|
5
|
+
type HandlerKind = "write" | "query" | "stream";
|
|
6
|
+
|
|
7
|
+
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
8
|
+
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
9
|
+
// sense openToAll+publicIntake is guarding against.
|
|
10
|
+
function isPersonalDataField(field: unknown): boolean {
|
|
11
|
+
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
12
|
+
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function personalFieldNames(entity: EntityDefinition): ReadonlySet<string> {
|
|
16
|
+
const names = new Set<string>();
|
|
17
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
18
|
+
if (isPersonalDataField(field)) names.add(fieldName);
|
|
19
|
+
}
|
|
20
|
+
return names;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function candidatePersonalFieldNames(
|
|
24
|
+
feature: FeatureDefinition,
|
|
25
|
+
handlerName: string,
|
|
26
|
+
): ReadonlySet<string> {
|
|
27
|
+
const mappedEntityName = feature.handlerEntityMappings?.[handlerName];
|
|
28
|
+
const entities = feature.entities ?? {};
|
|
29
|
+
if (mappedEntityName) {
|
|
30
|
+
const entity = entities[mappedEntityName];
|
|
31
|
+
return entity ? personalFieldNames(entity) : new Set();
|
|
32
|
+
}
|
|
33
|
+
const names = new Set<string>();
|
|
34
|
+
for (const entity of Object.values(entities)) {
|
|
35
|
+
for (const name of personalFieldNames(entity)) names.add(name);
|
|
36
|
+
}
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A value that is neither `true` nor an object with a non-empty string `reason`
|
|
41
|
+
// counts as "empty" — catches malformed openToAll from untyped sources too.
|
|
42
|
+
function openToAllReasonIsEmpty(access: AccessRule): boolean {
|
|
43
|
+
if (!("openToAll" in access)) return false;
|
|
44
|
+
const openToAll: unknown = access.openToAll;
|
|
45
|
+
if (openToAll === true) return false;
|
|
46
|
+
if (typeof openToAll !== "object" || openToAll === null) return true;
|
|
47
|
+
if (!("reason" in openToAll) || typeof openToAll.reason !== "string") return true;
|
|
48
|
+
return openToAll.reason.trim().length === 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hasOpenToAll(access: AccessRule): boolean {
|
|
52
|
+
return "openToAll" in access;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasPublicIntake(access: AccessRule): boolean {
|
|
56
|
+
return "publicIntake" in access && access.publicIntake === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function validateOpenToAllReason(
|
|
60
|
+
feature: FeatureDefinition,
|
|
61
|
+
kind: HandlerKind,
|
|
62
|
+
handlerName: string,
|
|
63
|
+
access: AccessRule,
|
|
64
|
+
): void {
|
|
65
|
+
// skip: reason is well-formed, nothing to validate
|
|
66
|
+
if (!openToAllReasonIsEmpty(access)) return;
|
|
67
|
+
throw new Error(
|
|
68
|
+
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid ` +
|
|
69
|
+
'{ openToAll: ... } — must be `true` or { reason: "<non-empty explanation>" } ' +
|
|
70
|
+
"for why any authenticated user may call this handler.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateEscapeHatchReason(
|
|
75
|
+
feature: FeatureDefinition,
|
|
76
|
+
handlerName: string,
|
|
77
|
+
escapeHatch: WriteHandlerDef["escapeHatch"],
|
|
78
|
+
): void {
|
|
79
|
+
// skip: no escapeHatch declared, or its reason is already non-empty
|
|
80
|
+
if (!escapeHatch || escapeHatch.reason.trim().length > 0) return;
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares ` +
|
|
83
|
+
`{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
|
|
84
|
+
"explaining why this handler needs db.global() write access.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validatePublicIntakeOnlyOnWrite(
|
|
89
|
+
feature: FeatureDefinition,
|
|
90
|
+
kind: HandlerKind,
|
|
91
|
+
handlerName: string,
|
|
92
|
+
access: AccessRule,
|
|
93
|
+
): void {
|
|
94
|
+
// skip: write handlers may declare publicIntake, or it wasn't declared here
|
|
95
|
+
if (kind === "write" || !hasPublicIntake(access)) return;
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
98
|
+
"{ publicIntake: true } — publicIntake is only meaningful on a write handler " +
|
|
99
|
+
"(it silences the personal-data + openToAll boot check for the fields a write " +
|
|
100
|
+
"handler's input accepts).",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateOpenToAllPersonalData(
|
|
105
|
+
feature: FeatureDefinition,
|
|
106
|
+
handlerName: string,
|
|
107
|
+
handler: WriteHandlerDef,
|
|
108
|
+
): void {
|
|
109
|
+
const access = handler.access;
|
|
110
|
+
// skip: no openToAll declared, or publicIntake already covers personal data
|
|
111
|
+
if (!hasOpenToAll(access) || hasPublicIntake(access)) return;
|
|
112
|
+
const shape = getZodObjectShape(handler.schema);
|
|
113
|
+
// skip: handler schema isn't a zod object — nothing to inspect
|
|
114
|
+
if (!shape) return;
|
|
115
|
+
const personalNames = candidatePersonalFieldNames(feature, handlerName);
|
|
116
|
+
const offending = Object.keys(shape).filter((key) => personalNames.has(key));
|
|
117
|
+
// skip: no personal-data fields in the handler's input
|
|
118
|
+
if (offending.length === 0) return;
|
|
119
|
+
throw new Error(
|
|
120
|
+
`[Feature ${feature.name}] write handler "${handlerName}" declares openToAll and ` +
|
|
121
|
+
`accepts personal-data field(s) ${offending.map((f) => `"${f}"`).join(", ")} without ` +
|
|
122
|
+
"{ publicIntake: true }. Restrict access to roles, or declare " +
|
|
123
|
+
"{ publicIntake: true } if any authenticated user may submit this personal data.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateAccessDeclarations(feature: FeatureDefinition): void {
|
|
128
|
+
for (const [handlerName, handler] of Object.entries(feature.writeHandlers)) {
|
|
129
|
+
validateOpenToAllReason(feature, "write", handlerName, handler.access);
|
|
130
|
+
validateEscapeHatchReason(feature, handlerName, handler.escapeHatch);
|
|
131
|
+
validateOpenToAllPersonalData(feature, handlerName, handler);
|
|
132
|
+
}
|
|
133
|
+
for (const [handlerName, handler] of Object.entries(feature.queryHandlers)) {
|
|
134
|
+
validateOpenToAllReason(feature, "query", handlerName, handler.access);
|
|
135
|
+
validatePublicIntakeOnlyOnWrite(feature, "query", handlerName, handler.access);
|
|
136
|
+
}
|
|
137
|
+
for (const [handlerName, handler] of Object.entries(feature.streamHandlers)) {
|
|
138
|
+
validateOpenToAllReason(feature, "stream", handlerName, handler.access);
|
|
139
|
+
validatePublicIntakeOnlyOnWrite(feature, "stream", handlerName, handler.access);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -143,7 +143,7 @@ export function validateHandlerAccess(feature: FeatureDefinition): void {
|
|
|
143
143
|
if (!handler.access) {
|
|
144
144
|
throw new Error(
|
|
145
145
|
`${label} handler "${feature.name}:${kind}:${name}" is missing an access rule. ` +
|
|
146
|
-
`Set { roles: [...] } for role-based access, or { openToAll:
|
|
146
|
+
`Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`,
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { FeatureDefinition } from "../types";
|
|
4
|
+
import { validateAccessDeclarations } from "./access-declarations";
|
|
4
5
|
import { warnOnUniqueAccessRoles } from "./access-roles";
|
|
5
6
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
6
7
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
@@ -187,6 +188,7 @@ export function validateBoot(
|
|
|
187
188
|
validateExtendSchemaCollisions(feature);
|
|
188
189
|
validateDerivedFieldCollisions(feature);
|
|
189
190
|
validateHandlerAccess(feature);
|
|
191
|
+
validateAccessDeclarations(feature);
|
|
190
192
|
validateLocatedTimestamps(feature);
|
|
191
193
|
validateEntityIndexes(feature);
|
|
192
194
|
validateConfigKeyBounds(feature);
|
|
@@ -85,6 +85,7 @@ export function defineWriteHandler<
|
|
|
85
85
|
unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
|
|
86
86
|
}),
|
|
87
87
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
88
|
+
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
if ("perform" in def && def.perform !== undefined) {
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
} from "./types/entity-handlers";
|
|
31
31
|
|
|
32
32
|
export type {
|
|
33
|
+
EntityCrudHandlerDefaults,
|
|
33
34
|
EntityCrudRegistrar,
|
|
34
35
|
EntityCrudVerb,
|
|
35
36
|
EntityHandlerOptions,
|
|
@@ -174,7 +175,7 @@ function parseHandlerName<TVerb extends string>(
|
|
|
174
175
|
export function defineEntityWriteHandler(
|
|
175
176
|
name: string,
|
|
176
177
|
entity: EntityDefinition,
|
|
177
|
-
options
|
|
178
|
+
options: EntityHandlerOptions,
|
|
178
179
|
): WriteHandlerDef {
|
|
179
180
|
const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
|
|
180
181
|
if (verb === "restore" && !entity.softDelete) {
|
|
@@ -291,9 +292,9 @@ export function defineEntityWriteHandler(
|
|
|
291
292
|
name,
|
|
292
293
|
schema,
|
|
293
294
|
handler,
|
|
294
|
-
|
|
295
|
-
...(options
|
|
296
|
-
...(options
|
|
295
|
+
access: options.access,
|
|
296
|
+
...(options.description !== undefined && { description: options.description }),
|
|
297
|
+
...(options.agent !== undefined && { agent: options.agent }),
|
|
297
298
|
};
|
|
298
299
|
}
|
|
299
300
|
|
|
@@ -323,7 +324,7 @@ function augmentDerivedFields(
|
|
|
323
324
|
export function defineEntityQueryHandler(
|
|
324
325
|
name: string,
|
|
325
326
|
entity: EntityDefinition,
|
|
326
|
-
options
|
|
327
|
+
options: EntityQueryHandlerOptions,
|
|
327
328
|
): QueryHandlerDef {
|
|
328
329
|
const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
|
|
329
330
|
|
|
@@ -421,9 +422,9 @@ export function defineEntityQueryHandler(
|
|
|
421
422
|
name,
|
|
422
423
|
schema,
|
|
423
424
|
handler,
|
|
424
|
-
|
|
425
|
-
...(options
|
|
426
|
-
...(options
|
|
425
|
+
access: options.access,
|
|
426
|
+
...(options.description !== undefined && { description: options.description }),
|
|
427
|
+
...(options.agent !== undefined && { agent: options.agent }),
|
|
427
428
|
// The "list" verb's executor.list() always returns { rows, nextCursor,
|
|
428
429
|
// total? } (see the handler body above) — brand it so the definition
|
|
429
430
|
// site documents the PagedRows contract without needing
|
|
@@ -458,7 +459,7 @@ export function defineEntityQueryHandler(
|
|
|
458
459
|
export function defineEntityCreateHandler(
|
|
459
460
|
entityName: string,
|
|
460
461
|
entity: EntityDefinition,
|
|
461
|
-
options
|
|
462
|
+
options: EntityHandlerOptions,
|
|
462
463
|
): WriteHandlerDef {
|
|
463
464
|
return defineEntityWriteHandler(`${entityName}:create`, entity, options);
|
|
464
465
|
}
|
|
@@ -467,7 +468,7 @@ export function defineEntityCreateHandler(
|
|
|
467
468
|
export function defineEntityUpdateHandler(
|
|
468
469
|
entityName: string,
|
|
469
470
|
entity: EntityDefinition,
|
|
470
|
-
options
|
|
471
|
+
options: EntityHandlerOptions,
|
|
471
472
|
): WriteHandlerDef {
|
|
472
473
|
return defineEntityWriteHandler(`${entityName}:update`, entity, options);
|
|
473
474
|
}
|
|
@@ -476,7 +477,7 @@ export function defineEntityUpdateHandler(
|
|
|
476
477
|
export function defineEntityDeleteHandler(
|
|
477
478
|
entityName: string,
|
|
478
479
|
entity: EntityDefinition,
|
|
479
|
-
options
|
|
480
|
+
options: EntityHandlerOptions,
|
|
480
481
|
): WriteHandlerDef {
|
|
481
482
|
return defineEntityWriteHandler(`${entityName}:delete`, entity, options);
|
|
482
483
|
}
|
|
@@ -485,7 +486,7 @@ export function defineEntityDeleteHandler(
|
|
|
485
486
|
export function defineEntityRestoreHandler(
|
|
486
487
|
entityName: string,
|
|
487
488
|
entity: EntityDefinition,
|
|
488
|
-
options
|
|
489
|
+
options: EntityHandlerOptions,
|
|
489
490
|
): WriteHandlerDef {
|
|
490
491
|
return defineEntityWriteHandler(`${entityName}:restore`, entity, options);
|
|
491
492
|
}
|
|
@@ -494,7 +495,7 @@ export function defineEntityRestoreHandler(
|
|
|
494
495
|
export function defineEntityListHandler(
|
|
495
496
|
entityName: string,
|
|
496
497
|
entity: EntityDefinition,
|
|
497
|
-
options
|
|
498
|
+
options: EntityQueryHandlerOptions,
|
|
498
499
|
): QueryHandlerDef {
|
|
499
500
|
return defineEntityQueryHandler(`${entityName}:list`, entity, options);
|
|
500
501
|
}
|
|
@@ -503,7 +504,7 @@ export function defineEntityListHandler(
|
|
|
503
504
|
export function defineEntityDetailHandler(
|
|
504
505
|
entityName: string,
|
|
505
506
|
entity: EntityDefinition,
|
|
506
|
-
options
|
|
507
|
+
options: EntityQueryHandlerOptions,
|
|
507
508
|
): QueryHandlerDef {
|
|
508
509
|
return defineEntityQueryHandler(`${entityName}:detail`, entity, options);
|
|
509
510
|
}
|
|
@@ -542,7 +543,7 @@ export function createEntityExecutor(
|
|
|
542
543
|
export function defineProjectionQueryHandler(
|
|
543
544
|
name: string,
|
|
544
545
|
projectionQualifiedName: string,
|
|
545
|
-
options
|
|
546
|
+
options: { access: AccessRule; unsafeAllTenants?: boolean },
|
|
546
547
|
): QueryHandlerDef {
|
|
547
548
|
return {
|
|
548
549
|
name,
|
|
@@ -554,9 +555,9 @@ export function defineProjectionQueryHandler(
|
|
|
554
555
|
handler: async (_query, ctx) =>
|
|
555
556
|
ctx.queryProjection(
|
|
556
557
|
projectionQualifiedName,
|
|
557
|
-
options
|
|
558
|
+
options.unsafeAllTenants ? { unsafeAllTenants: true } : undefined,
|
|
558
559
|
), // @wrapper-known semantic-alias
|
|
559
|
-
|
|
560
|
+
access: options.access,
|
|
560
561
|
};
|
|
561
562
|
}
|
|
562
563
|
|
|
@@ -588,14 +589,23 @@ export function registerEntityCrud(
|
|
|
588
589
|
}
|
|
589
590
|
const writeOpts = options?.write;
|
|
590
591
|
const readOpts = options?.read;
|
|
592
|
+
const requireAccess = (verb: EntityCrudVerb, access: AccessRule | undefined): AccessRule => {
|
|
593
|
+
if (!access) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`registerEntityCrud("${entityName}", verb: "${verb}"): no access rule resolved. ` +
|
|
596
|
+
`Set write.access/read.access on registerEntityCrud's options, or verbAccess.${verb}.`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
return access;
|
|
600
|
+
};
|
|
591
601
|
const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
|
|
592
602
|
...writeOpts,
|
|
593
|
-
access: options?.verbAccess?.[verb] ?? writeOpts?.access,
|
|
603
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? writeOpts?.access),
|
|
594
604
|
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
595
605
|
});
|
|
596
606
|
const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
|
|
597
607
|
...readOpts,
|
|
598
|
-
access: options?.verbAccess?.[verb] ?? readOpts?.access,
|
|
608
|
+
access: requireAccess(verb, options?.verbAccess?.[verb] ?? readOpts?.access),
|
|
599
609
|
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
600
610
|
});
|
|
601
611
|
|
package/src/engine/factories.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
|
|
1
2
|
import type {
|
|
2
3
|
BigIntFieldDef,
|
|
3
4
|
BooleanFieldDef,
|
|
@@ -442,7 +443,7 @@ export function createImagesField(
|
|
|
442
443
|
// Issue um Constraint-Inferenz auf strukturellen Records. Stattdessen
|
|
443
444
|
// validieren wir die Conformance via conditional return type: wenn F
|
|
444
445
|
// nicht FieldsMap-compat ist, kollabiert der Return zu `never`.
|
|
445
|
-
export function createEntity<F>(def: {
|
|
446
|
+
export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
|
|
446
447
|
readonly table?: string;
|
|
447
448
|
readonly fields: F;
|
|
448
449
|
readonly description?: string;
|
|
@@ -460,7 +461,8 @@ export function createEntity<F>(def: {
|
|
|
460
461
|
readonly parentRef?: EntityDefinition["parentRef"];
|
|
461
462
|
readonly retention?: RetentionDef;
|
|
462
463
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
463
|
-
|
|
464
|
+
readonly tenancy?: T;
|
|
465
|
+
}): F extends FieldsMap ? EntityDefinition<F, T> : never {
|
|
464
466
|
return {
|
|
465
467
|
softDelete: false,
|
|
466
468
|
searchWeight: 1,
|
|
@@ -468,5 +470,5 @@ export function createEntity<F>(def: {
|
|
|
468
470
|
// aggregate-ids are UUID. Opt-out with `idType: "serial"` for pre-ES
|
|
469
471
|
// legacy tables (should be rare).
|
|
470
472
|
...def,
|
|
471
|
-
} as F extends FieldsMap ? EntityDefinition<F> : never; // @cast-boundary engine-payload
|
|
473
|
+
} as F extends FieldsMap ? EntityDefinition<F, T> : never; // @cast-boundary engine-payload
|
|
472
474
|
}
|
|
@@ -42,8 +42,12 @@ export function readOptionalAccessRule(value: unknown): AccessRule | undefined {
|
|
|
42
42
|
if (Array.isArray(value["roles"]) && value["roles"].every((r) => typeof r === "string")) {
|
|
43
43
|
return { roles: value["roles"] as readonly string[] };
|
|
44
44
|
}
|
|
45
|
+
const publicIntake = value["publicIntake"] === true ? { publicIntake: true as const } : {};
|
|
45
46
|
if (value["openToAll"] === true) {
|
|
46
|
-
return { openToAll: true };
|
|
47
|
+
return { openToAll: true, ...publicIntake };
|
|
48
|
+
}
|
|
49
|
+
if (isPlainObject(value["openToAll"]) && typeof value["openToAll"]["reason"] === "string") {
|
|
50
|
+
return { openToAll: { reason: value["openToAll"]["reason"] }, ...publicIntake };
|
|
47
51
|
}
|
|
48
52
|
return undefined;
|
|
49
53
|
}
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
AgentHandlerHints,
|
|
20
20
|
EntityDefinition,
|
|
21
21
|
EntityRef,
|
|
22
|
+
EscapeHatchDeclaration,
|
|
22
23
|
HandlerRef,
|
|
23
24
|
NameOrRef,
|
|
24
25
|
QueryHandlerFn,
|
|
@@ -67,6 +68,18 @@ function agentSlots(source: {
|
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
function requireInlineHandlerArgs<TSchema, THandler, TOptions extends object>(
|
|
72
|
+
kind: string,
|
|
73
|
+
schema: TSchema | undefined,
|
|
74
|
+
handler: THandler | undefined,
|
|
75
|
+
options: TOptions | undefined,
|
|
76
|
+
): { schema: TSchema; handler: THandler; options: TOptions } {
|
|
77
|
+
if (!schema || !handler || !options) {
|
|
78
|
+
throw new Error(`${kind} inline form requires schema + handler + options.access`);
|
|
79
|
+
}
|
|
80
|
+
return { schema, handler, options };
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
// Builds entity/relation/writeHandler/queryHandler — the registrar
|
|
71
84
|
// methods that create or reference entities.
|
|
72
85
|
export function buildEntityHandlerMethods<TName extends string>(
|
|
@@ -110,10 +123,11 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
110
123
|
schema?: TSchema,
|
|
111
124
|
handler?: WriteHandlerFn<z.infer<TSchema>>,
|
|
112
125
|
options?: {
|
|
113
|
-
access
|
|
126
|
+
access: AccessRule;
|
|
114
127
|
rateLimit?: RateLimitOption;
|
|
115
128
|
description?: string;
|
|
116
129
|
agent?: AgentHandlerHints;
|
|
130
|
+
escapeHatch?: EscapeHatchDeclaration;
|
|
117
131
|
},
|
|
118
132
|
): HandlerRef {
|
|
119
133
|
if (typeof nameOrDef === "object") {
|
|
@@ -130,10 +144,11 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
130
144
|
// `satisfies` does not work here (it asserts assignability, which
|
|
131
145
|
// is what fails). Explicit cast is the right tool.
|
|
132
146
|
handler: def.handler as WriteHandlerFn,
|
|
133
|
-
|
|
147
|
+
access: def.access,
|
|
134
148
|
...agentSlots(def),
|
|
135
149
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
136
150
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
151
|
+
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
137
152
|
// Forward the pipeline-build closure so boot-validators and
|
|
138
153
|
// Designer/AI tooling can inspect the step list. Absent on
|
|
139
154
|
// free-form handlers — defineWriteHandler only sets `perform`
|
|
@@ -147,15 +162,15 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
147
162
|
tryMapEntity(state, name, def.name);
|
|
148
163
|
return { name: def.name };
|
|
149
164
|
}
|
|
150
|
-
|
|
151
|
-
throw new Error("writeHandler inline form requires schema + handler");
|
|
165
|
+
const inline = requireInlineHandlerArgs("writeHandler", schema, handler, options);
|
|
152
166
|
state.writeHandlers[nameOrDef] = {
|
|
153
167
|
name: nameOrDef,
|
|
154
|
-
schema,
|
|
155
|
-
handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
|
|
156
|
-
|
|
157
|
-
...agentSlots(options
|
|
158
|
-
...(options
|
|
168
|
+
schema: inline.schema,
|
|
169
|
+
handler: inline.handler as WriteHandlerFn, // @cast-boundary engine-bridge
|
|
170
|
+
access: inline.options.access,
|
|
171
|
+
...agentSlots(inline.options),
|
|
172
|
+
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
173
|
+
...(inline.options.escapeHatch && { escapeHatch: inline.options.escapeHatch }),
|
|
159
174
|
};
|
|
160
175
|
tryMapEntity(state, name, nameOrDef);
|
|
161
176
|
return { name: nameOrDef };
|
|
@@ -165,7 +180,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
165
180
|
schema?: TSchema,
|
|
166
181
|
handler?: QueryHandlerFn<z.infer<TSchema>>,
|
|
167
182
|
options?: {
|
|
168
|
-
access
|
|
183
|
+
access: AccessRule;
|
|
169
184
|
rateLimit?: RateLimitOption;
|
|
170
185
|
outputSchema?: ZodType;
|
|
171
186
|
description?: string;
|
|
@@ -179,7 +194,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
179
194
|
schema: def.schema,
|
|
180
195
|
// @cast-boundary engine-bridge — typed Dev-API → erased internal storage
|
|
181
196
|
handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
182
|
-
|
|
197
|
+
access: def.access,
|
|
183
198
|
...agentSlots(def),
|
|
184
199
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
185
200
|
...(def.outputSchema && { outputSchema: def.outputSchema }),
|
|
@@ -193,16 +208,15 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
193
208
|
tryMapEntity(state, name, def.name);
|
|
194
209
|
return { name: def.name };
|
|
195
210
|
}
|
|
196
|
-
|
|
197
|
-
throw new Error("queryHandler inline form requires schema + handler");
|
|
211
|
+
const inline = requireInlineHandlerArgs("queryHandler", schema, handler, options);
|
|
198
212
|
state.queryHandlers[nameOrDef] = {
|
|
199
213
|
name: nameOrDef,
|
|
200
|
-
schema,
|
|
201
|
-
handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
202
|
-
|
|
203
|
-
...agentSlots(options
|
|
204
|
-
...(options
|
|
205
|
-
...(options
|
|
214
|
+
schema: inline.schema,
|
|
215
|
+
handler: inline.handler as QueryHandlerFn, // @cast-boundary engine-bridge
|
|
216
|
+
access: inline.options.access,
|
|
217
|
+
...agentSlots(inline.options),
|
|
218
|
+
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
219
|
+
...(inline.options.outputSchema && { outputSchema: inline.options.outputSchema }),
|
|
206
220
|
};
|
|
207
221
|
tryMapEntity(state, name, nameOrDef);
|
|
208
222
|
return { name: nameOrDef };
|
|
@@ -211,7 +225,7 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
211
225
|
nameOrDef: string | StreamHandlerDefinition<TName, TSchema>,
|
|
212
226
|
schema?: TSchema,
|
|
213
227
|
handler?: StreamHandlerFn<z.infer<TSchema>>,
|
|
214
|
-
options?: { access
|
|
228
|
+
options?: { access: AccessRule; rateLimit?: RateLimitOption },
|
|
215
229
|
): HandlerRef {
|
|
216
230
|
if (typeof nameOrDef === "object") {
|
|
217
231
|
const def = nameOrDef;
|
|
@@ -220,19 +234,18 @@ export function buildEntityHandlerMethods<TName extends string>(
|
|
|
220
234
|
schema: def.schema,
|
|
221
235
|
// @cast-boundary engine-bridge — typed Dev-API → erased internal storage
|
|
222
236
|
handler: def.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
223
|
-
|
|
237
|
+
access: def.access,
|
|
224
238
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
225
239
|
};
|
|
226
240
|
return { name: def.name };
|
|
227
241
|
}
|
|
228
|
-
|
|
229
|
-
throw new Error("streamHandler inline form requires schema + handler");
|
|
242
|
+
const inline = requireInlineHandlerArgs("streamHandler", schema, handler, options);
|
|
230
243
|
state.streamHandlers[nameOrDef] = {
|
|
231
244
|
name: nameOrDef,
|
|
232
|
-
schema,
|
|
233
|
-
handler: handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
234
|
-
|
|
235
|
-
...(options
|
|
245
|
+
schema: inline.schema,
|
|
246
|
+
handler: inline.handler as StreamHandlerFn, // @cast-boundary engine-bridge
|
|
247
|
+
access: inline.options.access,
|
|
248
|
+
...(inline.options.rateLimit && { rateLimit: inline.options.rateLimit }),
|
|
236
249
|
};
|
|
237
250
|
return { name: nameOrDef };
|
|
238
251
|
},
|
package/src/engine/index.ts
CHANGED
|
@@ -358,6 +358,7 @@ export type {
|
|
|
358
358
|
EntityListScreenDefinition,
|
|
359
359
|
EntityRef,
|
|
360
360
|
EntityRelations,
|
|
361
|
+
EscapeHatchDeclaration,
|
|
361
362
|
EventDef,
|
|
362
363
|
FeatureDefinition,
|
|
363
364
|
FeatureRegistrar,
|
|
@@ -414,6 +415,8 @@ export type {
|
|
|
414
415
|
NotifyPriority,
|
|
415
416
|
NumberFieldDef,
|
|
416
417
|
OnDeleteStrategy,
|
|
418
|
+
OpenToAllAccessRule,
|
|
419
|
+
OpenToAllDeclaration,
|
|
417
420
|
ParentRefDef,
|
|
418
421
|
PersonalAnnotations,
|
|
419
422
|
PersonalAnnotationsLongText,
|
|
@@ -489,6 +492,7 @@ export {
|
|
|
489
492
|
DEFAULT_LOCALES,
|
|
490
493
|
HookPhases,
|
|
491
494
|
isAgentVisibleScreen,
|
|
495
|
+
isOpenToAllGranted,
|
|
492
496
|
resolveAgentExposure,
|
|
493
497
|
} from "./types";
|
|
494
498
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
|
|
@@ -156,6 +156,7 @@ export type {
|
|
|
156
156
|
DeclarativeEventMigration,
|
|
157
157
|
DispatchWriteRef,
|
|
158
158
|
EntityRef,
|
|
159
|
+
EscapeHatchDeclaration,
|
|
159
160
|
EventDef,
|
|
160
161
|
EventMigrationDef,
|
|
161
162
|
EventPiiFields,
|
|
@@ -172,6 +173,8 @@ export type {
|
|
|
172
173
|
NotifyFn,
|
|
173
174
|
NotifyOptions,
|
|
174
175
|
NotifyPriority,
|
|
176
|
+
OpenToAllAccessRule,
|
|
177
|
+
OpenToAllDeclaration,
|
|
175
178
|
QualifiedEventName,
|
|
176
179
|
QueryEvent,
|
|
177
180
|
QueryHandlerDef,
|
|
@@ -189,6 +192,7 @@ export type {
|
|
|
189
192
|
WriteHandlerFn,
|
|
190
193
|
WriteResult,
|
|
191
194
|
} from "@cosmicdrift/kumiko-types/handlers";
|
|
195
|
+
export { isOpenToAllGranted } from "@cosmicdrift/kumiko-types/handlers";
|
|
192
196
|
export type {
|
|
193
197
|
DeleteContext,
|
|
194
198
|
EntityHookMap,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// fw#2855 — a write handler's `escapeHatch` gates writes through ctx.db.global(table):
|
|
2
|
+
// WITH it the write succeeds, WITHOUT it the same handler rejects with access_denied.
|
|
3
|
+
// Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
8
|
+
import { defineFeature } from "../../engine";
|
|
9
|
+
import { createTestUser, setupTestStack, type TestStack } from "../../stack";
|
|
10
|
+
|
|
11
|
+
const globalStoreTable = defineUnmanagedTable({
|
|
12
|
+
tableName: "store_fw2855_escape_hatch_items",
|
|
13
|
+
tenancy: "global",
|
|
14
|
+
columns: [
|
|
15
|
+
{
|
|
16
|
+
name: "id",
|
|
17
|
+
pgType: "uuid",
|
|
18
|
+
notNull: true,
|
|
19
|
+
primaryKey: true,
|
|
20
|
+
defaultSql: "gen_random_uuid()",
|
|
21
|
+
},
|
|
22
|
+
{ name: "tenant_id", pgType: "uuid", notNull: true },
|
|
23
|
+
{ name: "note", pgType: "text", notNull: true },
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const escapeHatchFeature = defineFeature("escape-hatch-probe", (r) => {
|
|
28
|
+
r.storeTable(globalStoreTable, {
|
|
29
|
+
reason: "fw#2855 integration test — cross-tenant store table for db.global()",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
r.writeHandler({
|
|
33
|
+
name: "write-with-hatch",
|
|
34
|
+
schema: z.object({ note: z.string() }),
|
|
35
|
+
access: { roles: ["User"] },
|
|
36
|
+
escapeHatch: { reason: "fw#2855 integration test — declared cross-tenant write" },
|
|
37
|
+
handler: async (event, ctx) => {
|
|
38
|
+
const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
|
|
39
|
+
tenantId: event.user.tenantId,
|
|
40
|
+
note: event.payload.note,
|
|
41
|
+
});
|
|
42
|
+
return { isSuccess: true as const, data: row };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
r.writeHandler({
|
|
47
|
+
name: "write-without-hatch",
|
|
48
|
+
schema: z.object({ note: z.string() }),
|
|
49
|
+
access: { roles: ["User"] },
|
|
50
|
+
handler: async (event, ctx) => {
|
|
51
|
+
const row = await ctx.db.global(globalStoreTable).insertOne<{ id: string }>({
|
|
52
|
+
tenantId: event.user.tenantId,
|
|
53
|
+
note: event.payload.note,
|
|
54
|
+
});
|
|
55
|
+
return { isSuccess: true as const, data: row };
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let stack: TestStack;
|
|
61
|
+
const user = createTestUser({
|
|
62
|
+
id: 91,
|
|
63
|
+
tenantId: "11111111-0000-4000-8000-000000000091",
|
|
64
|
+
roles: ["User"],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
stack = await setupTestStack({ features: [escapeHatchFeature] });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await stack.cleanup();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("write handler escapeHatch gates ctx.db.global() writes", () => {
|
|
76
|
+
test("WITH escapeHatch: the write succeeds", async () => {
|
|
77
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
78
|
+
"escape-hatch-probe:write:write-with-hatch",
|
|
79
|
+
{ note: "hi" },
|
|
80
|
+
user,
|
|
81
|
+
);
|
|
82
|
+
expect(result.id).toBeDefined();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("WITHOUT escapeHatch: the same write shape fails with access_denied", async () => {
|
|
86
|
+
const err = await stack.http.writeErr(
|
|
87
|
+
"escape-hatch-probe:write:write-without-hatch",
|
|
88
|
+
{ note: "hi" },
|
|
89
|
+
user,
|
|
90
|
+
);
|
|
91
|
+
expect(err.code).toBe("access_denied");
|
|
92
|
+
});
|
|
93
|
+
});
|