@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
|
@@ -4,7 +4,7 @@ import { filterReadFields } from "../engine/field-access";
|
|
|
4
4
|
import type { QueryHandlerDef, SessionUser } from "../engine/types";
|
|
5
5
|
import { AccessDeniedError, NotFoundError, validationErrorFromZod } from "../errors";
|
|
6
6
|
import { assertNoSecretLeak } from "../secrets";
|
|
7
|
-
import type { DispatchContext } from "./dispatch-shared";
|
|
7
|
+
import type { DispatchContext, WriteOrigin } from "./dispatch-shared";
|
|
8
8
|
import {
|
|
9
9
|
buildHandlerContext,
|
|
10
10
|
enforceRateLimit,
|
|
@@ -21,10 +21,11 @@ export async function executeQuery(
|
|
|
21
21
|
type: string,
|
|
22
22
|
payload: unknown,
|
|
23
23
|
user: SessionUser,
|
|
24
|
+
origin: WriteOrigin,
|
|
24
25
|
tx?: DbTx,
|
|
25
26
|
): Promise<unknown> {
|
|
26
27
|
return runHandlerInstrumented(ctx, type, "query", user, () =>
|
|
27
|
-
executeQueryInner(ctx, type, payload, user, tx),
|
|
28
|
+
executeQueryInner(ctx, type, payload, user, origin, tx),
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -33,6 +34,7 @@ async function executeQueryInner(
|
|
|
33
34
|
type: string,
|
|
34
35
|
payload: unknown,
|
|
35
36
|
user: SessionUser,
|
|
37
|
+
origin: WriteOrigin,
|
|
36
38
|
tx?: DbTx,
|
|
37
39
|
): Promise<unknown> {
|
|
38
40
|
const { registry } = ctx;
|
|
@@ -83,9 +85,9 @@ async function executeQueryInner(
|
|
|
83
85
|
// A resolved member (ctx.queryAsMember) runs in a Postgres READ ONLY transaction, not just the ctx surface below.
|
|
84
86
|
return user.origin === "member-resolution"
|
|
85
87
|
? runInMemberReadOnlyTransaction(ctx, tx, (readOnlyTx) =>
|
|
86
|
-
runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, readOnlyTx),
|
|
88
|
+
runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, readOnlyTx),
|
|
87
89
|
)
|
|
88
|
-
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, tx);
|
|
90
|
+
: runQueryHandler(ctx, type, handler, parsed.data, includeDeleted, user, origin, tx);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
async function runQueryHandler(
|
|
@@ -95,10 +97,19 @@ async function runQueryHandler(
|
|
|
95
97
|
payload: unknown,
|
|
96
98
|
includeDeleted: boolean,
|
|
97
99
|
user: SessionUser,
|
|
100
|
+
origin: WriteOrigin,
|
|
98
101
|
tx: DbTx | undefined,
|
|
99
102
|
): Promise<unknown> {
|
|
100
103
|
const { registry } = ctx;
|
|
101
|
-
const handlerContext = await buildHandlerContext(
|
|
104
|
+
const handlerContext = await buildHandlerContext(
|
|
105
|
+
ctx,
|
|
106
|
+
type,
|
|
107
|
+
user,
|
|
108
|
+
origin,
|
|
109
|
+
tx,
|
|
110
|
+
undefined,
|
|
111
|
+
includeDeleted,
|
|
112
|
+
);
|
|
102
113
|
let result = await handler.handler({ type, payload, user }, handlerContext);
|
|
103
114
|
|
|
104
115
|
// postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
|
|
@@ -100,6 +100,9 @@ import {
|
|
|
100
100
|
systemIdentitySwitchDenied,
|
|
101
101
|
} from "./system-identity-switch";
|
|
102
102
|
import type { TenantTimezoneCache } from "./tenant-timezone-cache";
|
|
103
|
+
import { buildPersonalDataGate, rootWriteOrigin, type WriteOrigin } from "./write-origin";
|
|
104
|
+
|
|
105
|
+
export type { WriteOrigin } from "./write-origin";
|
|
103
106
|
|
|
104
107
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
105
108
|
// `tenant` feature — the literal below IS the coupling to its `timezone`
|
|
@@ -277,6 +280,7 @@ export async function buildHandlerContext(
|
|
|
277
280
|
ctx: DispatchContext,
|
|
278
281
|
type: string,
|
|
279
282
|
user: SessionUser,
|
|
283
|
+
origin: WriteOrigin,
|
|
280
284
|
tx?: DbTx,
|
|
281
285
|
afterCommitHooks?: AfterCommitHook[],
|
|
282
286
|
includeDeleted?: boolean,
|
|
@@ -320,6 +324,7 @@ export async function buildHandlerContext(
|
|
|
320
324
|
unsafeRaw: handlerEscapeHatch,
|
|
321
325
|
report: reportEscapeHatch,
|
|
322
326
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
327
|
+
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
323
328
|
},
|
|
324
329
|
);
|
|
325
330
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
@@ -421,10 +426,11 @@ export async function buildHandlerContext(
|
|
|
421
426
|
user,
|
|
422
427
|
hasIdentitySwitchGrant,
|
|
423
428
|
{
|
|
429
|
+
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
424
430
|
queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
425
|
-
executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
431
|
+
executeQuery(ctx, targetType, payload, asUser, origin, tx), // @wrapper-known semantic-alias
|
|
426
432
|
writeAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
427
|
-
executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink),
|
|
433
|
+
executeWrite(ctx, targetType, payload, asUser, origin, tx, bridgeSink),
|
|
428
434
|
},
|
|
429
435
|
identitySwitchAudit,
|
|
430
436
|
);
|
|
@@ -483,10 +489,10 @@ export async function buildHandlerContext(
|
|
|
483
489
|
);
|
|
484
490
|
const bridge = {
|
|
485
491
|
query: (targetType: string, payload: unknown) =>
|
|
486
|
-
executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
492
|
+
executeQuery(ctx, targetType, payload, user, origin, tx), // @wrapper-known semantic-alias
|
|
487
493
|
queryAs: identitySwitch.queryAs,
|
|
488
494
|
write: async (targetType: string, payload: unknown) => {
|
|
489
|
-
const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
|
|
495
|
+
const res = await executeWrite(ctx, targetType, payload, user, origin, tx, bridgeSink);
|
|
490
496
|
return res;
|
|
491
497
|
},
|
|
492
498
|
writeAs: identitySwitch.writeAs,
|
|
@@ -1175,8 +1181,9 @@ function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthCl
|
|
|
1175
1181
|
})
|
|
1176
1182
|
: undefined;
|
|
1177
1183
|
const identitySwitch = createGatedIdentitySwitch("r.authClaims hook", user, false, {
|
|
1184
|
+
// Login is itself the root operation; the hook context is read-only.
|
|
1178
1185
|
queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
|
|
1179
|
-
executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
|
|
1186
|
+
executeQuery(ctx, qn, payload, asUser, rootWriteOrigin(ctx.registry, qn, asUser)), // @wrapper-known semantic-alias
|
|
1180
1187
|
writeAs: async () => {
|
|
1181
1188
|
throw new InternalError({
|
|
1182
1189
|
message: "r.authClaims hook context has no writeAs — auth-claims hooks are read-only.",
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ensureFeatureEnabled,
|
|
15
15
|
isMemberResolutionPrincipal,
|
|
16
16
|
runStreamInstrumented,
|
|
17
|
+
type WriteOrigin,
|
|
17
18
|
} from "./dispatch-shared";
|
|
18
19
|
|
|
19
20
|
// Standalone stream execution — used by the public dispatcher.stream().
|
|
@@ -27,8 +28,11 @@ export async function* executeStream(
|
|
|
27
28
|
type: string,
|
|
28
29
|
payload: unknown,
|
|
29
30
|
user: SessionUser,
|
|
31
|
+
origin: WriteOrigin,
|
|
30
32
|
): AsyncGenerator<unknown> {
|
|
31
|
-
yield* runStreamInstrumented(ctx, type, user, () =>
|
|
33
|
+
yield* runStreamInstrumented(ctx, type, user, () =>
|
|
34
|
+
executeStreamInner(ctx, type, payload, user, origin),
|
|
35
|
+
);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
async function* executeStreamInner(
|
|
@@ -36,6 +40,7 @@ async function* executeStreamInner(
|
|
|
36
40
|
type: string,
|
|
37
41
|
payload: unknown,
|
|
38
42
|
user: SessionUser,
|
|
43
|
+
origin: WriteOrigin,
|
|
39
44
|
): AsyncGenerator<unknown> {
|
|
40
45
|
const { registry } = ctx;
|
|
41
46
|
const handler = registry.getStreamHandler(type);
|
|
@@ -80,7 +85,7 @@ async function* executeStreamInner(
|
|
|
80
85
|
// await; close is fire-and-forget instead (#1563).
|
|
81
86
|
let abandonedForInvalidation = false;
|
|
82
87
|
try {
|
|
83
|
-
const handlerContext = await buildHandlerContext(ctx, type, user);
|
|
88
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, origin);
|
|
84
89
|
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
85
90
|
iterator = chunks[Symbol.asyncIterator]();
|
|
86
91
|
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
writeFailure,
|
|
23
23
|
} from "../errors";
|
|
24
24
|
import { assertNoSecretLeak } from "../secrets";
|
|
25
|
-
import type { DispatchContext } from "./dispatch-shared";
|
|
25
|
+
import type { DispatchContext, WriteOrigin } from "./dispatch-shared";
|
|
26
26
|
import {
|
|
27
27
|
buildHandlerContext,
|
|
28
28
|
CONFIG_WRITE_RESET_TYPE,
|
|
@@ -129,6 +129,7 @@ async function runLifecycle(
|
|
|
129
129
|
data: unknown,
|
|
130
130
|
handlerContext: HandlerContext,
|
|
131
131
|
user: SessionUser,
|
|
132
|
+
origin: WriteOrigin,
|
|
132
133
|
afterCommitHooks: AfterCommitHook[],
|
|
133
134
|
runner: DbRunner | undefined,
|
|
134
135
|
): Promise<void> {
|
|
@@ -156,6 +157,7 @@ async function runLifecycle(
|
|
|
156
157
|
ctx,
|
|
157
158
|
type,
|
|
158
159
|
user,
|
|
160
|
+
origin,
|
|
159
161
|
undefined,
|
|
160
162
|
afterCommitHooks,
|
|
161
163
|
);
|
|
@@ -169,6 +171,7 @@ async function runLifecycle(
|
|
|
169
171
|
ctx,
|
|
170
172
|
type,
|
|
171
173
|
user,
|
|
174
|
+
origin,
|
|
172
175
|
undefined,
|
|
173
176
|
afterCommitHooks,
|
|
174
177
|
);
|
|
@@ -192,11 +195,12 @@ export async function executeWrite(
|
|
|
192
195
|
type: string,
|
|
193
196
|
payload: unknown,
|
|
194
197
|
user: SessionUser,
|
|
198
|
+
origin: WriteOrigin,
|
|
195
199
|
tx: DbTx | undefined,
|
|
196
200
|
afterCommitHooks: AfterCommitHook[],
|
|
197
201
|
): Promise<WriteResult> {
|
|
198
202
|
return runHandlerInstrumented(ctx, type, "write", user, () =>
|
|
199
|
-
executeWriteInner(ctx, type, payload, user, tx, afterCommitHooks),
|
|
203
|
+
executeWriteInner(ctx, type, payload, user, origin, tx, afterCommitHooks),
|
|
200
204
|
);
|
|
201
205
|
}
|
|
202
206
|
|
|
@@ -227,12 +231,13 @@ export async function executeNestedWrite(
|
|
|
227
231
|
type: string,
|
|
228
232
|
payload: unknown,
|
|
229
233
|
user: SessionUser,
|
|
234
|
+
origin: WriteOrigin,
|
|
230
235
|
tx: DbTx | undefined,
|
|
231
236
|
afterCommitHooks: AfterCommitHook[],
|
|
232
237
|
): Promise<WriteResult> {
|
|
233
238
|
const { registry } = ctx;
|
|
234
239
|
const nested = extractNestedSpecs(type, payload, registry);
|
|
235
|
-
if (!nested) return executeWrite(ctx, type, payload, user, tx, afterCommitHooks);
|
|
240
|
+
if (!nested) return executeWrite(ctx, type, payload, user, origin, tx, afterCommitHooks);
|
|
236
241
|
|
|
237
242
|
// Pre-flight client-shape checks. Merge non-array issues (collected up
|
|
238
243
|
// front by extractNestedSpecs) with fk-injection issues into one error
|
|
@@ -264,6 +269,7 @@ export async function executeNestedWrite(
|
|
|
264
269
|
type,
|
|
265
270
|
nested.cleanPayload,
|
|
266
271
|
user,
|
|
272
|
+
origin,
|
|
267
273
|
tx,
|
|
268
274
|
afterCommitHooks,
|
|
269
275
|
);
|
|
@@ -339,6 +345,7 @@ export async function executeNestedWrite(
|
|
|
339
345
|
spec.subType,
|
|
340
346
|
subPayload,
|
|
341
347
|
user,
|
|
348
|
+
origin,
|
|
342
349
|
tx,
|
|
343
350
|
afterCommitHooks,
|
|
344
351
|
);
|
|
@@ -363,6 +370,7 @@ async function executeWriteInner(
|
|
|
363
370
|
type: string,
|
|
364
371
|
payload: unknown,
|
|
365
372
|
user: SessionUser,
|
|
373
|
+
origin: WriteOrigin,
|
|
366
374
|
tx: DbTx | undefined,
|
|
367
375
|
afterCommitHooks: AfterCommitHook[],
|
|
368
376
|
): Promise<WriteResult> {
|
|
@@ -461,7 +469,7 @@ async function executeWriteInner(
|
|
|
461
469
|
}
|
|
462
470
|
}
|
|
463
471
|
|
|
464
|
-
const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
|
|
472
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, origin, tx, afterCommitHooks);
|
|
465
473
|
|
|
466
474
|
// Auto transition guard: if entity has transitions and handler doesn't skip it.
|
|
467
475
|
// Reads via the guard's own db handle — for r.systemScope() handlers
|
|
@@ -553,7 +561,16 @@ async function executeWriteInner(
|
|
|
553
561
|
if (result.isSuccess) {
|
|
554
562
|
try {
|
|
555
563
|
const runner = resolveDbSource(ctx, tx);
|
|
556
|
-
await runLifecycle(
|
|
564
|
+
await runLifecycle(
|
|
565
|
+
ctx,
|
|
566
|
+
type,
|
|
567
|
+
result.data,
|
|
568
|
+
handlerContext,
|
|
569
|
+
user,
|
|
570
|
+
origin,
|
|
571
|
+
afterCommitHooks,
|
|
572
|
+
runner,
|
|
573
|
+
);
|
|
557
574
|
} catch (e) {
|
|
558
575
|
return writeFailure(wrapToKumiko(e));
|
|
559
576
|
}
|
|
@@ -32,6 +32,7 @@ import type { IdempotencyGuard } from "./idempotency";
|
|
|
32
32
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
33
33
|
import { createMemberReaderFn } from "./member-reader";
|
|
34
34
|
import { createTenantTimezoneCache } from "./tenant-timezone-cache";
|
|
35
|
+
import { rootWriteOrigin } from "./write-origin";
|
|
35
36
|
|
|
36
37
|
// Re-export for callers that reach for dispatcher-adjacent types (tests,
|
|
37
38
|
// HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
|
|
@@ -182,9 +183,15 @@ export function createDispatcher(
|
|
|
182
183
|
|
|
183
184
|
batch: (commands, user, requestId?) => runBatch(ctx, commands, user, requestId),
|
|
184
185
|
|
|
185
|
-
query: (typeOrRef, payload, user) =>
|
|
186
|
+
query: (typeOrRef, payload, user) => {
|
|
187
|
+
const type = resolveType(typeOrRef);
|
|
188
|
+
return executeQuery(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
|
|
189
|
+
},
|
|
186
190
|
|
|
187
|
-
stream: (typeOrRef, payload, user) =>
|
|
191
|
+
stream: (typeOrRef, payload, user) => {
|
|
192
|
+
const type = resolveType(typeOrRef);
|
|
193
|
+
return executeStream(ctx, type, payload, user, rootWriteOrigin(registry, type, user));
|
|
194
|
+
},
|
|
188
195
|
|
|
189
196
|
async command(typeOrRef, payload, user) {
|
|
190
197
|
const type = resolveType(typeOrRef);
|
|
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
|
|
|
19
19
|
token: string,
|
|
20
20
|
result: unknown,
|
|
21
21
|
): Promise<void>;
|
|
22
|
+
release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// Sentinel prefix stored under the key while the handler is running. Each
|
|
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
|
|
|
64
65
|
end
|
|
65
66
|
`;
|
|
66
67
|
|
|
68
|
+
// Same CAS guard as storeScript: only clear the lock if we still own it.
|
|
69
|
+
// A stale token (lock already reclaimed by a new owner) is a no-op.
|
|
70
|
+
const releaseScript = `
|
|
71
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
72
|
+
return redis.call("del", KEYS[1])
|
|
73
|
+
else
|
|
74
|
+
return 0
|
|
75
|
+
end
|
|
76
|
+
`;
|
|
77
|
+
|
|
67
78
|
async function tryAcquire(key: string): Promise<string | null> {
|
|
68
79
|
const token = `${PENDING_PREFIX}${generateId()}`;
|
|
69
80
|
const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
|
|
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
|
|
|
138
149
|
// SET and could stomp that fresher result with our stale one.
|
|
139
150
|
void written;
|
|
140
151
|
},
|
|
152
|
+
|
|
153
|
+
async release(tenantId, userId, requestId, token) {
|
|
154
|
+
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
155
|
+
await redis.eval(releaseScript, 1, key, token);
|
|
156
|
+
},
|
|
141
157
|
};
|
|
142
158
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import { executeQuery } from "./dispatch-query";
|
|
16
16
|
import { type DispatchContext, resolveAuthClaimsFn, resolveDbSource } from "./dispatch-shared";
|
|
17
17
|
import { isSystemIdentity } from "./system-identity-switch";
|
|
18
|
+
import { rootWriteOrigin } from "./write-origin";
|
|
18
19
|
|
|
19
20
|
// Stricter than interactive sign-in: an unknown principal or a tenant
|
|
20
21
|
// mid-teardown must not resolve — there is no user-facing flow to recover.
|
|
@@ -103,6 +104,7 @@ export function createMemberReaderFn(
|
|
|
103
104
|
|
|
104
105
|
return async (userId, qn, payload) => {
|
|
105
106
|
const user = await resolve(userId);
|
|
106
|
-
|
|
107
|
+
// A resolved member is never anonymous and read-only, so it may start its own root.
|
|
108
|
+
return executeQuery(ctx, qn, payload, user, rootWriteOrigin(ctx.registry, qn, user), tx);
|
|
107
109
|
};
|
|
108
110
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type TenantDb,
|
|
3
|
+
type UncheckedSystemDb,
|
|
4
|
+
withSystemDbUnsafeRawGrant,
|
|
5
|
+
withUnsafeRawGrant,
|
|
6
|
+
} from "../db/tenant-db";
|
|
2
7
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
3
8
|
import type {
|
|
4
9
|
ActiveMembershipResult,
|
|
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
|
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
|
|
217
|
-
function readDbLikeValue(
|
|
222
|
+
function readDbLikeValue(
|
|
223
|
+
context: object,
|
|
224
|
+
key: "db" | "dbOutsideTransaction" | "systemDb",
|
|
225
|
+
): object | undefined {
|
|
218
226
|
if (!(key in context)) return undefined;
|
|
219
227
|
const value = (context as Record<string, unknown>)[key];
|
|
220
228
|
return typeof value === "object" && value !== null ? value : undefined;
|
|
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
340
348
|
const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
|
|
341
349
|
const ctxDb = readDbLikeValue(context, "db");
|
|
342
350
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
351
|
+
const ctxSystemDb = readDbLikeValue(context, "systemDb");
|
|
343
352
|
if (
|
|
344
353
|
!ctxQueryAs &&
|
|
345
354
|
!ctxWriteAs &&
|
|
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
347
356
|
!ctxQueryAsMember &&
|
|
348
357
|
!ctxQueryProjection &&
|
|
349
358
|
!ctxDb &&
|
|
350
|
-
!ctxDbOutsideTransaction
|
|
359
|
+
!ctxDbOutsideTransaction &&
|
|
360
|
+
!ctxSystemDb
|
|
351
361
|
) {
|
|
352
362
|
return context;
|
|
353
363
|
}
|
|
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
366
376
|
...(ctxDbOutsideTransaction && {
|
|
367
377
|
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
|
|
368
378
|
}),
|
|
379
|
+
// @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
|
|
380
|
+
...(ctxSystemDb && {
|
|
381
|
+
systemDb: withSystemDbUnsafeRawGrant(
|
|
382
|
+
ctxSystemDb as UncheckedSystemDb,
|
|
383
|
+
escapeHatch,
|
|
384
|
+
callerLabel,
|
|
385
|
+
),
|
|
386
|
+
}),
|
|
369
387
|
};
|
|
370
388
|
}
|
|
371
389
|
|
|
372
|
-
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
390
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
|
|
373
391
|
export function bindHookEscapeHatchGrant(
|
|
374
392
|
fn: LifecycleHookFn,
|
|
375
393
|
label: string,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The static check in boot-validator/access-declarations.ts only sees a handler's own
|
|
2
|
+
// input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
|
|
3
|
+
// tables are only visible at the actual write, so the gate runs there at runtime.
|
|
4
|
+
import { buildEntityTable } from "../db/table-builder";
|
|
5
|
+
import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
|
|
6
|
+
import {
|
|
7
|
+
accessAllowsAnonymous,
|
|
8
|
+
declaredPersonalData,
|
|
9
|
+
personalFieldNames,
|
|
10
|
+
} from "../engine/personal-data-fields";
|
|
11
|
+
import { ANONYMOUS_ROLE } from "../engine/system-user";
|
|
12
|
+
import type { AccessRule, Registry, SessionUser } from "../engine/types";
|
|
13
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
14
|
+
import { AccessDeniedError } from "../errors";
|
|
15
|
+
import { FrameworkReasons } from "../errors/reasons";
|
|
16
|
+
import { toSnakeCase } from "../utils/case";
|
|
17
|
+
|
|
18
|
+
export type WriteOrigin = {
|
|
19
|
+
readonly rootHandler: string;
|
|
20
|
+
readonly anonymousRoot: boolean;
|
|
21
|
+
// Only a write-handler root can declare public-intake; a query/stream root never does.
|
|
22
|
+
readonly publicIntake: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function declaresPublicIntake(access: AccessRule): boolean {
|
|
26
|
+
return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Only the public dispatcher entry points compute a root; every nested call inherits
|
|
30
|
+
// it, so switching identity via ctx.writeAs(SYSTEM, ...) cannot shed an anonymous root.
|
|
31
|
+
export function rootWriteOrigin(registry: Registry, type: string, user: SessionUser): WriteOrigin {
|
|
32
|
+
const writeHandler = registry.getWriteHandler(type);
|
|
33
|
+
return {
|
|
34
|
+
rootHandler: type,
|
|
35
|
+
anonymousRoot: user.roles.includes(ANONYMOUS_ROLE),
|
|
36
|
+
publicIntake: writeHandler !== undefined && declaresPublicIntake(writeHandler.access),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Owner binding is ignored (honorOwnerBinding=false): all anonymous callers share one
|
|
41
|
+
// user id, so from("user:id", ...) vouches for nobody.
|
|
42
|
+
const personalDataTableMaps = new WeakMap<Registry, ReadonlyMap<string, ReadonlySet<string>>>();
|
|
43
|
+
|
|
44
|
+
function personalColumnNames(entity: EntityDefinition): ReadonlySet<string> {
|
|
45
|
+
return new Set([...personalFieldNames(entity, false)].map(toSnakeCase));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildPersonalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlySet<string>> {
|
|
49
|
+
const map = new Map<string, ReadonlySet<string>>();
|
|
50
|
+
for (const [entityName, entity] of registry.getAllEntities()) {
|
|
51
|
+
const columns = personalColumnNames(entity);
|
|
52
|
+
if (columns.size === 0) continue;
|
|
53
|
+
const table = buildEntityTable(entityName, entity, {
|
|
54
|
+
relations: registry.getRelations(entityName),
|
|
55
|
+
});
|
|
56
|
+
map.set(tableNameOf(table), columns);
|
|
57
|
+
}
|
|
58
|
+
return map;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlySet<string>> {
|
|
62
|
+
const cached = personalDataTableMaps.get(registry);
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
const built = buildPersonalDataTableMap(registry);
|
|
65
|
+
personalDataTableMaps.set(registry, built);
|
|
66
|
+
return built;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Names fields only, never values: the error reaches the anonymous HTTP caller.
|
|
70
|
+
function publicIntakeRequiredError(
|
|
71
|
+
origin: WriteOrigin,
|
|
72
|
+
target: string,
|
|
73
|
+
fields: readonly string[],
|
|
74
|
+
): AccessDeniedError {
|
|
75
|
+
return new AccessDeniedError({
|
|
76
|
+
message:
|
|
77
|
+
`Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
|
|
78
|
+
`${fields.map((f) => `"${f}"`).join(", ")} on "${target}". Declare ` +
|
|
79
|
+
'access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
|
|
80
|
+
`"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
|
|
81
|
+
details: {
|
|
82
|
+
reason: FrameworkReasons.publicIntakeRequired,
|
|
83
|
+
rootHandler: origin.rootHandler,
|
|
84
|
+
target,
|
|
85
|
+
fields,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Without an entity the table name is looked up in the registry map; tables outside it
|
|
91
|
+
// (unmanaged stores, hand-built tables) carry no personal-data annotations and pass.
|
|
92
|
+
export function buildPersonalDataGate(
|
|
93
|
+
registry: Registry,
|
|
94
|
+
origin: WriteOrigin,
|
|
95
|
+
): PersonalDataGate | undefined {
|
|
96
|
+
if (!origin.anonymousRoot || origin.publicIntake) return undefined;
|
|
97
|
+
const map = personalDataTableMap(registry);
|
|
98
|
+
return (tableName, keys, entity) => {
|
|
99
|
+
const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
|
|
100
|
+
// skip: table carries no personal-data annotations
|
|
101
|
+
if (!personalFields) return;
|
|
102
|
+
const offending = [...new Set(keys.map(toSnakeCase))].filter((k) => personalFields.has(k));
|
|
103
|
+
// skip: write touches no personal-data field
|
|
104
|
+
if (offending.length === 0) return;
|
|
105
|
+
throw publicIntakeRequiredError(origin, tableName, offending);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -186,4 +186,44 @@ describe("authEndpointRateLimit (L2)", () => {
|
|
|
186
186
|
const otherAcc = await reqA("user-b");
|
|
187
187
|
expect(otherAcc.status).toBe(200);
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
test("GET /api/auth/tenants is exempt (session read), but POST on the same path is not", async () => {
|
|
191
|
+
const app = new Hono();
|
|
192
|
+
app.use(
|
|
193
|
+
"/api/auth/*",
|
|
194
|
+
authEndpointRateLimit({ resolver, limit: 2, windowSeconds: 60, onFailClosed: () => {} }),
|
|
195
|
+
);
|
|
196
|
+
app.get("/api/auth/tenants", (c) => c.text("ok"));
|
|
197
|
+
app.post("/api/auth/tenants", (c) => c.text("ok"));
|
|
198
|
+
app.post("/api/auth/login", (c) => c.text("ok"));
|
|
199
|
+
|
|
200
|
+
const ipHeader = { "x-forwarded-for": "10.0.2.1" };
|
|
201
|
+
|
|
202
|
+
// 10 GETs — none consume the l2:ip:/api/auth/tenants bucket.
|
|
203
|
+
for (let i = 0; i < 10; i++) {
|
|
204
|
+
const res = await app.request("/api/auth/tenants", { headers: ipHeader });
|
|
205
|
+
expect(res.status).toBe(200);
|
|
206
|
+
// Exempt requests skip the resolver entirely — no rate-limit headers.
|
|
207
|
+
expect(res.headers.get("X-RateLimit-Limit")).toBeNull();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// A separate credential-endpoint bucket is untouched by the above.
|
|
211
|
+
await app.request("/api/auth/login", { method: "POST", headers: ipHeader });
|
|
212
|
+
await app.request("/api/auth/login", { method: "POST", headers: ipHeader });
|
|
213
|
+
const loginBlocked = await app.request("/api/auth/login", {
|
|
214
|
+
method: "POST",
|
|
215
|
+
headers: ipHeader,
|
|
216
|
+
});
|
|
217
|
+
expect(loginBlocked.status).toBe(429);
|
|
218
|
+
|
|
219
|
+
// POST on the exempt path is method-exact, not path-exact — it still
|
|
220
|
+
// shares the ordinary ip+path bucket and trips at the same limit.
|
|
221
|
+
await app.request("/api/auth/tenants", { method: "POST", headers: ipHeader });
|
|
222
|
+
await app.request("/api/auth/tenants", { method: "POST", headers: ipHeader });
|
|
223
|
+
const postTenantsBlocked = await app.request("/api/auth/tenants", {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: ipHeader,
|
|
226
|
+
});
|
|
227
|
+
expect(postTenantsBlocked.status).toBe(429);
|
|
228
|
+
});
|
|
189
229
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
|
+
import { isAuthRateLimitExempt } from "../api/api-constants";
|
|
2
3
|
import { requestContext } from "../api/request-context";
|
|
3
4
|
import { RateLimitError, serializeError } from "../errors";
|
|
4
5
|
import type { RateLimitDecision, RateLimitResolver } from "./resolver";
|
|
@@ -84,6 +85,8 @@ export function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): Middl
|
|
|
84
85
|
const onFailClosed = opts.onFailClosed ?? defaultOnFailClosed("l2-auth-endpoints");
|
|
85
86
|
|
|
86
87
|
return async (c, next) => {
|
|
88
|
+
if (isAuthRateLimitExempt(c.req.method, c.req.path)) return next();
|
|
89
|
+
|
|
87
90
|
const ip = extractIp(c);
|
|
88
91
|
if (!ip) return next();
|
|
89
92
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../engine";
|
|
4
|
+
import { resolveObservabilityWiring } from "../../observability/metrics-wiring";
|
|
5
|
+
import { setupTestStack, type TestStack } from "../test-stack";
|
|
6
|
+
import { TestUsers } from "../test-users";
|
|
7
|
+
|
|
8
|
+
const METRICS_TOKEN = "setup-test-stack-metrics-token-minimum-32-chars!!";
|
|
9
|
+
|
|
10
|
+
const pingFeature = defineFeature("stmetrics", (r) => {
|
|
11
|
+
r.writeHandler(
|
|
12
|
+
"ping",
|
|
13
|
+
z.object({}),
|
|
14
|
+
async () => ({ isSuccess: true as const, data: { ok: true } }),
|
|
15
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let stack: TestStack | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await stack?.cleanup();
|
|
23
|
+
stack = undefined;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("setupTestStack metrics option (integration)", () => {
|
|
27
|
+
test("mirrors runProdApp wiring: /metrics scrapes after a real request, token-gated", async () => {
|
|
28
|
+
stack = await setupTestStack({
|
|
29
|
+
features: [pingFeature],
|
|
30
|
+
...resolveObservabilityWiring(METRICS_TOKEN),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// kumiko_http_requests_total is only recorded by the http middleware, so scrape after a real request.
|
|
34
|
+
await stack.http.command("stmetrics:write:ping", {}, TestUsers.admin);
|
|
35
|
+
|
|
36
|
+
const noAuth = await stack.app.request("/metrics");
|
|
37
|
+
expect(noAuth.status).toBe(401);
|
|
38
|
+
|
|
39
|
+
const wrongToken = await stack.app.request("/metrics", {
|
|
40
|
+
headers: { Authorization: "Bearer wrong-token" },
|
|
41
|
+
});
|
|
42
|
+
expect(wrongToken.status).toBe(401);
|
|
43
|
+
|
|
44
|
+
const scraped = await stack.app.request("/metrics", {
|
|
45
|
+
headers: { Authorization: `Bearer ${METRICS_TOKEN}` },
|
|
46
|
+
});
|
|
47
|
+
expect(scraped.status).toBe(200);
|
|
48
|
+
expect(scraped.headers.get("Content-Type")).toMatch(/openmetrics-text/);
|
|
49
|
+
const body = await scraped.text();
|
|
50
|
+
expect(body).toMatch(/kumiko_http_requests_total\{[^}]*route="\/api\/command"[^}]*\} 1/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no metrics option: /metrics is unmounted (404)", async () => {
|
|
54
|
+
stack = await setupTestStack({ features: [pingFeature] });
|
|
55
|
+
|
|
56
|
+
const res = await stack.app.request("/metrics");
|
|
57
|
+
expect(res.status).toBe(404);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("custom path override: mounts only at the overridden path", async () => {
|
|
61
|
+
const wiring = resolveObservabilityWiring(METRICS_TOKEN);
|
|
62
|
+
if (!("metrics" in wiring)) throw new Error("resolveObservabilityWiring did not wire metrics");
|
|
63
|
+
|
|
64
|
+
stack = await setupTestStack({
|
|
65
|
+
features: [pingFeature],
|
|
66
|
+
...wiring,
|
|
67
|
+
metrics: { ...wiring.metrics, path: "/internal/metrics" },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const atDefault = await stack.app.request("/metrics");
|
|
71
|
+
expect(atDefault.status).toBe(404);
|
|
72
|
+
|
|
73
|
+
const atCustom = await stack.app.request("/internal/metrics", {
|
|
74
|
+
headers: { Authorization: `Bearer ${METRICS_TOKEN}` },
|
|
75
|
+
});
|
|
76
|
+
expect(atCustom.status).toBe(200);
|
|
77
|
+
expect(atCustom.headers.get("Content-Type")).toMatch(/openmetrics-text/);
|
|
78
|
+
});
|
|
79
|
+
});
|