@cosmicdrift/kumiko-framework 0.358.0 → 0.360.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 (41) 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 +20 -4
  14. package/dist/api/session-cookies.d.ts +23 -0
  15. package/dist/api/session-cookies.js +85 -0
  16. package/dist/engine/build-app-schema.js +11 -2
  17. package/dist/engine/feature-config-events-jobs.js +8 -0
  18. package/dist/errors/reasons.d.ts +1 -0
  19. package/dist/errors/reasons.js +3 -0
  20. package/dist/files/bind-pending-file-refs.d.ts +8 -0
  21. package/dist/files/bind-pending-file-refs.js +54 -0
  22. package/dist/files/create-file-ref-from-bytes.d.ts +22 -0
  23. package/dist/files/create-file-ref-from-bytes.js +39 -0
  24. package/dist/files/create-file-ref.d.ts +50 -0
  25. package/dist/files/create-file-ref.js +135 -0
  26. package/dist/files/feature.js +2 -2
  27. package/dist/files/file-ref-entity.d.ts +1 -0
  28. package/dist/files/file-ref-entity.js +1 -0
  29. package/dist/files/file-ref-table.js +2 -2
  30. package/dist/files/file-routes.d.ts +2 -0
  31. package/dist/files/file-routes.js +36 -126
  32. package/dist/files/index.d.ts +2 -0
  33. package/dist/files/index.js +1 -0
  34. package/dist/files/record-read-check.d.ts +9 -0
  35. package/dist/files/record-read-check.js +49 -0
  36. package/dist/jobs/cron-repeat-options.d.ts +9 -0
  37. package/dist/jobs/cron-repeat-options.js +5 -0
  38. package/dist/jobs/job-runner.js +2 -1
  39. package/dist/pipeline/dispatch-write.js +7 -0
  40. package/package.json +4 -4
  41. package/src/changes.json +52 -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
+ }
@@ -11,6 +11,7 @@ import { assertSeedModeNotInProduction } from "../event-store/seed-clock.js";
11
11
  import { createFileContext } from "../files/file-handle.js";
12
12
  import { createFileRoutes, readFilesRouteOptions } from "../files/file-routes.js";
13
13
  import { makeFileProviderResolver } from "../files/provider-resolver.js";
14
+ import { createRecordReadCheck } from "../files/record-read-check.js";
14
15
  import { createFallbackLogger } from "../logging/index.js";
15
16
  import { createNoopProvider, DEFAULT_SENSITIVE_CONFIG, mergeSensitiveConfig, registerStandardMetrics, wrapRedisClient, } from "../observability/index.js";
16
17
  import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership.js";
@@ -34,7 +35,7 @@ import { ExtraRouteEntries, ExtraRouteRejection, } from "./extra-route.js";
34
35
  import { computeStrongEtag, etagMatches } from "./http-cache.js";
35
36
  import { createJwtHelper } from "./jwt.js";
36
37
  import { observabilityMiddleware } from "./observability-middleware.js";
37
- import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, originMiddleware, rejectOrigin, } from "./origin-middleware.js";
38
+ import { assertOriginGuardConfig, isWebSocketOriginAllowed, normalizeOrigin, rejectOrigin, resolveOriginGuard, } from "./origin-middleware.js";
38
39
  import { patRouteGuard } from "./pat-route-guard.js";
39
40
  import { piiCiphertextResponseGuard } from "./pii-leak-guard.js";
40
41
  import { createDefaultSseBroker } from "./redis-sse-broker.js";
@@ -42,6 +43,7 @@ import { requestContext } from "./request-context.js";
42
43
  import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware.js";
43
44
  import { bodyLimitForRoutePath, DEFAULT_MAX_REQUEST_BYTES, registerBodyLimit, registerHealthRoutes, registerMetricsRoute, registerVersionRoute, } from "./route-registrars.js";
44
45
  import { createApiRoutes, handleUncaughtRouteError } from "./routes.js";
46
+ import { resolveSessionCookies, sessionCookiesMiddleware } from "./session-cookies.js";
45
47
  import { createSseRoute } from "./sse-route.js";
