@cosmicdrift/kumiko-framework 0.265.0 → 0.268.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/api/__tests__/api.test.ts +6 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +6 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +6 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +6 -0
- package/src/api/__tests__/jwt.test.ts +8 -0
- package/src/api/jwt.ts +8 -0
- package/src/changes.json +35 -0
- package/src/db/__tests__/event-store-executor-global-tenancy.integration.test.ts +73 -0
- package/src/db/__tests__/tenant-db-global.integration.test.ts +63 -26
- package/src/db/__tests__/tenant-db-global.test.ts +95 -5
- package/src/db/entity-table-meta.ts +7 -0
- package/src/db/event-store-executor-context.ts +19 -0
- package/src/db/event-store-executor-write.ts +16 -0
- package/src/db/index.ts +3 -1
- package/src/db/table-builder.ts +27 -0
- package/src/db/tenant-db.ts +83 -10
- package/src/engine/active-membership.ts +15 -1
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +331 -15
- package/src/engine/boot-validator/__tests__/global-tenancy.test.ts +58 -0
- package/src/engine/boot-validator/access-declarations.ts +101 -27
- package/src/engine/boot-validator/global-tenancy.ts +17 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/zod-shape.ts +92 -1
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +28 -0
- package/src/engine/feature-ast/extractors/hooks.ts +8 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/index.ts +4 -0
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/index.ts +3 -0
- package/src/entrypoint/index.ts +2 -13
- package/src/errors/i18n/de.yaml +20 -0
- package/src/errors/i18n/en.yaml +19 -0
- package/src/errors/reasons.ts +8 -0
- package/src/jobs/job-runner.ts +14 -0
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +17 -0
- package/src/pipeline/__tests__/escape-hatch.integration.test.ts +0 -3
- package/src/pipeline/__tests__/system-identity-switch.test.ts +60 -15
- package/src/pipeline/__tests__/unsafe-raw-escape-hatch.integration.test.ts +103 -0
- package/src/pipeline/active-membership.ts +3 -1
- package/src/pipeline/dispatch-shared.ts +89 -10
- package/src/pipeline/dispatch-stream.ts +9 -5
- package/src/pipeline/dispatch-write.ts +7 -0
- package/src/pipeline/dispatcher-utils.ts +1 -0
- package/src/pipeline/dispatcher.ts +18 -0
- package/src/pipeline/index.ts +1 -1
- package/src/pipeline/member-reader.ts +108 -0
- package/src/pipeline/system-identity-switch.ts +73 -13
- package/src/stack/test-stack.ts +10 -1
- package/src/testing/handler-context.ts +3 -0
- package/src/ui-types/index.ts +1 -0
package/src/db/index.ts
CHANGED
|
@@ -146,14 +146,16 @@ export { tableExists } from "./schema-inspection";
|
|
|
146
146
|
export {
|
|
147
147
|
buildBaseColumns,
|
|
148
148
|
buildEntityTable,
|
|
149
|
+
declareGlobalTenancy,
|
|
149
150
|
type EntityTable,
|
|
150
151
|
toSnakeCase,
|
|
151
152
|
toTableName,
|
|
152
153
|
} from "./table-builder";
|
|
153
|
-
export type { TenantDb, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
154
|
+
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
154
155
|
export {
|
|
155
156
|
castTenantRows,
|
|
156
157
|
createTenantDb,
|
|
157
158
|
createUncheckedSystemDb,
|
|
158
159
|
SYSTEM_SCOPE_CHECK_BRAND,
|
|
160
|
+
withUnsafeRawGrant,
|
|
159
161
|
} from "./tenant-db";
|
package/src/db/table-builder.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
moneyAmount,
|
|
30
30
|
table as pgTable,
|
|
31
31
|
plainDate,
|
|
32
|
+
type SchemaTable,
|
|
32
33
|
SQL_EXPR_BRAND,
|
|
33
34
|
type SqlExpression,
|
|
34
35
|
serial,
|
|
@@ -505,6 +506,32 @@ function stampGlobalTenancyMeta<E extends EntityDefinition>(
|
|
|
505
506
|
};
|
|
506
507
|
}
|
|
507
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Stamps `tenancy: "global"` onto a `table()`/SchemaTable's meta in place.
|
|
511
|
+
* Throws if the table has a `tenant_id` column or no EntityTableMeta to stamp.
|
|
512
|
+
*/
|
|
513
|
+
export function declareGlobalTenancy<T extends SchemaTable>(table: T): T & TenancyBrand<"global"> {
|
|
514
|
+
const meta = (table as unknown as Record<symbol, EntityTableMeta | undefined>)[
|
|
515
|
+
KUMIKO_META_SYMBOL
|
|
516
|
+
];
|
|
517
|
+
if (!meta) {
|
|
518
|
+
throw new Error("declareGlobalTenancy(): table has no EntityTableMeta to stamp.");
|
|
519
|
+
}
|
|
520
|
+
if (meta.columns.some((c) => c.name === "tenant_id")) {
|
|
521
|
+
throw new Error(
|
|
522
|
+
`declareGlobalTenancy("${meta.tableName}"): tables with a "tenant_id" column cannot be ` +
|
|
523
|
+
'declared tenancy: "global" — a global table\'s rows carry the system tenant, not a ' +
|
|
524
|
+
"per-row tenant identity.",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
(table as unknown as Record<symbol, EntityTableMeta>)[KUMIKO_META_SYMBOL] = {
|
|
528
|
+
...meta,
|
|
529
|
+
tenancy: "global",
|
|
530
|
+
};
|
|
531
|
+
// @cast-boundary type-brand — TenancyBrand<"global"> is a phantom marker with no runtime representation.
|
|
532
|
+
return table as T & TenancyBrand<"global">;
|
|
533
|
+
}
|
|
534
|
+
|
|
508
535
|
export function buildEntityTable<E extends EntityDefinition>(
|
|
509
536
|
entityName: string,
|
|
510
537
|
entity: E,
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -137,17 +137,48 @@ function tableNameOf(table: Table | EntityTableMeta): string {
|
|
|
137
137
|
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// A grant is only real when its reason is non-empty — `{ reason: "" }` must not silently unlock it.
|
|
141
|
+
function hasGrant(decl: EscapeHatchDeclaration | undefined): boolean {
|
|
142
|
+
return decl !== undefined && decl.reason.trim().length > 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isForeignTenantId(tenantIdValue: unknown): boolean {
|
|
146
|
+
return tenantIdValue !== undefined && tenantIdValue !== SYSTEM_TENANT_ID;
|
|
147
|
+
}
|
|
148
|
+
|
|
140
149
|
// Checks the canonical EntityTableMeta (branded EntityTable's KUMIKO_META_SYMBOL
|
|
141
150
|
// or a plain deriveEntityTableMeta/defineUnmanagedTable result), not a direct
|
|
142
151
|
// `table.tenantId` property read — the latter only exists on branded EntityTables
|
|
143
152
|
// and silently returned false (no tenant filter!) for plain EntityTableMeta
|
|
144
153
|
// tables like unmanaged direct-write stores, e.g. userSessionTable.
|
|
145
|
-
function hasTenantColumn(table: Table): boolean {
|
|
154
|
+
function hasTenantColumn(table: Table | EntityTableMeta): boolean {
|
|
146
155
|
const meta = asEntityTableMeta(table);
|
|
147
156
|
if (meta) return meta.columns.some((c) => c.name === "tenant_id");
|
|
148
157
|
return (table as Record<string, unknown>)["tenantId"] !== undefined;
|
|
149
158
|
}
|
|
150
159
|
|
|
160
|
+
// Grants for TenantDb's two escape hatches: db.global()'s write methods
|
|
161
|
+
// (write-handler-only) and ctx.db.unsafeRaw() (handler or hook, re-granted per hook).
|
|
162
|
+
export type TenantDbGrants = {
|
|
163
|
+
readonly globalWrites?: EscapeHatchDeclaration;
|
|
164
|
+
readonly unsafeRaw?: EscapeHatchDeclaration;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const unsafeRawRebinders = new WeakMap<
|
|
168
|
+
TenantDb,
|
|
169
|
+
(grant: EscapeHatchDeclaration | undefined) => TenantDb
|
|
170
|
+
>();
|
|
171
|
+
|
|
172
|
+
// Rebinds tenantDb's unsafeRaw grant (e.g. a hook's own escapeHatch); inputs not built by
|
|
173
|
+
// createTenantDb pass through unchanged, without touching any property (ctx.db may be a throwing Proxy).
|
|
174
|
+
export function withUnsafeRawGrant(
|
|
175
|
+
tenantDb: TenantDb,
|
|
176
|
+
grant: EscapeHatchDeclaration | undefined,
|
|
177
|
+
): TenantDb {
|
|
178
|
+
const rebind = unsafeRawRebinders.get(tenantDb);
|
|
179
|
+
return rebind ? rebind(grant) : tenantDb;
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
export function createTenantDb(
|
|
152
183
|
db: DbRunner,
|
|
153
184
|
tenantId: TenantId,
|
|
@@ -155,8 +186,7 @@ export function createTenantDb(
|
|
|
155
186
|
tracer?: Tracer,
|
|
156
187
|
meter?: Meter,
|
|
157
188
|
signal?: AbortSignal,
|
|
158
|
-
|
|
159
|
-
escapeHatch?: EscapeHatchDeclaration,
|
|
189
|
+
grants?: TenantDbGrants,
|
|
160
190
|
): TenantDb {
|
|
161
191
|
if (meter) registerStandardMetrics(meter);
|
|
162
192
|
|
|
@@ -215,7 +245,7 @@ export function createTenantDb(
|
|
|
215
245
|
// (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
|
|
216
246
|
// after a limit). Values outside the scope are dropped; if nothing valid
|
|
217
247
|
// remains, the full enforced scope applies — widening is never possible.
|
|
218
|
-
function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
|
|
248
|
+
function readWhere(table: Table | EntityTableMeta, where?: WhereObject): WhereObject | undefined {
|
|
219
249
|
if (!hasTenantColumn(table) || mode === "system") return where;
|
|
220
250
|
const allowed = [tenantId, SYSTEM_TENANT_ID];
|
|
221
251
|
const requested = where?.["tenantId"];
|
|
@@ -242,7 +272,7 @@ export function createTenantDb(
|
|
|
242
272
|
}
|
|
243
273
|
|
|
244
274
|
function missingEscapeHatch(table: Table | EntityTableMeta): AccessDeniedError | undefined {
|
|
245
|
-
if (
|
|
275
|
+
if (hasGrant(grants?.globalWrites)) return undefined;
|
|
246
276
|
return new AccessDeniedError({
|
|
247
277
|
message:
|
|
248
278
|
`db.global(${tableNameOf(table)}): write rejected — declare ` +
|
|
@@ -251,6 +281,15 @@ export function createTenantDb(
|
|
|
251
281
|
});
|
|
252
282
|
}
|
|
253
283
|
|
|
284
|
+
function foreignTenantOnGlobalWrite(
|
|
285
|
+
table: Table | EntityTableMeta,
|
|
286
|
+
tenantIdValue: unknown,
|
|
287
|
+
message: string = `db.global(${tableNameOf(table)}): tenantId "${String(tenantIdValue)}" is not SYSTEM_TENANT_ID — a "global" table's rows must carry the system tenant, not an arbitrary tenant's id.`,
|
|
288
|
+
): AccessDeniedError | undefined {
|
|
289
|
+
if (!isForeignTenantId(tenantIdValue)) return undefined;
|
|
290
|
+
return new AccessDeniedError({ message });
|
|
291
|
+
}
|
|
292
|
+
|
|
254
293
|
function globalTable<TTable extends (SchemaTable | EntityTableMeta) & TenancyBrand<"global">>(
|
|
255
294
|
table: TTable,
|
|
256
295
|
): GlobalTableDb<TTable> {
|
|
@@ -273,7 +312,8 @@ export function createTenantDb(
|
|
|
273
312
|
insertOne<T = Record<string, unknown>>(
|
|
274
313
|
values: Record<string, unknown>,
|
|
275
314
|
): Promise<T | undefined> {
|
|
276
|
-
const denied =
|
|
315
|
+
const denied =
|
|
316
|
+
missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, values["tenantId"]);
|
|
277
317
|
if (denied) return Promise.reject(denied);
|
|
278
318
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
279
319
|
},
|
|
@@ -281,7 +321,8 @@ export function createTenantDb(
|
|
|
281
321
|
set: Record<string, unknown>,
|
|
282
322
|
where: WhereObject,
|
|
283
323
|
): Promise<readonly T[]> {
|
|
284
|
-
const denied =
|
|
324
|
+
const denied =
|
|
325
|
+
missingEscapeHatch(table) ?? foreignTenantOnGlobalWrite(table, set["tenantId"]);
|
|
285
326
|
if (denied) return Promise.reject(denied);
|
|
286
327
|
if (!where || Object.keys(where).length === 0) {
|
|
287
328
|
return Promise.reject(
|
|
@@ -308,14 +349,28 @@ export function createTenantDb(
|
|
|
308
349
|
} as GlobalTableDb<TTable>;
|
|
309
350
|
}
|
|
310
351
|
|
|
311
|
-
|
|
352
|
+
const tenantDb: TenantDb = {
|
|
312
353
|
tenantId,
|
|
313
354
|
mode,
|
|
314
355
|
raw: db,
|
|
315
356
|
global: globalTable,
|
|
316
357
|
|
|
358
|
+
unsafeRaw(reason: string): DbRunner {
|
|
359
|
+
if (reason.trim().length === 0) {
|
|
360
|
+
throw new Error("unsafeRaw requires a non-empty reason");
|
|
361
|
+
}
|
|
362
|
+
if (!hasGrant(grants?.unsafeRaw)) {
|
|
363
|
+
throw new AccessDeniedError({
|
|
364
|
+
message:
|
|
365
|
+
'ctx.db.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
366
|
+
"the handler or hook to allow unsafeRaw.",
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return db;
|
|
370
|
+
},
|
|
371
|
+
|
|
317
372
|
selectMany<T = Record<string, unknown>>(
|
|
318
|
-
table: Table,
|
|
373
|
+
table: Table | EntityTableMeta,
|
|
319
374
|
where?: WhereObject,
|
|
320
375
|
options?: SelectOptions,
|
|
321
376
|
): Promise<readonly T[]> {
|
|
@@ -324,7 +379,7 @@ export function createTenantDb(
|
|
|
324
379
|
},
|
|
325
380
|
|
|
326
381
|
fetchOne<T = Record<string, unknown>>(
|
|
327
|
-
table: Table,
|
|
382
|
+
table: Table | EntityTableMeta,
|
|
328
383
|
where: WhereObject,
|
|
329
384
|
): Promise<T | undefined> {
|
|
330
385
|
const filter = readWhere(table, where) ?? {};
|
|
@@ -335,6 +390,19 @@ export function createTenantDb(
|
|
|
335
390
|
table: Table,
|
|
336
391
|
values: Record<string, unknown>,
|
|
337
392
|
): Promise<T | undefined> {
|
|
393
|
+
if (
|
|
394
|
+
mode === "tenant" &&
|
|
395
|
+
hasTenantColumn(table) &&
|
|
396
|
+
asEntityTableMeta(table)?.tenancy === "global"
|
|
397
|
+
) {
|
|
398
|
+
const denied = foreignTenantOnGlobalWrite(
|
|
399
|
+
table,
|
|
400
|
+
tenantId,
|
|
401
|
+
`insertOne(${tableNameOf(table)}): "global" table rows must carry SYSTEM_TENANT_ID; ` +
|
|
402
|
+
"use db.global(table) with escapeHatch instead.",
|
|
403
|
+
);
|
|
404
|
+
if (denied) return Promise.reject(denied);
|
|
405
|
+
}
|
|
338
406
|
const data = insertValues(table, values);
|
|
339
407
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
|
|
340
408
|
},
|
|
@@ -367,6 +435,11 @@ export function createTenantDb(
|
|
|
367
435
|
return withDbSpan("delete", table, async () => bunDeleteMany(db, table, filter));
|
|
368
436
|
},
|
|
369
437
|
};
|
|
438
|
+
|
|
439
|
+
unsafeRawRebinders.set(tenantDb, (grant) =>
|
|
440
|
+
createTenantDb(db, tenantId, mode, tracer, meter, signal, { ...grants, unsafeRaw: grant }),
|
|
441
|
+
);
|
|
442
|
+
return tenantDb;
|
|
370
443
|
}
|
|
371
444
|
|
|
372
445
|
export { asRawClient };
|
|
@@ -10,12 +10,26 @@ import type { TenantId } from "./types/identifiers";
|
|
|
10
10
|
// with a since-deleted row should not silently treat it as active.
|
|
11
11
|
export type PrincipalStatus = "active" | "blocked" | "unknown";
|
|
12
12
|
|
|
13
|
+
// Global-roles/timezone/locale snapshot ctx.queryAsMember needs to mint a
|
|
14
|
+
// resolved principal's SessionUser, without exposing its own table.
|
|
15
|
+
export type PrincipalProfile = {
|
|
16
|
+
readonly globalRoles: readonly string[];
|
|
17
|
+
readonly timezone?: string;
|
|
18
|
+
readonly locale?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
13
21
|
// Registered via r.useExtension(EXT_PRINCIPAL_STATUS, "user", principalStatusPlugin).
|
|
14
22
|
export type PrincipalStatusPlugin = {
|
|
15
23
|
readonly resolveStatus: (
|
|
16
24
|
userId: string,
|
|
17
25
|
deps: { readonly db: DbConnection },
|
|
18
26
|
) => Promise<PrincipalStatus>;
|
|
27
|
+
// Required so a missing provider fails closed (InternalError) instead of
|
|
28
|
+
// silently minting empty global roles for ctx.queryAsMember.
|
|
29
|
+
readonly resolveProfile: (
|
|
30
|
+
userId: string,
|
|
31
|
+
deps: { readonly db: DbConnection },
|
|
32
|
+
) => Promise<PrincipalProfile | null>;
|
|
19
33
|
};
|
|
20
34
|
|
|
21
35
|
// `null` means the tenant has no lifecycle row (never entered teardown).
|
|
@@ -38,7 +52,7 @@ function hasResolveStatusFn(v: unknown): v is { readonly resolveStatus: unknown
|
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
export function isPrincipalStatusPlugin(v: unknown): v is PrincipalStatusPlugin {
|
|
41
|
-
return hasResolveStatusFn(v);
|
|
55
|
+
return hasResolveStatusFn(v) && "resolveProfile" in v && typeof v.resolveProfile === "function";
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
export function isTenantLifecycleStatusPlugin(v: unknown): v is TenantLifecycleStatusPlugin {
|