@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.320.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.
@@ -75,6 +75,7 @@ import {
75
75
  type ExtraRouteDefinition,
76
76
  type LoginRateLimiter,
77
77
  loadJwtSecretOrKeyring,
78
+ type PostAuthLandingResolver,
78
79
  type SseBroker,
79
80
  type TokenVerifier,
80
81
  } from "@cosmicdrift/kumiko-framework/api";
@@ -90,7 +91,6 @@ import {
90
91
  type DbRunner,
91
92
  } from "@cosmicdrift/kumiko-framework/db";
92
93
  import {
93
- buildAppSchema,
94
94
  collectWriteHandlerQns,
95
95
  createRegistry,
96
96
  type EffectiveFeaturesResolver,
@@ -145,7 +145,7 @@ import Redis from "ioredis";
145
145
  import { applyBootSeeds } from "./boot/apply-boot-seeds";
146
146
  import { resolveBootCrypto } from "./boot/boot-crypto";
147
147
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
148
- import { buildBunServeOptions } from "./bun-serve-options";
148
+ import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
149
149
  import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
150
150
  import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
151
151
  import { assertPiiBootInvariants } from "./pii-boot-gate";
@@ -248,17 +248,24 @@ function makeDryRunHandle(): ProdAppHandle {
248
248
  };
249
249
  }
250
250
 
251
- /** Wrapper-API für den Password-Reset-Flow.
251
+ /** Wrapper API for the password-reset flow.
252
252
  *
253
- * Seit der delivery-Migration trägt PasswordResetOptions selbst `appUrl`
254
- * (+ appName/locale) und der Handler mailt via ctx.notify — kein
255
- * sendResetEmail-Callback mehr. Apps geben `auth.mail` (Convenience,
256
- * resolveAuthMail baut die appUrl) ODER einen expliziten Block. */
257
- export type PasswordResetSetup = PasswordResetOptions;
253
+ * Since the delivery migration PasswordResetOptions carries `appUrl`
254
+ * (+ appName/locale) itself and the handler mails via ctx.notify — no
255
+ * sendResetEmail callback anymore. Apps pass `auth.mail` (convenience,
256
+ * resolveAuthMail builds the appUrl) OR an explicit block.
257
+ *
258
+ * hmacSecret is optional here (unlike PasswordResetOptions): resolveAuthMail
259
+ * backfills it, so overriding just appUrl needs no secret of its own. */
260
+ export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
261
+ readonly hmacSecret?: string;
262
+ };
258
263
 
259
- /** Wrapper-API für den Email-Verification-Flow. Symmetrisch zu
260
- * PasswordResetSetup — = EmailVerificationOptions (appUrl via delivery). */
261
- export type EmailVerificationSetup = EmailVerificationOptions;
264
+ /** Wrapper API for the email-verification flow, symmetric to
265
+ * PasswordResetSetup (appUrl via delivery, optional hmacSecret). */
266
+ export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
267
+ readonly hmacSecret?: string;
268
+ };
262
269
 
263
270
  /** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
264
271
  * Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
@@ -303,6 +310,9 @@ export type AuthMailOptions = {
303
310
  readonly from?: string;
304
311
  /** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
305
312
  readonly paths?: Partial<AuthPaths>;
313
+ /** Reset/verify token secret, decoupled from JWT_SECRET rotation.
314
+ * Falls back to resolveAuthMail's call-site secret when unset. */
315
+ readonly hmacSecret?: string;
306
316
  };
307
317
 
