@cosmicdrift/kumiko-framework 0.381.0 → 0.383.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/agents/examples/api-worker-split.md +57 -0
- package/agents/examples/record-detail-layout.md +20 -18
- package/agents/examples/screens-nav.md +3 -2
- package/agents/examples/writeform-section.md +2 -1
- package/dist/api/api-constants.d.ts +1 -0
- package/dist/api/api-constants.js +2 -0
- package/dist/api/auth-middleware.d.ts +1 -0
- package/dist/api/auth-middleware.js +17 -0
- package/dist/api/auth-routes.d.ts +1 -0
- package/dist/api/auth-routes.js +14 -4
- package/dist/api/client-error-route.d.ts +12 -0
- package/dist/api/client-error-route.js +113 -0
- package/dist/api/request-context.d.ts +6 -0
- package/dist/api/request-context.js +9 -0
- package/dist/api/routes.js +7 -4
- package/dist/api/server.js +11 -1
- package/dist/db/connection.d.ts +7 -0
- package/dist/db/connection.js +40 -0
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/migrate-runner.js +4 -0
- package/dist/db/queries/backfill-pii.js +5 -1
- package/dist/db/queries/event-store.js +9 -2
- package/dist/db/statement-timeout.d.ts +2 -0
- package/dist/db/statement-timeout.js +18 -0
- package/dist/db/tenant-db.js +5 -12
- package/dist/engine/boot-validator/access-declarations.js +17 -18
- package/dist/engine/boot-validator/query-output-columns.js +1 -2
- package/dist/engine/boot-validator/screens.js +12 -16
- package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
- package/dist/engine/build-config-feature-schema.js +4 -4
- package/dist/engine/entity-handlers.js +5 -7
- package/dist/engine/escape-hatch-declaration.d.ts +1 -1
- package/dist/engine/escape-hatch-declaration.js +5 -2
- package/dist/engine/escape-hatch-reason.d.ts +2 -0
- package/dist/engine/escape-hatch-reason.js +35 -0
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/feature-config-events-jobs.js +6 -4
- package/dist/engine/feature-ui-extensions.js +12 -6
- package/dist/engine/iban.d.ts +2 -0
- package/dist/engine/iban.js +19 -0
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +8 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +9 -0
- package/dist/engine/screen-helpers.d.ts +3 -3
- package/dist/engine/screen-helpers.js +1 -2
- package/dist/engine/steps/_read-source.js +2 -0
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/engine/types/screen.d.ts +1 -1
- package/dist/engine/types/screen.js +1 -1
- package/dist/entrypoint/index.d.ts +1 -0
- package/dist/entrypoint/index.js +1 -0
- package/dist/event-store/event-store.d.ts +0 -3
- package/dist/event-store/event-store.js +1 -26
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/snapshot.js +6 -1
- package/dist/files/file-ref-entity.d.ts +6 -6
- package/dist/files/file-ref-table.d.ts +6 -6
- package/dist/i18n/required-surface-keys.js +0 -1
- package/dist/jobs/job-runner.d.ts +1 -0
- package/dist/jobs/job-runner.js +74 -7
- package/dist/logging/index.d.ts +2 -1
- package/dist/logging/index.js +2 -1
- package/dist/logging/pino-logger.d.ts +1 -0
- package/dist/logging/pino-logger.js +35 -0
- package/dist/logging/request-log-fields.d.ts +1 -0
- package/dist/logging/request-log-fields.js +21 -0
- package/dist/logging/scrub.d.ts +2 -0
- package/dist/logging/scrub.js +21 -0
- package/dist/logging/utils.d.ts +1 -0
- package/dist/logging/utils.js +40 -6
- package/dist/migrations/pii-event-backfill.js +5 -1
- package/dist/observability/standard-metrics.d.ts +1 -0
- package/dist/observability/standard-metrics.js +9 -0
- package/dist/pipeline/msp-rebuild.js +5 -1
- package/dist/pipeline/projection-rebuild.js +5 -1
- package/dist/rate-limit/middleware.d.ts +8 -3
- package/dist/rate-limit/middleware.js +28 -10
- package/dist/rate-limit/resolver.js +10 -0
- package/dist/redis/client.d.ts +2 -0
- package/dist/redis/client.js +20 -0
- package/dist/redis/index.d.ts +1 -1
- package/dist/redis/index.js +1 -1
- package/dist/search/purge-subject.js +6 -8
- package/dist/search/reindex-entity.js +11 -10
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/package.json +3 -3
- package/src/changes.json +64 -0
|
@@ -62,6 +62,14 @@ With only the API running, `read_orders` gains the row but
|
|
|
62
62
|
`read_order_activity` stays empty. Start the worker and both the activity
|
|
63
63
|
row and the `read_fulfillments` row appear.
|
|
64
64
|
|
|
65
|
+
## Deferring a job
|
|
66
|
+
|
|
67
|
+
`process-order` returns `context.defer(ms)` while the (simulated) carrier is busy. The job
|
|
68
|
+
is re-run after `ms` on the same queue job: no retry attempt is used, no failure is logged
|
|
69
|
+
or recorded, and the run log shows only the runs that actually executed. `maxDefers`
|
|
70
|
+
(default 100) bounds it; beyond that the run fails. It needs a queued run: an inline boot
|
|
71
|
+
gate throws.
|
|
72
|
+
|
|
65
73
|
## Test
|
|
66
74
|
|
|
67
75
|
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
@@ -125,6 +133,7 @@ import {
|
|
|
125
133
|
fulfillmentEntity,
|
|
126
134
|
orderActivityTable,
|
|
127
135
|
orderEntity,
|
|
136
|
+
setCarrierRetryAfter,
|
|
128
137
|
setOrderFulfillWrite,
|
|
129
138
|
} from "../feature";
|
|
130
139
|
|
|
@@ -258,6 +267,44 @@ describe("api-worker-split", () => {
|
|
|
258
267
|
await Promise.all([api.stop(), worker.stop()]);
|
|
259
268
|
}
|
|
260
269
|
});
|
|
270
|
+
|
|
271
|
+
test("a busy carrier defers the job without failing it, then the fulfillment lands", async () => {
|
|
272
|
+
const prefix = uniquePrefix();
|
|
273
|
+
const api = createApiEntrypoint({
|
|
274
|
+
registry: createRegistry([createApiWorkerSplitFeature()]),
|
|
275
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
276
|
+
jwtSecret: JWT,
|
|
277
|
+
jobs: { redisUrl: redisUrl(), queueNamePrefix: prefix },
|
|
278
|
+
});
|
|
279
|
+
const worker = createWorkerEntrypoint({
|
|
280
|
+
registry: createRegistry([createApiWorkerSplitFeature()]),
|
|
281
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
282
|
+
jwtSecret: JWT,
|
|
283
|
+
redisUrl: redisUrl(),
|
|
284
|
+
queueNamePrefix: prefix,
|
|
285
|
+
});
|
|
286
|
+
setOrderFulfillWrite(makeDispatchSystemWrite(worker.dispatcher));
|
|
287
|
+
let busyChecks = 0;
|
|
288
|
+
setCarrierRetryAfter(() => {
|
|
289
|
+
busyChecks += 1;
|
|
290
|
+
return busyChecks === 1 ? 200 : 0;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
await Promise.all([api.start(), worker.start()]);
|
|
294
|
+
try {
|
|
295
|
+
expect(await writeOrder(api, "Initech AG", 250)).toBe(true);
|
|
296
|
+
await waitFor(async () => {
|
|
297
|
+
const fulfillments = await selectMany<{ orderKey: string }>(testDb.db, fulfillmentTable, {
|
|
298
|
+
tenantId: adminUser.tenantId,
|
|
299
|
+
});
|
|
300
|
+
expect(fulfillments.some((row) => row.orderKey === "Initech AG")).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
expect(busyChecks).toBe(2);
|
|
303
|
+
} finally {
|
|
304
|
+
setCarrierRetryAfter(() => 0);
|
|
305
|
+
await Promise.all([api.stop(), worker.stop()]);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
261
308
|
});
|
|
262
309
|
```
|
|
263
310
|
|
|
@@ -350,6 +397,13 @@ export function setOrderFulfillWrite(fn: FulfillWrite): void {
|
|
|
350
397
|
fulfillWrite = fn;
|
|
351
398
|
}
|
|
352
399
|
|
|
400
|
+
let carrierRetryAfterMs: () => number = () => 0;
|
|
401
|
+
|
|
402
|
+
/** Stand-in for a rate-limited carrier API: ms until it accepts work again, 0 when free. */
|
|
403
|
+
export function setCarrierRetryAfter(fn: () => number): void {
|
|
404
|
+
carrierRetryAfterMs = fn;
|
|
405
|
+
}
|
|
406
|
+
|
|
353
407
|
// Job payloads arrive as `Record<string, unknown>` (JobHandlerFn) — an
|
|
354
408
|
// unchecked `as string` cast here would let a missing/non-string field
|
|
355
409
|
// through silently, writing `orderKey: undefined` / `label: "label-
|
|
@@ -389,6 +443,9 @@ export function createApiWorkerSplitFeature() {
|
|
|
389
443
|
);
|
|
390
444
|
}
|
|
391
445
|
const { customerName } = processOrderPayloadSchema.parse(payload);
|
|
446
|
+
// Busy is not a failure: ctx.defer re-runs the job later without burning a retry.
|
|
447
|
+
const retryAfterMs = carrierRetryAfterMs();
|
|
448
|
+
if (retryAfterMs > 0) return context.defer(retryAfterMs);
|
|
392
449
|
const result = await fulfillWrite({
|
|
393
450
|
handlerQn: "orders:write:fulfillment:create",
|
|
394
451
|
payload: {
|
|
@@ -13,12 +13,12 @@ header, a metrics band, and a tabbed layout instead of one long stacked form.
|
|
|
13
13
|
|
|
14
14
|
## What it shows
|
|
15
15
|
|
|
16
|
-
- `header` (title/subtitle/status) and `metrics` (
|
|
16
|
+
- `header` (title/subtitle/status) and `metrics` (each with its own i18n `label`)
|
|
17
17
|
rendered from the columns of the screen's own query row
|
|
18
18
|
- `layout.mode: "tabs"` with two `relatedList` tabs (order items, payments)
|
|
19
19
|
and one `fields` tab (master data) — each `relatedList` runs its own query
|
|
20
20
|
- boot-validator catching two common author mistakes: a `metrics` entry
|
|
21
|
-
|
|
21
|
+
with an empty `label`, and a `tabs` layout with fewer than two
|
|
22
22
|
sections
|
|
23
23
|
|
|
24
24
|
## Source
|
|
@@ -43,7 +43,7 @@ bun test
|
|
|
43
43
|
// exactly as declared (column names, not literals)
|
|
44
44
|
// - relatedList columns collapse the string shorthand like entityList columns do
|
|
45
45
|
// - the boot-validator catches the two mistakes the projectionDetail tabs
|
|
46
|
-
// layout introduces: a metric
|
|
46
|
+
// layout introduces: a metric with an empty label, and a tabs
|
|
47
47
|
// layout with fewer than two sections
|
|
48
48
|
|
|
49
49
|
import { describe, expect, test } from "bun:test";
|
|
@@ -82,7 +82,12 @@ describe("record-detail-layout showcase — registration", () => {
|
|
|
82
82
|
subtitle: "orderNumber",
|
|
83
83
|
status: "status",
|
|
84
84
|
});
|
|
85
|
-
expect(screen.metrics).toEqual([
|
|
85
|
+
expect(screen.metrics?.map((metric) => metric.field)).toEqual([
|
|
86
|
+
"totalAmount",
|
|
87
|
+
"outstandingAmount",
|
|
88
|
+
"itemCount",
|
|
89
|
+
"placedAt",
|
|
90
|
+
]);
|
|
86
91
|
});
|
|
87
92
|
|
|
88
93
|
test("layout.mode is tabs with two relatedList sections, one fields section, and one extension section", () => {
|
|
@@ -113,7 +118,7 @@ describe("record-detail-layout showcase — registration", () => {
|
|
|
113
118
|
});
|
|
114
119
|
|
|
115
120
|
describe("record-detail-layout showcase — boot-validator catches author mistakes", () => {
|
|
116
|
-
test("metric
|
|
121
|
+
test("metric with an empty label fails boot — no fallback to the raw column name", () => {
|
|
117
122
|
const broken = defineFeature("broken-order-desk", (r) => {
|
|
118
123
|
r.queryHandler({
|
|
119
124
|
name: "order:detail",
|
|
@@ -125,13 +130,11 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
|
|
|
125
130
|
id: "order-detail",
|
|
126
131
|
type: "projectionDetail",
|
|
127
132
|
query: "broken-order-desk:query:order:detail",
|
|
128
|
-
metrics: ["totalAmount"],
|
|
133
|
+
metrics: [{ field: "totalAmount", label: "" }],
|
|
129
134
|
layout: { sections: [{ title: "Details", fields: ["id"] }] },
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
|
-
expect(() => validateBoot([broken])).toThrow(
|
|
133
|
-
/metric "totalAmount" has no entry in fieldLabels/,
|
|
134
|
-
);
|
|
137
|
+
expect(() => validateBoot([broken])).toThrow(/metric "totalAmount" has no "label"/);
|
|
135
138
|
});
|
|
136
139
|
|
|
137
140
|
test("tabs with only one section fails boot", () => {
|
|
@@ -165,8 +168,8 @@ describe("record-detail-layout showcase — boot-validator catches author mistak
|
|
|
165
168
|
// 1. `header` renders the query row's customer/order/status columns above
|
|
166
169
|
// the layout — the identity a support agent scans first.
|
|
167
170
|
// 2. `metrics` renders a labeled band of key numbers from the same row —
|
|
168
|
-
//
|
|
169
|
-
// column name.
|
|
171
|
+
// each metric carries its own i18n `label`, there is no fallback to the
|
|
172
|
+
// raw column name.
|
|
170
173
|
// 3. `layout.mode: "tabs"` splits the record into four tabs: two
|
|
171
174
|
// `relatedList` sections (own query, own pagination, mounts only when
|
|
172
175
|
// active), one `fields` section (master data from the detail row), and
|
|
@@ -238,13 +241,12 @@ export function createOrderDeskFeature(): FeatureDefinition {
|
|
|
238
241
|
type: "projectionDetail",
|
|
239
242
|
query: "order-desk:query:order:detail",
|
|
240
243
|
header: { title: "customerName", subtitle: "orderNumber", status: "status" },
|
|
241
|
-
metrics: [
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
},
|
|
244
|
+
metrics: [
|
|
245
|
+
{ field: "totalAmount", label: "order-desk.metric.totalAmount" },
|
|
246
|
+
{ field: "outstandingAmount", label: "order-desk.metric.outstandingAmount" },
|
|
247
|
+
{ field: "itemCount", label: "order-desk.metric.itemCount" },
|
|
248
|
+
{ field: "placedAt", label: "order-desk.metric.placedAt" },
|
|
249
|
+
],
|
|
248
250
|
layout: {
|
|
249
251
|
mode: "tabs",
|
|
250
252
|
sections: [
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
isFieldsEditSection,
|
|
50
50
|
normalizeEditField,
|
|
51
51
|
normalizeListColumn,
|
|
52
|
+
sectionFieldSpecs,
|
|
52
53
|
validateBoot as validateBootRaw,
|
|
53
54
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
54
55
|
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
@@ -160,9 +161,9 @@ describe("screens-nav showcase — field specs", () => {
|
|
|
160
161
|
if (!publishingSection || !isFieldsEditSection(publishingSection)) {
|
|
161
162
|
throw new Error("expected fields section");
|
|
162
163
|
}
|
|
163
|
-
const priceField = normalizeEditField(publishingSection
|
|
164
|
+
const priceField = normalizeEditField(sectionFieldSpecs(publishingSection)[0]!);
|
|
164
165
|
expect(priceField.readOnly).toEqual({ field: "published", eq: true });
|
|
165
|
-
const authorField = normalizeEditField(basicsSection
|
|
166
|
+
const authorField = normalizeEditField(sectionFieldSpecs(basicsSection)[1]!);
|
|
166
167
|
expect(authorField.visible).toEqual({ field: "published", eq: false });
|
|
167
168
|
});
|
|
168
169
|
});
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
createRegistry,
|
|
56
56
|
createTextField,
|
|
57
57
|
defineFeature,
|
|
58
|
+
sectionFieldSpecs,
|
|
58
59
|
validateBoot as validateBootRaw,
|
|
59
60
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
61
|
import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
|
|
@@ -107,7 +108,7 @@ describe("writeform-section sample — registration", () => {
|
|
|
107
108
|
throw new Error("expected an entityEdit fields section and a writeForm section");
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
const editFields = editSection.
|
|
111
|
+
const editFields = sectionFieldSpecs(editSection).map(fieldName);
|
|
111
112
|
const writeFormFields = detailSection.fields.map(fieldName);
|
|
112
113
|
expect(writeFormFields).toEqual(editFields);
|
|
113
114
|
});
|
|
@@ -30,6 +30,7 @@ export declare const Routes: {
|
|
|
30
30
|
readonly authInviteInfo: "/auth/invite-info";
|
|
31
31
|
readonly files: "/files";
|
|
32
32
|
readonly schema: "/schema";
|
|
33
|
+
readonly clientErrors: "/client-errors";
|
|
33
34
|
};
|
|
34
35
|
export declare const PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
35
36
|
export declare const NON_PUBLIC_API_PATHS: ReadonlySet<string>;
|
|
@@ -36,6 +36,7 @@ export const Routes = {
|
|
|
36
36
|
authInviteInfo: "/auth/invite-info",
|
|
37
37
|
files: "/files",
|
|
38
38
|
schema: "/schema",
|
|
39
|
+
clientErrors: "/client-errors",
|
|
39
40
|
};
|
|
40
41
|
// Routes that must be reachable WITHOUT a valid JWT.
|
|
41
42
|
// The auth middleware skips these paths.
|
|
@@ -61,6 +62,7 @@ export const PUBLIC_API_PATHS = new Set([
|
|
|
61
62
|
`/api${Routes.health}`,
|
|
62
63
|
`/api${Routes.healthReady}`,
|
|
63
64
|
`/api${Routes.version}`,
|
|
65
|
+
`/api${Routes.clientErrors}`,
|
|
64
66
|
]);
|
|
65
67
|
// Every other route in `Routes` — explicit, so a route can never fall
|
|
66
68
|
// through to "public" by simply being absent from PUBLIC_API_PATHS. A
|
|
@@ -33,6 +33,7 @@ export type AnonymousAccessResolved = AnonymousAccessConfig & {
|
|
|
33
33
|
readonly tenantExists?: TenantExists;
|
|
34
34
|
readonly resolverTrust?: "authoritative" | "fallback-only";
|
|
35
35
|
};
|
|
36
|
+
export declare function identifyOptionalSession(c: Context, jwt: JwtHelper): Promise<void>;
|
|
36
37
|
export declare function authMiddleware(jwt: JwtHelper, options?: AuthMiddlewareOptions): (c: Context, next: Next) => Promise<Response | undefined>;
|
|
37
38
|
export declare function getUser(c: Context): SessionUser;
|
|
38
39
|
export declare function getAuthTransport(c: Context): AuthTransport | undefined;
|
|
@@ -5,6 +5,7 @@ import { createAnonymousUser } from "../engine/system-user.js";
|
|
|
5
5
|
import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
6
6
|
import { isSessionRequiredApiPath, TENANT_HEADER_NAME } from "./api-constants.js";
|
|
7
7
|
import { isForeignCookieOrigin } from "./origin-middleware.js";
|
|
8
|
+
import { setRequestIdentity } from "./request-context.js";
|
|
8
9
|
import { clearSessionCookies, getSessionCookies, hasDuplicateSessionCookie, } from "./session-cookies.js";
|
|
9
10
|
const USER_KEY = "pipelineUser";
|
|
10
11
|
const AUTH_TRANSPORT_KEY = "authTransport";
|
|
@@ -86,6 +87,20 @@ function extractToken(c) {
|
|
|
86
87
|
return { token: bearerToken, transport: "bearer" };
|
|
87
88
|
return { error: "missing" };
|
|
88
89
|
}
|
|
90
|
+
// Best-effort for public routes that only want to attribute the log line: a bad or absent token is ignored.
|
|
91
|
+
export async function identifyOptionalSession(c, jwt) {
|
|
92
|
+
const extracted = extractToken(c);
|
|
93
|
+
// skip: without a token the report stays anonymous
|
|
94
|
+
if ("error" in extracted)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
const payload = await jwt.verify(extracted.token);
|
|
98
|
+
setRequestIdentity({ tenantId: payload.tenantId, userId: payload.sub });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// skip: an unverifiable token only means the report stays anonymous
|
|
102
|
+
}
|
|
103
|
+
}
|
|
89
104
|
export function authMiddleware(jwt, options = {}) {
|
|
90
105
|
const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus, foreignCookieOrigins, isTenantlessAnonymousWrite, } = options;
|
|
91
106
|
// Fail loud at boot, not silently at request time: a tenantResolver
|
|
@@ -224,6 +239,7 @@ export function authMiddleware(jwt, options = {}) {
|
|
|
224
239
|
if (lifecycleReject)
|
|
225
240
|
return lifecycleReject;
|
|
226
241
|
c.set(USER_KEY, user);
|
|
242
|
+
setRequestIdentity({ tenantId: user.tenantId, userId: user.id });
|
|
227
243
|
c.set(AUTH_TRANSPORT_KEY, transport);
|
|
228
244
|
if (payload.exp !== undefined)
|
|
229
245
|
c.set(AUTH_TOKEN_EXPIRY_KEY, payload.exp);
|
|
@@ -266,6 +282,7 @@ async function handleVerifiedBearerUser(c, user, next, resolveTenantLifecycleSta
|
|
|
266
282
|
});
|
|
267
283
|
}
|
|
268
284
|
c.set(USER_KEY, user);
|
|
285
|
+
setRequestIdentity({ tenantId: user.tenantId, userId: user.id });
|
|
269
286
|
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
270
287
|
const lifecycleReject = await rejectIfTenantTeardown(c, user.tenantId, resolveTenantLifecycleStatus);
|
|
271
288
|
if (lifecycleReject)
|
|
@@ -80,6 +80,7 @@ export type SignupConfig = {
|
|
|
80
80
|
requestHandler: string;
|
|
81
81
|
confirmHandler: string;
|
|
82
82
|
};
|
|
83
|
+
export declare function inMemoryLoginRateLimiterFallback(): LoginRateLimiter;
|
|
83
84
|
export declare function createInMemoryLoginRateLimiter(maxAttempts?: number, windowMs?: number, { maxEntries, sweepThreshold, }?: {
|
|
84
85
|
maxEntries?: number;
|
|
85
86
|
sweepThreshold?: number;
|
package/dist/api/auth-routes.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildSessionRoles } from "../engine/membership-roles.js";
|
|
|
6
6
|
import { createAnonymousUser, createSystemUser } from "../engine/system-user.js";
|
|
7
7
|
import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
|
|
8
8
|
import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors/index.js";
|
|
9
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
9
10
|
import { assertUnreachable } from "../utils/index.js";
|
|
10
11
|
import { parseRoles } from "../utils/serialization.js";
|
|
11
12
|
import { Routes } from "./api-constants.js";
|
|
@@ -209,6 +210,15 @@ function requestMeta(c, resolver) {
|
|
|
209
210
|
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
210
211
|
return { ip, userAgent };
|
|
211
212
|
}
|
|
213
|
+
let warnedInMemoryLimiterInProduction = false;
|
|
214
|
+
// Without Redis-backed limiters each replica counts on its own, so the effective limit scales with replica count.
|
|
215
|
+
export function inMemoryLoginRateLimiterFallback() {
|
|
216
|
+
if (process.env["NODE_ENV"] === "production" && !warnedInMemoryLimiterInProduction) {
|
|
217
|
+
warnedInMemoryLimiterInProduction = true;
|
|
218
|
+
createFallbackLogger("auth-routes").warn("login/MFA rate limiter falls back to in-memory; limits are per replica");
|
|
219
|
+
}
|
|
220
|
+
return createInMemoryLoginRateLimiter();
|
|
221
|
+
}
|
|
212
222
|
// Default: in-memory fixed window. Fine for a single Node process; for
|
|
213
223
|
// multi-process deployments, inject a Redis-backed LoginRateLimiter instead
|
|
214
224
|
// so attempts can't be spread across replicas.
|
|
@@ -413,7 +423,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
413
423
|
// Default to in-memory limiter unless the caller opted out via null.
|
|
414
424
|
const rateLimiter = config.loginRateLimit === null
|
|
415
425
|
? null
|
|
416
|
-
: (config.loginRateLimit ??
|
|
426
|
+
: (config.loginRateLimit ?? inMemoryLoginRateLimiterFallback());
|
|
417
427
|
api.post(Routes.authLogin, async (c) => {
|
|
418
428
|
const raw = await c.req.json().catch(() => null);
|
|
419
429
|
const parsed = LoginBody.safeParse(raw);
|
|
@@ -503,7 +513,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
503
513
|
const statusMap = config.mfaVerifyErrorStatusMap ?? {};
|
|
504
514
|
const rateLimiter = config.mfaVerifyRateLimit === null
|
|
505
515
|
? null
|
|
506
|
-
: (config.mfaVerifyRateLimit ??
|
|
516
|
+
: (config.mfaVerifyRateLimit ?? inMemoryLoginRateLimiterFallback());
|
|
507
517
|
api.post(Routes.authMfaVerify, async (c) => {
|
|
508
518
|
const raw = await c.req.json().catch(() => null);
|
|
509
519
|
const parsed = MfaVerifyBody.safeParse(raw);
|
|
@@ -560,7 +570,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
560
570
|
const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
|
|
561
571
|
const rateLimiter = config.mfaPreauthEnableStartRateLimit === null
|
|
562
572
|
? null
|
|
563
|
-
: (config.mfaPreauthEnableStartRateLimit ??
|
|
573
|
+
: (config.mfaPreauthEnableStartRateLimit ?? inMemoryLoginRateLimiterFallback());
|
|
564
574
|
api.post(Routes.authMfaPreauthEnableStart, async (c) => {
|
|
565
575
|
const raw = await c.req.json().catch(() => null);
|
|
566
576
|
const parsed = MfaPreauthEnableStartBody.safeParse(raw);
|
|
@@ -610,7 +620,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
|
|
|
610
620
|
const statusMap = config.mfaPreauthConfirmErrorStatusMap ?? {};
|
|
611
621
|
const rateLimiter = config.mfaPreauthConfirmRateLimit === null
|
|
612
622
|
? null
|
|
613
|
-
: (config.mfaPreauthConfirmRateLimit ??
|
|
623
|
+
: (config.mfaPreauthConfirmRateLimit ?? inMemoryLoginRateLimiterFallback());
|
|
614
624
|
api.post(Routes.authMfaPreauthConfirm, async (c) => {
|
|
615
625
|
const raw = await c.req.json().catch(() => null);
|
|
616
626
|
const parsed = MfaPreauthConfirmBody.safeParse(raw);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Context, Hono } from "hono";
|
|
2
|
+
import type { Meter } from "../observability/types/index.ts";
|
|
3
|
+
import type { RateLimitResolver } from "../rate-limit/resolver.ts";
|
|
4
|
+
import { type ClientIpResolver } from "./client-ip.ts";
|
|
5
|
+
export type ClientErrorRouteOptions = {
|
|
6
|
+
readonly resolver: RateLimitResolver | undefined;
|
|
7
|
+
readonly clientIpResolver: ClientIpResolver;
|
|
8
|
+
readonly meter?: Meter;
|
|
9
|
+
readonly now?: () => number;
|
|
10
|
+
readonly identifySession: (c: Context) => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export declare function createClientErrorRoute(options: ClientErrorRouteOptions): Hono;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { createFallbackLogger, scrubLogText } from "../logging/index.js";
|
|
6
|
+
import { httpRouteRateLimit, respondRateLimited } from "../rate-limit/middleware.js";
|
|
7
|
+
import { Routes } from "./api-constants.js";
|
|
8
|
+
import { clientIpSourceFromHonoContext } from "./client-ip.js";
|
|
9
|
+
const MAX_BODY_BYTES = 8 * 1024;
|
|
10
|
+
const REPORTS_PER_WINDOW = 10;
|
|
11
|
+
const WINDOW_SECONDS = 60;
|
|
12
|
+
const clientErrorReportSchema = z.strictObject({
|
|
13
|
+
errorId: z.uuid(),
|
|
14
|
+
kind: z.enum(["render", "unhandled"]),
|
|
15
|
+
message: z.string().max(500),
|
|
16
|
+
stack: z.string().max(4000).optional(),
|
|
17
|
+
screen: z.string().max(200).optional(),
|
|
18
|
+
path: z.string().max(300).optional(),
|
|
19
|
+
buildVersion: z
|
|
20
|
+
.string()
|
|
21
|
+
.regex(/^[\w.+-]{1,64}$/)
|
|
22
|
+
.optional(),
|
|
23
|
+
});
|
|
24
|
+
// Query and fragment can carry tokens or personal data; only the route shape is useful for triage.
|
|
25
|
+
function stripQueryAndFragment(path) {
|
|
26
|
+
return path.split(/[?#]/, 1)[0] ?? "";
|
|
27
|
+
}
|
|
28
|
+
const MAX_TRACKED_IPS = 10_000;
|
|
29
|
+
// Without Redis there is no shared limiter; a per-process window still bounds log flooding.
|
|
30
|
+
function inMemoryIpRateLimit(clientIpResolver, now) {
|
|
31
|
+
const windowMs = WINDOW_SECONDS * 1000;
|
|
32
|
+
const windows = new Map();
|
|
33
|
+
function evictExpired(nowMs) {
|
|
34
|
+
if (windows.size > MAX_TRACKED_IPS) {
|
|
35
|
+
for (const [ip, window] of windows) {
|
|
36
|
+
if (nowMs - window.startedAt >= windowMs)
|
|
37
|
+
windows.delete(ip);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (windows.size > MAX_TRACKED_IPS)
|
|
41
|
+
windows.clear();
|
|
42
|
+
}
|
|
43
|
+
return async (c, next) => {
|
|
44
|
+
const ip = clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
45
|
+
const nowMs = now();
|
|
46
|
+
evictExpired(nowMs);
|
|
47
|
+
const existing = windows.get(ip);
|
|
48
|
+
const window = existing && nowMs - existing.startedAt < windowMs ? existing : { count: 0, startedAt: nowMs };
|
|
49
|
+
window.count += 1;
|
|
50
|
+
windows.set(ip, window);
|
|
51
|
+
if (window.count > REPORTS_PER_WINDOW) {
|
|
52
|
+
const resetAtMs = window.startedAt + windowMs;
|
|
53
|
+
const decision = {
|
|
54
|
+
allowed: false,
|
|
55
|
+
limit: REPORTS_PER_WINDOW,
|
|
56
|
+
remaining: 0,
|
|
57
|
+
retryAfterSeconds: Math.max(1, Math.ceil((resetAtMs - nowMs) / 1000)),
|
|
58
|
+
windowSeconds: WINDOW_SECONDS,
|
|
59
|
+
resetAt: Temporal.Instant.fromEpochMilliseconds(resetAtMs),
|
|
60
|
+
};
|
|
61
|
+
return respondRateLimited(c, decision, `http:${ip}`);
|
|
62
|
+
}
|
|
63
|
+
await next();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function createClientErrorRoute(options) {
|
|
67
|
+
const app = new Hono();
|
|
68
|
+
const logger = createFallbackLogger("client-errors");
|
|
69
|
+
const routePath = `/api${Routes.clientErrors}`;
|
|
70
|
+
if (options.resolver) {
|
|
71
|
+
app.post(Routes.clientErrors, httpRouteRateLimit({
|
|
72
|
+
resolver: options.resolver,
|
|
73
|
+
rateLimit: { per: "ip", limit: REPORTS_PER_WINDOW, windowSeconds: WINDOW_SECONDS },
|
|
74
|
+
routeKey: `POST ${routePath}`,
|
|
75
|
+
clientIpResolver: options.clientIpResolver,
|
|
76
|
+
...(options.meter && { meter: options.meter }),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
app.post(Routes.clientErrors, inMemoryIpRateLimit(options.clientIpResolver, options.now ?? Date.now));
|
|
81
|
+
}
|
|
82
|
+
app.post(Routes.clientErrors, bodyLimit({
|
|
83
|
+
maxSize: MAX_BODY_BYTES,
|
|
84
|
+
onError: (c) => c.json({ error: { code: "payload_too_large" } }, 413),
|
|
85
|
+
}), async (c) => {
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = await c.req.json();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return c.json({ error: { code: "invalid_client_error_report" } }, 400);
|
|
92
|
+
}
|
|
93
|
+
const parsed = clientErrorReportSchema.safeParse(raw);
|
|
94
|
+
if (!parsed.success) {
|
|
95
|
+
return c.json({ error: { code: "invalid_client_error_report" } }, 400);
|
|
96
|
+
}
|
|
97
|
+
await options.identifySession(c);
|
|
98
|
+
const report = parsed.data;
|
|
99
|
+
logger.warn("client error", {
|
|
100
|
+
errorId: report.errorId,
|
|
101
|
+
kind: report.kind,
|
|
102
|
+
message: scrubLogText(report.message),
|
|
103
|
+
...(report.stack !== undefined && { stack: scrubLogText(report.stack) }),
|
|
104
|
+
...(report.screen !== undefined && { screen: scrubLogText(report.screen) }),
|
|
105
|
+
...(report.path !== undefined && {
|
|
106
|
+
path: scrubLogText(stripQueryAndFragment(report.path)),
|
|
107
|
+
}),
|
|
108
|
+
...(report.buildVersion !== undefined && { buildVersion: report.buildVersion }),
|
|
109
|
+
});
|
|
110
|
+
return c.body(null, 204);
|
|
111
|
+
});
|
|
112
|
+
return app;
|
|
113
|
+
}
|
|
@@ -12,6 +12,8 @@ export type RequestContextData = {
|
|
|
12
12
|
readonly locale?: string;
|
|
13
13
|
readonly feature?: string;
|
|
14
14
|
readonly handler?: string;
|
|
15
|
+
tenantId?: string;
|
|
16
|
+
userId?: string;
|
|
15
17
|
readonly startedAt?: number;
|
|
16
18
|
readonly writeOrigin?: WriteOrigin;
|
|
17
19
|
readonly entryHandler?: {
|
|
@@ -25,6 +27,10 @@ export declare const requestContext: {
|
|
|
25
27
|
get(): RequestContextData | undefined;
|
|
26
28
|
generateId(): string;
|
|
27
29
|
};
|
|
30
|
+
export declare function setRequestIdentity(identity: {
|
|
31
|
+
tenantId: string;
|
|
32
|
+
userId?: string;
|
|
33
|
+
}): void;
|
|
28
34
|
export declare const readonlyRequestContext: Pick<typeof requestContext, "get">;
|
|
29
35
|
export declare function runWithOrigin<T>(origin: {
|
|
30
36
|
readonly feature?: string;
|
|
@@ -13,6 +13,15 @@ export const requestContext = {
|
|
|
13
13
|
return generateId();
|
|
14
14
|
},
|
|
15
15
|
};
|
|
16
|
+
export function setRequestIdentity(identity) {
|
|
17
|
+
const store = storage.getStore();
|
|
18
|
+
// skip: outside a request scope there is no log context to enrich
|
|
19
|
+
if (!store)
|
|
20
|
+
return;
|
|
21
|
+
store.tenantId = identity.tenantId;
|
|
22
|
+
if (identity.userId !== undefined)
|
|
23
|
+
store.userId = identity.userId;
|
|
24
|
+
}
|
|
16
25
|
// Public view: `run` would let a caller replace the request scope (entryHandler,
|
|
17
26
|
// writeOrigin), so only the read side is exposed through the /api barrel.
|
|
18
27
|
export const readonlyRequestContext = {
|
package/dist/api/routes.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { HTTPException } from "hono/http-exception";
|
|
3
3
|
import { streamSSE } from "hono/streaming";
|
|
4
4
|
import { AccessDeniedError, reraiseAsKumikoError, serializeError, toKumikoError, ValidationError, } from "../errors/index.js";
|
|
5
|
-
import { createFallbackLogger } from "../logging/index.js";
|
|
5
|
+
import { createFallbackLogger, scrubLogText, scrubLogValue } from "../logging/index.js";
|
|
6
6
|
import { isPlainObject } from "../utils/is-plain-object.js";
|
|
7
7
|
import { stringifyJson } from "../utils/safe-json.js";
|
|
8
8
|
import { Routes } from "./api-constants.js";
|
|
@@ -378,6 +378,9 @@ function isClientAbort(err) {
|
|
|
378
378
|
const signal = requestContext.get()?.signal;
|
|
379
379
|
return signal?.aborted === true && err.cause !== undefined && err.cause === signal.reason;
|
|
380
380
|
}
|
|
381
|
+
function scrubOptionalText(text) {
|
|
382
|
+
return text === undefined ? undefined : scrubLogText(text);
|
|
383
|
+
}
|
|
381
384
|
// Unexpected server faults (5xx) carry their diagnostic stack only on the
|
|
382
385
|
// in-process error — serializeError strips cause/details from the wire body.
|
|
383
386
|
// Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
|
|
@@ -395,9 +398,9 @@ function logServerFault(err, requestId, type) {
|
|
|
395
398
|
requestId,
|
|
396
399
|
type: loggableType(type),
|
|
397
400
|
code: err.code,
|
|
398
|
-
message: err.message,
|
|
399
|
-
cause: cause instanceof Error ? cause.message : cause,
|
|
400
|
-
stack: cause instanceof Error ? cause.stack : err.stack,
|
|
401
|
+
message: scrubLogText(err.message),
|
|
402
|
+
cause: cause instanceof Error ? scrubLogText(cause.message) : scrubLogValue(cause),
|
|
403
|
+
stack: scrubOptionalText(cause instanceof Error ? cause.stack : err.stack),
|
|
401
404
|
});
|
|
402
405
|
}
|
|
403
406
|
// Same floor of 1 as the rate-limit middleware: "Retry-After: 0" invites an instant retry loop.
|
package/dist/api/server.js
CHANGED
|
@@ -31,8 +31,9 @@ import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-confi
|
|
|
31
31
|
import { assertUnreachable, generateId } from "../utils/index.js";
|
|
32
32
|
import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
|
|
33
33
|
import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
|
|
34
|
-
import { authMiddleware, getAuthTokenExpiry, getUser, } from "./auth-middleware.js";
|
|
34
|
+
import { authMiddleware, getAuthTokenExpiry, getUser, identifyOptionalSession, } from "./auth-middleware.js";
|
|
35
35
|
import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
|
|
36
|
+
import { createClientErrorRoute } from "./client-error-route.js";
|
|
36
37
|
import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
|
|
37
38
|
import { csrfMiddleware } from "./csrf-middleware.js";
|
|
38
39
|
import { collectEntityReadAccess } from "./entity-read-access.js";
|
|
@@ -511,6 +512,7 @@ export function buildServer(options) {
|
|
|
511
512
|
...options.rateLimit.global,
|
|
512
513
|
resolver: rateLimitResolver,
|
|
513
514
|
clientIpResolver,
|
|
515
|
+
meter: observability.meter,
|
|
514
516
|
});
|
|
515
517
|
app.use("/api/*", globalRateLimitGuard);
|
|
516
518
|
}
|
|
@@ -663,6 +665,13 @@ export function buildServer(options) {
|
|
|
663
665
|
resolver: clientIpResolver,
|
|
664
666
|
}));
|
|
665
667
|
}
|
|
668
|
+
app.route("/api", createClientErrorRoute({
|
|
669
|
+
resolver: rateLimitResolver ??
|
|
670
|
+
(wrappedRedis ? createRateLimitResolver({ redis: wrappedRedis }) : undefined),
|
|
671
|
+
clientIpResolver,
|
|
672
|
+
meter: observability.meter,
|
|
673
|
+
identifySession: (c) => identifyOptionalSession(c, jwt),
|
|
674
|
+
}));
|
|
666
675
|
app.route("/api", createApiRoutes(dispatcher, {
|
|
667
676
|
...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
|
|
668
677
|
}));
|
|
@@ -785,6 +794,7 @@ export function buildServer(options) {
|
|
|
785
794
|
rateLimit: route.rateLimit,
|
|
786
795
|
routeKey: `${route.method} ${route.path}`,
|
|
787
796
|
clientIpResolver,
|
|
797
|
+
meter: observability.meter,
|
|
788
798
|
}),
|
|
789
799
|
]
|
|
790
800
|
: [];
|
package/dist/db/connection.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export type DbConnectionOptions = {
|
|
|
9
9
|
readonly closeTimeoutSeconds?: number;
|
|
10
10
|
readonly ssl?: PostgresDriverOptions["ssl"];
|
|
11
11
|
readonly onNotice?: (notice: postgres.Notice) => void;
|
|
12
|
+
readonly statementTimeoutMs?: number;
|
|
13
|
+
readonly idleInTransactionSessionTimeoutMs?: number;
|
|
14
|
+
readonly lockTimeoutMs?: number;
|
|
12
15
|
};
|
|
13
16
|
export type CreateDbConnectionOptions = DbConnectionOptions & {
|
|
14
17
|
readonly url: string;
|
|
@@ -19,7 +22,11 @@ export type DbConnectionHandle = {
|
|
|
19
22
|
close: () => Promise<void>;
|
|
20
23
|
};
|
|
21
24
|
export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
|
|
25
|
+
export declare function openUnboundedSiblingPool(db: unknown): PgClient | undefined;
|
|
22
26
|
export declare function createDbConnection({ url, ...options }: CreateDbConnectionOptions): DbConnectionHandle;
|
|
23
27
|
export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
|
|
28
|
+
export declare const DEFAULT_SERVER_STATEMENT_TIMEOUT_MS = 60000;
|
|
29
|
+
export declare const DEFAULT_SERVER_IDLE_IN_TRANSACTION_TIMEOUT_MS = 300000;
|
|
30
|
+
export declare function serverTimeoutOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
|
|
24
31
|
export declare const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
|
|
25
32
|
export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
|