@cosmicdrift/kumiko-framework 0.105.1 → 0.108.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. 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.105.1",
3
+ "version": "0.108.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.105.1",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.108.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, updateMany } from "../db/query";
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 updateMany(stack.db, userTable, { firstName: "RawDbChange" }, { id: id });
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 updateMany(
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, insertOne, selectMany } from "../../db/query";
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 insertOne(stack.db, itemTable, {
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, insertOne, selectMany } from "../../db/query";
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 insertOne(stack.db, itemTable, {
163
+ await seedRow(stack.db, itemTable, {
163
164
  id: generateId(),
164
165
  tenantId: admin.tenantId,
165
166
  name: "seed",
@@ -26,6 +26,17 @@ function forge(claims: Record<string, unknown>): Promise<string> {
26
26
  .sign(new TextEncoder().encode(SECRET));
27
27
  }
28
28
 
29
+ // Same as forge(), but never calls .setSubject() — the only way to produce a
30
+ // validly-signed token with no `sub` claim at all (forge() always sets one).
31
+ function forgeNoSub(claims: Record<string, unknown>): Promise<string> {
32
+ return new jose.SignJWT(claims)
33
+ .setProtectedHeader({ alg: "HS256" })
34
+ .setIssuer(ISSUER)
35
+ .setIssuedAt()
36
+ .setExpirationTime("1h")
37
+ .sign(new TextEncoder().encode(SECRET));
38
+ }
39
+
29
40
  describe("createJwtHelper.verify — payload validation (KF-2)", () => {
30
41
  const jwt = createJwtHelper(SECRET);
31
42
 
@@ -55,4 +66,20 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
55
66
  const token = await forge({ tenantId: TENANT, roles: ["ok", 7] });
56
67
  await expect(jwt.verify(token)).rejects.toThrow(/roles/);
57
68
  });
69
+
70
+ it("rejects a validly-signed token with no sub claim", async () => {
71
+ const token = await forgeNoSub({ tenantId: TENANT, roles: ["TenantAdmin"] });
72
+ await expect(jwt.verify(token)).rejects.toThrow(/sub/);
73
+ });
74
+
75
+ it("rejects a token whose sub claim is an empty string", async () => {
76
+ const token = await new jose.SignJWT({ tenantId: TENANT, roles: ["TenantAdmin"] })
77
+ .setProtectedHeader({ alg: "HS256" })
78
+ .setSubject("")
79
+ .setIssuer(ISSUER)
80
+ .setIssuedAt()
81
+ .setExpirationTime("1h")
82
+ .sign(new TextEncoder().encode(SECRET));
83
+ await expect(jwt.verify(token)).rejects.toThrow(/sub/);
84
+ });
58
85
  });
@@ -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.
@@ -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
- // Defence in depth: a valid signature does not guarantee well-formed claims. A
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");
@@ -69,9 +66,12 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
69
66
  }
70
67
  roles.push(role);
71
68
  }
69
+ if (typeof payload.sub !== "string" || payload.sub === "") {
70
+ throw new Error("JWT payload validation failed: sub claim is missing or malformed");
71
+ }
72
72
 
73
73
  const result: JwtPayload = {
74
- sub: String(payload.sub),
74
+ sub: payload.sub,
75
75
  tenantId,
76
76
  roles,
77
77
  };
@@ -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" as const,
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" as const,
5
+ const meta: EntityTableMeta = {
6
+ source: "unmanaged",
6
7
  tableName: "read_items",
7
8
  indexes: [],
8
9
  columns: [