@cosmicdrift/kumiko-framework 0.359.0 → 0.361.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 (104) hide show
  1. package/dist/api/anonymous-cookie.js +9 -12
  2. package/dist/api/api-constants.d.ts +0 -1
  3. package/dist/api/api-constants.js +0 -1
  4. package/dist/api/auth-middleware.d.ts +0 -2
  5. package/dist/api/auth-middleware.js +16 -9
  6. package/dist/api/auth-routes.d.ts +2 -0
  7. package/dist/api/auth-routes.js +28 -40
  8. package/dist/api/csrf-middleware.js +3 -2
  9. package/dist/api/index.d.ts +4 -1
  10. package/dist/api/index.js +3 -1
  11. package/dist/api/origin-middleware.d.ts +11 -1
  12. package/dist/api/origin-middleware.js +40 -7
  13. package/dist/api/server.js +30 -4
  14. package/dist/api/session-cookies.d.ts +23 -0
  15. package/dist/api/session-cookies.js +85 -0
  16. package/dist/crypto/index.d.ts +1 -1
  17. package/dist/crypto/index.js +1 -1
  18. package/dist/crypto/owner-transition-rekey.d.ts +2 -0
  19. package/dist/crypto/owner-transition-rekey.js +30 -0
  20. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  21. package/dist/crypto/pii-field-encryption.js +5 -0
  22. package/dist/crypto/subject-resolver.d.ts +2 -0
  23. package/dist/crypto/subject-resolver.js +14 -1
  24. package/dist/db/apply-entity-event.js +0 -5
  25. package/dist/db/eagerload.js +2 -2
  26. package/dist/db/entity-field-encryption.js +3 -2
  27. package/dist/db/entity-table-meta.js +3 -3
  28. package/dist/db/event-store-executor-context.d.ts +1 -0
  29. package/dist/db/event-store-executor-context.js +9 -2
  30. package/dist/db/event-store-executor-write.js +31 -5
  31. package/dist/db/event-store-executor.d.ts +2 -1
  32. package/dist/db/event-store-executor.js +1 -0
  33. package/dist/db/index.d.ts +3 -3
  34. package/dist/db/index.js +2 -2
  35. package/dist/db/migrate-generator.d.ts +1 -0
  36. package/dist/db/migrate-generator.js +12 -0
  37. package/dist/db/queries/backfill-pii.d.ts +2 -0
  38. package/dist/db/queries/backfill-pii.js +120 -15
  39. package/dist/db/table-builder.d.ts +3 -1
  40. package/dist/db/table-builder.js +3 -6
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +38 -1
  43. package/dist/engine/boot-validator/index.js +2 -1
  44. package/dist/engine/boot-validator/query-output-columns.js +3 -0
  45. package/dist/engine/boot-validator/screens.d.ts +1 -1
  46. package/dist/engine/boot-validator/screens.js +89 -14
  47. package/dist/engine/build-app-schema.js +1 -1
  48. package/dist/engine/define-handler.js +1 -3
  49. package/dist/engine/factories.d.ts +2 -0
  50. package/dist/engine/feature-config-events-jobs.js +8 -0
  51. package/dist/engine/index.d.ts +1 -1
  52. package/dist/engine/schema-builder.js +2 -2
  53. package/dist/engine/screen-helpers.d.ts +1 -0
  54. package/dist/engine/screen-helpers.js +9 -0
  55. package/dist/engine/soft-delete-cleanup.js +12 -1
  56. package/dist/engine/types/index.d.ts +1 -1
  57. package/dist/errors/classes.d.ts +7 -0
  58. package/dist/errors/classes.js +12 -0
  59. package/dist/errors/index.d.ts +1 -1
  60. package/dist/errors/index.js +1 -1
  61. package/dist/errors/reasons.d.ts +1 -0
  62. package/dist/errors/reasons.js +3 -0
  63. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +8 -0
  64. package/dist/files/assert-file-refs-belong-to-tenant.js +50 -0
  65. package/dist/files/bind-pending-file-refs.d.ts +9 -0
  66. package/dist/files/bind-pending-file-refs.js +54 -0
  67. package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
  68. package/dist/files/create-file-ref-from-bytes.js +39 -0
  69. package/dist/files/create-file-ref.d.ts +50 -0
  70. package/dist/files/create-file-ref.js +135 -0
  71. package/dist/files/dispose-removed-file-refs.d.ts +8 -0
  72. package/dist/files/dispose-removed-file-refs.js +51 -0
  73. package/dist/files/feature.js +2 -2
  74. package/dist/files/file-ref-entity.d.ts +1 -0
  75. package/dist/files/file-ref-entity.js +1 -0
  76. package/dist/files/file-ref-table.js +2 -2
  77. package/dist/files/file-routes.d.ts +4 -0
  78. package/dist/files/file-routes.js +60 -126
  79. package/dist/files/index.d.ts +2 -0
  80. package/dist/files/index.js +1 -0
  81. package/dist/files/purge-expired-file-ref-bytes.d.ts +14 -0
  82. package/dist/files/purge-expired-file-ref-bytes.js +34 -0
  83. package/dist/files/record-read-check.d.ts +9 -0
  84. package/dist/files/record-read-check.js +49 -0
  85. package/dist/files/record-write-check.d.ts +21 -0
  86. package/dist/files/record-write-check.js +64 -0
  87. package/dist/files/types.d.ts +1 -0
  88. package/dist/files/types.js +3 -0
  89. package/dist/i18n/required-surface-keys.js +10 -0
  90. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  91. package/dist/jobs/cron-repeat-options.js +5 -0
  92. package/dist/jobs/job-runner.js +2 -1
  93. package/dist/migrations/pii-event-backfill.d.ts +1 -0
  94. package/dist/migrations/pii-event-backfill.js +16 -2
  95. package/dist/pipeline/cascade-handler.js +17 -2
  96. package/dist/pipeline/dispatch-write.js +7 -0
  97. package/dist/schema-cli.js +12 -6
  98. package/dist/search/reindex-entity.js +3 -4
  99. package/dist/stack/test-stack.d.ts +3 -1
  100. package/dist/stack/test-stack.js +1 -0
  101. package/dist/ui-types/index.d.ts +2 -2
  102. package/dist/ui-types/index.js +1 -1
  103. package/package.json +4 -4
  104. package/src/changes.json +135 -0
