@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.
Files changed (240) hide show
  1. package/AGENTS.md +6 -1
  2. package/agents/examples/anonymous-access.md +1 -1
  3. package/agents/examples/apex-landing.md +204 -0
  4. package/agents/examples/api-worker-split.md +64 -0
  5. package/agents/examples/app-config.md +14 -0
  6. package/agents/examples/auth-mfa.md +115 -0
  7. package/agents/examples/env-composition.md +28 -1
  8. package/agents/examples/legal-pages.md +1 -1
  9. package/agents/examples/prod-boot-options.md +16 -0
  10. package/agents/examples/rate-limiting.md +48 -0
  11. package/agents/examples/record-detail-layout.md +692 -0
  12. package/agents/examples/seed-migration.md +212 -0
  13. package/agents/examples/wizard-form.md +941 -0
  14. package/agents/examples/writeform-section.md +548 -0
  15. package/agents/features.md +19 -2
  16. package/agents/widgets.md +1 -0
  17. package/dist/api/client-error-route.js +8 -2
  18. package/dist/api/index.d.ts +3 -1
  19. package/dist/api/index.js +3 -1
  20. package/dist/api/request-context.d.ts +1 -1
  21. package/dist/api/request-context.js +1 -1
  22. package/dist/api/server-boot-checks.d.ts +10 -0
  23. package/dist/api/server-boot-checks.js +157 -0
  24. package/dist/api/server-event-dispatcher.d.ts +26 -0
  25. package/dist/api/server-event-dispatcher.js +208 -0
  26. package/dist/api/server-extra-routes.d.ts +11 -0
  27. package/dist/api/server-extra-routes.js +203 -0
  28. package/dist/api/server-middleware.d.ts +20 -0
  29. package/dist/api/server-middleware.js +211 -0
  30. package/dist/api/server-routes.d.ts +19 -0
  31. package/dist/api/server-routes.js +293 -0
  32. package/dist/api/server-websocket-routes.d.ts +4 -0
  33. package/dist/api/server-websocket-routes.js +108 -0
  34. package/dist/api/server-wiring.d.ts +32 -0
  35. package/dist/api/server-wiring.js +236 -0
  36. package/dist/api/server.d.ts +10 -15
  37. package/dist/api/server.js +48 -1251
  38. package/dist/api/session-cookies.js +4 -4
  39. package/dist/api/sse-route.js +16 -12
  40. package/dist/crypto/owner-transition-rekey.d.ts +5 -1
  41. package/dist/crypto/owner-transition-rekey.js +1 -1
  42. package/dist/crypto/pii-field-encryption.d.ts +5 -1
  43. package/dist/crypto/pii-field-encryption.js +3 -1
  44. package/dist/db/aggregate-sql.d.ts +21 -0
  45. package/dist/db/aggregate-sql.js +155 -0
  46. package/dist/db/closed-connection.d.ts +9 -0
  47. package/dist/db/closed-connection.js +95 -0
  48. package/dist/db/dialect.js +1 -1
  49. package/dist/db/distinct-on.d.ts +1 -1
  50. package/dist/db/driver-row-coercion.d.ts +2 -0
  51. package/dist/db/driver-row-coercion.js +186 -0
  52. package/dist/db/driver-value-preparation.d.ts +16 -0
  53. package/dist/db/driver-value-preparation.js +99 -0
  54. package/dist/db/entity-field-encryption.d.ts +1 -0
  55. package/dist/db/entity-field-encryption.js +1 -1
  56. package/dist/db/entity-table-meta.js +103 -110
  57. package/dist/db/event-store-executor-context.d.ts +2 -1
  58. package/dist/db/event-store-executor-read-aggregate.d.ts +8 -0
  59. package/dist/db/event-store-executor-read-aggregate.js +112 -0
  60. package/dist/db/event-store-executor-read-filter.d.ts +31 -0
  61. package/dist/db/event-store-executor-read-filter.js +203 -0
  62. package/dist/db/event-store-executor-read-paging.d.ts +14 -0
  63. package/dist/db/event-store-executor-read-paging.js +99 -0
  64. package/dist/db/event-store-executor-read-rows.d.ts +6 -0
  65. package/dist/db/event-store-executor-read-rows.js +32 -0
  66. package/dist/db/event-store-executor-read-search.d.ts +28 -0
  67. package/dist/db/event-store-executor-read-search.js +221 -0
  68. package/dist/db/event-store-executor-read-sort.d.ts +41 -0
  69. package/dist/db/event-store-executor-read-sort.js +130 -0
  70. package/dist/db/event-store-executor-read-where.d.ts +24 -0
  71. package/dist/db/event-store-executor-read-where.js +81 -0
  72. package/dist/db/event-store-executor-read.d.ts +2 -9
  73. package/dist/db/event-store-executor-read.js +48 -701
  74. package/dist/db/event-store-executor-write-steps.d.ts +20 -0
  75. package/dist/db/event-store-executor-write-steps.js +100 -0
  76. package/dist/db/event-store-executor-write.js +395 -499
  77. package/dist/db/index.d.ts +6 -2
  78. package/dist/db/index.js +5 -1
  79. package/dist/db/list-aggregate-labels.d.ts +14 -0
  80. package/dist/db/list-aggregate-labels.js +26 -0
  81. package/dist/db/migrate-generator.js +71 -86
  82. package/dist/db/migrate-runner.js +4 -1
  83. package/dist/db/query.d.ts +2 -15
  84. package/dist/db/query.js +8 -761
  85. package/dist/db/reference-data.js +6 -1
  86. package/dist/db/table-builder.js +113 -166
  87. package/dist/db/table-info.d.ts +10 -0
  88. package/dist/db/table-info.js +45 -0
  89. package/dist/db/temporal-value-guards.d.ts +6 -0
  90. package/dist/db/temporal-value-guards.js +15 -0
  91. package/dist/db/tenant-db-runner.d.ts +1 -0
  92. package/dist/db/tenant-db-runner.js +3 -0
  93. package/dist/db/tenant-db.js +39 -9
  94. package/dist/db/where-clause.d.ts +6 -0
  95. package/dist/db/where-clause.js +236 -0
  96. package/dist/derivatives/delete-stored-file.js +2 -1
  97. package/dist/engine/boot-validator/boot-check.js +1 -1
  98. package/dist/engine/boot-validator/detail-screens.js +2 -2
  99. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  100. package/dist/engine/boot-validator/entity-handler.js +17 -0
  101. package/dist/engine/boot-validator/form-derivations.js +35 -1
  102. package/dist/engine/boot-validator/i18n-keys.js +5 -3
  103. package/dist/engine/boot-validator/index.js +2 -0
  104. package/dist/engine/boot-validator/pii-retention.js +233 -155
  105. package/dist/engine/boot-validator/screens-actions.d.ts +1 -0
  106. package/dist/engine/boot-validator/screens-actions.js +8 -1
  107. package/dist/engine/boot-validator/screens-dashboard.js +2 -5
  108. package/dist/engine/boot-validator/screens-forms.js +2 -6
  109. package/dist/engine/boot-validator/screens-kind-config-edit.d.ts +3 -0
  110. package/dist/engine/boot-validator/screens-kind-config-edit.js +88 -0
  111. package/dist/engine/boot-validator/screens-kind-entity-edit.d.ts +3 -0
  112. package/dist/engine/boot-validator/screens-kind-entity-edit.js +55 -0
  113. package/dist/engine/boot-validator/screens-kind-entity-list.d.ts +3 -0
  114. package/dist/engine/boot-validator/screens-kind-entity-list.js +228 -0
  115. package/dist/engine/boot-validator/screens-kind-projection-detail.d.ts +3 -0
  116. package/dist/engine/boot-validator/screens-kind-projection-detail.js +235 -0
  117. package/dist/engine/boot-validator/screens-kind-projection-list.d.ts +3 -0
  118. package/dist/engine/boot-validator/screens-kind-projection-list.js +52 -0
  119. package/dist/engine/boot-validator/screens-kind-secret-mint.d.ts +3 -0
  120. package/dist/engine/boot-validator/screens-kind-secret-mint.js +28 -0
  121. package/dist/engine/boot-validator/screens-kind-shared.d.ts +37 -0
  122. package/dist/engine/boot-validator/screens-kind-shared.js +142 -0
  123. package/dist/engine/boot-validator/screens-lists.d.ts +2 -1
  124. package/dist/engine/boot-validator/screens-lists.js +12 -9
  125. package/dist/engine/boot-validator/screens.d.ts +0 -2
  126. package/dist/engine/boot-validator/screens.js +99 -860
  127. package/dist/engine/build-app-schema.js +56 -96
  128. package/dist/engine/define-feature.js +9 -2
  129. package/dist/engine/define-handler.js +1 -0
  130. package/dist/engine/entity-handlers.d.ts +13 -0
  131. package/dist/engine/entity-handlers.js +10 -21
  132. package/dist/engine/entity-list-runtime-options.d.ts +8 -0
  133. package/dist/engine/entity-list-runtime-options.js +18 -0
  134. package/dist/engine/feature-ast/extractors/handlers.js +1 -0
  135. package/dist/engine/feature-ast/extractors/round2.js +25 -6
  136. package/dist/engine/feature-ast/patch.js +4 -182
  137. package/dist/engine/feature-ast/pattern-change-schema.d.ts +3 -0
  138. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  139. package/dist/engine/feature-ast/pattern-id-matchers.d.ts +7 -0
  140. package/dist/engine/feature-ast/pattern-id-matchers.js +210 -0
  141. package/dist/engine/feature-ast/patterns.d.ts +9 -0
  142. package/dist/engine/feature-ast/patterns.js +43 -51
  143. package/dist/engine/feature-ast/render.js +48 -87
  144. package/dist/engine/feature-entity-handlers.js +3 -0
  145. package/dist/engine/form-derivations.js +18 -7
  146. package/dist/engine/handler-field-references.js +1 -1
  147. package/dist/engine/project-app-schema-for-roles.d.ts +2 -0
  148. package/dist/engine/project-app-schema-for-roles.js +6 -4
  149. package/dist/engine/registry-ingest.js +1 -1
  150. package/dist/engine/schema-builder.js +228 -212
  151. package/dist/engine/screen-helpers.js +1 -1
  152. package/dist/engine/screen-kind.d.ts +7 -0
  153. package/dist/engine/screen-kind.js +1 -0
  154. package/dist/engine/strip-disabled-features-from-app-schema.js +51 -14
  155. package/dist/engine/validate-projection-allowlist.js +2 -1
  156. package/dist/entrypoint/index.d.ts +4 -1
  157. package/dist/entrypoint/index.js +7 -1
  158. package/dist/errors/docs-url.d.ts +1 -1
  159. package/dist/errors/docs-url.js +22 -2
  160. package/dist/es-ops/context.js +1 -1
  161. package/dist/event-store/admin-api.js +1 -1
  162. package/dist/event-store/event-store.js +10 -4
  163. package/dist/files/bind-pending-file-refs.js +3 -3
  164. package/dist/files/create-file-ref-from-bytes.js +4 -1
  165. package/dist/files/file-handle.js +2 -3
  166. package/dist/files/file-routes.js +11 -8
  167. package/dist/files/resolve-content-type.js +43 -0
  168. package/dist/files/upload-ledger.js +7 -0
  169. package/dist/i18n/required-surface-keys.js +182 -206
  170. package/dist/internal/stack-support.d.ts +2 -1
  171. package/dist/internal/stack-support.js +1 -1
  172. package/dist/jobs/job-runner-context.d.ts +32 -0
  173. package/dist/jobs/job-runner-context.js +305 -0
  174. package/dist/jobs/job-runner-execution.d.ts +7 -0
  175. package/dist/jobs/job-runner-execution.js +160 -0
  176. package/dist/jobs/job-runner-gates.d.ts +19 -0
  177. package/dist/jobs/job-runner-gates.js +198 -0
  178. package/dist/jobs/job-runner-helpers.d.ts +17 -3
  179. package/dist/jobs/job-runner-helpers.js +61 -2
  180. package/dist/jobs/job-runner-scope.d.ts +44 -0
  181. package/dist/jobs/job-runner-scope.js +102 -0
  182. package/dist/jobs/job-runner-types.d.ts +12 -0
  183. package/dist/jobs/job-runner.d.ts +0 -1
  184. package/dist/jobs/job-runner.js +47 -657
  185. package/dist/observability/escape-hatch-report.d.ts +3 -0
  186. package/dist/observability/escape-hatch-report.js +12 -1
  187. package/dist/pipeline/cascade-handler.js +165 -138
  188. package/dist/pipeline/dispatch-query.js +10 -3
  189. package/dist/pipeline/dispatch-shared.d.ts +13 -3
  190. package/dist/pipeline/dispatch-shared.js +58 -710
  191. package/dist/pipeline/dispatch-write.js +296 -189
  192. package/dist/pipeline/event-dispatcher-admin.js +2 -1
  193. package/dist/pipeline/event-dispatcher-delivery.js +3 -5
  194. package/dist/pipeline/handler-context-auth.d.ts +9 -0
  195. package/dist/pipeline/handler-context-auth.js +54 -0
  196. package/dist/pipeline/handler-context-config.d.ts +14 -0
  197. package/dist/pipeline/handler-context-config.js +113 -0
  198. package/dist/pipeline/handler-context-db.d.ts +24 -0
  199. package/dist/pipeline/handler-context-db.js +149 -0
  200. package/dist/pipeline/handler-context-dispatch.d.ts +21 -0
  201. package/dist/pipeline/handler-context-dispatch.js +89 -0
  202. package/dist/pipeline/handler-context-events.d.ts +43 -0
  203. package/dist/pipeline/handler-context-events.js +237 -0
  204. package/dist/pipeline/handler-context-notify.d.ts +12 -0
  205. package/dist/pipeline/handler-context-notify.js +55 -0
  206. package/dist/pipeline/handler-context-observability.d.ts +7 -0
  207. package/dist/pipeline/handler-context-observability.js +21 -0
  208. package/dist/pipeline/handler-context-scope.d.ts +48 -0
  209. package/dist/pipeline/handler-context-scope.js +88 -0
  210. package/dist/pipeline/notify-scope.js +12 -1
  211. package/dist/pipeline/pre-handler-gates.js +20 -4
  212. package/dist/pipeline/pre-transaction-reservations.d.ts +1 -0
  213. package/dist/pipeline/pre-transaction-reservations.js +9 -3
  214. package/dist/pipeline/projection-rebuild.js +2 -1
  215. package/dist/schema-cli/apply-command.d.ts +2 -0
  216. package/dist/schema-cli/apply-command.js +97 -0
  217. package/dist/schema-cli/baseline-command.d.ts +2 -0
  218. package/dist/schema-cli/baseline-command.js +33 -0
  219. package/dist/schema-cli/generate-command.d.ts +2 -0
  220. package/dist/schema-cli/generate-command.js +90 -0
  221. package/dist/schema-cli/shared.d.ts +39 -0
  222. package/dist/schema-cli/shared.js +95 -0
  223. package/dist/schema-cli/status-command.d.ts +2 -0
  224. package/dist/schema-cli/status-command.js +73 -0
  225. package/dist/schema-cli/validate-boot-input.d.ts +5 -0
  226. package/dist/schema-cli/validate-boot-input.js +130 -0
  227. package/dist/schema-cli/validate-command.d.ts +2 -0
  228. package/dist/schema-cli/validate-command.js +130 -0
  229. package/dist/schema-cli.d.ts +2 -18
  230. package/dist/schema-cli.js +47 -604
  231. package/dist/search/meilisearch-adapter.js +2 -1
  232. package/dist/upgrade-cli.js +149 -121
  233. package/package.json +4 -4
  234. package/src/changes.json +310 -0
  235. package/src/scripts/codemod/feature-layout.ts +10 -2
  236. package/src/scripts/codemod/object-form.ts +41 -4
  237. package/src/scripts/codemod/package-merge.ts +58 -8
  238. package/src/scripts/codemod/pii-personal-migration.ts +175 -145
  239. package/src/scripts/codemod/testing-injection.ts +122 -42
  240. package/src/scripts/codemod/testing-move.ts +29 -7
