@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.
- package/package.json +2 -2
- package/src/__tests__/schema-cli.integration.test.ts +0 -29
- package/src/api/__tests__/batch.integration.test.ts +1 -4
- package/src/api/__tests__/request-id-middleware.test.ts +0 -51
- package/src/api/auth-middleware.ts +1 -65
- package/src/api/auth-routes.ts +0 -11
- package/src/api/index.ts +1 -3
- package/src/api/jwt.ts +4 -1
- package/src/api/request-context.ts +0 -3
- package/src/api/request-id-middleware.ts +0 -2
- package/src/api/routes.ts +1 -26
- package/src/api/server.ts +1 -29
- package/src/bun-db/__tests__/write-brand.test.ts +0 -21
- package/src/bun-db/query.ts +0 -17
- package/src/db/__tests__/config-seed.integration.test.ts +5 -13
- package/src/db/__tests__/entity-field-encryption.test.ts +7 -7
- package/src/db/__tests__/event-store-executor.integration.test.ts +4 -3
- package/src/db/__tests__/tenant-db-where-merge.test.ts +1 -4
- package/src/db/__tests__/tenant-db.integration.test.ts +2 -6
- package/src/db/config-seed.ts +9 -9
- package/src/db/encryption.ts +0 -7
- package/src/db/entity-field-encryption.ts +24 -81
- package/src/db/event-store-executor.ts +24 -37
- package/src/db/index.ts +0 -3
- package/src/db/query.ts +0 -1
- package/src/db/tenant-db.ts +9 -14
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +5 -6
- package/src/engine/__tests__/boot-validator.test.ts +9 -33
- package/src/engine/__tests__/build-config-feature-schema.test.ts +0 -21
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +0 -6
- package/src/engine/__tests__/registry.test.ts +0 -28
- package/src/engine/__tests__/screen.test.ts +0 -40
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +0 -3
- package/src/engine/boot-validator/gdpr-storage.ts +1 -2
- package/src/engine/boot-validator/index.ts +2 -7
- package/src/engine/boot-validator/screens-nav.ts +0 -21
- package/src/engine/define-feature.ts +0 -17
- package/src/engine/define-handler.ts +2 -16
- package/src/engine/extensions/user-data.ts +1 -12
- package/src/engine/index.ts +2 -5
- package/src/engine/registry.ts +21 -44
- package/src/engine/soft-delete-cleanup.ts +2 -6
- package/src/engine/types/feature.ts +21 -29
- package/src/engine/types/fields.ts +1 -3
- package/src/engine/types/handlers.ts +2 -16
- package/src/engine/types/index.ts +0 -2
- package/src/engine/types/projection.ts +0 -22
- package/src/engine/types/screen.ts +0 -29
- package/src/event-store/__tests__/row-to-stored-event.test.ts +2 -2
- package/src/event-store/index.ts +0 -8
- package/src/files/provider-resolver.ts +15 -28
- package/src/i18n/required-surface-keys.ts +0 -12
- package/src/observability/__tests__/observability.integration.test.ts +1 -4
- package/src/pipeline/dispatcher.ts +6 -2
- package/src/pipeline/msp-rebuild.ts +3 -36
- package/src/pipeline/projection-rebuild.ts +3 -61
- package/src/schema-cli.ts +15 -24
- package/src/secrets/__tests__/contains-secret.test.ts +0 -34
- package/src/secrets/dek-cache.ts +5 -26
- package/src/secrets/envelope.ts +3 -5
- package/src/secrets/index.ts +1 -13
- package/src/secrets/types.ts +3 -18
- package/src/testing/e2e-generator.ts +0 -3
- package/src/testing/index.ts +0 -2
- package/src/testing/mutable-master-key-provider.ts +3 -29
- package/src/time/__tests__/tz-dateline.test.ts +10 -8
- package/src/ui-types/index.ts +0 -1
- package/src/api/__tests__/pat-scope.test.ts +0 -36
- package/src/api/pat-scope.ts +0 -14
- package/src/engine/__tests__/extend-entity-projection.test.ts +0 -123
- package/src/event-store/rebuild-dead-letter.ts +0 -111
- package/src/files/__tests__/provider-resolver.test.ts +0 -70
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +0 -274
- package/src/secrets/__tests__/envelope-cipher.test.ts +0 -87
- package/src/secrets/envelope-cipher.ts +0 -81
- 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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -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 =
|
|
56
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
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 =
|
|
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",
|
|
27
|
+
test("encrypt on write / decrypt on read round-trip", () => {
|
|
28
28
|
const plain = { email: "a@b.de", secretNote: "top secret" };
|
|
29
|
-
const stored =
|
|
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 =
|
|
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",
|
|
37
|
+
test("onlyKeys limits encryption to changed fields", () => {
|
|
38
38
|
const row = { email: "a@b.de", secretNote: "note" };
|
|
39
|
-
const stored =
|
|
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 {
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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);
|