46
48
  import { buildWebSocketSessionRevalidator, createWebSocketConnectionLimiter, hasWebSocketUpgradeServer, isWebSocketUpgradeRequest, WEBSOCKET_DEFAULT_MAX_CONNECTIONS_PER_USER, WEBSOCKET_DEFAULT_MAX_MESSAGE_BYTES, } from "./websocket-route.js";
47
49
  // The per-tenant file-provider resolver, built once for a registry+context so
@@ -424,6 +426,14 @@ export function buildServer(options) {
424
426
  const app = new Hono();
425
427
  // Sub-apps mounted via app.route() without their own handler inherit this one.
426
428
  app.onError(handleUncaughtRouteError);
429
+ // Resolved once; auth routes, auth/csrf middleware and the tenant-cookie
430
+ // helpers read the cookie names from the request context.
431
+ const sessionCookies = resolveSessionCookies({
432
+ secureCookies: options.auth?.secureCookies,
433
+ cookieDomain: options.auth?.cookieDomain,
434
+ retiredCookieDomains: options.auth?.retiredCookieDomains,
435
+ });
436
+ app.use("*", sessionCookiesMiddleware(sessionCookies));
427
437
  // Only entry:"signature" bypasses jwtGuard (verify() authenticates
428
438
  // itself); entry:"anonymous" still needs the anonymousAccess fallthrough
429
439
  // (tenant-by-host), entry:"user" needs c.get("user") populated.
@@ -553,7 +563,8 @@ export function buildServer(options) {
553
563
  }
554
564
  // Origin-allowlist guard — additional CSRF-hardening for deployments that
555
565
  // widen the auth cookie across subdomains (auth.cookieDomain). Registered
556
- // only when allowedOrigins is non-empty, with the same /api/* + public-skip
566
+ // when allowedOrigins is non-empty, or with host-only cookies when
567
+ // auth.publicOrigin is known (see resolveOriginGuard), with the same /api/* + public-skip
557
568
  // scope as the CSRF guard and BEFORE it, so a disallowed cross-site POST
558
569
  // surfaces as `origin_not_allowed` rather than `csrf_token_mismatch`. Fails
559
570
  // closed (assertOriginGuardConfig throws) when a wide cookieDomain is set
@@ -562,7 +573,7 @@ export function buildServer(options) {
562
573
  assertOriginGuardConfig(options.auth);
563
574
  assertSessionCallbacksPaired(options.auth);
564
575
  const allowedOrigins = options.auth?.allowedOrigins;
565
- const originGuard = allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
576
+ const originGuard = resolveOriginGuard(options.auth);
566
577
  if (originGuard) {
567
578
  app.use("/api/*", async (c, next) => {
568
579
  if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c))
@@ -613,7 +624,11 @@ export function buildServer(options) {
613
624
  "is expected (the bundled user feature already provides it).");
614
625
  }
615
626
  }
616
- app.route("/api", createAuthRoutes(dispatcher, jwt, { ...options.auth, resolver: clientIpResolver }));
627
+ app.route("/api", createAuthRoutes(dispatcher, jwt, {
628
+ ...options.auth,
629
+ secureCookies: sessionCookies.secure,
630
+ resolver: clientIpResolver,
631
+ }));
617
632
  }
