@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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/package.json +8 -4
- package/src/__tests__/schema-cli.integration.test.ts +11 -0
- package/src/__tests__/upgrade-cli.test.ts +50 -3
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
- package/src/api/__tests__/client-ip.test.ts +164 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
- package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
- package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
- package/src/api/__tests__/post-auth-landing.test.ts +100 -0
- package/src/api/__tests__/request-id-middleware.test.ts +35 -2
- package/src/api/__tests__/schema-route.integration.test.ts +157 -0
- package/src/api/api-constants.ts +2 -0
- package/src/api/auth-routes.ts +92 -95
- package/src/api/client-ip.ts +118 -0
- package/src/api/extra-route.ts +3 -0
- package/src/api/index.ts +17 -0
- package/src/api/pat-route-guard.ts +51 -0
- package/src/api/post-auth-landing.ts +105 -0
- package/src/api/request-context.ts +4 -2
- package/src/api/request-id-middleware.ts +44 -14
- package/src/api/server.ts +130 -40
- package/src/changes.json +121 -0
- package/src/db/queries/event-store-transfer.ts +73 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
- package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
- package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
- package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
- package/src/engine/boot-validator/pii-retention.ts +18 -2
- package/src/engine/boot-validator/screens.ts +75 -18
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
- package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
- package/src/engine/feature-ast/extractors/index.ts +4 -0
- package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
- package/src/engine/feature-ast/extractors/shared.ts +26 -19
- package/src/engine/feature-ast/parse.ts +13 -20
- package/src/engine/index.ts +1 -0
- package/src/engine/project-app-schema-for-roles.ts +950 -0
- package/src/engine/steps/webhook-send.ts +4 -2
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/index.ts +5 -1
- package/src/entrypoint/index.ts +14 -1
- package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
- package/src/event-store/index.ts +6 -0
- package/src/event-store/transfer.ts +84 -0
- package/src/files/__tests__/files.integration.test.ts +105 -1
- package/src/files/file-routes.ts +54 -2
- package/src/files/index.ts +1 -1
- package/src/http/index.ts +10 -2
- package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
- package/src/jobs/job-runner.ts +6 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
- package/src/rate-limit/middleware.ts +32 -8
- package/src/schema-cli.ts +5 -1
- package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
- package/src/stack/push-entity-projection-tables.ts +52 -7
- package/src/stack/table-helpers.ts +17 -9
- package/src/stack/test-stack.ts +28 -1
- package/src/testing/real-providers.ts +2 -0
- package/src/ui-types/index.ts +1 -1
- package/src/upgrade-cli.ts +58 -18
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isSafeLandingPath,
|
|
4
|
+
type PostAuthLandingArgs,
|
|
5
|
+
type PostAuthLandingResolver,
|
|
6
|
+
resolvePostAuthLandingPath,
|
|
7
|
+
} from "../post-auth-landing";
|
|
8
|
+
|
|
9
|
+
describe("isSafeLandingPath", () => {
|
|
10
|
+
const rejected: unknown[] = [
|
|
11
|
+
"//evil.com",
|
|
12
|
+
"/\\evil.com",
|
|
13
|
+
"/a\\b",
|
|
14
|
+
"/\t/evil.com",
|
|
15
|
+
"/\n/evil.com",
|
|
16
|
+
" /a",
|
|
17
|
+
"https://evil.com",
|
|
18
|
+
"javascript:alert(1)",
|
|
19
|
+
"",
|
|
20
|
+
"a/b",
|
|
21
|
+
42,
|
|
22
|
+
undefined,
|
|
23
|
+
"/.//evil.com",
|
|
24
|
+
];
|
|
25
|
+
for (const candidate of rejected) {
|
|
26
|
+
test(`rejects ${JSON.stringify(candidate)}`, () => {
|
|
27
|
+
expect(isSafeLandingPath(candidate)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const accepted: string[] = ["/", "/a/profile", "/a/vehicle-detail/abc?x=1#h"];
|
|
32
|
+
for (const candidate of accepted) {
|
|
33
|
+
test(`accepts ${JSON.stringify(candidate)}`, () => {
|
|
34
|
+
expect(isSafeLandingPath(candidate)).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("resolvePostAuthLandingPath", () => {
|
|
40
|
+
const args: PostAuthLandingArgs = { flow: "login", roles: ["User"], tenantId: "t1" };
|
|
41
|
+
const realWarn = console.warn;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
console.warn = mock(() => {});
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
console.warn = realWarn;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("undefined resolver → undefined, no warning", () => {
|
|
51
|
+
expect(resolvePostAuthLandingPath(undefined, args)).toBeUndefined();
|
|
52
|
+
expect(console.warn).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("resolver returns undefined → undefined, no warning", () => {
|
|
56
|
+
expect(resolvePostAuthLandingPath(() => undefined, args)).toBeUndefined();
|
|
57
|
+
expect(console.warn).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("throwing resolver → undefined, logs a warning without the thrown value", () => {
|
|
61
|
+
const result = resolvePostAuthLandingPath(() => {
|
|
62
|
+
throw new Error("boom");
|
|
63
|
+
}, args);
|
|
64
|
+
expect(result).toBeUndefined();
|
|
65
|
+
expect(console.warn).toHaveBeenCalledTimes(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("throwing resolver → warning carries the error class, not the consumer message", () => {
|
|
69
|
+
resolvePostAuthLandingPath(() => {
|
|
70
|
+
throw new Error("secret-token-123");
|
|
71
|
+
}, args);
|
|
72
|
+
const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
|
|
73
|
+
expect(JSON.stringify(loggedArgs)).not.toContain("secret-token-123");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejecting async resolver → undefined, async warning, rejection observed", async () => {
|
|
77
|
+
// @cast-boundary test — simulates a JS consumer bypassing the sync signature
|
|
78
|
+
const asyncResolver = (async () => {
|
|
79
|
+
throw new Error("boom");
|
|
80
|
+
}) as unknown as PostAuthLandingResolver;
|
|
81
|
+
expect(resolvePostAuthLandingPath(asyncResolver, args)).toBeUndefined();
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
expect(console.warn).toHaveBeenCalledTimes(1);
|
|
84
|
+
const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
|
|
85
|
+
expect(String(loggedArgs?.[0])).toContain("async resolver not supported");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("unsafe path → undefined, warns without leaking the candidate", () => {
|
|
89
|
+
const result = resolvePostAuthLandingPath(() => "//evil.com", args);
|
|
90
|
+
expect(result).toBeUndefined();
|
|
91
|
+
expect(console.warn).toHaveBeenCalledTimes(1);
|
|
92
|
+
const loggedArgs = (console.warn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
|
|
93
|
+
expect(JSON.stringify(loggedArgs)).not.toContain("evil.com");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("valid path → the path, no warning", () => {
|
|
97
|
+
expect(resolvePostAuthLandingPath(() => "/a/profile", args)).toBe("/a/profile");
|
|
98
|
+
expect(console.warn).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -71,7 +71,11 @@ describe("requestIdMiddleware — signal propagation", () => {
|
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
73
|
describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
|
|
74
|
-
|
|
74
|
+
// Default trustedProxyHops=0 trusts no proxy header at all — the resolver
|
|
75
|
+
// ignores x-forwarded-for outright (a
|
|
76
|
+
// spoofable, client-controlled header) and falls back to the socket
|
|
77
|
+
// address, "unknown" here since app.request() has none.
|
|
78
|
+
test("default (trustedProxyHops=0): ignores x-forwarded-for, falls back to unknown", async () => {
|
|
75
79
|
let captured: { ip: string | undefined; userAgent: string | undefined } = {
|
|
76
80
|
ip: undefined,
|
|
77
81
|
userAgent: undefined,
|
|
@@ -96,7 +100,36 @@ describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
|
|
|
96
100
|
);
|
|
97
101
|
|
|
98
102
|
expect(res.status).toBe(200);
|
|
99
|
-
expect(captured.ip).toBe("
|
|
103
|
+
expect(captured.ip).toBe("unknown");
|
|
104
|
+
expect(captured.userAgent).toBe("Mozilla/5.0 (probe-test)");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("trustedProxyHops=1: populates ip from the last (trusted-proxy-appended) x-forwarded-for entry", async () => {
|
|
108
|
+
let captured: { ip: string | undefined; userAgent: string | undefined } = {
|
|
109
|
+
ip: undefined,
|
|
110
|
+
userAgent: undefined,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const app = new Hono();
|
|
114
|
+
app.use("/probe", requestIdMiddleware({ trustedProxyHops: 1 }));
|
|
115
|
+
app.get("/probe", (c) => {
|
|
116
|
+
const ctx = requestContext.get();
|
|
117
|
+
captured = { ip: ctx?.ip, userAgent: ctx?.userAgent };
|
|
118
|
+
return c.text("ok");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const res = await app.request(
|
|
122
|
+
new Request("http://test.local/probe", {
|
|
123
|
+
method: "GET",
|
|
124
|
+
headers: {
|
|
125
|
+
"x-forwarded-for": "203.0.113.7, 10.0.0.1",
|
|
126
|
+
"user-agent": "Mozilla/5.0 (probe-test)",
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
expect(captured.ip).toBe("10.0.0.1");
|
|
100
133
|
expect(captured.userAgent).toBe("Mozilla/5.0 (probe-test)");
|
|
101
134
|
});
|
|
102
135
|
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// kumiko-framework#3314: GET /api/schema is the only way a client gets the
|
|
2
|
+
// AppSchema now — HTML never carries it. anonymousAccess is deliberately
|
|
3
|
+
// wired here (like http-route-entry.integration.test.ts): without it, a
|
|
4
|
+
// wrongly-mounted route (jwtGuard skipping instead of rejecting) would
|
|
5
|
+
// still 401 on a missing token and mask the "anonymous user reaches the
|
|
6
|
+
// schema" bug the anonymous-401 test exists to catch.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { createEntity, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
11
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
12
|
+
import { Routes } from "../api-constants";
|
|
13
|
+
import { AUTH_COOKIE_NAME } from "../auth-middleware";
|
|
14
|
+
|
|
15
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
16
|
+
const SCHEMA_PATH = `/api${Routes.schema}`;
|
|
17
|
+
|
|
18
|
+
const schemaRouteFeature = defineFeature("schema-route-fixture", (r) => {
|
|
19
|
+
r.entity("widget", createEntity({ fields: {} }));
|
|
20
|
+
r.screen({
|
|
21
|
+
id: "widget-list",
|
|
22
|
+
type: "entityList",
|
|
23
|
+
entity: "widget",
|
|
24
|
+
columns: ["id"],
|
|
25
|
+
});
|
|
26
|
+
r.screen({
|
|
27
|
+
id: "admin-only",
|
|
28
|
+
type: "entityList",
|
|
29
|
+
entity: "widget",
|
|
30
|
+
columns: ["id"],
|
|
31
|
+
access: { roles: ["Admin"] },
|
|
32
|
+
});
|
|
33
|
+
r.nav({ id: "widget-list", label: "Widgets", screen: "widget-list" });
|
|
34
|
+
r.nav({ id: "admin-only", label: "Admin Only", screen: "admin-only" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let stack: TestStack;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
stack = await setupTestStack({
|
|
41
|
+
features: [schemaRouteFeature],
|
|
42
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(async () => {
|
|
47
|
+
await stack.cleanup();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("GET /api/schema", () => {
|
|
51
|
+
test("anonymous → 401, no schema leaked", async () => {
|
|
52
|
+
const res = await stack.app.request(SCHEMA_PATH);
|
|
53
|
+
expect(res.status).toBe(401);
|
|
54
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
55
|
+
expect(body.error.code).toBe("unauthenticated");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("signed-in user via Bearer token → 200 with the registry's schema, strong ETag, private/no-cache", async () => {
|
|
59
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
60
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
61
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
62
|
+
});
|
|
63
|
+
expect(res.status).toBe(200);
|
|
64
|
+
expect(res.headers.get("content-type")).toContain("application/json");
|
|
65
|
+
const cacheControl = res.headers.get("cache-control") ?? "";
|
|
66
|
+
expect(cacheControl).toContain("private");
|
|
67
|
+
expect(cacheControl).toContain("no-cache");
|
|
68
|
+
expect(cacheControl).not.toContain("public");
|
|
69
|
+
expect(cacheControl).not.toContain("no-store");
|
|
70
|
+
const etag = res.headers.get("etag");
|
|
71
|
+
expect(etag).toMatch(/^"/);
|
|
72
|
+
expect(etag).not.toMatch(/^W\//);
|
|
73
|
+
const body = (await res.json()) as { features: Array<{ featureName: string }> };
|
|
74
|
+
expect(body.features.length).toBeGreaterThan(0);
|
|
75
|
+
expect(body.features.some((f) => f.featureName === "schema-route-fixture")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("signed-in user via kumiko_auth cookie → 200", async () => {
|
|
79
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
80
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
81
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
const body = (await res.json()) as { features: Array<{ featureName: string }> };
|
|
85
|
+
expect(body.features.length).toBeGreaterThan(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("matching If-None-Match → 304 with no body, ETag kept", async () => {
|
|
89
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
90
|
+
const first = await stack.app.request(SCHEMA_PATH, {
|
|
91
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
92
|
+
});
|
|
93
|
+
const etag = first.headers.get("etag");
|
|
94
|
+
expect(etag).toBeTruthy();
|
|
95
|
+
|
|
96
|
+
const second = await stack.app.request(SCHEMA_PATH, {
|
|
97
|
+
headers: { Authorization: `Bearer ${token}`, "If-None-Match": etag ?? "" },
|
|
98
|
+
});
|
|
99
|
+
expect(second.status).toBe(304);
|
|
100
|
+
expect(await second.text()).toBe("");
|
|
101
|
+
expect(second.headers.get("etag")).toBe(etag);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("anonymous with a valid If-None-Match → still 401, not 304", async () => {
|
|
105
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
106
|
+
const authed = await stack.app.request(SCHEMA_PATH, {
|
|
107
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
108
|
+
});
|
|
109
|
+
const etag = authed.headers.get("etag");
|
|
110
|
+
expect(etag).toBeTruthy();
|
|
111
|
+
|
|
112
|
+
const res = await stack.app.request(SCHEMA_PATH, {
|
|
113
|
+
headers: { "If-None-Match": etag ?? "" },
|
|
114
|
+
});
|
|
115
|
+
expect(res.status).toBe(401);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("role projection: Admin sees the role-gated screen, User does not", async () => {
|
|
119
|
+
const adminToken = await stack.jwt.sign(TestUsers.admin);
|
|
120
|
+
const adminRes = await stack.app.request(SCHEMA_PATH, {
|
|
121
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
122
|
+
});
|
|
123
|
+
const adminBody = (await adminRes.json()) as {
|
|
124
|
+
features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
|
|
125
|
+
};
|
|
126
|
+
const adminFixture = adminBody.features.find((f) => f.featureName === "schema-route-fixture");
|
|
127
|
+
expect(adminFixture?.screens.map((s) => s.id).sort()).toEqual(["admin-only", "widget-list"]);
|
|
128
|
+
|
|
129
|
+
const userToken = await stack.jwt.sign(TestUsers.user);
|
|
130
|
+
const userRes = await stack.app.request(SCHEMA_PATH, {
|
|
131
|
+
headers: { Authorization: `Bearer ${userToken}` },
|
|
132
|
+
});
|
|
133
|
+
const userBody = (await userRes.json()) as {
|
|
134
|
+
features: Array<{ featureName: string; screens: Array<{ id: string }> }>;
|
|
135
|
+
};
|
|
136
|
+
const userFixture = userBody.features.find((f) => f.featureName === "schema-route-fixture");
|
|
137
|
+
expect(userFixture?.screens.map((s) => s.id)).toEqual(["widget-list"]);
|
|
138
|
+
|
|
139
|
+
// Different bodies must carry different (strong) ETags.
|
|
140
|
+
expect(adminRes.headers.get("etag")).not.toBe(userRes.headers.get("etag"));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("role projection: one role's ETag sent by a different role never 304s", async () => {
|
|
144
|
+
const adminToken = await stack.jwt.sign(TestUsers.admin);
|
|
145
|
+
const adminRes = await stack.app.request(SCHEMA_PATH, {
|
|
146
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
147
|
+
});
|
|
148
|
+
const adminEtag = adminRes.headers.get("etag");
|
|
149
|
+
expect(adminEtag).toBeTruthy();
|
|
150
|
+
|
|
151
|
+
const userToken = await stack.jwt.sign(TestUsers.user);
|
|
152
|
+
const userRes = await stack.app.request(SCHEMA_PATH, {
|
|
153
|
+
headers: { Authorization: `Bearer ${userToken}`, "If-None-Match": adminEtag ?? "" },
|
|
154
|
+
});
|
|
155
|
+
expect(userRes.status).toBe(200);
|
|
156
|
+
});
|
|
157
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -36,6 +36,7 @@ export const Routes = {
|
|
|
36
36
|
authInviteSignupComplete: "/auth/invite-signup-complete",
|
|
37
37
|
authInviteInfo: "/auth/invite-info",
|
|
38
38
|
files: "/files",
|
|
39
|
+
schema: "/schema",
|
|
39
40
|
} as const;
|
|
40
41
|
|
|
41
42
|
// Routes that must be reachable WITHOUT a valid JWT.
|
|
@@ -87,6 +88,7 @@ export const NON_PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
87
88
|
// invite-accept requires a JWT (Branch 1, see PUBLIC_API_PATHS above).
|
|
88
89
|
`/api${Routes.authInviteAccept}`,
|
|
89
90
|
`/api${Routes.files}`,
|
|
91
|
+
`/api${Routes.schema}`,
|
|
90
92
|
]);
|
|
91
93
|
|
|
92
94
|
// Opt-out from the default request-body-size cap (registerBodyLimit applies
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -26,7 +26,14 @@ import {
|
|
|
26
26
|
getUser,
|
|
27
27
|
type TokenVerifier,
|
|
28
28
|
} from "./auth-middleware";
|
|
29
|
+
import {
|
|
30
|
+
type ClientIpResolver,
|
|
31
|
+
clientIpSourceFromHonoContext,
|
|
32
|
+
createClientIpResolver,
|
|
33
|
+
} from "./client-ip";
|
|
29
34
|
import type { JwtHelper } from "./jwt";
|
|
35
|
+
import type { PostAuthLandingArgs, PostAuthLandingResolver } from "./post-auth-landing";
|
|
36
|
+
import { resolvePostAuthLandingPath } from "./post-auth-landing";
|
|
30
37
|
import { generateToken } from "./tokens";
|
|
31
38
|
|
|
32
39
|
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
@@ -441,15 +448,26 @@ export type AuthRoutesConfig = {
|
|
|
441
448
|
// `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
|
|
442
449
|
// derive the client IP for every auth rate-limiter (login, mfa-verify,
|
|
443
450
|
// preauth-enable-start, preauth-confirm) and for requestMeta's session
|
|
444
|
-
// IP
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
451
|
+
// IP via the shared resolveClientIp (see client-ip.ts for the extraction
|
|
452
|
+
// algorithm). Deprecated in favor of the
|
|
453
|
+
// top-level `trustedProxyHops` on ServerOptions/RunProdAppOptions/
|
|
454
|
+
// RunDevAppOptions, which wins when both are set — kept as a fallback so
|
|
455
|
+
// existing consumers that only set this don't regress. Default 0 = trust
|
|
456
|
+
// no proxy header, only the socket address (or "unknown") counts. Set
|
|
457
|
+
// this to your real proxy hop count (1 for a single ingress/reverse-
|
|
458
|
+
// proxy, 2 for edge-LB + ingress, etc.) when deployed behind a proxy.
|
|
452
459
|
trustedProxyHops?: number;
|
|
460
|
+
// Server-computed post-auth redirect, consulted at login, mfa-verify,
|
|
461
|
+
// mfa-preauth-confirm, signup-confirm, and all three invite-accept branches.
|
|
462
|
+
// Result lands in the response as `landingPath`; an invalid path or a
|
|
463
|
+
// throwing resolver just omits the field (see post-auth-landing.ts).
|
|
464
|
+
postAuthLanding?: PostAuthLandingResolver;
|
|
465
|
+
// Shared resolver instance from buildServer — takes precedence over
|
|
466
|
+
// `trustedProxyHops` so the whole server shares exactly one resolver
|
|
467
|
+
// instance (and warn-once flag) instead of building a private one here.
|
|
468
|
+
// Only standalone callers of createAuthRoutes
|
|
469
|
+
// (no buildServer) should rely on `trustedProxyHops` instead.
|
|
470
|
+
resolver?: ClientIpResolver;
|
|
453
471
|
};
|
|
454
472
|
|
|
455
473
|
export type PasswordResetConfig = {
|
|
@@ -504,69 +522,6 @@ export type SignupConfig = {
|
|
|
504
522
|
confirmHandler: string;
|
|
505
523
|
};
|
|
506
524
|
|
|
507
|
-
// Derives the caller IP from proxy headers, single source for the
|
|
508
|
-
// rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
|
|
509
|
-
// `x-forwarded-for` is attacker-controlled unless we know how many trusted
|
|
510
|
-
// proxy hops sit between the client and this process — `trustedProxyHops`
|
|
511
|
-
// (AuthRoutesConfig) is that count.
|
|
512
|
-
//
|
|
513
|
-
// hops === 0 (default): legacy behavior — trust the first XFF entry (or
|
|
514
|
-
// x-real-ip) at face value. Kept as the default so unconfigured
|
|
515
|
-
// deployments don't regress: collapsing everyone into a single "unknown"
|
|
516
|
-
// bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
|
|
517
|
-
// into a one-request-locks-out-everyone DoS, which is worse than the
|
|
518
|
-
// spoofing hole this issue is about. Apps behind a proxy MUST set this to
|
|
519
|
-
// close the hole — see AuthRoutesConfig.trustedProxyHops.
|
|
520
|
-
//
|
|
521
|
-
// hops >= 1: each trusted proxy appends (not overwrites) its peer address
|
|
522
|
-
// to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
|
|
523
|
-
// entries are proxy-supplied and everything before them — including the
|
|
524
|
-
// real client — sits at `entries[length - hops]`. Anything the client
|
|
525
|
-
// itself prepended lands further left and is ignored. If the header has
|
|
526
|
-
// fewer entries than `hops`, the proxy chain is shorter than configured
|
|
527
|
-
// (misconfiguration or a bypassed hop) — fall back to x-real-ip when present,
|
|
528
|
-
// else "unknown". x-real-ip has no hop-count semantics (so it isn't used for
|
|
529
|
-
// hop math), but a shared "unknown" bucket is worse than trusting the
|
|
530
|
-
// proxy-set header when XFF is absent (common nginx X-Real-IP-only setups).
|
|
531
|
-
//
|
|
532
|
-
// Callers that need a hard boundary regardless of this config
|
|
533
|
-
// (preauth-enable-start) additionally key on something the caller can't
|
|
534
|
-
// freely choose (see preauthTokenKeyOf below).
|
|
535
|
-
let warnedUnknownClientIp = false;
|
|
536
|
-
function clientIpOf(
|
|
537
|
-
c: { req: { header(name: string): string | undefined } },
|
|
538
|
-
trustedProxyHops = 0,
|
|
539
|
-
): string {
|
|
540
|
-
if (trustedProxyHops <= 0) {
|
|
541
|
-
return (
|
|
542
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
543
|
-
c.req.header("x-real-ip") ??
|
|
544
|
-
"unknown"
|
|
545
|
-
);
|
|
546
|
-
}
|
|
547
|
-
const entries = (c.req.header("x-forwarded-for") ?? "")
|
|
548
|
-
.split(",")
|
|
549
|
-
.map((entry) => entry.trim())
|
|
550
|
-
.filter((entry) => entry.length > 0);
|
|
551
|
-
if (entries.length < trustedProxyHops) {
|
|
552
|
-
// nginx often sets only X-Real-IP (no XFF). Prefer that over a shared
|
|
553
|
-
// "unknown" rate-limit bucket (fw#1555) — hop-count math still doesn't
|
|
554
|
-
// apply to X-Real-IP, but a shared bucket is worse than trusting the
|
|
555
|
-
// proxy-set header when the XFF chain is shorter than configured.
|
|
556
|
-
const realIp = c.req.header("x-real-ip")?.trim();
|
|
557
|
-
if (realIp) return realIp;
|
|
558
|
-
if (!warnedUnknownClientIp) {
|
|
559
|
-
warnedUnknownClientIp = true;
|
|
560
|
-
console.warn(
|
|
561
|
-
"[kumiko] trustedProxyHops>=1 but XFF chain too short and no x-real-ip — " +
|
|
562
|
-
'all such clients share the "unknown" rate-limit bucket. Check proxy headers.',
|
|
563
|
-
);
|
|
564
|
-
}
|
|
565
|
-
return "unknown";
|
|
566
|
-
}
|
|
567
|
-
return entries[entries.length - trustedProxyHops] ?? "unknown";
|
|
568
|
-
}
|
|
569
|
-
|
|
570
525
|
// Second rate-limit axis for preauth-enable-start: unlike the IP, a
|
|
571
526
|
// preauthSetupToken isn't freely choosable by the attacker (it's minted by
|
|
572
527
|
// login.write.ts), so hashing it into a bucket key still caps the replay
|
|
@@ -575,15 +530,12 @@ function preauthTokenKeyOf(token: string): string {
|
|
|
575
530
|
return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
|
|
576
531
|
}
|
|
577
532
|
|
|
578
|
-
// Extract `ip` and `user-agent` for the sessionCreator.
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
function requestMeta(
|
|
583
|
-
|
|
584
|
-
trustedProxyHops = 0,
|
|
585
|
-
): SessionMetadata {
|
|
586
|
-
const ip = clientIpOf(c, trustedProxyHops);
|
|
533
|
+
// Extract `ip` and `user-agent` for the sessionCreator. Client-IP resolution
|
|
534
|
+
// (trustedProxyHops-aware) is the shared resolveClientIp (see client-ip.ts)
|
|
535
|
+
// — createAuthRoutes builds one resolver instance below and
|
|
536
|
+
// closes over it here and in getClientIp.
|
|
537
|
+
function requestMeta(c: Context, resolver: ClientIpResolver): SessionMetadata {
|
|
538
|
+
const ip = resolver.resolve(clientIpSourceFromHonoContext(c));
|
|
587
539
|
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
588
540
|
return { ip, userAgent };
|
|
589
541
|
}
|
|
@@ -714,20 +666,14 @@ export function createAuthRoutes(
|
|
|
714
666
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
715
667
|
const cookieDomain = config.cookieDomain;
|
|
716
668
|
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
717
|
-
// see AuthRoutesConfig.trustedProxyHops
|
|
718
|
-
// loud on a non-finite/negative
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
config.trustedProxyHops
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
throw new Error(
|
|
726
|
-
`createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
const trustedProxyHops = config.trustedProxyHops ?? 0;
|
|
730
|
-
const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
|
|
669
|
+
// see AuthRoutesConfig.trustedProxyHops and client-ip.ts's resolveClientIp
|
|
670
|
+
// doc comment. createClientIpResolver fails loud on a non-finite/negative
|
|
671
|
+
// value rather than letting it silently collapse every request into the
|
|
672
|
+
// shared "unknown" bucket.
|
|
673
|
+
const clientIpResolver =
|
|
674
|
+
config.resolver ?? createClientIpResolver(config.trustedProxyHops ?? 0, "createAuthRoutes");
|
|
675
|
+
const getClientIp = (c: Context): string =>
|
|
676
|
+
clientIpResolver.resolve(clientIpSourceFromHonoContext(c));
|
|
731
677
|
|
|
732
678
|
// Shared tail of every route that ends a request logged-in: create the
|
|
733
679
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
@@ -737,7 +683,7 @@ export function createAuthRoutes(
|
|
|
737
683
|
async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
|
|
738
684
|
let sessionForJwt = session;
|
|
739
685
|
if (config.sessionCreator) {
|
|
740
|
-
const sid = await config.sessionCreator(session, requestMeta(c,
|
|
686
|
+
const sid = await config.sessionCreator(session, requestMeta(c, clientIpResolver));
|
|
741
687
|
sessionForJwt = { ...session, sid };
|
|
742
688
|
}
|
|
743
689
|
const token = await jwt.sign(sessionForJwt);
|
|
@@ -752,6 +698,13 @@ export function createAuthRoutes(
|
|
|
752
698
|
return token;
|
|
753
699
|
}
|
|
754
700
|
|
|
701
|
+
// Spreadable landingPath fragment for the 7 auth-response call sites below
|
|
702
|
+
// — absent (not `landingPath: undefined`) when unwired/invalid/throwing.
|
|
703
|
+
function landingPathFragment(args: PostAuthLandingArgs): { readonly landingPath?: string } {
|
|
704
|
+
const landingPath = resolvePostAuthLandingPath(config.postAuthLanding, args);
|
|
705
|
+
return landingPath === undefined ? {} : { landingPath };
|
|
706
|
+
}
|
|
707
|
+
|
|
755
708
|
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
756
709
|
// The configured login handler authenticates and returns a SessionUser;
|
|
757
710
|
// the route signs the JWT and hands it back to the client.
|
|
@@ -836,6 +789,11 @@ export function createAuthRoutes(
|
|
|
836
789
|
// that references it can be handed out, otherwise a fast client could
|
|
837
790
|
// arrive at an auth-middleware check before the insert commits.
|
|
838
791
|
const token = await mintSessionAndRespond(c, data.session);
|
|
792
|
+
const landingPath = landingPathFragment({
|
|
793
|
+
flow: "login",
|
|
794
|
+
roles: data.session.roles,
|
|
795
|
+
tenantId: data.session.tenantId,
|
|
796
|
+
});
|
|
839
797
|
|
|
840
798
|
if (rateLimiter) {
|
|
841
799
|
await rateLimiter.reset(rateLimitKey);
|
|
@@ -845,6 +803,7 @@ export function createAuthRoutes(
|
|
|
845
803
|
isSuccess: true,
|
|
846
804
|
token,
|
|
847
805
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
806
|
+
...landingPath,
|
|
848
807
|
});
|
|
849
808
|
});
|
|
850
809
|
}
|
|
@@ -907,6 +866,11 @@ export function createAuthRoutes(
|
|
|
907
866
|
const data = result.data as { kind: "mfa-verify-success"; session: SessionUser };
|
|
908
867
|
|
|
909
868
|
const token = await mintSessionAndRespond(c, data.session);
|
|
869
|
+
const landingPath = landingPathFragment({
|
|
870
|
+
flow: "login",
|
|
871
|
+
roles: data.session.roles,
|
|
872
|
+
tenantId: data.session.tenantId,
|
|
873
|
+
});
|
|
910
874
|
|
|
911
875
|
if (rateLimiter) {
|
|
912
876
|
await rateLimiter.reset(clientIp);
|
|
@@ -916,6 +880,7 @@ export function createAuthRoutes(
|
|
|
916
880
|
isSuccess: true,
|
|
917
881
|
token,
|
|
918
882
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
883
|
+
...landingPath,
|
|
919
884
|
});
|
|
920
885
|
});
|
|
921
886
|
}
|
|
@@ -1054,6 +1019,11 @@ export function createAuthRoutes(
|
|
|
1054
1019
|
const data = result.data as { kind: "mfa-preauth-confirm-success"; session: SessionUser };
|
|
1055
1020
|
|
|
1056
1021
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1022
|
+
const landingPath = landingPathFragment({
|
|
1023
|
+
flow: "login",
|
|
1024
|
+
roles: data.session.roles,
|
|
1025
|
+
tenantId: data.session.tenantId,
|
|
1026
|
+
});
|
|
1057
1027
|
|
|
1058
1028
|
if (rateLimiter) {
|
|
1059
1029
|
await rateLimiter.reset(clientIp);
|
|
@@ -1063,6 +1033,7 @@ export function createAuthRoutes(
|
|
|
1063
1033
|
isSuccess: true,
|
|
1064
1034
|
token,
|
|
1065
1035
|
user: { id: data.session.id, tenantId: data.session.tenantId, roles: data.session.roles },
|
|
1036
|
+
...landingPath,
|
|
1066
1037
|
});
|
|
1067
1038
|
});
|
|
1068
1039
|
}
|
|
@@ -1169,6 +1140,13 @@ export function createAuthRoutes(
|
|
|
1169
1140
|
|
|
1170
1141
|
// Session creation + JWT sign + cookies — see mintSessionAndRespond.
|
|
1171
1142
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1143
|
+
const landingPath = landingPathFragment({
|
|
1144
|
+
flow: "signup",
|
|
1145
|
+
roles: data.session.roles,
|
|
1146
|
+
tenantId: data.session.tenantId,
|
|
1147
|
+
tenantKey: data.tenantKey,
|
|
1148
|
+
...(data.handover !== undefined && { handover: data.handover }),
|
|
1149
|
+
});
|
|
1172
1150
|
|
|
1173
1151
|
return c.json({
|
|
1174
1152
|
isSuccess: true,
|
|
@@ -1189,6 +1167,7 @@ export function createAuthRoutes(
|
|
|
1189
1167
|
// absent, not null, so a client that doesn't know the field sees
|
|
1190
1168
|
// nothing unusual.
|
|
1191
1169
|
...(data.handover !== undefined && { handover: data.handover }),
|
|
1170
|
+
...landingPath,
|
|
1192
1171
|
});
|
|
1193
1172
|
});
|
|
1194
1173
|
}
|
|
@@ -1222,11 +1201,17 @@ export function createAuthRoutes(
|
|
|
1222
1201
|
role: string;
|
|
1223
1202
|
alreadyMember: boolean;
|
|
1224
1203
|
};
|
|
1204
|
+
const landingPath = landingPathFragment({
|
|
1205
|
+
flow: "invite",
|
|
1206
|
+
roles: [data.role],
|
|
1207
|
+
tenantId: data.tenantId,
|
|
1208
|
+
});
|
|
1225
1209
|
return c.json({
|
|
1226
1210
|
isSuccess: true,
|
|
1227
1211
|
tenantId: data.tenantId,
|
|
1228
1212
|
role: data.role,
|
|
1229
1213
|
alreadyMember: data.alreadyMember,
|
|
1214
|
+
...landingPath,
|
|
1230
1215
|
});
|
|
1231
1216
|
});
|
|
1232
1217
|
|
|
@@ -1269,6 +1254,11 @@ export function createAuthRoutes(
|
|
|
1269
1254
|
}
|
|
1270
1255
|
|
|
1271
1256
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1257
|
+
const landingPath = landingPathFragment({
|
|
1258
|
+
flow: "invite",
|
|
1259
|
+
roles: data.session.roles,
|
|
1260
|
+
tenantId: data.tenantId,
|
|
1261
|
+
});
|
|
1272
1262
|
return c.json({
|
|
1273
1263
|
isSuccess: true,
|
|
1274
1264
|
token,
|
|
@@ -1279,6 +1269,7 @@ export function createAuthRoutes(
|
|
|
1279
1269
|
},
|
|
1280
1270
|
tenantId: data.tenantId,
|
|
1281
1271
|
role: data.role,
|
|
1272
|
+
...landingPath,
|
|
1282
1273
|
});
|
|
1283
1274
|
});
|
|
1284
1275
|
|
|
@@ -1306,6 +1297,11 @@ export function createAuthRoutes(
|
|
|
1306
1297
|
role: string;
|
|
1307
1298
|
}; // @cast-boundary engine-payload
|
|
1308
1299
|
const token = await mintSessionAndRespond(c, data.session);
|
|
1300
|
+
const landingPath = landingPathFragment({
|
|
1301
|
+
flow: "invite",
|
|
1302
|
+
roles: data.session.roles,
|
|
1303
|
+
tenantId: data.tenantId,
|
|
1304
|
+
});
|
|
1309
1305
|
return c.json({
|
|
1310
1306
|
isSuccess: true,
|
|
1311
1307
|
token,
|
|
@@ -1316,6 +1312,7 @@ export function createAuthRoutes(
|
|
|
1316
1312
|
},
|
|
1317
1313
|
tenantId: data.tenantId,
|
|
1318
1314
|
role: data.role,
|
|
1315
|
+
...landingPath,
|
|
1319
1316
|
});
|
|
1320
1317
|
});
|
|
1321
1318
|
}
|