@cosmicdrift/kumiko-framework 0.382.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
package/dist/jobs/job-runner.js
CHANGED
|
@@ -13,7 +13,7 @@ import { SYSTEM_TENANT_ID, } from "../engine/types/index.js";
|
|
|
13
13
|
import { InternalError } from "../errors/index.js";
|
|
14
14
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
15
15
|
import { createTenantFileContext } from "../files/file-handle.js";
|
|
16
|
-
import { createFallbackLogger } from "../logging/index.js";
|
|
16
|
+
import { createFallbackLogger, scrubLogText } from "../logging/index.js";
|
|
17
17
|
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, createMetricsForFactory, emitJobLastSuccess, emitJobQueueDepth, flushEscapeHatchAudits, getFallbackTracer, registerStandardMetrics, withEscapeHatchAuditScope, } from "../observability/index.js";
|
|
18
18
|
import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
|
|
19
19
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
@@ -262,6 +262,9 @@ function tenantFailureMessageKey(err, declaration) {
|
|
|
262
262
|
// (_triggeredById, _tenantId, _payload).
|
|
263
263
|
const TRACE_CONTEXT_KEY = "_traceContext";
|
|
264
264
|
const DEFER_COUNT_KEY = "_deferCount";
|
|
265
|
+
// Separate from the maxPerTenant counter so tenant-limit deferrals do not eat into maxDefers.
|
|
266
|
+
const HANDLER_DEFER_COUNT_KEY = "_handlerDeferCount";
|
|
267
|
+
const DEFAULT_MAX_DEFERS = 100;
|
|
265
268
|
const DEFER_DELAY_CAP_MS = 30_000;
|
|
266
269
|
const DEFER_BACKOFF_MAX_DOUBLINGS = 5;
|
|
267
270
|
function readTraceContext(data) {
|
|
@@ -280,18 +283,28 @@ function captureTraceContext(tracer) {
|
|
|
280
283
|
return { traceId: span.traceId, spanId: span.spanId };
|
|
281
284
|
}
|
|
282
285
|
const QUEUE_DEPTH_POLL_INTERVAL_MS = 15_000;
|
|
286
|
+
const QUEUE_DEPTH_WARN_INTERVAL_MS = 5 * 60_000;
|
|
283
287
|
// kumiko_job_queue_depth: only the lane's own consumer polls — the count is
|
|
284
288
|
// a global Redis-backed value (BullMQ, not per-process), so one reporter per
|
|
285
289
|
// lane is enough. Fires once immediately (metrics exist before the first
|
|
286
290
|
// poll tick) then on an interval; the caller stores + clears the handle.
|
|
287
291
|
async function startQueueDepthPolling(queue, lane, meter) {
|
|
292
|
+
let lastWarnAt;
|
|
288
293
|
const pollQueueDepth = async () => {
|
|
289
294
|
try {
|
|
290
295
|
const counts = await queue.getJobCounts("waiting", "active", "delayed", "failed", "paused");
|
|
291
296
|
emitJobQueueDepth(meter, lane, counts);
|
|
292
297
|
}
|
|
293
|
-
catch {
|
|
294
|
-
//
|
|
298
|
+
catch (err) {
|
|
299
|
+
// Next poll retries; the warn is throttled so a Redis outage logs once per interval, not once per poll.
|
|
300
|
+
const nowMs = Date.now();
|
|
301
|
+
if (lastWarnAt === undefined || nowMs - lastWarnAt >= QUEUE_DEPTH_WARN_INTERVAL_MS) {
|
|
302
|
+
lastWarnAt = nowMs;
|
|
303
|
+
createFallbackLogger("job-runner").warn("queue depth poll failed", {
|
|
304
|
+
lane,
|
|
305
|
+
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
295
308
|
}
|
|
296
309
|
};
|
|
297
310
|
await pollQueueDepth();
|
|
@@ -614,6 +627,16 @@ export function createJobRunner(options) {
|
|
|
614
627
|
const grown = Math.min(deferMs * 2 ** Math.min(deferCount, DEFER_BACKOFF_MAX_DOUBLINGS), DEFER_DELAY_CAP_MS);
|
|
615
628
|
return grown + Math.floor(Math.random() * deferMs);
|
|
616
629
|
}
|
|
630
|
+
function isJobDeferral(value) {
|
|
631
|
+
return (typeof value === "object" &&
|
|
632
|
+
value !== null &&
|
|
633
|
+
"kind" in value &&
|
|
634
|
+
value.kind === "job-deferral");
|
|
635
|
+
}
|
|
636
|
+
function priorDeferCountOf(data, key) {
|
|
637
|
+
const prior = data[key];
|
|
638
|
+
return typeof prior === "number" ? prior : 0;
|
|
639
|
+
}
|
|
617
640
|
// Only err.message, never payload or the logs array: job payloads can carry PII.
|
|
618
641
|
function logFailedAttempt(entry) {
|
|
619
642
|
errorLogger.error("job failed", {
|
|
@@ -622,7 +645,9 @@ export function createJobRunner(options) {
|
|
|
622
645
|
attempt: entry.attempt,
|
|
623
646
|
// BullMQ never retries an UnrecoverableError, whatever attempts remain.
|
|
624
647
|
final: entry.finalAttempt || entry.err instanceof UnrecoverableError,
|
|
625
|
-
error: entry.err instanceof Error ? entry.err.message : String(entry.err),
|
|
648
|
+
error: scrubLogText(entry.err instanceof Error ? entry.err.message : String(entry.err)),
|
|
649
|
+
...(entry.err instanceof Error &&
|
|
650
|
+
entry.err.stack && { stack: scrubLogText(entry.err.stack) }),
|
|
626
651
|
});
|
|
627
652
|
}
|
|
628
653
|
async function reportPreRunFailure(jobName, bullJob, err) {
|
|
@@ -779,8 +804,7 @@ export function createJobRunner(options) {
|
|
|
779
804
|
activeOnly: true,
|
|
780
805
|
self: { id: bullJob.id, processedOn: bullJob.processedOn },
|
|
781
806
|
}))) {
|
|
782
|
-
const
|
|
783
|
-
const deferCount = typeof priorDeferCount === "number" ? priorDeferCount : 0;
|
|
807
|
+
const deferCount = priorDeferCountOf(bullJob.data, DEFER_COUNT_KEY);
|
|
784
808
|
await bullJob.updateData?.({ ...bullJob.data, [DEFER_COUNT_KEY]: deferCount + 1 });
|
|
785
809
|
await bullJob.moveToDelayed(Date.now() + deferDelayMs(maxPerTenant.deferMs, deferCount), token);
|
|
786
810
|
throw new DelayedError();
|
|
@@ -1008,6 +1032,17 @@ export function createJobRunner(options) {
|
|
|
1008
1032
|
},
|
|
1009
1033
|
}, { reason: identitySwitchGrantReason, report: reportEscapeHatch });
|
|
1010
1034
|
const jobContextWithoutTz = {
|
|
1035
|
+
defer: (delayMs) => {
|
|
1036
|
+
if (!Number.isInteger(delayMs) || delayMs <= 0) {
|
|
1037
|
+
throw new RangeError(`Job "${jobName}": ctx.defer needs a positive integer delay in ms (got ${delayMs})`);
|
|
1038
|
+
}
|
|
1039
|
+
if (token === undefined || !bullJob.moveToDelayed) {
|
|
1040
|
+
throw new InternalError({
|
|
1041
|
+
message: `Job "${jobName}": ctx.defer needs a queued run; it is not available inline (boot gate).`,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return { kind: "job-deferral", delayMs };
|
|
1045
|
+
},
|
|
1011
1046
|
...context,
|
|
1012
1047
|
db: jobDb ?? missingDbProxy(jobName),
|
|
1013
1048
|
files,
|
|
@@ -1064,6 +1099,7 @@ export function createJobRunner(options) {
|
|
|
1064
1099
|
const inheritedCorrelationId = rawData["_correlationId"] ?? undefined; // @cast-boundary dynamic-key
|
|
1065
1100
|
const jobRequestId = requestContext.generateId();
|
|
1066
1101
|
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
1102
|
+
const deferred = {};
|
|
1067
1103
|
const runInSpan = async () => {
|
|
1068
1104
|
try {
|
|
1069
1105
|
if (subjectConfigError) {
|
|
@@ -1076,17 +1112,29 @@ export function createJobRunner(options) {
|
|
|
1076
1112
|
"a trustworthy anonymous-root gate.",
|
|
1077
1113
|
});
|
|
1078
1114
|
}
|
|
1079
|
-
await requestContext.run({
|
|
1115
|
+
const handlerResult = await requestContext.run({
|
|
1080
1116
|
requestId: jobRequestId,
|
|
1081
1117
|
correlationId: jobCorrelationId,
|
|
1082
1118
|
// #3043 — events a job writes carry the job as their origin.
|
|
1083
1119
|
handler: jobName,
|
|
1084
1120
|
feature: registry.getJobFeature(jobName) ?? qnScope(jobName),
|
|
1085
1121
|
writeOrigin: jobOrigin,
|
|
1122
|
+
tenantId,
|
|
1123
|
+
...(triggeredById !== null && { userId: triggeredById }),
|
|
1086
1124
|
}, async () => jobDef.handler(payload, {
|
|
1087
1125
|
...jobContextWithoutTz,
|
|
1088
1126
|
tz: await resolveJobTz(config, context),
|
|
1089
1127
|
}));
|
|
1128
|
+
if (isJobDeferral(handlerResult)) {
|
|
1129
|
+
const maxDefers = jobDef.maxDefers ?? DEFAULT_MAX_DEFERS;
|
|
1130
|
+
if (priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) >= maxDefers) {
|
|
1131
|
+
// The counter never resets, so a retry would hit the cap again.
|
|
1132
|
+
throw new UnrecoverableError(`Job "${jobName}" was deferred ${maxDefers} times (maxDefers); giving up.`);
|
|
1133
|
+
}
|
|
1134
|
+
deferred.deferral = handlerResult;
|
|
1135
|
+
// skip: the deferral is applied after the span closes, below
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1090
1138
|
// Stamped before the observer hook: a throwing onJobComplete must not
|
|
1091
1139
|
// make a run that actually succeeded look dead to the liveness alert.
|
|
1092
1140
|
if (context.meter) {
|
|
@@ -1135,6 +1183,25 @@ export function createJobRunner(options) {
|
|
|
1135
1183
|
},
|
|
1136
1184
|
...(parentContext ? { parent: parentContext } : {}),
|
|
1137
1185
|
}, runInSpan));
|
|
1186
|
+
if (deferred.deferral && token !== undefined && bullJob.moveToDelayed) {
|
|
1187
|
+
// Run-log bookkeeping must not turn a defer into a failure.
|
|
1188
|
+
try {
|
|
1189
|
+
await options.onJobDeferred?.(jobName, jobId, meta);
|
|
1190
|
+
}
|
|
1191
|
+
catch (err) {
|
|
1192
|
+
errorLogger.warn("job deferred hook failed", {
|
|
1193
|
+
job: jobName,
|
|
1194
|
+
jobId,
|
|
1195
|
+
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
await bullJob.updateData?.({
|
|
1199
|
+
...bullJob.data,
|
|
1200
|
+
[HANDLER_DEFER_COUNT_KEY]: priorDeferCountOf(bullJob.data, HANDLER_DEFER_COUNT_KEY) + 1,
|
|
1201
|
+
});
|
|
1202
|
+
await bullJob.moveToDelayed(Date.now() + deferred.deferral.delayMs, token);
|
|
1203
|
+
throw new DelayedError();
|
|
1204
|
+
}
|
|
1138
1205
|
}
|
|
1139
1206
|
finally {
|
|
1140
1207
|
// Release the sequential lock value-matched (Lua compare-and-delete
|
package/dist/logging/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type { LoggerOptions } from "./pino-logger.ts";
|
|
2
2
|
export { createLogger } from "./pino-logger.ts";
|
|
3
|
+
export { scrubLogText, scrubLogValue } from "./scrub.ts";
|
|
3
4
|
export type { Logger } from "./types.ts";
|
|
4
|
-
export { createFallbackLogger } from "./utils.ts";
|
|
5
|
+
export { createFallbackLogger, setProcessLogger } from "./utils.ts";
|
package/dist/logging/index.js
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import pino, {} from "pino";
|
|
2
2
|
import { observabilityContext } from "../observability/index.js";
|
|
3
|
+
import { requestLogFields } from "./request-log-fields.js";
|
|
4
|
+
const REDACTED_KEYS = [
|
|
5
|
+
"password",
|
|
6
|
+
"passwordHash",
|
|
7
|
+
"newPassword",
|
|
8
|
+
"token",
|
|
9
|
+
"accessToken",
|
|
10
|
+
"refreshToken",
|
|
11
|
+
"idToken",
|
|
12
|
+
"secret",
|
|
13
|
+
"clientSecret",
|
|
14
|
+
"apiKey",
|
|
15
|
+
"authorization",
|
|
16
|
+
"cookie",
|
|
17
|
+
"iban",
|
|
18
|
+
"email",
|
|
19
|
+
"set-cookie",
|
|
20
|
+
"x-api-key",
|
|
21
|
+
"sessionToken",
|
|
22
|
+
"privateKey",
|
|
23
|
+
"credentials",
|
|
24
|
+
"otp",
|
|
25
|
+
];
|
|
26
|
+
// pino/fast-redact needs bracket notation for keys that are not plain identifiers.
|
|
27
|
+
function redactPathSegment(key) {
|
|
28
|
+
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : `["${key}"]`;
|
|
29
|
+
}
|
|
30
|
+
// Depth 0-2: pino wildcards match exactly one level, so each depth needs its own path.
|
|
31
|
+
export const REDACT_PATHS = REDACTED_KEYS.flatMap((key) => {
|
|
32
|
+
const segment = redactPathSegment(key);
|
|
33
|
+
const dotted = segment.startsWith("[") ? segment : `.${segment}`;
|
|
34
|
+
return [segment, `*${dotted}`, `*.*${dotted}`];
|
|
35
|
+
});
|
|
3
36
|
export function createLogger(options = {}) {
|
|
4
37
|
const level = options.level ?? process.env["LOG_LEVEL"] ?? "info"; // @cast-boundary dynamic-key
|
|
5
38
|
const pretty = options.pretty ?? process.env["LOG_FORMAT"] === "pretty";
|
|
6
39
|
const pinoConfig = {
|
|
7
40
|
level,
|
|
41
|
+
redact: { paths: [...REDACT_PATHS], censor: "[redacted]" },
|
|
42
|
+
mixin: requestLogFields,
|
|
8
43
|
...(pretty && !options.destination
|
|
9
44
|
? { transport: { target: "pino-pretty", options: { colorize: true } } }
|
|
10
45
|
: {}),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function requestLogFields(): Record<string, string>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context.js";
|
|
2
|
+
// Only set fields are emitted so log lines outside a request stay lean.
|
|
3
|
+
export function requestLogFields() {
|
|
4
|
+
const store = requestContext.get();
|
|
5
|
+
if (!store)
|
|
6
|
+
return {};
|
|
7
|
+
const fields = {};
|
|
8
|
+
if (store.requestId)
|
|
9
|
+
fields["requestId"] = store.requestId;
|
|
10
|
+
if (store.correlationId)
|
|
11
|
+
fields["correlationId"] = store.correlationId;
|
|
12
|
+
if (store.tenantId)
|
|
13
|
+
fields["tenantId"] = store.tenantId;
|
|
14
|
+
if (store.userId)
|
|
15
|
+
fields["userId"] = store.userId;
|
|
16
|
+
if (store.feature)
|
|
17
|
+
fields["feature"] = store.feature;
|
|
18
|
+
if (store.handler)
|
|
19
|
+
fields["handler"] = store.handler;
|
|
20
|
+
return fields;
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const EMAIL_PATTERN = /([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]*@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})/g;
|
|
2
|
+
// Postgres echoes the offending row values in constraint-violation details.
|
|
3
|
+
const PG_KEY_DETAIL_PATTERN = /Key \(([^)]*)\)=\(.*?\)(?=\s|$|[.,;])/g;
|
|
4
|
+
const PG_FAILING_ROW_PATTERN = /Failing row contains \(.*\)/g;
|
|
5
|
+
const JWT_PATTERN = /eyJ[\w-]+\.[\w-]+\.[\w-]+/g;
|
|
6
|
+
const BEARER_PATTERN = /Bearer\s+\S+/g;
|
|
7
|
+
export function scrubLogText(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(PG_KEY_DETAIL_PATTERN, "Key ($1)=(***)")
|
|
10
|
+
.replace(PG_FAILING_ROW_PATTERN, "Failing row contains (***)")
|
|
11
|
+
.replace(EMAIL_PATTERN, "$1***@$2")
|
|
12
|
+
.replace(JWT_PATTERN, "[jwt]")
|
|
13
|
+
.replace(BEARER_PATTERN, "Bearer [redacted]");
|
|
14
|
+
}
|
|
15
|
+
export function scrubLogValue(value) {
|
|
16
|
+
if (typeof value === "string")
|
|
17
|
+
return scrubLogText(value);
|
|
18
|
+
if (value instanceof Error)
|
|
19
|
+
return scrubLogText(value.message);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
package/dist/logging/utils.d.ts
CHANGED
|
@@ -4,5 +4,6 @@ type FallbackLogger = {
|
|
|
4
4
|
warn(msg: string, data?: Record<string, unknown>): void;
|
|
5
5
|
debug(msg: string, data?: Record<string, unknown>): void;
|
|
6
6
|
};
|
|
7
|
+
export declare function setProcessLogger(logger: Logger | null): void;
|
|
7
8
|
export declare function createFallbackLogger(namespace: string, logger?: (Pick<Logger, "error"> & Partial<Pick<Logger, "warn" | "debug">>) | undefined): FallbackLogger;
|
|
8
9
|
export {};
|
package/dist/logging/utils.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
let processLogger = null;
|
|
2
|
+
// Fallback loggers created at module level before boot must pick this up, so it is read per call.
|
|
3
|
+
export function setProcessLogger(logger) {
|
|
4
|
+
processLogger = logger;
|
|
5
|
+
}
|
|
1
6
|
export function createFallbackLogger(namespace, logger) {
|
|
2
7
|
if (logger) {
|
|
3
8
|
return {
|
|
@@ -24,18 +29,47 @@ export function createFallbackLogger(namespace, logger) {
|
|
|
24
29
|
},
|
|
25
30
|
};
|
|
26
31
|
}
|
|
32
|
+
let cachedParent = null;
|
|
33
|
+
let cachedChild = null;
|
|
34
|
+
const resolveProcessChild = () => {
|
|
35
|
+
if (!processLogger)
|
|
36
|
+
return null;
|
|
37
|
+
if (cachedParent !== processLogger || !cachedChild) {
|
|
38
|
+
cachedParent = processLogger;
|
|
39
|
+
cachedChild = processLogger.child({ component: namespace });
|
|
40
|
+
}
|
|
41
|
+
return cachedChild;
|
|
42
|
+
};
|
|
27
43
|
return {
|
|
28
44
|
error(msg, data) {
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
const child = resolveProcessChild();
|
|
46
|
+
if (child) {
|
|
47
|
+
child.error(msg, data);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
51
|
+
console.error(`[${namespace}] ${msg}`, data);
|
|
52
|
+
}
|
|
31
53
|
},
|
|
32
54
|
warn(msg, data) {
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
const child = resolveProcessChild();
|
|
56
|
+
if (child) {
|
|
57
|
+
child.warn(msg, data);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
61
|
+
console.warn(`[${namespace}] ${msg}`, data);
|
|
62
|
+
}
|
|
35
63
|
},
|
|
36
64
|
debug(msg, data) {
|
|
37
|
-
|
|
38
|
-
|
|
65
|
+
const child = resolveProcessChild();
|
|
66
|
+
if (child) {
|
|
67
|
+
child.debug(msg, data);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
71
|
+
console.debug(`[${namespace}] ${msg}`, data);
|
|
72
|
+
}
|
|
39
73
|
},
|
|
40
74
|
};
|
|
41
75
|
}
|
|
@@ -18,6 +18,7 @@ import { alterTableAddColumn } from "../db/queries/ddl.js";
|
|
|
18
18
|
import { persistPiiBackfillState, readPiiBackfillState, } from "../db/queries/pii-backfill-state.js";
|
|
19
19
|
import { upsertOnConflict } from "../db/query.js";
|
|
20
20
|
import { tableExists } from "../db/schema-inspection.js";
|
|
21
|
+
import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
21
22
|
import { unsafePushTables } from "../db/table-push.js";
|
|
22
23
|
import { createFallbackLogger } from "../logging/utils.js";
|
|
23
24
|
import { clearPendingRebuilds, createPendingRebuildsTable, listPendingRebuildRows, pendingRebuildsTable, rebuildProjectionOrMultiStream, } from "./pending-rebuilds.js";
|
|
@@ -207,7 +208,10 @@ function reportRunOutcome(log, progress) {
|
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
|
-
export
|
|
211
|
+
export function runPiiEventBackfill(db, registry, options = {}) {
|
|
212
|
+
return withoutStatementTimeout(db, (unboundedDb) => backfillPiiEvents(unboundedDb, registry, options));
|
|
213
|
+
}
|
|
214
|
+
async function backfillPiiEvents(db, registry, options) {
|
|
211
215
|
const log = createFallbackLogger("pii-event-backfill", options.logger);
|
|
212
216
|
const kms = configuredPiiSubjectKms();
|
|
213
217
|
if (!kms || !isLocalKeyKmsAdapter(kms))
|
|
@@ -32,6 +32,7 @@ export declare function emitEventConsumerPendingGaps(meter: Meter, labels: {
|
|
|
32
32
|
readonly consumer: string;
|
|
33
33
|
readonly instanceId: string;
|
|
34
34
|
}, gapRanges: number): void;
|
|
35
|
+
export declare function emitRateLimitFailOpen(meter: Meter, label: string): void;
|
|
35
36
|
export declare function emitEventConsumerRearmExhausted(meter: Meter, labels: {
|
|
36
37
|
readonly consumer: string;
|
|
37
38
|
readonly instanceId: string;
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// Feature code never emits these — the Framework wires them from the HTTP
|
|
4
4
|
// middleware, dispatcher, and DB wrapper.
|
|
5
5
|
export const STANDARD_METRIC_DEFS = [
|
|
6
|
+
{
|
|
7
|
+
name: "kumiko_rate_limit_fail_open_total",
|
|
8
|
+
type: "counter",
|
|
9
|
+
description: "Requests let through because the rate-limit backend (Redis) failed.",
|
|
10
|
+
labels: ["label"],
|
|
11
|
+
},
|
|
6
12
|
{
|
|
7
13
|
name: "kumiko_http_requests_total",
|
|
8
14
|
type: "counter",
|
|
@@ -227,6 +233,9 @@ export function emitEventConsumerPendingGaps(meter, labels, gapRanges) {
|
|
|
227
233
|
// Caller (event-dispatcher.ts) is responsible for calling this exactly
|
|
228
234
|
// once per (consumer, instanceId) transition into exhausted-dead — not
|
|
229
235
|
// once per poll pass while it stays dead.
|
|
236
|
+
export function emitRateLimitFailOpen(meter, label) {
|
|
237
|
+
meter.counter("kumiko_rate_limit_fail_open_total").inc(1, { label });
|
|
238
|
+
}
|
|
230
239
|
export function emitEventConsumerRearmExhausted(meter, labels) {
|
|
231
240
|
meter.counter("kumiko_event_consumer_rearm_exhausted_total").inc(1, {
|
|
232
241
|
consumer: labels.consumer,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { markConsumerRebuildFailed, resetConsumerForMspRebuild, selectConsumerForUpdate, selectEventIdHorizonWithMissingRanges, selectSnapshotXmax, updateConsumerRebuildCursor, } from "../db/queries/event-consumer.js";
|
|
2
2
|
import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, buildShadowTable, ensureRebuildSchema, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
|
|
3
3
|
import { runInSavepoint, selectMany } from "../db/query.js";
|
|
4
|
+
import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
4
5
|
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
5
6
|
import { InternalError } from "../errors/index.js";
|
|
6
7
|
import { loadAggregate, loadAggregateAsOf } from "../event-store/event-store.js";
|
|
@@ -29,7 +30,10 @@ function createRebuildCtx(registry, db, tenantId) {
|
|
|
29
30
|
},
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
|
-
export
|
|
33
|
+
export function rebuildMultiStreamProjection(mspName, deps) {
|
|
34
|
+
return withoutStatementTimeout(deps.db, (db) => replayMultiStreamProjectionIntoShadow(mspName, { ...deps, db }));
|
|
35
|
+
}
|
|
36
|
+
async function replayMultiStreamProjectionIntoShadow(mspName, deps) {
|
|
33
37
|
const { db, registry } = deps;
|
|
34
38
|
const msp = registry.getAllMultiStreamProjections().get(mspName);
|
|
35
39
|
if (!msp) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { countSubscribedEvents, finalizeProjectionRebuild, markProjectionRebuildFailed, markProjectionRebuilding, selectEventsForProjectionRebuildBatch, } from "../db/queries/projection-rebuild.js";
|
|
2
2
|
import { assertLiveColumnsMatchMeta, assertLiveTableHasNoRowLevelSecurity, assertNoBlindIndexLoss, assertNoUnreachableLiveRows, buildShadowTable, countColumnDrift, ensureRebuildSchema, fenceLiveTable, rebuildMetaOrThrow, swapShadowIntoLive, } from "../db/queries/shadow-swap.js";
|
|
3
3
|
import { coerceRow, extractTableInfo, runInSavepoint, selectMany } from "../db/query.js";
|
|
4
|
+
import { withoutStatementTimeout } from "../db/statement-timeout.js";
|
|
4
5
|
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
5
6
|
import { eventsTable, getEventsHighWaterMark, upcastStoredEvent, } from "../event-store/index.js";
|
|
6
7
|
import { createRebuildDeadLetterTable, recordRebuildDeadLetters, } from "../event-store/rebuild-dead-letter.js";
|
|
@@ -45,7 +46,10 @@ function warnOnColumnDrift(projectionName, tableName, drift) {
|
|
|
45
46
|
"Not blocking — replay wins on swap. Likely cause: a legacy direct-write predating the row's " +
|
|
46
47
|
"event history (#494), or a sensitive custom field pending #972. Investigate if unexpected.");
|
|
47
48
|
}
|
|
48
|
-
export
|
|
49
|
+
export function rebuildProjection(projectionName, deps) {
|
|
50
|
+
return withoutStatementTimeout(deps.db, (db) => replayProjectionIntoShadow(projectionName, { ...deps, db }));
|
|
51
|
+
}
|
|
52
|
+
async function replayProjectionIntoShadow(projectionName, deps) {
|
|
49
53
|
const { db, registry } = deps;
|
|
50
54
|
const projection = registry.getAllProjections().get(projectionName);
|
|
51
55
|
if (!projection) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Context, MiddlewareHandler } from "hono";
|
|
2
2
|
import { type ClientIpResolver } from "../api/client-ip.ts";
|
|
3
3
|
import type { HttpRouteRateLimit } from "../engine/types/http-route.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type { Meter } from "../observability/types/index.ts";
|
|
5
|
+
import type { RateLimitDecision, RateLimitResolver } from "./resolver.ts";
|
|
5
6
|
export type GlobalIpRateLimitOptions = {
|
|
6
7
|
readonly resolver: RateLimitResolver;
|
|
7
8
|
readonly limit?: number;
|
|
@@ -9,7 +10,8 @@ export type GlobalIpRateLimitOptions = {
|
|
|
9
10
|
readonly trustedProxyHops?: number;
|
|
10
11
|
readonly clientIpResolver?: ClientIpResolver;
|
|
11
12
|
readonly extractIp?: (c: Context) => string | undefined;
|
|
12
|
-
readonly
|
|
13
|
+
readonly onFailOpen?: (err: unknown) => void;
|
|
14
|
+
readonly meter?: Meter;
|
|
13
15
|
};
|
|
14
16
|
export declare const DEFAULT_GLOBAL_RATE_LIMIT: {
|
|
15
17
|
readonly limit: 600;
|
|
@@ -21,7 +23,8 @@ export type HttpRouteRateLimitOptions = {
|
|
|
21
23
|
readonly rateLimit: HttpRouteRateLimit;
|
|
22
24
|
readonly routeKey: string;
|
|
23
25
|
readonly clientIpResolver: ClientIpResolver;
|
|
24
|
-
readonly
|
|
26
|
+
readonly onFailOpen?: (err: unknown) => void;
|
|
27
|
+
readonly meter?: Meter;
|
|
25
28
|
};
|
|
26
29
|
export declare function httpRouteRateLimit(opts: HttpRouteRateLimitOptions): MiddlewareHandler;
|
|
27
30
|
export type AuthEndpointRateLimitOptions = {
|
|
@@ -35,3 +38,5 @@ export type AuthEndpointRateLimitOptions = {
|
|
|
35
38
|
readonly onFailClosed?: (err: unknown) => void;
|
|
36
39
|
};
|
|
37
40
|
export declare function authEndpointRateLimit(opts: AuthEndpointRateLimitOptions): MiddlewareHandler;
|
|
41
|
+
export declare function createFailOpenReporter(label: string, meter?: Meter, now?: () => number): (err: unknown) => void;
|
|
42
|
+
export declare function respondRateLimited(c: Context, decision: RateLimitDecision, bucket: string): Response;
|
|
@@ -2,6 +2,8 @@ import { isAuthRateLimitExempt } from "../api/api-constants.js";
|
|
|
2
2
|
import { clientIpSourceFromHonoContext, createClientIpResolver, } from "../api/client-ip.js";
|
|
3
3
|
import { requestContext } from "../api/request-context.js";
|
|
4
4
|
import { RateLimitError, serializeError } from "../errors/index.js";
|
|
5
|
+
import { createFallbackLogger, scrubLogText } from "../logging/index.js";
|
|
6
|
+
import { emitRateLimitFailOpen } from "../observability/standard-metrics.js";
|
|
5
7
|
import { toPublicBucketName } from "./bucket.js";
|
|
6
8
|
// Shared with runProdApp (server-runtime re-exports it). The auth middleware default stays
|
|
7
9
|
// stricter than runProdApp's auth limit (30): a bare authEndpointRateLimit guards brute force.
|
|
@@ -12,7 +14,7 @@ export function globalIpRateLimit(opts) {
|
|
|
12
14
|
const clientIpResolver = opts.clientIpResolver ??
|
|
13
15
|
createClientIpResolver(opts.trustedProxyHops ?? 0, "globalIpRateLimit");
|
|
14
16
|
const extractIp = opts.extractIp ?? ((c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c)));
|
|
15
|
-
const
|
|
17
|
+
const onFailOpen = opts.onFailOpen ?? createFailOpenReporter("l1-global-ip", opts.meter);
|
|
16
18
|
return async (c, next) => {
|
|
17
19
|
const ip = extractIp(c);
|
|
18
20
|
if (!ip) {
|
|
@@ -28,20 +30,18 @@ export function globalIpRateLimit(opts) {
|
|
|
28
30
|
setRateLimitHeaders(c, decision);
|
|
29
31
|
}
|
|
30
32
|
catch (e) {
|
|
31
|
-
// Fail-closed: refuse rather than let a flood through with no cap.
|
|
32
33
|
// resolver.check never throws RateLimitError (only enforce does),
|
|
33
34
|
// so any throw here is an infrastructure failure (Redis down).
|
|
34
|
-
|
|
35
|
-
return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
|
|
35
|
+
onFailOpen(e);
|
|
36
36
|
}
|
|
37
37
|
await next();
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
-
// Per-route limit for r.httpRoute({ rateLimit }). Fail-
|
|
41
|
-
//
|
|
40
|
+
// Per-route limit for r.httpRoute({ rateLimit }). Fail-open like L1: a Redis outage must not
|
|
41
|
+
// take anonymous routes (status pages, webhooks) down with it.
|
|
42
42
|
export function httpRouteRateLimit(opts) {
|
|
43
43
|
const { rateLimit, routeKey } = opts;
|
|
44
|
-
const
|
|
44
|
+
const onFailOpen = opts.onFailOpen ?? createFailOpenReporter("http-route", opts.meter);
|
|
45
45
|
const config = {
|
|
46
46
|
limit: rateLimit.limit,
|
|
47
47
|
windowSeconds: rateLimit.windowSeconds,
|
|
@@ -59,8 +59,7 @@ export function httpRouteRateLimit(opts) {
|
|
|
59
59
|
setRateLimitHeaders(c, decision);
|
|
60
60
|
}
|
|
61
61
|
catch (e) {
|
|
62
|
-
|
|
63
|
-
return c.json({ error: { code: "rate_limit_unavailable", message: "Rate limiter unavailable" } }, 503);
|
|
62
|
+
onFailOpen(e);
|
|
64
63
|
}
|
|
65
64
|
await next();
|
|
66
65
|
};
|
|
@@ -95,6 +94,25 @@ export function authEndpointRateLimit(opts) {
|
|
|
95
94
|
await next();
|
|
96
95
|
};
|
|
97
96
|
}
|
|
97
|
+
const FAIL_OPEN_WARN_INTERVAL_MS = 30_000;
|
|
98
|
+
export function createFailOpenReporter(label, meter, now = Date.now) {
|
|
99
|
+
const logger = createFallbackLogger("rate-limit");
|
|
100
|
+
let lastWarnAt;
|
|
101
|
+
return (err) => {
|
|
102
|
+
if (meter)
|
|
103
|
+
emitRateLimitFailOpen(meter, label);
|
|
104
|
+
const nowMs = now();
|
|
105
|
+
// skip: throttled, the counter above still records every fail-open
|
|
106
|
+
if (lastWarnAt !== undefined && nowMs - lastWarnAt < FAIL_OPEN_WARN_INTERVAL_MS)
|
|
107
|
+
return;
|
|
108
|
+
lastWarnAt = nowMs;
|
|
109
|
+
logger.warn("rate limiter unavailable, failing open", {
|
|
110
|
+
label,
|
|
111
|
+
// ioredis errors can carry the command args, i.e. the bucket key with IP/target.
|
|
112
|
+
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
}
|
|
98
116
|
function defaultOnFailClosed(label) {
|
|
99
117
|
return (err) => {
|
|
100
118
|
// Loud by default — fail-closed for an unknown reason is an ops
|
|
@@ -116,7 +134,7 @@ function setRateLimitHeaders(c, decision) {
|
|
|
116
134
|
// client libs that parse as integer would silently see NaN.
|
|
117
135
|
c.header("X-RateLimit-Reset", String(Math.floor(decision.resetAt.epochMilliseconds / 1000)));
|
|
118
136
|
}
|
|
119
|
-
function respondRateLimited(c, decision, bucket) {
|
|
137
|
+
export function respondRateLimited(c, decision, bucket) {
|
|
120
138
|
// Build a RateLimitError so the wire shape is identical to the L3
|
|
121
139
|
// dispatcher path. serializeError adds i18nKey, requestId, timestamp —
|
|
122
140
|
// fields a hand-rolled body would silently miss.
|
|
@@ -179,11 +179,19 @@ function ensureCommand(redis) {
|
|
|
179
179
|
}
|
|
180
180
|
return redis; // @cast-boundary engine-bridge
|
|
181
181
|
}
|
|
182
|
+
// ioredis queues commands while reconnecting; waiting out commandTimeout per request would
|
|
183
|
+
// stall every caller, so a non-ready connection counts as a failure immediately.
|
|
184
|
+
function assertRedisReady(redis) {
|
|
185
|
+
if (redis.status !== "ready") {
|
|
186
|
+
throw new Error(`rate-limit redis connection not ready (status: ${redis.status})`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
182
189
|
export function createRateLimitResolver(opts) {
|
|
183
190
|
const client = ensureCommand(opts.redis);
|
|
184
191
|
const prefix = opts.keyPrefix ?? RedisKeys.rateLimit;
|
|
185
192
|
const now = opts.nowMs ?? (() => Date.now());
|
|
186
193
|
async function check(bucket, config) {
|
|
194
|
+
assertRedisReady(opts.redis);
|
|
187
195
|
const cost = config.cost ?? 1;
|
|
188
196
|
const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
|
|
189
197
|
const ttlSeconds = Math.max(1, config.windowSeconds * 2);
|
|
@@ -216,6 +224,7 @@ export function createRateLimitResolver(opts) {
|
|
|
216
224
|
async function enforceAll(requests) {
|
|
217
225
|
if (requests.length === 0)
|
|
218
226
|
return [];
|
|
227
|
+
assertRedisReady(opts.redis);
|
|
219
228
|
const nowMs = now();
|
|
220
229
|
const keys = requests.map(({ bucket }) => `${prefix}${bucket}`);
|
|
221
230
|
// The script writes each key back once, so a repeated key would deduct only one of its costs.
|
|
@@ -257,6 +266,7 @@ export function createRateLimitResolver(opts) {
|
|
|
257
266
|
});
|
|
258
267
|
}
|
|
259
268
|
async function peek(bucket, config) {
|
|
269
|
+
assertRedisReady(opts.redis);
|
|
260
270
|
const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
|
|
261
271
|
const nowMs = now();
|
|
262
272
|
const [remaining, retryAfterMs] = await client.kumikoRateLimitPeek(`${prefix}${bucket}`, String(config.limit), String(refillRatePerMs), String(nowMs));
|
package/dist/redis/client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Redis, type RedisOptions } from "ioredis";
|
|
2
|
+
import type { Logger } from "../logging/types.ts";
|
|
2
3
|
export type RedisClientOptions = {
|
|
3
4
|
readonly connectTimeoutMs?: number;
|
|
4
5
|
readonly commandTimeoutMs?: number;
|
|
@@ -6,5 +7,6 @@ export type RedisClientOptions = {
|
|
|
6
7
|
readonly extra?: Readonly<Omit<RedisOptions, "connectTimeout" | "commandTimeout" | "maxRetriesPerRequest">>;
|
|
7
8
|
};
|
|
8
9
|
export declare function createRedisClient(url: string, options?: RedisClientOptions): Redis;
|
|
10
|
+
export declare function logRedisConnectionState(redis: Redis, logger: Pick<Logger, "warn" | "info">): void;
|
|
9
11
|
export declare function redisChannelPrefixFromEnv(env?: Readonly<Record<string, string | undefined>>): string;
|
|
10
12
|
export declare function redisClientOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): RedisClientOptions;
|