@cosmicdrift/kumiko-framework 0.113.1 → 1.0.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 (76) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/schema-cli.integration.test.ts +0 -29
  3. package/src/api/__tests__/batch.integration.test.ts +1 -4
  4. package/src/api/__tests__/request-id-middleware.test.ts +0 -51
  5. package/src/api/auth-middleware.ts +1 -65
  6. package/src/api/auth-routes.ts +0 -11
  7. package/src/api/index.ts +1 -3
  8. package/src/api/jwt.ts +4 -1
  9. package/src/api/request-context.ts +0 -3
  10. package/src/api/request-id-middleware.ts +0 -2
  11. package/src/api/routes.ts +1 -26
  12. package/src/api/server.ts +1 -29
  13. package/src/bun-db/__tests__/write-brand.test.ts +0 -21
  14. package/src/bun-db/query.ts +0 -17
  15. package/src/db/__tests__/config-seed.integration.test.ts +5 -13
  16. package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
  17. package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
  18. package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
  19. package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
  20. package/src/db/config-seed.ts +9 -9
  21. package/src/db/encryption.ts +0 -7
  22. package/src/db/entity-field-encryption.ts +24 -81
  23. package/src/db/event-store-executor.ts +24 -37
  24. package/src/db/index.ts +0 -3
  25. package/src/db/query.ts +0 -1
  26. package/src/db/tenant-db.ts +9 -14
  27. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
  28. package/src/engine/__tests__/boot-validator.test.ts +9 -33
  29. package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
  30. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
  31. package/src/engine/__tests__/registry.test.ts +0 -28
  32. package/src/engine/__tests__/screen.test.ts +0 -40
  33. package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
  34. package/src/engine/boot-validator/gdpr-storage.ts +1 -2
  35. package/src/engine/boot-validator/index.ts +2 -7
  36. package/src/engine/boot-validator/screens-nav.ts +0 -21
  37. package/src/engine/define-feature.ts +0 -17
  38. package/src/engine/define-handler.ts +2 -16
  39. package/src/engine/extensions/user-data.ts +1 -12
  40. package/src/engine/index.ts +2 -5
  41. package/src/engine/registry.ts +21 -44
  42. package/src/engine/soft-delete-cleanup.ts +2 -6
  43. package/src/engine/types/feature.ts +21 -29
  44. package/src/engine/types/fields.ts +1 -3
  45. package/src/engine/types/handlers.ts +2 -16
  46. package/src/engine/types/index.ts +0 -2
  47. package/src/engine/types/projection.ts +0 -22
  48. package/src/engine/types/screen.ts +0 -29
  49. package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
  50. package/src/event-store/index.ts +0 -8
  51. package/src/files/provider-resolver.ts +15 -28
  52. package/src/i18n/required-surface-keys.ts +0 -12
  53. package/src/observability/__tests__/observability.integration.test.ts +1 -4
  54. package/src/pipeline/dispatcher.ts +6 -2
  55. package/src/pipeline/msp-rebuild.ts +3 -36
  56. package/src/pipeline/projection-rebuild.ts +3 -61
  57. package/src/schema-cli.ts +15 -24
  58. package/src/secrets/__tests__/contains-secret.test.ts +0 -34
  59. package/src/secrets/dek-cache.ts +5 -26
  60. package/src/secrets/envelope.ts +3 -5
  61. package/src/secrets/index.ts +1 -13
  62. package/src/secrets/types.ts +3 -18
  63. package/src/testing/e2e-generator.ts +0 -3
  64. package/src/testing/index.ts +0 -2
  65. package/src/testing/mutable-master-key-provider.ts +3 -29
  66. package/src/time/__tests__/tz-dateline.test.ts +10 -8
  67. package/src/ui-types/index.ts +0 -1
  68. package/src/api/__tests__/pat-scope.test.ts +0 -36
  69. package/src/api/pat-scope.ts +0 -14
  70. package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
  71. package/src/event-store/rebuild-dead-letter.ts +0 -111
  72. package/src/files/__tests__/provider-resolver.test.ts +0 -70
  73. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
  74. package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
  75. package/src/secrets/envelope-cipher.ts +0 -81
  76. package/src/secrets/stored-envelope.ts +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.113.1",