618
633
  app.route("/api", createApiRoutes(dispatcher, {
619
634
  ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
@@ -676,6 +691,7 @@ export function buildServer(options) {
676
691
  db: fileDb,
677
692
  registry: options.registry,
678
693
  resolveProvider: fileProviderResolver,
694
+ canReadRecord: createRecordReadCheck(options.registry, dispatcher.query),
679
695
  ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
680
696
  ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
681
697
  ...(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;
@@ -0,0 +1,85 @@
1
+ import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, isHostPrefixedCookieName, resolveSessionCookieNames, } from "@cosmicdrift/kumiko-types/session-cookies";
2
+ import { deleteCookie } from "hono/cookie";
3
+ export { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, HOST_COOKIE_PREFIX, isHostPrefixedCookieName, resolveSessionCookieNames, sessionCookieName, TENANT_COOKIE_BASE, } from "@cosmicdrift/kumiko-types/session-cookies";
4
+ const SESSION_COOKIES_KEY = "sessionCookies";
5
+ // Browsers drop Secure cookies on http, so Secure is off outside production
6
+ // (Playwright against http://localhost) and on in production, where a
7
+ // misconfigured http deploy breaks login loudly instead of silently.
8
+ export function resolveSecureCookies(configured) {
9
+ return configured ?? process.env["NODE_ENV"] === "production";
10
+ }
11
+ export function resolveSessionCookies(options) {
12
+ const secure = resolveSecureCookies(options.secureCookies);
13
+ return {
14
+ secure,
15
+ names: resolveSessionCookieNames({ secure, domain: options.cookieDomain }),
16
+ domain: options.cookieDomain,
17
+ retiredDomains: options.retiredCookieDomains,
18
+ };
19
+ }
20
+ export function sessionCookiesMiddleware(resolved) {
21
+ return async (c, next) => {
22
+ c.set(SESSION_COOKIES_KEY, resolved);
23
+ await next();
24
+ };
25
+ }
26
+ function isResolvedSessionCookies(value) {
27
+ return typeof value === "object" && value !== null && "names" in value && "secure" in value;
28
+ }
29
+ // buildServer resolves the names once and stores them per request, so writer
30
+ // (auth routes) and readers (auth/csrf/tenant) cannot diverge. The fallback
31
+ // only applies to contexts that never ran through buildServer, e.g. a bare
32
+ // Hono app in a unit test or a standalone createAuthRoutes mount.
33
+ export function getSessionCookies(c, fallback) {
34
+ const stored = c.get(SESSION_COOKIES_KEY);
35
+ if (isResolvedSessionCookies(stored))
36
+ return stored;
37
+ return fallback ?? resolveSessionCookies({});
38
+ }
39
+ // A deletion of a `__Host-` cookie is ignored by the browser unless it is
40
+ // itself Secure, Path=/ and Domain-less.
41
+ export function hostOnlyDeleteOptions(name) {
42
+ return isHostPrefixedCookieName(name) ? { path: "/", secure: true } : { path: "/" };
43
+ }
44
+ // Expire every variant: the Domain cookie (current) and the host-only one
45
+ // (from before cookieDomain), otherwise a cookie left over from an earlier
46
+ // config keeps the session alive after logout. With __Host- names the
47
+ // unprefixed leftovers from before the prefix are expired as well. Retired
48
+ // domains only ever carried unprefixed names.
49
+ export function clearSessionCookies(c, cookies) {
50
+ const { names, domain } = cookies;
51
+ deleteCookie(c, names.auth, hostOnlyDeleteOptions(names.auth));
52
+ deleteCookie(c, names.csrf, hostOnlyDeleteOptions(names.csrf));
53
+ if (isHostPrefixedCookieName(names.auth)) {
54
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
55
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
56
+ }
57
+ if (domain !== undefined) {
58
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain });
59
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain });
60
+ }
61
+ for (const retiredDomain of cookies.retiredDomains ?? []) {
62
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
63
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
64
+ }
65
+ }
66
+ function cookieNameOccurrences(cookieHeader, name) {
67
+ let count = 0;
68
+ for (const pair of cookieHeader.split(";")) {
69
+ const separator = pair.indexOf("=");
70
+ const pairName = (separator === -1 ? pair : pair.slice(0, separator)).trim();
71
+ if (pairName === name)
72
+ count++;
73
+ }
74
+ return count;
75
+ }
76
+ // Two cookies with the same name (e.g. a host-only and a Domain variant, or one
77
+ // planted by a sibling subdomain) are indistinguishable server-side; Hono's
78
+ // getCookie would silently bind to whichever the browser sent first.
79
+ export function hasDuplicateSessionCookie(c, names) {
80
+ const cookieHeader = c.req.header("cookie");
81
+ if (cookieHeader === undefined)
82
+ return false;
83
+ return (cookieNameOccurrences(cookieHeader, names.auth) > 1 ||
84
+ cookieNameOccurrences(cookieHeader, names.csrf) > 1);
85
+ }