@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -14,6 +14,7 @@ Feature-scoped translations — 'Hallo' and 'Hello' in the same app.
14
14
 
15
15
  - `r.translations()` for multi-language feature keys
16
16
  - Plurals
17
+ - `requiredLocales`: boot fails when a key misses one of the app's languages
17
18
 
18
19
  ## Plurals
19
20
 
@@ -32,6 +33,20 @@ exist where the locale's grammar needs them:
32
33
  `Intl.PluralRules(locale)`, then interpolates `{count}` (and any other
33
34
  `{name}` placeholder) into the chosen form.
34
35
 
36
+ ## Required locales
37
+
38
+ Without further options boot only checks that each feature is consistent with
39
+ itself. An app that ships fixed languages lists them, and boot then fails on any
40
+ key that misses one of them, including framework keys. Locale packages such as
41
+ `locale-de` count:
42
+
43
+ ```ts illustration
44
+ runProdApp({ ...options, validateBootOptions: { requiredLocales: ["de", "en"] } });
45
+ ```
46
+
47
+ Define the options once and re-export them as `VALIDATE_BOOT_OPTIONS` from
48
+ `kumiko/schema.ts`, so `kumiko-schema validate` checks the same list in CI.
49
+
35
50
  ## Source
36
51
 
37
52
  Feature entry point: `src/feature.ts`.
@@ -53,7 +68,7 @@ bun test
53
68
  // Note: keys are prefixed with feature name → "featureName:key"
54
69
 
55
70
  import { describe, expect, test } from "bun:test";
56
- import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
71
+ import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
57
72
  import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
58
73
  import { errorFeature, greetingFeature } from "../feature";
59
74
 
@@ -106,6 +121,22 @@ describe("multiple features merge translations", () => {
106
121
  expect(keys).toHaveLength(6);
107
122
  });
108
123
  });
124
+
125
+ describe("requiredLocales", () => {
126
+ test("boot passes when every key carries the required locales", () => {
127
+ expect(() =>
128
+ validateBoot([greetingFeature, errorFeature], { requiredLocales: ["de", "en"] }),
129
+ ).not.toThrow();
130
+ });
131
+
132
+ test("boot names each key that lacks a required locale", () => {
133
+ expect(() =>
134
+ validateBoot([greetingFeature, errorFeature], { requiredLocales: ["de", "en", "fr"] }),
135
+ ).toThrow(
136
+ /Feature "errors": translation key "errors.not_found" is missing required locale\(s\) fr/,
137
+ );
138
+ });
139
+ });
109
140
  ```
110
141
 
111
142
  ### src/feature.ts
@@ -124,20 +155,24 @@ export const greetingFeature = defineFeature("greeting", (r) => {
124
155
  de: "Willkommen",
125
156
  en: "Welcome",
126
157
  fr: "Bienvenue",
158
+ pl: "Witamy",
127
159
  },
128
160
  "greeting.goodbye": {
129
161
  de: "Auf Wiedersehen",
130
162
  en: "Goodbye",
131
163
  fr: "Au revoir",
164
+ pl: "Do widzenia",
132
165
  },
133
166
  "greeting.hello_name": {
134
167
  de: "Hallo, {name}!",
135
168
  en: "Hello, {name}!",
136
169
  fr: "Bonjour, {name}!",
170
+ pl: "Cześć, {name}!",
137
171
  },
138
172
  "greeting.unread_count": {
139
173
  de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
140
174
  en: { one: "{count} unread message", other: "{count} unread messages" },
175
+ fr: { one: "{count} message non lu", other: "{count} messages non lus" },
141
176
  pl: {
142
177
  one: "{count} nieprzeczytana wiadomość",
143
178
  few: "{count} nieprzeczytane wiadomości",
@@ -518,6 +518,10 @@ Requires: `user-data-rights`
518
518
 
519
519
  Optional requires: `notes-history`
520
520
 
521
+ ## pat-scope-labels
522
+
523
+ German labels for the token scopes this sample app declares.
524
+
521
525
  ## personal-access-tokens
522
526
 
523
527
  Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `store_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — registered as an auth-foundation tokenVerifier provider, resolved generically by the middleware. Pass { toggleable: { default: false } } to tier-gate the whole feature.