3
+ "version": "1.0.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.113.1",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "1.0.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -204,35 +204,6 @@ 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
- });
236
207
  });
237
208
 
238
209
  describe("runSchemaCli — DB-backed paths", () => {
@@ -1,7 +1,6 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
3
  import { z } from "zod";
4
- import type { TableColumns } from "../../db/dialect";
5
4
  import { createEventStoreExecutor } from "../../db/event-store-executor";
6
5
  import { asRawClient, selectMany } from "../../db/query";
7
6
  import { buildEntityTable } from "../../db/table-builder";
@@ -37,9 +36,7 @@ const auditEntity = createEntity({
37
36
  itemId: createTextField({ required: true }),
38
37
  },
39
38
  });
40
- // Brand (#742) is compile-time-only; the postSave hook writes this sink via method-form,
41
- // so hold it at the unbranded TableColumns view (identical runtime shape).
42
- const auditTable: TableColumns = buildEntityTable("audit", auditEntity);
39
+ const auditTable = buildEntityTable("audit", auditEntity);
43
40
 
44
41
  // Hook invocation logs — reset per test. Captures which phase each hook saw.
45
42
  const inTxHookLog: Array<{ id: EntityId; name: string }> = [];
@@ -70,54 +70,3 @@ 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,12 +16,6 @@ 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
-
25
19
  // Which wire the current request authenticated over. Downstream
26
20
  // csrf-middleware reads this: cookie-auth gets a CSRF-token check, bearer
27
21
  // does not (headers aren't set cross-origin by browsers, so there is no
@@ -47,23 +41,11 @@ export type AuthSessionChecker = (
47
41
  expectedUserId: string,
48
42
  ) => Promise<AuthSessionStatus>;
49
43
 
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
-
58
44
  export type AuthMiddlewareOptions = {
59
45
  // Called after JWT-verify when the token carries a sid. If the checker
60
46
  // reports anything other than "live", the request is rejected with 401.
61
47
  // Omit to run in stateless-JWT mode (any valid JWT is accepted).
62
48
  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;
67
49
  // When true, a JWT WITHOUT a sid is rejected. Leave false during rollout
68
50
  // so already-issued stateless JWTs keep working until they expire; flip
69
51
  // to true once the server has been emitting sid for longer than the JWT
@@ -187,7 +169,7 @@ function extractToken(
187
169
  }
188
170
 
189
171
  export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
190
- const { sessionChecker, strictMode = false, anonymousAccess, patResolver } = options;
172
+ const { sessionChecker, strictMode = false, anonymousAccess } = options;
191
173
 
192
174
  return async (c: Context, next: Next) => {
193
175
  const extracted = extractToken(c);
@@ -230,13 +212,6 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
230
212
  }
231
213
  const { token, transport } = extracted;
232
214
 
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
-
240
215
  let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
241
216
  try {
242
217
  payload = await jwt.verify(token);
@@ -302,45 +277,6 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
302
277
  return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
303
278
  }
304
279
 
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
-
344
280
  // Anonymous request flow. Steps:
345
281
  // 1. Read raw client-supplied tenant from X-Tenant header / cookie.
346
282
  // 2. Validate format (UUID-shape) — junk strings get 400 right here.
@@ -15,7 +15,6 @@ import {
15
15
  type AuthSessionStatus,
16
16
  CSRF_COOKIE_NAME,
17
17
  getUser,
18
- type PatResolver,
19
18
  } from "./auth-middleware";
20
19
  import type { JwtHelper } from "./jwt";
21
20
  import { generateToken } from "./tokens";
@@ -233,16 +232,6 @@ export type AuthRoutesConfig = {
233
232
  // once all fresh JWTs emit a sid and the legacy stateless tokens are
234
233
  // expected to have expired. Default false keeps old tokens working.
235
234
  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;
246
235
  // Password-reset flow. When wired, POST /auth/request-password-reset and
247
236
  // POST /auth/reset-password are mounted as public routes. The framework
248
237
  // dispatches to the feature-level handlers (authoring QNs typically come
package/src/api/index.ts CHANGED
@@ -5,11 +5,10 @@ export type {
5
5
  AuthMiddlewareOptions,
6
6
  AuthSessionChecker,
7
7
  AuthSessionStatus,
8
- PatResolver,
9
8
  TenantExists,
10
9
  TenantResolver,
11
10
  } from "./auth-middleware";
12
- export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
11
+ export { authMiddleware, getUser } from "./auth-middleware";
13
12
  export type {
14
13
  AuthRoutesConfig,
15
14
  LoginRateLimiter,
@@ -31,7 +30,6 @@ export {
31
30
  } from "./http-cache";
32
31
  export type { JwtHelper, JwtPayload } from "./jwt";
33
32
  export { createJwtHelper } from "./jwt";
34
- export { patAllows, qnMatches } from "./pat-scope";
35
33
  export { type RequestContextData, requestContext } from "./request-context";
36
34
  export { requestIdMiddleware } from "./request-id-middleware";
37
35
  export { createApiRoutes } from "./routes";
package/src/api/jwt.ts CHANGED
@@ -50,7 +50,10 @@ 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: valid sig well-formed claims; malformed payload → throw → 401
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.
54
57
  const tenantId = parseTenantId(payload["tenantId"]);
55
58
  if (tenantId === null) {
56
59
  throw new Error("JWT payload validation failed: tenantId claim is missing or malformed");
@@ -30,9 +30,6 @@ 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;
36
33
  };
37
34
 
38
35
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -37,14 +37,12 @@ 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");
41
40
  await requestContext.run(
42
41
  {
43
42
  requestId,
44
43
  correlationId,
45
44
  ...(signal ? { signal } : {}),
46
45
  ...(ip && ip.length > 0 ? { ip } : {}),
47
- ...(userAgent !== undefined ? { userAgent } : {}),
48
46
  },
49
47
  () => next(),
50
48
  );
package/src/api/routes.ts CHANGED
@@ -1,8 +1,6 @@
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";
4
3
  import {
5
- AccessDeniedError,
6
4
  type KumikoError,
7
5
  reraiseAsKumikoError,
8
6
  serializeError,
@@ -14,7 +12,6 @@ import type { Dispatcher } from "../pipeline/dispatcher";
14
12
  import { stringifyJson } from "../utils/safe-json";
15
13
  import { Routes } from "./api-constants";
16
14
  import { getUser } from "./auth-middleware";
17
- import { patAllows } from "./pat-scope";
18
15
  import { requestContext } from "./request-context";
19
16
 
20
17
  export function createApiRoutes(dispatcher: Dispatcher) {
@@ -25,7 +22,6 @@ export function createApiRoutes(dispatcher: Dispatcher) {
25
22
  const body = await c.req.json<{ type: string; payload: unknown; requestId?: string }>();
26
23
 
27
24
  try {
28
- assertPatAllowed(user, body.type);
29
25
  const result = await dispatcher.write(body.type, body.payload, user, body.requestId);
30
26
  if (!result.isSuccess) {
31
27
  return writeErrorResponse(c, reraiseAsKumikoError(result.error), body.type);
@@ -63,9 +59,6 @@ export function createApiRoutes(dispatcher: Dispatcher) {
63
59
  }
64
60
 
65
61
  try {
66
- if (user.pat) {
67
- for (const cmd of body.commands) assertPatAllowed(user, cmd.type);
68
- }
69
62
  const result = await dispatcher.batch(body.commands, user, body.requestId);
70
63
  if (!result.isSuccess) {
71
64
  const err = reraiseAsKumikoError(result.error);
@@ -90,10 +83,7 @@ export function createApiRoutes(dispatcher: Dispatcher) {
90
83
  }
91
84
  return c.json(result);
92
85
  } catch (e) {
93
- // 615/1: pass the batch's command types, not undefined — a single
94
- // "type" doesn't apply to a batch, but logging none loses the fault
95
- // context entirely.
96
- return writeErrorResponse(c, toKumiko(e), body.commands?.map((cmd) => cmd.type).join(","));
86
+ return writeErrorResponse(c, toKumiko(e));
97
87
  }
98
88
  });
99
89
 
@@ -102,7 +92,6 @@ export function createApiRoutes(dispatcher: Dispatcher) {
102
92
  const body = await c.req.json<{ type: string; payload: unknown }>();
103
93
 
104
94
  try {
105
- assertPatAllowed(user, body.type);
106
95
  const result = await dispatcher.query(body.type, body.payload, user);
107
96
  return jsonResponse(c, { data: result });
108
97
  } catch (e) {
@@ -115,7 +104,6 @@ export function createApiRoutes(dispatcher: Dispatcher) {
115
104
  const body = await c.req.json<{ type: string; payload: unknown }>();
116
105
 
117
106
  try {
118
- assertPatAllowed(user, body.type);
119
107
  await dispatcher.command(body.type, body.payload, user);
120
108
  return c.json({ ok: true }, 202);
121
109
  } catch (e) {
@@ -132,19 +120,6 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
132
120
 
133
121
  const toKumiko = toKumikoError;
134
122
 
135
- // PAT scope enforcement at the API boundary. No-op for cookie/JWT users
136
- // (user.pat undefined → unrestricted). For a PAT-authenticated request the
137
- // dispatch type must match one of the token's granted-scope QN globs, else
138
- // 403 — fail-closed, thrown so each route's existing catch shapes the body.
139
- function assertPatAllowed(user: SessionUser, type: string): void {
140
- if (user.pat && !patAllows(user.pat.allowedQns, type)) {
141
- throw new AccessDeniedError({
142
- message: `personal access token scope does not permit ${type}`,
143
- details: { handler: type, scopes: user.pat.scopes },
144
- });
145
- }
146
- }
147
-
148
123
  // Unexpected server faults (5xx) carry their diagnostic stack only on the
149
124
  // in-process error — serializeError strips cause/details from the wire body.
150
125
  // 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, getUser } from "./auth-middleware";
49
+ import { type AnonymousAccessConfig, authMiddleware } 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,7 +561,6 @@ 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 } : {}),
565
564
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
566
565
  });
567
566
  app.use("/api/*", async (c, next) => {
@@ -569,33 +568,6 @@ export function buildServer(options: ServerOptions): KumikoServer {
569
568
  return jwtGuard(c, next);
570
569
  });
571
570
 
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
-
599
571
  // Origin-allowlist guard — additional CSRF-hardening for deployments that
600
572
  // widen the auth cookie across subdomains (auth.cookieDomain). Registered
601
573
  // only when allowedOrigins is non-empty, with the same /api/* + public-skip
@@ -8,7 +8,6 @@
8
8
  import { expect, test } from "bun:test";
9
9
  import { defineUnmanagedTable } from "../../db/entity-table-meta";
10
10
  import { buildEntityTable } from "../../db/table-builder";
11
- import type { TenantDb } from "../../db/tenant-db";
12
11
  import { createEntity, createTextField } from "../../engine";
13
12
  import { type AnyDb, deleteMany, insertOne, selectMany, updateMany } from "../query";
14
13
 
@@ -42,28 +41,8 @@ async function _readAllowsManagedEntity(db: AnyDb): Promise<void> {
42
41
  await selectMany(db, brandedEntity, { id: "1" });
43
42
  }
44
43
 
45
- // Method-form (ctx.db.insertOne/updateMany/deleteMany) rejects the brand too — a
46
- // projection written past its event stream is wiped on rebuild whether the write
47
- // went through the free function or the TenantDb method.
48
- async function _methodFormRejectsManagedEntity(db: TenantDb): Promise<void> {
49
- // @ts-expect-error — managed EntityTable is executor-only; method-form insert is a compile error.
50
- await db.insertOne(brandedEntity, { title: "x" });
51
- // @ts-expect-error — method-form update on a managed EntityTable is rejected.
52
- await db.updateMany(brandedEntity, { title: "y" }, { id: "1" });
53
- // @ts-expect-error — method-form delete on a managed EntityTable is rejected.
54
- await db.deleteMany(brandedEntity, { id: "1" });
55
- }
56
-
57
- async function _methodFormReadAllowsManagedEntity(db: TenantDb): Promise<void> {
58
- // Method-form reads on a managed EntityTable stay fine (reads keep the plain param).
59
- await db.selectMany(brandedEntity, { id: "1" });
60
- await db.fetchOne(brandedEntity, { id: "1" });
61
- }
62
-
63
44
  test("ES-write brand: compile-time contracts are wired", () => {
64
45
  expect(_writeRejectsManagedEntity).toBeDefined();
65
46
  expect(_writeAllowsUnmanagedTable).toBeDefined();
66
47
  expect(_readAllowsManagedEntity).toBeDefined();
67
- expect(_methodFormRejectsManagedEntity).toBeDefined();
68
- expect(_methodFormReadAllowsManagedEntity).toBeDefined();
69
48
  });
@@ -109,23 +109,6 @@ export function asRawClient(db: unknown): RawClient {
109
109
  );
110
110
  }
111
111
 
112
- // postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
113
- // it errored — even if the JS error was caught (the driver tracks per-block
114
- // query failures). Error confinement therefore must go through the driver's
115
- // own savepoint(), not manual SAVEPOINT statements. The callback receives
116
- // the savepoint-scoped tx; run every confined statement on THAT handle.
117
- export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T> {
118
- const raw = asRawClient(tx) as unknown as {
119
- savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
120
- };
121
- if (typeof raw.savepoint !== "function") {
122
- throw new Error(
123
- "runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).",
124
- );
125
- }
126
- return raw.savepoint(fn);
127
- }
128
-
129
112
  /**
130
113
  * When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
131
114
  * unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
@@ -10,11 +10,10 @@ import {
10
10
  SYSTEM_TENANT_ID,
11
11
  } from "../../engine";
12
12
  import type { ConfigSeedDef, Registry } from "../../engine/types";
13
- import { createEnvMasterKeyProvider } from "../../secrets/env-master-key-provider";
14
- import { createEnvelopeCipher } from "../../secrets/envelope-cipher";
15
13
  import { unsafeCreateEntityTable } from "../../stack";
16
14
  import { ensureTemporalPolyfill } from "../../time/polyfill";
17
15
  import { seedConfigValues } from "../config-seed";
16
+ import { createEncryptionProvider } from "../encryption";
18
17
  import { buildEntityTable } from "../table-builder";
19
18
 
20
19
  // --- Test Entity ---
@@ -52,15 +51,8 @@ const mockRegistry = {
52
51
  } as unknown as Registry;
53
52
 
54
53
  // --- Helpers ---
55
- const encryption = createEnvelopeCipher(
56
- createEnvMasterKeyProvider({
57
- env: {
58
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
59
- KUMIKO_SECRETS_MASTER_KEY_V1: Buffer.from("0123456789abcdef0123456789abcdef").toString(
60
- "base64",
61
- ),
62
- },
63
- }),
54
+ const encryption = createEncryptionProvider(
55
+ Buffer.from("0123456789abcdef0123456789abcdef").toString("base64"),
64
56
  );
65
57
 
66
58
  let testDb: BunTestDb;
@@ -222,7 +214,7 @@ describe("seedConfigValues", () => {
222
214
 
223
215
  await expect(
224
216
  seedConfigValues(seeds, configTable, configEntity, mockRegistry, testDb.db),
225
- ).rejects.toThrow(/encrypted but no EnvelopeCipher/);
217
+ ).rejects.toThrow(/encrypted but no EncryptionProvider/);
226
218
  });
227
219
 
228
220
  test("encrypted seed with provider stores ciphertext, not plaintext", async () => {
@@ -248,7 +240,7 @@ describe("seedConfigValues", () => {
248
240
  // resolver later runs `decrypt → JSON.parse` to get the primitive
249
241
  // back; we replay the same round-trip here.
250
242
  expect(row!.value).not.toContain("sk_live_secret_token");
251
- expect(JSON.parse(await encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
243
+ expect(JSON.parse(encryption.decrypt(row!.value))).toBe("sk_live_secret_token");
252
244
  });
253
245
 
254
246
  test("race-safe parallel boot — two concurrent calls result in 1 created + 1 skipped", async () => {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
- import { createTestEnvelopeCipher } from "../../testing";
3
+ import { createEncryptionProvider } from "../encryption";
4
4
  import {
5
5
  collectEncryptedFieldNames,
6
6
  decryptEntityFieldValues,
@@ -18,25 +18,25 @@ describe("entity-field-encryption", () => {
18
18
  },
19
19
  });
20
20
  const encryptedFields = collectEncryptedFieldNames(entity);
21
- const encryption = createTestEnvelopeCipher(TEST_KEY);
21
+ const encryption = createEncryptionProvider(TEST_KEY);
22
22
 
23
23
  test("collectEncryptedFieldNames finds encrypted text fields only", () => {
24
24
  expect([...encryptedFields]).toEqual(["secretNote"]);
25
25
  });
26
26
 
27
- test("encrypt on write / decrypt on read round-trip", async () => {
27
+ test("encrypt on write / decrypt on read round-trip", () => {
28
28
  const plain = { email: "a@b.de", secretNote: "top secret" };
29
- const stored = await encryptEntityFieldValues(plain, encryptedFields, encryption);
29
+ const stored = encryptEntityFieldValues(plain, encryptedFields, encryption);
30
30
  expect(stored["email"]).toBe(plain.email);
31
31
  expect(stored["secretNote"]).not.toBe("top secret");
32
32
 
33
- const read = await decryptEntityFieldValues(stored, encryptedFields, encryption);
33
+ const read = decryptEntityFieldValues(stored, encryptedFields, encryption);
34
34
  expect(read).toEqual(plain);
35
35
  });
36
36
 
37
- test("onlyKeys limits encryption to changed fields", async () => {
37
+ test("onlyKeys limits encryption to changed fields", () => {
38
38
  const row = { email: "a@b.de", secretNote: "note" };
39
- const stored = await encryptEntityFieldValues(row, encryptedFields, encryption, {
39
+ const stored = encryptEntityFieldValues(row, encryptedFields, encryption, {
40
40
  onlyKeys: ["secretNote"],
41
41
  });
42
42
  expect(stored["email"]).toBe("a@b.de");
@@ -10,7 +10,7 @@ import {
10
10
  testTenantId,
11
11
  unsafeCreateEntityTable,
12
12
  } from "../../stack";
13
- import { createTestEnvelopeCipher } from "../../testing";
13
+ import { createEncryptionProvider } from "../encryption";
14
14
  import { resetEntityFieldEncryptionCacheForTests } from "../entity-field-encryption";
15
15
  import { createEventStoreExecutor } from "../event-store-executor";
16
16
  import { buildEntityTable } from "../table-builder";
@@ -324,7 +324,7 @@ const encryptedSoftDeleteEntity = createEntity({
324
324
  const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
325
325
 
326
326
  describe("event-store-executor — encrypted fields", () => {
327
- const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
327
+ const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
328
328
  const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
329
329
  entityName: "esExecEncrypted",
330
330
  encryption,
@@ -337,6 +337,7 @@ describe("event-store-executor — encrypted fields", () => {
337
337
  );
338
338
 
339
339
  beforeAll(async () => {
340
+ process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
340
341
  resetEntityFieldEncryptionCacheForTests();
341
342
  await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
342
343
  await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
@@ -523,7 +524,7 @@ describe("event-store-executor — entity cache + encrypted fields", () => {
523
524
  store.delete(`${tenantId}:${name}:${id}`);
524
525
  },
525
526
  };
526
- const encryption = createTestEnvelopeCipher(ENCRYPTION_TEST_KEY);
527
+ const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
527
528
  const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
528
529
  entityName: "esExecEncrypted",
529
530
  entityCache,
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
- import type { TableColumns } from "../dialect";
5
4
  import { buildEntityTable } from "../table-builder";
6
5
  import { createTenantDb } from "../tenant-db";
7
6
 
@@ -14,9 +13,7 @@ const entity = createEntity({
14
13
  table: "merge_items",
15
14
  fields: { name: createTextField({ required: true }) },
16
15
  });
17
- // Brand (#742) is compile-time-only; hold the handle at the unbranded TableColumns
18
- // view so the method-form scoping test still compiles (runtime shape is identical).
19
- const table: TableColumns = buildEntityTable("mergeItem", entity);
16
+ const table = buildEntityTable("mergeItem", entity);
20
17
 
21
18
  const own = testTenantId(1);
22
19
  const foreign = testTenantId(2);