@cosmicdrift/kumiko-framework 0.387.0 → 0.388.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.md +6 -1
- package/agents/examples/anonymous-access.md +1 -1
- package/agents/examples/apex-landing.md +204 -0
- package/agents/examples/api-worker-split.md +64 -0
- package/agents/examples/app-config.md +14 -0
- package/agents/examples/auth-mfa.md +115 -0
- package/agents/examples/env-composition.md +28 -1
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/prod-boot-options.md +16 -0
- package/agents/examples/rate-limiting.md +48 -0
- package/agents/examples/record-detail-layout.md +692 -0
- package/agents/examples/seed-migration.md +212 -0
- package/agents/examples/wizard-form.md +941 -0
- package/agents/examples/writeform-section.md +548 -0
- package/agents/features.md +19 -2
- package/agents/widgets.md +1 -0
- package/dist/api/client-error-route.js +8 -2
- package/dist/api/index.d.ts +3 -1
- package/dist/api/index.js +3 -1
- package/dist/api/request-context.d.ts +1 -1
- package/dist/api/request-context.js +1 -1
- package/dist/api/server-boot-checks.d.ts +10 -0
- package/dist/api/server-boot-checks.js +157 -0
- package/dist/api/server-event-dispatcher.d.ts +26 -0
- package/dist/api/server-event-dispatcher.js +208 -0
- package/dist/api/server-extra-routes.d.ts +11 -0
- package/dist/api/server-extra-routes.js +203 -0
- package/dist/api/server-middleware.d.ts +20 -0
- package/dist/api/server-middleware.js +211 -0
- package/dist/api/server-routes.d.ts +19 -0
- package/dist/api/server-routes.js +293 -0
- package/dist/api/server-websocket-routes.d.ts +4 -0
- package/dist/api/server-websocket-routes.js +108 -0
- package/dist/api/server-wiring.d.ts +32 -0
- package/dist/api/server-wiring.js +236 -0
- package/dist/api/server.d.ts +10 -15
- package/dist/api/server.js +48 -1251
- package/dist/api/session-cookies.js +4 -4
- package/dist/api/sse-route.js +16 -12
- package/dist/crypto/owner-transition-rekey.d.ts +5 -1
- package/dist/crypto/owner-transition-rekey.js +1 -1
- package/dist/crypto/pii-field-encryption.d.ts +5 -1
- package/dist/crypto/pii-field-encryption.js +3 -1
- package/dist/db/aggregate-sql.d.ts +21 -0
- package/dist/db/aggregate-sql.js +155 -0
- package/dist/db/closed-connection.d.ts +9 -0
- package/dist/db/closed-connection.js +95 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +1 -1
- package/dist/db/driver-row-coercion.d.ts +2 -0
- package/dist/db/driver-row-coercion.js +186 -0
- package/dist/db/driver-value-preparation.d.ts +16 -0
- package/dist/db/driver-value-preparation.js +99 -0
- package/dist/db/entity-field-encryption.d.ts +1 -0
- package/dist/db/entity-field-encryption.js +1 -1
- package/dist/db/entity-table-meta.js +103 -110
- package/dist/db/event-store-executor-context.d.ts +2 -1
- package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
- package/dist/db/event-store-executor-read-aggregate.js +112 -0
- package/dist/db/event-store-executor-read-filter.d.ts +31 -0
- package/dist/db/event-store-executor-read-filter.js +203 -0
- package/dist/db/event-store-executor-read-paging.d.ts +14 -0
- package/dist/db/event-store-executor-read-paging.js +99 -0
- package/dist/db/event-store-executor-read-rows.d.ts +6 -0
- package/dist/db/event-store-executor-read-rows.js +32 -0
- package/dist/db/event-store-executor-read-search.d.ts +28 -0
- package/dist/db/event-store-executor-read-search.js +221 -0
- package/dist/db/event-store-executor-read-sort.d.ts +41 -0
- package/dist/db/event-store-executor-read-sort.js +130 -0
- package/dist/db/event-store-executor-read-where.d.ts +24 -0
- package/dist/db/event-store-executor-read-where.js +81 -0
- package/dist/db/event-store-executor-read.d.ts +2 -9
- package/dist/db/event-store-executor-read.js +48 -701
- package/dist/db/event-store-executor-write-steps.d.ts +20 -0
- package/dist/db/event-store-executor-write-steps.js +100 -0
- package/dist/db/event-store-executor-write.js +395 -499
- package/dist/db/index.d.ts +6 -2
- package/dist/db/index.js +5 -1
- package/dist/db/list-aggregate-labels.d.ts +14 -0
- package/dist/db/list-aggregate-labels.js +26 -0
- package/dist/db/migrate-generator.js +71 -86
- package/dist/db/migrate-runner.js +4 -1
- package/dist/db/query.d.ts +2 -15
- package/dist/db/query.js +8 -761
- package/dist/db/reference-data.js +6 -1
- package/dist/db/table-builder.js +113 -166
- package/dist/db/table-info.d.ts +10 -0
- package/dist/db/table-info.js +45 -0
- package/dist/db/temporal-value-guards.d.ts +6 -0
- package/dist/db/temporal-value-guards.js +15 -0
- package/dist/db/tenant-db-runner.d.ts +1 -0
- package/dist/db/tenant-db-runner.js +3 -0
- package/dist/db/tenant-db.js +39 -9
- package/dist/db/where-clause.d.ts +6 -0
- package/dist/db/where-clause.js +236 -0
- package/dist/derivatives/delete-stored-file.js +2 -1
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/detail-screens.js +2 -2
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +17 -0
- package/dist/engine/boot-validator/form-derivations.js +35 -1
- package/dist/engine/boot-validator/i18n-keys.js +5 -3
- package/dist/engine/boot-validator/index.js +2 -0
- package/dist/engine/boot-validator/pii-retention.js +233 -155
- package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
- package/dist/engine/boot-validator/screens-actions.js +8 -1
- package/dist/engine/boot-validator/screens-dashboard.js +2 -5
- package/dist/engine/boot-validator/screens-forms.js +2 -6
- package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
- package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
- package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
- package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
- package/dist/engine/boot-validator/screens-lists.js +12 -9
- package/dist/engine/boot-validator/screens.d.ts +0 -2
- package/dist/engine/boot-validator/screens.js +99 -860
- package/dist/engine/build-app-schema.js +56 -96
- package/dist/engine/define-feature.js +9 -2
- package/dist/engine/define-handler.js +1 -0
- package/dist/engine/entity-handlers.d.ts +13 -0
- package/dist/engine/entity-handlers.js +10 -21
- package/dist/engine/entity-list-runtime-options.d.ts +8 -0
- package/dist/engine/entity-list-runtime-options.js +18 -0
- package/dist/engine/feature-ast/extractors/handlers.js +1 -0
- package/dist/engine/feature-ast/extractors/round2.js +25 -6
- package/dist/engine/feature-ast/patch.js +4 -182
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
- package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
- package/dist/engine/feature-ast/patterns.d.ts +9 -0
- package/dist/engine/feature-ast/patterns.js +43 -51
- package/dist/engine/feature-ast/render.js +48 -87
- package/dist/engine/feature-entity-handlers.js +3 -0
- package/dist/engine/form-derivations.js +18 -7
- package/dist/engine/handler-field-references.js +1 -1
- package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
- package/dist/engine/project-app-schema-for-roles.js +6 -4
- package/dist/engine/registry-ingest.js +1 -1
- package/dist/engine/schema-builder.js +228 -212
- package/dist/engine/screen-helpers.js +1 -1
- package/dist/engine/screen-kind.d.ts +7 -0
- package/dist/engine/screen-kind.js +1 -0
- package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
- package/dist/engine/validate-projection-allowlist.js +2 -1
- package/dist/entrypoint/index.d.ts +4 -1
- package/dist/entrypoint/index.js +7 -1
- package/dist/errors/docs-url.d.ts +1 -1
- package/dist/errors/docs-url.js +22 -2
- package/dist/es-ops/context.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/event-store.js +10 -4
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +4 -1
- package/dist/files/file-handle.js +2 -3
- package/dist/files/file-routes.js +11 -8
- package/dist/files/resolve-content-type.js +43 -0
- package/dist/files/upload-ledger.js +7 -0
- package/dist/i18n/required-surface-keys.js +182 -206
- package/dist/internal/stack-support.d.ts +2 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-context.d.ts +32 -0
- package/dist/jobs/job-runner-context.js +305 -0
- package/dist/jobs/job-runner-execution.d.ts +7 -0
- package/dist/jobs/job-runner-execution.js +160 -0
- package/dist/jobs/job-runner-gates.d.ts +19 -0
- package/dist/jobs/job-runner-gates.js +198 -0
- package/dist/jobs/job-runner-helpers.d.ts +17 -3
- package/dist/jobs/job-runner-helpers.js +61 -2
- package/dist/jobs/job-runner-scope.d.ts +44 -0
- package/dist/jobs/job-runner-scope.js +102 -0
- package/dist/jobs/job-runner-types.d.ts +12 -0
- package/dist/jobs/job-runner.d.ts +0 -1
- package/dist/jobs/job-runner.js +47 -657
- package/dist/observability/escape-hatch-report.d.ts +3 -0
- package/dist/observability/escape-hatch-report.js +12 -1
- package/dist/pipeline/cascade-handler.js +165 -138
- package/dist/pipeline/dispatch-query.js +10 -3
- package/dist/pipeline/dispatch-shared.d.ts +13 -3
- package/dist/pipeline/dispatch-shared.js +58 -710
- package/dist/pipeline/dispatch-write.js +296 -189
- package/dist/pipeline/event-dispatcher-admin.js +2 -1
- package/dist/pipeline/event-dispatcher-delivery.js +3 -5
- package/dist/pipeline/handler-context-auth.d.ts +9 -0
- package/dist/pipeline/handler-context-auth.js +54 -0
- package/dist/pipeline/handler-context-config.d.ts +14 -0
- package/dist/pipeline/handler-context-config.js +113 -0
- package/dist/pipeline/handler-context-db.d.ts +24 -0
- package/dist/pipeline/handler-context-db.js +149 -0
- package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
- package/dist/pipeline/handler-context-dispatch.js +89 -0
- package/dist/pipeline/handler-context-events.d.ts +43 -0
- package/dist/pipeline/handler-context-events.js +237 -0
- package/dist/pipeline/handler-context-notify.d.ts +12 -0
- package/dist/pipeline/handler-context-notify.js +55 -0
- package/dist/pipeline/handler-context-observability.d.ts +7 -0
- package/dist/pipeline/handler-context-observability.js +21 -0
- package/dist/pipeline/handler-context-scope.d.ts +48 -0
- package/dist/pipeline/handler-context-scope.js +88 -0
- package/dist/pipeline/notify-scope.js +12 -1
- package/dist/pipeline/pre-handler-gates.js +20 -4
- package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
- package/dist/pipeline/pre-transaction-reservations.js +9 -3
- package/dist/pipeline/projection-rebuild.js +2 -1
- package/dist/schema-cli/apply-command.d.ts +2 -0
- package/dist/schema-cli/apply-command.js +97 -0
- package/dist/schema-cli/baseline-command.d.ts +2 -0
- package/dist/schema-cli/baseline-command.js +33 -0
- package/dist/schema-cli/generate-command.d.ts +2 -0
- package/dist/schema-cli/generate-command.js +90 -0
- package/dist/schema-cli/shared.d.ts +39 -0
- package/dist/schema-cli/shared.js +95 -0
- package/dist/schema-cli/status-command.d.ts +2 -0
- package/dist/schema-cli/status-command.js +73 -0
- package/dist/schema-cli/validate-boot-input.d.ts +5 -0
- package/dist/schema-cli/validate-boot-input.js +130 -0
- package/dist/schema-cli/validate-command.d.ts +2 -0
- package/dist/schema-cli/validate-command.js +130 -0
- package/dist/schema-cli.d.ts +2 -18
- package/dist/schema-cli.js +47 -604
- package/dist/search/meilisearch-adapter.js +2 -1
- package/dist/upgrade-cli.js +149 -121
- package/package.json +4 -4
- package/src/changes.json +310 -0
- package/src/scripts/codemod/feature-layout.ts +10 -2
- package/src/scripts/codemod/object-form.ts +41 -4
- package/src/scripts/codemod/package-merge.ts +58 -8
- package/src/scripts/codemod/pii-personal-migration.ts +175 -145
- package/src/scripts/codemod/testing-injection.ts +122 -42
- package/src/scripts/codemod/testing-move.ts +29 -7
|
@@ -53,7 +53,7 @@ export function runWithOrigin(origin, fn) {
|
|
|
53
53
|
// surrounding turn/approve handler. Reserved for dispatchToolCall, so it also marks
|
|
54
54
|
// the scope as an agent tool call; it always mints a scope, because an agent loop
|
|
55
55
|
// running outside any request must still carry that marker.
|
|
56
|
-
export function
|
|
56
|
+
export function runAsAgentToolCallEntry(fn) {
|
|
57
57
|
const current = requestContext.get();
|
|
58
58
|
const requestId = current?.requestId ?? requestContext.generateId();
|
|
59
59
|
const { entryHandler: _entryHandler, ...rest } = current ?? {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type AppContext, type Registry } from "../engine/types/index.ts";
|
|
2
|
+
import type { createFallbackLogger } from "../logging/index.ts";
|
|
3
|
+
import type { AuthRoutesConfig } from "./auth-routes.ts";
|
|
4
|
+
export type BootLogger = ReturnType<typeof createFallbackLogger>;
|
|
5
|
+
export declare function assertNoPayloadRateLimitsWithoutResolver(registry: Registry): void;
|
|
6
|
+
export declare function assertFileStorageProviderForFileFields(registry: Registry, context: AppContext): void;
|
|
7
|
+
export declare function warnSearchableScreensWithoutSearchAdapter(registry: Registry, context: AppContext, bootLog: BootLogger): void;
|
|
8
|
+
export declare function resolveServerInstanceId(explicitInstanceId: string | undefined, bootLog: BootLogger): string;
|
|
9
|
+
export declare function warnUnlimitedSystemScopeFeatures(registry: Registry, bootLog: BootLogger): void;
|
|
10
|
+
export declare function assertSinglePrincipalStatusProvider(registry: Registry, auth: AuthRoutesConfig): void;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
|
|
2
|
+
import { isFileField } from "../engine/types/index.js";
|
|
3
|
+
import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
|
|
4
|
+
import { generateId } from "../utils/index.js";
|
|
5
|
+
// A payload-bucket handler without a resolver would answer every call with a
|
|
6
|
+
// 500 at request time; fail at boot instead.
|
|
7
|
+
export function assertNoPayloadRateLimitsWithoutResolver(registry) {
|
|
8
|
+
const offenders = [];
|
|
9
|
+
for (const feature of registry.features.values()) {
|
|
10
|
+
for (const [name, handler] of Object.entries(feature.writeHandlers)) {
|
|
11
|
+
if (handler.additionalRateLimits !== undefined) {
|
|
12
|
+
offenders.push(`${feature.name}:write:${name}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// skip: every handler's payload buckets are covered by the resolver
|
|
17
|
+
if (offenders.length === 0)
|
|
18
|
+
return;
|
|
19
|
+
throw new Error(`Handlers declare additionalRateLimits but no RateLimitResolver is configured: ${offenders.join(", ")}. ` +
|
|
20
|
+
"Wire a Redis client on context.redis (or pass context.rateLimit), or drop the payload buckets " +
|
|
21
|
+
"(for the waitlist: emailRateLimits: []).");
|
|
22
|
+
}
|
|
23
|
+
export function assertFileStorageProviderForFileFields(registry, context) {
|
|
24
|
+
// File-storage is resolved per-tenant through file-foundation: a mounted
|
|
25
|
+
// `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
|
|
26
|
+
// uploads, ctx.files and the GDPR jobs.
|
|
27
|
+
const hasFileProvider = registry.getExtensionUsages(EXT_FILE_PROVIDER).length > 0;
|
|
28
|
+
// A test/advanced caller may inject the resolver directly on the context
|
|
29
|
+
// (e.g. a static in-memory provider) instead of mounting a provider plugin.
|
|
30
|
+
const hasInjectedResolver = context._fileProviderResolver !== undefined;
|
|
31
|
+
// Hard-fail when the registry declares file/image fields but no provider
|
|
32
|
+
// plugin is mounted — uploads would otherwise fail at the first request
|
|
33
|
+
// instead of at boot. Proves a plugin is mounted, not that each tenant
|
|
34
|
+
// selected one; per-tenant misconfig surfaces via createFileProviderForTenant.
|
|
35
|
+
if (registryDeclaresFileFields(registry) && !hasFileProvider && !hasInjectedResolver) {
|
|
36
|
+
throw new Error("Features declare file/image fields but no file-storage provider is mounted — " +
|
|
37
|
+
"mount `file-foundation` + a `file-provider-*` feature (inmemory/s3/s3-env) and " +
|
|
38
|
+
"select one per tenant via the 'file-foundation:config:provider' config-key.");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function warnSearchableScreensWithoutSearchAdapter(registry, context, bootLog) {
|
|
42
|
+
// A screen whose search box renders (kumiko-screen.tsx gates it on
|
|
43
|
+
// `screen.searchable ?? entity-has-searchable-field`, the same condition
|
|
44
|
+
// as entitiesWithSearchableScreen) sends `payload.search` on every query. Without a
|
|
45
|
+
// context.searchAdapter that throws UnprocessableError at request time
|
|
46
|
+
// — surface the misconfig at boot instead of on the first search.
|
|
47
|
+
// Warn, not throw: unlike missing file-storage (uploads always fail),
|
|
48
|
+
// list screens still work without search: only the search box is broken.
|
|
49
|
+
// Several deployed apps (offlot-app, publicstatus, kumiko-studio,
|
|
50
|
+
// kumiko-enterprise) currently run in exactly this state.
|
|
51
|
+
if (!context.searchAdapter) {
|
|
52
|
+
const unwiredEntities = entitiesWithSearchableScreen(registry);
|
|
53
|
+
if (unwiredEntities.length > 0) {
|
|
54
|
+
bootLog.warn(`${unwiredEntities.length} entit${unwiredEntities.length === 1 ? "y" : "ies"} ` +
|
|
55
|
+
`have a searchable list screen but no SearchAdapter is wired on context.searchAdapter: ` +
|
|
56
|
+
`${unwiredEntities.join(", ")}. Search requests against ${unwiredEntities.length === 1 ? "it" : "them"} will fail with a 422 (search_adapter_not_wired) at runtime. ` +
|
|
57
|
+
"Wire a SearchAdapter (e.g. createMeilisearchAdapter) on context.searchAdapter, or remove `searchable: true` from the affected fields.");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function resolveServerInstanceId(explicitInstanceId, bootLog) {
|
|
62
|
+
// Resolve the per-process instance identifier. Prefer explicit
|
|
63
|
+
// ServerOptions.instanceId (tests, deliberate wiring), fall back to the
|
|
64
|
+
// deploy-env variable, finally a boot-time UUID. Validator rejects the
|
|
65
|
+
// sentinel — a deliberate collision attempt would silently merge this
|
|
66
|
+
// instance's per-instance cursors with the shared-row cursors and
|
|
67
|
+
// deliver events twice to one shard while starving the other.
|
|
68
|
+
const resolvedInstanceId = explicitInstanceId ?? process.env["KUMIKO_INSTANCE_ID"] ?? generateId();
|
|
69
|
+
if (resolvedInstanceId === SHARED_INSTANCE_SENTINEL) {
|
|
70
|
+
throw new Error(`ServerOptions.instanceId / KUMIKO_INSTANCE_ID cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}" — ` +
|
|
71
|
+
`pick any other stable string.`);
|
|
72
|
+
}
|
|
73
|
+
// Warn when we fell back to a random UUID. The built-in SSE consumer is
|
|
74
|
+
// delivery="shared" (one cursor for all pods), so it no longer
|
|
75
|
+
// writes a row per instance. The id still labels this process in the
|
|
76
|
+
// app.started event and in per-instance consumers (opt-in delivery), where a
|
|
77
|
+
// fresh id per boot leaves an orphaned cursor-row that pins pruneEvents
|
|
78
|
+
// (retention-guard uses MIN(lastProcessedEventId) across all shards). Silent
|
|
79
|
+
// when explicitInstanceId or KUMIKO_INSTANCE_ID is explicit — those are
|
|
80
|
+
// deliberate choices (the test suite sets KUMIKO_INSTANCE_ID="test-instance").
|
|
81
|
+
const instanceIdWasRandom = explicitInstanceId === undefined && !process.env["KUMIKO_INSTANCE_ID"];
|
|
82
|
+
if (instanceIdWasRandom) {
|
|
83
|
+
bootLog.warn(`No ServerOptions.instanceId / KUMIKO_INSTANCE_ID set — generated a random UUID (${resolvedInstanceId}). ` +
|
|
84
|
+
`SSE no longer needs a per-instance cursor, but the id labels this process in the app.started event and in per-instance consumers; without a stable id each restart leaves an orphaned cursor-row behind for those and pins events-retention on its last cursor. ` +
|
|
85
|
+
`Set KUMIKO_INSTANCE_ID to the pod name (e.g. from the Kubernetes downward API metadata.name) in production.`);
|
|
86
|
+
}
|
|
87
|
+
return resolvedInstanceId;
|
|
88
|
+
}
|
|
89
|
+
export function warnUnlimitedSystemScopeFeatures(registry, bootLog) {
|
|
90
|
+
// The systemScope default limit only exists once a resolver does, and an app that
|
|
91
|
+
// declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
|
|
92
|
+
// false) — so its cross-tenant handlers are silently unlimited.
|
|
93
|
+
const unlimitedSystemScopeFeatures = [...registry.features.values()]
|
|
94
|
+
.filter((feature) => feature.systemScope &&
|
|
95
|
+
Object.keys(feature.writeHandlers).length + Object.keys(feature.queryHandlers).length > 0)
|
|
96
|
+
.map((feature) => feature.name);
|
|
97
|
+
if (unlimitedSystemScopeFeatures.length > 0) {
|
|
98
|
+
bootLog.warn(`r.systemScope() features have handlers but no RateLimitResolver is configured, ` +
|
|
99
|
+
`so their default rate limit is not enforced: ${unlimitedSystemScopeFeatures.join(", ")}. ` +
|
|
100
|
+
"Wire a Redis client on context.redis and declare rateLimit on at least one handler (or pass " +
|
|
101
|
+
"context.rateLimit) to enable the resolver.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export function assertSinglePrincipalStatusProvider(registry, auth) {
|
|
105
|
+
// A membershipQuery handler is registered but principalStatus is not provided by exactly
|
|
106
|
+
// one feature — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
|
|
107
|
+
if (registry.getQueryHandler(auth.membershipQuery)) {
|
|
108
|
+
const principalStatusProviders = registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length;
|
|
109
|
+
if (principalStatusProviders === 0) {
|
|
110
|
+
throw new Error(`[kumiko] auth membershipQuery "${auth.membershipQuery}" is registered but no feature ` +
|
|
111
|
+
'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
|
|
112
|
+
"need it to reject blocked principals).");
|
|
113
|
+
}
|
|
114
|
+
if (principalStatusProviders > 1) {
|
|
115
|
+
throw new Error(`[kumiko] auth membershipQuery "${auth.membershipQuery}" is registered but ` +
|
|
116
|
+
`${principalStatusProviders} features provide the "principalStatus" contract — exactly one ` +
|
|
117
|
+
"is expected (the bundled user feature already provides it).");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Scans every feature's entities for a file/image/files/images field. Short-
|
|
122
|
+
// circuits on the first hit — no need to build a full inventory, we only want
|
|
123
|
+
// the yes/no answer for the boot check.
|
|
124
|
+
function registryDeclaresFileFields(registry) {
|
|
125
|
+
for (const feature of registry.features.values()) {
|
|
126
|
+
for (const entity of Object.values(feature.entities ?? {})) {
|
|
127
|
+
for (const field of Object.values(entity.fields)) {
|
|
128
|
+
if (isFileField(field))
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
// Entities whose entityList screen WOULD render a search box based on
|
|
136
|
+
// screen/field config alone: `screen.searchable` wins when set, otherwise
|
|
137
|
+
// the box shows iff the entity has ≥1 searchable field. `screen.searchable
|
|
138
|
+
// === false` is excluded — the boot-validator (entity-list-screens.ts) only
|
|
139
|
+
// allows that on the whitelisted download-attempt-list-style screens, which
|
|
140
|
+
// never render the box. This is the config-only half of the rule; the
|
|
141
|
+
// client additionally gates on FeatureSchema.searchAdapterMissing
|
|
142
|
+
// (set from the same `!options.context.searchAdapter` check in mountSchemaRoute) so the
|
|
143
|
+
// box is actually suppressed once this function finds a hit.
|
|
144
|
+
function entitiesWithSearchableScreen(registry) {
|
|
145
|
+
const entities = new Set();
|
|
146
|
+
for (const feature of registry.features.values()) {
|
|
147
|
+
for (const screen of Object.values(feature.screens)) {
|
|
148
|
+
if (screen.type !== "entityList")
|
|
149
|
+
continue;
|
|
150
|
+
const isSearchable = screen.searchable === true ||
|
|
151
|
+
(screen.searchable === undefined && registry.getSearchableFields(screen.entity).length > 0);
|
|
152
|
+
if (isSearchable)
|
|
153
|
+
entities.add(screen.entity);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [...entities];
|
|
157
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DbConnection } from "../db/connection.ts";
|
|
2
|
+
import type { AppContext } from "../engine/types/index.ts";
|
|
3
|
+
import type { Lifecycle } from "../lifecycle/index.ts";
|
|
4
|
+
import type { ObservabilityProvider } from "../observability/index.ts";
|
|
5
|
+
import { type EventConsumer, type EventDispatcher } from "../pipeline/event-dispatcher.ts";
|
|
6
|
+
import type { ResolvedCacheSyncBus } from "../redis/cache-sync-bus.ts";
|
|
7
|
+
import type { RedisSseBroker } from "./redis-sse-broker.ts";
|
|
8
|
+
import type { ServerOptions } from "./server.ts";
|
|
9
|
+
import type { SseBroker } from "./sse-broker.ts";
|
|
10
|
+
export declare function createServerEventDispatcher(deps: {
|
|
11
|
+
readonly options: Pick<ServerOptions, "registry" | "eventDispatcher" | "dispatcherOptions" | "processLane">;
|
|
12
|
+
readonly context: AppContext;
|
|
13
|
+
readonly sseBroker: SseBroker;
|
|
14
|
+
readonly observability: ObservabilityProvider;
|
|
15
|
+
readonly fileProviderResolver: AppContext["_fileProviderResolver"];
|
|
16
|
+
readonly instanceId: string;
|
|
17
|
+
}): {
|
|
18
|
+
readonly baseDb: DbConnection | undefined;
|
|
19
|
+
readonly consumers: readonly EventConsumer[];
|
|
20
|
+
readonly eventDispatcher: EventDispatcher | undefined;
|
|
21
|
+
};
|
|
22
|
+
export declare function registerOwnedShutdownHooks(lifecycle: Lifecycle | undefined, owned: {
|
|
23
|
+
readonly eventDispatcher: EventDispatcher | undefined;
|
|
24
|
+
readonly ownedRedisSseBroker: RedisSseBroker | undefined;
|
|
25
|
+
readonly ownedCacheSync: ResolvedCacheSyncBus["owned"];
|
|
26
|
+
}): void;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { withExecutorManagedWrites } from "../db/tenant-db.js";
|
|
2
|
+
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
3
|
+
import { runsInLane } from "../engine/run-in.js";
|
|
4
|
+
import { createTenantFileContext } from "../files/file-handle.js";
|
|
5
|
+
import { createEventDispatcher, } from "../pipeline/event-dispatcher.js";
|
|
6
|
+
import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.js";
|
|
7
|
+
import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
|
|
8
|
+
import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
|
|
9
|
+
export function createServerEventDispatcher(deps) {
|
|
10
|
+
const { options, context, sseBroker, observability, fileProviderResolver, instanceId } = deps;
|
|
11
|
+
// Async event-dispatcher — the replacement for the old transactional
|
|
12
|
+
// outbox. Consumer sources:
|
|
13
|
+
// 1. System: SSE broadcast (iff sseBroker), Search index (iff
|
|
14
|
+
// context.searchAdapter).
|
|
15
|
+
// 2. Features: every r.multiStreamProjection registered in the registry
|
|
16
|
+
// becomes its own consumer row with an independent cursor. The MSP
|
|
17
|
+
// apply map is routed by event.type; apply receives the raw DbRunner
|
|
18
|
+
// of a TX-scoped, tenant-bound DB handle so per-tenant writes stay
|
|
19
|
+
// isolated.
|
|
20
|
+
//
|
|
21
|
+
// The dispatcher is built but NOT started here. Production boot code
|
|
22
|
+
// must call `.start()`; test code typically calls `.runOnce()`.
|
|
23
|
+
const { baseDb, searchAdapter } = resolveBaseDbAndSearchAdapter(context, options.registry);
|
|
24
|
+
const systemConsumers = buildSystemEventConsumers(options, sseBroker, searchAdapter);
|
|
25
|
+
const mspConsumers = buildMultiStreamProjectionConsumers(options, {
|
|
26
|
+
baseDb,
|
|
27
|
+
context,
|
|
28
|
+
fileProviderResolver,
|
|
29
|
+
});
|
|
30
|
+
const consumers = [...systemConsumers, ...mspConsumers];
|
|
31
|
+
const { disabled: dispatcherDisabled, systemConsumers: _systemConsumersOpt, ...dispatcherTunables } = options.eventDispatcher ?? {};
|
|
32
|
+
let eventDispatcher;
|
|
33
|
+
if (consumers.length > 0 && baseDb && !dispatcherDisabled) {
|
|
34
|
+
eventDispatcher = createEventDispatcher({
|
|
35
|
+
db: baseDb,
|
|
36
|
+
consumers,
|
|
37
|
+
context,
|
|
38
|
+
tracer: observability.tracer,
|
|
39
|
+
meter: observability.meter,
|
|
40
|
+
instanceId,
|
|
41
|
+
...dispatcherTunables,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return { baseDb, consumers, eventDispatcher };
|
|
45
|
+
}
|
|
46
|
+
function resolveBaseDbAndSearchAdapter(context, registry) {
|
|
47
|
+
// @cast-boundary engine-bridge — context.db union narrows to DbConnection here
|
|
48
|
+
const baseDb = context.db;
|
|
49
|
+
// @cast-boundary engine-bridge — searchAdapter is an optional context-extension
|
|
50
|
+
const searchAdapter = context.searchAdapter;
|
|
51
|
+
// Adapter is built before the registry exists (app boot order) — hand it
|
|
52
|
+
// the registry-derived default config here so it can lazily configure
|
|
53
|
+
// each tenant index on first access instead of requiring per-tenant app
|
|
54
|
+
// wiring.
|
|
55
|
+
const derivedSearchConfig = searchAdapter ? deriveSearchAdapterConfig(registry) : undefined;
|
|
56
|
+
if (searchAdapter && derivedSearchConfig) {
|
|
57
|
+
searchAdapter.setDefaultConfig?.(derivedSearchConfig);
|
|
58
|
+
}
|
|
59
|
+
return { baseDb, searchAdapter };
|
|
60
|
+
}
|
|
61
|
+
function buildSystemEventConsumers(options, sseBroker, searchAdapter) {
|
|
62
|
+
const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
|
|
63
|
+
const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
|
|
64
|
+
const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
|
|
65
|
+
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
66
|
+
const systemConsumers = [];
|
|
67
|
+
if (sseConsumerEnabled) {
|
|
68
|
+
systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
|
|
69
|
+
}
|
|
70
|
+
if (searchConsumerEnabled && searchAdapter) {
|
|
71
|
+
systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
|
|
72
|
+
}
|
|
73
|
+
if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
|
|
74
|
+
systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
|
|
75
|
+
}
|
|
76
|
+
// Default ON (global-by-default). Tests that opt out of SSE via
|
|
77
|
+
// systemConsumers.accessInvalidation=false (test-stack mirrors sse off)
|
|
78
|
+
// skip the row so retention prune suites are not blocked by a lagging
|
|
79
|
+
// cursor=0 consumer they never drain.
|
|
80
|
+
const accessInvalidationEnabled = options.eventDispatcher?.systemConsumers?.accessInvalidation ?? true;
|
|
81
|
+
if (accessInvalidationEnabled) {
|
|
82
|
+
systemConsumers.push(createAccessInvalidationEventConsumer(sseBroker));
|
|
83
|
+
}
|
|
84
|
+
return systemConsumers;
|
|
85
|
+
}
|
|
86
|
+
function buildMultiStreamProjectionConsumers(options, deps) {
|
|
87
|
+
const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
|
|
88
|
+
const mspJobs = jobRunnerForTriggers?.dispatch
|
|
89
|
+
? {
|
|
90
|
+
dispatch: async (jobName, payload, dispatchOptions) => {
|
|
91
|
+
await jobRunnerForTriggers.dispatch?.(jobName, payload, dispatchOptions);
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
: undefined;
|
|
95
|
+
// MultiStreamProjections: one EventConsumer per MSP. Handler routes by
|
|
96
|
+
// event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
|
|
97
|
+
// still within one tenant by default — the applier receives the plain
|
|
98
|
+
// baseDb DbRunner; tenant consistency comes from the triggering event's
|
|
99
|
+
// own tenantId, not a per-event TenantDb scope.
|
|
100
|
+
//
|
|
101
|
+
// Lane-filter: MSPs declare `runIn` to pin them to a
|
|
102
|
+
// deploy-lane. An MSP with `runIn: "api"` won't be wired into the
|
|
103
|
+
// worker-process dispatcher (and vice versa). `runIn: "both"` (or the
|
|
104
|
+
// legacy undefined default of "worker") runs wherever a dispatcher is
|
|
105
|
+
// started — SKIP LOCKED on the consumer-cursor handles the race between
|
|
106
|
+
// processes that both want the same event.
|
|
107
|
+
const processLane = options.processLane ?? "worker";
|
|
108
|
+
const mspDefs = [...options.registry.getAllMultiStreamProjections().values()].filter((msp) => runsInLane(msp.runIn, processLane));
|
|
109
|
+
return mspDefs.map((msp) => buildMultiStreamProjectionConsumer(msp, {
|
|
110
|
+
registry: options.registry,
|
|
111
|
+
mspJobs,
|
|
112
|
+
...deps,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
function buildMultiStreamProjectionConsumer(msp, deps) {
|
|
116
|
+
const { registry, mspJobs, baseDb, context, fileProviderResolver } = deps;
|
|
117
|
+
return {
|
|
118
|
+
name: msp.name,
|
|
119
|
+
// Feature-toggle gating: carry the owning feature so the event-dispatcher
|
|
120
|
+
// can pause this consumer when the feature is globally disabled. Events
|
|
121
|
+
// queue up in the store and replay cleanly from the same cursor on resume.
|
|
122
|
+
...(registry.getMultiStreamProjectionFeature(msp.name) && {
|
|
123
|
+
featureName: registry.getMultiStreamProjectionFeature(msp.name), // @cast-boundary engine-bridge
|
|
124
|
+
}),
|
|
125
|
+
// Copy the continuous-lifecycle error policy straight onto the consumer.
|
|
126
|
+
// Rebuild uses its own policy (rebuildProjection reads msp.errorMode.rebuild
|
|
127
|
+
// directly); steady-state delivery runs through this consumer.
|
|
128
|
+
...(msp.errorMode?.continuous && { errorPolicy: msp.errorMode.continuous }),
|
|
129
|
+
// Carry the MSP's declared delivery semantic through to the consumer.
|
|
130
|
+
// Default (shared) is applied inside event-dispatcher, so omitting when
|
|
131
|
+
// the MSP didn't declare one keeps the existing behaviour.
|
|
132
|
+
...(msp.delivery && { delivery: msp.delivery }),
|
|
133
|
+
// Carry the MSP's declared cursor-seed through to the consumer. Default
|
|
134
|
+
// (beginning) is applied inside insertConsumerIfAbsent.
|
|
135
|
+
...(msp.startFrom && { startFrom: msp.startFrom }),
|
|
136
|
+
handler: async (event, ctx) => {
|
|
137
|
+
const applyFn = msp.apply[event.type];
|
|
138
|
+
// skip: this MSP doesn't care about this event type — fast path,
|
|
139
|
+
// every event type passes through every MSP consumer exactly once.
|
|
140
|
+
if (!applyFn)
|
|
141
|
+
return;
|
|
142
|
+
if (!baseDb) {
|
|
143
|
+
// skip: no baseDb wired — consumers.length > 0 + baseDb check in
|
|
144
|
+
// createServerEventDispatcher gates dispatcher creation, so we won't reach here in
|
|
145
|
+
// production. Defensive return for the type-narrowing path.
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Saga/process-manager ctx: apply can call ctx.appendEvent to cascade
|
|
149
|
+
// a follow-up event onto another aggregate. Uses the triggering event's
|
|
150
|
+
// tenantId + userId so the causal chain stays tenant-consistent.
|
|
151
|
+
// MSP qualified names are "<feature>:projection:<short>" — the
|
|
152
|
+
// prefix before the first ":" owns the MSP. Used to reject
|
|
153
|
+
// cross-feature ctx.appendEvent calls at emit-site.
|
|
154
|
+
const mspOwner = msp.name.split(":")[0];
|
|
155
|
+
const mspFiles = fileProviderResolver
|
|
156
|
+
? createTenantFileContext(fileProviderResolver, event.tenantId)
|
|
157
|
+
: undefined;
|
|
158
|
+
const mspDb = withExecutorManagedWrites(baseDb);
|
|
159
|
+
const applyCtx = createMultiStreamApplyContext({
|
|
160
|
+
registry,
|
|
161
|
+
db: mspDb,
|
|
162
|
+
tenantId: event.tenantId,
|
|
163
|
+
userId: event.metadata.userId,
|
|
164
|
+
...(mspOwner && { callerFeature: mspOwner }),
|
|
165
|
+
...(mspFiles && { files: mspFiles }),
|
|
166
|
+
...(context.secrets && { secrets: context.secrets }),
|
|
167
|
+
...(mspJobs && { jobs: mspJobs }),
|
|
168
|
+
...(mspFiles && {
|
|
169
|
+
derivatives: createDerivativesContext({
|
|
170
|
+
files: mspFiles,
|
|
171
|
+
registry,
|
|
172
|
+
db: baseDb,
|
|
173
|
+
tenantId: event.tenantId,
|
|
174
|
+
}),
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
await applyFn(event, mspDb, applyCtx);
|
|
178
|
+
// Keep ctx reachable to satisfy the EventConsumerHandler signature.
|
|
179
|
+
void ctx;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function registerOwnedShutdownHooks(lifecycle, owned) {
|
|
184
|
+
const { eventDispatcher, ownedRedisSseBroker, ownedCacheSync } = owned;
|
|
185
|
+
// Wire the event-dispatcher shutdown into the lifecycle so the caller
|
|
186
|
+
// doesn't have to know the dispatcher exists. Hooks drain LIFO, so this
|
|
187
|
+
// runs before anything registered later by the caller (e.g. DB pool close).
|
|
188
|
+
if (lifecycle && eventDispatcher) {
|
|
189
|
+
const dispatcher = eventDispatcher;
|
|
190
|
+
lifecycle.registerShutdownHook("eventDispatcher", async () => {
|
|
191
|
+
await dispatcher.stop();
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
// Same reasoning, scoped to the broker this function created itself — an
|
|
195
|
+
// app-injected sseBroker (options.sseBroker) owns its own teardown.
|
|
196
|
+
if (lifecycle && ownedRedisSseBroker) {
|
|
197
|
+
const broker = ownedRedisSseBroker;
|
|
198
|
+
lifecycle.registerShutdownHook("redisSseBroker", async () => {
|
|
199
|
+
await broker.close();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
if (lifecycle && ownedCacheSync) {
|
|
203
|
+
const bus = ownedCacheSync;
|
|
204
|
+
lifecycle.registerShutdownHook("cacheSync", async () => {
|
|
205
|
+
await bus.close();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Context, Hono } from "hono";
|
|
2
|
+
import type { WriteResult } from "../engine/types/index.ts";
|
|
3
|
+
import type { Dispatcher } from "../pipeline/dispatcher.ts";
|
|
4
|
+
import { type ExtraRouteDefinition, type RoleDispatchArgs, type SystemDispatchArgs } from "./extra-route.ts";
|
|
5
|
+
import type { ServerWiring } from "./server-wiring.ts";
|
|
6
|
+
export type PublicApiRequestMatcher = (c: Context) => boolean;
|
|
7
|
+
export declare function compilePublicApiRequestMatcher(extraRoutes: readonly ExtraRouteDefinition[] | undefined): PublicApiRequestMatcher;
|
|
8
|
+
export declare function mountExtraRoutes(app: Hono, wiring: Pick<ServerWiring, "options" | "dispatcher" | "context" | "clientIpResolver">): void;
|
|
9
|
+
export declare function makeDispatchSystemWrite(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
10
|
+
export declare function makeDispatchWriteAs(dispatcher: Dispatcher): (args: RoleDispatchArgs) => Promise<WriteResult>;
|
|
11
|
+
export declare function makeDispatchSystemQuery(dispatcher: Dispatcher): (args: SystemDispatchArgs) => Promise<unknown>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { ROLES } from "../auth/roles.js";
|
|
2
|
+
import { createSystemUser } from "../engine/system-user.js";
|
|
3
|
+
import { createFallbackLogger } from "../logging/index.js";
|
|
4
|
+
import { assertUnreachable } from "../utils/index.js";
|
|
5
|
+
import { NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS } from "./api-constants.js";
|
|
6
|
+
import { getUser } from "./auth-middleware.js";
|
|
7
|
+
import { clientIpSourceFromHonoContext } from "./client-ip.js";
|
|
8
|
+
import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
|
|
9
|
+
import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES } from "./route-registrars.js";
|
|
10
|
+
import { isMissingOrAnonymousUser, makeSystemQuery, mountHonoRoute, unauthenticatedResponse, } from "./server-routes.js";
|
|
11
|
+
// Only entry:"signature" bypasses jwtGuard (verify() authenticates
|
|
12
|
+
// itself); entry:"anonymous" still needs the anonymousAccess fallthrough
|
|
13
|
+
// (tenant-by-host), entry:"user" needs c.get("user") populated.
|
|
14
|
+
export function compilePublicApiRequestMatcher(extraRoutes) {
|
|
15
|
+
const matchers = (extraRoutes ?? [])
|
|
16
|
+
.filter((route) => route.entry === ExtraRouteEntries.signature)
|
|
17
|
+
.map((route) => ({ method: route.method, pattern: honoPathToRegex(route.path) }));
|
|
18
|
+
return (c) => PUBLIC_API_PATHS.has(c.req.path) ||
|
|
19
|
+
matchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
|
|
20
|
+
}
|
|
21
|
+
export function mountExtraRoutes(app, wiring) {
|
|
22
|
+
const { options, dispatcher, context, clientIpResolver } = wiring;
|
|
23
|
+
// extraRoutes — declarative HTTP-routes with a
|
|
24
|
+
// required `entry` tier. Mounted after r.httpRoute: an
|
|
25
|
+
// extraRoute dispatching through `dispatcher` builds Hono's matcher, so
|
|
26
|
+
// this must run before any seed that also dispatches (runProdApp/
|
|
27
|
+
// createKumikoServer call buildServer before seeding).
|
|
28
|
+
if (options.extraRoutes) {
|
|
29
|
+
// Boot-time validation for the whole list BEFORE mounting anything —
|
|
30
|
+
// an app with one bad route should fail loud at boot, not mount N-1
|
|
31
|
+
// routes and then throw on route N.
|
|
32
|
+
for (const route of options.extraRoutes) {
|
|
33
|
+
assertValidExtraRoute(route);
|
|
34
|
+
}
|
|
35
|
+
const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
|
|
36
|
+
const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
|
|
37
|
+
for (const route of options.extraRoutes) {
|
|
38
|
+
const honoHandler = buildExtraRouteHonoHandler(route, {
|
|
39
|
+
app,
|
|
40
|
+
dispatcher,
|
|
41
|
+
registry: options.registry,
|
|
42
|
+
secrets: context.secrets,
|
|
43
|
+
dispatchSystemWrite,
|
|
44
|
+
dispatchSystemQuery,
|
|
45
|
+
clientIpResolver,
|
|
46
|
+
});
|
|
47
|
+
mountHonoRoute(app, route.method, route.path, honoHandler, bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function assertValidExtraRoute(route) {
|
|
52
|
+
if (!isKnownExtraRouteEntry(route.entry)) {
|
|
53
|
+
throw new Error(`[kumiko] extraRoutes: unknown entry "${String(route.entry)}" on ` +
|
|
54
|
+
`"${route.method} ${route.path}" — expected "anonymous" | "user" | "signature". ` +
|
|
55
|
+
"A JS caller without the ExtraRouteDefinition type can hit this at boot.");
|
|
56
|
+
}
|
|
57
|
+
if (route.entry === ExtraRouteEntries.user && !route.path.startsWith("/api/")) {
|
|
58
|
+
throw new Error(`[kumiko] extraRoutes: entry:"user" route "${route.method} ${route.path}" must be ` +
|
|
59
|
+
"mounted under \"/api/\" — that's the only path prefix that rides the framework's " +
|
|
60
|
+
"jwtGuard chain, which is what populates deps.user.");
|
|
61
|
+
}
|
|
62
|
+
// A signature route under /api/ skips jwtGuard/origin/csrf for every
|
|
63
|
+
// path its pattern matches — a wildcard would switch off auth for
|
|
64
|
+
// unrelated /api/* handlers (e.g. "/api/*" swallows /api/write).
|
|
65
|
+
if (route.entry === ExtraRouteEntries.signature &&
|
|
66
|
+
route.path.startsWith("/api/") &&
|
|
67
|
+
route.path.includes("*")) {
|
|
68
|
+
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
69
|
+
'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
|
|
70
|
+
}
|
|
71
|
+
// A `:param` segment is as broad as a wildcard, and the public matchers test
|
|
72
|
+
// the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
|
|
73
|
+
if (route.entry === ExtraRouteEntries.signature) {
|
|
74
|
+
const routePattern = honoPathToRegex(route.path);
|
|
75
|
+
const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
|
|
76
|
+
if (shadowedFrameworkPath !== undefined) {
|
|
77
|
+
throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
78
|
+
`match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// SystemAdmin write/query builders shared by mountExtraRoutes
|
|
83
|
+
// and server-runtime's `wire` hook (runProdApp/createKumikoServer,
|
|
84
|
+
// after buildServer). Privilege-scope: SystemAdmin is the highest
|
|
85
|
+
// non-tenant-scoped role — reaches ANY SystemAdmin-gated handler on ANY
|
|
86
|
+
// tenant. Only safe for callers that already proved their own authenticity
|
|
87
|
+
// (signature verify(), HMAC state, ...), never exposed to a raw request.
|
|
88
|
+
export function makeDispatchSystemWrite(dispatcher) {
|
|
89
|
+
return ({ handlerQn, payload, tenantId }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
90
|
+
}
|
|
91
|
+
// Privilege-scope: the system user gets exactly the roles the caller names —
|
|
92
|
+
// no implicit SystemAdmin — so a seed can write data gated on app roles.
|
|
93
|
+
// Same trust requirement as makeDispatchSystemWrite: never expose to a raw
|
|
94
|
+
// request, only to code that already proved its own authenticity.
|
|
95
|
+
export function makeDispatchWriteAs(dispatcher) {
|
|
96
|
+
return ({ handlerQn, payload, tenantId, roles }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, roles));
|
|
97
|
+
}
|
|
98
|
+
export function makeDispatchSystemQuery(dispatcher) {
|
|
99
|
+
return ({ handlerQn, payload, tenantId }) => dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
100
|
+
}
|
|
101
|
+
// Same dispatcher.write(...) as entry:"user", but the user is getUser(c) —
|
|
102
|
+
// see AnonymousExtraRouteDeps.write for the privilege/tenant contract.
|
|
103
|
+
function makeAnonymousWrite(
|
|
104
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
105
|
+
c, dispatcher) {
|
|
106
|
+
return (type, payload) => {
|
|
107
|
+
const user = getUser(c);
|
|
108
|
+
if (!user) {
|
|
109
|
+
throw new Error('[kumiko] extraRoutes: entry:"anonymous" deps.write requires this route to be mounted ' +
|
|
110
|
+
'under "/api/" with anonymousAccess wired — no request-resolved session user was found.');
|
|
111
|
+
}
|
|
112
|
+
return dispatcher.write(type, payload, user);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const extraRouteLog = createFallbackLogger("extra-route");
|
|
116
|
+
function isKnownExtraRouteEntry(entry) {
|
|
117
|
+
return (entry === ExtraRouteEntries.anonymous ||
|
|
118
|
+
entry === ExtraRouteEntries.user ||
|
|
119
|
+
entry === ExtraRouteEntries.signature);
|
|
120
|
+
}
|
|
121
|
+
// Hono path pattern ("/api/foo/:bar", "/api/foo/*") → RegExp. Only the
|
|
122
|
+
// subset extraRoutes actually uses (static segments, `:param`, trailing
|
|
123
|
+
// `*`) — no inline `:param{regex}` constraints, no optional `:param?`.
|
|
124
|
+
function honoPathToRegex(path) {
|
|
125
|
+
const segments = path
|
|
126
|
+
.split("/")
|
|
127
|
+
.map((segment) => {
|
|
128
|
+
if (segment.startsWith(":"))
|
|
129
|
+
return "[^/]+";
|
|
130
|
+
if (segment === "*")
|
|
131
|
+
return ".*";
|
|
132
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
133
|
+
})
|
|
134
|
+
.join("/");
|
|
135
|
+
return new RegExp(`^${segments}$`);
|
|
136
|
+
}
|
|
137
|
+
function buildExtraRouteHonoHandler(route, shared) {
|
|
138
|
+
switch (route.entry) {
|
|
139
|
+
case ExtraRouteEntries.anonymous:
|
|
140
|
+
return async (c) => route.handler(c, {
|
|
141
|
+
app: shared.app,
|
|
142
|
+
registry: shared.registry,
|
|
143
|
+
systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
|
|
144
|
+
write: makeAnonymousWrite(c, shared.dispatcher),
|
|
145
|
+
clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
|
|
146
|
+
});
|
|
147
|
+
case ExtraRouteEntries.user:
|
|
148
|
+
return async (c) => {
|
|
149
|
+
const user = getUser(c);
|
|
150
|
+
if (isMissingOrAnonymousUser(user)) {
|
|
151
|
+
return unauthenticatedResponse(c);
|
|
152
|
+
}
|
|
153
|
+
return route.handler(c, {
|
|
154
|
+
app: shared.app,
|
|
155
|
+
registry: shared.registry,
|
|
156
|
+
user,
|
|
157
|
+
query: (type, payload) => shared.dispatcher.query(type, payload, user),
|
|
158
|
+
write: (type, payload) => shared.dispatcher.write(type, payload, user),
|
|
159
|
+
});
|
|
160
|
+
};
|
|
161
|
+
case ExtraRouteEntries.signature:
|
|
162
|
+
return async (c) => {
|
|
163
|
+
const rawBody = await c.req.text();
|
|
164
|
+
const headers = {};
|
|
165
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
166
|
+
headers[key.toLowerCase()] = value;
|
|
167
|
+
});
|
|
168
|
+
let verified;
|
|
169
|
+
try {
|
|
170
|
+
verified = await route.verify({ rawBody, headers, params: c.req.param(), query: c.req.query() }, { registry: shared.registry, secrets: shared.secrets });
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (e instanceof ExtraRouteRejection) {
|
|
174
|
+
if (e.retryAfterSeconds !== undefined) {
|
|
175
|
+
return c.json(e.body, e.status, { "Retry-After": String(e.retryAfterSeconds) });
|
|
176
|
+
}
|
|
177
|
+
return c.json(e.body, e.status);
|
|
178
|
+
}
|
|
179
|
+
// The body goes to unauthenticated callers; infra errors thrown by
|
|
180
|
+
// verify() (secrets, DB) must not leak, so the detail stays in the log.
|
|
181
|
+
extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
|
|
182
|
+
error: e instanceof Error ? e.message : String(e),
|
|
183
|
+
});
|
|
184
|
+
return c.json({
|
|
185
|
+
error: {
|
|
186
|
+
code: "extra_route_signature_invalid",
|
|
187
|
+
message: "signature verification failed",
|
|
188
|
+
},
|
|
189
|
+
}, 401);
|
|
190
|
+
}
|
|
191
|
+
return route.handler(c, verified, {
|
|
192
|
+
app: shared.app,
|
|
193
|
+
registry: shared.registry,
|
|
194
|
+
secrets: shared.secrets,
|
|
195
|
+
systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
|
|
196
|
+
dispatchSystemWrite: shared.dispatchSystemWrite,
|
|
197
|
+
dispatchSystemQuery: shared.dispatchSystemQuery,
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
default:
|
|
201
|
+
return assertUnreachable(route, "extra route entry");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Hono, MiddlewareHandler } from "hono";
|
|
2
|
+
import type { ObservabilityProvider } from "../observability/index.ts";
|
|
3
|
+
import { type TenantLifecycleStatusResolver } from "./auth-middleware.ts";
|
|
4
|
+
import type { ServerOptions } from "./server.ts";
|
|
5
|
+
import type { PublicApiRequestMatcher } from "./server-extra-routes.ts";
|
|
6
|
+
import type { ServerWiring } from "./server-wiring.ts";
|
|
7
|
+
import { type ResolvedSessionCookies } from "./session-cookies.ts";
|
|
8
|
+
export declare function installSessionCookies(app: Hono, auth: ServerOptions["auth"]): ResolvedSessionCookies;
|
|
9
|
+
export declare function installRequestId(app: Hono, wiring: Pick<ServerWiring, "options" | "clientIpResolver">): void;
|
|
10
|
+
export declare function installRateLimitMiddleware(app: Hono, wiring: Pick<ServerWiring, "options" | "rateLimitResolver" | "rateLimitMiddlewareRequested" | "clientIpResolver" | "observability">): MiddlewareHandler | undefined;
|
|
11
|
+
export declare function installObservability(app: Hono, observability: ObservabilityProvider): void;
|
|
12
|
+
export declare function installPiiLeakGuard(app: Hono): void;
|
|
13
|
+
export type AuthGuardChain = {
|
|
14
|
+
readonly tenantLifecycleResolver: TenantLifecycleStatusResolver | undefined;
|
|
15
|
+
readonly sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[];
|
|
16
|
+
};
|
|
17
|
+
export declare function installAuthGuardChain(app: Hono, wiring: Pick<ServerWiring, "options" | "jwt" | "baseDb">, scope: {
|
|
18
|
+
readonly isPublicApiRequest: PublicApiRequestMatcher;
|
|
19
|
+
readonly globalRateLimitGuard: MiddlewareHandler | undefined;
|
|
20
|
+
}): AuthGuardChain;
|