@@ -1,1263 +1,60 @@
1
1
  import { Hono } from "hono";
2
- import { ROLES } from "../auth/roles.js";
3
- import { createProjectingTenantDb, withExecutorManagedWrites } from "../db/tenant-db.js";
4
- import { createDerivativesContext } from "../derivatives/derivatives-context.js";
5
- import { buildAppSchema } from "../engine/build-app-schema.js";
6
- import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
7
- import { projectAppSchemaForRoles } from "../engine/project-app-schema-for-roles.js";
8
- import { runsInLane } from "../engine/run-in.js";
9
- import { resolveDisabledFeatureNames, stripDisabledFeaturesFromAppSchema, } from "../engine/strip-disabled-features-from-app-schema.js";
10
- import { createAnonymousUser, createSystemUser, isAnonymousSessionUser, } from "../engine/system-user.js";
11
- import { isFileField, } from "../engine/types/index.js";
12
- import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
13
- import { createTenantFileContext } from "../files/file-handle.js";
14
- import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
15
- import { makeFileProviderResolver } from "../files/provider-resolver.js";
16
- import { createRecordReadCheck } from "../files/record-read-check.js";
17
- import { createRecordWriteCheck } from "../files/record-write-check.js";
18
- import { createFallbackLogger } from "../logging/index.js";
19
- import { createNoopProvider, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
20
- import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
21
- import { configAccessorFactoryOf } from "../pipeline/config-accessor-factory.js";
22
- import { createDispatcher } from "../pipeline/dispatcher.js";
23
- import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state.js";
24
- import { createEventDispatcher } from "../pipeline/event-dispatcher.js";
25
- import { createLifecycleHooks } from "../pipeline/lifecycle-pipeline.js";
26
- import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context.js";
27
- import { createAccessInvalidationEventConsumer, createJobTriggerEventConsumer, createSearchEventConsumer, createSseBroadcastEventConsumer, } from "../pipeline/system-hooks.js";
28
- import { authEndpointRateLimit, createPayloadDigest, createRateLimitResolver, globalIpRateLimit, httpRouteRateLimit, } from "../rate-limit/index.js";
29
- import { resolveCacheSyncBus } from "../redis/cache-sync-bus.js";
30
- import { deriveSearchAdapterConfig } from "../search/derive-search-adapter-config.js";
31
- import { assertUnreachable, generateId } from "../utils/index.js";
32
- import { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
33
- import { NO_ROUTE_MATCH_HEADER_NAME, NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes, } from "./api-constants.js";
34
- import { authMiddleware, getAuthTokenExpiry, getUser, identifyOptionalSession, } from "./auth-middleware.js";
35
- import { assertSessionCallbacksPaired, createAuthRoutes, } from "./auth-routes.js";
36
- import { createClientErrorRoute } from "./client-error-route.js";
37
- import { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
38
- import { csrfMiddleware } from "./csrf-middleware.js";
39
- import { collectEntityReadAccess } from "./entity-read-access.js";
40
- import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
41
- import { computeStrongEtag, etagMatches } from "./http-cache.js";
42
- import { createJwtHelper } from "./jwt.js";
43
- import { observabilityMiddleware } from "./observability-middleware.js";
44
- import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, rejectOrigin, resolveOriginGuard, } from "./origin-middleware.js";
45
- import { patRouteGuard } from "./pat-route-guard.js";
46
- import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
47
- import { createDefaultSseBroker } from "./redis-sse-broker.js";
48
- import { requestContext } from "./request-context.js";
49
- import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
50
- import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
51
- import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
52
- import { resolveSessionCookies, sessionCookiesMiddleware } from "./session-cookies.js";
53
- import { createSseRoute } from "./sse-route.js";
54
- import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
55
- // The per-tenant file-provider resolver, built once for a registry+context so
56
- // a job-runner and the server it runs beside share one instance (and one
57
- // per-tenant provider cache). Mirrors buildServer's own resolution exactly —
58
- // including NOT inventing a resolver when no `file-provider-*` plugin is
59
- // mounted, which is what keeps buildServer's boot-guard below able to fire.
60
- export function withFileProviderResolver(registry, context, cacheSync) {
61
- if (context._fileProviderResolver !== undefined)
62
- return context;
63
- if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0)
64
- return context;
65
- return {
66
- ...context,
67
- _fileProviderResolver: makeFileProviderResolver({
68
- registry,
69
- _configAccessorFactory: configAccessorFactoryOf(context),
70
- secrets: context.secrets,
71
- db: context.db,
72
- ...(cacheSync ? { cacheSync } : {}),
73
- }),
74
- };
75
- }
76
- // A payload-bucket handler without a resolver would answer every call with a
77
- // 500 at request time; fail at boot instead.
78
- function assertNoPayloadRateLimitsWithoutResolver(registry) {
79
- const offenders = [];
80
- for (const feature of registry.features.values()) {
81
- for (const [name, handler] of Object.entries(feature.writeHandlers)) {
82
- if (handler.additionalRateLimits !== undefined) {
83
- offenders.push(`${feature.name}:write:${name}`);
84
- }
85
- }
86
- }
87
- // skip: every handler's payload buckets are covered by the resolver
88
- if (offenders.length === 0)
89
- return;
90
- throw new Error(`Handlers declare additionalRateLimits but no RateLimitResolver is configured: ${offenders.join(", ")}. ` +
91
- "Wire a Redis client on context.redis (or pass context.rateLimit), or drop the payload buckets " +
92
- "(for the waitlist: emailRateLimits: []).");
93
- }
2
+ import { DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerVersionRoute, } from "./route-registrars.js";
3
+ import { handleUncaughtRouteError } from "./routes.js";
4
+ import { compilePublicApiRequestMatcher, mountExtraRoutes } from "./server-extra-routes.js";
5
+ import { installAuthGuardChain, installObservability, installPiiLeakGuard, installRateLimitMiddleware, installRequestId, installSessionCookies, } from "./server-middleware.js";
6
+ import { installNoRouteMatchMarker, mountAuthRoutes, mountClientErrorRoute, mountDispatcherRoutes, mountFeatureHttpRoutes, mountFileRoutes, mountOperationalRoutes, mountSchemaRoute, } from "./server-routes.js";
7
+ import { mountWebSocketRoutes } from "./server-websocket-routes.js";
8
+ import { resolveServerWiring } from "./server-wiring.js";
94
9
  export function buildServer(options) {
95
- assertSeedModeNotInProduction();
96
- const bootLog = createFallbackLogger("kumiko:boot", options.context.log);
97
- // Single effective hop-count for every IP-derived rate-limit + requestMeta
98
- // below — the top-level option wins over the deprecated
99
- // `auth.trustedProxyHops` fallback.
100
- const trustedProxyHops = options.trustedProxyHops ?? options.auth?.trustedProxyHops ?? 0;
101
- assertValidTrustedProxyHops(trustedProxyHops, "buildServer");
102
- // Exactly ONE resolver for the whole server — the warn-once flag must fire
103
- // once per boot, not once per consumer (requestIdMiddleware, L1, L2,
104
- // auth-routes each sharing this same instance rather than building a
105
- // private one).
106
- const clientIpResolver = createClientIpResolver(trustedProxyHops, "buildServer");
107
- // File-storage is resolved per-tenant through file-foundation: a mounted
108
- // `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
109
- // uploads, ctx.files and the GDPR jobs.
110
- const hasFileProvider = options.registry.getExtensionUsages(EXT_FILE_PROVIDER).length > 0;
111
- // A test/advanced caller may inject the resolver directly on the context
112
- // (e.g. a static in-memory provider) instead of mounting a provider plugin.
113
- const hasInjectedResolver = options.context._fileProviderResolver !== undefined;
114
- // Hard-fail when the registry declares file/image fields but no provider
115
- // plugin is mounted — uploads would otherwise fail at the first request
116
- // instead of at boot. Proves a plugin is mounted, not that each tenant
117
- // selected one; per-tenant misconfig surfaces via createFileProviderForTenant.
118
- if (registryDeclaresFileFields(options.registry) && !hasFileProvider && !hasInjectedResolver) {
119
- throw new Error("Features declare file/image fields but no file-storage provider is mounted — " +
120
- "mount `file-foundation` + a `file-provider-*` feature (inmemory/s3/s3-env) and " +
121
- "select one per tenant via the 'file-foundation:config:provider' config-key.");
122
- }
123
- // A screen whose search box renders (kumiko-screen.tsx gates it on
124
- // `screen.searchable ?? entity-has-searchable-field`, the same condition
125
- // used below) sends `payload.search` on every query. Without a
126
- // context.searchAdapter that throws UnprocessableError at request time
127
- // — surface the misconfig at boot instead of on the first search.
128
- // Warn, not throw: unlike missing file-storage (uploads always fail),
129
- // list screens still work without search: only the search box is broken.
130
- // Several deployed apps (offlot-app, publicstatus, kumiko-studio,
131
- // kumiko-enterprise) currently run in exactly this state.
132
- if (!options.context.searchAdapter) {
133
- const unwiredEntities = entitiesWithSearchableScreen(options.registry);
134
- if (unwiredEntities.length > 0) {
135
- bootLog.warn(`${unwiredEntities.length} entit${unwiredEntities.length === 1 ? "y" : "ies"} ` +
136
- `have a searchable list screen but no SearchAdapter is wired on context.searchAdapter: ` +
137
- `${unwiredEntities.join(", ")}. Search requests against ${unwiredEntities.length === 1 ? "it" : "them"} will fail with a 422 (search_adapter_not_wired) at runtime. ` +
138
- "Wire a SearchAdapter (e.g. createMeilisearchAdapter) on context.searchAdapter, or remove `searchable: true` from the affected fields.");
139
- }
140
- }
141
- // Stateless JWTs (no sessionChecker → no revocation) default to a shorter
142
- // TTL than session-backed ones, since a leaked stateless token can't be
143
- // revoked and stays valid until it expires. Explicit jwtTtl always wins.
144
- const defaultJwtTtl = options.auth?.sessionChecker ? 8 * 60 * 60 : 60 * 60;
145
- const jwt = createJwtHelper(options.jwtSecret, options.jwtIssuer, options.jwtTtl ?? defaultJwtTtl);
146
- // An explicit ServerOptions.sseBroker always wins (caller owns its
147
- // lifecycle) — createDefaultSseBroker is the single decision point every
148
- // other caller must funnel through instead of picking a broker on its
149
- // own, so the REDIS_URL-gated cross-replica default can't be silently
150
- // bypassed the way runProdApp bypassed it before this existed.
151
- let sseBroker;
152
- let ownedRedisSseBroker;
153
- if (options.sseBroker) {
154
- sseBroker = options.sseBroker;
155
- }
156
- else {
157
- const defaults = createDefaultSseBroker();
158
- sseBroker = defaults.sseBroker;
159
- ownedRedisSseBroker = defaults.ownedRedisSseBroker;
160
- if (!ownedRedisSseBroker) {
161
- bootLog.warn("REDIS_URL is not set: cross-replica fanout disabled. SSE, access-invalidation and toggle-sync reach only the cursor-owning process, so with more than one replica clients on the other replicas miss events and revoked sessions stay open there.");
162
- }
163
- }
164
- // Same decision as the SSE broker: an explicit dispatcherOptions value
165
- // (incl. null = opt out) wins, otherwise REDIS_URL turns on cross-replica
166
- // cache invalidation (no REDIS_URL = process-local bus).
167
- const { bus: cacheSync, owned: ownedCacheSync } = resolveCacheSyncBus(options.dispatcherOptions?.cacheSync);
168
- // Resolve the per-process instance identifier. Prefer explicit
169
- // ServerOptions.instanceId (tests, deliberate wiring), fall back to the
170
- // deploy-env variable, finally a boot-time UUID. Validator rejects the
171
- // sentinel — a deliberate collision attempt would silently merge this
172
- // instance's per-instance cursors with the shared-row cursors and
173
- // deliver events twice to one shard while starving the other.
174
- const resolvedInstanceId = options.instanceId ?? process.env["KUMIKO_INSTANCE_ID"] ?? generateId();
175
- if (resolvedInstanceId === SHARED_INSTANCE_SENTINEL) {
176
- throw new Error(`ServerOptions.instanceId / KUMIKO_INSTANCE_ID cannot equal the reserved sentinel "${SHARED_INSTANCE_SENTINEL}" — ` +
177
- `pick any other stable string.`);
178
- }
179
- // Warn when we fell back to a random UUID. The built-in SSE consumer is
180
- // delivery="shared" (one cursor for all pods), so it no longer
181
- // writes a row per instance. The id still labels this process in the
182
- // app.started event and in per-instance consumers (opt-in delivery), where a
183
- // fresh id per boot leaves an orphaned cursor-row that pins pruneEvents
184
- // (retention-guard uses MIN(lastProcessedEventId) across all shards). Silent
185
- // when options.instanceId or KUMIKO_INSTANCE_ID is explicit — those are
186
- // deliberate choices (the test suite sets KUMIKO_INSTANCE_ID="test-instance").
187
- const instanceIdWasRandom = options.instanceId === undefined && !process.env["KUMIKO_INSTANCE_ID"];
188
- if (instanceIdWasRandom) {
189
- bootLog.warn(`No ServerOptions.instanceId / KUMIKO_INSTANCE_ID set — generated a random UUID (${resolvedInstanceId}). ` +
190
- `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. ` +
191
- `Set KUMIKO_INSTANCE_ID to the pod name (e.g. from the Kubernetes downward API metadata.name) in production.`);
192
- }
193
- // Observability — Noop by default so no call-site needs to null-check.
194
- // Every handler/middleware that reaches for ctx.tracer / ctx.metrics gets
195
- // a working, zero-cost fallback when no provider is configured.
196
- const observability = options.observability ?? createNoopProvider();
197
- // Register framework + feature metrics once on this meter. Standard
198
- // metrics (HTTP, dispatcher, DB) are used by Auto-Instrumentation; feature
199
- // metrics come from r.metric(...) declarations collected in the registry.
200
- registerStandardMetrics(observability.meter);
201
- for (const [name, def] of options.registry.getAllMetrics()) {
202
- if (observability.meter.definitions().has(name))
203
- continue;
204
- observability.meter.registerMetric({
205
- name,
206
- type: def.type,
207
- description: def.description,
208
- labels: def.labels,
209
- buckets: def.buckets,
210
- unit: def.unit,
211
- tenantLabel: def.tenantLabel,
212
- });
213
- }
214
- // When a non-default provider is configured, wrap the injected Redis clients
215
- // so `redis.cmd` spans attach to every command. For the default NoopProvider
216
- // we skip the proxy to keep zero runtime overhead when observability is off.
217
- const shouldWrapRedis = options.observability !== undefined;
218
- const redisCtx = options.context.redis;
219
- const wrappedRedis = shouldWrapRedis && redisCtx ? wrapRedisClient(redisCtx, observability.tracer) : redisCtx;
220
- // Inject tracer + meter into the AppContext so the dispatcher can propagate
221
- // them into every HandlerContext it builds. Build the per-tenant file-provider
222
- // resolver once (when a provider plugin is mounted) — the dispatcher uses it
223
- // to materialise `ctx.files`, the upload routes + MSP-applies share it. The
224
- // resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
225
- const contextWithFiles = withFileProviderResolver(options.registry, options.context, cacheSync);
226
- const fileProviderResolver = contextWithFiles._fileProviderResolver;
227
- // Auto-wire the rate-limit resolver, but ONLY when at least one
228
- // handler actually declared a rateLimit option. Apps that don't use
229
- // L3 pay zero cost: no resolver instance, no Lua-script registration
230
- // on Redis, no AppContext field. Apps that wire L1/L2 middleware can
231
- // pass `context.rateLimit` explicitly — that takes precedence over
232
- // the auto-wire (e.g. middleware-only setup without any L3 handler).
233
- // Auto-build the resolver when L3 handlers declared rateLimit OR when
234
- // the caller asked for L1/L2 middleware. Either path needs a resolver;
235
- // both share the same instance to avoid duplicate Lua-script registration.
236
- const wantsL3 = options.registry.hasRateLimitedHandler();
237
- const wantsL1L2 = options.rateLimit?.global !== undefined || options.rateLimit?.auth !== undefined;
238
- const wantsHttpRouteLimit = [...options.registry.features.values()].some((feature) => Object.values(feature.httpRoutes).some((route) => route.rateLimit !== undefined));
239
- const wantsResolver = wantsL3 || wantsL1L2 || wantsHttpRouteLimit;
240
- // Master for the payload-bucket HMAC: the current signing secret (the
241
- // keyring's signKid for rotation setups). buildServer always has one, so
242
- // the digest is available whenever a handler declares additionalRateLimits.
243
- const payloadDigest = createPayloadDigest(payloadDigestMasterSecret(options.jwtSecret));
244
- const rateLimitResolver = options.context.rateLimit ??
245
- (wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
246
- if (!rateLimitResolver) {
247
- assertNoPayloadRateLimitsWithoutResolver(options.registry);
248
- // The systemScope default limit only exists once a resolver does, and an app that
249
- // declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
250
- // false) — so its cross-tenant handlers are silently unlimited.
251
- const unlimitedSystemScopeFeatures = [...options.registry.features.values()]
252
- .filter((feature) => feature.systemScope &&
253
- Object.keys(feature.writeHandlers).length + Object.keys(feature.queryHandlers).length > 0)
254
- .map((feature) => feature.name);
255
- if (unlimitedSystemScopeFeatures.length > 0) {
256
- bootLog.warn(`r.systemScope() features have handlers but no RateLimitResolver is configured, ` +
257
- `so their default rate limit is not enforced: ${unlimitedSystemScopeFeatures.join(", ")}. ` +
258
- "Wire a Redis client on context.redis and declare rateLimit on at least one handler (or pass " +
259
- "context.rateLimit) to enable the resolver.");
260
- }
261
- }
262
- const contextWithObservability = {
263
- ...contextWithFiles,
264
- ...(wrappedRedis ? { redis: wrappedRedis } : {}),
265
- ...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
266
- _rateLimitPayloadDigest: payloadDigest,
267
- ...(cacheSync ? { cacheSync } : {}),
268
- // Propagate the feature-toggle resolver to the context so the event-
269
- // dispatcher (and any future context-reading consumer) sees the same
270
- // source as the command dispatcher's handler-gate. Options take
271
- // precedence over whatever was already on context — the
272
- // dispatcher-options arg is the authoritative wire-up point.
273
- ...(options.dispatcherOptions?.effectiveFeatures
274
- ? { effectiveFeatures: options.dispatcherOptions.effectiveFeatures }
275
- : {}),
276
- tracer: observability.tracer,
277
- meter: observability.meter,
278
- };
279
- const lifecycle = createLifecycleHooks(options.registry, options.systemHooks, options.eventDedup ? { eventDedup: options.eventDedup } : undefined);
280
- const dispatcher = createDispatcher(options.registry, contextWithObservability, {
281
- ...options.dispatcherOptions,
282
- lifecycle,
283
- sseBroker,
284
- cacheSync: cacheSync ?? null,
285
- ...(options.auth ? { membershipQuery: options.auth.membershipQuery } : {}),
286
- });
287
- // Async event-dispatcher — the replacement for the old transactional
288
- // outbox. Consumer sources:
289
- // 1. System: SSE broadcast (iff sseBroker), Search index (iff
290
- // context.searchAdapter).
291
- // 2. Features: every r.multiStreamProjection registered in the registry
292
- // becomes its own consumer row with an independent cursor. The MSP
293
- // apply map is routed by event.type; apply receives the raw DbRunner
294
- // of a TX-scoped, tenant-bound DB handle so per-tenant writes stay
295
- // isolated.
296
- //
297
- // The dispatcher is built but NOT started here. Production boot code
298
- // must call `.start()`; test code typically calls `.runOnce()`.
299
- // @cast-boundary engine-bridge — context.db union narrows to DbConnection here
300
- const baseDb = contextWithObservability.db;
301
- // @cast-boundary engine-bridge — searchAdapter is an optional context-extension
302
- const searchAdapter = contextWithObservability
303
- .searchAdapter;
304
- // Adapter is built before the registry exists (app boot order) — hand it
305
- // the registry-derived default config here so it can lazily configure
306
- // each tenant index on first access instead of requiring per-tenant app
307
- // wiring.
308
- const derivedSearchConfig = searchAdapter
309
- ? deriveSearchAdapterConfig(options.registry)
310
- : undefined;
311
- if (searchAdapter && derivedSearchConfig) {
312
- searchAdapter.setDefaultConfig?.(derivedSearchConfig);
313
- }
314
- const sseConsumerEnabled = options.eventDispatcher?.systemConsumers?.sse ?? true;
315
- const searchConsumerEnabled = options.eventDispatcher?.systemConsumers?.search ?? true;
316
- const jobTriggerConsumerEnabled = options.eventDispatcher?.systemConsumers?.jobTrigger ?? true;
317
- const jobRunnerForTriggers = options.dispatcherOptions?.jobRunner;
318
- const mspJobs = jobRunnerForTriggers?.dispatch
319
- ? {
320
- dispatch: async (jobName, payload, dispatchOptions) => {
321
- await jobRunnerForTriggers.dispatch?.(jobName, payload, dispatchOptions);
322
- },
323
- }
324
- : undefined;
325
- const systemConsumers = [];
326
- if (sseConsumerEnabled) {
327
- systemConsumers.push(createSseBroadcastEventConsumer(sseBroker));
328
- }
329
- if (searchConsumerEnabled && searchAdapter) {
330
- systemConsumers.push(createSearchEventConsumer(searchAdapter, options.registry));
331
- }
332
- if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
333
- systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
334
- }
335
- // Default ON (global-by-default). Tests that opt out of SSE via
336
- // systemConsumers.accessInvalidation=false (test-stack mirrors sse off)
337
- // skip the row so retention prune suites are not blocked by a lagging
338
- // cursor=0 consumer they never drain.
339
- const accessInvalidationEnabled = options.eventDispatcher?.systemConsumers?.accessInvalidation ?? true;
340
- if (accessInvalidationEnabled) {
341
- systemConsumers.push(createAccessInvalidationEventConsumer(sseBroker));
342
- }
343
- // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
344
- // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
345
- // still within one tenant by default — the applier receives the plain
346
- // baseDb DbRunner; tenant consistency comes from the triggering event's
347
- // own tenantId, not a per-event TenantDb scope.
348
- //
349
- // Lane-filter: MSPs declare `runIn` to pin them to a
350
- // deploy-lane. An MSP with `runIn: "api"` won't be wired into the
351
- // worker-process dispatcher (and vice versa). `runIn: "both"` (or the
352
- // legacy undefined default of "worker") runs wherever a dispatcher is
353
- // started — SKIP LOCKED on the consumer-cursor handles the race between
354
- // processes that both want the same event.
355
- const processLane = options.processLane ?? "worker";
356
- const mspDefs = [...options.registry.getAllMultiStreamProjections().values()].filter((msp) => runsInLane(msp.runIn, processLane));
357
- const mspConsumers = mspDefs.map((msp) => ({
358
- name: msp.name,
359
- // Feature-toggle gating: carry the owning feature so the event-dispatcher
360
- // can pause this consumer when the feature is globally disabled. Events
361
- // queue up in the store and replay cleanly from the same cursor on resume.
362
- ...(options.registry.getMultiStreamProjectionFeature(msp.name) && {
363
- featureName: options.registry.getMultiStreamProjectionFeature(msp.name), // @cast-boundary engine-bridge
364
- }),
365
- // Copy the continuous-lifecycle error policy straight onto the consumer.
366
- // Rebuild uses its own policy (rebuildProjection reads msp.errorMode.rebuild
367
- // directly); steady-state delivery runs through this consumer.
368
- ...(msp.errorMode?.continuous && { errorPolicy: msp.errorMode.continuous }),
369
- // Carry the MSP's declared delivery semantic through to the consumer.
370
- // Default (shared) is applied inside event-dispatcher, so omitting when
371
- // the MSP didn't declare one keeps the existing behaviour.
372
- ...(msp.delivery && { delivery: msp.delivery }),
373
- // Carry the MSP's declared cursor-seed through to the consumer. Default
374
- // (beginning) is applied inside insertConsumerIfAbsent.
375
- ...(msp.startFrom && { startFrom: msp.startFrom }),
376
- handler: async (event, ctx) => {
377
- const applyFn = msp.apply[event.type];
378
- // skip: this MSP doesn't care about this event type — fast path,
379
- // every event type passes through every MSP consumer exactly once.
380
- if (!applyFn)
381
- return;
382
- if (!baseDb) {
383
- // skip: no baseDb wired — allConsumers.length > 0 + baseDb check
384
- // above gates dispatcher creation, so we won't reach here in
385
- // production. Defensive return for the type-narrowing path.
386
- return;
387
- }
388
- // Saga/process-manager ctx: apply can call ctx.appendEvent to cascade
389
- // a follow-up event onto another aggregate. Uses the triggering event's
390
- // tenantId + userId so the causal chain stays tenant-consistent.
391
- // MSP qualified names are "<feature>:projection:<short>" — the
392
- // prefix before the first ":" owns the MSP. Used to reject
393
- // cross-feature ctx.appendEvent calls at emit-site.
394
- const mspOwner = msp.name.split(":")[0];
395
- const mspFiles = fileProviderResolver
396
- ? createTenantFileContext(fileProviderResolver, event.tenantId)
397
- : undefined;
398
- const mspDb = withExecutorManagedWrites(baseDb);
399
- const applyCtx = createMultiStreamApplyContext({
400
- registry: options.registry,
401
- db: mspDb,
402
- tenantId: event.tenantId,
403
- userId: event.metadata.userId,
404
- ...(mspOwner && { callerFeature: mspOwner }),
405
- ...(mspFiles && { files: mspFiles }),
406
- ...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
407
- ...(mspJobs && { jobs: mspJobs }),
408
- ...(mspFiles && {
409
- derivatives: createDerivativesContext({
410
- files: mspFiles,
411
- registry: options.registry,
412
- db: baseDb,
413
- tenantId: event.tenantId,
414
- }),
415
- }),
416
- });
417
- await applyFn(event, mspDb, applyCtx);
418
- // Keep ctx reachable to satisfy the EventConsumerHandler signature.
419
- void ctx;
420
- },
421
- }));
422
- const allConsumers = [...systemConsumers, ...mspConsumers];
423
- const { disabled: dispatcherDisabled, systemConsumers: _systemConsumersOpt, ...dispatcherTunables } = options.eventDispatcher ?? {};
424
- let eventDispatcher;
425
- if (allConsumers.length > 0 && baseDb && !dispatcherDisabled) {
426
- eventDispatcher = createEventDispatcher({
427
- db: baseDb,
428
- consumers: allConsumers,
429
- context: contextWithObservability,
430
- tracer: observability.tracer,
431
- meter: observability.meter,
432
- instanceId: resolvedInstanceId,
433
- ...dispatcherTunables,
434
- });
435
- }
436
- // Wire the event-dispatcher shutdown into the lifecycle so the caller
437
- // doesn't have to know the dispatcher exists. Hooks drain LIFO, so this
438
- // runs before anything registered later by the caller (e.g. DB pool close).
439
- if (options.lifecycle && eventDispatcher) {
440
- const dispatcher = eventDispatcher;
441
- options.lifecycle.registerShutdownHook("eventDispatcher", async () => {
442
- await dispatcher.stop();
443
- });
444
- }
445
- // Same reasoning, scoped to the broker this function created itself — an
446
- // app-injected sseBroker (options.sseBroker) owns its own teardown.
447
- if (options.lifecycle && ownedRedisSseBroker) {
448
- const broker = ownedRedisSseBroker;
449
- options.lifecycle.registerShutdownHook("redisSseBroker", async () => {
450
- await broker.close();
451
- });
452
- }
453
- if (options.lifecycle && ownedCacheSync) {
454
- const bus = ownedCacheSync;
455
- options.lifecycle.registerShutdownHook("cacheSync", async () => {
456
- await bus.close();
457
- });
458
- }
10
+ const wiring = resolveServerWiring(options);
459
11
  const app = new Hono();
460
12
  // Sub-apps mounted via app.route() without their own handler inherit this one.
461
13
  app.onError(handleUncaughtRouteError);
462
- // Resolved once; auth routes, auth/csrf middleware and the tenant-cookie
463
- // helpers read the cookie names from the request context.
464
- const sessionCookies = resolveSessionCookies({
465
- secureCookies: options.auth?.secureCookies,
466
- cookieDomain: options.auth?.cookieDomain,
467
- retiredCookieDomains: options.auth?.retiredCookieDomains,
468
- });
469
- app.use("*", sessionCookiesMiddleware(sessionCookies));
470
- // Only entry:"signature" bypasses jwtGuard (verify() authenticates
471
- // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
472
- // (tenant-by-host), entry:"user" needs c.get("user") populated.
473
- const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
474
- const isExtraRoutePublicPath = (c) => extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
475
- registerHealthRoutes(app, {
476
- lifecycle: options.lifecycle,
477
- readiness: {
478
- db: baseDb,
479
- redis: options.context.redis,
480
- consumers: allConsumers,
481
- ...(options.readiness ?? {}),
482
- },
483
- });
484
- if (options.metrics) {
485
- registerMetricsRoute(app, observability.meter, options.metrics);
486
- }
487
- else if (process.env["NODE_ENV"] === "production") {
488
- bootLog.warn("/metrics is off: no `metrics` option, so nothing is measured or scraped. " +
489
- "Set PROMETHEUS_METRICS_TOKEN (min 32 chars) and spread resolveObservabilityWiring(token) into the server options.");
490
- }
491
- app.use("/api/*", requestIdMiddleware({
492
- resolver: clientIpResolver,
493
- ...(options.errorDocs && { errorDocs: options.errorDocs }),
494
- }));
495
- // Cap JSON bodies before rate-limit/auth/observability even run. Header-
496
- // check is O(1); oversized requests never allocate memory for a full body
497
- // parse. Upload route keeps its own per-field maxSize.
14
+ // Hono runs middleware and routes in registration order, so the order below is behavior.
15
+ // On "*" and first: auth routes, auth/csrf guards and tenant-cookie helpers read the names from the context.
16
+ const sessionCookies = installSessionCookies(app, options.auth);
17
+ const isPublicApiRequest = compilePublicApiRequestMatcher(options.extraRoutes);
18
+ // /health and /metrics sit outside /api/*, so the /api/* guards below never run for them.
19
+ mountOperationalRoutes(app, wiring);
20
+ // First on /api/*: every later layer logs with the request id and client ip it sets.
21
+ installRequestId(app, wiring);
22
+ // O(1) header check before rate limit, auth and observability touch the body.
498
23
  registerBodyLimit(app, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES);
499
- // L1/L2 rate-limit middleware run BEFORE auth so an unauthenticated
500
- // flood can't even reach the JWT-verify code path. Wired only when
501
- // the caller passed `rateLimit.global` or `rateLimit.auth`. The
502
- // resolver is the auto-wired one (or `context.rateLimit` if set);
503
- // boot-fails loudly when the caller asked for middleware without a
504
- // working Redis to back it.
505
- let globalRateLimitGuard;
506
- if (wantsL1L2) {
507
- if (!rateLimitResolver) {
508
- throw new Error("rateLimit middleware requested but no resolver available — pass `context.redis` " +
509
- "or `context.rateLimit` so the resolver can be built.");
510
- }
511
- if (options.rateLimit?.global) {
512
- globalRateLimitGuard = globalIpRateLimit({
513
- ...options.rateLimit.global,
514
- resolver: rateLimitResolver,
515
- clientIpResolver,
516
- meter: observability.meter,
517
- });
518
- app.use("/api/*", globalRateLimitGuard);
519
- }
520
- if (options.rateLimit?.auth) {
521
- const { path: l2Path = "/api/auth/*", ...l2Opts } = options.rateLimit.auth;
522
- app.use(l2Path, authEndpointRateLimit({ ...l2Opts, resolver: rateLimitResolver, clientIpResolver }));
523
- }
524
- }
525
- // Observability span wraps everything that follows (auth, routes).
526
- // Must come AFTER request-id (so span can carry the id) and BEFORE auth
527
- // (so auth-verify can be a child span once we instrument it in v2).
528
- app.use("/api/*", observabilityMiddleware({
529
- tracer: observability.tracer,
530
- meter: observability.meter,
531
- }));
532
- // Tripwire: a kumiko-pii: ciphertext in a JSON response is always a bug
533
- // (raw read leaked past a decrypt) — dev fails loud, prod redacts.
534
- app.use("/api/*", piiCiphertextResponseGuard());
535
- // Auth middleware skips public paths (login, health) — those routes need
536
- // to be callable without a valid JWT. Every other /api/* request requires
537
- // a token (or, when anonymousAccess is wired, falls through as anonymous).
538
- // A session-checker is forwarded when the auth-config wires one, so the
539
- // middleware can reject revoked sids on every request.
540
- // Mounting a tenantLifecycleStatus provider is what turns the 410 on, so no
541
- // entrypoint can forget the wiring; `??` short-circuits, so an explicit
542
- // auth.resolveTenantLifecycleStatus remains the override.
543
- const tenantLifecycleResolver = options.auth?.resolveTenantLifecycleStatus ??
544
- deriveTenantLifecycleResolver(options.registry, baseDb);
545
- // sessionOnlyGuard deliberately does NOT get this — its callers have no
546
- // anonymousAccess fallback, so a foreign-origin cookie there must keep
547
- // failing through the sibling originMiddleware 403.
548
- const normalizedForeignCookieOrigins = options.auth?.allowedOrigins?.length
549
- ? new Set(options.auth.allowedOrigins.map(normalizeOrigin))
550
- : undefined;
551
- const tenantlessAnonymousQns = new Set([...options.registry.getAllWriteHandlers()]
552
- .filter(([, handler]) => handler.tenantlessAnonymous === true)
553
- .map(([qn]) => qn));
554
- const jwtGuard = authMiddleware(jwt, {
555
- ...(tenantlessAnonymousQns.size > 0
556
- ? { isTenantlessAnonymousWrite: (qn) => tenantlessAnonymousQns.has(qn) }
557
- : {}),
558
- ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
559
- ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
560
- ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
561
- ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
562
- ...(normalizedForeignCookieOrigins
563
- ? { foreignCookieOrigins: normalizedForeignCookieOrigins }
564
- : {}),
565
- });
566
- app.use("/api/*", async (c, next) => {
567
- if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
568
- return next();
569
- return jwtGuard(c, next);
570
- });
571
- // Fail-closed PAT scoping: a PAT-authenticated caller (user.pat set by
572
- // jwtGuard's tokenVerifier above) may only reach the dispatcher routes —
573
- // everywhere else on /api/* is off-limits regardless of granted scopes.
574
- const patRouteGuardMiddleware = patRouteGuard();
575
- app.use("/api/*", async (c, next) => {
576
- if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
577
- return next();
578
- return patRouteGuardMiddleware(c, next);
579
- });
580
- // Without anonymousAccess a missing token 401s instead of falling through as anonymous.
581
- const sessionOnlyGuard = authMiddleware(jwt, {
582
- ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
583
- ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
584
- ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
585
- });
586
- // PAT rate limiting — runs AFTER the auth guard so the resolved principal is
587
- // available. Only PAT-authenticated requests are counted (keyed by token id);
588
- // cookie/JWT users pass through untouched. In-memory limiter is per-instance
589
- // (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
590
- // in a Redis-backed LoginRateLimiter.
591
- const patRateLimiter = options.auth?.patRateLimiter;
592
- const patRateLimitGuard = patRateLimiter ? buildPatRateLimitGuard(patRateLimiter) : undefined;
593
- if (patRateLimitGuard) {
594
- app.use("/api/*", async (c, next) => {
595
- if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
596
- return next();
597
- return patRateLimitGuard(c, next);
598
- });
599
- }
600
- // Origin-allowlist guard — additional CSRF-hardening for deployments that
601
- // widen the auth cookie across subdomains (auth.cookieDomain). Registered
602
- // when allowedOrigins is non-empty, or with host-only cookies when
603
- // auth.publicOrigin is known (see resolveOriginGuard), with the same /api/* + public-skip
604
- // scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
605
- // surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
606
- // closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
607
- // without an allowlist and without an explicit opt-out — that config is the
608
- // unguarded-subdomain-XSS footgun, not a warn-and-continue case.
609
- assertOriginGuardConfig(options.auth);
610
- assertSessionCallbacksPaired(options.auth);
611
- const allowedOrigins = options.auth?.allowedOrigins;
612
- const originGuard = resolveOriginGuard(options.auth);
613
- if (originGuard) {
614
- app.use("/api/*", async (c, next) => {
615
- if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
616
- return next();
617
- return originGuard(c, next);
618
- });
619
- }
620
- // Double-submit CSRF guard — runs only on cookie-authenticated,
621
- // state-changing requests (POST/PUT/PATCH/DELETE). The guard reads the
622
- // authTransport flag set by authMiddleware, so public paths (no auth)
623
- // and bearer-authenticated paths (no cookie vector) fall straight
624
- // through. Must be registered AFTER the auth middleware above so the
625
- // flag is populated; registered for the same scope so /api/* routes
626
- // are covered uniformly.
627
- const csrfGuard = csrfMiddleware();
628
- app.use("/api/*", async (c, next) => {
629
- if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
630
- return next();
631
- return csrfGuard(c, next);
632
- });
633
- // No PAT rate-limit here: patRouteGuardMiddleware rejects every PAT on httpRoutes before it could count.
634
- // httpRoutes sit outside /api/*, so the pre-auth /api/* middlewares do not run for them: the
635
- // global IP limit (an unauthenticated flood must not reach JWT verify / session checks) and the
636
- // PII ciphertext tripwire are re-attached here. requestId, body limit and observability are not.
637
- const sessionOnlyHttpRouteGuards = [
638
- ...(globalRateLimitGuard ? [globalRateLimitGuard] : []),
639
- piiCiphertextResponseGuard(),
640
- sessionOnlyGuard,
641
- patRouteGuardMiddleware,
642
- ...(originGuard ? [originGuard] : []),
643
- csrfGuard,
644
- ];
645
- // Public auth routes (login) need to be registered BEFORE the generic
646
- // api routes so Hono matches them first.
647
- if (options.auth) {
648
- // A membershipQuery handler is registered but principalStatus is not provided by exactly
649
- // one feature — surface that misconfig at boot instead of an InternalError on first login/switch-tenant.
650
- if (options.registry.getQueryHandler(options.auth.membershipQuery)) {
651
- const principalStatusProviders = options.registry.getExtensionUsages(EXT_PRINCIPAL_STATUS).length;
652
- if (principalStatusProviders === 0) {
653
- throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but no feature ` +
654
- 'provides the "principalStatus" contract — mount the user feature (tenant switch and sign-in ' +
655
- "need it to reject blocked principals).");
656
- }
657
- if (principalStatusProviders > 1) {
658
- throw new Error(`[kumiko] auth membershipQuery "${options.auth.membershipQuery}" is registered but ` +
659
- `${principalStatusProviders} features provide the "principalStatus" contract — exactly one ` +
660
- "is expected (the bundled user feature already provides it).");
661
- }
662
- }
663
- app.route("/api", createAuthRoutes(dispatcher, jwt, {
664
- ...options.auth,
665
- secureCookies: sessionCookies.secure,
666
- resolver: clientIpResolver,
667
- }));
668
- }
669
- app.route("/api", createClientErrorRoute({
670
- resolver: rateLimitResolver ??
671
- (wrappedRedis ? createRateLimitResolver({ redis: wrappedRedis }) : undefined),
672
- clientIpResolver,
673
- meter: observability.meter,
674
- identifySession: (c) => identifyOptionalSession(c, jwt),
675
- }));
676
- app.route("/api", createApiRoutes(dispatcher, {
677
- ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
678
- }));
679
- app.route("/api", createSseRoute(sseBroker, {
680
- anonymousLiveEntities: collectAnonymousLiveEntities(options.registry),
681
- entityReadAccess: collectEntityReadAccess(options.registry),
682
- }));
683
- // GET /api/schema — the only way a client obtains the AppSchema. Behind
684
- // jwtGuard (NON_PUBLIC_API_PATHS), so anonymousAccess's synthesised user
685
- // also gets rejected below — the schema never reaches an unauthenticated
686
- // caller. The FULL schema is built lazily once — the registry is
687
- // immutable for this server's lifetime, so it's the same on every
688
- // request — then projected per caller-role-set (a role only
689
- // sees the screens/navs/workspaces/content-collections it may reach,
690
- // not the whole app's schema) and cached per role key. Tenant is
691
- // deliberately NOT part of the key: the projection depends only on
692
- // roles, and per-tenant caching would grow unboundedly across tenants
693
- // that share the same role set. What IS part of the key: the sorted set of
694
- // features the caller's tenant has switched off (r.toggleable) — few
695
- // distinct sets, so the cache stays small. The resolver is read per request
696
- // because run{Dev,Prod}App fill it in after the server is built.
697
- let fullSchema;
698
- const projectedSchemaCache = new Map();
699
- app.get(`/api${Routes.schema}`, async (c) => {
700
- const user = getUser(c);
701
- if (isMissingOrAnonymousUser(user)) {
702
- return unauthenticatedResponse(c);
703
- }
704
- const effectiveFeatures = options.dispatcherOptions?.effectiveFeatures;
705
- const disabledFeatureNames = effectiveFeatures === undefined
706
- ? []
707
- : await resolveDisabledFeatureNames(options.registry, effectiveFeatures, user.tenantId);
708
- const roleKey = JSON.stringify([[...new Set(user?.roles ?? [])].sort(), disabledFeatureNames]);
709
- let cached = projectedSchemaCache.get(roleKey);
710
- if (cached === undefined) {
711
- if (fullSchema === undefined) {
712
- fullSchema = buildAppSchema(options.registry, {
713
- searchAdapterMissing: !options.context.searchAdapter,
714
- });
715
- }
716
- const projected = projectAppSchemaForRoles(stripDisabledFeaturesFromAppSchema(fullSchema, options.registry, disabledFeatureNames), user?.roles ?? []);
717
- const json = JSON.stringify(projected);
718
- cached = { json, etag: computeStrongEtag(json) };
719
- projectedSchemaCache.set(roleKey, cached);
720
- }
721
- const cacheHeaders = { "Cache-Control": "private, no-cache", ETag: cached.etag };
722
- if (etagMatches(c.req.header("If-None-Match") ?? null, cached.etag)) {
723
- return c.body(null, 304, cacheHeaders);
724
- }
725
- return c.body(cached.json, 200, {
726
- "Content-Type": "application/json; charset=utf-8",
727
- ...cacheHeaders,
728
- });
729
- });
730
- // Mount upload/download routes whenever a file provider is resolvable (a
731
- // file-provider plugin is mounted, or a resolver was injected). They resolve
732
- // the provider per-tenant through file-foundation — no `files` option; route
733
- // policy comes from createFilesFeature(opts?). Gating on the resolver (not on
734
- // declared file fields) keeps unattached uploads working and matches the old
735
- // "files option present → routes" behavior.
736
- if (fileProviderResolver) {
737
- const fileDb = options.context.db; // @cast-boundary engine-bridge
738
- if (!fileDb)
739
- throw new Error("file routes require db in context");
740
- const routeOptions = readFilesRouteOptions(options.registry);
741
- const canReadRecord = createRecordReadCheck(options.registry, dispatcher.query);
742
- app.route("/api", createFileRoutes({
743
- db: fileDb,
744
- registry: options.registry,
745
- resolveProvider: fileProviderResolver,
746
- canReadRecord,
747
- canWriteRecord: createRecordWriteCheck({
748
- registry: options.registry,
749
- canReadRecord,
750
- tenantDbFor: (tenantId) => createProjectingTenantDb(fileDb, tenantId, options.registry),
751
- }),
752
- ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
753
- ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
754
- ...(routeOptions.unclaimedUploadReaders
755
- ? { unclaimedUploadReaders: routeOptions.unclaimedUploadReaders }
756
- : {}),
757
- ...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
758
- }));
759
- }
760
- // Feature-declared HTTP routes (r.httpRoute). Mount after /api/* so /api/* always
761
- // takes precedence; feature routes must be outside that prefix (the boot validator
762
- // enforces this). deps.app is the outer app, so the handler can call /api/query
763
- // internally through app.fetch(...) and use the same auth path as a real HTTP call.
764
- // deps.systemQuery deliberately bypasses that path for routes that must enforce a
765
- // FIXED tenant (for example, legal-pages always uses SYSTEM_TENANT_ID), rather than
766
- // spoofing it through the X-Tenant header. That header looks like a normal client
767
- // header to anonymousAccess resolution, so resolverTrust: "authoritative" configs
768
- // correctly reject it as a tenant override.
769
- for (const feature of options.registry.features.values()) {
770
- for (const route of Object.values(feature.httpRoutes)) {
771
- // @wrapper-known semantic-alias
772
- const honoHandler = async (c) => route.handler(c, {
773
- app,
774
- // createAnonymousUser, NOT createSystemUser: systemQuery's
775
- // synthesized user must clear the SAME access gate a real
776
- // anonymous visitor would, no more — regardless of the route's
777
- // own `anonymous` mode. The system role would ALSO satisfy that
778
- // gate here, but it can read fields gated to "system" that
779
- // "anonymous" can't (filterReadFields is a plain role-in-map
780
- // check) — a systemQuery caller reading a system-gated field
781
- // would leak it into the response. The forced tenant already
782
- // comes from bypassing the HTTP layer entirely; no elevated
783
- // role is needed or wanted on top of that.
784
- systemQuery: makeSystemQuery(c, dispatcher, clientIpResolver),
785
- clientIp: clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
786
- });
787
- // Without a resolver (no Redis in the context — dev/test stacks) the
788
- // declared limit is skipped rather than failing the boot of every app
789
- // that mounts a bundled feature whose routes carry a default limit.
790
- const routeLimit = route.rateLimit && rateLimitResolver
791
- ? [
792
- httpRouteRateLimit({
793
- resolver: rateLimitResolver,
794
- rateLimit: route.rateLimit,
795
- routeKey: `${route.method} ${route.path}`,
796
- clientIpResolver,
797
- meter: observability.meter,
798
- }),
799
- ]
800
- : [];
801
- mountHonoRoute(app, route.method, route.path, honoHandler, [
802
- ...routeLimit,
803
- ...(route.anonymous ? [] : sessionOnlyHttpRouteGuards),
804
- ]);
805
- }
806
- }
807
- // r.webSocketRoute — GET routes under /api/ws/*, so the jwt/pat/rate-limit
808
- // chain above already ran. Origin/CSRF guards skip GETs, hence the own check.
809
- const webSocketOriginAllowlist = allowedOrigins?.length
810
- ? new Set(allowedOrigins.map(normalizeOrigin))
811
- : undefined;
812
- const webSocketRoutePaths = new Map();
813
- const webSocketConnectionLimiter = createWebSocketConnectionLimiter();
814
- for (const feature of options.registry.features.values()) {
815
- for (const route of Object.values(feature.webSocketRoutes)) {
816
- const owner = webSocketRoutePaths.get(route.path);
817
- if (owner !== undefined) {
818
- throw new Error(`[kumiko] WebSocket route "${route.path}" is declared by both feature "${owner}" and ` +
819
- `"${feature.name}" — paths must be unique across features.`);
820
- }
821
- webSocketRoutePaths.set(route.path, feature.name);
822
- app.get(route.path, buildWebSocketRouteHandler(route, {
823
- dispatcher,
824
- clientIpResolver,
825
- originAllowlist: webSocketOriginAllowlist,
826
- ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
827
- ...(tenantLifecycleResolver
828
- ? { resolveTenantLifecycleStatus: tenantLifecycleResolver }
829
- : {}),
830
- connectionLimiter: webSocketConnectionLimiter,
831
- }));
832
- }
833
- }
834
- // extraRoutes — declarative HTTP-routes with a
835
- // required `entry` tier. Mounted after r.httpRoute for the same reason: an
836
- // extraRoute dispatching through `dispatcher` builds Hono's matcher, so
837
- // this must run before any seed that also dispatches (runProdApp/
838
- // createKumikoServer call buildServer before seeding).
839
- if (options.extraRoutes) {
840
- // Boot-time validation for the whole list BEFORE mounting anything —
841
- // an app with one bad route should fail loud at boot, not mount N-1
842
- // routes and then throw on route N.
843
- for (const route of options.extraRoutes) {
844
- if (!isKnownExtraRouteEntry(route.entry)) {
845
- throw new Error(`[kumiko] extraRoutes: unknown entry "${String(route.entry)}" on ` +
846
- `"${route.method} ${route.path}" — expected "anonymous" | "user" | "signature". ` +
847
- "A JS caller without the ExtraRouteDefinition type can hit this at boot.");
848
- }
849
- if (route.entry === ExtraRouteEntries.user && !route.path.startsWith("/api/")) {
850
- throw new Error(`[kumiko] extraRoutes: entry:"user" route "${route.method} ${route.path}" must be ` +
851
- "mounted under \"/api/\" — that's the only path prefix that rides the framework's " +
852
- "jwtGuard chain, which is what populates deps.user.");
853
- }
854
- // A signature route under /api/ skips jwtGuard/origin/csrf for every
855
- // path its pattern matches — a wildcard would switch off auth for
856
- // unrelated /api/* handlers (e.g. "/api/*" swallows /api/write).
857
- if (route.entry === ExtraRouteEntries.signature &&
858
- route.path.startsWith("/api/") &&
859
- route.path.includes("*")) {
860
- throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
861
- 'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.');
862
- }
863
- // A `:param` segment is as broad as a wildcard, and the public matchers test
864
- // the bare request path: "/:seg/write" and "/api/:hook" both match /api/write.
865
- if (route.entry === ExtraRouteEntries.signature) {
866
- const routePattern = honoPathToRegex(route.path);
867
- const shadowedFrameworkPath = [...PUBLIC_API_PATHS, ...NON_PUBLIC_API_PATHS].find((path) => routePattern.test(path));
868
- if (shadowedFrameworkPath !== undefined) {
869
- throw new Error(`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
870
- `match the framework path "${shadowedFrameworkPath}" — it would bypass the auth chain for it.`);
871
- }
872
- }
873
- }
874
- const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
875
- const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
876
- for (const route of options.extraRoutes) {
877
- const honoHandler = buildExtraRouteHonoHandler(route, {
878
- app,
879
- dispatcher,
880
- registry: options.registry,
881
- secrets: contextWithObservability.secrets,
882
- dispatchSystemWrite,
883
- dispatchSystemQuery,
884
- clientIpResolver,
885
- });
886
- mountHonoRoute(app, route.method, route.path, honoHandler, bodyLimitForRoutePath(route.path, options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES));
887
- }
888
- }
889
- // The default /version route is registered AFTER feature routes — Hono uses
890
- // "first match wins", so a feature-declared /version route (for example, an
891
- // app-specific version format with tenant statistics) takes precedence when
892
- // present; otherwise the default handler uses BUILD_VERSION/BUILD_TIME environment
893
- // variables.
24
+ // Before auth, so an unauthenticated flood never reaches JWT verify.
25
+ const globalRateLimitGuard = installRateLimitMiddleware(app, wiring);
26
+ // After the request id (the span carries it), before auth and routes (they run inside the span).
27
+ installObservability(app, wiring.observability);
28
+ // Before the auth chain, so its error responses pass the ciphertext tripwire too.
29
+ installPiiLeakGuard(app);
30
+ // jwt first: the PAT route scope, PAT rate limit and csrf guards read the principal it resolves.
31
+ const authGuards = installAuthGuardChain(app, wiring, {
32
+ isPublicApiRequest,
33
+ globalRateLimitGuard,
34
+ });
35
+ // Before the generic /api routes: Hono serves the first match.
36
+ mountAuthRoutes(app, wiring, sessionCookies);
37
+ mountClientErrorRoute(app, wiring);
38
+ mountDispatcherRoutes(app, wiring);
39
+ mountSchemaRoute(app, options);
40
+ mountFileRoutes(app, wiring);
41
+ // Feature routes after /api/*, so the framework paths always win.
42
+ mountFeatureHttpRoutes(app, wiring, authGuards.sessionOnlyHttpRouteGuards);
43
+ mountWebSocketRoutes(app, wiring, authGuards.tenantLifecycleResolver);
44
+ mountExtraRoutes(app, wiring);
45
+ // After feature routes, so a feature-declared /version wins.
894
46
  registerVersionRoute(app);