308
318
  export type RunProdAppAuthOptions = {
@@ -373,6 +383,10 @@ export type RunProdAppAuthOptions = {
373
383
  * env var when unset; both unset means the pre-#1539 spoofable default
374
384
  * (0 hops). Set this to your real ingress hop count (typically 1). */
375
385
  readonly trustedProxyHops?: number;
386
+ /** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
387
+ * Result lands in the login/signup/invite response as `landingPath`; an
388
+ * invalid path or a throwing resolver just omits the field. */
389
+ readonly postAuthLanding?: PostAuthLandingResolver;
376
390
  };
377
391
 
378
392
  /** Hook for app-specific seeding — runs after the admin (when auth is
@@ -403,23 +417,25 @@ export type ExtraContextOption =
403
417
  | Record<string, unknown>
404
418
  | ((deps: RunProdAppDeps) => Record<string, unknown>);
405
419
 
406
- /** Per-Host Routing-Entscheidung für den staticDir-Fallback. Wird aus
407
- * hostDispatch returned. Drei Modi:
408
- * - "html": eine bestimmte HTML-Datei (relativ zu staticDir) servieren,
409
- * mit optionaler Schema-Injection und CSP. Schema-Injection MUSS
410
- * explizit eingeschaltet werden (default false) — Public-Domain-
411
- * Antworten leaken sonst die volle Admin-UI-Schema-Topologie.
412
- * - "redirect": 301/302 an die angegebene Location.
413
- * - "not-found": klar abweisen (z.B. unbekannte Subdomain).
420
+ /** Per-host routing decision for the staticDir fallback, returned from
421
+ * hostDispatch. Three modes:
422
+ * - "html": serve a specific HTML file (relative to staticDir), optionally
423
+ * with CSP. HTML never carries a schema — createKumikoApp fetches it
424
+ * itself from the authenticated GET /api/schema, after the
425
+ * clientFeature gates (auth) let it through.
426
+ * - "redirect": 301/302 to the given location.
427
+ * - "not-found": reject outright (e.g. unknown subdomain).
414
428
  *
415
- * Wird NUR konsultiert wenn der Pfad sonst auf den HTML-Fallback gehen
416
- * würde — also für "/", "/index.html", oder SPA-Routen die weder Hono
417
- * matched noch eine konkrete Disk-Datei treffen. Asset-Pfade (/assets/*)
418
- * und API-Pfade laufen unabhängig vom Host. */
429
+ * Only consulted when the path would otherwise fall through to the HTML
430
+ * fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
431
+ * matches nor a concrete disk file. Asset paths (/assets/*) and API paths
432
+ * run independent of the host. */
419
433
  export type HostDispatchResult =
420
434
  | {
421
435
  readonly kind: "html";
422
436
  readonly file: string;
437
+ /** @deprecated Ignored — HTML never carries the schema; createKumikoApp
438
+ * fetches it from the authenticated GET /api/schema. */
423
439
  readonly injectSchema?: boolean;
424
440
  readonly csp?: string;
425
441
  }
@@ -458,6 +474,12 @@ export type RunProdAppOptions = {
458
474
  readonly validateBootOptions?: ValidateBootOptions;
459
475
  /** Listen-Port. Default 3000 (or $PORT). */
460
476
  readonly port?: number;
477
+ /** Ceiling Bun.serve buffers a request body to before any app-level
478
+ * body-limit middleware runs. Default is derived from `createFilesFeature`'s
479
+ * configured `maxUploadSize`/field `maxSize` (see
480
+ * resolveDerivedMaxRequestBodySize in bun-serve-options.ts) — override
481
+ * only for a custom ceiling unrelated to that config. */
482
+ readonly maxRequestBodySize?: number;
461
483
  /** Auth-mode: standard features + routes wired, admin seeded. */
462
484
  readonly auth?: RunProdAppAuthOptions;
463
485
  /** Custom seed functions, run after the admin seed (when auth-mode). */
@@ -481,21 +503,19 @@ export type RunProdAppOptions = {
481
503
  * for any path that doesn't match an /api/ handler. Use this for the
482
504
  * public status page HTML, embed widget JS, etc. */
483
505
  readonly staticDir?: string;
484
- /** Host-aware Routing-Hook für Multi-Tenant + Multi-App-Deployments
485
- * (z.B. publicstatus's `<sub>.publicstatus.eu` (Public-Page) +
486
- * `admin.publicstatus.eu` (Admin-UI) + `publicstatus.eu` (Apex/
487
- * Marketing) im SELBEN Container).
506
+ /** Host-aware routing hook for multi-tenant + multi-app deployments
507
+ * (e.g. publicstatus's `<sub>.publicstatus.eu` (public page) +
508
+ * `admin.publicstatus.eu` (admin UI) + `publicstatus.eu` (apex/
509
+ * marketing) in the SAME container).
488
510
  *
489
- * Wird aufgerufen wenn der staticDir-Fallback einen HTML-Response
490
- * generieren würde (Root oder SPA-Route). Default-Verhalten ohne
491
- * hostDispatch: index.html mit Schema-Injection (Single-App).
511
+ * Called when the staticDir fallback would generate an HTML response
512
+ * (root or SPA route). Default behavior without hostDispatch: index.html
513
+ * (single-app), no schema.
492
514
  *
493
- * Sicherheitshinweis: Schema-Injection (`__KUMIKO_SCHEMA__`) leakt
494
- * die Admin-UI-Topologie (alle Screens, Felder, Layouts) ans HTML.
495
- * Public-Domain-Antworten sollen das NIEMALS — `injectSchema` ist
496
- * daher default false und MUSS pro Host explizit eingeschaltet
497
- * werden. CSP-Header pro Host können zusätzlich Asset-Pfade
498
- * einschränken. */
515
+ * Schema safety: HTML (per-host as well as single-app) never carries an
516
+ * AppSchema — createKumikoApp fetches it via GET /api/schema, which sits
517
+ * behind the auth middleware and only serves non-anonymous, signed-in
518
+ * users. Per-host CSP headers can additionally restrict asset paths. */
499
519
  readonly hostDispatch?: HostDispatchFn;
500
520
  /** Per-request head-metadata resolver (Open-Graph/title/description) for
501
521
  * the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
@@ -664,6 +684,12 @@ export type RunProdAppOptions = {
664
684
  * overridden. `false` disables the whole block; per-header overrides
665
685
  * via the object form — see SecurityHeadersOption. */
666
686
  readonly securityHeaders?: SecurityHeadersOption;
687
+ /** Number of trusted reverse-proxy hops for client-IP resolution — 1:1
688
+ * durchgereicht an `buildServer`s
689
+ * top-level `ServerOptions.trustedProxyHops`. Wins over the deprecated
690
+ * `auth.trustedProxyHops` and `KUMIKO_TRUSTED_PROXY_HOPS`. Default 0 =
691
+ * trust no proxy header, only the socket address (or "unknown") counts. */
692
+ readonly trustedProxyHops?: number;
667
693
  };
668
694
 
669
695
  export type ProdAppHandle = {
@@ -799,6 +825,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
799
825
  // deployment — a self-inflicted DoS, worse than staying on the default.
800
826
  const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
801
827
  const trustedProxyHops = ((): number | undefined => {
828
+ if (options.trustedProxyHops !== undefined) return options.trustedProxyHops;
802
829
  if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
803
830
  if (trustedProxyHopsFromEnv === undefined) return undefined;
804
831
  // Digits-only — parseInt("0x10")/("1e3")/("2x") would silently coerce
@@ -989,11 +1016,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
989
1016
  ...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
990
1017
  ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
991
1018
  };
992
- // @cast-boundary engine-bridge — searchAdapter is an optional context-extension
993
- // (SharedContextFields.searchAdapter), invisible to extraContext's loose
994
- // Record<string, unknown>-based typing. Presence check only, no need to
995
- // pull in the SearchAdapter type here.
996
- const searchAdapterMissing = !(extraContext as { searchAdapter?: unknown }).searchAdapter;
997
1019
  const baseAnonymousAccess =
998
1020
  typeof options.anonymousAccess === "function"
999
1021
  ? options.anonymousAccess(deps)
@@ -1075,6 +1097,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1075
1097
  ...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
1076
1098
  ...(options.metrics && { metrics: options.metrics }),
1077
1099
  ...(options.rateLimit && { rateLimit: options.rateLimit }),
1100
+ ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1078
1101
  ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
1079
1102
  ...(effectiveAuth && {
1080
1103
  auth: {
@@ -1101,6 +1124,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1101
1124
  unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
1102
1125
  }),
1103
1126
  ...(trustedProxyHops !== undefined && { trustedProxyHops }),
1127
+ ...(effectiveAuth.postAuthLanding !== undefined && {
1128
+ postAuthLanding: effectiveAuth.postAuthLanding,
1129
+ }),
1104
1130
  ...sessionAuthFragment,
1105
1131
  ...patAuthFragment,
1106
1132
  ...(mfaFeature && {
@@ -1222,18 +1248,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1222
1248
  });
1223
1249
  }
1224
1250
 
1225
- // 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
1226
- // in die index.html injiziert damit createKumikoApp() im Browser
1227
- // `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
1228
- // wie im dev-server, damit der Client-Code keine Sonderfall-
1229
- // Branch zwischen dev/prod braucht. Boot-once weil Features
1230
- // nach dem Start nicht mehr ändern.
1231
- // TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
1232
- // pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
1233
- // Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
1234
- // statt eines fixed JSON-Strings. Heute: registry-static, also OK.
1235
- const appSchemaJson = JSON.stringify(buildAppSchema(registry, { searchAdapterMissing }));
1236
-
1237
1251
  // `extraRoutes` itself is already mounted — buildServer wires it inside
1238
1252
  // createApiEntrypoint/createAllInOneEntrypoint, before this function ever
1239
1253
  // runs. `wire` is the remaining escape-hatch for app-wired co-running
@@ -1301,19 +1315,20 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1301
1315
  ? buildStaticFallback(
1302
1316
  entrypoint.app.fetch.bind(entrypoint.app),
1303
1317
  options.staticDir,
1304
- appSchemaJson,
1305
1318
  options.hostDispatch,
1306
1319
  options.resolvePageHead
1307
1320
  ? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
1308
1321
  : undefined,
1309
1322
  entrypoint.dispatcher,
1323
+ trustedProxyHops ?? 0,
1310
1324
  )
1311
1325
  : // No staticDir (split-deploy / API-only container) → app.fetch's
1312
1326
  // response goes straight to the client, bypassing buildStaticFallback
1313
1327
  // (and with it tryHonoFirst) entirely. Must strip the router-miss
1314
1328
  // marker here too, same reason as the /api/* passthrough in
1315
1329
  // run-prod-app-static-files.ts (see try-hono-first.ts).
1316
- async (req: Request) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req)),
1330
+ async (req: Request, socketAddress?: string) =>
1331
+ stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)),
1317
1332
  options.securityHeaders,
1318
1333
  );
