@cosmicdrift/kumiko-framework 0.200.1 → 0.202.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 (47) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/entity-list-limits.integration.test.ts +84 -0
  3. package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
  4. package/src/api/__tests__/api.test.ts +116 -1
  5. package/src/api/__tests__/batch.integration.test.ts +53 -0
  6. package/src/api/__tests__/body-limit.test.ts +90 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
  8. package/src/api/api-constants.ts +44 -7
  9. package/src/api/auth-middleware.ts +19 -3
  10. package/src/api/index.ts +1 -0
  11. package/src/api/route-registrars.ts +19 -21
  12. package/src/api/routes.ts +47 -1
  13. package/src/api/server.ts +1 -1
  14. package/src/db/__tests__/unchecked-system-db.test.ts +66 -0
  15. package/src/db/tenant-db.ts +46 -2
  16. package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
  17. package/src/engine/__tests__/build-app-schema.test.ts +25 -0
  18. package/src/engine/boot-validator/detail-screens.ts +35 -0
  19. package/src/engine/boot-validator/index.ts +2 -0
  20. package/src/engine/entity-handlers.ts +8 -1
  21. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/constants.ts +2 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/cross-file-patch-const/feature.ts +8 -0
  23. package/src/engine/feature-ast/__tests__/patch.test.ts +156 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +155 -0
  25. package/src/engine/feature-ast/extractors/events.ts +5 -3
  26. package/src/engine/feature-ast/extractors/round3.ts +5 -3
  27. package/src/engine/feature-ast/extractors/round5.ts +5 -4
  28. package/src/engine/feature-ast/extractors/shared.ts +29 -4
  29. package/src/engine/feature-ast/patch.ts +48 -21
  30. package/src/engine/feature-ast/patterns.ts +18 -0
  31. package/src/engine/feature-ast/render.ts +19 -6
  32. package/src/engine/index.ts +1 -0
  33. package/src/files/__tests__/files.integration.test.ts +97 -1
  34. package/src/files/file-routes.ts +10 -2
  35. package/src/files/types.ts +72 -0
  36. package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
  37. package/src/http/__tests__/egress.test.ts +440 -0
  38. package/src/http/__tests__/policy.test.ts +125 -0
  39. package/src/http/egress.ts +158 -0
  40. package/src/http/index.ts +2 -0
  41. package/src/http/policy.ts +193 -0
  42. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +44 -8
  43. package/src/pipeline/__tests__/dispatcher.test.ts +23 -4
  44. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +116 -22
  45. package/src/pipeline/dispatch-batch.ts +11 -5
  46. package/src/pipeline/dispatch-shared.ts +42 -16
  47. package/src/pipeline/idempotency.ts +91 -30
@@ -9,6 +9,11 @@
9
9
  // - Mixed patterns (writeHandler, hook, screen) embed the original
10
10
  // source-text of opaque bodies (handler/fn/closure) verbatim via
11
11
  // SourceLocation.raw — the renderer doesn't re-print closure code.
12
+ // - Patterns carrying a RawRefSentinel value (e.g. entity/metric/secret
13
+ // `definition`/`options`) or a `*NameRaw` identifier reference
14
+ // (useExtension/defineEvent/extendsRegistrar) fall back to positional-arg
15
+ // form instead of Object-Form, since the raw source text can't be spread
16
+ // into a merged object literal without losing its verbatim-ness.
12
17
  // - Comments inside an existing pattern are NOT preserved (Designer
13
18
  // edits via forms; for AI generation the output is fresh anyway).
14
19
  //
@@ -306,8 +311,16 @@ function renderReferenceData(p: ReferenceDataPattern): string {
306
311
  }
307
312
 
