@cosmicdrift/kumiko-framework 0.341.0 → 0.343.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/dist/api/auth-routes.d.ts +2 -0
- package/dist/api/auth-routes.js +35 -2
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-id-middleware.d.ts +2 -0
- package/dist/api/request-id-middleware.js +4 -1
- package/dist/api/routes.js +5 -5
- package/dist/api/server.d.ts +3 -0
- package/dist/api/server.js +4 -1
- package/dist/compliance/duration-spec.d.ts +2 -1
- package/dist/compliance/duration-spec.js +11 -0
- package/dist/compliance/index.d.ts +2 -2
- package/dist/compliance/index.js +1 -1
- package/dist/compliance/profiles.d.ts +6 -5
- package/dist/crypto/kek-source.d.ts +3 -4
- package/dist/crypto/kek-source.js +2 -5
- package/dist/crypto/kms-wiring.d.ts +2 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/db/entity-field-encryption.d.ts +1 -1
- package/dist/db/entity-field-encryption.js +5 -5
- package/dist/db/event-store-executor-write.js +38 -11
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/migrate-generator.d.ts +6 -0
- package/dist/db/migrate-generator.js +15 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +11 -1
- package/dist/db/projection-registry-binding.d.ts +3 -0
- package/dist/db/projection-registry-binding.js +12 -0
- package/dist/db/retired-framework-tables.d.ts +10 -0
- package/dist/db/retired-framework-tables.js +72 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +22 -7
- package/dist/engine/boot-validator/index.d.ts +11 -0
- package/dist/engine/boot-validator/index.js +45 -1
- package/dist/engine/boot-validator/query-output-columns.js +8 -7
- package/dist/engine/boot-validator/query-refs.js +3 -0
- package/dist/engine/boot-validator/screens.js +28 -0
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/soft-delete-cleanup.d.ts +2 -0
- package/dist/engine/soft-delete-cleanup.js +28 -9
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/env/index.d.ts +3 -0
- package/dist/env/index.js +12 -0
- package/dist/errors/docs-url.d.ts +7 -0
- package/dist/errors/docs-url.js +36 -0
- package/dist/errors/index.d.ts +2 -0
- package/dist/errors/index.js +1 -0
- package/dist/errors/kumiko-error.d.ts +0 -2
- package/dist/errors/kumiko-error.js +0 -24
- package/dist/errors/serialize.d.ts +3 -2
- package/dist/errors/serialize.js +4 -2
- package/dist/jobs/job-runner.js +37 -2
- package/dist/observability/escape-hatch-report.d.ts +10 -1
- package/dist/observability/escape-hatch-report.js +19 -1
- package/dist/observability/index.d.ts +2 -2
- package/dist/observability/index.js +2 -2
- package/dist/observability/standard-metrics.d.ts +1 -0
- package/dist/observability/standard-metrics.js +14 -0
- package/dist/pipeline/dispatch-shared.js +3 -1
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +6 -0
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/projections-runner.d.ts +1 -0
- package/dist/pipeline/projections-runner.js +23 -14
- package/dist/rate-limit/middleware.js +1 -1
- package/dist/schema-cli.d.ts +8 -6
- package/dist/schema-cli.js +37 -4
- package/dist/stack/test-stack.d.ts +7 -0
- package/dist/stack/test-stack.js +6 -0
- package/package.json +4 -4
- package/src/changes.json +60 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
2
|
+
const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
|
|
3
|
+
const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
|
|
4
|
+
const frameworkReasons = new Set([
|
|
5
|
+
...Object.values(FrameworkReasons),
|
|
6
|
+
...Object.values(AgentReasons),
|
|
7
|
+
]);
|
|
8
|
+
function reasonOf(err) {
|
|
9
|
+
if (!err.details || typeof err.details !== "object")
|
|
10
|
+
return undefined;
|
|
11
|
+
// @cast-boundary error-details — per-error typed details; reflection shape only for the reason lookup.
|
|
12
|
+
const reason = err.details["reason"];
|
|
13
|
+
return typeof reason === "string" && REASON_SLUG_RE.test(reason) ? reason : undefined;
|
|
14
|
+
}
|
|
15
|
+
function isCoveredByConfig(reason, errorDocs) {
|
|
16
|
+
if (!errorDocs)
|
|
17
|
+
return false;
|
|
18
|
+
return errorDocs.reasons === "all" || (errorDocs.reasons?.includes(reason) ?? false);
|
|
19
|
+
}
|
|
20
|
+
function errorsUrl(baseUrl, slug) {
|
|
21
|
+
return `${baseUrl}/errors/${encodeURIComponent(slug)}`;
|
|
22
|
+
}
|
|
23
|
+
// A reason the framework documents (or the error code itself) links to the framework docs,
|
|
24
|
+
// overridable via KUMIKO_DOCS_URL. An app's own reason links only where `errorDocs` covers it —
|
|
25
|
+
// otherwise the framework docs would have no page for it.
|
|
26
|
+
export function resolveErrorDocsUrl(err, errorDocs) {
|
|
27
|
+
const reason = reasonOf(err);
|
|
28
|
+
if (reason === undefined || reason === err.code || frameworkReasons.has(reason)) {
|
|
29
|
+
const baseUrl = process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
|
|
30
|
+
return errorsUrl(baseUrl, reason ?? err.code);
|
|
31
|
+
}
|
|
32
|
+
if (errorDocs && isCoveredByConfig(reason, errorDocs)) {
|
|
33
|
+
return errorsUrl(errorDocs.baseUrl, reason);
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
package/dist/errors/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { FeatureDisabledDetails, FieldIssue, NotFoundDetails, PreconditionFailedDetails, RateLimitDetails, UnconfiguredDetails, UniqueViolationDetails, UnprocessableOpts, ValidationDetails, VersionConflictDetails, } from "./classes.js";
|
|
2
2
|
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
3
|
+
export type { ErrorDocsConfig } from "./docs-url.js";
|
|
4
|
+
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
3
5
|
export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error.js";
|
|
4
6
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
5
7
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
package/dist/errors/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AccessDeniedError, ConflictError, FeatureDisabledError, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RateLimitError, RateLimitUnavailableError, UnauthenticatedError, UnconfiguredError, UniqueViolationError, UnprocessableError, ValidationError, VersionConflictError, } from "./classes.js";
|
|
2
|
+
export { resolveErrorDocsUrl } from "./docs-url.js";
|
|
2
3
|
export { isKumikoError, KumikoError } from "./kumiko-error.js";
|
|
3
4
|
export { memberResolutionReadOnlyDenied } from "./member-resolution.js";
|
|
4
5
|
export { AgentReasons, FrameworkReasons } from "./reasons.js";
|
|
@@ -19,7 +19,5 @@ export declare abstract class KumikoError extends Error {
|
|
|
19
19
|
readonly i18nParams: Readonly<Record<string, unknown>> | undefined;
|
|
20
20
|
readonly details: unknown;
|
|
21
21
|
constructor(input: ErrorCtorInput);
|
|
22
|
-
get docsUrl(): string;
|
|
23
|
-
private get reasonSlug();
|
|
24
22
|
}
|
|
25
23
|
export declare function isKumikoError(e: unknown): e is KumikoError;
|
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
// Anything that isn't a KumikoError is treated as an unexpected crash and
|
|
3
3
|
// auto-wrapped into InternalError by the dispatcher — so the HTTP layer never
|
|
4
4
|
// has to guess the status or the client-facing shape.
|
|
5
|
-
// Default docs URL for self-service errors. Override via `KUMIKO_DOCS_URL`
|
|
6
|
-
// (e.g. self-hosted customers pointing at their own docs instance).
|
|
7
|
-
const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
|
|
8
|
-
const REASON_SLUG_RE = /^[a-z0-9_.-]+$/;
|
|
9
|
-
function docsBaseUrl() {
|
|
10
|
-
return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
|
|
11
|
-
}
|
|
12
5
|
export class KumikoError extends Error {
|
|
13
6
|
i18nKey;
|
|
14
7
|
i18nParams;
|
|
@@ -20,23 +13,6 @@ export class KumikoError extends Error {
|
|
|
20
13
|
this.i18nParams = input.i18nParams;
|
|
21
14
|
this.details = input.details;
|
|
22
15
|
}
|
|
23
|
-
// Docs URL for self-service. Prefer a well-formed `details.reason` slug
|
|
24
|
-
// (e.g. ConflictError → "stale_state"); otherwise fall back to the error
|
|
25
|
-
// code. Encode the path segment so spaces/`../` in free-form reasons
|
|
26
|
-
// cannot break or redirect the link the default renderer shows.
|
|
27
|
-
get docsUrl() {
|
|
28
|
-
return `${docsBaseUrl()}/errors/${encodeURIComponent(this.reasonSlug)}`;
|
|
29
|
-
}
|
|
30
|
-
get reasonSlug() {
|
|
31
|
-
if (this.details && typeof this.details === "object") {
|
|
32
|
-
// @cast-boundary error-details — per-error typed details; reflection
|
|
33
|
-
// shape only for the reasonSlug lookup.
|
|
34
|
-
const r = this.details["reason"];
|
|
35
|
-
if (typeof r === "string" && REASON_SLUG_RE.test(r))
|
|
36
|
-
return r;
|
|
37
|
-
}
|
|
38
|
-
return this.code;
|
|
39
|
-
}
|
|
40
16
|
}
|
|
41
17
|
export function isKumikoError(e) {
|
|
42
18
|
return e instanceof KumikoError;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ErrorDocsConfig } from "./docs-url.js";
|
|
1
2
|
import type { KumikoError } from "./kumiko-error.js";
|
|
2
3
|
export type ErrorResponseBody = {
|
|
3
4
|
readonly error: {
|
|
@@ -6,12 +7,12 @@ export type ErrorResponseBody = {
|
|
|
6
7
|
readonly i18nParams?: Readonly<Record<string, unknown>>;
|
|
7
8
|
readonly message: string;
|
|
8
9
|
readonly details?: unknown;
|
|
9
|
-
readonly docsUrl
|
|
10
|
+
readonly docsUrl?: string;
|
|
10
11
|
readonly requestId?: string;
|
|
11
12
|
readonly timestamp: string;
|
|
12
13
|
};
|
|
13
14
|
};
|
|
14
|
-
export declare function serializeError(err: KumikoError, requestId?: string): ErrorResponseBody;
|
|
15
|
+
export declare function serializeError(err: KumikoError, requestId?: string, errorDocs?: ErrorDocsConfig): ErrorResponseBody;
|
|
15
16
|
export type ErrorLogEntry = {
|
|
16
17
|
readonly name: string;
|
|
17
18
|
readonly code: string;
|
package/dist/errors/serialize.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveErrorDocsUrl } from "./docs-url.js";
|
|
1
2
|
// InternalError deliberately omits `details` from the response in production,
|
|
2
3
|
// because that field often carries the internal cause. The log still has
|
|
3
4
|
// everything (see buildErrorLog).
|
|
@@ -19,7 +20,7 @@ function devCauseDetail(cause) {
|
|
|
19
20
|
...(cause.stack && { causeStack: cause.stack.split("\n").slice(0, 8).join("\n") }),
|
|
20
21
|
};
|
|
21
22
|
}
|
|
22
|
-
export function serializeError(err, requestId) {
|
|
23
|
+
export function serializeError(err, requestId, errorDocs) {
|
|
23
24
|
const stripped = CODES_WITHOUT_CLIENT_DETAILS.has(err.code) && isProductionEnv();
|
|
24
25
|
const exposeDeclaredDetails = err.details !== undefined && !stripped;
|
|
25
26
|
// For internal_error in dev/test, surface the cause chain so the
|
|
@@ -34,6 +35,7 @@ export function serializeError(err, requestId) {
|
|
|
34
35
|
}
|
|
35
36
|
return undefined;
|
|
36
37
|
})();
|
|
38
|
+
const docsUrl = resolveErrorDocsUrl(err, errorDocs);
|
|
37
39
|
return {
|
|
38
40
|
error: {
|
|
39
41
|
code: err.code,
|
|
@@ -41,7 +43,7 @@ export function serializeError(err, requestId) {
|
|
|
41
43
|
...(err.i18nParams && { i18nParams: err.i18nParams }),
|
|
42
44
|
message: err.message,
|
|
43
45
|
...(detailsForResponse !== undefined && { details: detailsForResponse }),
|
|
44
|
-
docsUrl
|
|
46
|
+
...(docsUrl !== undefined && { docsUrl }),
|
|
45
47
|
...(requestId && { requestId }),
|
|
46
48
|
timestamp: new Date().toISOString(),
|
|
47
49
|
},
|
package/dist/jobs/job-runner.js
CHANGED
|
@@ -11,7 +11,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
11
11
|
import { isKumikoError } from "../errors/kumiko-error.js";
|
|
12
12
|
import { createFileContext } from "../files/file-handle.js";
|
|
13
13
|
import { createFallbackLogger } from "../logging/index.js";
|
|
14
|
-
import { createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
14
|
+
import { createEscapeHatchProcessDedup, createEscapeHatchReporter, emitJobLastSuccess, emitJobQueueDepth, getFallbackTracer, registerStandardMetrics, } from "../observability/index.js";
|
|
15
15
|
import { createDistributedLock } from "../pipeline/distributed-lock.js";
|
|
16
16
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
17
17
|
import { buildPersonalDataGate, isPersonalDataGated, parseWriteOrigin, } from "../pipeline/write-origin.js";
|
|
@@ -159,6 +159,22 @@ function missingDbProxy(jobName) {
|
|
|
159
159
|
},
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
|
+
function createCrossTenantReads(systemModeDb, reason, report) {
|
|
163
|
+
return {
|
|
164
|
+
selectMany(table, where, options) {
|
|
165
|
+
report("cross-tenant-read", reason);
|
|
166
|
+
return systemModeDb.selectMany(table, where, options);
|
|
167
|
+
},
|
|
168
|
+
fetchOne(table, where) {
|
|
169
|
+
report("cross-tenant-read", reason);
|
|
170
|
+
return systemModeDb.fetchOne(table, where);
|
|
171
|
+
},
|
|
172
|
+
count(table, where) {
|
|
173
|
+
report("cross-tenant-read", reason);
|
|
174
|
+
return systemModeDb.count(table, where);
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
162
178
|
function priorAttemptsOf(data) {
|
|
163
179
|
const value = data["_priorAttempts"];
|
|
164
180
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 0;
|
|
@@ -316,6 +332,12 @@ export function createJobRunner(options) {
|
|
|
316
332
|
// must not depend on buildServer having done it first. Idempotent.
|
|
317
333
|
if (context.meter)
|
|
318
334
|
registerStandardMetrics(context.meter);
|
|
335
|
+
// Instance-scoped, not module-level: each runner (and each test) audits a system cron's declared hatch once.
|
|
336
|
+
const systemCronEscapeHatchDedup = createEscapeHatchProcessDedup([
|
|
337
|
+
"unsafe-raw",
|
|
338
|
+
"global-write",
|
|
339
|
+
"cross-tenant-read",
|
|
340
|
+
]);
|
|
319
341
|
// Set at the top of stop() — a graceful shutdown closes the redis/BullMQ
|
|
320
342
|
// clients itself, which fires the exact same 'error' listeners below with
|
|
321
343
|
// an expected "Connection is closed." Downgrading to debug once stopping
|
|
@@ -688,16 +710,22 @@ export function createJobRunner(options) {
|
|
|
688
710
|
// unchecked cross-tenant escape hatch for such a job. Gated like jobDb so
|
|
689
711
|
// ctx.systemDb, built from it, is gated too.
|
|
690
712
|
const tenantScopedDb = configDb
|
|
691
|
-
? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined,
|
|
713
|
+
? createTenantDb(configDb, tenantId, "system", undefined, undefined, undefined, {
|
|
714
|
+
projectionRegistry: registry,
|
|
715
|
+
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
716
|
+
})
|
|
692
717
|
: undefined;
|
|
693
718
|
const isSystemJob = registry.isJobSystemScoped(jobName);
|
|
694
719
|
// One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
|
|
720
|
+
const isSystemCronRun = triggeredById === null && jobDef.escapeHatch !== undefined && "cron" in jobDef.trigger;
|
|
695
721
|
const reportEscapeHatch = createEscapeHatchReporter({
|
|
696
722
|
handler: jobName,
|
|
697
723
|
tenantId,
|
|
698
724
|
actor: jobSystemUser.id,
|
|
699
725
|
sink: context._escapeHatchAuditSink,
|
|
700
726
|
log: context.log,
|
|
727
|
+
...(isSystemCronRun && { processDedup: systemCronEscapeHatchDedup }),
|
|
728
|
+
...(context.meter && { meter: context.meter }),
|
|
701
729
|
});
|
|
702
730
|
const systemDb = isSystemJob && tenantScopedDb
|
|
703
731
|
? createUncheckedSystemDb(tenantScopedDb, undefined, reportEscapeHatch)
|
|
@@ -705,10 +733,16 @@ export function createJobRunner(options) {
|
|
|
705
733
|
const jobDb = configDb
|
|
706
734
|
? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
|
|
707
735
|
unsafeRaw: escapeHatchFor(jobDef.escapeHatch, "unsafeRaw"),
|
|
736
|
+
globalWrites: escapeHatchFor(jobDef.escapeHatch, "globalWrites"),
|
|
708
737
|
report: reportEscapeHatch,
|
|
738
|
+
projectionRegistry: registry,
|
|
709
739
|
...(jobPersonalDataGate && { personalDataGate: jobPersonalDataGate }),
|
|
710
740
|
})
|
|
711
741
|
: undefined;
|
|
742
|
+
const crossTenantReadsReason = escapeHatchFor(jobDef.escapeHatch, "unsafeRaw")?.reason;
|
|
743
|
+
const crossTenantReads = tenantScopedDb && crossTenantReadsReason !== undefined
|
|
744
|
+
? createCrossTenantReads(tenantScopedDb, crossTenantReadsReason, reportEscapeHatch)
|
|
745
|
+
: undefined;
|
|
712
746
|
const config = context._configAccessorFactory && tenantScopedDb
|
|
713
747
|
? context._configAccessorFactory({
|
|
714
748
|
user: { id: jobSystemUser.id, tenantId },
|
|
@@ -737,6 +771,7 @@ export function createJobRunner(options) {
|
|
|
737
771
|
...(notify !== undefined && { notify }),
|
|
738
772
|
...(config !== undefined && { config }),
|
|
739
773
|
...(systemDb && { systemDb }),
|
|
774
|
+
...(crossTenantReads && { crossTenantReads }),
|
|
740
775
|
// The runner owns the registry it resolved this job from — expose it so
|
|
741
776
|
// workers can reach projections/jobs without the app author duplicating
|
|
742
777
|
// it into `context` (the JobContext contract guarantees `registry`).
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { EscapeHatchAuditSink, EscapeHatchReporter, EscapeHatchUseEvent, TenantId } from "../engine/types/index.js";
|
|
1
|
+
import type { EscapeHatchAuditSink, EscapeHatchKind, EscapeHatchReporter, EscapeHatchUseEvent, TenantId } from "../engine/types/index.js";
|
|
2
2
|
import type { Logger } from "../logging/types.js";
|
|
3
|
+
import type { Meter } from "./types/index.js";
|
|
3
4
|
export declare const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
|
|
4
5
|
export type EscapeHatchReportWindow = {
|
|
5
6
|
shouldReport(key: string, nowMs: number): boolean;
|
|
@@ -8,6 +9,11 @@ export declare function createEscapeHatchReportWindow(opts?: {
|
|
|
8
9
|
readonly windowMs?: number;
|
|
9
10
|
readonly maxEntries?: number;
|
|
10
11
|
}): EscapeHatchReportWindow;
|
|
12
|
+
export type EscapeHatchProcessDedup = {
|
|
13
|
+
readonly kinds: ReadonlySet<EscapeHatchKind>;
|
|
14
|
+
readonly seen: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
export declare function createEscapeHatchProcessDedup(kinds: readonly EscapeHatchKind[]): EscapeHatchProcessDedup;
|
|
11
17
|
export declare function reportEscapeHatchUse(event: EscapeHatchUseEvent, deps: {
|
|
12
18
|
readonly sink?: EscapeHatchAuditSink;
|
|
13
19
|
readonly log?: Logger;
|
|
@@ -19,6 +25,9 @@ export declare function createEscapeHatchReporter(opts: {
|
|
|
19
25
|
readonly sink?: EscapeHatchAuditSink;
|
|
20
26
|
readonly log?: Logger;
|
|
21
27
|
readonly window?: EscapeHatchReportWindow;
|
|
28
|
+
readonly processDedup?: EscapeHatchProcessDedup;
|
|
29
|
+
readonly meter?: Meter;
|
|
30
|
+
readonly now?: () => number;
|
|
22
31
|
}): EscapeHatchReporter;
|
|
23
32
|
export declare const UNATTRIBUTED_ACTOR = "system";
|
|
24
33
|
export declare function fallbackEscapeHatchReporter(tenantId: TenantId): EscapeHatchReporter;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emitEscapeHatchUse } from "./standard-metrics.js";
|
|
1
2
|
export const ESCAPE_HATCH_USED_SIGNAL = "security:escape-hatch-used";
|
|
2
3
|
const DEFAULT_WINDOW_MS = 60_000;
|
|
3
4
|
const DEFAULT_MAX_ENTRIES = 1_000;
|
|
@@ -24,6 +25,9 @@ export function createEscapeHatchReportWindow(opts) {
|
|
|
24
25
|
},
|
|
25
26
|
};
|
|
26
27
|
}
|
|
28
|
+
export function createEscapeHatchProcessDedup(kinds) {
|
|
29
|
+
return { kinds: new Set(kinds), seen: new Set() };
|
|
30
|
+
}
|
|
27
31
|
// Default window for createEscapeHatchReporter callers that don't pass their own.
|
|
28
32
|
const fallbackReportWindow = createEscapeHatchReportWindow();
|
|
29
33
|
const consoleLogger = {
|
|
@@ -67,7 +71,21 @@ export function reportEscapeHatchUse(event, deps) {
|
|
|
67
71
|
}
|
|
68
72
|
export function createEscapeHatchReporter(opts) {
|
|
69
73
|
const window = opts.window ?? fallbackReportWindow;
|
|
74
|
+
const now = opts.now ?? Date.now;
|
|
70
75
|
return (kind, reason, target) => {
|
|
76
|
+
if (opts.meter)
|
|
77
|
+
emitEscapeHatchUse(opts.meter, opts.handler, kind);
|
|
78
|
+
const { processDedup } = opts;
|
|
79
|
+
if (processDedup?.kinds.has(kind)) {
|
|
80
|
+
const processKey = JSON.stringify([opts.handler, kind, opts.tenantId]);
|
|
81
|
+
// skip: this (handler, kind, tenant) was already audited by this process
|
|
82
|
+
if (processDedup.seen.has(processKey))
|
|
83
|
+
return;
|
|
84
|
+
processDedup.seen.add(processKey);
|
|
85
|
+
reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
|
|
86
|
+
// skip: audited above, the window dedup below only serves the other kinds
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
71
89
|
const key = JSON.stringify([
|
|
72
90
|
opts.handler,
|
|
73
91
|
kind,
|
|
@@ -78,7 +96,7 @@ export function createEscapeHatchReporter(opts) {
|
|
|
78
96
|
target?.tenantId ?? null,
|
|
79
97
|
]);
|
|
80
98
|
// skip: same (handler, kind, reason, target) already reported within the window — dedup
|
|
81
|
-
if (!window.shouldReport(key,
|
|
99
|
+
if (!window.shouldReport(key, now()))
|
|
82
100
|
return;
|
|
83
101
|
reportEscapeHatchUse({ handler: opts.handler, kind, reason, tenantId: opts.tenantId, actor: opts.actor, target }, { sink: opts.sink, log: opts.log });
|
|
84
102
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { type ConsoleProviderOptions, createConsoleProvider } from "./console-provider.js";
|
|
2
2
|
export { observabilityContext } from "./context.js";
|
|
3
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
3
|
+
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, type EscapeHatchProcessDedup, type EscapeHatchReportWindow, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
4
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
5
5
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
6
6
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -12,5 +12,5 @@ export { type MetricEvent, type MetricEventHandler, RecordingMeter, } from "./re
|
|
|
12
12
|
export { type RecordedSpan, RecordingTracer, type RecordingTracerOptions, serializeSpanContext, } from "./recording-tracer.js";
|
|
13
13
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
14
14
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
15
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
15
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
16
|
export type { Counter, Gauge, Histogram, Meter, MetricDefinition, MetricLabels, MetricsHandle, MetricType, ObservabilityOptions, ObservabilityProvider, SamplingConfig, SensitiveFilterConfig, SerializedTraceContext, Span, SpanAttributes, SpanAttributeValue, SpanKind, SpanStatus, StartSpanOptions, Tracer, } from "./types/index.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public surface of the observability module.
|
|
2
2
|
export { createConsoleProvider } from "./console-provider.js";
|
|
3
3
|
export { observabilityContext } from "./context.js";
|
|
4
|
-
export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
4
|
+
export { createEscapeHatchProcessDedup, createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_USED_SIGNAL, fallbackEscapeHatchReporter, reportEscapeHatchUse, UNATTRIBUTED_ACTOR, } from "./escape-hatch-report.js";
|
|
5
5
|
export { getFallbackMeter, getFallbackProvider, getFallbackTracer } from "./fallback.js";
|
|
6
6
|
export { generateSpanId, generateTraceId } from "./ids.js";
|
|
7
7
|
export { buildMetricName, validateLabelKey, validateMetricName, } from "./metric-validator.js";
|
|
@@ -13,4 +13,4 @@ export { RecordingMeter, } from "./recording-meter.js";
|
|
|
13
13
|
export { RecordingTracer, serializeSpanContext, } from "./recording-tracer.js";
|
|
14
14
|
export { wrapRedisClient } from "./redis-wrapper.js";
|
|
15
15
|
export { DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, REDACTED, redactAttributes, redactHeaders, redactQueryString, redactValue, shouldRedactAttribute, } from "./sensitive-filter.js";
|
|
16
|
-
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
16
|
+
export { emitDbQuery, emitDispatcherError, emitDispatcherHandler, emitEscapeHatchUse, emitEventConsumerLag, emitEventConsumerPassOutcome, emitEventConsumerPassSkipped, emitEventConsumerRearmExhausted, emitEventDispatcherListenConnected, emitHttpRequest, emitJobLastSuccess, emitJobQueueDepth, registerStandardMetrics, STANDARD_METRIC_DEFS, } from "./standard-metrics.js";
|
|
@@ -43,3 +43,4 @@ export declare function emitEventConsumerPassOutcome(meter: Meter, labels: {
|
|
|
43
43
|
}, processed: number, failed: number): void;
|
|
44
44
|
export declare function emitJobQueueDepth(meter: Meter, lane: string, counts: Readonly<Record<string, number>>): void;
|
|
45
45
|
export declare function emitJobLastSuccess(meter: Meter, job: string): void;
|
|
46
|
+
export declare function emitEscapeHatchUse(meter: Meter, handler: string, kind: string): void;
|
|
@@ -140,6 +140,15 @@ export const STANDARD_METRIC_DEFS = [
|
|
|
140
140
|
description: "Unix timestamp of the last successful run, per registered job.",
|
|
141
141
|
labels: ["job"],
|
|
142
142
|
},
|
|
143
|
+
// Every escape-hatch use counts here, including the ones the audit sink
|
|
144
|
+
// dedups away: a system cron firing every minute writes one audit event per
|
|
145
|
+
// process, so this counter is the only record of how often it ran.
|
|
146
|
+
{
|
|
147
|
+
name: "kumiko_escape_hatch_uses_total",
|
|
148
|
+
type: "counter",
|
|
149
|
+
description: "Escape-hatch uses by handler and kind, whether or not an audit event was written.",
|
|
150
|
+
labels: ["handler", "kind"],
|
|
151
|
+
},
|
|
143
152
|
];
|
|
144
153
|
export function registerStandardMetrics(meter) {
|
|
145
154
|
for (const def of STANDARD_METRIC_DEFS) {
|
|
@@ -251,3 +260,8 @@ export function emitJobQueueDepth(meter, lane, counts) {
|
|
|
251
260
|
export function emitJobLastSuccess(meter, job) {
|
|
252
261
|
meter.gauge("kumiko_job_last_success_timestamp_seconds").set(Date.now() / 1000, { job });
|
|
253
262
|
}
|
|
263
|
+
// `handler` is a registry-declared handler/job name, `kind` a closed union —
|
|
264
|
+
// no tenant or user input reaches the label set.
|
|
265
|
+
export function emitEscapeHatchUse(meter, handler, kind) {
|
|
266
|
+
meter.counter("kumiko_escape_hatch_uses_total").inc(1, { handler, kind });
|
|
267
|
+
}
|
|
@@ -178,6 +178,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
178
178
|
report: reportEscapeHatch,
|
|
179
179
|
memberReadOnly: isMemberResolutionPrincipal(user),
|
|
180
180
|
personalDataGate: buildPersonalDataGate(registry, origin),
|
|
181
|
+
projectionRegistry: registry,
|
|
181
182
|
});
|
|
182
183
|
// Propagate the request's AbortSignal so every TenantDb query throws when
|
|
183
184
|
// the client has disconnected — handlers with many sequential queries skip
|
|
@@ -244,7 +245,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
244
245
|
const targetSystemUser = createSystemUser(targetTenantId);
|
|
245
246
|
return configFactory({
|
|
246
247
|
user: { id: targetSystemUser.id, tenantId: targetTenantId },
|
|
247
|
-
db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter),
|
|
248
|
+
db: createTenantDb(dbSource, targetTenantId, "tenant", context.tracer, context.meter, undefined, { projectionRegistry: registry }),
|
|
248
249
|
secrets: context.secrets,
|
|
249
250
|
});
|
|
250
251
|
}
|
|
@@ -983,6 +984,7 @@ function buildAuthClaimsContext(ctx, user) {
|
|
|
983
984
|
log: context.log,
|
|
984
985
|
window: ctx.escapeHatchReportWindow,
|
|
985
986
|
}),
|
|
987
|
+
projectionRegistry: ctx.registry,
|
|
986
988
|
});
|
|
987
989
|
const configAccessor = context._configAccessorFactory
|
|
988
990
|
? context._configAccessorFactory({
|
|
@@ -4,6 +4,7 @@ export type PruneEventsOptions = {
|
|
|
4
4
|
readonly olderThan?: Temporal.Instant;
|
|
5
5
|
readonly olderThanDays?: number;
|
|
6
6
|
readonly aggregateTypes: readonly string[];
|
|
7
|
+
readonly aggregateIds?: readonly string[];
|
|
7
8
|
readonly dryRun?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type PruneEventsResult = {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/// <reference types="temporal-polyfill/global" preserve="true" />
|
|
1
2
|
import { lockEventConsumersShareMode } from "../db/queries/event-consumer.js";
|
|
2
3
|
import { deleteMany, selectMany, transaction } from "../db/query.js";
|
|
3
4
|
import { eventsTable } from "../event-store/index.js";
|
|
@@ -30,7 +31,11 @@ export async function pruneEvents(db, options) {
|
|
|
30
31
|
if (!options.aggregateTypes || options.aggregateTypes.length === 0) {
|
|
31
32
|
throw new Error("pruneEvents: aggregateTypes is required and must be non-empty. Pruning the event log is destructive — name the aggregate types to delete explicitly.");
|
|
32
33
|
}
|
|
34
|
+
if (options.aggregateIds !== undefined && options.aggregateIds.length === 0) {
|
|
35
|
+
throw new Error("pruneEvents: aggregateIds must be non-empty when set.");
|
|
36
|
+
}
|
|
33
37
|
const aggregateTypes = options.aggregateTypes;
|
|
38
|
+
const aggregateIds = options.aggregateIds;
|
|
34
39
|
const dryRun = options.dryRun === true;
|
|
35
40
|
return transaction(db, async (tx) => {
|
|
36
41
|
// Serialise against consumer-bootstrap INSERTs. Without this, the race
|
|
@@ -49,6 +54,7 @@ export async function pruneEvents(db, options) {
|
|
|
49
54
|
// Step 1 — collect candidate event ids.
|
|
50
55
|
const candidates = await selectMany(tx, eventsTable, {
|
|
51
56
|
aggregateType: [...aggregateTypes],
|
|
57
|
+
...(aggregateIds ? { aggregateId: [...aggregateIds] } : {}),
|
|
52
58
|
createdAt: { lt: cutoff },
|
|
53
59
|
});
|
|
54
60
|
if (candidates.length === 0) {
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
|
5
5
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
6
6
|
export type { Dispatcher } from "./dispatcher.js";
|
|
7
7
|
export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
|
|
8
|
+
export type { FailedWriteResult } from "./dispatcher-utils.js";
|
|
9
|
+
export { isFailedWriteResult } from "./dispatcher-utils.js";
|
|
8
10
|
export type { DistributedLock } from "./distributed-lock.js";
|
|
9
11
|
export { createDistributedLock } from "./distributed-lock.js";
|
|
10
12
|
export type { EntityCache, EntityCacheOptions } from "./entity-cache.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { createEscapeHatchReporter, createEscapeHatchReportWindow, ESCAPE_HATCH_
|
|
|
2
2
|
export { resolveAuthClaims } from "./auth-claims-resolver.js";
|
|
3
3
|
export { createCascadeDeleteHook } from "./cascade-handler.js";
|
|
4
4
|
export { createDispatcher, dispatcherToWriteRef } from "./dispatcher.js";
|
|
5
|
+
export { isFailedWriteResult } from "./dispatcher-utils.js";
|
|
5
6
|
export { createDistributedLock } from "./distributed-lock.js";
|
|
6
7
|
export { createEntityCache } from "./entity-cache.js";
|
|
7
8
|
export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
|
|
@@ -2,4 +2,5 @@ import type { DbRunner } from "../db/index.js";
|
|
|
2
2
|
import type { LifecycleResult, Registry } from "../engine/types/index.js";
|
|
3
3
|
import type { StoredEvent } from "../event-store/index.js";
|
|
4
4
|
export declare function runProjections(result: LifecycleResult, registry: Registry, runner: DbRunner | undefined): Promise<void>;
|
|
5
|
+
export declare function hasLiveProjectionsForEvent(event: StoredEvent, registry: Registry): boolean;
|
|
5
6
|
export declare function runProjectionsForEvent(event: StoredEvent, registry: Registry, tx: DbRunner): Promise<void>;
|
|
@@ -36,22 +36,31 @@ export async function runProjections(result, registry, runner) {
|
|
|
36
36
|
// CRUD path (via runProjections) and the ctx.appendEvent path (domain events
|
|
37
37
|
// emitted inside a write handler). Keeping one function means an auto-event
|
|
38
38
|
// and a r.defineEvent-event land in the same inline-projection pipeline.
|
|
39
|
+
// Events whose projections already ran. The EventStoreExecutor projects right
|
|
40
|
+
// after its write and hands the same object back on the LifecycleResult, so the
|
|
41
|
+
// dispatcher's later runProjections() and manual calls become no-ops instead of
|
|
42
|
+
// double-applying. Marked only after a successful run: a rolled-back write is
|
|
43
|
+
// retried with a fresh event object, so a thrown projection never blocks a retry.
|
|
44
|
+
const projectedEvents = new WeakSet();
|
|
45
|
+
// Implicit projections exist only for rebuildProjection: the EventStoreExecutor
|
|
46
|
+
// already writes their table on the live path, so applying them live would write
|
|
47
|
+
// twice and hit a unique key violation.
|
|
48
|
+
function liveProjectionsForEvent(event, registry) {
|
|
49
|
+
return registry
|
|
50
|
+
.getProjectionsForSource(event.aggregateType)
|
|
51
|
+
.filter((proj) => !proj.isImplicit && proj.apply[event.type] !== undefined);
|
|
52
|
+
}
|
|
53
|
+
export function hasLiveProjectionsForEvent(event, registry) {
|
|
54
|
+
return liveProjectionsForEvent(event, registry).length > 0;
|
|
55
|
+
}
|
|
39
56
|
export async function runProjectionsForEvent(event, registry, tx) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (projections.length === 0)
|
|
57
|
+
// skip: this event object was already projected in this write
|
|
58
|
+
if (projectedEvents.has(event))
|
|
43
59
|
return;
|
|
44
|
-
for (const proj of
|
|
45
|
-
// ImplicitProjections existieren nur für rebuildProjection — der
|
|
46
|
-
// EventStoreExecutor schreibt im Live-Pfad bereits direkt in die
|
|
47
|
-
// Tabelle. Live-Apply der Implicit würde doppelt schreiben → unique
|
|
48
|
-
// key violation. Filter ist Pflicht.
|
|
49
|
-
if (proj.isImplicit)
|
|
50
|
-
continue;
|
|
60
|
+
for (const proj of liveProjectionsForEvent(event, registry)) {
|
|
51
61
|
const applyFn = proj.apply[event.type];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
continue;
|
|
55
|
-
await applyFn(event, tx, proj.table);
|
|
62
|
+
if (applyFn)
|
|
63
|
+
await applyFn(event, tx, proj.table);
|
|
56
64
|
}
|
|
65
|
+
projectedEvents.add(event);
|
|
57
66
|
}
|
|
@@ -128,5 +128,5 @@ function respondRateLimited(c, decision, bucket) {
|
|
|
128
128
|
c.header("Retry-After", String(Math.max(1, decision.retryAfterSeconds)));
|
|
129
129
|
setRateLimitHeaders(c, decision);
|
|
130
130
|
const reqId = requestContext.get()?.requestId;
|
|
131
|
-
return c.json(serializeError(err, reqId), 429);
|
|
131
|
+
return c.json(serializeError(err, reqId, requestContext.get()?.errorDocs), 429);
|
|
132
132
|
}
|
package/dist/schema-cli.d.ts
CHANGED
|
@@ -4,15 +4,17 @@ export type SchemaCliOut = {
|
|
|
4
4
|
readonly err: (line: string) => void;
|
|
5
5
|
};
|
|
6
6
|
export type RunSchemaCliOptions = {
|
|
7
|
+
readonly features?: undefined;
|
|
8
|
+
readonly kmsSlots?: undefined;
|
|
9
|
+
} | {
|
|
7
10
|
/** Composed app features. When given, `apply` rebuilds the projections whose
|
|
8
11
|
* tables a freshly applied migration changed (via its `.rebuild.json`
|
|
9
12
|
* marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
|
|
10
|
-
readonly features
|
|
11
|
-
/** Schema-declared Key Manager slots,
|
|
12
|
-
* This CLI parses no env schema, so
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
readonly kmsSlots?: readonly string[];
|
|
13
|
+
readonly features: readonly FeatureDefinition[];
|
|
14
|
+
/** Schema-declared Key Manager slots, `kmsSlotsOf(composedEnv.schema)`, or
|
|
15
|
+
* `[]` when the app wires none. This CLI parses no env schema, so the
|
|
16
|
+
* rebuild's KMS wiring cannot derive them itself. */
|
|
17
|
+
readonly kmsSlots: readonly string[];
|
|
16
18
|
};
|
|
17
19
|
/**
|
|
18
20
|
* Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
|