1319
1334
 
@@ -1338,7 +1353,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1338
1353
  "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
1339
1354
  );
1340
1355
  }
1341
- handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler));
1356
+ handle.server = Bun.serve(
1357
+ buildBunServeOptions(
1358
+ listenPort,
1359
+ fetchHandler,
1360
+ options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
1361
+ ),
1362
+ );
1342
1363
 
1343
1364
  // SIGTERM/SIGINT — graceful shutdown. Only registered when we
1344
1365
  // actually own a Bun-server, otherwise the test process picks up
@@ -43,13 +43,13 @@ export function resolveSecurityHeaders(
43
43
  // Sets each header only when absent so per-response values (e.g. the
44
44
  // per-host CSP from hostDispatch) always win over the runtime default.
45
45
  export function withSecurityHeaders(
46
- handler: (req: Request) => Response | Promise<Response>,
46
+ handler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
47
47
  option: SecurityHeadersOption | undefined,
48
- ): (req: Request) => Response | Promise<Response> {
48
+ ): (req: Request, socketAddress?: string) => Response | Promise<Response> {
49
49
  const defaults = resolveSecurityHeaders(option);
50
50
  if (defaults.length === 0) return handler;
51
- return async (req: Request): Promise<Response> => {
52
- const res = await handler(req);
51
+ return async (req: Request, socketAddress?: string): Promise<Response> => {
52
+ const res = await handler(req, socketAddress);
53
53
  try {
54
54
  for (const [name, value] of defaults) {
55
55
  if (!res.headers.has(name)) res.headers.set(name, value);
@@ -31,11 +31,13 @@
31
31
  import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
32
32
 
33
33
  export type HonoLikeApp = {
34
- // Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if all
35
- // handlers are sync, otherwise a Promise). createApiEntrypoint's
34
+ // Hono.app.fetch is `(req, env?) => Response | Promise<Response>` (sync if
35
+ // all handlers are sync, otherwise a Promise). createApiEntrypoint's
36
36
  // apiHandler matches the same shape. The union accepts both — we await
37
- // below, which works for either case.
38
- readonly fetch: (req: Request) => Response | Promise<Response>;
37
+ // below, which works for either case. `socketAddress` rides in as `env`
38
+ // so requestIdMiddleware/rate-limit can read it via `c.env` without Hono
39
+ // ever seeing a raw Bun `Server`.
40
+ readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
39
41
  };
40
42
 
41
43
  export type HonoFirstResult = {
@@ -69,8 +71,12 @@ export function stripNoRouteMatchHeader(response: Response): Response {
69
71
  * req.clone() because downstream needs to read the request body again
70
72
  * (future-proofing for POST/PUT/PATCH — only GET routes today).
71
73
  */
72
- export async function tryHonoFirst(app: HonoLikeApp, req: Request): Promise<HonoFirstResult> {
73
- const response = await app.fetch(req.clone());
74
+ export async function tryHonoFirst(
75
+ app: HonoLikeApp,
76
+ req: Request,
77
+ socketAddress?: string,
78
+ ): Promise<HonoFirstResult> {
79
+ const response = await app.fetch(req.clone(), socketAddress);
74
80
  const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
75
81
  stripNoRouteMatchHeader(response);
76
82
  return { matched: !isRouterMiss, response };
@@ -1,100 +0,0 @@
1
- // injectSchema is shared by the dev-server (every HTML response) and
2
- // prod-server (static-fallback index.html) paths. A bug here would be a
3
- // silent production failure: createKumikoApp wouldn't find
4
- // `window.__KUMIKO_SCHEMA__` and would mount empty. Tests pin idempotency
5
- // plus the two insertion points (before the /client.js tag or before </body>).
6
-
7
- import { describe, expect, test } from "bun:test";
8
- import { injectSchema } from "../inject-schema";
9
-
10
- const SCHEMA = '{"features":[]}';
11
- const TAG = `<script>window.__KUMIKO_SCHEMA__=${SCHEMA};</script>`;
12
-
13
- describe("injectSchema", () => {
14
- test("HTML mit /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
15
- const html = '<html><body><script src="/client.js" defer></script></body></html>';
16
- const out = injectSchema(html, SCHEMA);
17
- expect(out).toContain(TAG);
18
- // Schema MUST come before the client bundle — otherwise createKumikoApp()
19
- // runs before window.__KUMIKO_SCHEMA__ is set.
20
- expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script src="/client.js"'));
21
- });
22
-
23
- test("HTML mit module-form /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
24
- const html = '<html><body><script type="module" src="/client.js"></script></body></html>';
25
- const out = injectSchema(html, SCHEMA);
26
- expect(out).toContain(TAG);
27
- // Dev templates use `type="module"`, not the classic form — insertion
28
- // must match this tag pattern too.
29
- expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script type="module" src="/client.js"'));
30
- });
31
-
32
- test("HTML ohne /client.js-Tag: Schema-Tag wird vor </body> eingefügt", () => {
33
- const html = "<html><body><div id=root></div></body></html>";
34
- const out = injectSchema(html, SCHEMA);
35
- expect(out).toContain(TAG);
36
- expect(out.indexOf(TAG)).toBeLessThan(out.indexOf("</body>"));
37
- });
38
-
39
- test("HTML ohne </body>: Schema-Tag wird angehängt (defensiver Fallback)", () => {
40
- const html = "<div>fragment</div>";
41
- const out = injectSchema(html, SCHEMA);
42
- expect(out.endsWith(TAG)).toBe(true);
43
- });
44
-
45
- test("Idempotent: bei bereits injectem Schema kein zweiter Tag", () => {
46
- const html = `<html><body>${TAG}</body></html>`;
47
- const out = injectSchema(html, '{"features":[{"differentSchema":true}]}');
48
- // Original tag stays, no second tag added — the marker check otherwise
49
- // prevents stacking tags on repeated reads.
50
- expect(out).toBe(html);
51
- });
52
-
53
- test("Schema mit Komplex-Daten (entities + screens) bleibt valides JS", () => {
54
- const complex = JSON.stringify({
55
- features: [
56
- {
57
- featureName: "items",
58
- entities: { item: { fields: { title: { type: "text" } } } },
59
- screens: [{ id: "list", type: "entityList", entity: "item", columns: ["title"] }],
60
- },
61
- ],
62
- });
63
- const html = '<html><body><script src="/client.js"></script></body></html>';
64
- const out = injectSchema(html, complex);
65
- // Sanity: the injected script must be valid JS (object-literal syntax,
66
- // no HTML-reserved chars in the JSON that would break the <script>
67
- // block — JSON.stringify doesn't escape /, < etc., but the standard
68
- // chars used here are harmless).
69
- expect(out).toContain(`window.__KUMIKO_SCHEMA__=${complex}`);
70
- });
71
-
72
- test("Schema mit $-Replacement-Patterns bleibt byte-identisch (kein HTML-Splicing)", () => {
73
- // String.prototype.replace() interprets $$, $&, $`, $' specially in the
74
- // REPLACEMENT argument. The injected schema JSON string lands there — an
75
- // i18n label containing "$'" or similar would otherwise splice parts of
76
- // `html` into the script tag. A replacer-function argument sidesteps that.
77
- const dollarSchema = '{"label":"$\' tail $$ amp $& end"}';
78
- const dollarTag = `<script>window.__KUMIKO_SCHEMA__=${dollarSchema};</script>`;
79
- const html = '<html><body><script src="/client.js"></script></body></html>';
80
- const out = injectSchema(html, dollarSchema);
81
- // Insertion point is right before the /client.js tag, not the start of
82
- // the document — built via indexOf/slice, not .replace(), so the
83
- // expected value doesn't itself run through the same $-pattern footgun.
84
- const clientScriptIdx = html.indexOf('<script src="/client.js"');
85
- const expected = html.slice(0, clientScriptIdx) + dollarTag + html.slice(clientScriptIdx);
86
- expect(out).toBe(expected);
87
- });
88
-
89
- test("Schema mit $-Replacement-Patterns, Insertion vor </body>", () => {
90
- const dollarSchema = '{"label":"$\' tail $$ amp $& end"}';
91
- const dollarTag = `<script>window.__KUMIKO_SCHEMA__=${dollarSchema};</script>`;
92
- const html = "<html><body><div id=root></div></body></html>";
93
- const out = injectSchema(html, dollarSchema);
94
- // Built via slice/concat, not .replace() — the expected value must not
95
- // itself run through the same $-pattern footgun the fix removes.
96
- const bodyIdx = html.indexOf("</body>");
97
- const expected = html.slice(0, bodyIdx) + dollarTag + html.slice(bodyIdx);
98
- expect(out).toBe(expected);
99
- });
100
- });
@@ -1,33 +0,0 @@
1
- // Injiziert das Server-aufgelöste AppSchema in das HTML-Template damit
2
- // createKumikoApp() es synchron unter `window.__KUMIKO_SCHEMA__` vorfindet.
3
- // JSON ist valides JS — direkt eingebettet, der Browser parsed das
4
- // Object-Literal nativ.
5
- //
6
- // Geteilt zwischen dev-server (Schema kommt frisch aus dem laufenden
7
- // Prozess) und prod-server (Schema wird beim Boot einmal berechnet und
8
- // in die statisch ausgelieferte index.html injiziert). Beide Pfade
9
- // nutzen dieselbe Tag-Form damit `createKumikoApp` den Lookup nicht je
10
- // nach Kontext anders machen muss.
11
- //
12
- // Idempotenz: wenn das HTML schon einen __KUMIKO_SCHEMA__-Marker hat,
13
- // wird nicht doppelt injected — verhindert dass repeated index.html-
14
- // Reads (prod) oder bereits-vorbereitete templates (custom CI-builds)
15
- // stacking-Tags produzieren.
16
-
17
- // `<` als < serialisieren: verhindert `</script>`-Breakout aus dem
18
- // RAWTEXT-Block, JSON bleibt valides JS.
19
- function scriptSafeJsonHtml(json: string): string {
20
- return json.replace(/</g, "\\u003c");
21
- }
22
-
23
- const CLIENT_SCRIPT_TAG_RE = /<script\b[^>]*\ssrc="\/client\.js"/;
24
-
25
- export function injectSchema(html: string, schemaJson: string): string {
26
- if (html.includes("__KUMIKO_SCHEMA__")) return html;
27
- const tag = `<script>window.__KUMIKO_SCHEMA__=${scriptSafeJsonHtml(schemaJson)};</script>`;
28
- const clientScriptMatch = html.match(CLIENT_SCRIPT_TAG_RE);
29
- if (clientScriptMatch) {
30
- return html.replace(CLIENT_SCRIPT_TAG_RE, (m) => `${tag}${m}`);
31
- }
32
- return html.includes("</body>") ? html.replace("</body>", () => `${tag}</body>`) : html + tag;
33
- }