@@ -8,24 +8,18 @@
8
8
  // middleware on the next request — no client-side JS needs to read it,
9
9
  // and not exposing it to JS keeps it out of XSS reach.
10
10
  import { deleteCookie, setCookie } from "hono/cookie";
11
- import { TENANT_COOKIE_NAME } from "./api-constants.js";
11
+ import { getSessionCookies, hostOnlyDeleteOptions, isHostPrefixedCookieName, TENANT_COOKIE_BASE, } from "./session-cookies.js";
12
12
  // 30 days. The tenant assignment is stable for the lifetime of the
13
13
  // visitor's relationship with the deployment — re-running the resolver
14
14
  // on every visit would just hit the DB for no new information.
15
15
  const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
16
- // Cookies marked Secure are dropped by browsers on http:// — locked off in
17
- // dev/test so localhost works without TLS, on in prod where http would be
18
- // a deployment bug we want to fail loud about (no cookie ⇒ resolver runs
19
- // every request and the misconfiguration is visible in latency dashboards).
20
- function cookieSecure() {
21
- return process.env["NODE_ENV"] === "production";
22
- }
23
16
  // Stamps the kumiko_tenant cookie on the response. Idempotent — calling it
24
17
  // twice in the same request just overwrites with the latest value.
25
18
  export function setTenantCookie(c, tenantId, options = {}) {
26
- setCookie(c, TENANT_COOKIE_NAME, tenantId, {
19
+ const { secure, names } = getSessionCookies(c);
20
+ setCookie(c, names.tenant, tenantId, {
27
21
  httpOnly: true,
28
- secure: cookieSecure(),
22
+ secure,
29
23
  sameSite: options.sameSite ?? "Lax",
30
24
  path: "/",
31
25
  maxAge: options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS,
@@ -34,7 +28,10 @@ export function setTenantCookie(c, tenantId, options = {}) {
34
28
  // Removes the kumiko_tenant cookie. Use on switch-tenant flows or when
35
29
  // the resolver no longer recognises the visitor's tenant — leaving a
36
30
  // stale cookie behind would keep them pointed at a deleted tenant.
37
- // @wrapper-known semantic-alias
38
31
  export function deleteTenantCookie(c) {
39
- deleteCookie(c, TENANT_COOKIE_NAME, { path: "/" });
32
+ const { names } = getSessionCookies(c);
33
+ deleteCookie(c, names.tenant, hostOnlyDeleteOptions(names.tenant));
34
+ if (isHostPrefixedCookieName(names.tenant)) {
35
+ deleteCookie(c, TENANT_COOKIE_BASE, { path: "/" });
36
+ }
40
37
  }
@@ -42,7 +42,6 @@ export declare const AUTH_RATE_LIMIT_EXEMPT_ROUTES: ReadonlyArray<{
42
42
  export declare function isAuthRateLimitExempt(method: string, path: string): boolean;
43
43
  export declare const STATE_CHANGING_METHODS: ReadonlySet<string>;
44
44
  export declare const TENANT_HEADER_NAME = "X-Tenant";
45
- export declare const TENANT_COOKIE_NAME = "kumiko_tenant";
46
45
  export declare const LOCALE_HEADER_NAME = "X-Locale";
47
46
  export declare const NO_ROUTE_MATCH_HEADER_NAME = "X-Kumiko-No-Route-Match";
48
47
  export type Route = (typeof Routes)[keyof typeof Routes];
@@ -127,7 +127,6 @@ export const STATE_CHANGING_METHODS = new Set([
127
127
  // access. The middleware reads header first, then cookie, then falls back to
128
128
  // `anonymousAccess.tenantResolver` and finally `anonymousAccess.defaultTenantId`.
129
129
  export const TENANT_HEADER_NAME = "X-Tenant";
130
- export const TENANT_COOKIE_NAME = "kumiko_tenant";
131
130
  // Client-declared active UI locale (BCP-47). Read once per request in
132
131
  // request-id-middleware.ts, before auth, so it reaches public routes
133
132
  // (e.g. signup-request) too. Falls back to Accept-Language, then the app's
@@ -1,8 +1,6 @@
1
1
  import type { Context, Next } from "hono";
2
2
  import type { SessionUser, TenantId } from "../engine/types/index.js";
3
3
  import type { JwtHelper } from "./jwt.js";
4
- export declare const AUTH_COOKIE_NAME = "kumiko_auth";
5
- export declare const CSRF_COOKIE_NAME = "kumiko_csrf";
6
4
  export declare const CSRF_HEADER_NAME = "X-CSRF-Token";
7
5
  export declare const PAT_TOKEN_PREFIX = "kpat_";
8
6
  export type AuthTransport = "cookie" | "bearer";
@@ -2,16 +2,12 @@ import { getCookie } from "hono/cookie";
2
2
  import { TENANT_TEARDOWN_STATUSES } from "../engine/active-membership.js";
3
3
  import { createAnonymousUser } from "../engine/system-user.js";
4
4
  import { parseTenantId, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
5
- import { isSessionRequiredApiPath, TENANT_COOKIE_NAME, TENANT_HEADER_NAME, } from "./api-constants.js";
5
+ import { isSessionRequiredApiPath, TENANT_HEADER_NAME } from "./api-constants.js";
6
6
  import { isForeignCookieOrigin } from "./origin-middleware.js";
7
+ import { clearSessionCookies, getSessionCookies, hasDuplicateSessionCookie, } from "./session-cookies.js";
7
8
  const USER_KEY = "pipelineUser";
8
9
  const AUTH_TRANSPORT_KEY = "authTransport";
9
10
  const AUTH_TOKEN_EXPIRY_KEY = "authTokenExpiry";
10
- // Names used across middleware and auth-routes. Kept here so csrf-middleware
11
- // and auth-routes import them from a single source of truth — renaming a
12
- // cookie is a coordinated change across issuer, reader and deleter.
13
- export const AUTH_COOKIE_NAME = "kumiko_auth";
14
- export const CSRF_COOKIE_NAME = "kumiko_csrf";
15
11
  export const CSRF_HEADER_NAME = "X-CSRF-Token";
16
12
  // Prefix that marks a bearer token as a long-lived Personal Access Token
17
13
  // rather than a JWT session. The personal-access-tokens feature mints tokens
@@ -57,7 +53,7 @@ const INVALID_TOKEN_REJECTION = {
57
53
  function isStaleCookieDowngradable(c, transport, anonymousAccess) {
58
54
  return (transport === "cookie" && anonymousAccess !== undefined && !c.req.path.startsWith("/api/auth/"));
59
55
  }
60
- // Extract the JWT from either the kumiko_auth cookie (web) or the
56
+ // Extract the JWT from either the auth cookie (web) or the
61
57
  // Authorization Bearer header (native / server-to-server). The two paths
62
58
  // are mutually exclusive: if both are present the request is rejected with
63
59
  // `ambiguous_auth` to prevent a confused-deputy bug where a server-bug
@@ -65,8 +61,19 @@ function isStaleCookieDowngradable(c, transport, anonymousAccess) {
65
61
  // Note: this is NOT a CSRF control — Bearer-only clients are already safe
66
62
  // because browsers can't set Authorization headers cross-origin. The reject
67
63
  // exists so future middleware authors can't accidentally mix transports.
64
+ //
65
+ // Duplicate auth/csrf cookie names make the cookie transport untrustworthy:
66
+ // the request is treated as carrying no cookie and every variant is expired,
67
+ // so the browser recovers with a fresh login instead of staying stuck.
68
68
  function extractToken(c) {
69
- const cookieToken = getCookie(c, AUTH_COOKIE_NAME);
69
+ const sessionCookies = getSessionCookies(c);
70
+ let cookieToken;
71
+ if (hasDuplicateSessionCookie(c, sessionCookies.names)) {
72
+ clearSessionCookies(c, sessionCookies);
73
+ }
74
+ else {
75
+ cookieToken = getCookie(c, sessionCookies.names.auth);
76
+ }
70
77
  const header = c.req.header("Authorization");
71
78
  const bearerToken = header?.startsWith("Bearer ") ? header.slice(7) : undefined;
72
79
  if (cookieToken && bearerToken)
@@ -281,7 +288,7 @@ async function handleAnonymous(c, config, next, resolveTenantLifecycleStatus, ex
281
288
  // Step 1+2: parse client-supplied tenant. Reject malformed values before
282
289
  // they touch any downstream consumer (DB, cache, audit row).
283
290
  const headerRaw = c.req.header(TENANT_HEADER_NAME);
284
- const cookieRaw = getCookie(c, TENANT_COOKIE_NAME);
291
+ const cookieRaw = getCookie(c, getSessionCookies(c).names.tenant);
285
292
  const headerCheck = parseClientTenant(headerRaw, "X-Tenant header");
286
293
  if (headerCheck.error)
287
294
  return rejectTenant(headerCheck.error);
@@ -50,8 +50,10 @@ export type AuthRoutesConfig = {
50
50
  invite?: InviteConfig;
51
51
  cookieSameSite?: "lax" | "strict";
52
52
  cookieDomain?: string;
53
+ secureCookies?: boolean;
53
54
  retiredCookieDomains?: readonly string[];
54
55
  allowedOrigins?: readonly string[];
56
+ publicOrigin?: string;
55
57
  unsafeSkipOriginCheck?: boolean;
56
58
  trustedProxyHops?: number;
57
59
  postAuthLanding?: PostAuthLandingResolver;
@@ -9,67 +9,49 @@ import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors/index.
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { parseRoles } from "../utils/serialization.js";
11
11
  import { Routes } from "./api-constants.js";
12
- import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, getUser, } from "./auth-middleware.js";
12
+ import { getUser, } from "./auth-middleware.js";
13
13
  import { clientIpSourceFromHonoContext, createClientIpResolver, } from "./client-ip.js";
14
14
  import { resolvePostAuthLandingPath } from "./post-auth-landing.js";
15
+ import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, clearSessionCookies, getSessionCookies, isHostPrefixedCookieName, resolveSessionCookies, } from "./session-cookies.js";
15
16
  import { generateToken } from "./tokens.js";
16
- // Resolves the Secure cookie flag. Locked off in dev/test so Playwright
17
- // against http://localhost:… can actually receive the cookie. Production
18
- // flips it on — browsers drop Secure cookies on http, so a misconfigured
19
- // prod deploy would silently break login rather than fail loud.
20
- function cookieSecure() {
21
- return process.env["NODE_ENV"] === "production";
22
- }
23
- // Double-entry cookie write used at login and switch-tenant. kumiko_auth is
24
- // the HttpOnly carrier of the JWT; kumiko_csrf is the JS-readable token the
17
+ // Double-entry cookie write used at login and switch-tenant. The auth cookie is
18
+ // the HttpOnly carrier of the JWT; the csrf cookie is the JS-readable token the
25
19
  // web client echoes in X-CSRF-Token on every state-changing request. Both
26
20
  // cookies share lifetime and SameSite so a stale auth-cookie can't outlive
27
21
  // its csrf partner (or vice versa) and leave the client in a half-logged-in
28
22
  // state that would trip the csrf-middleware on every retry.
29
23
  function setAuthCookies(c, opts) {
24
+ const { secure, names } = opts.cookies;
30
25
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
31
26
  const common = {
32
- secure: cookieSecure(),
27
+ secure,
33
28
  sameSite,
34
29
  path: "/",
35
30
  maxAge: opts.ttlSeconds,
36
31
  ...(opts.domain !== undefined && { domain: opts.domain }),
37
32
  };
38
- // Bei gesetzter Domain zuerst die host-only-Variante invalidieren (analog
39
- // clearAuthCookies): sonst koexistiert nach einem Deploy mit neuem
40
- // cookieDomain das alte host-only-Cookie mit dem neuen Domain-Cookie
41
- // (RFC 6265: name+domain ist distinct) und der Server bindet
42
- // umgebungsabhängig potenziell ans veraltete host-only-Token.
43
- if (opts.domain !== undefined) {
44
- deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
45
- deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
33
+ // With a Domain, expire the host-only variant first (as clearSessionCookies
34
+ // does): after a deploy that sets cookieDomain, the old host-only cookie
35
+ // would coexist with the new Domain cookie (RFC 6265 treats name+domain as
36
+ // distinct) and the server could bind to the stale host-only token. With
37
+ // __Host- names the unprefixed host-only cookie is a leftover from before
38
+ // the prefix and is expired too.
39
+ if (opts.domain !== undefined || isHostPrefixedCookieName(names.auth)) {
40
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
41
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
46
42
  }
47
43
  // Must precede the setCookie calls: browsers apply Set-Cookie in order and
48
44
  // a retired domain equal to the new one is rejected at config time.
45
+ // Retired domains only ever carried unprefixed names (a __Host- cookie has no
46
+ // Domain), and a __Host- delete with a Domain would be ignored.
49
47
  for (const retiredDomain of opts.retiredDomains) {
50
- deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
51
- deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
48
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
49
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
52
50
  }
53
- setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
51
+ setCookie(c, names.auth, opts.token, { ...common, httpOnly: true });
54
52
  // Intentionally NOT HttpOnly — the web client has to read this from
55
53
  // document.cookie to include it in the X-CSRF-Token request header.
56
- setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
57
- }
58
- function clearAuthCookies(c, domain, retiredDomains) {
59
- // Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
60
- // (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
61
- // Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
62
- // Logout wirkt nur scheinbar.
63
- deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
64
- deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
65
- if (domain !== undefined) {
66
- deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
67
- deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
68
- }
69
- for (const retiredDomain of retiredDomains) {
70
- deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain: retiredDomain });
71
- deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain: retiredDomain });
72
- }
54
+ setCookie(c, names.csrf, opts.csrfToken, { ...common, httpOnly: false });
73
55
  }
74
56
  const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
75
57
  function normalizeCookieDomain(domain) {
@@ -332,6 +314,11 @@ export function createAuthRoutes(dispatcher, jwt, config) {
332
314
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
333
315
  const cookieSameSite = config.cookieSameSite ?? "lax";
334
316
  const cookieDomain = config.cookieDomain;
317
+ const sessionCookies = resolveSessionCookies({
318
+ secureCookies: config.secureCookies,
319
+ cookieDomain,
320
+ retiredCookieDomains: config.retiredCookieDomains,
321
+ });
335
322
  assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
336
323
  const retiredCookieDomains = config.retiredCookieDomains ?? [];
337
324
  // Single hop-count-aware IP getter for every rate-limit call site below —
@@ -369,6 +356,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
369
356
  const token = await jwt.sign(sessionForJwt);
370
357
  const csrfToken = generateToken();
371
358
  setAuthCookies(c, {
359
+ cookies: getSessionCookies(c, sessionCookies),
372
360
  token,
373
361
  csrfToken,
374
362
  sameSite: cookieSameSite,
@@ -908,7 +896,7 @@ export function createAuthRoutes(dispatcher, jwt, config) {
908
896
  }
909
897
  // Clear cookies on the cookie-transport path. Idempotent — clearing a
910
898
  // missing cookie is a no-op, so bearer-only clients aren't affected.
911
- clearAuthCookies(c, cookieDomain, retiredCookieDomains);
899
+ clearSessionCookies(c, getSessionCookies(c, sessionCookies));
912
900
  return c.json({ isSuccess: true });
913
901
  });
914
902
  // GET /auth/tenants — list tenants the current user belongs to
@@ -1,7 +1,8 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import { getCookie } from "hono/cookie";
3
3
  import { STATE_CHANGING_METHODS } from "./api-constants.js";
4
- import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getAuthTransport } from "./auth-middleware.js";
4
+ import { CSRF_HEADER_NAME, getAuthTransport } from "./auth-middleware.js";
5
+ import { getSessionCookies } from "./session-cookies.js";
5
6
  // Constant-time byte compare. `a !== b` short-circuits at the first
6
7
  // differing byte and leaks the common prefix length to anyone who can
7
8
  // time requests — in principle exploitable against sufficiently small
@@ -46,7 +47,7 @@ export function csrfMiddleware() {
46
47
  // CSRF-via-GET vector.
47
48
  if (!STATE_CHANGING_METHODS.has(c.req.method))
48
49
  return next();
49
- const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
50
+ const cookieToken = getCookie(c, getSessionCookies(c).names.csrf);
50
51
  const headerToken = c.req.header(CSRF_HEADER_NAME);
51
52
  // Both must exist and match byte-for-byte. A missing cookie means the
52
53
  // token was never issued (stale session or cross-origin attempt);
@@ -3,7 +3,7 @@ export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
3
3
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
4
4
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
5
5
  export type { AnonymousAccessConfig, AnonymousAccessResolved, AuthMiddlewareOptions, AuthSessionChecker, AuthSessionCheckResult, AuthSessionStatus, TenantExists, TenantLifecycleStatusResolver, TenantResolver, TokenVerifier, } from "./auth-middleware.js";
6
- export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
6
+ export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
7
7
  export type { AuthRoutesConfig, LoginRateLimiter, SessionChecker, SessionCreator, SessionMassRevoker, SessionMetadata, SessionRevoker, } from "./auth-routes.js";
8
8
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
9
9
  export type { ClientIpHeaderSource, ClientIpResolver } from "./client-ip.js";
@@ -14,6 +14,7 @@ export type { CachedResponseInit, CachePolicy } from "./http-cache.js";
14
14
  export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, parseIfNoneMatch, } from "./http-cache.js";
15
15
  export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt.js";
16
16
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
17
+ export { publicOriginOf } from "./origin-middleware.js";
17
18
  export { patAllows, qnMatches } from "./pat-scope.js";
18
19
  export type { PostAuthLandingArgs, PostAuthLandingFlow, PostAuthLandingResolver, } from "./post-auth-landing.js";
19
20
  export { isSafeLandingPath } from "./post-auth-landing.js";
@@ -26,6 +27,8 @@ export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from
26
27
  export { createApiRoutes } from "./routes.js";
27
28
  export type { KumikoServer, ServerOptions } from "./server.js";
28
29
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
30
+ export type { ResolvedSessionCookies, SessionCookieBase, SessionCookieNames, } from "./session-cookies.js";
31
+ export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
29
32
  export type { SseBroker, SseClient, SseEvent } from "./sse-broker.js";
30
33
  export { createSseBroker } from "./sse-broker.js";
31
34
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS, type SseRouteOptions } from "./sse-route.js";
package/dist/api/index.js CHANGED
@@ -1,12 +1,13 @@
1
1
  export { deleteTenantCookie, setTenantCookie } from "./anonymous-cookie.js";
2
2
  export { collectAnonymousLiveEntities } from "./anonymous-live-entities.js";
3
3
  export { LOCALE_HEADER_NAME, NO_ROUTE_MATCH_HEADER_NAME } from "./api-constants.js";
4
- export { AUTH_COOKIE_NAME, authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
4
+ export { authMiddleware, getUser, PAT_TOKEN_PREFIX, sessionCheckStatus, } from "./auth-middleware.js";
5
5
  export { createAuthRoutes, createInMemoryLoginRateLimiter, createRedisLoginRateLimiter, } from "./auth-routes.js";
6
6
  export { assertValidTrustedProxyHops, clientIpSourceFromHonoContext, createClientIpResolver, extractSocketAddress, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, UNKNOWN_CLIENT_IP, } from "./client-ip.js";
7
7
  export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route.js";
8
8
  export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, parseIfNoneMatch, } from "./http-cache.js";
9
9
  export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt.js";
10
+ export { publicOriginOf } from "./origin-middleware.js";
10
11
  export { patAllows, qnMatches } from "./pat-scope.js";
11
12
  export { isSafeLandingPath } from "./post-auth-landing.js";
12
13
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
@@ -15,6 +16,7 @@ export { buildRequestContextDataFromRequest, requestIdMiddleware, } from "./requ
15
16
  export { createMetricsApp, DEFAULT_MAX_REQUEST_BYTES, isPrometheusMeter, } from "./route-registrars.js";
16
17
  export { createApiRoutes } from "./routes.js";
17
18
  export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server.js";
19
+ export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, getSessionCookies, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, resolveSessionCookies, sessionCookieName, sessionCookiesMiddleware, TENANT_COOKIE_BASE, } from "./session-cookies.js";
18
20
  export { createSseBroker } from "./sse-broker.js";
19
21
  export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route.js";
20
22
  export { generateToken } from "./tokens.js";
@@ -4,9 +4,19 @@ export declare function isOriginAllowed(origin: string, normalizedAllowlist: Rea
4
4
  export declare function isForeignCookieOrigin(c: Context, allowlist: ReadonlySet<string>): boolean;
5
5
  export declare function rejectOrigin(c: Context): Response;
6
6
  export declare function isWebSocketOriginAllowed(c: Context, normalizedAllowlist: ReadonlySet<string> | undefined): boolean;
7
- export declare function originMiddleware(allowedOrigins: readonly string[]): (c: Context, next: Next) => Promise<void | Response>;
7
+ export declare function isSameHostOrigin(c: Context): boolean;
8
+ export declare function publicOriginOf(baseUrl: string): string | undefined;
9
+ export declare function originMiddleware(allowedOrigins: readonly string[], options?: {
10
+ readonly allowRequestHost?: boolean;
11
+ }): (c: Context, next: Next) => Promise<void | Response>;
8
12
  export declare function assertOriginGuardConfig(auth: {
9
13
  cookieDomain?: string;
10
14
  allowedOrigins?: readonly string[];
11
15
  unsafeSkipOriginCheck?: boolean;
12
16
  } | undefined): void;
17
+ export declare function resolveOriginGuard(auth: {
18
+ cookieDomain?: string;
19
+ allowedOrigins?: readonly string[];
20
+ publicOrigin?: string;
21
+ unsafeSkipOriginCheck?: boolean;
22
+ } | undefined): ReturnType<typeof originMiddleware> | undefined;
@@ -44,6 +44,19 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
44
44
  if (getAuthTransport(c) !== "cookie")
45
45
  return true;
46
46
  const origin = c.req.header("origin");
47
+ if (origin === undefined || origin === "null")
48
+ return false;
49
+ // With an allowlist there is deliberately no same-host fallback: wide
50
+ // cookieDomain setups are exactly where a sibling subdomain is the attacker.
51
+ if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
52
+ return isOriginAllowed(origin, normalizedAllowlist);
53
+ }
54
+ return isSameHostOrigin(c);
55
+ }
56
+ // The request's own host is a safe extra origin only with host-only session
57
+ // cookies: no sibling host ever receives them.
58
+ export function isSameHostOrigin(c) {
59
+ const origin = c.req.header("origin");
47
60
  if (origin === undefined || origin === "null")
48
61
  return false;
49
62
  let originHost;
@@ -53,14 +66,18 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
53
66
  catch {
54
67
  return false;
55
68
  }
56
- // With an allowlist there is deliberately no same-host fallback: wide
57
- // cookieDomain setups are exactly where a sibling subdomain is the attacker.
58
- if (normalizedAllowlist !== undefined && normalizedAllowlist.size > 0) {
59
- return isOriginAllowed(origin, normalizedAllowlist);
60
- }
61
69
  const requestHost = c.req.header("host")?.toLowerCase();
62
70
  return requestHost !== undefined && originHost === requestHost;
63
71
  }
72
+ export function publicOriginOf(baseUrl) {
73
+ try {
74
+ const origin = new URL(baseUrl).origin;
75
+ return origin === "null" ? undefined : normalizeOrigin(origin);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ }
64
81
  // Server-side Origin-allowlist guard — an additional CSRF-hardening layer on
65
82
  // top of the double-submit token (csrf-middleware) for deployments that widen
66
83
  // the auth cookie across subdomains via AuthRoutesConfig.cookieDomain. A wide
@@ -75,7 +92,7 @@ export function isWebSocketOriginAllowed(c, normalizedAllowlist) {
75
92
  // Bearer-auth requests skip — browsers cannot set Authorization cross-origin,
76
93
  // and native clients send no/foreign Origin, so guarding them would be a
77
94
  // false-positive with no CSRF vector to defend.
78
- export function originMiddleware(allowedOrigins) {
95
+ export function originMiddleware(allowedOrigins, options = {}) {
79
96
  const allowlist = new Set(allowedOrigins.map(normalizeOrigin));
80
97
  return async (c, next) => {
81
98
  const transport = getAuthTransport(c);
@@ -88,8 +105,11 @@ export function originMiddleware(allowedOrigins) {
88
105
  // by the allowlist branch inside isForeignCookieOrigin. The residual is
89
106
  // only the no-Origin-yet-Sec-Fetch-Site combo, which no current browser
90
107
  // emits for state-changing requests.
91
- if (isForeignCookieOrigin(c, allowlist))
108
+ if (isForeignCookieOrigin(c, allowlist)) {
109
+ if (options.allowRequestHost === true && isSameHostOrigin(c))
110
+ return next();
92
111
  return rejectOrigin(c);
112
+ }
93
113
  return next();
94
114
  };
95
115
  }
@@ -118,3 +138,16 @@ export function assertOriginGuardConfig(auth) {
118
138
  "auth.unsafeSkipOriginCheck: true to accept the risk explicitly.");
119
139
  }
120
140
  }
141
+ // An explicit allowlist always wins and is strict. Without one, a known public
142
+ // origin still enables the guard, but only for host-only cookies (no
143
+ // cookieDomain) and never against an explicit opt-out.
144
+ export function resolveOriginGuard(auth) {
145
+ if (auth?.allowedOrigins && auth.allowedOrigins.length > 0) {
146
+ return originMiddleware(auth.allowedOrigins);
147
+ }
148
+ if (auth?.publicOrigin === undefined || auth.unsafeSkipOriginCheck === true)
149
+ return undefined;
150
+ if (auth.cookieDomain)
151
+ return undefined;
152
+ return originMiddleware([auth.publicOrigin], { allowRequestHost: true });
153
+ }
@@ -1,5 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { ROLES } from "../auth/roles.js";
3
+ import { createTenantDb } from "../db/tenant-db.js";
3
4
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
4
5
  import { buildAppSchema } from "../engine/build-app-schema.js";
5
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
@@ -11,6 +12,8 @@ import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
11
12
  import { createFileContext } from "../files/file-handle.js";
12
13
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
13
14
  import { makeFileProviderResolver } from "../files/provider-resolver.js";
15
+ import { createRecordReadCheck } from "../files/record-read-check.js";
16
+ import { createRecordWriteCheck } from "../files/record-write-check.js";
14
17
  import { createFallbackLogger } from "../logging/index.js";
15
18
  import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
16
19
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
@@ -34,7 +37,7 @@ import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
34
37
  import { computeStrongEtag, etagMatches } from "./http-cache.js";
35
38
  import { createJwtHelper } from "./jwt.js";
36
39
  import { observabilityMiddleware } from "./observability-middleware.js";
37
- import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, originMiddleware, rejectOrigin, } from "./origin-middleware.js";
40
+ import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, rejectOrigin, resolveOriginGuard, } from "./origin-middleware.js";
38
41
  import { patRouteGuard } from "./pat-route-guard.js";
39
42
  import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
40
43
  import { createDefaultSseBroker } from "./redis-sse-broker.js";
@@ -42,6 +45,7 @@ import { requestContext } from "./request-context.js";
42
45
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
43
46
  import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
44
47
  import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
48
+ import { resolveSessionCookies, sessionCookiesMiddleware } from "./session-cookies.js";
45
49
  import { createSseRoute } from "./sse-route.js";
46
50
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
47
51
  // The per-tenant file-provider resolver, built once for a registry+context so
@@ -424,6 +428,14 @@ export function buildServer(options) {
424
428
  const app = new Hono();
425
429
  // Sub-apps mounted via app.route() without their own handler inherit this one.
426
430
  app.onError(handleUncaughtRouteError);
431
+ // Resolved once; auth routes, auth/csrf middleware and the tenant-cookie
432
+ // helpers read the cookie names from the request context.
433
+ const sessionCookies = resolveSessionCookies({
434
+ secureCookies: options.auth?.secureCookies,
435
+ cookieDomain: options.auth?.cookieDomain,
436
+ retiredCookieDomains: options.auth?.retiredCookieDomains,
437
+ });
438
+ app.use("*", sessionCookiesMiddleware(sessionCookies));
427
439
  // Only entry:"signature" bypasses jwtGuard (verify() authenticates
428
440
  // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
429
441
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
@@ -553,7 +565,8 @@ export function buildServer(options) {
553
565
  }
554
566
  // Origin-allowlist guard — additional CSRF-hardening for deployments that
555
567
  // widen the auth cookie across subdomains (auth.cookieDomain). Registered
556
- // only when allowedOrigins is non-empty, with the same /api/* + public-skip
568
+ // when allowedOrigins is non-empty, or with host-only cookies when
569
+ // auth.publicOrigin is known (see resolveOriginGuard), with the same /api/* + public-skip
557
570
  // scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
558
571
  // surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
559
572
  // closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
@@ -562,7 +575,7 @@ export function buildServer(options) {
562
575
  assertOriginGuardConfig(options.auth);
563
576
  assertSessionCallbacksPaired(options.auth);
564
577
  const allowedOrigins = options.auth?.allowedOrigins;
565
- const originGuard = allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
578
+ const originGuard = resolveOriginGuard(options.auth);
566
579
  if (originGuard) {
567
580
  app.use("/api/*", async (c, next) => {
568
581
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
@@ -613,7 +626,11 @@ export function buildServer(options) {
613
626
  "is expected (the bundled user feature already provides it).");
614
627
  }
615
628
  }
616
- app.route("/api", createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }));
629
+ app.route("/api", createAuthRoutes(dispatcher, jwt, {
630
+ ...options.auth,
631
+ secureCookies: sessionCookies.secure,
632
+ resolver: clientIpResolver,
633
+ }));
617
634
  }
618
635
  app.route("/api", createApiRoutes(dispatcher, {
619
636
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
@@ -672,10 +689,19 @@ export function buildServer(options) {
672
689
  if (!fileDb)
673
690
  throw new Error("file routes require db in context");
674
691
  const routeOptions = readFilesRouteOptions(options.registry);
692
+ const canReadRecord = createRecordReadCheck(options.registry, dispatcher.query);
675
693
  app.route("/api", createFileRoutes({
676
694
  db: fileDb,
677
695
  registry: options.registry,
678
696
  resolveProvider: fileProviderResolver,
697
+ canReadRecord,
698
+ canWriteRecord: createRecordWriteCheck({
699
+ registry: options.registry,
700
+ canReadRecord,
701
+ tenantDbFor: (tenantId) => createTenantDb(fileDb, tenantId, "tenant", undefined, undefined, undefined, {
702
+ projectionRegistry: options.registry,
703
+ }),
704
+ }),
679
705
  ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
680
706
  ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
681
707
  ...(routeOptions.maxUploadSize ? { maxUploadSize: routeOptions.maxUploadSize } : {}),
@@ -0,0 +1,23 @@
1
+ import { type SessionCookieNames } from "@cosmicdrift/kumiko-types/session-cookies";
2
+ import type { Context, MiddlewareHandler } from "hono";
3
+ export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, type SessionCookieBase, type SessionCookieNames, sessionCookieName, TENANT_COOKIE_BASE, } from "@cosmicdrift/kumiko-types/session-cookies";
4
+ export type ResolvedSessionCookies = {
5
+ readonly secure: boolean;
6
+ readonly names: SessionCookieNames;
7
+ readonly domain?: string | undefined;
8
+ readonly retiredDomains?: readonly string[] | undefined;
9
+ };
10
+ export declare function resolveSecureCookies(configured?: boolean): boolean;
11
+ export declare function resolveSessionCookies(options: {
12
+ readonly secureCookies?: boolean | undefined;
13
+ readonly cookieDomain?: string | undefined;
14
+ readonly retiredCookieDomains?: readonly string[] | undefined;
15
+ }): ResolvedSessionCookies;
16
+ export declare function sessionCookiesMiddleware(resolved: ResolvedSessionCookies): MiddlewareHandler;
17
+ export declare function getSessionCookies(c: Context, fallback?: ResolvedSessionCookies): ResolvedSessionCookies;
18
+ export declare function hostOnlyDeleteOptions(name: string): {
19
+ path: "/";
20
+ secure?: true;
21
+ };
22
+ export declare function clearSessionCookies(c: Context, cookies: ResolvedSessionCookies): void;
23
+ export declare function hasDuplicateSessionCookie(c: Context, names: SessionCookieNames): boolean;