308
313
  function renderUseExtension(p: UseExtensionPattern): string {
314
+ const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
309
315
  if (isRawRefSentinel(p.options)) {
310
- return `r.useExtension(${JSON.stringify(p.extensionName)}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
316
+ return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}, ${p.options.__raw});`;
317
+ }
318
+ if (p.extensionNameRaw !== undefined) {
319
+ // A raw-ref name can't be spread into the merged Object-Form without
320
+ // losing it to renderValue's plain-string serialization — fall back
321
+ // to positional form, which keeps the reference verbatim (#2111).
322
+ const optionsArg = p.options !== undefined ? `, ${renderValue(p.options)}` : "";
323
+ return `r.useExtension(${nameLiteral}, ${JSON.stringify(p.entityName)}${optionsArg});`;
311
324
  }
312
325
  const merged: Record<string, unknown> = {
313
326
  name: p.extensionName,
@@ -506,14 +519,13 @@ function renderMultiStreamProjection(p: MultiStreamProjectionPattern): string {
506
519
  }
507
520
 
508
521
  function renderDefineEvent(p: DefineEventPattern): string {
522
+ const nameLiteral = p.eventNameRaw ?? JSON.stringify(p.eventName);
509
523
  const migrationEntries = p.migrations !== undefined ? Object.entries(p.migrations) : [];
510
524
  const hasOptions = p.version !== undefined || migrationEntries.length > 0;
511
525
  if (!hasOptions) {
512
- return `r.defineEvent(${JSON.stringify(p.eventName)}, ${p.schemaSource.raw});`;
526
+ return `r.defineEvent(${nameLiteral}, ${p.schemaSource.raw});`;
513
527
  }
514
- const lines: string[] = [
515
- `r.defineEvent(${JSON.stringify(p.eventName)}, ${p.schemaSource.raw}, {`,
516
- ];
528
+ const lines: string[] = [`r.defineEvent(${nameLiteral}, ${p.schemaSource.raw}, {`];
517
529
  if (p.version !== undefined) lines.push(` version: ${p.version},`);
518
530
  if (migrationEntries.length > 0) {
519
531
  lines.push(" migrations: [");
@@ -530,7 +542,8 @@ function renderDefineEvent(p: DefineEventPattern): string {
530
542
  }
531
543
 
532
544
  function renderExtendsRegistrar(p: ExtendsRegistrarPattern): string {
533
- return `r.extendsRegistrar(${JSON.stringify(p.extensionName)}, ${p.defBody.raw});`;
545
+ const nameLiteral = p.extensionNameRaw ?? JSON.stringify(p.extensionName);
546
+ return `r.extendsRegistrar(${nameLiteral}, ${p.defBody.raw});`;
534
547
  }
535
548
 
536
549
  function renderEnvSchema(p: EnvSchemaPattern): string {
@@ -70,6 +70,7 @@ export {
70
70
  defineProjectionQueryHandler,
71
71
  type EntityCrudRegistrar,
72
72
  entityListSchema,
73
+ MAX_LIST_LIMIT,
73
74
  type RegisterEntityCrudOptions,
74
75
  registerEntityCrud,
75
76
  } from "./entity-handlers";
@@ -33,7 +33,7 @@ import type { FileRoutesOptions } from "../file-routes";
33
33
  import { createInMemoryFileProvider } from "../in-memory-provider";
34
34
  import { createLocalProvider } from "../local-provider";
35
35
  import type { FileStorageProvider } from "../types";
36
- import { parseMaxSize, validateFile } from "../types";
36
+ import { parseMaxSize, sniffMimeType, validateFile } from "../types";
37
37
 
38
38
  // UUID for "this row doesn't exist" assertions. Valid v4 format so PG accepts
39
39
  // the query — the row just isn't there. Pre-v1 files-feature tests used
@@ -199,6 +199,28 @@ describe("file validation", () => {
199
199
  validateFile({ fileName: "a.jpg", mimeType: "image/jpg", size: 100 }, { accept: ["jpg"] }),
200
200
  ).toBeNull();
201
201
  });
202
+
203
+ test("sniffMimeType recognizes gif, webp and pdf signatures", () => {
204
+ expect(sniffMimeType(new TextEncoder().encode(`GIF89a${"x".repeat(20)}`))).toBe("image/gif");
205
+ const webp = new Uint8Array([
206
+ ...new TextEncoder().encode("RIFF"),
207
+ 0,
208
+ 0,
209
+ 0,
210
+ 0,
211
+ ...new TextEncoder().encode("WEBP"),
212
+ ...Array(20).fill(0),
213
+ ]);
214
+ expect(sniffMimeType(webp)).toBe("image/webp");
215
+ expect(sniffMimeType(new TextEncoder().encode(`%PDF-1.4${"x".repeat(20)}`))).toBe(
216
+ "application/pdf",
217
+ );
218
+ });
219
+
220
+ test("sniffMimeType returns null for unrecognized or too-short byte sequences", () => {
221
+ expect(sniffMimeType(new Uint8Array([0x00, 0x01, 0x02]))).toBeNull();
222
+ expect(sniffMimeType(new TextEncoder().encode("hello world"))).toBeNull();
223
+ });
202
224
  });
203
225
 
204
226
  // --- Integration: Upload → Download → Delete via real HTTP API ---
@@ -312,6 +334,80 @@ describe("file upload flow via API", () => {
312
334
  });
313
335
  });