895
- // Marks "no route matched this path" so tryHonoFirst (server-runtime)
896
- // can tell it apart from a matched route's OWN deliberate 404 (e.g.
897
- // default-deny reads like file-derivatives' public-variant route) —
898
- // without this, tryHonoFirst's only signal was the status code, and a
899
- // matched route answering 404 got silently rewritten to the SPA shell
900
- // with status 200. Must be the LAST thing wired
901
- // on `app`: this only fires when the router found no handler at all, so
902
- // a route's own `c.text(..., 404)` never passes through here. A handler
903
- // that calls `c.notFound()` instead of building its own Response DOES
904
- // route through this same handler and stays indistinguishable from a
905
- // routing miss — pre-existing Hono semantics, not something this fix
906
- // changes; deliberate 404s must build their own Response.
907
- // The header is an internal signal only — tryHonoFirst and every direct
908
- // app.fetch() passthrough strip it before a response reaches a client.
909
- app.notFound((c) => c.text("Not Found", 404, { [NO_ROUTE_MATCH_HEADER_NAME]: "1" }));
47
+ // Last: it may only fire when no route matched at all.
48
+ installNoRouteMatchMarker(app);
910
49
  return {
911
50
  app,
912
- jwt,
913
- sseBroker,
914
- observability,
915
- dispatcher,
916
- context: contextWithObservability,
917
- ...(eventDispatcher ? { eventDispatcher } : {}),
51
+ jwt: wiring.jwt,
52
+ sseBroker: wiring.sseBroker,
53
+ observability: wiring.observability,
54
+ dispatcher: wiring.dispatcher,
55
+ lifecycleHooks: wiring.lifecycleHooks,
56
+ context: wiring.context,
57
+ ...(wiring.eventDispatcher ? { eventDispatcher: wiring.eventDispatcher } : {}),
918
58
  ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
919
59
  };
