@cosmicdrift/kumiko-framework 0.304.0 → 0.306.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +108 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +140 -16
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/boot-validator/access-declarations.ts +5 -66
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
- package/src/jobs/job-runner.ts +170 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// unsafeReadRetrying instead — this test mirrors
|
|
6
|
-
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
7
|
-
// the retry now fires. Writes (insertSubsequentEventRow, upsertSnapshot,
|
|
8
|
-
// markProjectionRebuilding, ...) stay unretried per #1358, and the
|
|
9
|
-
// FOR UPDATE / FOR UPDATE SKIP LOCKED reads in event-consumer.ts are always
|
|
10
|
-
// called inside transaction() (verified against their only call sites) — the
|
|
11
|
-
// retry guard there is a no-op, so those are left as asRawClient calls too.
|
|
1
|
+
// event-store.ts's and projection-rebuild.ts's SELECT helpers run through
|
|
2
|
+
// unsafeReadRetrying; this proves the retry fires using a real captured
|
|
3
|
+
// driver error. Writes and the FOR UPDATE reads inside transaction() stay
|
|
4
|
+
// out of scope — unretried by design.
|
|
12
5
|
|
|
13
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { captureClosedConnectionError } from "../../../testing/closed-connection-error";
|
|
14
8
|
import {
|
|
15
9
|
selectAggregateMaxVersion,
|
|
16
10
|
selectEventsHighWaterMark,
|
|
@@ -22,24 +16,28 @@ import {
|
|
|
22
16
|
selectEventsForProjectionRebuildBatch,
|
|
23
17
|
} from "../projection-rebuild";
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
let closedConnectionError: unknown;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
23
|
+
});
|
|
28
24
|
|
|
29
25
|
type RecordedCall = { readonly sql: string; readonly params: readonly unknown[] | undefined };
|
|
30
26
|
|
|
31
27
|
type FakeClient = {
|
|
32
28
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
33
29
|
begin: () => never;
|
|
30
|
+
options: { max: number };
|
|
34
31
|
calls: number;
|
|
35
32
|
recordedCalls: RecordedCall[];
|
|
36
33
|
};
|
|
37
34
|
|
|
38
|
-
function fakeClient(failures:
|
|
35
|
+
function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
|
|
39
36
|
const remaining = [...failures];
|
|
40
37
|
const client: FakeClient = {
|
|
41
38
|
calls: 0,
|
|
42
39
|
recordedCalls: [],
|
|
40
|
+
options: { max: 1 },
|
|
43
41
|
unsafe: async (sql, params) => {
|
|
44
42
|
client.calls++;
|
|
45
43
|
client.recordedCalls.push({ sql, params });
|
|
@@ -54,9 +52,9 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
54
52
|
return client;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
describe("framework db/queries — closed-connection retry
|
|
55
|
+
describe("framework db/queries — closed-connection retry", () => {
|
|
58
56
|
test("selectStreamMaxVersion retries once and returns the version", async () => {
|
|
59
|
-
const db = fakeClient([closedConnectionError
|
|
57
|
+
const db = fakeClient([closedConnectionError], { v: 5 });
|
|
60
58
|
const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
|
|
61
59
|
expect(result).toBe(5);
|
|
62
60
|
expect(db.calls).toBe(2);
|
|
@@ -65,28 +63,28 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
|
65
63
|
});
|
|
66
64
|
|
|
67
65
|
test("selectAggregateMaxVersion retries once and returns the version", async () => {
|
|
68
|
-
const db = fakeClient([closedConnectionError
|
|
66
|
+
const db = fakeClient([closedConnectionError], { v: 7 });
|
|
69
67
|
const result = await selectAggregateMaxVersion(db as never, "agg1");
|
|
70
68
|
expect(result).toBe(7);
|
|
71
69
|
expect(db.calls).toBe(2);
|
|
72
70
|
});
|
|
73
71
|
|
|
74
72
|
test("selectEventsHighWaterMark retries once and returns the max id", async () => {
|
|
75
|
-
const db = fakeClient([closedConnectionError
|
|
73
|
+
const db = fakeClient([closedConnectionError], { max: 42n });
|
|
76
74
|
const result = await selectEventsHighWaterMark(db as never);
|
|
77
75
|
expect(result).toBe(42n);
|
|
78
76
|
expect(db.calls).toBe(2);
|
|
79
77
|
});
|
|
80
78
|
|
|
81
79
|
test("selectNextEventIdAfter retries once and returns the next id", async () => {
|
|
82
|
-
const db = fakeClient([closedConnectionError
|
|
80
|
+
const db = fakeClient([closedConnectionError], { id: 43n });
|
|
83
81
|
const result = await selectNextEventIdAfter(db as never, 42n);
|
|
84
82
|
expect(result).toBe(43n);
|
|
85
83
|
expect(db.calls).toBe(2);
|
|
86
84
|
});
|
|
87
85
|
|
|
88
86
|
test("selectEventsForProjectionRebuildBatch retries once and returns rows", async () => {
|
|
89
|
-
const db = fakeClient([closedConnectionError
|
|
87
|
+
const db = fakeClient([closedConnectionError], { id: "1", type: "created" });
|
|
90
88
|
const rows = await selectEventsForProjectionRebuildBatch(
|
|
91
89
|
db as never,
|
|
92
90
|
["user"],
|
|
@@ -99,17 +97,19 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
|
99
97
|
});
|
|
100
98
|
|
|
101
99
|
test("countSubscribedEvents retries once and returns the count", async () => {
|
|
102
|
-
const db = fakeClient([closedConnectionError
|
|
100
|
+
const db = fakeClient([closedConnectionError], { n: 12n });
|
|
103
101
|
const result = await countSubscribedEvents(db as never, ["user"], ["user:created"]);
|
|
104
102
|
expect(result).toBe(12n);
|
|
105
103
|
expect(db.calls).toBe(2);
|
|
106
104
|
});
|
|
107
105
|
|
|
108
|
-
test("gives up after
|
|
109
|
-
const db = fakeClient([closedConnectionError
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
107
|
+
const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
|
|
108
|
+
v: 5,
|
|
109
|
+
});
|
|
110
|
+
await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toBe(
|
|
111
|
+
closedConnectionError,
|
|
112
112
|
);
|
|
113
|
-
expect(db.calls).toBe(
|
|
113
|
+
expect(db.calls).toBe(3);
|
|
114
114
|
});
|
|
115
115
|
});
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type SelectOptions,
|
|
25
25
|
type WhereObject,
|
|
26
26
|
} from "../db/query";
|
|
27
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
27
28
|
import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
|
|
28
29
|
import { AccessDeniedError, InternalError, memberResolutionReadOnlyDenied } from "../errors";
|
|
29
30
|
import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
|
|
@@ -45,6 +46,21 @@ const declaredUnsafeRawRunners = new WeakMap<
|
|
|
45
46
|
(reason: string) => DbRunner
|
|
46
47
|
>();
|
|
47
48
|
|
|
49
|
+
// The CRUD executor writes through tenantDbRunner, not insertOne, so it asks the
|
|
50
|
+
// TenantDb it was handed for its gate. Bound inside createTenantDb so rebound instances
|
|
51
|
+
// (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
|
|
52
|
+
const personalDataGates = new WeakMap<TenantDb, PersonalDataGate>();
|
|
53
|
+
|
|
54
|
+
// The executor passes its entity so the check does not depend on the table-name lookup.
|
|
55
|
+
export function assertPersonalDataWrite(
|
|
56
|
+
db: TenantDb,
|
|
57
|
+
tableName: string,
|
|
58
|
+
keys: readonly string[],
|
|
59
|
+
entity: EntityDefinition,
|
|
60
|
+
): void {
|
|
61
|
+
personalDataGates.get(db)?.(tableName, keys, entity);
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
// Framework-private (not re-exported from db/index.ts): same grant check + audit as unsafeRaw, for engine forwarding.
|
|
49
65
|
export function unsafeRawForDeclaredStep(
|
|
50
66
|
holder: TenantDb | UncheckedSystemDb,
|
|
@@ -57,25 +73,49 @@ export function unsafeRawForDeclaredStep(
|
|
|
57
73
|
if (!runner) {
|
|
58
74
|
throw new InternalError({
|
|
59
75
|
message:
|
|
60
|
-
"unsafeRawForDeclaredStep received a holder not built by createTenantDb
|
|
61
|
-
"createUncheckedSystemDb — no declared unsafeRaw runner bound.",
|
|
76
|
+
"unsafeRawForDeclaredStep received a holder not built by createTenantDb, " +
|
|
77
|
+
"createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
|
|
62
78
|
});
|
|
63
79
|
}
|
|
64
80
|
return runner(reason);
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
export function
|
|
83
|
+
const systemDbRebinders = new WeakMap<
|
|
84
|
+
UncheckedSystemDb,
|
|
85
|
+
(grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string) => UncheckedSystemDb
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
// Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
|
|
89
|
+
// rebuilt from the original db/dbOutsideTransaction/report, never stacked onto a prior rebind.
|
|
90
|
+
// Inputs not built by createUncheckedSystemDb pass through unchanged.
|
|
91
|
+
export function withSystemDbUnsafeRawGrant(
|
|
92
|
+
systemDb: UncheckedSystemDb,
|
|
93
|
+
grant: EscapeHatchDeclaration | undefined,
|
|
94
|
+
deniedCallerLabel: string,
|
|
95
|
+
): UncheckedSystemDb {
|
|
96
|
+
const rebind = systemDbRebinders.get(systemDb);
|
|
97
|
+
return rebind ? rebind(grant, deniedCallerLabel) : systemDb;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ungated when `gate` is absent (the handler's own ctx.systemDb — systemScope() is
|
|
101
|
+
// itself the grant there). Gated by a hook's own escapeHatch when `gate.kind` is
|
|
102
|
+
// "hook-grant": unsafeRaw then denies without `hasGrant(gate.grant)`, same error shape
|
|
103
|
+
// as ctx.db.unsafeRaw's denial in createTenantDb below. Gated by the source TenantDb's
|
|
104
|
+
// own escapeHatch when `gate.kind` is "source-tenant-db" (createSystemDbView): unsafeRaw
|
|
105
|
+
// defers entirely to db's own declared runner (reason/memberReadOnly/grant check and
|
|
106
|
+
// report) — the view's own `report` is never called for unsafe-raw in that mode, and a
|
|
107
|
+
// source not built by createTenantDb fails closed.
|
|
108
|
+
function buildUncheckedSystemDb(
|
|
76
109
|
db: TenantDb,
|
|
77
|
-
dbOutsideTransaction
|
|
78
|
-
report: EscapeHatchReporter
|
|
110
|
+
dbOutsideTransaction: TenantDb | undefined,
|
|
111
|
+
report: EscapeHatchReporter,
|
|
112
|
+
gate?:
|
|
113
|
+
| {
|
|
114
|
+
readonly kind: "hook-grant";
|
|
115
|
+
readonly grant: EscapeHatchDeclaration | undefined;
|
|
116
|
+
readonly deniedCallerLabel: string;
|
|
117
|
+
}
|
|
118
|
+
| { readonly kind: "source-tenant-db" },
|
|
79
119
|
): UncheckedSystemDb {
|
|
80
120
|
const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
|
|
81
121
|
|
|
@@ -97,9 +137,19 @@ export function createUncheckedSystemDb(
|
|
|
97
137
|
}
|
|
98
138
|
|
|
99
139
|
function grantedUnsafeRawRunner(reason: string): DbRunner {
|
|
140
|
+
if (gate?.kind === "source-tenant-db") {
|
|
141
|
+
return unsafeRawForDeclaredStep(db, reason);
|
|
142
|
+
}
|
|
100
143
|
if (reason.trim().length === 0) {
|
|
101
144
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
102
145
|
}
|
|
146
|
+
if (gate && !hasGrant(gate.grant)) {
|
|
147
|
+
throw new AccessDeniedError({
|
|
148
|
+
message:
|
|
149
|
+
'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
150
|
+
`${gate.deniedCallerLabel} to allow unsafeRaw.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
103
153
|
report("unsafe-raw", reason);
|
|
104
154
|
return tenantDbRunner(db);
|
|
105
155
|
}
|
|
@@ -162,15 +212,58 @@ export function createUncheckedSystemDb(
|
|
|
162
212
|
},
|
|
163
213
|
};
|
|
164
214
|
declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
|
|
215
|
+
if (gate?.kind !== "source-tenant-db") {
|
|
216
|
+
systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) =>
|
|
217
|
+
buildUncheckedSystemDb(
|
|
218
|
+
withUnsafeRawGrant(db, grant),
|
|
219
|
+
dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant),
|
|
220
|
+
report,
|
|
221
|
+
{ kind: "hook-grant", grant, deniedCallerLabel },
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
165
225
|
return uncheckedSystemDb;
|
|
166
226
|
}
|
|
167
227
|
|
|
228
|
+
// Framework-private (not re-exported from db/index.ts): buildHandlerContext
|
|
229
|
+
// (pipeline/dispatch-shared.ts) always builds "system" mode from the caller's
|
|
230
|
+
// own tenantId, never a foreign one.
|
|
231
|
+
//
|
|
232
|
+
// dbOutsideTransaction is optional so every existing single-arg call site
|
|
233
|
+
// (jobs/job-runner.ts, tests) keeps compiling — those callers have no
|
|
234
|
+
// outside-tx source to hand in and never needed one. Only
|
|
235
|
+
// buildHandlerContext passes it, which is also the only place `.outsideTransaction`
|
|
236
|
+
// is reachable through `ctx.systemDb`.
|
|
237
|
+
//
|
|
238
|
+
// Ungated here (the handler's own systemScope() is the grant); a hook's own
|
|
239
|
+
// escapeHatch is layered on afterwards via withSystemDbUnsafeRawGrant. Public
|
|
240
|
+
// callers use createSystemDbView instead, whose unsafeRaw follows the source
|
|
241
|
+
// TenantDb's own escapeHatch gate.
|
|
242
|
+
export function createUncheckedSystemDb(
|
|
243
|
+
db: TenantDb,
|
|
244
|
+
dbOutsideTransaction?: TenantDb,
|
|
245
|
+
report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
|
|
246
|
+
): UncheckedSystemDb {
|
|
247
|
+
return buildUncheckedSystemDb(db, dbOutsideTransaction, report);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Public: must never grant more raw access than the source TenantDb — unlike
|
|
251
|
+
// createUncheckedSystemDb (framework-private; r.systemScope()/a job IS the
|
|
252
|
+
// declaration), this view's unsafeRaw defers entirely to db's own escapeHatch gate.
|
|
253
|
+
export function createSystemDbView(
|
|
254
|
+
db: TenantDb,
|
|
255
|
+
dbOutsideTransaction?: TenantDb,
|
|
256
|
+
report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
|
|
257
|
+
): UncheckedSystemDb {
|
|
258
|
+
return buildUncheckedSystemDb(db, dbOutsideTransaction, report, { kind: "source-tenant-db" });
|
|
259
|
+
}
|
|
260
|
+
|
|
168
261
|
// @cast-boundary tenant-db-row
|
|
169
262
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
170
263
|
return rows as unknown as readonly T[];
|
|
171
264
|
}
|
|
172
265
|
|
|
173
|
-
function tableNameOf(table: Table | EntityTableMeta): string {
|
|
266
|
+
export function tableNameOf(table: Table | EntityTableMeta): string {
|
|
174
267
|
const sym = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
175
268
|
if (typeof sym === "string") return sym;
|
|
176
269
|
return asEntityTableMeta(table)?.tableName ?? "<unknown>";
|
|
@@ -205,8 +298,16 @@ export type TenantDbGrants = {
|
|
|
205
298
|
// Set for a resolved member principal (ctx.queryAsMember): no raw DbRunner leaves
|
|
206
299
|
// this TenantDb, so no handler can COMMIT/RELEASE SAVEPOINT out of the READ ONLY scope.
|
|
207
300
|
readonly memberReadOnly?: boolean;
|
|
301
|
+
// Set only for an anonymous root without personalData: "public-intake" (write-origin.ts).
|
|
302
|
+
readonly personalDataGate?: PersonalDataGate;
|
|
208
303
|
};
|
|
209
304
|
|
|
305
|
+
export type PersonalDataGate = (
|
|
306
|
+
tableName: string,
|
|
307
|
+
keys: readonly string[],
|
|
308
|
+
entity?: EntityDefinition,
|
|
309
|
+
) => void;
|
|
310
|
+
|
|
210
311
|
const unsafeRawRebinders = new WeakMap<
|
|
211
312
|
TenantDb,
|
|
212
313
|
(grant: EscapeHatchDeclaration | undefined) => TenantDb
|
|
@@ -347,6 +448,20 @@ export function createTenantDb(
|
|
|
347
448
|
return grants?.globalWrites?.reason ?? "";
|
|
348
449
|
}
|
|
349
450
|
|
|
451
|
+
function personalDataDenied(
|
|
452
|
+
table: Table | EntityTableMeta,
|
|
453
|
+
keys: readonly string[],
|
|
454
|
+
): AccessDeniedError | undefined {
|
|
455
|
+
if (!grants?.personalDataGate) return undefined;
|
|
456
|
+
try {
|
|
457
|
+
grants.personalDataGate(tableNameOf(table), keys);
|
|
458
|
+
return undefined;
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if (e instanceof AccessDeniedError) return e;
|
|
461
|
+
throw e;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
350
465
|
function foreignTenantOnGlobalWrite(
|
|
351
466
|
table: Table | EntityTableMeta,
|
|
352
467
|
tenantIdValue: unknown,
|
|
@@ -379,7 +494,9 @@ export function createTenantDb(
|
|
|
379
494
|
values: Record<string, unknown>,
|
|
380
495
|
): Promise<T | undefined> {
|
|
381
496
|
const denied =
|
|
382
|
-
missingEscapeHatch(table) ??
|
|
497
|
+
missingEscapeHatch(table) ??
|
|
498
|
+
foreignTenantOnGlobalWrite(table, values["tenantId"]) ??
|
|
499
|
+
personalDataDenied(table, Object.keys(values));
|
|
383
500
|
if (denied) return Promise.reject(denied);
|
|
384
501
|
report("global-write", globalWriteReason());
|
|
385
502
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, values));
|
|
@@ -389,7 +506,9 @@ export function createTenantDb(
|
|
|
389
506
|
where: WhereObject,
|
|
390
507
|
): Promise<readonly T[]> {
|
|
391
508
|
const denied =
|
|
392
|
-
missingEscapeHatch(table) ??
|
|
509
|
+
missingEscapeHatch(table) ??
|
|
510
|
+
foreignTenantOnGlobalWrite(table, set["tenantId"]) ??
|
|
511
|
+
personalDataDenied(table, Object.keys(set));
|
|
393
512
|
if (denied) return Promise.reject(denied);
|
|
394
513
|
if (!where || Object.keys(where).length === 0) {
|
|
395
514
|
return Promise.reject(
|
|
@@ -483,6 +602,8 @@ export function createTenantDb(
|
|
|
483
602
|
);
|
|
484
603
|
if (denied) return Promise.reject(denied);
|
|
485
604
|
}
|
|
605
|
+
const personalDenied = personalDataDenied(table, Object.keys(values));
|
|
606
|
+
if (personalDenied) return Promise.reject(personalDenied);
|
|
486
607
|
const data = insertValues(table, values);
|
|
487
608
|
return withDbSpan("insert", table, async () => bunInsertOne<T>(db, table, data));
|
|
488
609
|
},
|
|
@@ -499,6 +620,8 @@ export function createTenantDb(
|
|
|
499
620
|
),
|
|
500
621
|
);
|
|
501
622
|
}
|
|
623
|
+
const personalDenied = personalDataDenied(table, Object.keys(set));
|
|
624
|
+
if (personalDenied) return Promise.reject(personalDenied);
|
|
502
625
|
const filter = writeWhere(table, where);
|
|
503
626
|
return withDbSpan("update", table, async () => bunUpdateMany<T>(db, table, set, filter));
|
|
504
627
|
},
|
|
@@ -525,6 +648,7 @@ export function createTenantDb(
|
|
|
525
648
|
return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
|
|
526
649
|
});
|
|
527
650
|
bindTenantDbRunner(tenantDb, db);
|
|
651
|
+
if (grants?.personalDataGate) personalDataGates.set(tenantDb, grants.personalDataGate);
|
|
528
652
|
return tenantDb;
|
|
529
653
|
}
|
|
530
654
|
|
|
@@ -15,7 +15,12 @@ import { defineFeature } from "../define-feature";
|
|
|
15
15
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
16
16
|
const fileProvider = (name: string) =>
|
|
17
17
|
defineFeature(`file-provider-${name}`, (r) => {
|
|
18
|
-
|
|
18
|
+
// build is never invoked here — the boot check only cares that a provider is mounted.
|
|
19
|
+
r.useExtension("fileProvider", name, {
|
|
20
|
+
build: async () => {
|
|
21
|
+
throw new Error("test stub — never invoked");
|
|
22
|
+
},
|
|
23
|
+
});
|
|
19
24
|
});
|
|
20
25
|
|
|
21
26
|
const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
@@ -104,8 +104,8 @@ describe("S0 Integration — full surface stack", () => {
|
|
|
104
104
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
r.useExtension(EXT_USER_DATA, "user", {});
|
|
108
|
-
r.useExtension(EXT_USER_DATA, "comment", {});
|
|
107
|
+
r.useExtension(EXT_USER_DATA, "user", { export: async () => null });
|
|
108
|
+
r.useExtension(EXT_USER_DATA, "comment", { export: async () => null });
|
|
109
109
|
|
|
110
110
|
r.writeHandler({
|
|
111
111
|
name: "user:rename",
|
|
@@ -358,7 +358,7 @@ describe("boot-validator", () => {
|
|
|
358
358
|
const self = defineFeature("tier-stub", (r) => {
|
|
359
359
|
r.extendsRegistrar("tenantTierResolver", { onRegister: () => {} });
|
|
360
360
|
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
361
|
-
r.useExtension("tenantTierResolver", "dummy");
|
|
361
|
+
r.useExtension("tenantTierResolver", "dummy", { build: async () => () => new Set() });
|
|
362
362
|
});
|
|
363
363
|
expect(() => validateBoot([self])).not.toThrow();
|
|
364
364
|
});
|
|
@@ -15,7 +15,7 @@ function tierResolverFeature(name: string) {
|
|
|
15
15
|
return defineFeature(name, (r) => {
|
|
16
16
|
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
|
|
17
17
|
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
18
|
-
r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy");
|
|
18
|
+
r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy", { build: async () => () => new Set() });
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1,68 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessAllowsAnonymous,
|
|
3
|
+
declaredPersonalData,
|
|
4
|
+
personalFieldNames,
|
|
5
|
+
} from "../personal-data-fields";
|
|
1
6
|
import { ANONYMOUS_ROLE } from "../system-user";
|
|
2
7
|
import type {
|
|
3
8
|
AccessRule,
|
|
4
9
|
FeatureDefinition,
|
|
5
|
-
OwnershipMap,
|
|
6
|
-
OwnershipRule,
|
|
7
10
|
QueryHandlerDef,
|
|
8
11
|
StreamHandlerDef,
|
|
9
12
|
WriteHandlerDef,
|
|
10
13
|
} from "../types";
|
|
11
|
-
import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
|
|
12
14
|
import { collectZodObjectKeys } from "./zod-shape";
|
|
13
15
|
|
|
14
16
|
type HandlerKind = "write" | "query" | "stream";
|
|
15
17
|
|
|
16
|
-
// Personal-data annotation check mirrors pii-retention.ts's hasAnonymizableSubjectField,
|
|
17
|
-
// minus tenantOwned: a tenant-scoped field isn't an individual's personal data in the
|
|
18
|
-
// sense the openToAll personal-data check is guarding against.
|
|
19
|
-
function isPersonalDataField(field: unknown): boolean {
|
|
20
|
-
const annot = field as ResolvedPiiFlags; // @cast-boundary schema-walk — see pii-retention.ts
|
|
21
|
-
return Boolean(annot.pii || annot.userOwned || annot.recordOwned);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function isCallerIdRuleOn(rule: OwnershipRule, column: string): boolean {
|
|
25
|
-
if (rule === "all" || rule.kind !== "from") return false;
|
|
26
|
-
return rule.refKind === "user" && rule.refPath === "id" && rule.column === column;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// The executor checks access.write against every created/updated row; one "all" role
|
|
30
|
-
// or an empty map (= public) lets a caller write rows owned by someone else.
|
|
31
|
-
function writeMapBindsRowsToCaller(
|
|
32
|
-
writeMap: OwnershipMap | undefined,
|
|
33
|
-
ownerColumn: string,
|
|
34
|
-
): boolean {
|
|
35
|
-
const rules = Object.values(writeMap ?? {});
|
|
36
|
-
return rules.length > 0 && rules.every((rule) => isCallerIdRuleOn(rule, ownerColumn));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const ROW_ID_COLUMN = "id";
|
|
40
|
-
|
|
41
|
-
// A self/record-owned field's subject is the row itself, so only from("user:id", "id")
|
|
42
|
-
// makes that row the caller — on any other entity "self" names a third party.
|
|
43
|
-
function callerBindingColumn(annot: ResolvedPiiFlags): string | undefined {
|
|
44
|
-
if (annot.userOwned) return annot.userOwned.ownerField;
|
|
45
|
-
if (annot.pii || annot.recordOwned) return ROW_ID_COLUMN;
|
|
46
|
-
return undefined;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function isOwnerBoundField(field: unknown, entity: EntityDefinition): boolean {
|
|
50
|
-
const column = callerBindingColumn(field as ResolvedPiiFlags); // @cast-boundary schema-walk — see pii-retention.ts
|
|
51
|
-
return column !== undefined && writeMapBindsRowsToCaller(entity.access?.write, column);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function personalFieldNames(
|
|
55
|
-
entity: EntityDefinition,
|
|
56
|
-
honorOwnerBinding: boolean,
|
|
57
|
-
): ReadonlySet<string> {
|
|
58
|
-
const names = new Set<string>();
|
|
59
|
-
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
60
|
-
const exempt = honorOwnerBinding && isOwnerBoundField(field, entity);
|
|
61
|
-
if (isPersonalDataField(field) && !exempt) names.add(fieldName);
|
|
62
|
-
}
|
|
63
|
-
return names;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
18
|
// escapeHatch and r.systemScope() can write around the entity's write map
|
|
67
19
|
// (db.global(), systemDb, SYSTEM identity), so the map no longer vouches for the row.
|
|
68
20
|
function canWriteAroundExecutor(feature: FeatureDefinition, handler: WriteHandlerDef): boolean {
|
|
@@ -104,19 +56,6 @@ function hasOpenToAll(access: AccessRule): boolean {
|
|
|
104
56
|
return "openToAll" in access;
|
|
105
57
|
}
|
|
106
58
|
|
|
107
|
-
// Read via `unknown`: access can come from untyped sources (pattern JSON, Designer).
|
|
108
|
-
function declaredPersonalData(access: AccessRule): unknown {
|
|
109
|
-
if (!("openToAll" in access)) return access.personalData;
|
|
110
|
-
const openToAll: unknown = access.openToAll;
|
|
111
|
-
if (typeof openToAll !== "object" || openToAll === null) return undefined;
|
|
112
|
-
return "personalData" in openToAll ? openToAll.personalData : undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function accessAllowsAnonymous(access: AccessRule): boolean {
|
|
116
|
-
if ("openToAll" in access) return false;
|
|
117
|
-
return Array.isArray(access.roles) && access.roles.includes(ANONYMOUS_ROLE);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
59
|
function declaresTenantMembersPersonalData(access: AccessRule): boolean {
|
|
121
60
|
return declaredPersonalData(access) === "tenant-members";
|
|
122
61
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
2
|
+
import type { OverlayResolverPlugin } from "../derivatives/derivatives-context";
|
|
3
|
+
import type { FileProviderPlugin } from "../files/provider-resolver";
|
|
4
|
+
import type { PrincipalStatusPlugin, TenantLifecycleStatusPlugin } from "./active-membership";
|
|
5
|
+
import type { TenantDataExtensionHooks } from "./extensions/tenant-data";
|
|
6
|
+
import type { TenantResourceExtensionHooks } from "./extensions/tenant-resource";
|
|
7
|
+
import type { UserDataExtensionOptions } from "./extensions/user-data";
|
|
8
|
+
import { TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin } from "./tier-resolver-extension";
|
|
9
|
+
|
|
1
10
|
// Standardisierte Extension-Namen fuer Datenschutz-Hook-Achsen.
|
|
2
11
|
//
|
|
3
12
|
// Features registrieren Extensions via:
|
|
@@ -55,14 +64,12 @@ export const EXT_USER_DATA_ORDER = {
|
|
|
55
64
|
export const EXT_TENANT_DATA = "tenantData" as const;
|
|
56
65
|
|
|
57
66
|
/**
|
|
58
|
-
* `storageProvider` —
|
|
67
|
+
* `storageProvider` — file-storage-plugin tenant-destroy hook.
|
|
59
68
|
*
|
|
60
|
-
*
|
|
61
|
-
* - `destroyTenant(tenantId, ctx) => Promise<void>`
|
|
62
|
-
* - `destroySubject(subject, ctx) => Promise<{ deleted: number }>`
|
|
69
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
63
70
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
71
|
+
* Registered by: `files-tenant-data`.
|
|
72
|
+
* Consumed by: pluggable providers (Local, MinIO, S3, R2).
|
|
66
73
|
*/
|
|
67
74
|
export const EXT_STORAGE_PROVIDER = "storageProvider" as const;
|
|
68
75
|
|
|
@@ -148,37 +155,28 @@ export const EXT_DERIVATIVE_PUBLIC_PREDICATE = "derivativePublicPredicate" as co
|
|
|
148
155
|
export const EXT_DERIVATIVE_OVERLAY_RESOLVER = "derivativeOverlayResolver" as const;
|
|
149
156
|
|
|
150
157
|
/**
|
|
151
|
-
* `searchAdapter` —
|
|
152
|
-
*
|
|
158
|
+
* `searchAdapter` — search-index tenant-destroy hook (Meilisearch index
|
|
159
|
+
* cleanup on tenant-destroy).
|
|
153
160
|
*
|
|
154
|
-
*
|
|
155
|
-
* - `destroyTenant(tenantId, ctx) => Promise<void>`
|
|
156
|
-
* - `eraseSubject(subject, ctx) => Promise<void>`
|
|
161
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
157
162
|
*
|
|
158
|
-
*
|
|
159
|
-
* Genutzt von: Meilisearch- und andere Search-Adapter-Implementierungen.
|
|
163
|
+
* Consumed by: Meilisearch and other search-adapter implementations.
|
|
160
164
|
*/
|
|
161
165
|
export const EXT_SEARCH_ADAPTER = "searchAdapter" as const;
|
|
162
166
|
|
|
163
167
|
/**
|
|
164
|
-
* `externalResource` —
|
|
165
|
-
*
|
|
168
|
+
* `externalResource` — external-service tenant-destroy hook (webhook
|
|
169
|
+
* subscriptions, Brevo recipient lists, provider customer accounts).
|
|
166
170
|
*
|
|
167
|
-
*
|
|
168
|
-
* - `destroyTenant(tenantId, ctx) => Promise<void>`
|
|
169
|
-
*
|
|
170
|
-
* Registriert von: `tenant-lifecycle` (Sprint 5).
|
|
171
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
171
172
|
*/
|
|
172
173
|
export const EXT_EXTERNAL_RESOURCE = "externalResource" as const;
|
|
173
174
|
|
|
174
175
|
/**
|
|
175
|
-
* `infraResource` — Pulumi-managed
|
|
176
|
-
* (
|
|
176
|
+
* `infraResource` — Pulumi-managed per-tenant resource tenant-destroy hook
|
|
177
|
+
* (custom domain, cert-manager issuer, dedicated pod/volume).
|
|
177
178
|
*
|
|
178
|
-
*
|
|
179
|
-
* - `destroyTenant(tenantId, ctx) => Promise<void>`
|
|
180
|
-
*
|
|
181
|
-
* Registriert von: `tenant-lifecycle` (Sprint 5).
|
|
179
|
+
* Expected hook method: `destroyTenant(tenantId, ctx) => Promise<void>`.
|
|
182
180
|
*/
|
|
183
181
|
export const EXT_INFRA_RESOURCE = "infraResource" as const;
|
|
184
182
|
|
|
@@ -206,7 +204,39 @@ export const TENANT_MEMBERSHIPS_QUERY = "tenant:query:memberships" as const;
|
|
|
206
204
|
export type KumikoExtensionName =
|
|
207
205
|
| typeof EXT_USER_DATA
|
|
208
206
|
| typeof EXT_TENANT_DATA
|
|
207
|
+
| typeof EXT_STORAGE_PROVIDER
|
|
208
|
+
| typeof EXT_SEARCH_ADAPTER
|
|
209
|
+
| typeof EXT_EXTERNAL_RESOURCE
|
|
210
|
+
| typeof EXT_INFRA_RESOURCE
|
|
211
|
+
| typeof EXT_FILE_PROVIDER
|
|
212
|
+
| typeof EXT_DERIVATIVE_RENDERER
|
|
213
|
+
| typeof EXT_DERIVATIVE_PUBLIC_PREDICATE
|
|
214
|
+
| typeof EXT_DERIVATIVE_OVERLAY_RESOLVER
|
|
215
|
+
| typeof EXT_PRINCIPAL_STATUS
|
|
216
|
+
| typeof EXT_TENANT_LIFECYCLE_STATUS;
|
|
217
|
+
|
|
218
|
+
/** The four `destroyTenant(tenantId, ctx)`-only resource-cleanup extension points. */
|
|
219
|
+
export type TenantResourceExtensionName =
|
|
209
220
|
| typeof EXT_STORAGE_PROVIDER
|
|
210
221
|
| typeof EXT_SEARCH_ADAPTER
|
|
211
222
|
| typeof EXT_EXTERNAL_RESOURCE
|
|
212
223
|
| typeof EXT_INFRA_RESOURCE;
|
|
224
|
+
|
|
225
|
+
// r.useExtension options-shape for framework-owned extension points; app/bundled-features-owned
|
|
226
|
+
// points augment co-located with their owner, since the framework never imports upward.
|
|
227
|
+
declare module "@cosmicdrift/kumiko-framework/engine" {
|
|
228
|
+
interface KumikoExtensionOptionsMap {
|
|
229
|
+
[EXT_TENANT_DATA]: TenantDataExtensionHooks;
|
|
230
|
+
[EXT_USER_DATA]: UserDataExtensionOptions;
|
|
231
|
+
[EXT_FILE_PROVIDER]: FileProviderPlugin;
|
|
232
|
+
[EXT_DERIVATIVE_RENDERER]: DerivativeRendererPlugin;
|
|
233
|
+
[EXT_DERIVATIVE_OVERLAY_RESOLVER]: OverlayResolverPlugin;
|
|
234
|
+
[EXT_PRINCIPAL_STATUS]: PrincipalStatusPlugin;
|
|
235
|
+
[EXT_TENANT_LIFECYCLE_STATUS]: TenantLifecycleStatusPlugin;
|
|
236
|
+
[EXT_STORAGE_PROVIDER]: TenantResourceExtensionHooks;
|
|
237
|
+
[EXT_SEARCH_ADAPTER]: TenantResourceExtensionHooks;
|
|
238
|
+
[EXT_EXTERNAL_RESOURCE]: TenantResourceExtensionHooks;
|
|
239
|
+
[EXT_INFRA_RESOURCE]: TenantResourceExtensionHooks;
|
|
240
|
+
[TENANT_TIER_RESOLVER_EXT]: TierResolverPlugin;
|
|
241
|
+
}
|
|
242
|
+
}
|