314
336
 
337
+ // --- Serving hardens Content-Type against a spoofed/mismatched declared MIME ---
338
+
339
+ describe("download Content-Type is sniffed from bytes, not trusted from the declared MIME", () => {
340
+ const testPngContent = new Uint8Array([
341
+ 0x89,
342
+ 0x50,
343
+ 0x4e,
344
+ 0x47,
345
+ 0x0d,
346
+ 0x0a,
347
+ 0x1a,
348
+ 0x0a,
349
+ ...Array(50).fill(0),
350
+ ]);
351
+ const testJpegContent = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...Array(50).fill(0)]);
352
+
353
+ test("real PNG bytes declared as text/html are served as octet-stream, never text/html", async () => {
354
+ // Bun's own Request#formData() derives File#type from the *filename*
355
+ // extension, not from the multipart part's declared Content-Type header
356
+ // — so the fileName here (.html) is what actually lands as the stored
357
+ // mimeType; the mismatch this test needs is against the real PNG bytes.
358
+ const uploadRes = await uploadFile(adminUser, "sneaky.html", testPngContent, "text/html");
359
+ // Upload itself is not rejected — mismatch enforcement stays at the
360
+ // serving side, existing uploads with an off mimeType keep working.
361
+ expect(uploadRes.status).toBe(201);
362
+ const { id } = await uploadRes.json();
363
+
364
+ const res = await getFile(adminUser, id);
365
+ expect(res.status).toBe(200);
366
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
367
+ expect(res.headers.get("Content-Disposition")).toContain("attachment");
368
+ });
369
+
370
+ test("actual HTML bytes are never served as text/html or image/svg+xml", async () => {
371
+ const htmlBytes = new TextEncoder().encode(
372
+ "<html><body><script>alert(document.domain)</script></body></html>",
373
+ );
374
+ const uploadRes = await uploadFile(adminUser, "evil.html", htmlBytes, "text/html");
375
+ expect(uploadRes.status).toBe(201);
376
+ const { id } = await uploadRes.json();
377
+
378
+ const res = await getFile(adminUser, id);
379
+ expect(res.status).toBe(200);
380
+ expect(res.headers.get("Content-Type")).not.toBe("text/html");
381
+ expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
382
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
383
+ expect(res.headers.get("Content-Disposition")).toContain("attachment");
384
+ });
385
+
386
+ test("an SVG upload — bytes and declared MIME both image/svg+xml — is still downgraded to octet-stream, never served inline", async () => {
387
+ const svgBytes = new TextEncoder().encode(
388
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
389
+ );
390
+ const uploadRes = await uploadFile(adminUser, "logo.svg", svgBytes, "image/svg+xml");
391
+ expect(uploadRes.status).toBe(201);
392
+ const { id } = await uploadRes.json();
393
+
394
+ const res = await getFile(adminUser, id);
395
+ expect(res.status).toBe(200);
396
+ expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
397
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
398
+ });
399
+
400
+ test("regression: a real allowed image type with a correctly declared MIME still serves with the matching sniffed Content-Type", async () => {
401
+ const uploadRes = await uploadFile(adminUser, "photo.jpg", testJpegContent, "image/jpeg");
402
+ expect(uploadRes.status).toBe(201);
403
+ const { id } = await uploadRes.json();
404
+
405
+ const res = await getFile(adminUser, id);
406
+ expect(res.status).toBe(200);
407
+ expect(res.headers.get("Content-Type")).toBe("image/jpeg");
408
+ });
409
+ });
410
+
315
411
  // --- Cross-user access within a tenant (attached file owner-scope) ---
