@cosmicdrift/kumiko-headless 0.165.0 → 2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.165.0",
3
+ "version": "2.0.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.165.0",
39
+ "@cosmicdrift/kumiko-framework": "2.0.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -13,3 +13,4 @@ export type {
13
13
  WriteOpts,
14
14
  WriteResult,
15
15
  } from "./types";
16
+ export { StreamFrame, type StreamFrameEvent } from "./types";
@@ -153,6 +153,18 @@ export type StreamOpts = {
153
153
  readonly signal?: AbortSignal;
154
154
  };
155
155
 
156
+ // SSE frame event names for POST /api/stream — shared across the server route,
157
+ // the live-dispatcher wire parser, and their tests so a typo on one side
158
+ // fails the type-checker instead of the integration test.
159
+ export const StreamFrame = {
160
+ chunk: "chunk",
161
+ ping: "ping",
162
+ done: "done",
163
+ error: "error",
164
+ } as const;
165
+
166
+ export type StreamFrameEvent = (typeof StreamFrame)[keyof typeof StreamFrame];
167
+
156
168
  // ---------------------------------------------------------------------------
157
169
  // The contract
158
170
  // ---------------------------------------------------------------------------
@@ -22,3 +22,58 @@ export function escapeXml(s: string): string {
22
22
  .replace(/"/g, "&quot;")
23
23
  .replace(/'/g, "&apos;");
24
24
  }
25
+
26
+ // Strips ASCII control chars + space (codepoints 0x00-0x20 and 0x7f) — the
27
+ // same characters browsers strip before scheme detection. `.trim()` alone
28
+ // only removes leading/trailing whitespace, so a scheme like
29
+ // "java\tscript:" (tab embedded mid-scheme) survives to isSafeHref's regex
30
+ // check below, breaks its character class before the ":", falls through to
31
+ // `return true`, and the browser then normalizes it back to "javascript:"
32
+ // and executes it. Written as a char-code filter rather than a regex range
33
+ // to avoid embedding literal control characters in source.
34
+ export function stripControlChars(value: string): string {
35
+ let result = "";
36
+ for (const ch of value) {
37
+ const code = ch.codePointAt(0) ?? 0;
38
+ if (code > 0x20 && code !== 0x7f) result += ch;
39
+ }
40
+ return result;
41
+ }
42
+
43
+ // http(s)/mailto or scheme-less (relative/anchor) allowed; javascript:,
44
+ // data:, vbscript:, etc. rejected. Shared between renderer-web's Link
45
+ // primitive and page-render's server-side markdown renderer (both take
46
+ // untrusted tenant-authored hrefs) — no sanitizer dependency needed for a
47
+ // four-line regex check.
48
+ // Browsers decode HTML character references (&colon;, &#58;, &#x3a;, &Tab;,
49
+ // &#9;, ...) while parsing the href attribute, before the URL parser ever
50
+ // sees the value — so "javascript&colon;alert(1)" and "java&Tab;script:"
51
+ // both normalize to an executable javascript: URL at click time even though
52
+ // neither contains a literal ":" for the regex below to catch. Decode
53
+ // (not delete) so the scheme check sees what the browser will execute —
54
+ // deleting "&colon;" would leave no colon at all, which reads as a safe
55
+ // scheme-less href, exactly backwards.
56
+ const NAMED_HTML_ENTITY_CHARS: Record<string, string> = {
57
+ colon: ":",
58
+ tab: "\t",
59
+ newline: "\n",
60
+ semi: ";",
61
+ amp: "&",
62
+ };
63
+
64
+ function decodeHtmlEntities(value: string): string {
65
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z][a-z0-9]*);/gi, (match, entity: string) => {
66
+ if (entity[0] === "#") {
67
+ const isHex = entity[1]?.toLowerCase() === "x";
68
+ const code = Number.parseInt(isHex ? entity.slice(2) : entity.slice(1), isHex ? 16 : 10);
69
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
70
+ }
71
+ return NAMED_HTML_ENTITY_CHARS[entity.toLowerCase()] ?? match;
72
+ });
73
+ }
74
+
75
+ export function isSafeHref(href: string): boolean {
76
+ const trimmed = stripControlChars(decodeHtmlEntities(href)).toLowerCase();
77
+ if (!/^[a-z][a-z0-9+.-]*:/.test(trimmed)) return true;
78
+ return /^(?:https?|mailto):/.test(trimmed);
79
+ }
@@ -67,7 +67,7 @@ function formatDateCell(
67
67
  }
68
68
  }
69
69
 
70
- export { escapeHtml, escapeHtmlAttr, escapeXml } from "./escape";
70
+ export { escapeHtml, escapeHtmlAttr, escapeXml, isSafeHref, stripControlChars } from "./escape";
71
71
  export { type HtmlValue, html, RawHtml, raw } from "./html-template";
72
72
  export function applyFormatSpec(
73
73
  spec: { format: string } & Record<string, unknown>,
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ export type {
38
38
  WriteOpts,
39
39
  WriteResult,
40
40
  } from "./dispatcher";
41
+ export { StreamFrame, type StreamFrameEvent } from "./dispatcher";
41
42
  export type {
42
43
  FieldConditionPredicate,
43
44
  FieldConditions,
@@ -59,8 +60,10 @@ export {
59
60
  escapeXml,
60
61
  type HtmlValue,
61
62
  html,
63
+ isSafeHref,
62
64
  RawHtml,
63
65
  raw,
66
+ stripControlChars,
64
67
  toInstant,
65
68
  } from "./format";
66
69
  export type {