@cosmicdrift/kumiko-framework 0.105.2 → 0.109.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 +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.109.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
4
|
-
import { selectMany
|
|
5
|
+
import { selectMany } from "../db/query";
|
|
5
6
|
import { defineFeature, type EntityId, type HandlerContext, type SaveContext } from "../engine";
|
|
6
7
|
import { UnprocessableError, writeFailure } from "../errors";
|
|
7
8
|
import { eventsTable } from "../event-store";
|
|
@@ -715,7 +716,7 @@ describe("full stack: entity cache", () => {
|
|
|
715
716
|
await stack.http.queryOk("users:query:user:detail", { id }, adminUser);
|
|
716
717
|
|
|
717
718
|
// Raw DB update — bypasses cache invalidation
|
|
718
|
-
await
|
|
719
|
+
await updateRows(stack.db, userTable, { firstName: "RawDbChange" }, { id: id });
|
|
719
720
|
|
|
720
721
|
// Detail still returns cached (old) value
|
|
721
722
|
const stale = await stack.http.queryOk<Record<string, unknown>>(
|
|
@@ -204,6 +204,35 @@ export const FEATURES = [];
|
|
|
204
204
|
expect(code).toBe(0);
|
|
205
205
|
expect(cap.log.join("\n")).toContain("feature configuration is valid");
|
|
206
206
|
});
|
|
207
|
+
|
|
208
|
+
test("FEATURES with a bad nav→screen ref → validateBoot fails, exit 1 (512/1)", async () => {
|
|
209
|
+
// Absolute file import (not the package name): appCwd is a bare tmpdir
|
|
210
|
+
// with no node_modules of its own — a real app resolves
|
|
211
|
+
// "@cosmicdrift/kumiko-framework/engine" via its own install, but this
|
|
212
|
+
// synthetic fixture needs a resolvable path regardless of cwd.
|
|
213
|
+
const engineSrc = join(import.meta.dir, "../engine/index.ts");
|
|
214
|
+
writeFileSync(
|
|
215
|
+
join(appCwd, "kumiko/schema.ts"),
|
|
216
|
+
`import { defineFeature } from "${engineSrc}";
|
|
217
|
+
export const ENTITY_METAS = [
|
|
218
|
+
{ tableName: "read_widgets", source: "unmanaged", indexes: [],
|
|
219
|
+
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true, defaultSql: "gen_random_uuid()" }] },
|
|
220
|
+
];
|
|
221
|
+
export const FEATURES = [
|
|
222
|
+
defineFeature("probe", (r) => {
|
|
223
|
+
r.nav({ id: "nav-ghost", label: "Ghost", screen: "probe:screen:ghost" });
|
|
224
|
+
}),
|
|
225
|
+
];
|
|
226
|
+
`,
|
|
227
|
+
);
|
|
228
|
+
await runSchemaCli(["generate", "init"], appCwd, captureOut().out);
|
|
229
|
+
const cap = captureOut();
|
|
230
|
+
const code = await runSchemaCli(["validate"], appCwd, cap.out);
|
|
231
|
+
expect(code).toBe(1);
|
|
232
|
+
expect(cap.err.join("\n")).toContain("boot:");
|
|
233
|
+
expect(cap.err.join("\n")).toContain("probe:screen:ghost");
|
|
234
|
+
expect(cap.err.join("\n")).toContain("is not registered");
|
|
235
|
+
});
|
|
207
236
|
});
|
|
208
237
|
|
|
209
238
|
describe("runSchemaCli — DB-backed paths", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
4
|
-
import { updateMany } from "../db/query";
|
|
5
5
|
import { buildEntityTable } from "../db/table-builder";
|
|
6
6
|
import {
|
|
7
7
|
createBooleanField,
|
|
@@ -268,7 +268,7 @@ describe("auto transition guard: per-entity transition map (cache key includes e
|
|
|
268
268
|
// Raw-DB-mark-deleted — we need a soft-deleted row whose status is a
|
|
269
269
|
// terminal state. If the guard fired, any status write would throw
|
|
270
270
|
// "Invalid transition: closed → <x>". We want it silently skipped.
|
|
271
|
-
await
|
|
271
|
+
await updateRows(
|
|
272
272
|
stack.db,
|
|
273
273
|
ticketTable,
|
|
274
274
|
{ status: "closed", isDeleted: true },
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
4
|
-
import { asRawClient,
|
|
5
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
5
6
|
import { buildEntityTable } from "../../db/table-builder";
|
|
6
7
|
import {
|
|
7
8
|
createEntity,
|
|
@@ -217,7 +218,7 @@ describe("POST /api/batch", () => {
|
|
|
217
218
|
|
|
218
219
|
test("mid-batch failure: all writes roll back, afterCommit hooks do NOT fire", async () => {
|
|
219
220
|
// Seed with one existing item so we can verify the batch didn't persist anything
|
|
220
|
-
await
|
|
221
|
+
await seedRow(stack.db, itemTable, {
|
|
221
222
|
name: "seed",
|
|
222
223
|
counter: 0,
|
|
223
224
|
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { createLiveDispatcher } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { generateToken } from "../../api/tokens";
|
|
5
6
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
6
|
-
import { asRawClient,
|
|
7
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
7
8
|
import { buildEntityTable } from "../../db/table-builder";
|
|
8
9
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
9
10
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
@@ -159,7 +160,7 @@ describe("dispatcher-live (integration) — full path against Kumiko server", ()
|
|
|
159
160
|
|
|
160
161
|
test("query: dispatches GET-style-POST (Kumiko uses POST for query too), returns data", async () => {
|
|
161
162
|
// Seed a row first.
|
|
162
|
-
await
|
|
163
|
+
await seedRow(stack.db, itemTable, {
|
|
163
164
|
id: generateId(),
|
|
164
165
|
tenantId: admin.tenantId,
|
|
165
166
|
name: "seed",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { patAllows, qnMatches } from "../pat-scope";
|
|
3
|
+
|
|
4
|
+
describe("qnMatches", () => {
|
|
5
|
+
it("exact match", () => {
|
|
6
|
+
expect(qnMatches("credit:query:schedule", "credit:query:schedule")).toBe(true);
|
|
7
|
+
expect(qnMatches("credit:query:schedule", "credit:query:credit:list")).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("wildcard suffix matches on prefix", () => {
|
|
11
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:create")).toBe(true);
|
|
12
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:bauspar:delete")).toBe(true);
|
|
13
|
+
expect(qnMatches("credit:write:bauspar:*", "credit:write:create")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("bare wildcard matches everything", () => {
|
|
17
|
+
expect(qnMatches("*", "anything:at:all")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("patAllows", () => {
|
|
22
|
+
const allowed = ["credit:write:create", "credit:write:bauspar:*"];
|
|
23
|
+
|
|
24
|
+
it("permits an exact or wildcard-covered type", () => {
|
|
25
|
+
expect(patAllows(allowed, "credit:write:create")).toBe(true);
|
|
26
|
+
expect(patAllows(allowed, "credit:write:bauspar:update")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("denies a type no scope covers", () => {
|
|
30
|
+
expect(patAllows(allowed, "credit:write:credit:delete")).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("fail-closed on an empty allow-list", () => {
|
|
34
|
+
expect(patAllows([], "credit:write:create")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -70,3 +70,54 @@ describe("requestIdMiddleware — signal propagation", () => {
|
|
|
70
70
|
expect(captured?.aborted).toBe(true);
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
|
+
describe("requestIdMiddleware — ip + userAgent capture (603/2)", () => {
|
|
74
|
+
test("populates ip from x-forwarded-for and userAgent from the User-Agent header", async () => {
|
|
75
|
+
let captured: { ip: string | undefined; userAgent: string | undefined } = {
|
|
76
|
+
ip: undefined,
|
|
77
|
+
userAgent: undefined,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const app = new Hono();
|
|
81
|
+
app.use("/probe", requestIdMiddleware());
|
|
82
|
+
app.get("/probe", (c) => {
|
|
83
|
+
const ctx = requestContext.get();
|
|
84
|
+
captured = { ip: ctx?.ip, userAgent: ctx?.userAgent };
|
|
85
|
+
return c.text("ok");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const res = await app.request(
|
|
89
|
+
new Request("http://test.local/probe", {
|
|
90
|
+
method: "GET",
|
|
91
|
+
headers: {
|
|
92
|
+
"x-forwarded-for": "203.0.113.7, 10.0.0.1",
|
|
93
|
+
"user-agent": "Mozilla/5.0 (probe-test)",
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(captured.ip).toBe("203.0.113.7");
|
|
100
|
+
expect(captured.userAgent).toBe("Mozilla/5.0 (probe-test)");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("no User-Agent header → userAgent stays undefined, not an empty string", async () => {
|
|
104
|
+
let captured: string | undefined = "unset";
|
|
105
|
+
|
|
106
|
+
const app = new Hono();
|
|
107
|
+
app.use("/probe", requestIdMiddleware());
|
|
108
|
+
app.get("/probe", (c) => {
|
|
109
|
+
captured = requestContext.get()?.userAgent;
|
|
110
|
+
return c.text("ok");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// fetch/undici always add a default User-Agent unless explicitly cleared —
|
|
114
|
+
// Hono's app.request accepts a raw Request, so an empty-but-present header
|
|
115
|
+
// simulates a client that sends the header with no value.
|
|
116
|
+
const res = await app.request(
|
|
117
|
+
new Request("http://test.local/probe", { method: "GET", headers: {} }),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(res.status).toBe(200);
|
|
121
|
+
expect(captured).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -16,6 +16,12 @@ export const AUTH_COOKIE_NAME = "kumiko_auth";
|
|
|
16
16
|
export const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
17
17
|
export const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
18
18
|
|
|
19
|
+
// Prefix that marks a bearer token as a long-lived Personal Access Token
|
|
20
|
+
// rather than a JWT session. The PAT feature mints tokens with this prefix;
|
|
21
|
+
// the middleware uses it to route to patResolver instead of jwt.verify. Kept
|
|
22
|
+
// here so both sides import the same literal.
|
|
23
|
+
export const PAT_TOKEN_PREFIX = "kpat_";
|
|
24
|
+
|
|
19
25
|
// Which wire the current request authenticated over. Downstream
|
|
20
26
|
// csrf-middleware reads this: cookie-auth gets a CSRF-token check, bearer
|
|
21
27
|
// does not (headers aren't set cross-origin by browsers, so there is no
|
|
@@ -41,11 +47,23 @@ export type AuthSessionChecker = (
|
|
|
41
47
|
expectedUserId: string,
|
|
42
48
|
) => Promise<AuthSessionStatus>;
|
|
43
49
|
|
|
50
|
+
// Resolves a raw Personal Access Token (bearer, prefixed PAT_TOKEN_PREFIX)
|
|
51
|
+
// into a SessionUser, or null when the token is unknown/revoked/expired. The
|
|
52
|
+
// PAT feature owns the DB-backed implementation: hash the token, look up the
|
|
53
|
+
// row, resolve the user's CURRENT roles live (not a snapshot), and expand the
|
|
54
|
+
// token's granted scopes into `pat.allowedQns`. Middleware just consults it
|
|
55
|
+
// and short-circuits the JWT path on a hit.
|
|
56
|
+
export type PatResolver = (rawToken: string) => Promise<SessionUser | null>;
|
|
57
|
+
|
|
44
58
|
export type AuthMiddlewareOptions = {
|
|
45
59
|
// Called after JWT-verify when the token carries a sid. If the checker
|
|
46
60
|
// reports anything other than "live", the request is rejected with 401.
|
|
47
61
|
// Omit to run in stateless-JWT mode (any valid JWT is accepted).
|
|
48
62
|
readonly sessionChecker?: AuthSessionChecker;
|
|
63
|
+
// Called for bearer tokens carrying the PAT prefix, BEFORE jwt.verify. On a
|
|
64
|
+
// hit the middleware sets the returned SessionUser and skips the JWT path
|
|
65
|
+
// entirely. Omit to disable PAT auth (bearer PATs then fail jwt.verify → 401).
|
|
66
|
+
readonly patResolver?: PatResolver;
|
|
49
67
|
// When true, a JWT WITHOUT a sid is rejected. Leave false during rollout
|
|
50
68
|
// so already-issued stateless JWTs keep working until they expire; flip
|
|
51
69
|
// to true once the server has been emitting sid for longer than the JWT
|
|
@@ -169,7 +187,7 @@ function extractToken(
|
|
|
169
187
|
}
|
|
170
188
|
|
|
171
189
|
export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
|
|
172
|
-
const { sessionChecker, strictMode = false, anonymousAccess } = options;
|
|
190
|
+
const { sessionChecker, strictMode = false, anonymousAccess, patResolver } = options;
|
|
173
191
|
|
|
174
192
|
return async (c: Context, next: Next) => {
|
|
175
193
|
const extracted = extractToken(c);
|
|
@@ -212,6 +230,13 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
212
230
|
}
|
|
213
231
|
const { token, transport } = extracted;
|
|
214
232
|
|
|
233
|
+
// PAT path: a bearer token carrying the PAT prefix is a long-lived
|
|
234
|
+
// Personal Access Token, not a JWT. Short-circuit the JWT path entirely.
|
|
235
|
+
// Cookie transport is never a PAT (the browser holds the JWT).
|
|
236
|
+
if (patResolver && transport === "bearer" && token.startsWith(PAT_TOKEN_PREFIX)) {
|
|
237
|
+
return await handlePat(c, patResolver, token, next);
|
|
238
|
+
}
|
|
239
|
+
|
|
215
240
|
let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
|
|
216
241
|
try {
|
|
217
242
|
payload = await jwt.verify(token);
|
|
@@ -277,6 +302,45 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
|
|
|
277
302
|
return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
|
|
278
303
|
}
|
|
279
304
|
|
|
305
|
+
// PAT request flow. Resolve the hashed token → SessionUser (live roles +
|
|
306
|
+
// granted scopes), then apply the same X-Tenant-mismatch guard as the JWT
|
|
307
|
+
// path before continuing. A null resolve is an invalid/revoked/expired token
|
|
308
|
+
// → 401. Structured like handleAnonymous so authMiddleware stays flat.
|
|
309
|
+
async function handlePat(
|
|
310
|
+
c: Context,
|
|
311
|
+
patResolver: PatResolver,
|
|
312
|
+
token: string,
|
|
313
|
+
next: Next,
|
|
314
|
+
): Promise<Response | undefined> {
|
|
315
|
+
const patUser = await patResolver(token);
|
|
316
|
+
if (!patUser) {
|
|
317
|
+
return middlewareReject(c, {
|
|
318
|
+
code: "invalid_token",
|
|
319
|
+
status: 401,
|
|
320
|
+
message: "personal access token invalid, revoked or expired",
|
|
321
|
+
i18nKey: "auth.errors.invalidToken",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
// The PAT carries its own tenant; an X-Tenant header pointing elsewhere is a
|
|
325
|
+
// confused client — reject loudly, same stance as the JWT path.
|
|
326
|
+
const headerTenant = c.req.header(TENANT_HEADER_NAME);
|
|
327
|
+
if (headerTenant !== undefined && headerTenant !== patUser.tenantId) {
|
|
328
|
+
return middlewareReject(c, {
|
|
329
|
+
code: "tenant_mismatch",
|
|
330
|
+
status: 400,
|
|
331
|
+
message: "PAT tenantId and X-Tenant header disagree",
|
|
332
|
+
i18nKey: "auth.errors.tenantMismatch",
|
|
333
|
+
details: { patTenantId: patUser.tenantId, headerTenantId: headerTenant },
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
c.set(USER_KEY, patUser);
|
|
337
|
+
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
338
|
+
await next();
|
|
339
|
+
// skip: PAT path completed — next() ran; explicit return keeps the
|
|
340
|
+
// Response|undefined union honest (same as handleAnonymous).
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
280
344
|
// Anonymous request flow. Steps:
|
|
281
345
|
// 1. Read raw client-supplied tenant from X-Tenant header / cookie.
|
|
282
346
|
// 2. Validate format (UUID-shape) — junk strings get 400 right here.
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type AuthSessionStatus,
|
|
16
16
|
CSRF_COOKIE_NAME,
|
|
17
17
|
getUser,
|
|
18
|
+
type PatResolver,
|
|
18
19
|
} from "./auth-middleware";
|
|
19
20
|
import type { JwtHelper } from "./jwt";
|
|
20
21
|
import { generateToken } from "./tokens";
|
|
@@ -232,6 +233,16 @@ export type AuthRoutesConfig = {
|
|
|
232
233
|
// once all fresh JWTs emit a sid and the legacy stateless tokens are
|
|
233
234
|
// expected to have expired. Default false keeps old tokens working.
|
|
234
235
|
sessionStrictMode?: boolean;
|
|
236
|
+
// Resolves bearer Personal Access Tokens (PAT_TOKEN_PREFIX) into a
|
|
237
|
+
// SessionUser, consulted BEFORE jwt.verify. Wired by the
|
|
238
|
+
// personal-access-tokens feature; unwired = PAT auth disabled.
|
|
239
|
+
patResolver?: PatResolver;
|
|
240
|
+
// Per-token request-rate limiter for PAT-authenticated requests, keyed by
|
|
241
|
+
// the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
|
|
242
|
+
// Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
|
|
243
|
+
// Wired by run-prod-app when the PAT feature is mounted; unwired = no PAT
|
|
244
|
+
// rate limiting.
|
|
245
|
+
patRateLimiter?: LoginRateLimiter;
|
|
235
246
|
// Password-reset flow. When wired, POST /auth/request-password-reset and
|
|
236
247
|
// POST /auth/reset-password are mounted as public routes. The framework
|
|
237
248
|
// dispatches to the feature-level handlers (authoring QNs typically come
|
package/src/api/index.ts
CHANGED
|
@@ -5,10 +5,11 @@ export type {
|
|
|
5
5
|
AuthMiddlewareOptions,
|
|
6
6
|
AuthSessionChecker,
|
|
7
7
|
AuthSessionStatus,
|
|
8
|
+
PatResolver,
|
|
8
9
|
TenantExists,
|
|
9
10
|
TenantResolver,
|
|
10
11
|
} from "./auth-middleware";
|
|
11
|
-
export { authMiddleware, getUser } from "./auth-middleware";
|
|
12
|
+
export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
|
|
12
13
|
export type {
|
|
13
14
|
AuthRoutesConfig,
|
|
14
15
|
LoginRateLimiter,
|
|
@@ -30,6 +31,7 @@ export {
|
|
|
30
31
|
} from "./http-cache";
|
|
31
32
|
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
32
33
|
export { createJwtHelper } from "./jwt";
|
|
34
|
+
export { patAllows, qnMatches } from "./pat-scope";
|
|
33
35
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
34
36
|
export { requestIdMiddleware } from "./request-id-middleware";
|
|
35
37
|
export { createApiRoutes } from "./routes";
|
package/src/api/jwt.ts
CHANGED
|
@@ -50,10 +50,7 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
50
50
|
async verify(token) {
|
|
51
51
|
const { payload } = await jose.jwtVerify(token, encodedSecret, { issuer });
|
|
52
52
|
|
|
53
|
-
//
|
|
54
|
-
// leaked secret, key confusion, or a hand-crafted token can still carry junk —
|
|
55
|
-
// validate the claim shape and reject (verify() throws → 401 in auth-middleware)
|
|
56
|
-
// instead of casting blindly.
|
|
53
|
+
// defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
|
|
57
54
|
const tenantId = parseTenantId(payload["tenantId"]);
|
|
58
55
|
if (tenantId === null) {
|
|
59
56
|
throw new Error("JWT payload validation failed: tenantId claim is missing or malformed");
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// QN scope-matching for Personal Access Tokens. A token's granted scopes
|
|
2
|
+
// expand (in the PAT feature's resolver) to QN globs like "credit:write:*" or
|
|
3
|
+
// "credit:query:credit:list". A glob ending in "*" matches any dispatch type
|
|
4
|
+
// sharing the prefix; otherwise it is an exact match. Fail-closed: an empty
|
|
5
|
+
// allow-list matches nothing, so a PAT with no scopes can call nothing.
|
|
6
|
+
|
|
7
|
+
export function qnMatches(pattern: string, type: string): boolean {
|
|
8
|
+
if (pattern.endsWith("*")) return type.startsWith(pattern.slice(0, -1));
|
|
9
|
+
return pattern === type;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function patAllows(allowedQns: readonly string[], type: string): boolean {
|
|
13
|
+
return allowedQns.some((pattern) => qnMatches(pattern, type));
|
|
14
|
+
}
|
|
@@ -30,6 +30,9 @@ export type RequestContextData = {
|
|
|
30
30
|
// Populated by requestIdMiddleware from x-forwarded-for or the
|
|
31
31
|
// socket address. Undefined for non-HTTP entry points (jobs, MSP).
|
|
32
32
|
readonly ip?: string;
|
|
33
|
+
// Raw User-Agent header — audit trails (download tokens, GDPR export
|
|
34
|
+
// access) want it alongside `ip`. Undefined for non-HTTP entry points.
|
|
35
|
+
readonly userAgent?: string;
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -37,12 +37,14 @@ export function requestIdMiddleware() {
|
|
|
37
37
|
// than fabricate one.
|
|
38
38
|
const xff = c.req.header("x-forwarded-for");
|
|
39
39
|
const ip = xff?.split(",")[0]?.trim();
|
|
40
|
+
const userAgent = c.req.header("user-agent");
|
|
40
41
|
await requestContext.run(
|
|
41
42
|
{
|
|
42
43
|
requestId,
|
|
43
44
|
correlationId,
|
|
44
45
|
...(signal ? { signal } : {}),
|
|
45
46
|
...(ip && ip.length > 0 ? { ip } : {}),
|
|
47
|
+
...(userAgent !== undefined ? { userAgent } : {}),
|
|
46
48
|
},
|
|
47
49
|
() => next(),
|
|
48
50
|
);
|
package/src/api/routes.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
2
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
|
+
import type { SessionUser } from "../engine/types/handlers";
|
|
3
4
|
import {
|
|
5
|
+
AccessDeniedError,
|
|
4
6
|
type KumikoError,
|
|
5
7
|
reraiseAsKumikoError,
|
|
6
8
|
serializeError,
|
|
@@ -12,6 +14,7 @@ import type { Dispatcher } from "../pipeline/dispatcher";
|
|
|
12
14
|
import { stringifyJson } from "../utils/safe-json";
|
|
13
15
|
import { Routes } from "./api-constants";
|
|
14
16
|
import { getUser } from "./auth-middleware";
|
|
17
|
+
import { patAllows } from "./pat-scope";
|
|
15
18
|
import { requestContext } from "./request-context";
|
|
16
19
|
|
|
17
20
|
export function createApiRoutes(dispatcher: Dispatcher) {
|
|
@@ -22,6 +25,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
22
25
|
const body = await c.req.json<{ type: string; payload: unknown; requestId?: string }>();
|
|
23
26
|
|
|
24
27
|
try {
|
|
28
|
+
assertPatAllowed(user, body.type);
|
|
25
29
|
const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
|
|
26
30
|
if (!result.isSuccess) {
|
|
27
31
|
return writeErrorResponse(c, reraiseAsKumikoError(result.error), body.type);
|
|
@@ -59,6 +63,9 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
try {
|
|
66
|
+
if (user.pat) {
|
|
67
|
+
for (const cmd of body.commands) assertPatAllowed(user, cmd.type);
|
|
68
|
+
}
|
|
62
69
|
const result = await dispatcher.batch(body.commands, user, body.requestId);
|
|
63
70
|
if (!result.isSuccess) {
|
|
64
71
|
const err = reraiseAsKumikoError(result.error);
|
|
@@ -92,6 +99,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
92
99
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
93
100
|
|
|
94
101
|
try {
|
|
102
|
+
assertPatAllowed(user, body.type);
|
|
95
103
|
const result = await dispatcher.query(body.type, body.payload, user);
|
|
96
104
|
return jsonResponse(c, { data: result });
|
|
97
105
|
} catch (e) {
|
|
@@ -104,6 +112,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
104
112
|
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
105
113
|
|
|
106
114
|
try {
|
|
115
|
+
assertPatAllowed(user, body.type);
|
|
107
116
|
await dispatcher.command(body.type, body.payload, user);
|
|
108
117
|
return c.json({ ok: true }, 202);
|
|
109
118
|
} catch (e) {
|
|
@@ -120,6 +129,19 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
|
|
|
120
129
|
|
|
121
130
|
const toKumiko = toKumikoError;
|
|
122
131
|
|
|
132
|
+
// PAT scope enforcement at the API boundary. No-op for cookie/JWT users
|
|
133
|
+
// (user.pat undefined → unrestricted). For a PAT-authenticated request the
|
|
134
|
+
// dispatch type must match one of the token's granted-scope QN globs, else
|
|
135
|
+
// 403 — fail-closed, thrown so each route's existing catch shapes the body.
|
|
136
|
+
function assertPatAllowed(user: SessionUser, type: string): void {
|
|
137
|
+
if (user.pat && !patAllows(user.pat.allowedQns, type)) {
|
|
138
|
+
throw new AccessDeniedError({
|
|
139
|
+
message: `personal access token scope does not permit ${type}`,
|
|
140
|
+
details: { handler: type, scopes: user.pat.scopes },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
123
145
|
// Unexpected server faults (5xx) carry their diagnostic stack only on the
|
|
124
146
|
// in-process error — serializeError strips cause/details from the wire body.
|
|
125
147
|
// Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
|
package/src/api/server.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
import type { SearchAdapter } from "../search/types";
|
|
47
47
|
import { assertUnreachable, generateId } from "../utils";
|
|
48
48
|
import { PUBLIC_API_PATHS } from "./api-constants";
|
|
49
|
-
import { type AnonymousAccessConfig, authMiddleware } from "./auth-middleware";
|
|
49
|
+
import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
|
|
50
50
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
51
51
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
52
52
|
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
@@ -561,6 +561,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
561
561
|
const jwtGuard = authMiddleware(jwt, {
|
|
562
562
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
563
563
|
...(options.auth?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
|
|
564
|
+
...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
|
|
564
565
|
...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
|
|
565
566
|
});
|
|
566
567
|
app.use("/api/*", async (c, next) => {
|
|
@@ -568,6 +569,33 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
568
569
|
return jwtGuard(c, next);
|
|
569
570
|
});
|
|
570
571
|
|
|
572
|
+
// PAT rate limiting — runs AFTER the auth guard so the resolved principal is
|
|
573
|
+
// available. Only PAT-authenticated requests are counted (keyed by token id);
|
|
574
|
+
// cookie/JWT users pass through untouched. In-memory limiter is per-instance
|
|
575
|
+
// (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
|
|
576
|
+
// in a Redis-backed LoginRateLimiter.
|
|
577
|
+
const patRateLimiter = options.auth?.patRateLimiter;
|
|
578
|
+
if (patRateLimiter) {
|
|
579
|
+
app.use("/api/*", async (c, next) => {
|
|
580
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
581
|
+
const pat = getUser(c)?.pat;
|
|
582
|
+
if (pat && !(await patRateLimiter.check(pat.tokenId))) {
|
|
583
|
+
return c.json(
|
|
584
|
+
{
|
|
585
|
+
error: {
|
|
586
|
+
code: "pat_rate_limited",
|
|
587
|
+
httpStatus: 429,
|
|
588
|
+
message: "personal access token rate limit exceeded",
|
|
589
|
+
i18nKey: "auth.errors.patRateLimited",
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
429,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return next();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
571
599
|
// Origin-allowlist guard — additional CSRF-hardening for deployments that
|
|
572
600
|
// widen the auth cookie across subdomains (auth.cookieDomain). Registered
|
|
573
601
|
// only when allowedOrigins is non-empty, with the same /api/* + public-skip
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
2
3
|
import { deleteManyBatched } from "../query";
|
|
3
4
|
|
|
4
5
|
describe("deleteManyBatched (mock)", () => {
|
|
5
6
|
test("requires non-empty where", async () => {
|
|
6
|
-
const meta = {
|
|
7
|
-
source: "unmanaged"
|
|
7
|
+
const meta: EntityTableMeta = {
|
|
8
|
+
source: "unmanaged",
|
|
8
9
|
tableName: "read_items",
|
|
9
10
|
indexes: [],
|
|
10
11
|
columns: [
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
2
3
|
import { incrementCounter, selectMany } from "../query";
|
|
3
4
|
|
|
4
|
-
const meta = {
|
|
5
|
-
source: "unmanaged"
|
|
5
|
+
const meta: EntityTableMeta = {
|
|
6
|
+
source: "unmanaged",
|
|
6
7
|
tableName: "read_items",
|
|
7
8
|
indexes: [],
|
|
8
9
|
columns: [
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Type-level contract for the ES-write brand (#742): the public write helpers
|
|
2
|
+
// reject a managed EntityTable at COMPILE time (executor-only), while reads on
|
|
3
|
+
// it and writes on unmanaged tables stay allowed. The bodies below are never
|
|
4
|
+
// invoked — tsc checks them, bun:test does not run them. Each `@ts-expect-error`
|
|
5
|
+
// turns into an "unused directive" compile error if the brand ever stops firing,
|
|
6
|
+
// so a regression breaks the build, not just this test.
|
|
7
|
+
|
|
8
|
+
import { expect, test } from "bun:test";
|
|
9
|
+
import { defineUnmanagedTable } from "../../db/entity-table-meta";
|
|
10
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
11
|
+
import { createEntity, createTextField } from "../../engine";
|
|
12
|
+
import { type AnyDb, deleteMany, insertOne, selectMany, updateMany } from "../query";
|
|
13
|
+
|
|
14
|
+
const brandedEntity = buildEntityTable(
|
|
15
|
+
"brandSample",
|
|
16
|
+
createEntity({ table: "brand_sample", fields: { title: createTextField({ required: true }) } }),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const unmanagedTable = defineUnmanagedTable({
|
|
20
|
+
tableName: "brand_unmanaged",
|
|
21
|
+
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
async function _writeRejectsManagedEntity(db: AnyDb): Promise<void> {
|
|
25
|
+
// @ts-expect-error — managed EntityTable is executor-only; direct insert is a compile error.
|
|
26
|
+
await insertOne(db, brandedEntity, { title: "x" });
|
|
27
|
+
// @ts-expect-error — direct update on a managed EntityTable is rejected.
|
|
28
|
+
await updateMany(db, brandedEntity, { title: "y" }, { id: "1" });
|
|
29
|
+
// @ts-expect-error — direct delete on a managed EntityTable is rejected.
|
|
30
|
+
await deleteMany(db, brandedEntity, { id: "1" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function _writeAllowsUnmanagedTable(db: AnyDb): Promise<void> {
|
|
34
|
+
// Unmanaged tables carry no brand — direct writes stay allowed.
|
|
35
|
+
await insertOne(db, unmanagedTable, { id: "1" });
|
|
36
|
+
await deleteMany(db, unmanagedTable, { id: "1" });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function _readAllowsManagedEntity(db: AnyDb): Promise<void> {
|
|
40
|
+
// Reads on a managed EntityTable are always fine (reads keep the permissive param).
|
|
41
|
+
await selectMany(db, brandedEntity, { id: "1" });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("ES-write brand: compile-time contracts are wired", () => {
|
|
45
|
+
expect(_writeRejectsManagedEntity).toBeDefined();
|
|
46
|
+
expect(_writeAllowsUnmanagedTable).toBeDefined();
|
|
47
|
+
expect(_readAllowsManagedEntity).toBeDefined();
|
|
48
|
+
});
|