316
412
 
317
413
  describe("attached file owner-scope", () => {
@@ -18,7 +18,7 @@ import { createFileContext } from "./file-handle";
18
18
  import { fileRefEntity } from "./file-ref-entity";
19
19
  import { fileRefsTable } from "./file-ref-table";
20
20
  import type { FileProviderResolver } from "./provider-resolver";
21
- import { buildStorageKey, validateFile } from "./types";
21
+ import { buildStorageKey, resolveServedContentType, validateFile } from "./types";
22
22
 
23
23
  // Decision returned by a FileAccessGuard — distinct from boolean so callers
24
24
  // can't accidentally negate or default it.
@@ -234,11 +234,19 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
234
234
 
235
235
  const storageProvider = await options.resolveProvider(user.tenantId);
236
236
  const data = await storageProvider.read(fileRef.storageKey);
237
+ // The stored mimeType is client-declared (file.type off the upload) and
238
+ // never verified against the bytes — serving it as-is would let a
239
+ // client label real HTML/SVG as e.g. image/png to slip active content
240
+ // out under a trusted-looking Content-Type. Sniff the actual bytes
241
+ // instead; anything that isn't a known-safe binary signature (including
242
+ // svg/html, which have none) goes out as application/octet-stream.
243
+ const contentType = resolveServedContentType(data, fileRef.mimeType);
237
244
  return new Response(Buffer.from(data), {
238
245
  headers: {
239
- "Content-Type": fileRef.mimeType,
246
+ "Content-Type": contentType,
240
247
  "Content-Disposition": buildContentDispositionHeader(fileRef.fileName),
241
248
  "Content-Length": String(fileRef.size),
249
+ "X-Content-Type-Options": "nosniff",
242
250
  },
243
251
  });
244
252
  });
@@ -52,6 +52,78 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
52
52
  md: ["text/markdown", "text/plain"],
53
53
  } satisfies Record<string, readonly string[]>;
54
54
 
