@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.
- package/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.js +19 -19
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +8 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +2 -1
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/required-surface-keys.d.ts +16 -0
- package/dist/i18n/required-surface-keys.js +62 -0
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +44 -0
- package/dist/jobs/job-runner-helpers.js +265 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +10 -386
- package/dist/pipeline/dispatch-shared.js +29 -4
- package/dist/pipeline/event-dispatcher.js +2 -3
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +19 -0
package/agents/examples/i18n.md
CHANGED
|
@@ -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",
|
package/agents/features.md
CHANGED
|
@@ -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,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
|
+
}
|