920
60
  }
921
- // Method-switch shared by r.httpRoute and extraRoutes — one place to keep
922
- // the two mounting paths from drifting on which Hono methods get a
923
- // convenience-call vs. app.on().
924
- function mountHonoRoute(app, method, path,
925
- // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
926
- handler, middlewares = []) {
927
- // Guards go into the route's own handler chain, never a method-gated
928
- // app.use: Hono serves HEAD through the GET route with c.req.method still
929
- // "HEAD", so a `method === c.req.method` gate would skip them for HEAD.
930
- if (middlewares.length > 0) {
931
- // [path]: only Hono's array-path overload accepts a variable-length handler spread.
932
- app.on(method, [path], ...middlewares, handler);
933
- // skip: guarded route already mounted with its guard chain
934
- return;
935
- }
936
- switch (method) {
937
- case "GET":
938
- app.get(path, handler);
939
- break;
940
- case "POST":
941
- app.post(path, handler);
942
- break;
943
- case "PUT":
944
- app.put(path, handler);
945
- break;
946
- case "PATCH":
947
- app.patch(path, handler);
948
- break;
949
- case "DELETE":
950
- app.delete(path, handler);
951
- break;
952
- case "OPTIONS":
953
- case "HEAD":
954
- // Hono's on() for the methods without a convenience method.
955
- app.on(method, path, handler);
956
- break;
957
- default:
958
- assertUnreachable(method, "http method");
959
- }
960
- }
961
- // Must run after the auth guard so getUser(c) carries the PAT.
962
- function buildPatRateLimitGuard(patRateLimiter) {
963
- return async (c, next) => {
964
- const pat = getUser(c)?.pat;
965
- if (pat && !(await patRateLimiter.check(pat.tokenId))) {
966
- return c.json({
967
- error: {
968
- code: "pat_rate_limited",
969
- httpStatus: 429,
970
- message: "personal access token rate limit exceeded",
971
- i18nKey: "auth.errors.patRateLimited",
972
- },
973
- }, 429);
974
- }
975
- return next();
976
- };
977
- }
978
- // Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
979
- // signature entries). requestContext.run must wrap the dispatcher call —
980
- // both route kinds run outside the requestIdMiddleware chain that normally
981
- // populates it, and `rateLimit: {per: "ip", ...}` on a handler invoked
982
- // through systemQuery is otherwise silent dead-code (enforceRateLimit reads
983
- // requestContext.get()?.ip, undefined without this wrap).
984
- function makeSystemQuery(
985
- // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
986
- c, dispatcher, clientIpResolver) {
987
- return (type, payload, tenantId) => requestContext.run(requestContext.get() ?? buildRequestContextData(c, clientIpResolver), () => dispatcher.query(type, payload, createAnonymousUser(tenantId)));
988
- }
989
- // SystemAdmin write/query builders shared by buildServer's `extraRoutes`
990
- // mount and server-runtime's `wire` hook (runProdApp/createKumikoServer,
991
- // after buildServer). Privilege-scope: SystemAdmin is the highest
992
- // non-tenant-scoped role — reaches ANY SystemAdmin-gated handler on ANY
993
- // tenant. Only safe for callers that already proved their own authenticity
994
- // (signature verify(), HMAC state, ...), never exposed to a raw request.
995
- export function makeDispatchSystemWrite(dispatcher) {
996
- return ({ handlerQn, payload, tenantId }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
997
- }
998
- // Privilege-scope: the system user gets exactly the roles the caller names —
999
- // no implicit SystemAdmin — so a seed can write data gated on app roles.
1000
- // Same trust requirement as makeDispatchSystemWrite: never expose to a raw
1001
- // request, only to code that already proved its own authenticity.
1002
- export function makeDispatchWriteAs(dispatcher) {
1003
- return ({ handlerQn, payload, tenantId, roles }) => dispatcher.write(handlerQn, payload, createSystemUser(tenantId, roles));
1004
- }
1005
- export function makeDispatchSystemQuery(dispatcher) {
1006
- return ({ handlerQn, payload, tenantId }) => dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
1007
- }
1008
- // Same dispatcher.write(...) as entry:"user", but the user is getUser(c) —
1009
- // see AnonymousExtraRouteDeps.write for the privilege/tenant contract.
1010
- function makeAnonymousWrite(
1011
- // biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
1012
- c, dispatcher) {
1013
- return (type, payload) => {
1014
- const user = getUser(c);
1015
- if (!user) {
1016
- throw new Error('[kumiko] extraRoutes: entry:"anonymous" deps.write requires this route to be mounted ' +
1017
- 'under "/api/" with anonymousAccess wired — no request-resolved session user was found.');
1018
- }
1019
- return dispatcher.write(type, payload, user);
1020
- };
1021
- }
1022
- // Shared by entry:"user" extra routes and GET /api/schema — both require a
1023
- // real, signed-in principal and reject the synthesised anonymousAccess user
1024
- // (SessionUser.roles includes ANONYMOUS_ROLE) the same way.
1025
- function isMissingOrAnonymousUser(user) {
1026
- return !user || isAnonymousSessionUser(user);
1027
- }
1028
- function unauthenticatedResponse(c) {
1029
- return c.json({
1030
- error: {
1031
- code: "unauthenticated",
1032
- httpStatus: 401,
1033
- message: "this route requires a signed-in user",
1034
- i18nKey: "auth.errors.missingToken",
1035
- },
1036
- }, 401);
1037
- }
1038
- const webSocketLog = createFallbackLogger("websocket");
1039
- const extraRouteLog = createFallbackLogger("extra-route");
1040
- function webSocketErrorResponse(c, status, code, message) {
1041
- return c.json({ error: { code, httpStatus: status, message } }, status);
1042
- }
1043
- function buildWebSocketRouteHandler(route, shared) {
1044
- return async (c) => {
1045
- if (!isWebSocketOriginAllowed(c, shared.originAllowlist))
1046
- return rejectOrigin(c);
1047
- const user = getUser(c);
1048
- if (isMissingOrAnonymousUser(user))
1049
- return unauthenticatedResponse(c);
1050
- if (!isWebSocketUpgradeRequest(c.req.raw)) {
1051
- return webSocketErrorResponse(c, 426, "websocket_upgrade_required", "this route only accepts WebSocket upgrade requests");
1052
- }
1053
- const honoEnv = c.env;
1054
- if (!hasWebSocketUpgradeServer(honoEnv)) {
1055
- const message = "webSocketUpgradeFetch not wired: pass it to buildBunServeOptions";
1056
- webSocketLog.error(message, { path: route.path });
1057
- return webSocketErrorResponse(c, 501, "websocket_upgrade_not_wired", message);
1058
- }
1059
- // The slot is taken before connect (a refused attempt must not run it) and stays synchronous with
1060
- // the check, so concurrent upgrades can't overshoot the cap. Every path without a live socket frees it.
1061
- const releaseConnectionSlot = shared.connectionLimiter.tryAcquire(`${route.path}\n${user.tenantId}\n${user.id}`, route.maxConnectionsPerUser ?? WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER);
1062
- if (!releaseConnectionSlot) {
1063
- return webSocketErrorResponse(c, 429, "websocket_connection_limit", "too many open WebSocket connections for this user");
1064
- }
1065
- let upgraded = false;
1066
- try {
1067
- const handlers = await route.connect(c, {
1068
- user,
1069
- query: (type, payload) => shared.dispatcher.query(type, payload, user),
1070
- write: (type, payload) => shared.dispatcher.write(type, payload, user),
1071
- clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1072
- });
1073
- if (handlers instanceof Response)
1074
- return handlers;
1075
- const captured = requestContext.get();
1076
- const requestContextData = captured ? withoutAbortSignal(captured) : undefined;
1077
- const revalidateSession = buildWebSocketSessionRevalidator({
1078
- user,
1079
- sessionChecker: shared.sessionChecker,
1080
- resolveTenantLifecycleStatus: shared.resolveTenantLifecycleStatus,
1081
- tokenExpiresAtSec: getAuthTokenExpiry(c),
1082
- });
1083
- upgraded = honoEnv.server.upgrade(c.req.raw, {
1084
- data: {
1085
- handlers,
1086
- maxMessageBytes: route.maxMessageBytes ?? WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES,
1087
- requestContextData,
1088
- revalidateSession,
1089
- releaseConnectionSlot,
1090
- },
1091
- });
1092
- if (!upgraded) {
1093
- return webSocketErrorResponse(c, 400, "websocket_upgrade_failed", "the WebSocket upgrade was rejected");
1094
- }
1095
- // Bun ignores the returned Response after a successful upgrade.
1096
- return new Response(null);
1097
- }
1098
- finally {
1099
- if (!upgraded)
1100
- releaseConnectionSlot();
1101
- }
1102
- };
1103
- }
1104
- // The request's AbortSignal fires when the upgrade request ends, which would
1105
- // abort every later handler running under the captured context.
1106
- function withoutAbortSignal(data) {
1107
- const { signal: _signal, ...rest } = data;
1108
- return rest;
1109
- }
1110
- function isKnownExtraRouteEntry(entry) {
1111
- return (entry === ExtraRouteEntries.anonymous ||
1112
- entry === ExtraRouteEntries.user ||
1113
- entry === ExtraRouteEntries.signature);
1114
- }
1115
- // Hono path pattern ("/api/foo/:bar", "/api/foo/*") → RegExp. Only the
1116
- // subset extraRoutes actually uses (static segments, `:param`, trailing
1117
- // `*`) — no inline `:param{regex}` constraints, no optional `:param?`.
1118
- function honoPathToRegex(path) {
1119
- const segments = path
1120
- .split("/")
1121
- .map((segment) => {
1122
- if (segment.startsWith(":"))
1123
- return "[^/]+";
1124
- if (segment === "*")
1125
- return ".*";
1126
- return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1127
- })
1128
- .join("/");
1129
- return new RegExp(`^${segments}$`);
1130
- }
1131
- // See the bypass comment at the jwtGuard mount above — only signature
1132
- // routes are public here.
1133
- function compileExtraRoutePublicMatchers(extraRoutes) {
1134
- if (!extraRoutes)
1135
- return [];
1136
- return extraRoutes
1137
- .filter((route) => route.entry === ExtraRouteEntries.signature)
1138
- .map((route) => ({ method: route.method, pattern: honoPathToRegex(route.path) }));
1139
- }
1140
- function buildExtraRouteHonoHandler(route, shared) {
1141
- switch (route.entry) {
1142
- case ExtraRouteEntries.anonymous:
1143
- return async (c) => route.handler(c, {
1144
- app: shared.app,
1145
- registry: shared.registry,
1146
- systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1147
- write: makeAnonymousWrite(c, shared.dispatcher),
1148
- clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1149
- });
1150
- case ExtraRouteEntries.user:
1151
- return async (c) => {
1152
- const user = getUser(c);
1153
- if (isMissingOrAnonymousUser(user)) {
1154
- return unauthenticatedResponse(c);
1155
- }
1156
- return route.handler(c, {
1157
- app: shared.app,
1158
- registry: shared.registry,
1159
- user,
1160
- query: (type, payload) => shared.dispatcher.query(type, payload, user),
1161
- write: (type, payload) => shared.dispatcher.write(type, payload, user),
1162
- });
1163
- };
1164
- case ExtraRouteEntries.signature:
1165
- return async (c) => {
1166
- const rawBody = await c.req.text();
1167
- const headers = {};
1168
- c.req.raw.headers.forEach((value, key) => {
1169
- headers[key.toLowerCase()] = value;
1170
- });
1171
- let verified;
1172
- try {
1173
- verified = await route.verify({ rawBody, headers, params: c.req.param(), query: c.req.query() }, { registry: shared.registry, secrets: shared.secrets });
1174
- }
1175
- catch (e) {
1176
- if (e instanceof ExtraRouteRejection) {
1177
- if (e.retryAfterSeconds !== undefined) {
1178
- return c.json(e.body, e.status, { "Retry-After": String(e.retryAfterSeconds) });
1179
- }
1180
- return c.json(e.body, e.status);
1181
- }
1182
- // The body goes to unauthenticated callers; infra errors thrown by
1183
- // verify() (secrets, DB) must not leak, so the detail stays in the log.
1184
- extraRouteLog.error(`signature verify() threw on ${c.req.path}`, {
1185
- error: e instanceof Error ? e.message : String(e),
1186
- });
1187
- return c.json({
1188
- error: {
1189
- code: "extra_route_signature_invalid",
1190
- message: "signature verification failed",
1191
- },
1192
- }, 401);
1193
- }
1194
- return route.handler(c, verified, {
1195
- app: shared.app,
1196
- registry: shared.registry,
1197
- secrets: shared.secrets,
1198
- systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1199
- dispatchSystemWrite: shared.dispatchSystemWrite,
1200
- dispatchSystemQuery: shared.dispatchSystemQuery,
1201
- });
1202
- };
1203
- default:
1204
- return assertUnreachable(route, "extra route entry");
1205
- }
1206
- }
1207
- function deriveTenantLifecycleResolver(registry, db) {
1208
- const plugin = resolveTenantLifecyclePlugin(registry, "buildServer");
1209
- if (!plugin)
1210
- return undefined;
1211
- if (!db) {
1212
- throw new Error("[kumiko] a tenantLifecycleStatus provider is mounted (tenant-lifecycle) but context.db is " +
1213
- "missing — the request-level teardown gate needs a DbConnection. Pass context.db, or wire " +
1214
- "auth.resolveTenantLifecycleStatus explicitly.");
1215
- }
1216
- return (tenantId) => plugin.resolveStatus(tenantId, { db });
1217
- }
1218
- // Scans every feature's entities for a file/image/files/images field. Short-
1219
- // circuits on the first hit — no need to build a full inventory, we only want
1220
- // the yes/no answer for the boot check.
1221
- function registryDeclaresFileFields(registry) {
1222
- for (const feature of registry.features.values()) {
1223
- for (const entity of Object.values(feature.entities ?? {})) {
1224
- for (const field of Object.values(entity.fields)) {
1225
- if (isFileField(field))
1226
- return true;
1227
- }
1228
- }
1229
- }
1230
- return false;
1231
- }
1232
- // Entities whose entityList screen WOULD render a search box based on
1233
- // screen/field config alone: `screen.searchable` wins when set, otherwise
1234
- // the box shows iff the entity has ≥1 searchable field. `screen.searchable
1235
- // === false` is excluded — the boot-validator (entity-list-screens.ts) only
1236
- // allows that on the whitelisted download-attempt-list-style screens, which
1237
- // never render the box. This is the config-only half of the rule; the
1238
- // client additionally gates on FeatureSchema.searchAdapterMissing
1239
- // (set from the same `!options.context.searchAdapter` check below) so the
1240
- // box is actually suppressed once this function finds a hit.
1241
- function entitiesWithSearchableScreen(registry) {
1242
- const entities = new Set();
1243
- for (const feature of registry.features.values()) {
1244
- for (const screen of Object.values(feature.screens)) {
1245
- if (screen.type !== "entityList")
1246
- continue;
1247
- const isSearchable = screen.searchable === true ||
1248
- (screen.searchable === undefined && registry.getSearchableFields(screen.entity).length > 0);
1249
- if (isSearchable)
1250
- entities.add(screen.entity);
1251
- }
1252
- }
1253
- return [...entities];
1254
- }
1255
- function payloadDigestMasterSecret(jwtSecret) {
1256
- if (typeof jwtSecret === "string")
1257
- return jwtSecret;
1258
- const signingSecret = jwtSecret.keys[jwtSecret.signKid];
1259
- if (signingSecret === undefined) {
1260
- throw new Error(`[kumiko] jwtSecret.signKid "${jwtSecret.signKid}" not found in jwtSecret.keys`);
1261
- }
1262
- return signingSecret;
1263
- }