@cosmicdrift/kumiko-framework 0.337.1 → 0.338.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.js +2 -2
- package/dist/api/server.js +11 -1
- package/dist/compliance/sub-processors.js +10 -1
- 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/tenant-db.js +16 -8
- package/dist/engine/boot-validator/access-declarations.js +13 -7
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +26 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/pii-retention.js +11 -1
- package/dist/engine/config-helpers.d.ts +2 -1
- package/dist/engine/config-helpers.js +1 -0
- package/dist/engine/extensions/escape-hatch-usage.js +3 -1
- 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-manifest.d.ts +1 -0
- package/dist/engine/feature-manifest.js +1 -0
- package/dist/engine/feature-ui-extensions.js +9 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/jobs/job-runner.js +2 -1
- package/dist/pipeline/dispatch-shared.js +32 -5
- package/dist/pipeline/system-identity-switch.js +10 -7
- package/package.json +4 -4
- package/src/changes.json +61 -0
|
@@ -33,6 +33,7 @@ export declare const Routes: {
|
|
|
33
33
|
};
|
|
34
34
|
export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
35
35
|
export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
36
|
+
export declare function isSessionRequiredApiPath(path: string): boolean;
|
|
36
37
|
export declare const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string>;
|
|
37
38
|
export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
|
|
38
39
|
readonly method: string;
|
|
@@ -87,6 +87,15 @@ export const NON_PUBLIC_API_PATHS = new Set([
|
|
|
87
87
|
`/api${Routes.files}`,
|
|
88
88
|
`/api${Routes.schema}`,
|
|
89
89
|
]);
|
|
90
|
+
// Paths that need a session but no tenant: without a token they answer 401
|
|
91
|
+
// before any tenant resolution, so an unresolvable tenant can never turn
|
|
92
|
+
// "not logged in" into a 400 tenant_required.
|
|
93
|
+
const SESSION_REQUIRED_API_PREFIXES = [`/api${Routes.auth}/`];
|
|
94
|
+
const SESSION_REQUIRED_API_EXACT_PATHS = new Set([`/api${Routes.schema}`]);
|
|
95
|
+
export function isSessionRequiredApiPath(path) {
|
|
96
|
+
return (SESSION_REQUIRED_API_EXACT_PATHS.has(path) ||
|
|
97
|
+
SESSION_REQUIRED_API_PREFIXES.some((prefix) => path.startsWith(prefix)));
|
|
98
|
+
}
|
|
90
99
|
// Opt-out from the default request-body-size cap (registerBodyLimit applies
|
|
91
100
|
// it to all of /api/* by construction — a new route needs no entry here to
|
|
92
101
|
// be covered). Only routes with their own, deliberately different size
|
|
@@ -2,7 +2,7 @@ import { getCookie } from "hono/cookie";
|
|
|
2
2
|
import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
|
|
3
3
|
import { createAnonymousUser } from "../engine/system-user.js";
|
|
4
4
|
import { parseTenantId } from "../engine/types/identifiers.js";
|
|
5
|
-
import { TENANT_COOKIE_NAME, TENANT_HEADER_NAME } from "./api-constants.js";
|
|
5
|
+
import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
|
|
6
6
|
import { isForeignCookieOrigin } from "./origin-middleware.js";
|
|
7
7
|
const USER_KEY = "pipelineUser";
|
|
8
8
|
const AUTH_TRANSPORT_KEY = "authTransport";
|
|
@@ -109,7 +109,7 @@ export function authMiddleware(jwt, options = {}) {
|
|
|
109
109
|
// missing authentication, nicht missing tenant. Daher hier direkt
|
|
110
110
|
// 401, ohne den anonymous-Tenant-Flow zu durchlaufen.
|
|
111
111
|
if (anonymousAccess) {
|
|
112
|
-
if (c.req.path
|
|
112
|
+
if (isSessionRequiredApiPath(c.req.path)) {
|
|
113
113
|
return middlewareReject(c, {
|
|
114
114
|
code: "missing_token",
|
|
115
115
|
status: 401,
|
package/dist/api/server.js
CHANGED
|
@@ -23,7 +23,7 @@ import { authEndpointRateLimit, createRateLimitResolver, globalIpRateLimit, } fr
|
|
|
23
23
|
import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
|
|
24
24
|
import { assertUnreachable, generateId } from "../utils/index.js";
|
|
25
25
|
import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
26
|
-
import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS, Routes } from "./api-constants.js";
|
|
26
|
+
import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
|
|
27
27
|
import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
|
|
28
28
|
import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
|
|
29
29
|
import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
|
|
@@ -708,6 +708,16 @@ export function buildServer(options) {
|
|
|
708
708
|
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
709
709
|
'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
|
|
710
710
|
}
|
|
711
|
+
// A `:param` segment is as broad as a wildcard, and the public matchers test
|
|
712
|
+
// the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
|
|
713
|
+
if (route.entry === ExtraRouteEntries.signature) {
|
|
714
|
+
const routePattern = honoPathToRegex(route.path);
|
|
715
|
+
const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
|
|
716
|
+
if (shadowedFrameworkPath !== undefined) {
|
|
717
|
+
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
718
|
+
`match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
711
721
|
}
|
|
712
722
|
const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
|
|
713
723
|
const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
|
|
@@ -84,13 +84,22 @@ export const KUMIKO_SUB_PROCESSORS = [
|
|
|
84
84
|
},
|
|
85
85
|
{
|
|
86
86
|
name: "Scaleway SAS",
|
|
87
|
-
purpose: "Offsite Backup Mirror (Object Storage)
|
|
87
|
+
purpose: "Offsite Backup Mirror (Object Storage)",
|
|
88
88
|
region: "EU (France)",
|
|
89
89
|
dpa: "https://www.scaleway.com/en/contracts/",
|
|
90
90
|
addedAt: "2026-09-18",
|
|
91
91
|
appliesTo: ["all-tiers"],
|
|
92
92
|
status: "active",
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: "Scaleway SAS",
|
|
96
|
+
purpose: "Platform key custody (Key Manager) and Transactional Email Delivery",
|
|
97
|
+
region: "EU (France)",
|
|
98
|
+
dpa: "https://www.scaleway.com/en/contracts/",
|
|
99
|
+
addedAt: "2026-09-18",
|
|
100
|
+
appliesTo: ["all-tiers"],
|
|
101
|
+
status: "planned",
|
|
102
|
+
},
|
|
94
103
|
];
|
|
95
104
|
/**
|
|
96
105
|
* Filter helper — nur aktive Sub-Processors (kein status="planned").
|
|
@@ -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";
|
package/dist/db/tenant-db.js
CHANGED
|
@@ -61,7 +61,7 @@ export function unsafeRawForDeclaredStep(holder, reason) {
|
|
|
61
61
|
"createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
|
-
return runner(reason);
|
|
64
|
+
return runner(reason, true);
|
|
65
65
|
}
|
|
66
66
|
const systemDbRebinders = new WeakMap();
|
|
67
67
|
// Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
|
|
@@ -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(reason, forwardedDeclaredStep);
|
|
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) {
|
|
@@ -419,7 +426,7 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
419
426
|
// @cast-boundary type-brand — GlobalWrites is only present in the type when TTable is not ExecutorOnly; the object above always carries the methods.
|
|
420
427
|
};
|
|
421
428
|
}
|
|
422
|
-
function grantedUnsafeRawRunner(reason) {
|
|
429
|
+
function grantedUnsafeRawRunner(reason, forwardedDeclaredStep) {
|
|
423
430
|
if (reason.trim().length === 0) {
|
|
424
431
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
425
432
|
}
|
|
@@ -433,14 +440,15 @@ export function createTenantDb(db, tenantId, mode = "tenant", tracer, meter, sig
|
|
|
433
440
|
"the handler or hook to allow unsafeRaw.",
|
|
434
441
|
});
|
|
435
442
|
}
|
|
436
|
-
|
|
443
|
+
// Engine-forwarded steps carry their own declared reason; a caller-supplied one never reaches the audit trail.
|
|
444
|
+
report("unsafe-raw", forwardedDeclaredStep ? reason : (grants?.unsafeRaw?.reason ?? reason));
|
|
437
445
|
return personalDataGate ? gatedRunner(db, personalDataGate) : db;
|
|
438
446
|
}
|
|
439
447
|
const tenantDb = {
|
|
440
448
|
tenantId,
|
|
441
449
|
mode,
|
|
442
450
|
global: globalTable,
|
|
443
|
-
unsafeRaw: grantedUnsafeRawRunner,
|
|
451
|
+
unsafeRaw: (reason) => grantedUnsafeRawRunner(reason, false),
|
|
444
452
|
selectMany(table, where, options) {
|
|
445
453
|
const filter = readWhere(table, where);
|
|
446
454
|
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
|
|
@@ -22,3 +22,4 @@ export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
|
22
22
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
23
23
|
export declare function validateExtendSchemaCollisions(feature: FeatureDefinition): void;
|
|
24
24
|
export declare function validateDerivedFieldCollisions(feature: FeatureDefinition): void;
|
|
25
|
+
export declare function validateSearchableFieldReadAccess(feature: FeatureDefinition): void;
|
|
@@ -784,3 +784,29 @@ export function validateDerivedFieldCollisions(feature) {
|
|
|
784
784
|
}
|
|
785
785
|
}
|
|
786
786
|
}
|
|
787
|
+
// --- Searchable fields with restricted read access ---
|
|
788
|
+
// The search index is tenant-wide, not per-role: `search` matches every
|
|
789
|
+
// searchable field and `filterReadFields` only strips the response afterwards.
|
|
790
|
+
// A role that cannot read the field could still guess its value from match
|
|
791
|
+
// counts, so the combination is rejected until the index can filter per role.
|
|
792
|
+
export function validateSearchableFieldReadAccess(feature) {
|
|
793
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
794
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
795
|
+
if (field.type !== "text")
|
|
796
|
+
continue;
|
|
797
|
+
if (!field.searchable)
|
|
798
|
+
continue;
|
|
799
|
+
const readAccess = field.access?.read;
|
|
800
|
+
if (readAccess === undefined)
|
|
801
|
+
continue;
|
|
802
|
+
const isUnrestricted = Array.isArray(readAccess)
|
|
803
|
+
? readAccess.length === 0
|
|
804
|
+
: Object.keys(readAccess).length === 0;
|
|
805
|
+
if (isUnrestricted)
|
|
806
|
+
continue;
|
|
807
|
+
throw new Error(`Field "${fieldName}" on entity "${entityName}" cannot be searchable with a restricted ` +
|
|
808
|
+
"access.read — the search index is not filtered per role, so search matches would leak " +
|
|
809
|
+
'the value to roles that cannot read it. Drop `searchable`/`find: "fuzzy"` or `access.read`.');
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
@@ -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) {
|
|
@@ -184,7 +190,11 @@ export function validatePiiAndRetention(feature) {
|
|
|
184
190
|
const noAnnotation = annotCount === 0 && !annot.allowPlaintext;
|
|
185
191
|
if (noAnnotation) {
|
|
186
192
|
const lower = fieldName.toLowerCase();
|
|
187
|
-
if (
|
|
193
|
+
if (FILE_FIELD_TYPES.has(field.type)) {
|
|
194
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
195
|
+
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.`);
|
|
196
|
+
}
|
|
197
|
+
else if (PII_DIRECT_NAME_HINTS.has(lower)) {
|
|
188
198
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
189
199
|
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
200
|
}
|
|
@@ -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 } : {}),
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
@@ -7,6 +7,7 @@ export type ManifestConfigKey = {
|
|
|
7
7
|
readonly default: string | number | boolean | null;
|
|
8
8
|
readonly encrypted: boolean;
|
|
9
9
|
readonly computed: boolean;
|
|
10
|
+
readonly validated: boolean;
|
|
10
11
|
readonly options: readonly string[] | null;
|
|
11
12
|
readonly bounds: {
|
|
12
13
|
readonly min?: number;
|
|
@@ -29,6 +29,7 @@ export function buildManifestFromRegistry(registry, options) {
|
|
|
29
29
|
default: def.default ?? null,
|
|
30
30
|
encrypted: isEncryptedAtRest(def),
|
|
31
31
|
computed: def.computed !== undefined,
|
|
32
|
+
validated: def.validate !== undefined,
|
|
32
33
|
options: def.options ?? null,
|
|
33
34
|
bounds: def.bounds ?? null,
|
|
34
35
|
pattern: def.pattern ?? null,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHatchGrantsProblem } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES, WEBSOCKET_ROUTE_PATH_PREFIX, } from "@cosmicdrift/kumiko-types/websocket-route";
|
|
2
3
|
import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
|
|
3
4
|
import { LifecycleHookTypes } from "./constants.js";
|
|
@@ -117,6 +118,10 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
117
118
|
throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares { escapeHatch: { reason: "" } } — ` +
|
|
118
119
|
"the reason must be a non-empty string explaining why this hook switches identity to SYSTEM.");
|
|
119
120
|
}
|
|
121
|
+
const hookGrantsProblem = options?.escapeHatch && escapeHatchGrantsProblem(options.escapeHatch);
|
|
122
|
+
if (hookGrantsProblem) {
|
|
123
|
+
throw new Error(`[Feature ${name}] r.hook("${type}", ...) declares an invalid escapeHatch — ${hookGrantsProblem}`);
|
|
124
|
+
}
|
|
120
125
|
// Wrapped once for both branches below; validation hooks stay unwrapped (no context).
|
|
121
126
|
const hookLabel = `${type} hook of feature "${name}"`;
|
|
122
127
|
const wrapped = type === "validation"
|
|
@@ -211,6 +216,10 @@ export function buildUiExtensionsMethods(state, name) {
|
|
|
211
216
|
throw new Error(`[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ` +
|
|
212
217
|
`must be { reason: "<non-empty string>" } explaining why this usage needs unfiltered db access.`);
|
|
213
218
|
}
|
|
219
|
+
const extGrantsProblem = escapeHatchGrantsProblem(escapeHatch);
|
|
220
|
+
if (extGrantsProblem) {
|
|
221
|
+
throw new Error(`[Feature ${name}] r.useExtension("${extensionName}", "${resolvedEntityName}", ...) declares an invalid { escapeHatch } — ${extGrantsProblem}`);
|
|
222
|
+
}
|
|
214
223
|
}
|
|
215
224
|
state.extensionUsages.push({
|
|
216
225
|
extensionName,
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -73,7 +73,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
|
|
|
73
73
|
export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
|
|
74
74
|
export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
|
|
75
75
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
|
|
76
|
-
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
76
|
+
export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitPer, RecordHeaderSpec, RecordHeaderSubtitlePart, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyDefinition, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
|
|
77
77
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
|
|
78
78
|
export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
|
|
79
79
|
export { runValidation } from "./validation.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
2
|
-
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
export type { ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigBounds, ConfigCascade, ConfigCascadeLevel, ConfigComputedContext, ConfigComputedFn, ConfigDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValidateContext, ConfigValidateFn, ConfigValue, ConfigValueSource, ConfigValueWithSource, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, ExtensionSelectorDef, ExtensionSelectorPanel, JobBackoff, JobBackoffStrategy, JobDefinition, JobHandlerFn, JobRunIn, JobTrigger, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, PluralForms, ReferenceDataDef, RegistrarExtensionDef, RegistrarExtensionHooks, RegistrarExtensionRegistration, RunIn, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, UiExtensionDef, } from "@cosmicdrift/kumiko-types/config";
|
|
3
3
|
export type { QueryHandlerDefinition, StreamHandlerDefinition, WriteHandlerDefinition, WriteHandlerInput, } from "@cosmicdrift/kumiko-types/define-handler";
|
|
4
4
|
export type { EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "@cosmicdrift/kumiko-types/entity-handlers";
|
|
5
5
|
export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, } from "@cosmicdrift/kumiko-types/event-type-map";
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { Queue, UnrecoverableError, Worker } from "bullmq";
|
|
2
3
|
import { Redis } from "ioredis";
|
|
3
4
|
import { requestContext } from "../api/request-context.js";
|
|
@@ -703,7 +704,7 @@ export function createJobRunner(options) {
|
|
|
703
704
|
: undefined;
|
|
704
705
|
const jobDb = configDb
|
|
705
706
|
? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
|
|
706
|
-
unsafeRaw: jobDef.escapeHatch,
|
|
707
|
+
unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
|
|
707
708
|
report: reportEscapeHatch,
|
|
708
709
|
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
709
710
|
})
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { requestContext, runWithOrigin } from "../api/request-context.js";
|
|
2
3
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
3
4
|
import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, } from "../db/tenant-db.js";
|
|
4
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
|
+
import { createSystemUser } from "../engine/system-user.js";
|
|
5
7
|
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
6
|
-
import { FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
|
|
8
|
+
import { AccessDeniedError, FeatureDisabledError, InternalError, memberResolutionReadOnlyDenied, VersionConflictError, } from "../errors/index.js";
|
|
7
9
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
8
10
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
9
11
|
import { getStreamVersion, loadAggregate, loadAggregateAsOf, } from "../event-store/event-store.js";
|
|
@@ -148,7 +150,8 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
148
150
|
const handlerEscapeHatch = writeEscapeHatch ??
|
|
149
151
|
registry.getQueryHandler(type)?.escapeHatch ??
|
|
150
152
|
registry.getStreamHandler(type)?.escapeHatch;
|
|
151
|
-
const
|
|
153
|
+
const identitySwitchEscapeHatch = escapeHatchFor(handlerEscapeHatch, "systemIdentity");
|
|
154
|
+
const hasIdentitySwitchGrant = isSystem || identitySwitchEscapeHatch !== undefined;
|
|
152
155
|
const featureName = registry.getHandlerFeature(type);
|
|
153
156
|
const reportEscapeHatch = createEscapeHatchReporter({
|
|
154
157
|
handler: type,
|
|
@@ -158,11 +161,11 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
158
161
|
log: context.log,
|
|
159
162
|
window: ctx.escapeHatchReportWindow,
|
|
160
163
|
});
|
|
161
|
-
const identitySwitchGrantReason =
|
|
164
|
+
const identitySwitchGrantReason = identitySwitchEscapeHatch?.reason ??
|
|
162
165
|
(isSystem ? `r.systemScope() feature "${featureName ?? "unknown"}"` : undefined);
|
|
163
166
|
const buildTenantScopedDb = (source, signal) => createTenantDb(source, user.tenantId, isSystem ? "system" : "tenant", context.tracer, context.meter, signal, {
|
|
164
|
-
globalWrites: writeEscapeHatch,
|
|
165
|
-
unsafeRaw: handlerEscapeHatch,
|
|
167
|
+
globalWrites: escapeHatchFor(writeEscapeHatch, "globalWrites"),
|
|
168
|
+
unsafeRaw: escapeHatchFor(handlerEscapeHatch, "unsafeRaw"),
|
|
166
169
|
report: reportEscapeHatch,
|
|
167
170
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
168
171
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
@@ -212,6 +215,29 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
212
215
|
secrets: context.secrets,
|
|
213
216
|
})
|
|
214
217
|
: undefined;
|
|
218
|
+
// Cross-tenant accessor for SystemAdmin/system callers. The
|
|
219
|
+
// identity is the target tenant's system user so the caller's own user-scope
|
|
220
|
+
// values never resolve into another tenant's config; the db is tenant-mode
|
|
221
|
+
// scoped to the target.
|
|
222
|
+
const configFactory = context._configAccessorFactory;
|
|
223
|
+
const configFor = configFactory && dbSource
|
|
224
|
+
? (targetTenantId) => {
|
|
225
|
+
if (targetTenantId === user.tenantId && config)
|
|
226
|
+
return config;
|
|
227
|
+
if (!isSystemIdentity(user) && !user.roles.includes("SystemAdmin")) {
|
|
228
|
+
throw new AccessDeniedError({
|
|
229
|
+
message: "ctx.configFor(otherTenant) requires the system role or SystemAdmin",
|
|
230
|
+
details: { reason: "config_for_requires_system_admin" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const targetSystemUser = createSystemUser(targetTenantId);
|
|
234
|
+
return configFactory({
|
|
235
|
+
user: { id: targetSystemUser.id, tenantId: targetTenantId },
|
|
236
|
+
db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
|
|
237
|
+
secrets: context.secrets,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
: undefined;
|
|
215
241
|
// ctx.files resolved per-tenant through file-foundation (lazy — the
|
|
216
242
|
// provider is only resolved when a handle actually does I/O). Boot wires
|
|
217
243
|
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
@@ -584,6 +610,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
584
610
|
log,
|
|
585
611
|
notify,
|
|
586
612
|
...(config && { config }),
|
|
613
|
+
...(configFor && { configFor }),
|
|
587
614
|
...(files && { files }),
|
|
588
615
|
...(derivatives && { derivatives }),
|
|
589
616
|
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant, } from "../db/tenant-db.js";
|
|
2
3
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
3
4
|
import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors/index.js";
|
|
@@ -212,6 +213,8 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
|
|
|
212
213
|
const ctxDb = readDbLikeValue(context, "db");
|
|
213
214
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
214
215
|
const ctxSystemDb = readDbLikeValue(context, "systemDb");
|
|
216
|
+
const identityHatch = escapeHatchFor(escapeHatch, "systemIdentity");
|
|
217
|
+
const rawHatch = escapeHatchFor(escapeHatch, "unsafeRaw");
|
|
215
218
|
if (!ctxQueryAs &&
|
|
216
219
|
!ctxWriteAs &&
|
|
217
220
|
!ctxResolveActiveMembership &&
|
|
@@ -224,21 +227,21 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
|
|
|
224
227
|
}
|
|
225
228
|
return {
|
|
226
229
|
...context,
|
|
227
|
-
...gatedIdentitySwitchFields(callerLabel,
|
|
230
|
+
...gatedIdentitySwitchFields(callerLabel, identityHatch, ctxQueryAs, ctxWriteAs),
|
|
228
231
|
...(ctxResolveActiveMembership &&
|
|
229
|
-
|
|
232
|
+
identityHatch === undefined && {
|
|
230
233
|
resolveActiveMembership: deniedResolveActiveMembership(callerLabel),
|
|
231
234
|
}),
|
|
232
|
-
...gatedMemberReaderField(callerLabel,
|
|
233
|
-
...gatedProjectionReaderField(callerLabel,
|
|
235
|
+
...gatedMemberReaderField(callerLabel, identityHatch, ctxQueryAsMember),
|
|
236
|
+
...gatedProjectionReaderField(callerLabel, identityHatch, ctxQueryProjection),
|
|
234
237
|
// @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
|
|
235
|
-
...(ctxDb && { db: withUnsafeRawGrant(ctxDb,
|
|
238
|
+
...(ctxDb && { db: withUnsafeRawGrant(ctxDb, rawHatch) }),
|
|
236
239
|
...(ctxDbOutsideTransaction && {
|
|
237
|
-
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction,
|
|
240
|
+
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, rawHatch),
|
|
238
241
|
}),
|
|
239
242
|
// @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
|
|
240
243
|
...(ctxSystemDb && {
|
|
241
|
-
systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb,
|
|
244
|
+
systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, rawHatch, callerLabel),
|
|
242
245
|
}),
|
|
243
246
|
};
|
|
244
247
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.338.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -206,8 +206,8 @@
|
|
|
206
206
|
"./package.json": "./package.json"
|
|
207
207
|
},
|
|
208
208
|
"dependencies": {
|
|
209
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
210
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-http": "0.338.0",
|
|
210
|
+
"@cosmicdrift/kumiko-types": "0.338.0",
|
|
211
211
|
"bullmq": "^5.76.7",
|
|
212
212
|
"bun-types": "^1.3.13",
|
|
213
213
|
"hono": "^4.13.1",
|
|
@@ -223,7 +223,7 @@
|
|
|
223
223
|
"zod": "^4.4.3"
|
|
224
224
|
},
|
|
225
225
|
"devDependencies": {
|
|
226
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
226
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
|
|
227
227
|
"bun-types": "^1.3.13",
|
|
228
228
|
"pino-pretty": "^13.1.3"
|
|
229
229
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,65 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.338.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "ctx.configFor(tenantId) resolves config for another tenant",
|
|
6
|
+
"detail": "Query and write handler contexts get `configFor(tenantId)` next to `config`, present when the config feature wired its accessor factory. The accessor runs as the target tenant's system user on a db scoped to that tenant, so the caller's user-scope values never leak into the result. Calls for another tenant throw `AccessDeniedError` unless the caller is the system identity or has the `SystemAdmin` role; the caller's own tenant needs no privilege and returns the same accessor as `ctx.config`.",
|
|
7
|
+
"migration": "No code change needed. Handlers that compute values for a tenant other than the caller's (limits, quotas, billing previews) call `ctx.configFor(tenantId)` instead of passing `ctx.config`."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.338.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "escapeHatch grants split the SYSTEM identity switch from raw access",
|
|
13
|
+
"detail": "escapeHatch: new optional `grants` (`systemIdentity`, `globalWrites`, `unsafeRaw`) narrows what a declaration unlocks. Without `grants` the declaration keeps unlocking all three. An empty or unknown `grants` list is a boot error. Bundled handlers that only needed the raw runner now declare `grants: [\"unsafeRaw\"]`, so they no longer gain the SYSTEM identity switch.",
|
|
14
|
+
"migration": "No action needed: `grants` is additive and an escapeHatch without it behaves as before.\nDeclare `grants: [\"unsafeRaw\"]` on handlers that only call ctx.db.unsafeRaw to drop the SYSTEM identity switch."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.338.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "boot warning for file and image fields without a personal annotation",
|
|
20
|
+
"detail": "Boot validation warns for every `file`, `image`, `files` and `images` field without a `personal` annotation. A forget with strategy `delete` keeps the binary of such a field and only severs the uploader link, so the stance has to be declared.",
|
|
21
|
+
"migration": "Mark fields that hold personal data with `personal: \"self\"` or `personal: { of: \"<ownerField>\" }`, and business data with `personal: false, reason: \"is_business_data\"`. Unannotated fields keep their binary on forget."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.338.0",
|
|
25
|
+
"type": "fix",
|
|
26
|
+
"title": "reference label search applies target read access on the plaintext path",
|
|
27
|
+
"detail": "Searching a list by a reference field's label now respects the target entity's row-level `access.read` and the label field's own `access.read` on the plaintext (ILIKE) path. Before, a viewer who could not read the target row or its label could probe names through the match results."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"version": "0.338.0",
|
|
31
|
+
"type": "fix",
|
|
32
|
+
"title": "/api/schema answers 401 instead of 400 tenant_required after logout",
|
|
33
|
+
"detail": "With `anonymousAccess` and an authoritative tenant resolver, a request without a token to `/api/schema` ran through tenant resolution and failed with 400 `tenant_required`. Paths that need a session but no tenant (`/api/auth/*` and `/api/schema`) are now listed in `isSessionRequiredApiPath` and answer 401 `missing_token` before any tenant lookup.",
|
|
34
|
+
"migration": "No code change needed."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"version": "0.338.0",
|
|
38
|
+
"type": "breaking",
|
|
39
|
+
"title": "searchable text fields must not declare a restricted access.read",
|
|
40
|
+
"detail": "Boot validation rejects a text field that is `searchable` (or `find: \"fuzzy\"`) and also has a restricted `access.read`. The search index is not filtered per role, so search matches leaked the value to roles that cannot read the field.",
|
|
41
|
+
"migration": "Remove `searchable` / `find: \"fuzzy\"` from the field, or remove the read restriction. Write-only `access.write` restrictions are unaffected."
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"version": "0.338.0",
|
|
45
|
+
"type": "breaking",
|
|
46
|
+
"title": "signature routes with a :param segment or wildcard must not match framework paths",
|
|
47
|
+
"detail": "Boot validation rejects `entry: \"signature\"` extra routes whose pattern, including `:param` segments, matches a framework path such as `/api/write`. Such a route skipped the JWT, origin and CSRF guards for that path.",
|
|
48
|
+
"migration": "Give the route a static prefix, for example `/api/webhooks/:provider` instead of `/api/:provider` or `/:provider/write`."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.338.0",
|
|
52
|
+
"type": "fix",
|
|
53
|
+
"title": "planned Scaleway purposes listed as planned sub-processor entry",
|
|
54
|
+
"detail": "The sub-processor list now carries the planned Scaleway purposes (key custody and transactional email) as their own `status: \"planned\"` entry. The active Scaleway entry only names the backup mirror, so the public `planned` section and change notifications see the upcoming processing."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "0.338.0",
|
|
58
|
+
"type": "improvement",
|
|
59
|
+
"title": "unsafeRaw audit events carry the declared escapeHatch reason",
|
|
60
|
+
"detail": "`ctx.db.unsafeRaw(reason)` and `ctx.systemDb.unsafeRaw(reason)` now write the reason declared on the handler or hook `escapeHatch` into the escape-hatch audit event, not the string passed at the call. A call-site string can no longer put arbitrary text into the audit trail.",
|
|
61
|
+
"migration": "No action needed: calls that pass the same reason as the declaration audit unchanged. Calls with a different string now audit the declared reason."
|
|
62
|
+
},
|
|
2
63
|
{
|
|
3
64
|
"version": "0.337.1",
|
|
4
65
|
"type": "improvement",
|