55
+ // Magic-byte signatures for the subset of EXTENSION_MIME_WHITELIST that has
56
+ // a reliable binary signature. Used at SERVE time — never trust the stored/
57
+ // client-declared mimeType for Content-Type on its own, sniff the actual
58
+ // bytes instead. Types without a stable signature (svg, txt, csv, json, md)
59
+ // and anything that matches none of these fall back to
60
+ // application/octet-stream in resolveServedContentType below — this also
61
+ // guarantees text/html and image/svg+xml are never served inline, even for
62
+ // an honestly-declared upload.
63
+ const MAGIC_BYTE_SIGNATURES: ReadonlyArray<{
64
+ readonly mimeType: string;
65
+ readonly matches: (bytes: Uint8Array) => boolean;
66
+ }> = [
67
+ {
68
+ mimeType: "image/png",
69
+ matches: (bytes) => startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
70
+ },
71
+ { mimeType: "image/jpeg", matches: (bytes) => startsWithBytes(bytes, [0xff, 0xd8, 0xff]) },
72
+ {
73
+ mimeType: "image/gif",
74
+ matches: (bytes) => startsWithAscii(bytes, "GIF87a") || startsWithAscii(bytes, "GIF89a"),
75
+ },
76
+ {
77
+ mimeType: "image/webp",
78
+ matches: (bytes) => startsWithAscii(bytes, "RIFF") && startsWithAscii(bytes, "WEBP", 8),
79
+ },
80
+ { mimeType: "application/pdf", matches: (bytes) => startsWithAscii(bytes, "%PDF-") },
81
+ ];
82
+
83
+ function startsWithBytes(bytes: Uint8Array, signature: readonly number[]): boolean {
84
+ if (bytes.length < signature.length) return false;
85
+ return signature.every((byte, i) => bytes[i] === byte);
86
+ }
87
+
88
+ function startsWithAscii(bytes: Uint8Array, ascii: string, offset = 0): boolean {
89
+ if (bytes.length < offset + ascii.length) return false;
90
+ for (let i = 0; i < ascii.length; i++) {
91
+ if (bytes[offset + i] !== ascii.charCodeAt(i)) return false;
92
+ }
93
+ return true;
94
+ }
95
+
96
+ export function sniffMimeType(bytes: Uint8Array): string | null {
97
+ for (const signature of MAGIC_BYTE_SIGNATURES) {
98
+ if (signature.matches(bytes)) return signature.mimeType;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // image/jpg is not a registered IANA type but EXTENSION_MIME_WHITELIST above
104
+ // accepts it as a jpg alias — sniffMimeType only ever returns the canonical
105
+ // image/jpeg, so without this a legitimately-declared "image/jpg" upload
106
+ // would mismatch its own sniffed type and get needlessly downgraded.
107
+ const DECLARED_MIME_ALIASES: Readonly<Record<string, string>> = {
108
+ "image/jpg": "image/jpeg",
109
+ };
110
+
111
+ // The Content-Type a served file's bytes are safe to go out with. Bytes that
112
+ // don't sniff as one of the known-safe binary signatures (including
113
+ // svg/html/text formats, which have no reliable magic bytes) always
114
+ // downgrade to application/octet-stream. Bytes that DO sniff safely still
115
+ // downgrade unless the sniffed type matches what was declared at upload —
116
+ // a mismatch (e.g. real PNG bytes uploaded as "text/html") is itself a
117
+ // spoofing signal and is never trusted enough to pick a Content-Type from,
118
+ // even one that would otherwise be harmless.
119
+ export function resolveServedContentType(bytes: Uint8Array, declaredMimeType: string): string {
120
+ const sniffed = sniffMimeType(bytes);
121
+ if (!sniffed) return "application/octet-stream";
122
+ const normalizedDeclared = declaredMimeType.toLowerCase().split(";")[0]?.trim() ?? "";
123
+ const declared = DECLARED_MIME_ALIASES[normalizedDeclared] ?? normalizedDeclared;
124
+ return sniffed === declared ? sniffed : "application/octet-stream";
125
+ }
126
+
55
127
  export function validateFile(
56
128
  metadata: FileMetadata,
57
129
  options: FileValidationOptions,
@@ -0,0 +1,37 @@
1
+ import { beforeAll, describe, expect, test } from "bun:test";
2
+ import { lookup } from "node:dns/promises";
3
+ import { egress } from "../egress";
4
+
5
+ // fw#2149 DoD requires proving TLS/SNI validation stays intact against a
6
+ // real HTTPS endpoint, not just a mock — the self-signed-cert tests in
7
+ // egress.test.ts pin the fetch-by-pinned-IP mechanism, this test pins it
8
+ // against a certificate chain issued by a real, publicly trusted CA.
9
+ // example.com is IANA-reserved and kept up for exactly this kind of use.
10
+ const REAL_HOST = "example.com";
11
+
12
+ let networkAvailable = true;
13
+
14
+ beforeAll(async () => {
15
+ try {
16
+ await lookup(REAL_HOST);
17
+ } catch {
18
+ networkAvailable = false;
19
+ }
20
+ });
21
+
22
+ describe("egress external: real HTTPS endpoint", () => {
23
+ test("connects through the pinned IP and validates the real certificate chain", async () => {
24
+ if (!networkAvailable) {
25
+ console.warn(
26
+ `egress real-endpoint test skipped: DNS resolution for ${REAL_HOST} failed (no network in this environment)`,
27
+ );
28
+ return;
29
+ }
30
+
31
+ const fetchIt = egress({ kind: "external" });
32
+ const res = await fetchIt(`https://${REAL_HOST}/`);
33
+
34
+ expect(res.status).toBe(200);
35
+ expect(await res.text()).toContain("Example Domain");
36
+ });
37
+ });