@czap/astro 0.1.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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/Satellite.d.ts +42 -0
  4. package/dist/Satellite.d.ts.map +1 -0
  5. package/dist/Satellite.js +55 -0
  6. package/dist/Satellite.js.map +1 -0
  7. package/dist/client-directives/gpu.d.ts +3 -0
  8. package/dist/client-directives/gpu.d.ts.map +1 -0
  9. package/dist/client-directives/gpu.js +5 -0
  10. package/dist/client-directives/gpu.js.map +1 -0
  11. package/dist/client-directives/llm.d.ts +3 -0
  12. package/dist/client-directives/llm.d.ts.map +1 -0
  13. package/dist/client-directives/llm.js +5 -0
  14. package/dist/client-directives/llm.js.map +1 -0
  15. package/dist/client-directives/satellite.d.ts +3 -0
  16. package/dist/client-directives/satellite.d.ts.map +1 -0
  17. package/dist/client-directives/satellite.js +5 -0
  18. package/dist/client-directives/satellite.js.map +1 -0
  19. package/dist/client-directives/stream.d.ts +3 -0
  20. package/dist/client-directives/stream.d.ts.map +1 -0
  21. package/dist/client-directives/stream.js +5 -0
  22. package/dist/client-directives/stream.js.map +1 -0
  23. package/dist/client-directives/wasm.d.ts +3 -0
  24. package/dist/client-directives/wasm.d.ts.map +1 -0
  25. package/dist/client-directives/wasm.js +6 -0
  26. package/dist/client-directives/wasm.js.map +1 -0
  27. package/dist/client-directives/worker.d.ts +3 -0
  28. package/dist/client-directives/worker.d.ts.map +1 -0
  29. package/dist/client-directives/worker.js +5 -0
  30. package/dist/client-directives/worker.js.map +1 -0
  31. package/dist/detect-upgrade.d.ts +16 -0
  32. package/dist/detect-upgrade.d.ts.map +1 -0
  33. package/dist/detect-upgrade.js +105 -0
  34. package/dist/detect-upgrade.js.map +1 -0
  35. package/dist/headers.d.ts +45 -0
  36. package/dist/headers.d.ts.map +1 -0
  37. package/dist/headers.js +64 -0
  38. package/dist/headers.js.map +1 -0
  39. package/dist/index.d.ts +30 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +26 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/integration.d.ts +76 -0
  44. package/dist/integration.d.ts.map +1 -0
  45. package/dist/integration.js +240 -0
  46. package/dist/integration.js.map +1 -0
  47. package/dist/middleware.d.ts +69 -0
  48. package/dist/middleware.d.ts.map +1 -0
  49. package/dist/middleware.js +75 -0
  50. package/dist/middleware.js.map +1 -0
  51. package/dist/quantize.d.ts +50 -0
  52. package/dist/quantize.d.ts.map +1 -0
  53. package/dist/quantize.js +122 -0
  54. package/dist/quantize.js.map +1 -0
  55. package/dist/runtime/boundary.d.ts +123 -0
  56. package/dist/runtime/boundary.d.ts.map +1 -0
  57. package/dist/runtime/boundary.js +164 -0
  58. package/dist/runtime/boundary.js.map +1 -0
  59. package/dist/runtime/globals.d.ts +32 -0
  60. package/dist/runtime/globals.d.ts.map +1 -0
  61. package/dist/runtime/globals.js +45 -0
  62. package/dist/runtime/globals.js.map +1 -0
  63. package/dist/runtime/gpu.d.ts +15 -0
  64. package/dist/runtime/gpu.d.ts.map +1 -0
  65. package/dist/runtime/gpu.js +266 -0
  66. package/dist/runtime/gpu.js.map +1 -0
  67. package/dist/runtime/index.d.ts +7 -0
  68. package/dist/runtime/index.d.ts.map +1 -0
  69. package/dist/runtime/index.js +5 -0
  70. package/dist/runtime/index.js.map +1 -0
  71. package/dist/runtime/llm-receipt-tracker.d.ts +21 -0
  72. package/dist/runtime/llm-receipt-tracker.d.ts.map +1 -0
  73. package/dist/runtime/llm-receipt-tracker.js +60 -0
  74. package/dist/runtime/llm-receipt-tracker.js.map +1 -0
  75. package/dist/runtime/llm-render-pipeline.d.ts +89 -0
  76. package/dist/runtime/llm-render-pipeline.d.ts.map +1 -0
  77. package/dist/runtime/llm-render-pipeline.js +241 -0
  78. package/dist/runtime/llm-render-pipeline.js.map +1 -0
  79. package/dist/runtime/llm-session.d.ts +126 -0
  80. package/dist/runtime/llm-session.d.ts.map +1 -0
  81. package/dist/runtime/llm-session.js +385 -0
  82. package/dist/runtime/llm-session.js.map +1 -0
  83. package/dist/runtime/llm.d.ts +16 -0
  84. package/dist/runtime/llm.d.ts.map +1 -0
  85. package/dist/runtime/llm.js +273 -0
  86. package/dist/runtime/llm.js.map +1 -0
  87. package/dist/runtime/policy.d.ts +100 -0
  88. package/dist/runtime/policy.d.ts.map +1 -0
  89. package/dist/runtime/policy.js +147 -0
  90. package/dist/runtime/policy.js.map +1 -0
  91. package/dist/runtime/receipt-chain.d.ts +22 -0
  92. package/dist/runtime/receipt-chain.d.ts.map +1 -0
  93. package/dist/runtime/receipt-chain.js +80 -0
  94. package/dist/runtime/receipt-chain.js.map +1 -0
  95. package/dist/runtime/runtime-session.d.ts +34 -0
  96. package/dist/runtime/runtime-session.d.ts.map +1 -0
  97. package/dist/runtime/runtime-session.js +102 -0
  98. package/dist/runtime/runtime-session.js.map +1 -0
  99. package/dist/runtime/satellite.d.ts +13 -0
  100. package/dist/runtime/satellite.d.ts.map +1 -0
  101. package/dist/runtime/satellite.js +59 -0
  102. package/dist/runtime/satellite.js.map +1 -0
  103. package/dist/runtime/slots.d.ts +34 -0
  104. package/dist/runtime/slots.d.ts.map +1 -0
  105. package/dist/runtime/slots.js +108 -0
  106. package/dist/runtime/slots.js.map +1 -0
  107. package/dist/runtime/stream-session.d.ts +47 -0
  108. package/dist/runtime/stream-session.d.ts.map +1 -0
  109. package/dist/runtime/stream-session.js +82 -0
  110. package/dist/runtime/stream-session.js.map +1 -0
  111. package/dist/runtime/stream.d.ts +9 -0
  112. package/dist/runtime/stream.d.ts.map +1 -0
  113. package/dist/runtime/stream.js +308 -0
  114. package/dist/runtime/stream.js.map +1 -0
  115. package/dist/runtime/url-policy.d.ts +28 -0
  116. package/dist/runtime/url-policy.d.ts.map +1 -0
  117. package/dist/runtime/url-policy.js +87 -0
  118. package/dist/runtime/url-policy.js.map +1 -0
  119. package/dist/runtime/wasm.d.ts +20 -0
  120. package/dist/runtime/wasm.d.ts.map +1 -0
  121. package/dist/runtime/wasm.js +70 -0
  122. package/dist/runtime/wasm.js.map +1 -0
  123. package/dist/runtime/worker.d.ts +11 -0
  124. package/dist/runtime/worker.d.ts.map +1 -0
  125. package/dist/runtime/worker.js +249 -0
  126. package/dist/runtime/worker.js.map +1 -0
  127. package/package.json +106 -0
  128. package/src/Satellite.astro +39 -0
  129. package/src/Satellite.ts +84 -0
  130. package/src/client-directives/gpu.ts +5 -0
  131. package/src/client-directives/llm.ts +5 -0
  132. package/src/client-directives/satellite.ts +5 -0
  133. package/src/client-directives/stream.ts +5 -0
  134. package/src/client-directives/wasm.ts +6 -0
  135. package/src/client-directives/worker.ts +5 -0
  136. package/src/detect-upgrade.ts +105 -0
  137. package/src/headers.ts +84 -0
  138. package/src/index.ts +30 -0
  139. package/src/integration.ts +309 -0
  140. package/src/middleware.ts +133 -0
  141. package/src/quantize.ts +173 -0
  142. package/src/runtime/boundary.ts +263 -0
  143. package/src/runtime/globals.ts +57 -0
  144. package/src/runtime/gpu.ts +291 -0
  145. package/src/runtime/index.ts +12 -0
  146. package/src/runtime/llm-receipt-tracker.ts +88 -0
  147. package/src/runtime/llm-render-pipeline.ts +366 -0
  148. package/src/runtime/llm-session.ts +548 -0
  149. package/src/runtime/llm.ts +344 -0
  150. package/src/runtime/policy.ts +229 -0
  151. package/src/runtime/receipt-chain.ts +106 -0
  152. package/src/runtime/runtime-session.ts +139 -0
  153. package/src/runtime/satellite.ts +80 -0
  154. package/src/runtime/slots.ts +136 -0
  155. package/src/runtime/stream-session.ts +125 -0
  156. package/src/runtime/stream.ts +407 -0
  157. package/src/runtime/url-policy.ts +107 -0
  158. package/src/runtime/wasm.ts +85 -0
  159. package/src/runtime/worker.ts +307 -0
