@cosmicdrift/kumiko-framework 0.337.1 → 0.339.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/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +9 -0
- package/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +56 -12
- package/dist/api/routes.d.ts +2 -1
- package/dist/api/routes.js +29 -6
- package/dist/api/server.js +20 -2
- package/dist/bun-db/query.js +8 -2
- package/dist/compliance/sub-processors.js +10 -1
- package/dist/crypto/event-pii.js +13 -4
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/pii-ciphertext-format.d.ts +4 -0
- package/dist/crypto/pii-ciphertext-format.js +11 -0
- package/dist/crypto/pii-field-encryption.d.ts +4 -2
- package/dist/crypto/pii-field-encryption.js +50 -21
- package/dist/crypto/system-event-pii.js +2 -2
- package/dist/db/blind-index-cleanup.d.ts +1 -0
- package/dist/db/blind-index-cleanup.js +11 -0
- package/dist/db/event-store-executor-read.js +18 -3
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/queries/backfill-pii.js +4 -0
- package/dist/db/tenant-db.js +25 -13
- package/dist/engine/boot-validator/access-declarations.js +13 -7
- package/dist/engine/boot-validator/entity-handler.d.ts +2 -0
- package/dist/engine/boot-validator/entity-handler.js +45 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/pii-retention.js +41 -1
- package/dist/engine/config-helpers.d.ts +2 -1
- package/dist/engine/config-helpers.js +1 -0
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/extensions/escape-hatch-usage.js +3 -1
- package/dist/engine/extensions/user-data.d.ts +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/hooks.js +7 -1
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +40 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +2 -0
- package/dist/engine/feature-config-events-jobs.js +5 -1
- package/dist/engine/feature-entity-handlers.js +1 -0
- package/dist/engine/feature-manifest.d.ts +8 -0
- package/dist/engine/feature-manifest.js +9 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/registry-ingest.d.ts +1 -0
- package/dist/engine/registry-ingest.js +9 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/steps/retry.js +0 -1
- package/dist/engine/steps/wait-for-event.js +0 -1
- package/dist/engine/steps/wait.js +0 -4
- package/dist/engine/steps/workflow-system-events.d.ts +2 -0
- package/dist/engine/steps/workflow-system-events.js +89 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/jobs/job-runner.js +2 -1
- package/dist/pipeline/append-event-core.d.ts +3 -1
- package/dist/pipeline/append-event-core.js +8 -4
- package/dist/pipeline/dispatch-shared.js +37 -7
- package/dist/pipeline/system-identity-switch.js +10 -7
- package/package.json +4 -4
- package/src/changes.json +102 -0
|
@@ -3,4 +3,5 @@ import type { DbRunner } from "./connection.js";
|
|
|
3
3
|
export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
|
|
4
4
|
export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
|
|
5
5
|
export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
|
|
6
|
+
export declare function recordEventsOwnedExclusivelyByTenant(db: DbRunner, aggregateType: string, aggregateId: string, tenantId: string): Promise<boolean>;
|
|
6
7
|
export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
|
|
@@ -131,6 +131,17 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
|
|
|
131
131
|
const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
|
|
132
132
|
return rows.length > 0;
|
|
133
133
|
}
|
|
134
|
+
// Provenance fallback for record subjects whose projection row is gone (hard
|
|
135
|
+
// delete) or that have no registered entity at all (custom aggregates): the
|
|
136
|
+
// event stream still names the owning tenant. The unique index is per tenant
|
|
137
|
+
// and the subject key is global, so another tenant could open a stream under
|
|
138
|
+
// the same aggregate id; any foreign event voids the proof.
|
|
139
|
+
export async function recordEventsOwnedExclusivelyByTenant(db, aggregateType, aggregateId, tenantId) {
|
|
140
|
+
const rows = await executeRawQueryRead(db, `SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id = $3::uuid
|
|
141
|
+
AND NOT EXISTS (SELECT 1 FROM "kumiko_events" WHERE aggregate_type = $1 AND aggregate_id = $2::uuid AND tenant_id <> $3::uuid)
|
|
142
|
+
LIMIT 1`, [aggregateType, aggregateId, tenantId]);
|
|
143
|
+
return rows.length > 0;
|
|
144
|
+
}
|
|
134
145
|
// Owning tenant of a record subject's row, for callers that must evaluate
|
|
135
146
|
// tenant-scoped policy for the row's real owner (not the acting user's tenant).
|
|
136
147
|
export async function recordRowOwningTenantId(db, features, entityName, recordId) {
|
|
@@ -258,7 +258,15 @@ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
|
258
258
|
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
259
259
|
// (fw#2660's hard constraint). Mirrors the main table's own tenant filter
|
|
260
260
|
// (see the `table["tenantId"]` check further down in list()).
|
|
261
|
-
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db) {
|
|
261
|
+
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
262
|
+
// The label must not leak through match counts: a viewer who cannot read the
|
|
263
|
+
// target row or its label field would otherwise probe names by searching.
|
|
264
|
+
const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
|
|
265
|
+
if (labelAccess.kind !== "pass")
|
|
266
|
+
return undefined;
|
|
267
|
+
const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
|
|
268
|
+
if (ownership.kind === "empty")
|
|
269
|
+
return undefined;
|
|
262
270
|
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
263
271
|
const subParams = [`%${escapeLikePattern(searchTerm)}%`];
|
|
264
272
|
let tenantClause = "";
|
|
@@ -266,12 +274,19 @@ async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEnt
|
|
|
266
274
|
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
267
275
|
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
268
276
|
}
|
|
277
|
+
let ownershipClause = "";
|
|
278
|
+
if (ownership.kind === "sql") {
|
|
279
|
+
const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, subParams.length);
|
|
280
|
+
ownershipClause = ` AND ${shifted.sqlText}`;
|
|
281
|
+
for (const p of shifted.params)
|
|
282
|
+
subParams.push(p);
|
|
283
|
+
}
|
|
269
284
|
const deletedClause = isSoftDeletedTargetHidden(targetEntity, targetTable)
|
|
270
285
|
? ` AND "${physicalColumnName(targetTable, "isDeleted")}" = FALSE`
|
|
271
286
|
: "";
|
|
272
287
|
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
273
288
|
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
274
|
-
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${deletedClause} ` +
|
|
289
|
+
const sql = `SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause}${ownershipClause}${deletedClause} ` +
|
|
275
290
|
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
276
291
|
const rows = await executeRawQueryRead(tenantDbRunner(db), sql, subParams);
|
|
277
292
|
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
@@ -328,7 +343,7 @@ async function resolveReferenceMatches(descriptor, searchTerm, resolveEntity, db
|
|
|
328
343
|
const targetTable = referenceTargetTable(descriptor.targetEntityName, targetEntity);
|
|
329
344
|
const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
|
|
330
345
|
if (!isSensitiveLabelField(targetEntity, descriptor.labelField)) {
|
|
331
|
-
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, db);
|
|
346
|
+
return resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db);
|
|
332
347
|
}
|
|
333
348
|
if (!hasSearchablePlaintext(targetEntity, descriptor.labelField)) {
|
|
334
349
|
// skip: no plaintext anywhere to match — boot rejects this normally,
|
package/dist/db/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
package/dist/db/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
|
@@ -300,6 +300,10 @@ export async function backfillEventPiiEncryptionBatch(db, registry, options = {}
|
|
|
300
300
|
const value = section[field];
|
|
301
301
|
if (value === null || value === undefined)
|
|
302
302
|
continue;
|
|
303
|
+
// jsonb PII is not retro-encrypted (consumer wave); a legacy string
|
|
304
|
+
// value here would otherwise get the v2 text format into a jsonb column.
|
|
305
|
+
if (target.entity.fields[field]?.type === "jsonb")
|
|
306
|
+
continue;
|
|
303
307
|
if (typeof value !== "string")
|
|
304
308
|
continue;
|
|
305
309
|
// Already-ciphertext/sentinel fields must never enter owner
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -11,7 +11,7 @@ const declaredUnsafeRawRunners = new WeakMap();
|
|
|
11
11
|
// TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
|
|
12
12
|
// (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
|
|
13
13
|
const personalDataGates = new WeakMap();
|
|
14
|
-
// Lets createTenantDb(ctx.db.unsafeRaw(
|
|
14
|
+
// Lets createTenantDb(ctx.db.unsafeRaw(), ...) inherit the gate. Keyed by a
|
|
15
15
|
// per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
|
|
16
16
|
const runnerPersonalDataGates = new WeakMap();
|
|
17
17
|
function gatedRunner(runner, gate) {
|
|
@@ -96,9 +96,15 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
96
96
|
}
|
|
97
97
|
return dbOutsideTransaction;
|
|
98
98
|
}
|
|
99
|
-
function grantedUnsafeRawRunner(reason) {
|
|
99
|
+
function grantedUnsafeRawRunner(reason, forwardedDeclaredStep) {
|
|
100
100
|
if (gate?.kind === "source-tenant-db") {
|
|
101
|
-
|
|
101
|
+
const sourceRunner = declaredUnsafeRawRunners.get(db);
|
|
102
|
+
if (!sourceRunner) {
|
|
103
|
+
throw new InternalError({
|
|
104
|
+
message: "createSystemDbView received a TenantDb with no declared unsafeRaw runner bound.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return sourceRunner(forwardedDeclaredStep ? reason : undefined);
|
|
102
108
|
}
|
|
103
109
|
if (reason.trim().length === 0) {
|
|
104
110
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
@@ -109,7 +115,8 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
109
115
|
`${gate.deniedCallerLabel} to allow unsafeRaw.`,
|
|
110
116
|
});
|
|
111
117
|
}
|
|
112
|
-
|
|
118
|
+
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
119
|
+
report("unsafe-raw", forwardedDeclaredStep ? reason : (gate?.grant?.reason ?? reason));
|
|
113
120
|
const runner = tenantDbRunner(db);
|
|
114
121
|
const personalDataGate = personalDataGates.get(db);
|
|
115
122
|
return personalDataGate ? gatedRunner(runner, personalDataGate) : runner;
|
|
@@ -143,7 +150,7 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
143
150
|
report("acknowledge-cross-tenant", reason);
|
|
144
151
|
return db;
|
|
145
152
|
},
|
|
146
|
-
unsafeRaw: grantedUnsafeRawRunner,
|
|
153
|
+
unsafeRaw: (reason) => grantedUnsafeRawRunner(reason, false),
|
|
147
154
|
outsideTransaction: {
|
|
148
155
|
assertTenantMatch(tenantId) {
|
|
149
156
|
if (tenantId !== db.tenantId) {
|
|
@@ -163,7 +170,14 @@ function buildUncheckedSystemDb(db, dbOutsideTransaction, report, gate) {
|
|
|
163
170
|
},
|
|
164
171
|
},
|
|
165
172
|
};
|
|
166
|
-
declaredUnsafeRawRunners.set(uncheckedSystemDb,
|
|
173
|
+
declaredUnsafeRawRunners.set(uncheckedSystemDb, (declaredStepReason) => {
|
|
174
|
+
if (declaredStepReason === undefined) {
|
|
175
|
+
throw new InternalError({
|
|
176
|
+
message: "A declared unsafeRaw runner on a system db requires the step reason.",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return grantedUnsafeRawRunner(declaredStepReason, true);
|
|
180
|
+
});
|
|
167
181
|
if (gate?.kind !== "source-tenant-db") {
|
|
168
182
|
systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) => buildUncheckedSystemDb(withUnsafeRawGrant(db, grant), dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant), report, { kind: "hook-grant", grant, deniedCallerLabel }));
|
|
169
183
|
}
|
|
@@ -419,28 +433,26 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
419
433
|
// @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
420
434
|
};
|
|
421
435
|
}
|
|
422
|
-
function grantedUnsafeRawRunner(
|
|
423
|
-
if (reason.trim().length === 0) {
|
|
424
|
-
throw new Error("unsafeRaw requires a non-empty reason");
|
|
425
|
-
}
|
|
436
|
+
function grantedUnsafeRawRunner(declaredStepReason) {
|
|
426
437
|
// Ahead of the grant check: a declared escapeHatch must not buy a raw runner here either.
|
|
427
438
|
if (grants?.memberReadOnly) {
|
|
428
439
|
throw memberResolutionReadOnlyDenied();
|
|
429
440
|
}
|
|
430
441
|
if (!hasGrant(grants?.unsafeRaw)) {
|
|
431
442
|
throw new AccessDeniedError({
|
|
432
|
-
message: 'ctx.db.unsafeRaw(
|
|
443
|
+
message: 'ctx.db.unsafeRaw(): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
433
444
|
"the handler or hook to allow unsafeRaw.",
|
|
434
445
|
});
|
|
435
446
|
}
|
|
436
|
-
|
|
447
|
+
// Engine-forwarded steps carry their own declared reason; otherwise the grant's reason is audited.
|
|
448
|
+
report("unsafe-raw", declaredStepReason ?? grants?.unsafeRaw?.reason ?? "");
|
|
437
449
|
return personalDataGate ? gatedRunner(db, personalDataGate) : db;
|
|
438
450
|
}
|
|
439
451
|
const tenantDb = {
|
|
440
452
|
tenantId,
|
|
441
453
|
mode,
|
|
442
454
|
global: globalTable,
|
|
443
|
-
unsafeRaw: grantedUnsafeRawRunner,
|
|
455
|
+
unsafeRaw: () => grantedUnsafeRawRunner(),
|
|
444
456
|
selectMany(table, where, options) {
|
|
445
457
|
const filter = readWhere(table, where);
|
|
446
458
|
return withDbSpan("select", table, async () => bunSelectMany(db, table, filter, options));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { accessAllowsAnonymous, declaredPersonalData, personalFieldNames, } from "../personal-data-fields.js";
|
|
2
3
|
import { ANONYMOUS_ROLE } from "../system-user.js";
|
|
3
4
|
import { collectZodObjectKeys } from "./zod-shape.js";
|
|
@@ -48,13 +49,18 @@ function validateOpenToAllReason(feature, kind, handlerName, access) {
|
|
|
48
49
|
"authenticated user may call this handler. `{ openToAll: true }` is no longer accepted.");
|
|
49
50
|
}
|
|
50
51
|
function validateEscapeHatchReason(feature, kind, handlerName, escapeHatch) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
if (escapeHatch) {
|
|
53
|
+
const grantsProblem = escapeHatchGrantsProblem(escapeHatch);
|
|
54
|
+
if (grantsProblem !== undefined) {
|
|
55
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares an invalid escapeHatch — ${grantsProblem}`);
|
|
56
|
+
}
|
|
57
|
+
if (escapeHatch.reason.trim().length === 0) {
|
|
58
|
+
throw new Error(`[Feature ${feature.name}] ${kind} handler "${handlerName}" declares ` +
|
|
59
|
+
`{ escapeHatch: { reason: "" } } — the reason must be a non-empty string ` +
|
|
60
|
+
"explaining why this handler needs db.global() write access, a SYSTEM identity switch " +
|
|
61
|
+
"or a cross-tenant ctx.queryProjection({ unsafeAllTenants: true }) read.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
58
64
|
}
|
|
59
65
|
function validatePersonalDataOnlyOnWrite(feature, kind, handlerName, access) {
|
|
60
66
|
// skip: write handlers may declare personalData, or it wasn't declared here
|
|
@@ -6,6 +6,7 @@ export declare const PII_USER_REFERENCE_NAME_HINTS: ReadonlySet<string>;
|
|
|
6
6
|
/** Extensions with preSave must target an entity that has mapped write handlers. */
|
|
7
7
|
export declare function validateExtensionPreSaveWiring(features: readonly FeatureDefinition[]): void;
|
|
8
8
|
export declare function validateHandlerAccess(feature: FeatureDefinition): void;
|
|
9
|
+
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
9
10
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
10
11
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
11
12
|
export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
@@ -22,3 +23,4 @@ export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
|
22
23
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
23
24
|
export declare function validateExtendSchemaCollisions(feature: FeatureDefinition): void;
|
|
24
25
|
export declare function validateDerivedFieldCollisions(feature: FeatureDefinition): void;
|
|
26
|
+
export declare function validateSearchableFieldReadAccess(feature: FeatureDefinition): void;
|
|
@@ -134,6 +134,25 @@ export function validateHandlerAccess(feature) {
|
|
|
134
134
|
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
138
|
+
validateTenantlessAnonymous(feature.name, name, handler);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
142
|
+
// skip: handler does not opt into tenantless anonymous calls
|
|
143
|
+
if (!handler.tenantlessAnonymous)
|
|
144
|
+
return;
|
|
145
|
+
const where = `write handler "${featureName}:write:${handlerName}" declares tenantlessAnonymous`;
|
|
146
|
+
const { access } = handler;
|
|
147
|
+
const isAnonymousOnly = "roles" in access && access.roles.length === 1 && access.roles[0] === "anonymous";
|
|
148
|
+
if (!isAnonymousOnly) {
|
|
149
|
+
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
150
|
+
`carries no principal beyond the anonymous visitor.`);
|
|
151
|
+
}
|
|
152
|
+
if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
|
|
153
|
+
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
154
|
+
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
155
|
+
}
|
|
137
156
|
}
|
|
138
157
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
139
158
|
// skip: rate limit isn't disabled, so no reason is required
|
|
@@ -784,3 +803,29 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
784
803
|
}
|
|
785
804
|
}
|
|
786
805
|
}
|
|
806
|
+
// --- Searchable fields with restricted read access ---
|
|
807
|
+
// The search index is tenant-wide, not per-role: `search` matches every
|
|
808
|
+
// searchable field and `filterReadFields` only strips the response afterwards.
|
|
809
|
+
// A role that cannot read the field could still guess its value from match
|
|
810
|
+
// counts, so the combination is rejected until the index can filter per role.
|
|
811
|
+
export function validateSearchableFieldReadAccess(feature) {
|
|
812
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
813
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
814
|
+
if (field.type !== "text")
|
|
815
|
+
continue;
|
|
816
|
+
if (!field.searchable)
|
|
817
|
+
continue;
|
|
818
|
+
const readAccess = field.access?.read;
|
|
819
|
+
if (readAccess === undefined)
|
|
820
|
+
continue;
|
|
821
|
+
const isUnrestricted = Array.isArray(readAccess)
|
|
822
|
+
? readAccess.length === 0
|
|
823
|
+
: Object.keys(readAccess).length === 0;
|
|
824
|
+
if (isUnrestricted)
|
|
825
|
+
continue;
|
|
826
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be searchable with a restricted ` +
|
|
827
|
+
"access.read — the search index is not filtered per role, so search matches would leak " +
|
|
828
|
+
'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
@@ -9,7 +9,7 @@ import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.
|
|
|
9
9
|
import { validateFeatureBootChecks } from "./boot-check.js";
|
|
10
10
|
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
11
11
|
import { collectDetailForScreens } from "./detail-screens.js";
|
|
12
|
-
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
|
|
12
|
+
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateSearchableFieldReadAccess, validateTransitions, } from "./entity-handler.js";
|
|
13
13
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
14
14
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
15
15
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
@@ -118,6 +118,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
118
118
|
validateCircularDeps(feature.name, featureMap);
|
|
119
119
|
if (validateEncryptedFields(feature))
|
|
120
120
|
hasEncryptedFields = true;
|
|
121
|
+
validateSearchableFieldReadAccess(feature);
|
|
121
122
|
if (validateFileFields(feature))
|
|
122
123
|
hasFileFields = true;
|
|
123
124
|
validatePiiAndRetention(feature);
|
|
@@ -12,6 +12,12 @@ const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
|
|
|
12
12
|
// macht nur den Sanity-Check damit Tippfehler ("30days") frueh sichtbar
|
|
13
13
|
// werden statt erst beim ersten Cleanup-Run.
|
|
14
14
|
const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
15
|
+
const FILE_FIELD_TYPES = new Set([
|
|
16
|
+
"file",
|
|
17
|
+
"image",
|
|
18
|
+
"files",
|
|
19
|
+
"images",
|
|
20
|
+
]);
|
|
15
21
|
// Excludes subjectRef: its `personal: "ref"` union member structurally
|
|
16
22
|
// forbids `anonymize` (packages/types/src/fields.ts) — #2336.
|
|
17
23
|
function hasAnonymizableSubjectField(annot) {
|
|
@@ -65,6 +71,33 @@ function validateWriteOnlyField(featureName, entityName, fieldName, field) {
|
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
}
|
|
74
|
+
// A personal jsonb field is stored as a ciphertext JSON string scalar, so
|
|
75
|
+
// anything that reads inside the column (index, filter, sort, search, jsonb
|
|
76
|
+
// operators/merges such as the custom-fields column) sees only ciphertext.
|
|
77
|
+
const JSONB_PERSONAL_FORBIDDEN_FLAGS = [
|
|
78
|
+
"sortable",
|
|
79
|
+
"filterable",
|
|
80
|
+
"searchable",
|
|
81
|
+
"lookupable",
|
|
82
|
+
];
|
|
83
|
+
const CUSTOM_FIELDS_COLUMN = "customFields";
|
|
84
|
+
function validatePersonalJsonbField(featureName, entityName, entity, fieldName, field) {
|
|
85
|
+
const where = `[Feature ${featureName}] Field "${fieldName}" on entity "${entityName}"`;
|
|
86
|
+
const reason = "a personal jsonb field is stored as a ciphertext string, so nothing inside it can be indexed, filtered, sorted, searched or merged";
|
|
87
|
+
const flags = field; // @cast-boundary schema-walk
|
|
88
|
+
for (const flag of JSONB_PERSONAL_FORBIDDEN_FLAGS) {
|
|
89
|
+
if (flags[flag] !== undefined && flags[flag] !== false) {
|
|
90
|
+
throw new Error(`${where} combines a personal annotation on a jsonb field with { ${flag} } — ${reason}.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const index = entity.indexes?.find((def) => def.columns.includes(fieldName));
|
|
94
|
+
if (index) {
|
|
95
|
+
throw new Error(`${where} is a personal jsonb field used in an entity index — ${reason}. Drop the index or the personal annotation.`);
|
|
96
|
+
}
|
|
97
|
+
if (fieldName === CUSTOM_FIELDS_COLUMN) {
|
|
98
|
+
throw new Error(`${where} is the custom-fields jsonb column — its values are merged and searched per key, which ciphertext cannot support. Drop the personal annotation.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
68
101
|
// --- PII / Subject-Key Annotations + Retention validation ---
|
|
69
102
|
//
|
|
70
103
|
// Drei Klassen von Checks:
|
|
@@ -147,6 +180,9 @@ export function validatePiiAndRetention(feature) {
|
|
|
147
180
|
throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines { sensitive: true } with { searchable: true } — sensitive means ciphertext at rest, never searchable, stripped from event echoes — authorized readers can still read it back (passwords, tokens, tax IDs). Subject-annotated identity fields may be searchable (#1610); sensitive fields may not. To never return the value at all, add { writeOnly: true }.`);
|
|
148
181
|
}
|
|
149
182
|
}
|
|
183
|
+
if (field.type === "jsonb" && annotCount > 0) {
|
|
184
|
+
validatePersonalJsonbField(feature.name, entityName, entity, fieldName, field);
|
|
185
|
+
}
|
|
150
186
|
if (annot.userOwned) {
|
|
151
187
|
const ownerName = annot.userOwned.ownerField;
|
|
152
188
|
if (!ownerName || typeof ownerName !== "string") {
|
|
@@ -184,7 +220,11 @@ export function validatePiiAndRetention(feature) {
|
|
|
184
220
|
const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
|
|
185
221
|
if (noAnnotation) {
|
|
186
222
|
const lower = fieldName.toLowerCase();
|
|
187
|
-
if (
|
|
223
|
+
if (FILE_FIELD_TYPES.has(field.type)) {
|
|
224
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
225
|
+
console.warn(`[kumiko:boot] [Feature ${feature.name}] File field "${fieldName}" on entity "${entityName}" has no personal annotation. A forget with strategy "delete" then only severs the uploader link and keeps the binary. If the file holds personal data, mark it { personal: "self" } or { personal: { of: "<ownerField>" } }. If it is business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
226
|
+
}
|
|
227
|
+
else if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
188
228
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
189
229
|
console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a PII-typical name but no personal annotation. If this is PII, mark it { personal: "self", find: ... }. If business data, set { personal: false, reason: "is_business_data" } to silence.`);
|
|
190
230
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
|
|
1
|
+
import type { ConfigBacking, ConfigBounds, ConfigComputedFn, ConfigKeyDefinition, ConfigKeyType, ConfigMask, ConfigSeedDef, ConfigValidateFn, ConfigValue, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, OptionsQueryPayload } from "./types/index.js";
|
|
2
2
|
export declare function isEncryptedAtRest(def: Pick<ConfigKeyDefinition, "encrypted" | "backing">): boolean;
|
|
3
3
|
export declare const access: {
|
|
4
4
|
readonly all: readonly string[];
|
|
@@ -26,6 +26,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
26
26
|
flags?: string;
|
|
27
27
|
} : never;
|
|
28
28
|
computed?: ConfigComputedFn<T>;
|
|
29
|
+
validate?: ConfigValidateFn<T>;
|
|
29
30
|
allowPerRequest?: T extends "text" ? never : boolean;
|
|
30
31
|
required?: boolean;
|
|
31
32
|
env?: string;
|
|
@@ -60,6 +60,7 @@ function createConfigKey(scope, type, opts = {}) {
|
|
|
60
60
|
bounds: opts.bounds, // @cast-boundary schema-walk
|
|
61
61
|
...(opts.pattern ? { pattern: opts.pattern } : {}),
|
|
62
62
|
computed: opts.computed,
|
|
63
|
+
validate: opts.validate,
|
|
63
64
|
...(opts.allowPerRequest === true ? { allowPerRequest: true } : {}),
|
|
64
65
|
...(opts.required === true ? { required: true } : {}),
|
|
65
66
|
...(opts.env ? { env: opts.env } : {}),
|
|
@@ -51,6 +51,7 @@ export function defineWriteHandler(def,
|
|
|
51
51
|
}),
|
|
52
52
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
53
53
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
54
|
+
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
54
55
|
};
|
|
55
56
|
if ("perform" in def && def.perform !== undefined) {
|
|
56
57
|
const performDef = def.perform;
|
|
@@ -7,6 +7,8 @@ export function extensionUsageEscapeHatchReason(usage) {
|
|
|
7
7
|
const escapeHatch = usage.options?.["escapeHatch"];
|
|
8
8
|
if (typeof escapeHatch !== "object" || escapeHatch === null)
|
|
9
9
|
return undefined;
|
|
10
|
-
const reason = escapeHatch
|
|
10
|
+
const { reason, grants } = escapeHatch;
|
|
11
|
+
if (Array.isArray(grants) && !grants.includes("unsafeRaw"))
|
|
12
|
+
return undefined;
|
|
11
13
|
return typeof reason === "string" && reason.trim().length > 0 ? reason : undefined;
|
|
12
14
|
}
|
|
@@ -30,7 +30,7 @@ export type TenantUserModel = "single-user" | "multi-user";
|
|
|
30
30
|
* fw#2914 — `db` is a tenant-filtered `TenantDb`, bound to `tenantId`
|
|
31
31
|
* (or the per-user sub-tx in the forget path). Unfiltered access needs
|
|
32
32
|
* `escapeHatch: { reason }` on the `r.useExtension(...)` registration,
|
|
33
|
-
* then `ctx.db.unsafeRaw(
|
|
33
|
+
* then `ctx.db.unsafeRaw()`.
|
|
34
34
|
*/
|
|
35
35
|
/**
|
|
36
36
|
* Minimal storage surface a file-aware forget hook needs to erase binaries.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isEscapeHatchGrant } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { SyntaxKind } from "ts-morph";
|
|
2
3
|
import { sourceLocationFromNode } from "../source-location.js";
|
|
3
4
|
import { containsRawRefSentinel, fail, findFunctionLiteral, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, } from "./shared.js";
|
|
@@ -75,7 +76,12 @@ export function readOptionalEscapeHatch(value) {
|
|
|
75
76
|
return undefined;
|
|
76
77
|
if (typeof value["reason"] !== "string")
|
|
77
78
|
return undefined;
|
|
78
|
-
|
|
79
|
+
const grants = value["grants"];
|
|
80
|
+
if (grants === undefined)
|
|
81
|
+
return { reason: value["reason"] };
|
|
82
|
+
if (!Array.isArray(grants) || !grants.every(isEscapeHatchGrant))
|
|
83
|
+
return undefined;
|
|
84
|
+
return { reason: value["reason"], grants };
|
|
79
85
|
}
|
|
80
86
|
// Reads the `escapeHatch` sub-property node first, not via readDataLiteralNode
|
|
81
87
|
// on the whole object: a sibling property like `handler` (a closure) isn't
|
|
@@ -197,6 +197,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
197
197
|
}, z.core.$strict>]>>;
|
|
198
198
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
199
199
|
reason: z.ZodString;
|
|
200
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
201
|
+
globalWrites: "globalWrites";
|
|
202
|
+
systemIdentity: "systemIdentity";
|
|
203
|
+
unsafeRaw: "unsafeRaw";
|
|
204
|
+
}>>>;
|
|
200
205
|
}, z.core.$strict>, z.ZodObject<{
|
|
201
206
|
__raw: z.ZodString;
|
|
202
207
|
}, z.core.$strict>]>>;
|
|
@@ -249,6 +254,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
249
254
|
}, z.core.$strict>]>>;
|
|
250
255
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
251
256
|
reason: z.ZodString;
|
|
257
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
258
|
+
globalWrites: "globalWrites";
|
|
259
|
+
systemIdentity: "systemIdentity";
|
|
260
|
+
unsafeRaw: "unsafeRaw";
|
|
261
|
+
}>>>;
|
|
252
262
|
}, z.core.$strict>, z.ZodObject<{
|
|
253
263
|
__raw: z.ZodString;
|
|
254
264
|
}, z.core.$strict>]>>;
|
|
@@ -290,6 +300,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
290
300
|
}, z.core.$strict>]>>;
|
|
291
301
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
292
302
|
reason: z.ZodString;
|
|
303
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
304
|
+
globalWrites: "globalWrites";
|
|
305
|
+
systemIdentity: "systemIdentity";
|
|
306
|
+
unsafeRaw: "unsafeRaw";
|
|
307
|
+
}>>>;
|
|
293
308
|
}, z.core.$strict>, z.ZodObject<{
|
|
294
309
|
__raw: z.ZodString;
|
|
295
310
|
}, z.core.$strict>]>>;
|
|
@@ -321,6 +336,11 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
321
336
|
}>>;
|
|
322
337
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
323
338
|
reason: z.ZodString;
|
|
339
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
340
|
+
globalWrites: "globalWrites";
|
|
341
|
+
systemIdentity: "systemIdentity";
|
|
342
|
+
unsafeRaw: "unsafeRaw";
|
|
343
|
+
}>>>;
|
|
324
344
|
}, z.core.$strict>, z.ZodObject<{
|
|
325
345
|
__raw: z.ZodString;
|
|
326
346
|
}, z.core.$strict>]>>;
|
|
@@ -689,6 +709,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
689
709
|
}, z.core.$strict>]>>;
|
|
690
710
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
691
711
|
reason: z.ZodString;
|
|
712
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
713
|
+
globalWrites: "globalWrites";
|
|
714
|
+
systemIdentity: "systemIdentity";
|
|
715
|
+
unsafeRaw: "unsafeRaw";
|
|
716
|
+
}>>>;
|
|
692
717
|
}, z.core.$strict>, z.ZodObject<{
|
|
693
718
|
__raw: z.ZodString;
|
|
694
719
|
}, z.core.$strict>]>>;
|
|
@@ -740,6 +765,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
740
765
|
}, z.core.$strict>]>>;
|
|
741
766
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
742
767
|
reason: z.ZodString;
|
|
768
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
769
|
+
globalWrites: "globalWrites";
|
|
770
|
+
systemIdentity: "systemIdentity";
|
|
771
|
+
unsafeRaw: "unsafeRaw";
|
|
772
|
+
}>>>;
|
|
743
773
|
}, z.core.$strict>, z.ZodObject<{
|
|
744
774
|
__raw: z.ZodString;
|
|
745
775
|
}, z.core.$strict>]>>;
|
|
@@ -780,6 +810,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
780
810
|
}, z.core.$strict>]>>;
|
|
781
811
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
782
812
|
reason: z.ZodString;
|
|
813
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
814
|
+
globalWrites: "globalWrites";
|
|
815
|
+
systemIdentity: "systemIdentity";
|
|
816
|
+
unsafeRaw: "unsafeRaw";
|
|
817
|
+
}>>>;
|
|
783
818
|
}, z.core.$strict>, z.ZodObject<{
|
|
784
819
|
__raw: z.ZodString;
|
|
785
820
|
}, z.core.$strict>]>>;
|
|
@@ -810,6 +845,11 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
810
845
|
}>>;
|
|
811
846
|
escapeHatch: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
|
|
812
847
|
reason: z.ZodString;
|
|
848
|
+
grants: z.ZodOptional<z.ZodArray<z.ZodEnum<{
|
|
849
|
+
globalWrites: "globalWrites";
|
|
850
|
+
systemIdentity: "systemIdentity";
|
|
851
|
+
unsafeRaw: "unsafeRaw";
|
|
852
|
+
}>>>;
|
|
813
853
|
}, z.core.$strict>, z.ZodObject<{
|
|
814
854
|
__raw: z.ZodString;
|
|
815
855
|
}, z.core.$strict>]>>;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// resolve this statically" markers) and, for EntityDefinition only,
|
|
22
22
|
// additionally checked for field-type membership via the shared
|
|
23
23
|
// `findUnknownEntityFieldTypes` helper (kept in sync with `extractEntity`).
|
|
24
|
+
import { ESCAPE_HATCH_GRANTS } from "@cosmicdrift/kumiko-types/handlers";
|
|
24
25
|
import * as z from "zod";
|
|
25
26
|
import { LifecycleHookTypes } from "../constants.js";
|
|
26
27
|
import { describeUnknownFieldType, findUnknownEntityFieldTypes } from "./entity-field-types.js";
|
|
@@ -131,6 +132,7 @@ const escapeHatchSchema = z
|
|
|
131
132
|
reason: z.string().refine((s) => s.trim().length > 0, {
|
|
132
133
|
message: "reason must be non-empty",
|
|
133
134
|
}),
|
|
135
|
+
grants: z.array(z.enum(ESCAPE_HATCH_GRANTS)).min(1).optional(),
|
|
134
136
|
})
|
|
135
137
|
.strict();
|
|
136
138
|
const rawRefSentinelSchema = z.object({ __raw: z.string() }).strict();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
|
+
import { escapeHatchGrantsProblem, normalizeEventPiiSubject, } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { ZodObject } from "zod";
|
|
3
3
|
import { resolveName } from "./handler-helpers.js";
|
|
4
4
|
import { splitNamedDefinition, unwrapArrayForm } from "./object-form.js";
|
|
@@ -124,6 +124,10 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
124
124
|
throw new Error(`[Feature ${name}] r.job("${jobName}") declares { escapeHatch: { reason: "" } } — ` +
|
|
125
125
|
"the reason must be a non-empty string explaining why this job needs unfiltered database access.");
|
|
126
126
|
}
|
|
127
|
+
const jobGrantsProblem = jobOptions.escapeHatch && escapeHatchGrantsProblem(jobOptions.escapeHatch);
|
|
128
|
+
if (jobGrantsProblem) {
|
|
129
|
+
throw new Error(`[Feature ${name}] r.job("${jobName}") declares an invalid escapeHatch — ${jobGrantsProblem}`);
|
|
130
|
+
}
|
|
127
131
|
// Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
|
|
128
132
|
// wird zu Array von resolved strings, Single bleibt single string —
|
|
129
133
|
// job-runner unterscheidet anhand Array.isArray.
|
|
@@ -106,6 +106,7 @@ export function buildEntityHandlerMethods(state, name) {
|
|
|
106
106
|
...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
|
|
107
107
|
...(def.rateLimit && { rateLimit: def.rateLimit }),
|
|
108
108
|
...(def.escapeHatch && { escapeHatch: def.escapeHatch }),
|
|
109
|
+
...(def.tenantlessAnonymous && { tenantlessAnonymous: true }),
|
|
109
110
|
// Forward the pipeline-build closure so boot-validators and
|
|
110
111
|
// Designer/AI tooling can inspect the step list. Absent on
|
|
111
112
|
// free-form handlers — defineWriteHandler only sets `perform`
|