@@ -0,0 +1,8 @@
1
+ import type { Redis } from "ioredis";
2
+ import type { LoginRateLimiter } from "./auth-routes-types.ts";
3
+ export declare function inMemoryLoginRateLimiterFallback(): LoginRateLimiter;
4
+ export declare function createInMemoryLoginRateLimiter(maxAttempts?: number, windowMs?: number, { maxEntries, sweepThreshold, }?: {
5
+ maxEntries?: number;
6
+ sweepThreshold?: number;
7
+ }): LoginRateLimiter;
8
+ export declare function createRedisLoginRateLimiter(redis: Redis, maxAttempts?: number, windowMs?: number, namespace?: string): LoginRateLimiter;
@@ -0,0 +1,105 @@
1
+ import { createFallbackLogger } from "../logging/index.js";
2
+ let warnedInMemoryLimiterInProduction = false;
3
+ // Without Redis-backed limiters each replica counts on its own, so the effective limit scales with replica count.
4
+ export function inMemoryLoginRateLimiterFallback() {
5
+ if (process.env["NODE_ENV"] === "production" && !warnedInMemoryLimiterInProduction) {
6
+ warnedInMemoryLimiterInProduction = true;
7
+ createFallbackLogger("auth-routes").warn("login/MFA rate limiter falls back to in-memory; limits are per replica");
8
+ }
9
+ return createInMemoryLoginRateLimiter();
10
+ }
11
+ // Default: in-memory fixed window. Fine for a single Node process; for
12
+ // multi-process deployments, inject a Redis-backed LoginRateLimiter instead
13
+ // so attempts can't be spread across replicas.
14
+ //
15
+ // Memory management: entries expire after `windowMs` but the map entries
16
+ // themselves linger until something touches them. To stop the map from
17
+ // growing unbounded (a single attacker can create entries with different
18
+ // `ip|email` buckets at ~req rate), we opportunistically sweep expired
19
+ // entries when the map crosses `sweepThreshold` keys and hard-cap total
20
+ // entries at `maxEntries` — oldest ones get dropped first.
21
+ export function createInMemoryLoginRateLimiter(maxAttempts = 10, windowMs = 5 * 60_000, { maxEntries = 10_000, sweepThreshold = 1_000, } = {}) {
22
+ const hits = new Map();
23
+ function sweepExpired(now) {
24
+ for (const [k, entry] of hits) {
25
+ if (entry.resetAt <= now)
26
+ hits.delete(k);
27
+ }
28
+ }
29
+ function enforceCap() {
30
+ // Map iteration is insertion-order, so the oldest entries are first.
31
+ // Drop from the front until we're back under the cap.
32
+ // skip: under the cap, nothing to do
33
+ if (hits.size <= maxEntries)
34
+ return;
35
+ const toDrop = hits.size - maxEntries;
36
+ let dropped = 0;
37
+ for (const k of hits.keys()) {
38
+ if (dropped >= toDrop)
39
+ break;
40
+ hits.delete(k);
41
+ dropped++;
42
+ }
43
+ }
44
+ return {
45
+ async check(key) {
46
+ const now = Date.now();
47
+ if (hits.size >= sweepThreshold)
48
+ sweepExpired(now);
49
+ const entry = hits.get(key);
50
+ if (!entry || entry.resetAt <= now) {
51
+ hits.set(key, { count: 1, resetAt: now + windowMs });
52
+ enforceCap();
53
+ return true;
54
+ }
55
+ if (entry.count >= maxAttempts)
56
+ return false;
57
+ entry.count++;
58
+ return true;
59
+ },
60
+ async reset(key) {
61
+ hits.delete(key);
62
+ },
63
+ };
64
+ }
65
+ // Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
66
+ // semantics (count <= maxAttempts allows, window anchored on first hit), but
67
+ // shared across replicas via INCR/PEXPIRE instead of an in-process Map.
68
+ // runProdApp defaults to this: an in-memory limiter only rate-limits within
69
+ // a single instance, so a multi-replica prod deployment would silently give
70
+ // each replica its own bucket. namespace separates the login-key keyspace
71
+ // from the mfa-verify one (they share the same LoginRateLimiter shape but
72
+ // key on different values).
73
+ // Fail-closed on Redis outage, unlike the in-memory limiter (which never
74
+ // throws): `check`/`reset` propagate any Redis error, so callers 500
75
+ // instead of falling back to unlimited attempts. Deliberate — Redis is
76
+ // already required infra for a multi-replica deployment, and for a
77
+ // security-relevant limiter "briefly unavailable" should read as "briefly
78
+ // down", not "briefly unlimited".
79
+ export function createRedisLoginRateLimiter(redis, maxAttempts = 10, windowMs = 5 * 60_000, namespace = "login") {
80
+ const prefix = `kumiko:auth:ratelimit:${namespace}:`;
81
+ return {
82
+ async check(key) {
83
+ const redisKey = `${prefix}${key}`;
84
+ // INCR then PEXPIRE was two round-trips: a crash/network blip between
85
+ // them (only possible right after count===1, when the key is fresh)
86
+ // left the key permanently without a TTL — the window never resets,
87
+ // the counter only grows, and the bucket is locked out until an
88
+ // operator manually resets/deletes the key. One atomic eval closes
89
+ // the gap: the TTL is set in the same script invocation that creates
90
+ // the key, so no observer (including a crash) can see count===1
91
+ // without the expiry already applied. The `PTTL < 0` branch also
92
+ // heals keys that already exist without a TTL from before this fix
93
+ // shipped (PTTL returns -1 for "no expiry set, key exists") — without
94
+ // it, those pre-existing keys would grow forever with no way back to
95
+ // a normal window.
96
+ const count = (await redis.eval(`local c = redis.call('INCR', KEYS[1])
97
+ if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
98
+ return c`, 1, redisKey, windowMs));
99
+ return count <= maxAttempts;
100
+ },
101
+ async reset(key) {
102
+ await redis.del(`${prefix}${key}`);
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,34 @@
1
+ import type { Context } from "hono";
2
+ import { Hono } from "hono";
3
+ import type { SessionUser, TenantId } from "../engine/types/index.ts";
4
+ import type { Dispatcher } from "../pipeline/dispatcher.ts";
5
+ import type { AuthRoutesConfig } from "./auth-routes-types.ts";
6
+ import type { ClientIpResolver } from "./client-ip.ts";
7
+ import type { JwtHelper } from "./jwt.ts";
8
+ import type { PostAuthLandingArgs } from "./post-auth-landing.ts";
9
+ import type { ResolvedSessionCookies } from "./session-cookies.ts";
10
+ export type AuthRouteContext = {
11
+ readonly api: Hono;
12
+ readonly dispatcher: Dispatcher;
13
+ readonly jwt: JwtHelper;
14
+ readonly config: AuthRoutesConfig;
15
+ readonly cookieSameSite: "lax" | "strict";
16
+ readonly cookieDomain: string | undefined;
17
+ readonly sessionCookies: ResolvedSessionCookies;
18
+ readonly retiredCookieDomains: readonly string[];
19
+ readonly clientIpResolver: ClientIpResolver;
20
+ readonly getClientIp: (c: Context) => string;
21
+ readonly switchTenantMfaGate: (c: Context, user: SessionUser, targetTenantId: TenantId) => Promise<{
22
+ readonly response: Response;
23
+ } | {
24
+ readonly clearedRoles: readonly string[];
25
+ } | undefined>;
26
+ readonly resolveSwitchTenantRoles: (user: SessionUser, membership: {
27
+ readonly roles: readonly string[];
28
+ }) => Promise<readonly string[]>;
29
+ readonly mintSessionAndRespond: (c: Context, session: SessionUser) => Promise<string>;
30
+ readonly landingPathFragment: (args: PostAuthLandingArgs) => {
31
+ readonly landingPath?: string;
32
+ };
33
+ };
34
+ export declare function createAuthRouteContext(dispatcher: Dispatcher, jwt: JwtHelper, config: AuthRoutesConfig): AuthRouteContext;
@@ -0,0 +1,192 @@
1
+ import { Hono } from "hono";
2
+ import { deleteCookie, setCookie } from "hono/cookie";
3
+ import { buildSessionRoles } from "../engine/membership-roles.js";
4
+ import { createSystemUser } from "../engine/system-user.js";
5
+ import { parseRoles } from "../utils/serialization.js";
6
+ import { isUnknownHandlerError, pendingMfaStepResponse } from "./auth-routes-shared.js";
7
+ import { clientIpSourceFromHonoContext, createClientIpResolver } from "./client-ip.js";
8
+ import { resolvePostAuthLandingPath } from "./post-auth-landing.js";
9
+ import { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, expireLegacySessionCookieVariants, getSessionCookies, resolveSessionCookies, } from "./session-cookies.js";
10
+ import { generateToken } from "./tokens.js";
11
+ // Double-entry cookie write used at login and switch-tenant. The auth cookie is
12
+ // the HttpOnly carrier of the JWT; the csrf cookie is the JS-readable token the
13
+ // web client echoes in X-CSRF-Token on every state-changing request. Both
14
+ // cookies share lifetime and SameSite so a stale auth-cookie can't outlive
15
+ // its csrf partner (or vice versa) and leave the client in a half-logged-in
16
+ // state that would trip the csrf-middleware on every retry.
17
+ function setAuthCookies(c, opts) {
18
+ const { secure, names } = opts.cookies;
19
+ const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
20
+ const common = {
21
+ secure,
22
+ sameSite,
23
+ path: "/",
24
+ maxAge: opts.ttlSeconds,
25
+ ...(opts.domain !== undefined && { domain: opts.domain }),
26
+ };
27
+ // With a Domain, expire the host-only variant first (as clearSessionCookies
28
+ // does): after a deploy that sets cookieDomain, the old host-only cookie
29
+ // would coexist with the new Domain cookie (RFC 6265 treats name+domain as
30
+ // distinct) and the server could bind to the stale host-only token. With
31
+ // __Host- names the unprefixed host-only cookie is a leftover from before
32
+ // the prefix and is expired too (and vice versa, see
33
+ // expireLegacySessionCookieVariants).
34
+ if (opts.domain !== undefined) {
35
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/" });
36
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/" });
37
+ }
38
+ expireLegacySessionCookieVariants(c, opts.cookies);
39
+ // Must precede the setCookie calls: browsers apply Set-Cookie in order and
40
+ // a retired domain equal to the new one is rejected at config time.
41
+ // Retired domains only ever carried unprefixed names (a __Host- cookie has no
42
+ // Domain), and a __Host- delete with a Domain would be ignored.
43
+ for (const retiredDomain of opts.retiredDomains) {
44
+ deleteCookie(c, AUTH_COOKIE_BASE, { path: "/", domain: retiredDomain });
45
+ deleteCookie(c, CSRF_COOKIE_BASE, { path: "/", domain: retiredDomain });
46
+ }
47
+ setCookie(c, names.auth, opts.token, { ...common, httpOnly: true });
48
+ // Intentionally NOT HttpOnly — the web client has to read this from
49
+ // document.cookie to include it in the X-CSRF-Token request header.
50
+ setCookie(c, names.csrf, opts.csrfToken, { ...common, httpOnly: false });
51
+ }
52
+ const INVALID_COOKIE_DOMAIN_CHARS = /[;,\s]/;
53
+ function normalizeCookieDomain(domain) {
54
+ return domain.replace(/^\./, "").toLowerCase();
55
+ }
56
+ // A malformed entry would corrupt the Set-Cookie header; an entry equal to the
57
+ // current cookieDomain would make login delete the cookie it just sets.
58
+ function assertRetiredCookieDomains(retiredCookieDomains, cookieDomain) {
59
+ const current = cookieDomain === undefined ? undefined : normalizeCookieDomain(cookieDomain);
60
+ for (const entry of retiredCookieDomains ?? []) {
61
+ if (entry === "" || normalizeCookieDomain(entry) === "") {
62
+ throw new Error("createAuthRoutes: retiredCookieDomains must not contain an empty entry");
63
+ }
64
+ if (INVALID_COOKIE_DOMAIN_CHARS.test(entry)) {
65
+ throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" contains an invalid character (;, comma or whitespace)`);
66
+ }
67
+ if (normalizeCookieDomain(entry) === current) {
68
+ throw new Error(`createAuthRoutes: retiredCookieDomains entry "${entry}" equals cookieDomain; login would delete its own cookie`);
69
+ }
70
+ }
71
+ }
72
+ // Extract `ip` and `user-agent` for the sessionCreator. Client-IP resolution
73
+ // (trustedProxyHops-aware) is the shared resolveClientIp (see client-ip.ts)
74
+ // — createAuthRoutes builds one resolver instance below and
75
+ // closes over it here and in getClientIp.
76
+ function requestMeta(c, resolver) {
77
+ const ip = resolver.resolve(clientIpSourceFromHonoContext(c));
78
+ const userAgent = c.req.header("user-agent") ?? "unknown";
79
+ return { ip, userAgent };
80
+ }
81
+ export function createAuthRouteContext(dispatcher, jwt, config) {
82
+ const api = new Hono();
83
+ // Default to "lax": CSRF control comes from the double-submit token, and
84
+ // "lax" keeps email deep-links (invite, magic-link, notification click)
85
+ // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
86
+ const cookieSameSite = config.cookieSameSite ?? "lax";
87
+ const cookieDomain = config.cookieDomain;
88
+ const sessionCookies = resolveSessionCookies({
89
+ secureCookies: config.secureCookies,
90
+ cookieDomain,
91
+ retiredCookieDomains: config.retiredCookieDomains,
92
+ });
93
+ assertRetiredCookieDomains(config.retiredCookieDomains, cookieDomain);
94
+ if (config.mfaVerifyHandler && !config.switchTenantMfaGateHandler) {
95
+ throw new Error("createAuthRoutes: mfaVerifyHandler is set but switchTenantMfaGateHandler is not; " +
96
+ "POST /auth/switch-tenant would mint admin sessions without the second factor");
97
+ }
98
+ const retiredCookieDomains = config.retiredCookieDomains ?? [];
99
+ // Single hop-count-aware IP getter for every rate-limit call site below —
100
+ // see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
101
+ // doc comment. createClientIpResolver fails loud on a non-finite/negative
102
+ // value rather than letting it silently collapse every request into the
103
+ // shared "unknown" bucket.
104
+ const clientIpResolver = config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
105
+ const getClientIp = (c) => clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
106
+ // undefined = no gate wired (setup without MFA). "clearedRoles" are the roles the gate decided on,
107
+ // so the minted session cannot differ from what the gate checked.
108
+ async function switchTenantMfaGate(c, user, targetTenantId) {
109
+ if (!config.switchTenantMfaGateHandler)
110
+ return undefined;
111
+ const gate = await dispatcher.write(config.switchTenantMfaGateHandler, { userId: user.id, tenantId: targetTenantId }, createSystemUser(user.tenantId));
112
+ if (!gate.isSuccess) {
113
+ const status = gate.error.httpStatus; // @cast-boundary engine-payload
114
+ return { response: c.json({ isSuccess: false, error: gate.error }, status) };
115
+ }
116
+ // @cast-boundary engine-payload — the gate handler's three-shape result
117
+ const gateData = gate.data;
118
+ if (gateData.kind === "mfa-gate-clear")
119
+ return { clearedRoles: gateData.roles };
120
+ return { response: pendingMfaStepResponse(c, gateData) };
121
+ }
122
+ // Global roles (SystemAdmin etc.) come from the user feature when userQuery is wired, so they survive
123
+ // the tenant switch. buildSessionRoles calls stripForbiddenMembershipRoles on the membership side only — a backstop
124
+ // for a role a projection rebuild resurrected past command-time validation (engine/membership-roles).
125
+ async function resolveSwitchTenantRoles(user, membership) {
126
+ let globalRoles = [];
127
+ if (config.userQuery) {
128
+ try {
129
+ // @cast-boundary engine-payload — dispatcher.query returns unknown.
130
+ // Per the AuthUserRow contract, userQuery returns a row with a roles column.
131
+ const userRow = (await dispatcher.query(config.userQuery, { id: user.id }, createSystemUser(user.tenantId)));
132
+ // roles is jsonb string[] (multiSelect); parseRoles also accepts legacy
133
+ // JSON-encoded text from pre-0.217.0 rows.
134
+ globalRoles = parseRoles(userRow?.roles ?? null);
135
+ }
136
+ catch (e) {
137
+ // Non-fatal: when global roles cannot be resolved the switch continues with tenant roles
138
+ // only. A server error carrying just the cause would hurt the UX more than a switch without
139
+ // SystemAdmin, which the user notices and reports.
140
+ if (!isUnknownHandlerError(e))
141
+ throw e;
142
+ }
143
+ }
144
+ return buildSessionRoles(globalRoles, membership.roles);
145
+ }
146
+ // Shared tail of every route that ends a request logged-in: create the
147
+ // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
148
+ // duplicated 5x (login/signup-confirm/invite-accept-with-login/invite-
149
+ // signup-complete/switch-tenant) — extracted so a 6th caller (mfa-verify)
150
+ // doesn't grow it to 6.
151
+ async function mintSessionAndRespond(c, session) {
152
+ let sessionForJwt = session;
153
+ if (config.sessionCreator) {
154
+ const sid = await config.sessionCreator(session, requestMeta(c, clientIpResolver));
155
+ sessionForJwt = { ...session, sid };
156
+ }
157
+ const token = await jwt.sign(sessionForJwt);
158
+ const csrfToken = generateToken();
159
+ setAuthCookies(c, {
160
+ cookies: getSessionCookies(c, sessionCookies),
161
+ token,
162
+ csrfToken,
163
+ sameSite: cookieSameSite,
164
+ domain: cookieDomain,
165
+ retiredDomains: retiredCookieDomains,
166
+ ttlSeconds: jwt.ttlSeconds,
167
+ });
168
+ return token;
169
+ }
170
+ // Spreadable landingPath fragment for the 7 auth-response call sites below
171
+ // — absent (not `landingPath: undefined`) when unwired/invalid/throwing.
172
+ function landingPathFragment(args) {
173
+ const landingPath = resolvePostAuthLandingPath(config.postAuthLanding, args);
174
+ return landingPath === undefined ? {} : { landingPath };
175
+ }
176
+ return {
177
+ api,
178
+ dispatcher,
179
+ jwt,
180
+ config,
181
+ cookieSameSite,
182
+ cookieDomain,
183
+ sessionCookies,
184
+ retiredCookieDomains,
185
+ clientIpResolver,
186
+ getClientIp,
187
+ switchTenantMfaGate,
188
+ resolveSwitchTenantRoles,
189
+ mintSessionAndRespond,
190
+ landingPathFragment,
191
+ };
192
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuthRouteContext } from "./auth-routes-context.ts";
2
+ export declare function registerInviteRoutes(ctx: AuthRouteContext): void;
@@ -0,0 +1,171 @@
1
+ import * as z from "zod";
2
+ import { createAnonymousUser } from "../engine/system-user.js";
3
+ import { SYSTEM_TENANT_ID } from "../engine/types/index.js";
4
+ import { toKumikoError, toWriteErrorInfo } from "../errors/index.js";
5
+ import { Routes } from "./api-constants.js";
6
+ import { getUser } from "./auth-middleware.js";
7
+ import { pendingMfaStepResponse } from "./auth-routes-shared.js";
8
+ const InviteAcceptBody = z.object({
9
+ token: z.string().min(1),
10
+ });
11
+ const InviteAcceptWithLoginBody = z.object({
12
+ token: z.string().min(1),
13
+ email: z.string().email(),
14
+ password: z.string().min(8).max(200),
15
+ });
16
+ const InviteSignupCompleteBody = z.object({
17
+ token: z.string().min(1),
18
+ password: z.string().min(8).max(200),
19
+ });
20
+ const InviteInfoBody = z.object({
21
+ token: z.string().min(1),
22
+ });
23
+ export function registerInviteRoutes(ctx) {
24
+ const { api, dispatcher, config, mintSessionAndRespond, landingPathFragment } = ctx;
25
+ // Tenant-Invite magic link. 3 separate routes for 3 accept branches:
26
+ if (config.invite) {
27
+ const inv = config.invite;
28
+ // Branch 1: a logged-in user clicks the invite link → membership add in
29
+ // the invited tenant (NOT a tenant switch — the user stays in their
30
+ // current session and can switch later via the tenant switcher).
31
+ // Requires JWT (see PUBLIC_API_PATHS — invite-accept is NOT public,
32
+ // unlike acceptWithLogin/signupComplete).
33
+ api.post(Routes.authInviteAccept, async (c) => {
34
+ const user = getUser(c);
35
+ const raw = await c.req.json().catch(() => null);
36
+ const parsed = InviteAcceptBody.safeParse(raw);
37
+ if (!parsed.success) {
38
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
39
+ }
40
+ const result = await dispatcher.write(inv.acceptHandler, parsed.data, user);
41
+ if (!result.isSuccess) {
42
+ // @cast-boundary engine-payload — KumikoError.httpStatus
43
+ const status = result.error.httpStatus;
44
+ return c.json({ isSuccess: false, error: result.error }, status);
45
+ }
46
+ // @cast-boundary engine-payload — generic dispatcher.write result
47
+ const data = result.data;
48
+ const landingPath = landingPathFragment({
49
+ flow: "invite",
50
+ roles: [data.role],
51
+ tenantId: data.tenantId,
52
+ });
53
+ return c.json({
54
+ isSuccess: true,
55
+ tenantId: data.tenantId,
56
+ role: data.role,
57
+ alreadyMember: data.alreadyMember,
58
+ ...landingPath,
59
+ });
60
+ });
61
+ // Branch 2: anonymous user with an existing email — login + accept in a
62
+ // single round trip. JWT mint analogous to signup-confirm.
63
+ api.post(Routes.authInviteAcceptWithLogin, async (c) => {
64
+ const raw = await c.req.json().catch(() => null);
65
+ const parsed = InviteAcceptWithLoginBody.safeParse(raw);
66
+ if (!parsed.success) {
67
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
68
+ }
69
+ const result = await dispatcher.write(inv.acceptWithLoginHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
70
+ if (!result.isSuccess) {
71
+ const status = result.error.httpStatus; // @cast-boundary engine-payload
72
+ return c.json({ isSuccess: false, error: result.error }, status);
73
+ }
74
+ // @cast-boundary engine-payload — same three-shape union as /auth/login
75
+ // (see gateEnforceMfa): a straight session, an MFA challenge, or a
76
+ // hard mfa-setup-required block. Only the auth-session branch also
77
+ // carries tenantId/role (invite-specific).
78
+ const data = result.data;
79
+ if (data.kind === "mfa-setup-required") {
80
+ return c.json({
81
+ isSuccess: true,
82
+ mfaSetupRequired: true,
83
+ preauthSetupToken: data.preauthSetupToken,
84
+ });
85
+ }
86
+ if (data.kind === "mfa-challenge") {
87
+ return c.json({ isSuccess: true, mfaRequired: true, challengeToken: data.challengeToken });
88
+ }
89
+ const token = await mintSessionAndRespond(c, data.session);
90
+ const landingPath = landingPathFragment({
91
+ flow: "invite",
92
+ roles: data.session.roles,
93
+ tenantId: data.tenantId,
94
+ });
95
+ return c.json({
96
+ isSuccess: true,
97
+ token,
98
+ user: {
99
+ id: data.session.id,
100
+ tenantId: data.session.tenantId,
101
+ roles: data.session.roles,
102
+ },
103
+ tenantId: data.tenantId,
104
+ role: data.role,
105
+ ...landingPath,
106
+ });
107
+ });
108
+ // Branch 3: anonymous user with a new email — user + membership are created,
109
+ // NO new tenant. JWT mint.
110
+ api.post(Routes.authInviteSignupComplete, async (c) => {
111
+ const raw = await c.req.json().catch(() => null);
112
+ const parsed = InviteSignupCompleteBody.safeParse(raw);
113
+ if (!parsed.success) {
114
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
115
+ }
116
+ const result = await dispatcher.write(inv.signupCompleteHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID));
117
+ if (!result.isSuccess) {
118
+ const status = result.error.httpStatus; // @cast-boundary engine-payload
119
+ return c.json({ isSuccess: false, error: result.error }, status);
120
+ }
121
+ // @cast-boundary engine-payload — same three-shape union as invite-accept-with-login
122
+ const data = result.data;
123
+ if (data.kind !== "auth-session")
124
+ return pendingMfaStepResponse(c, data);
125
+ const token = await mintSessionAndRespond(c, data.session);
126
+ const landingPath = landingPathFragment({
127
+ flow: "invite",
128
+ roles: data.session.roles,
129
+ tenantId: data.tenantId,
130
+ });
131
+ return c.json({
132
+ isSuccess: true,
133
+ token,
134
+ user: {
135
+ id: data.session.id,
136
+ tenantId: data.session.tenantId,
137
+ roles: data.session.roles,
138
+ },
139
+ tenantId: data.tenantId,
140
+ role: data.role,
141
+ ...landingPath,
142
+ });
143
+ });
144
+ // Anonymous, read-only invite lookup for the acceptance page: pre-fill
145
+ // the invited email and pick Branch 2 (existing account) vs Branch 3
146
+ // (new account) before the user submits. Token is never consumed here.
147
+ if (inv.infoHandler) {
148
+ const infoHandler = inv.infoHandler;
149
+ api.post(Routes.authInviteInfo, async (c) => {
150
+ const raw = await c.req.json().catch(() => null);
151
+ const parsed = InviteInfoBody.safeParse(raw);
152
+ if (!parsed.success) {
153
+ return c.json({ isSuccess: false, error: "invalid_body" }, 400);
154
+ }
155
+ try {
156
+ // @cast-boundary engine-payload — generic dispatcher.query result
157
+ const data = (await dispatcher.query(infoHandler, parsed.data, createAnonymousUser(SYSTEM_TENANT_ID)));
158
+ return c.json({ isSuccess: true, ...data });
159
+ }
160
+ catch (e) {
161
+ const err = toKumikoError(e);
162
+ // Same body shape as the write routes' `{ isSuccess: false, error:
163
+ // result.error }` (WriteErrorInfo) — this is a query, not a write,
164
+ // so there's no WriteResult to read `.error` off; toWriteErrorInfo
165
+ // builds the equivalent shape directly from the caught error.
166
+ return c.json({ isSuccess: false, error: toWriteErrorInfo(err) }, err.httpStatus);
167
+ }
168
+ });
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,2 @@
1
+ import type { AuthRouteContext } from "./auth-routes-context.ts";
2
+ export declare function registerLoginRoute(ctx: AuthRouteContext): void;