@@ -0,0 +1,344 @@
1
+ import { Diagnostics } from '@czap/core';
2
+ import type { Receipt } from '@czap/core';
3
+ import type { LLMChunk } from '@czap/web';
4
+ import { createLLMSession } from './llm-session.js';
5
+ import { readRuntimeHtmlPolicy, readRuntimeEndpointPolicy } from './policy.js';
6
+ import { allowRuntimeEndpointUrl } from './url-policy.js';
7
+
8
+ const SAFE_LLM_TARGET_SELECTOR = /^(?:#[A-Za-z][\w-]*|\.[A-Za-z_][\w-]*|\[data-czap-target="[-:A-Za-z0-9_]+"\])$/;
9
+
10
+ const parseJSONUnknown = (text: string): unknown => JSON.parse(text);
11
+
12
+ function normalizeToolDeltaContent(content: unknown, toolArgs: unknown): string | undefined {
13
+ if (typeof content === 'string') {
14
+ return content;
15
+ }
16
+
17
+ if (typeof toolArgs === 'string') {
18
+ return toolArgs;
19
+ }
20
+
21
+ if (toolArgs && typeof toolArgs === 'object') {
22
+ return JSON.stringify(toolArgs);
23
+ }
24
+
25
+ return undefined;
26
+ }
27
+
28
+ function firstMeaningfulCharCode(raw: string): number {
29
+ for (let index = 0; index < raw.length; index++) {
30
+ const code = raw.charCodeAt(index);
31
+ if (code !== 32 && code !== 9 && code !== 10 && code !== 13) {
32
+ return code;
33
+ }
34
+ }
35
+
36
+ return -1;
37
+ }
38
+
39
+ function toStructuredChunk(
40
+ type: LLMChunk['type'],
41
+ partial: unknown,
42
+ content: unknown,
43
+ toolName: unknown,
44
+ toolArgs: unknown,
45
+ ): LLMChunk {
46
+ const normalizedToolName = typeof toolName === 'string' ? toolName : undefined;
47
+ const normalizedContent =
48
+ type === 'tool-call-delta'
49
+ ? normalizeToolDeltaContent(content, toolArgs)
50
+ : typeof content === 'string'
51
+ ? content
52
+ : undefined;
53
+
54
+ return {
55
+ type,
56
+ partial: partial === true,
57
+ content: normalizedContent,
58
+ toolName: normalizedToolName,
59
+ toolArgs,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Parse a raw `MessageEvent` payload into an {@link LLMChunk}. Returns
65
+ * `null` when the payload is unrecognised so callers can drop
66
+ * non-chunk events (metrics, heartbeats, ...) silently.
67
+ */
68
+ export function parseLLMChunk(event: Pick<MessageEvent, 'data'>): LLMChunk | null {
69
+ const decoded = decodeLLMEventData(event.data);
70
+ return decoded.type === 'chunk' ? decoded.chunk : null;
71
+ }
72
+
73
+ function mapDeviceTier(): 'none' | 'transitions' | 'animations' | 'physics' | 'compute' {
74
+ switch (document.documentElement.getAttribute('data-czap-tier')) {
75
+ case 'static':
76
+ return 'none';
77
+ case 'styled':
78
+ return 'transitions';
79
+ case 'gpu':
80
+ return 'compute';
81
+ case 'animated':
82
+ return 'physics';
83
+ default:
84
+ return 'animations';
85
+ }
86
+ }
87
+
88
+ function parseLLMError(error: { content?: unknown; message?: unknown }): string {
89
+ if (typeof error.content === 'string') {
90
+ return error.content;
91
+ }
92
+ if (typeof error.message === 'string') {
93
+ return error.message;
94
+ }
95
+
96
+ return 'unknown error';
97
+ }
98
+
99
+ type ParsedEventData =
100
+ | { readonly type: 'receipt'; readonly envelope: Receipt.Envelope }
101
+ | { readonly type: 'error'; readonly message: string }
102
+ | { readonly type: 'chunk'; readonly chunk: LLMChunk }
103
+ | { readonly type: 'ignored' };
104
+
105
+ interface StructuredLLMEvent {
106
+ readonly type?: unknown;
107
+ readonly data?: unknown;
108
+ readonly content?: unknown;
109
+ readonly message?: unknown;
110
+ readonly partial?: unknown;
111
+ readonly toolName?: unknown;
112
+ readonly toolArgs?: unknown;
113
+ }
114
+
115
+ function isStructuredLLMEvent(value: unknown): value is StructuredLLMEvent {
116
+ return typeof value === 'object' && value !== null;
117
+ }
118
+
119
+ function decodeStructuredLLMEventData(data: unknown): ParsedEventData {
120
+ if (!isStructuredLLMEvent(data)) {
121
+ return { type: 'ignored' };
122
+ }
123
+
124
+ switch (data.type) {
125
+ case 'receipt':
126
+ return isReceiptEnvelope(data.data) ? { type: 'receipt', envelope: data.data } : { type: 'ignored' };
127
+ case 'error': {
128
+ return { type: 'error', message: parseLLMError(data) };
129
+ }
130
+ case 'text':
131
+ case 'tool-call-start':
132
+ case 'tool-call-delta':
133
+ case 'tool-call-end':
134
+ case 'done':
135
+ return {
136
+ type: 'chunk',
137
+ chunk: toStructuredChunk(data.type, data.partial, data.content, data.toolName, data.toolArgs),
138
+ };
139
+ default:
140
+ return { type: 'ignored' };
141
+ }
142
+ }
143
+
144
+ function decodeLLMEventData(data: unknown): ParsedEventData {
145
+ if (typeof data === 'string') {
146
+ const firstChar = firstMeaningfulCharCode(data);
147
+ if (firstChar === -1) {
148
+ return { type: 'ignored' };
149
+ }
150
+
151
+ if (firstChar !== 123 && firstChar !== 91) {
152
+ return {
153
+ type: 'chunk',
154
+ chunk: {
155
+ type: 'text',
156
+ partial: false,
157
+ content: data,
158
+ toolName: undefined,
159
+ toolArgs: undefined,
160
+ },
161
+ };
162
+ }
163
+
164
+ let parsed: unknown;
165
+ let syntaxError = false;
166
+
167
+ try {
168
+ parsed = parseJSONUnknown(data);
169
+ } catch (error) {
170
+ if (error instanceof SyntaxError) {
171
+ syntaxError = true;
172
+ } else {
173
+ throw error;
174
+ }
175
+ }
176
+
177
+ if (syntaxError) {
178
+ return { type: 'ignored' };
179
+ }
180
+
181
+ return decodeStructuredLLMEventData(parsed);
182
+ }
183
+
184
+ return decodeStructuredLLMEventData(data);
185
+ }
186
+
187
+ function isReceiptEnvelope(value: unknown): value is Receipt.Envelope {
188
+ if (typeof value !== 'object' || value === null) return false;
189
+ if (!('hash' in value) || !('previous' in value)) return false;
190
+ return typeof value.hash === 'string';
191
+ }
192
+
193
+ function resolveLLMTarget(element: HTMLElement, selector: string | null): HTMLElement {
194
+ if (!selector || !SAFE_LLM_TARGET_SELECTOR.test(selector)) {
195
+ return element;
196
+ }
197
+
198
+ try {
199
+ const found = element.querySelector(selector);
200
+ return found instanceof HTMLElement ? found : element;
201
+ } catch {
202
+ return element;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Entry point used by the `client:llm` directive to start a streaming
208
+ * LLM session on `element`. Reads `data-czap-llm-url` (plus optional
209
+ * target / mode attributes), validates it against the runtime
210
+ * endpoint policy, opens an SSE stream, and drives an
211
+ * {@link LLMSessionShape} to completion.
212
+ */
213
+ export function initLLMDirective(load: () => Promise<unknown>, element: HTMLElement): void {
214
+ const endpointPolicy = readRuntimeEndpointPolicy();
215
+ const htmlPolicy = readRuntimeHtmlPolicy();
216
+ const llmUrl = allowRuntimeEndpointUrl(
217
+ element.getAttribute('data-czap-llm-url'),
218
+ 'llm',
219
+ 'czap/astro.llm',
220
+ {
221
+ crossOriginRejected: 'llm-cross-origin-url-rejected',
222
+ malformedUrl: 'llm-malformed-url-rejected',
223
+ originNotAllowed: 'llm-origin-not-allowed',
224
+ endpointKindNotPermitted: 'llm-endpoint-kind-not-permitted',
225
+ },
226
+ endpointPolicy,
227
+ );
228
+ if (!llmUrl) {
229
+ return;
230
+ }
231
+
232
+ const mode = element.getAttribute('data-czap-llm-mode') ?? 'append';
233
+ const targetSelector = element.getAttribute('data-czap-llm-target');
234
+ const resolveTarget = (): HTMLElement => resolveLLMTarget(element, targetSelector);
235
+ const resetTarget = (target: HTMLElement): void => {
236
+ target.replaceChildren();
237
+ };
238
+
239
+ let source: EventSource | null = null;
240
+ const session = createLLMSession({
241
+ element,
242
+ target: resolveTarget(),
243
+ mode,
244
+ getDeviceTier: mapDeviceTier,
245
+ htmlPolicy: htmlPolicy.llmDefault,
246
+ allowTrustedHtml: htmlPolicy.allowTrustedHtml,
247
+ });
248
+
249
+ const cleanupSource = (): void => {
250
+ if (source) {
251
+ source.onopen = null;
252
+ source.onmessage = null;
253
+ source.onerror = null;
254
+ }
255
+ source?.close();
256
+ source = null;
257
+ };
258
+
259
+ const cleanup = (): void => {
260
+ cleanupSource();
261
+ session.dispose();
262
+ };
263
+
264
+ const handleDisconnect = (): void => {
265
+ cleanupSource();
266
+ session.beginReconnect();
267
+
268
+ const strategy = session.replayGap();
269
+ if (strategy.type === 'replay') {
270
+ return;
271
+ }
272
+
273
+ element.dispatchEvent(
274
+ new CustomEvent('czap:llm-error', {
275
+ detail: { reason: 'connection-error', strategy: strategy.type },
276
+ bubbles: true,
277
+ }),
278
+ );
279
+ };
280
+
281
+ const connect = (): void => {
282
+ cleanupSource();
283
+ const target = resolveTarget();
284
+ resetTarget(target);
285
+ session.reset(target);
286
+ source = new EventSource(llmUrl);
287
+
288
+ source.onopen = () => {
289
+ session.activate();
290
+ element.dispatchEvent(new CustomEvent('czap:llm-start', { bubbles: true }));
291
+ };
292
+
293
+ source.onmessage = (event: MessageEvent) => {
294
+ const decoded = decodeLLMEventData(event.data);
295
+ switch (decoded.type) {
296
+ case 'receipt':
297
+ session.rememberEnvelope(decoded.envelope);
298
+ return;
299
+ case 'error':
300
+ element.dispatchEvent(
301
+ new CustomEvent('czap:llm-error', {
302
+ detail: { message: decoded.message },
303
+ bubbles: true,
304
+ }),
305
+ );
306
+ cleanupSource();
307
+ return;
308
+ case 'ignored':
309
+ return;
310
+ case 'chunk':
311
+ if (session.ingest(decoded.chunk) === 'done') {
312
+ cleanupSource();
313
+ }
314
+ return;
315
+ }
316
+ };
317
+
318
+ source.onerror = () => {
319
+ handleDisconnect();
320
+ };
321
+ };
322
+
323
+ try {
324
+ connect();
325
+ } catch (error) {
326
+ Diagnostics.error({
327
+ source: 'czap/astro.llm',
328
+ code: 'llm-runtime-init-failed',
329
+ message: 'The shared LLM runtime could not initialize.',
330
+ detail: error instanceof Error ? error.message : String(error),
331
+ });
332
+ cleanup();
333
+ }
334
+
335
+ element.addEventListener('czap:reinit', () => {
336
+ connect();
337
+ });
338
+
339
+ element.addEventListener('czap:dispose', () => {
340
+ cleanup();
341
+ });
342
+
343
+ load();
344
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Runtime security-policy data model: how `@czap/astro`'s client
3
+ * directives decide which endpoints they may fetch and how to treat
4
+ * HTML returned from those endpoints.
5
+ *
6
+ * @module
7
+ */
8
+ import type { HtmlPolicy, RuntimeEndpointKind, RuntimeEndpointPolicy } from '@czap/web';
9
+ import { readRuntimeGlobal, writeRuntimeGlobal } from './globals.js';
10
+
11
+ /**
12
+ * User-supplied HTML policy. Directives fall back to conservative
13
+ * defaults (`text` for LLM output, `sanitized-html` for stream
14
+ * payloads) when individual fields are omitted.
15
+ */
16
+ export interface RuntimeHtmlPolicy {
17
+ /** Default HTML trust level for `client:llm` text sinks. */
18
+ readonly llmDefault?: HtmlPolicy;
19
+ /** Default HTML trust level for `client:stream` payloads. */
20
+ readonly streamDefault?: HtmlPolicy;
21
+ /** Opt-in to `trusted-html` system-wide. */
22
+ readonly allowTrustedHtml?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Combined runtime security policy (endpoint + HTML). Passed to
27
+ * {@link configureRuntimePolicy} and persisted on `window` for
28
+ * directive consumption.
29
+ */
30
+ export interface RuntimeSecurityPolicy {
31
+ /** Endpoint allowlist configuration. */
32
+ readonly endpointPolicy?: RuntimeEndpointPolicy;
33
+ /** HTML policy configuration. */
34
+ readonly htmlPolicy?: RuntimeHtmlPolicy;
35
+ }
36
+
37
+ /**
38
+ * Frozen, fully-populated form of {@link RuntimeEndpointPolicy}. Every
39
+ * `RuntimeEndpointKind` has an allowlist (possibly empty) so callers
40
+ * can index safely without presence checks.
41
+ */
42
+ export interface NormalizedRuntimeEndpointPolicy {
43
+ readonly mode: RuntimeEndpointPolicy['mode'];
44
+ readonly allowOrigins: readonly string[];
45
+ readonly byKind: Readonly<Record<RuntimeEndpointKind, readonly string[]>>;
46
+ }
47
+
48
+ /**
49
+ * Frozen form of {@link RuntimeSecurityPolicy} with every optional
50
+ * field materialised to its default. Produced by
51
+ * {@link normalizeRuntimeSecurityPolicy}.
52
+ */
53
+ export interface NormalizedRuntimeSecurityPolicy {
54
+ readonly endpointPolicy: NormalizedRuntimeEndpointPolicy;
55
+ readonly htmlPolicy: {
56
+ readonly llmDefault: HtmlPolicy;
57
+ readonly streamDefault: HtmlPolicy;
58
+ readonly allowTrustedHtml: boolean;
59
+ };
60
+ }
61
+
62
+ function isNormalizedRuntimeSecurityPolicy(value: unknown): value is NormalizedRuntimeSecurityPolicy {
63
+ return (
64
+ typeof value === 'object' &&
65
+ value !== null &&
66
+ 'endpointPolicy' in value &&
67
+ 'htmlPolicy' in value &&
68
+ typeof value.endpointPolicy === 'object' &&
69
+ value.endpointPolicy !== null &&
70
+ typeof value.htmlPolicy === 'object' &&
71
+ value.htmlPolicy !== null
72
+ );
73
+ }
74
+
75
+ const DEFAULT_ENDPOINT_POLICY: NormalizedRuntimeEndpointPolicy = Object.freeze({
76
+ mode: 'same-origin',
77
+ allowOrigins: Object.freeze([]),
78
+ byKind: Object.freeze({
79
+ stream: Object.freeze([]),
80
+ snapshot: Object.freeze([]),
81
+ replay: Object.freeze([]),
82
+ llm: Object.freeze([]),
83
+ 'gpu-shader': Object.freeze([]),
84
+ wasm: Object.freeze([]),
85
+ }),
86
+ });
87
+
88
+ const DEFAULT_HTML_POLICY: {
89
+ readonly llmDefault: HtmlPolicy;
90
+ readonly streamDefault: HtmlPolicy;
91
+ readonly allowTrustedHtml: boolean;
92
+ } = Object.freeze({
93
+ llmDefault: 'text',
94
+ streamDefault: 'sanitized-html',
95
+ allowTrustedHtml: false,
96
+ });
97
+
98
+ function freezeOrigins(origins?: readonly string[]): readonly string[] {
99
+ return Object.freeze((origins ?? []).map((origin) => origin.trim()).filter((origin) => origin.length > 0));
100
+ }
101
+
102
+ function freezeEndpointPolicy(policy?: RuntimeEndpointPolicy): NormalizedRuntimeEndpointPolicy {
103
+ if (!policy) {
104
+ return DEFAULT_ENDPOINT_POLICY;
105
+ }
106
+
107
+ // Fully populate every kind up-front so the record is typed correctly
108
+ // from the initializer, without an empty-literal cast.
109
+ const byKind: Record<RuntimeEndpointKind, readonly string[]> = {
110
+ stream: freezeOrigins(policy.byKind?.stream),
111
+ snapshot: freezeOrigins(policy.byKind?.snapshot),
112
+ replay: freezeOrigins(policy.byKind?.replay),
113
+ llm: freezeOrigins(policy.byKind?.llm),
114
+ 'gpu-shader': freezeOrigins(policy.byKind?.['gpu-shader']),
115
+ wasm: freezeOrigins(policy.byKind?.wasm),
116
+ };
117
+
118
+ return Object.freeze({
119
+ mode: policy.mode,
120
+ allowOrigins: freezeOrigins(policy.allowOrigins),
121
+ byKind: Object.freeze(byKind),
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Freeze a user-supplied security policy into the fully-populated
127
+ * {@link NormalizedRuntimeSecurityPolicy} form. Applies conservative
128
+ * defaults for any missing fields.
129
+ */
130
+ export function normalizeRuntimeSecurityPolicy(policy?: RuntimeSecurityPolicy): NormalizedRuntimeSecurityPolicy {
131
+ return Object.freeze({
132
+ endpointPolicy: freezeEndpointPolicy(policy?.endpointPolicy),
133
+ htmlPolicy: Object.freeze({
134
+ llmDefault: policy?.htmlPolicy?.llmDefault ?? DEFAULT_HTML_POLICY.llmDefault,
135
+ streamDefault: policy?.htmlPolicy?.streamDefault ?? DEFAULT_HTML_POLICY.streamDefault,
136
+ allowTrustedHtml: policy?.htmlPolicy?.allowTrustedHtml ?? DEFAULT_HTML_POLICY.allowTrustedHtml,
137
+ }),
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Module-private source of truth for the active runtime policy. Lives
143
+ * in a closure no external script can `Object.defineProperty` past;
144
+ * the window global is a discoverable broadcast and a cross-bundle
145
+ * bridge, not the canonical store. Production callers always go
146
+ * through `configureRuntimePolicy` / `readRuntimePolicy`, both of
147
+ * which read the closure first.
148
+ *
149
+ * @internal
150
+ */
151
+ let _currentPolicy: NormalizedRuntimeSecurityPolicy | null = null;
152
+
153
+ /**
154
+ * Tracks whether the cross-bundle window broadcast has been published.
155
+ * The broadcast happens once per realm with `configurable: false` so
156
+ * an attacker with script execution cannot redefine the property on
157
+ * later loaders. Subsequent `configureRuntimePolicy` calls (HMR,
158
+ * tests) update the module-private store but skip a re-broadcast,
159
+ * which would throw against the locked descriptor. The broadcast is
160
+ * informational; consumers in the same module-graph see updates
161
+ * through the closure regardless.
162
+ *
163
+ * @internal
164
+ */
165
+ let _windowGlobalPublished = false;
166
+
167
+ /**
168
+ * Normalise `policy` and install it as the active runtime configuration.
169
+ *
170
+ * The first call in a given realm publishes the value to
171
+ * `window.__CZAP_RUNTIME_POLICY__` with `configurable: false` and
172
+ * `writable: false`, so an attacker cannot redefine the global via
173
+ * `Object.defineProperty` to install a permissive policy. Subsequent
174
+ * calls (HMR, test re-initialisation) update the module-private store
175
+ * only — the window global stays locked at the first published value.
176
+ *
177
+ * Production callers run `configureRuntimePolicy` once during the
178
+ * integration boot script. Test harnesses re-call it freely; reads
179
+ * via `readRuntimePolicy()` return the latest configured value
180
+ * because the module-private store is checked first.
181
+ */
182
+ export function configureRuntimePolicy(policy?: RuntimeSecurityPolicy): NormalizedRuntimeSecurityPolicy {
183
+ const normalized = normalizeRuntimeSecurityPolicy(policy);
184
+ _currentPolicy = normalized;
185
+
186
+ if (!_windowGlobalPublished) {
187
+ writeRuntimeGlobal('__CZAP_RUNTIME_POLICY__', normalized, { configurable: false });
188
+ _windowGlobalPublished = true;
189
+ }
190
+
191
+ return normalized;
192
+ }
193
+
194
+ /**
195
+ * Read the active runtime policy. Prefers the module-private store
196
+ * (the canonical source of truth), falls back to the cross-bundle
197
+ * window broadcast for consumers loaded as a separate bundle, and
198
+ * finally to a default normalised policy when nothing has been
199
+ * configured (e.g. in tests that haven't called
200
+ * `configureRuntimePolicy` yet).
201
+ */
202
+ export function readRuntimePolicy(): NormalizedRuntimeSecurityPolicy {
203
+ if (_currentPolicy) return _currentPolicy;
204
+ return (
205
+ readRuntimeGlobal('__CZAP_RUNTIME_POLICY__', isNormalizedRuntimeSecurityPolicy) ?? normalizeRuntimeSecurityPolicy()
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Reset the module-private policy store to its uninitialised state.
211
+ * Test-only: production code must not call this. The window-global
212
+ * broadcast is intentionally NOT cleared (the descriptor is
213
+ * non-configurable and cannot be redefined within a single realm).
214
+ *
215
+ * @internal
216
+ */
217
+ export function _resetRuntimePolicyForTests(): void {
218
+ _currentPolicy = null;
219
+ }
220
+
221
+ /** Convenience accessor for the endpoint sub-policy. */
222
+ export function readRuntimeEndpointPolicy(): NormalizedRuntimeEndpointPolicy {
223
+ return readRuntimePolicy().endpointPolicy;
224
+ }
225
+
226
+ /** Convenience accessor for the HTML sub-policy. */
227
+ export function readRuntimeHtmlPolicy(): NormalizedRuntimeSecurityPolicy['htmlPolicy'] {
228
+ return readRuntimePolicy().htmlPolicy;
229
+ }
@@ -0,0 +1,106 @@
1
+ import { DAG, Diagnostics } from '@czap/core';
2
+ import type { Receipt, UIFrame } from '@czap/core';
3
+
4
+ type ReceiptEnvelope = Receipt.Envelope;
5
+ type ReceiptTrustMode = 'advisory-unverified';
6
+
7
+ interface ReceiptChainShape {
8
+ rememberFrame(frame: UIFrame): void;
9
+ ingestEnvelope(envelope: ReceiptEnvelope): boolean;
10
+ hasFramesAfter(receiptId: string | null): boolean;
11
+ getFramesAfter(receiptId: string | null): readonly UIFrame[];
12
+ latestReceiptId(): string | null;
13
+ trustMode(): ReceiptTrustMode;
14
+ }
15
+
16
+ /**
17
+ * Build a new in-memory receipt chain. Owns a DAG of ingested receipt
18
+ * envelopes plus a map of remembered frames keyed by receipt id, so
19
+ * the LLM directive can replay gaps when the SSE stream reconnects.
20
+ *
21
+ * The chain currently treats signatures as advisory; a diagnostic is
22
+ * emitted when signed envelopes arrive without a configured verifier.
23
+ */
24
+ export function createReceiptChain(): ReceiptChainShape {
25
+ let dag = DAG.empty();
26
+ const framesByReceipt = new Map<string, UIFrame>();
27
+ const orderedReceipts: string[] = [];
28
+
29
+ const orderedFrameIds = (): readonly string[] => {
30
+ if (DAG.size(dag) > 0) {
31
+ const ids = DAG.linearize(dag)
32
+ .map((envelope) => envelope.hash)
33
+ .filter((hash) => framesByReceipt.has(hash));
34
+ if (ids.length > 0) {
35
+ return ids;
36
+ }
37
+ }
38
+
39
+ return orderedReceipts;
40
+ };
41
+
42
+ return {
43
+ rememberFrame(frame) {
44
+ framesByReceipt.set(frame.receiptId, frame);
45
+ if (!orderedReceipts.includes(frame.receiptId)) {
46
+ orderedReceipts.push(frame.receiptId);
47
+ }
48
+ },
49
+
50
+ ingestEnvelope(envelope) {
51
+ if (envelope.signature) {
52
+ Diagnostics.warnOnce({
53
+ source: 'czap/astro.receipt-chain',
54
+ code: 'receipt-signature-unverified',
55
+ message:
56
+ 'Receipt signatures are present but runtime ingestion treats them as advisory metadata until verification is configured.',
57
+ });
58
+ }
59
+
60
+ dag = DAG.ingest(dag, envelope);
61
+ return true;
62
+ },
63
+
64
+ hasFramesAfter(receiptId) {
65
+ const ids = orderedFrameIds();
66
+ if (ids.length === 0) {
67
+ return false;
68
+ }
69
+
70
+ if (receiptId === null) {
71
+ return ids.length > 0;
72
+ }
73
+
74
+ const index = ids.indexOf(receiptId);
75
+ if (index === -1) {
76
+ return false;
77
+ }
78
+
79
+ return index < ids.length - 1;
80
+ },
81
+
82
+ getFramesAfter(receiptId) {
83
+ const ids = orderedFrameIds();
84
+ if (receiptId === null) {
85
+ return ids.map((id) => framesByReceipt.get(id)!).filter(Boolean);
86
+ }
87
+ const index = ids.indexOf(receiptId);
88
+ if (index === -1) {
89
+ return [];
90
+ }
91
+ return ids
92
+ .slice(index + 1)
93
+ .map((id) => framesByReceipt.get(id)!)
94
+ .filter(Boolean);
95
+ },
96
+
97
+ latestReceiptId() {
98
+ const ids = orderedFrameIds();
99
+ return ids.length > 0 ? ids[ids.length - 1]! : null;
100
+ },
101
+
102
+ trustMode() {
103
+ return 'advisory-unverified';
104
+ },
105
+ };
106
+ }