@cosmicdrift/kumiko-framework 0.299.0 → 0.305.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__/api.test.ts +3 -2
- 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__/http-route-entry.integration.test.ts +114 -0
- package/src/api/__tests__/server-error-logging.test.ts +33 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +79 -34
- package/src/changes.json +68 -0
- package/src/db/event-store-executor-write.ts +7 -0
- package/src/db/tenant-db.ts +50 -3
- package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
- package/src/engine/__tests__/membership-roles.test.ts +13 -4
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
- package/src/engine/boot-validator/access-declarations.ts +58 -67
- package/src/engine/boot-validator/entity-handler.ts +20 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
- package/src/engine/feature-ast/extractors/hooks.ts +3 -1
- package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/patterns.ts +1 -1
- package/src/engine/feature-ast/render.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +6 -0
- package/src/engine/index.ts +4 -0
- package/src/engine/membership-roles.ts +20 -4
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- 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/index.ts +4 -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__/jobs.integration.test.ts +35 -0
- package/src/jobs/job-runner.ts +19 -5
- package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
- package/src/observability/index.ts +5 -0
- package/src/observability/metrics-wiring.ts +32 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +3 -0
- 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/member-reader.ts +3 -1
- 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/handler-context.ts +3 -1
- package/src/ui-types/index.ts +2 -0
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -110,6 +110,10 @@ export type TestStackOptions = {
|
|
|
110
110
|
* MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
|
|
111
111
|
* never `createTestDispatcher`. */
|
|
112
112
|
extraRoutes?: import("../api/server").ServerOptions["extraRoutes"];
|
|
113
|
+
/** Forwarded to buildServer like runProdApp. Without a PrometheusMeter-backed
|
|
114
|
+
* `observability` the route answers 503, so spread `resolveObservabilityWiring(token)`
|
|
115
|
+
* rather than setting `metrics` alone. */
|
|
116
|
+
metrics?: import("../api/server").ServerOptions["metrics"];
|
|
113
117
|
/** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
|
|
114
118
|
* AppContext — set/delete/get + rotation job pick it up. Omit for
|
|
115
119
|
* suites that don't touch secrets. */
|
|
@@ -410,6 +414,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
410
414
|
eventDedup,
|
|
411
415
|
sseBroker,
|
|
412
416
|
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
417
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
413
418
|
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
414
419
|
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
415
420
|
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// production services (delivery-service uses it to run cross-feature notify
|
|
6
6
|
// calls without a real dispatcher). Hence the runtime classification despite
|
|
7
7
|
// living under `testing/` — no vitest imports, no test side-effects.
|
|
8
|
+
|
|
9
|
+
import { ANONYMOUS_ROLE } from "../engine/system-user";
|
|
8
10
|
import type {
|
|
9
11
|
AppendEventArgs,
|
|
10
12
|
FetchForWritingArgs,
|
|
@@ -76,7 +78,7 @@ export function bridgeStub(opts?: {
|
|
|
76
78
|
const stubUser: SessionUser = opts?.user ?? {
|
|
77
79
|
id: "00000000-0000-0000-0000-000000000000",
|
|
78
80
|
tenantId: "00000000-0000-0000-0000-000000000000" as SessionUser["tenantId"], // @cast-boundary engine-bridge
|
|
79
|
-
roles: [
|
|
81
|
+
roles: [ANONYMOUS_ROLE],
|
|
80
82
|
};
|
|
81
83
|
return {
|
|
82
84
|
user: stubUser,
|
package/src/ui-types/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export type {
|
|
|
61
61
|
OpenToAllAccessRule,
|
|
62
62
|
OpenToAllDeclaration,
|
|
63
63
|
OpenToAllPersonalData,
|
|
64
|
+
RoleAccessPersonalData,
|
|
65
|
+
RoleAccessRule,
|
|
64
66
|
} from "../engine/types/handlers";
|
|
65
67
|
export { isOpenToAllGranted } from "../engine/types/handlers";
|
|
66
68
|
export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav";
|