@botiverse/porthole 0.1.1 → 0.1.3

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 (96) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +5 -3
  8. package/dist/compile/policy.js +8 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +8 -0
  12. package/dist/config/schema.js +5 -0
  13. package/dist/config/validate.js +15 -1
  14. package/dist/conformance/clipboard.d.ts +2 -0
  15. package/dist/conformance/clipboard.js +167 -0
  16. package/dist/conformance/kit.d.ts +1 -1
  17. package/dist/conformance/probes.js +2 -0
  18. package/dist/guide/guide.js +8 -4
  19. package/dist/host/clipboard.d.ts +11 -0
  20. package/dist/host/clipboard.js +43 -0
  21. package/dist/host/create-host.js +141 -0
  22. package/dist/host/outcome.d.ts +1 -0
  23. package/dist/host/outcome.js +19 -6
  24. package/dist/host/types.d.ts +34 -5
  25. package/dist/index.d.ts +8 -4
  26. package/dist/index.js +8 -3
  27. package/dist/mcp/adapter.js +10 -1
  28. package/dist/presets/presets.d.ts +3 -1
  29. package/dist/presets/presets.js +1 -0
  30. package/dist/protocol/mcp-apps.d.ts +5 -0
  31. package/dist/protocol/mcp-apps.js +5 -0
  32. package/dist/protocol/shell-adapter.js +6 -2
  33. package/dist/react/dialog.js +2 -0
  34. package/dist/react/frame.d.ts +18 -0
  35. package/dist/react/frame.js +82 -2
  36. package/dist/react/index.d.ts +2 -1
  37. package/dist/react/index.js +2 -1
  38. package/dist/react/toast.d.ts +33 -0
  39. package/dist/react/toast.js +42 -0
  40. package/dist/server/crypto.d.ts +19 -0
  41. package/dist/server/crypto.js +144 -0
  42. package/dist/server/index.d.ts +12 -4
  43. package/dist/server/index.js +11 -3
  44. package/dist/server/keys.js +12 -9
  45. package/dist/server/naming.d.ts +17 -0
  46. package/dist/server/naming.js +34 -0
  47. package/dist/server/pages.d.ts +1 -1
  48. package/dist/server/pages.js +13 -5
  49. package/dist/server/server.js +162 -18
  50. package/dist/server/tickets.d.ts +24 -0
  51. package/dist/server/tickets.js +46 -0
  52. package/dist/server/types.d.ts +76 -5
  53. package/dist/shell/annotate.d.ts +20 -0
  54. package/dist/shell/annotate.js +669 -0
  55. package/dist/shell/document.js +4 -1
  56. package/dist/shell/plan.d.ts +2 -0
  57. package/dist/shell/plan.js +2 -1
  58. package/dist/shell/runtime.d.ts +4 -1
  59. package/dist/shell/runtime.js +38 -2
  60. package/package.json +2 -1
  61. package/src/annotate/types.ts +126 -0
  62. package/src/annotate/validate.ts +186 -0
  63. package/src/bridge/bridge.ts +13 -0
  64. package/src/compile/policy.ts +8 -4
  65. package/src/config/capabilities.ts +33 -3
  66. package/src/config/schema.ts +10 -0
  67. package/src/config/validate.ts +16 -1
  68. package/src/conformance/clipboard.ts +184 -0
  69. package/src/conformance/kit.ts +1 -1
  70. package/src/conformance/probes.ts +2 -0
  71. package/src/guide/guide.ts +19 -4
  72. package/src/host/clipboard.ts +41 -0
  73. package/src/host/create-host.ts +155 -0
  74. package/src/host/outcome.ts +25 -7
  75. package/src/host/types.ts +39 -5
  76. package/src/index.ts +31 -1
  77. package/src/mcp/adapter.ts +10 -1
  78. package/src/presets/presets.ts +4 -0
  79. package/src/protocol/mcp-apps.ts +6 -0
  80. package/src/protocol/shell-adapter.ts +11 -2
  81. package/src/react/dialog.tsx +2 -0
  82. package/src/react/frame.tsx +117 -1
  83. package/src/react/index.ts +9 -1
  84. package/src/react/toast.tsx +96 -0
  85. package/src/server/crypto.ts +166 -0
  86. package/src/server/index.ts +20 -3
  87. package/src/server/keys.ts +21 -13
  88. package/src/server/naming.ts +42 -0
  89. package/src/server/pages.ts +13 -4
  90. package/src/server/server.ts +190 -20
  91. package/src/server/tickets.ts +65 -0
  92. package/src/server/types.ts +84 -7
  93. package/src/shell/annotate.ts +669 -0
  94. package/src/shell/document.ts +6 -1
  95. package/src/shell/plan.ts +4 -1
  96. package/src/shell/runtime.ts +38 -2
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  export {
8
8
  CAPABILITIES,
9
9
  CAPABILITY_NAMES,
10
+ MAX_COPY_CHARS,
10
11
  capabilitySpec,
11
12
  isCapabilityName,
12
13
  type CapabilityArgs,
@@ -38,8 +39,14 @@ export {
38
39
  originSourceProblem,
39
40
  validateConfig,
40
41
  } from "./config/validate.ts";
41
- export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
42
+ export {
43
+ URL_PERMISSIONS,
44
+ URL_SANDBOX_EXTRAS,
45
+ type UrlPermission,
46
+ type UrlSandboxExtra,
47
+ } from "./config/schema.ts";
42
48
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
49
+ export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
43
50
 
44
51
  // Presets.
45
52
  export {
@@ -99,6 +106,7 @@ export type {
99
106
  ShellConnection,
100
107
  } from "./protocol/adapter.ts";
101
108
  export {
109
+ COPY_TEXT_METHOD,
102
110
  MCP_APPS_METHODS,
103
111
  MCP_APPS_PROTOCOL_VERSION,
104
112
  MCP_APPS_STYLE_VARIABLES,
@@ -118,11 +126,33 @@ export type {
118
126
  Refusal,
119
127
  } from "./bridge/types.ts";
120
128
  export { createHost } from "./host/create-host.ts";
129
+ export { writeClipboard } from "./host/clipboard.ts";
121
130
  export type {
131
+ AnnotationControls,
122
132
  DisplayControls,
123
133
  DisplayEvents,
134
+ FrameBlock,
135
+ FrameOutcome,
124
136
  DisplayHandle,
125
137
  HostOptions,
126
138
  MountOptions,
127
139
  PortholeHost,
128
140
  } from "./host/types.ts";
141
+
142
+ // Annotations (P053): anchors, what a frame reports, and the host's checks on both.
143
+ export {
144
+ ANNOTATE_METHODS,
145
+ ANNOTATION_LIMITS,
146
+ type AnchorResult,
147
+ type AnchorState,
148
+ type AnnotateMode,
149
+ type AnnotationAnchor,
150
+ type AnnotationMark,
151
+ type AnnotationRect,
152
+ type AnnotationReport,
153
+ type AnnotationSelector,
154
+ type CssSelector,
155
+ type TextPositionSelector,
156
+ type TextQuoteSelector,
157
+ } from "./annotate/types.ts";
158
+ export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
@@ -14,7 +14,7 @@ import {
14
14
  MCP_APPS_PROTOCOL_VERSION,
15
15
  type HostContext,
16
16
  } from "../protocol/mcp-apps.ts";
17
- import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
17
+ import { COPY_TEXT_METHOD, MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.ts";
18
18
 
19
19
  /**
20
20
  * The MCP Apps adapter (P004, P007): the host side of the protocol for full MCP Apps views, written
@@ -283,6 +283,15 @@ function connect(session: AdapterSession, options: McpAppsAdapterOptions): McpAp
283
283
  () => ({}),
284
284
  );
285
285
  return;
286
+ case COPY_TEXT_METHOD:
287
+ // Porthole's own request (the standard has no clipboard request): the host copies, and
288
+ // shows the person what (P052).
289
+ if (typeof params.text !== "string") {
290
+ invalid("needs text");
291
+ return;
292
+ }
293
+ await call(request, "copyText", { text: params.text }, done);
294
+ return;
286
295
  case MCP_APPS_METHODS.requestDisplayMode: {
287
296
  if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
288
297
  invalid("needs a display mode");
@@ -10,6 +10,7 @@ import type {
10
10
  Limits,
11
11
  PortholeConfig,
12
12
  ThemeValue,
13
+ UrlPermission,
13
14
  UrlSandboxExtra,
14
15
  } from "../config/schema.ts";
15
16
  import { validateConfig } from "../config/validate.ts";
@@ -51,6 +52,8 @@ export interface UrlPresetOptions {
51
52
  readonly contentOrigins: readonly string[];
52
53
  /** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
53
54
  readonly sandbox?: readonly UrlSandboxExtra[];
55
+ /** Features to delegate to the app (clipboard writes, full screen; P049). */
56
+ readonly permissions?: readonly UrlPermission[];
54
57
  readonly limits?: Partial<Limits>;
55
58
  readonly layout?: Partial<LayoutConfig>;
56
59
  readonly embedder?: EmbedderConfig;
@@ -211,6 +214,7 @@ export const presets = {
211
214
  contentOrigins: options.contentOrigins,
212
215
  embedder: options.embedder ?? {},
213
216
  ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
217
+ ...(options.permissions === undefined ? {} : { permissions: options.permissions }),
214
218
  }),
215
219
  } as const;
216
220
 
@@ -115,6 +115,12 @@ export const MCP_APPS_STYLE_VARIABLES: readonly string[] = [
115
115
  */
116
116
  export const PORTHOLE_STYLES = "porthole/styles";
117
117
 
118
+ /**
119
+ * Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
120
+ * host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
121
+ */
122
+ export const COPY_TEXT_METHOD = "porthole/copy-text";
123
+
118
124
  /** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
119
125
  export const SHELL_LOGGER = "porthole";
120
126
 
@@ -8,7 +8,12 @@ import {
8
8
  type JsonRpcRequest,
9
9
  type JsonRpcResponse,
10
10
  } from "./jsonrpc.ts";
11
- import { MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION, type HostContext } from "./mcp-apps.ts";
11
+ import {
12
+ COPY_TEXT_METHOD,
13
+ MCP_APPS_METHODS as M,
14
+ MCP_APPS_PROTOCOL_VERSION,
15
+ type HostContext,
16
+ } from "./mcp-apps.ts";
12
17
 
13
18
  /**
14
19
  * The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
@@ -42,6 +47,7 @@ const REQUESTS: Readonly<
42
47
  }),
43
48
  },
44
49
  [M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
50
+ [COPY_TEXT_METHOD]: { capability: "copyText", args: (params) => ({ text: params.text }) },
45
51
  [M.downloadFile]: {
46
52
  capability: "downloadFile",
47
53
  args: (params) => ({ contents: params.contents }),
@@ -63,7 +69,10 @@ function answer(
63
69
  outcome: CallOutcome<unknown>,
64
70
  ): { result: unknown } | { error: { code: number; message: string } } {
65
71
  const flagged =
66
- capability === "sendPrompt" || capability === "openLink" || capability === "downloadFile";
72
+ capability === "sendPrompt" ||
73
+ capability === "openLink" ||
74
+ capability === "downloadFile" ||
75
+ capability === "copyText";
67
76
  if (!outcome.ok) {
68
77
  return flagged
69
78
  ? { result: { isError: true } }
@@ -66,6 +66,8 @@ function words(request: ConfirmRequest): Words {
66
66
  return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
67
67
  case "readData":
68
68
  return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
69
+ case "copyText":
70
+ return { heading: "Copy this text?", accept: "Copy", detail: request.args.text };
69
71
  default:
70
72
  return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
71
73
  }
@@ -14,8 +14,10 @@ import {
14
14
  type ReactElement,
15
15
  type ReactNode,
16
16
  } from "react";
17
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
17
18
  import type {
18
19
  CallContext,
20
+ CapabilityHandler,
19
21
  CapabilityHandlers,
20
22
  Confirm,
21
23
  ConfirmAnswer,
@@ -25,11 +27,13 @@ import type {
25
27
  } from "../bridge/types.ts";
26
28
  import type { CapabilityName } from "../config/capabilities.ts";
27
29
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
30
+ import { writeClipboard } from "../host/clipboard.ts";
28
31
  import { createHost } from "../host/create-host.ts";
29
32
  import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
30
33
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
31
34
  import { ConfirmDialog } from "./dialog.tsx";
32
35
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
36
+ import { CopyToast, type CopyNotice } from "./toast.tsx";
33
37
 
34
38
  /**
35
39
  * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
@@ -50,6 +54,8 @@ interface PageState {
50
54
  readonly confirm?: Confirm;
51
55
  readonly hostInfo?: { readonly name: string; readonly version: string };
52
56
  readonly locator?: InstanceLocator;
57
+ /** The default `copyText`: copies on the host's page and shows the toast (P052). */
58
+ readonly copyText?: CapabilityHandler<"copyText">;
53
59
  }
54
60
 
55
61
  /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
@@ -69,6 +75,8 @@ export interface PortholeProviderProps {
69
75
  readonly locator?: InstanceLocator;
70
76
  /** A class for the default dialog, to restyle it. */
71
77
  readonly dialogClassName?: string;
78
+ /** A class for the default copy toast, to restyle it. */
79
+ readonly toastClassName?: string;
72
80
  readonly children?: ReactNode;
73
81
  }
74
82
 
@@ -96,16 +104,26 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
96
104
  ),
97
105
  [],
98
106
  );
107
+ // The default copyText: the host's page writes the clipboard (in the frame's click, which
108
+ // activated this page too) and shows the person what went on it, or offers to copy it.
109
+ const [notice, setNotice] = useState<CopyNotice | null>(null);
110
+ const copyText = useCallback<CapabilityHandler<"copyText">>(async ({ text }, context) => {
111
+ const copied = await writeClipboard(text, globalThis.document);
112
+ setNotice({ text, copied, title: context.title });
113
+ return copied;
114
+ }, []);
115
+ const closeNotice = useCallback(() => setNotice(null), []);
99
116
  const { hostInfo, locator } = props;
100
117
  const value = useMemo<PageState>(
101
118
  () => ({
102
119
  live,
103
120
  heights,
104
121
  confirm,
122
+ copyText,
105
123
  ...(hostInfo === undefined ? {} : { hostInfo }),
106
124
  ...(locator === undefined ? {} : { locator }),
107
125
  }),
108
- [live, heights, confirm, hostInfo, locator],
126
+ [live, heights, confirm, copyText, hostInfo, locator],
109
127
  );
110
128
  const current = queue[0] ?? null;
111
129
  return (
@@ -125,6 +143,17 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
125
143
  }}
126
144
  />
127
145
  ) : null}
146
+ <CopyToast
147
+ notice={notice}
148
+ {...(props.toastClassName === undefined ? {} : { className: props.toastClassName })}
149
+ onClose={closeNotice}
150
+ onCopy={(failed) => {
151
+ // A click on the host's own page: the write has the gesture it needs now.
152
+ void writeClipboard(failed.text, globalThis.document).then((copied) =>
153
+ setNotice({ ...failed, copied }),
154
+ );
155
+ }}
156
+ />
128
157
  </Page.Provider>
129
158
  );
130
159
  }
@@ -186,6 +215,21 @@ export interface PortholeFrameProps {
186
215
  * restyle): the running frame reads them again.
187
216
  */
188
217
  readonly themeKey?: string | number;
218
+ /**
219
+ * Annotations, where the config declares `annotate` (P053): what the person may point at now
220
+ * (elements, text, or both), or null to stop. A picked element ends element picking: set it
221
+ * again to pick again.
222
+ */
223
+ readonly annotating?: AnnotateMode | null;
224
+ /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
225
+ readonly annotations?: readonly AnnotationMark[];
226
+ /** The mark to bring into view and make the active one; each new id focuses it. */
227
+ readonly focusedAnnotation?: string | null;
228
+ /**
229
+ * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
230
+ * pick, where marks are. The content's word: UI hints, never permissions.
231
+ */
232
+ readonly onAnnotation?: (report: AnnotationReport) => void;
189
233
  /** Draws the placeholder instead of the default one. */
190
234
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
191
235
  readonly className?: string;
@@ -371,6 +415,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
371
415
  case "outcome":
372
416
  frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
417
  return;
418
+ case "annotation":
419
+ frame.onAnnotation?.(detail as DisplayEvents["annotation"]);
420
+ return;
374
421
  }
375
422
  },
376
423
  );
@@ -397,6 +444,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
397
444
  named(context),
398
445
  );
399
446
  }
447
+ // Copy buttons without a handler of the host's: the provider's, which shows a toast.
448
+ if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
449
+ const fallback = page.copyText;
450
+ if (fallback !== undefined) {
451
+ handlers.copyText = (args, context) =>
452
+ fallback(args as { readonly text: string }, named(context));
453
+ }
454
+ }
400
455
  const asks = first.confirm !== undefined || page.confirm !== undefined;
401
456
  let display: DisplayHandle;
402
457
  try {
@@ -435,10 +490,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
435
490
  "refused",
436
491
  "muted",
437
492
  "outcome",
493
+ "annotation",
438
494
  ];
439
495
  for (const event of events) {
440
496
  display.on(event, (detail) => notify(event, detail));
441
497
  }
498
+ if (config.capabilities.annotate !== undefined) {
499
+ applyAnnotations(display, first);
500
+ }
442
501
  if (first.content !== undefined) {
443
502
  display.setContent(first.content, { partial: first.partial === true });
444
503
  }
@@ -455,6 +514,39 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
455
514
  handle.current?.setContent(content, { partial: partial === true });
456
515
  }
457
516
  }, [content, partial]);
517
+ // Annotations follow their props (a new display gets them all when it is made, above).
518
+ const annotates = config.capabilities.annotate !== undefined;
519
+ const { annotating, annotations, focusedAnnotation } = frame;
520
+ const mode =
521
+ annotating === undefined || annotating === null
522
+ ? ""
523
+ : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
524
+ useEffect(() => {
525
+ const display = handle.current;
526
+ if (!annotates || display === null) {
527
+ return;
528
+ }
529
+ const wanted = mode === "" ? null : (JSON.parse(mode) as [boolean, boolean]);
530
+ attempt(
531
+ () =>
532
+ wanted === null
533
+ ? display.annotations.stop()
534
+ : display.annotations.start({ elements: wanted[0], text: wanted[1] }),
535
+ latest(),
536
+ );
537
+ }, [annotates, mode]);
538
+ useEffect(() => {
539
+ const display = handle.current;
540
+ if (annotates && display !== null) {
541
+ attempt(() => display.annotations.show(annotations ?? []), latest());
542
+ }
543
+ }, [annotates, annotations]);
544
+ useEffect(() => {
545
+ const display = handle.current;
546
+ if (annotates && display !== null && typeof focusedAnnotation === "string") {
547
+ attempt(() => display.annotations.focus(focusedAnnotation), latest());
548
+ }
549
+ }, [annotates, focusedAnnotation]);
458
550
  useEffect(() => {
459
551
  handle.current?.setTheme(scheme);
460
552
  }, [scheme]);
@@ -484,3 +576,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
484
576
  </div>
485
577
  );
486
578
  }
579
+
580
+ /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
581
+ function attempt(run: () => void, frame: PortholeFrameProps): void {
582
+ try {
583
+ run();
584
+ } catch (error) {
585
+ frame.onError?.(error instanceof Error ? error.message : String(error));
586
+ }
587
+ }
588
+
589
+ /** A new display's annotations, from the props as they are. */
590
+ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): void {
591
+ attempt(() => {
592
+ if (frame.annotations !== undefined && frame.annotations.length > 0) {
593
+ display.annotations.show(frame.annotations);
594
+ }
595
+ if (typeof frame.focusedAnnotation === "string") {
596
+ display.annotations.focus(frame.focusedAnnotation);
597
+ }
598
+ if (frame.annotating !== undefined && frame.annotating !== null) {
599
+ display.annotations.start(frame.annotating);
600
+ }
601
+ }, frame);
602
+ }
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
3
  * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
4
  *
5
- * <PortholeProvider> // per page: live-frame cap, heights, the dialog
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
6
6
  * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
7
  * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
8
  * theme={tokensFor} onError={report} />
@@ -17,4 +17,12 @@ export {
17
17
  type PortholeProviderProps,
18
18
  } from "./frame.tsx";
19
19
  export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
20
+ export {
21
+ CopyToast,
22
+ TOAST_CHARS,
23
+ TOAST_MS,
24
+ clipped,
25
+ type CopyNotice,
26
+ type CopyToastProps,
27
+ } from "./toast.tsx";
20
28
  export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -0,0 +1,96 @@
1
+ import { useEffect, type ReactElement } from "react";
2
+
3
+ /**
4
+ * The default copy notice (P052): when content's `copyText` copied something, the host's page
5
+ * shows the person what went on their clipboard, clipped, in a small toast outside every frame.
6
+ * If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
7
+ * host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
8
+ * styles it through the `porthole-toast` class and data attributes.
9
+ */
10
+
11
+ /** How long a toast for a copy that worked stays. */
12
+ export const TOAST_MS = 5_000;
13
+ /** The most of the copied text a toast shows. */
14
+ export const TOAST_CHARS = 280;
15
+ const TOAST_LINES = 4;
16
+
17
+ export interface CopyNotice {
18
+ /** The whole text copied (or to copy). */
19
+ readonly text: string;
20
+ /** Whether it is on the clipboard. */
21
+ readonly copied: boolean;
22
+ /** The display's title, as the host named it. */
23
+ readonly title: string;
24
+ }
25
+
26
+ export interface CopyToastProps {
27
+ readonly notice: CopyNotice | null;
28
+ /** The person asked to copy it again (it failed), from this click. */
29
+ readonly onCopy: (notice: CopyNotice) => void;
30
+ readonly onClose: () => void;
31
+ readonly className?: string;
32
+ }
33
+
34
+ /** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
35
+ export function clipped(text: string): { readonly shown: string; readonly clipped: boolean } {
36
+ const lines = text.split("\n");
37
+ let shown = lines.slice(0, TOAST_LINES).join("\n");
38
+ if (shown.length > TOAST_CHARS) {
39
+ shown = shown.slice(0, TOAST_CHARS);
40
+ }
41
+ return shown.length < text.length
42
+ ? { shown: `${shown}…`, clipped: true }
43
+ : { shown, clipped: false };
44
+ }
45
+
46
+ const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
47
+ .porthole-toast p{margin:0 0 6px}
48
+ .porthole-toast [data-porthole-source]{opacity:.72}
49
+ .porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
50
+ .porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
51
+ .porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
52
+
53
+ export function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement {
54
+ useEffect(() => {
55
+ if (notice === null || !notice.copied) {
56
+ return;
57
+ }
58
+ const timer = setTimeout(onClose, TOAST_MS);
59
+ return () => clearTimeout(timer);
60
+ }, [notice, onClose]);
61
+ const view = notice === null ? null : clipped(notice.text);
62
+ return (
63
+ <div
64
+ role="status"
65
+ aria-live="polite"
66
+ className={className === undefined ? "porthole-toast" : `porthole-toast ${className}`}
67
+ data-porthole-toast=""
68
+ data-porthole-copied={notice === null ? "" : String(notice.copied)}
69
+ hidden={notice === null}
70
+ >
71
+ <style>{STYLE}</style>
72
+ {notice === null || view === null ? null : (
73
+ <>
74
+ <p>
75
+ <strong>{notice.copied ? "Copied" : "Not copied yet"}</strong>{" "}
76
+ <span data-porthole-source="">{`from “${notice.title}”`}</span>
77
+ </p>
78
+ <pre data-porthole-copied-text="">{view.shown}</pre>
79
+ {view.clipped ? (
80
+ <p data-porthole-length="">{`${notice.text.length.toLocaleString("en-US")} characters in all`}</p>
81
+ ) : null}
82
+ <menu>
83
+ {notice.copied ? null : (
84
+ <button type="button" data-porthole-copy="" onClick={() => onCopy(notice)}>
85
+ Copy
86
+ </button>
87
+ )}
88
+ <button type="button" data-porthole-close="" onClick={onClose}>
89
+ {notice.copied ? "OK" : "Close"}
90
+ </button>
91
+ </menu>
92
+ </>
93
+ )}
94
+ </div>
95
+ );
96
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The hashing `/server` needs, in plain JavaScript over web-standard types (`Uint8Array`,
3
+ * `TextEncoder`, `crypto.getRandomValues`), so it runs the same on Node, Cloudflare Workers and
4
+ * any other runtime (P044). Synchronous on purpose: Web Crypto's HMAC is async, and an origin
5
+ * (`originFor`) is computed synchronously. SHA-256 per FIPS 180-4, HMAC per RFC 2104; checked
6
+ * against the standard vectors and `node:crypto` in `test/crypto.test.ts`.
7
+ */
8
+
9
+ const K = new Uint32Array([
10
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
11
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
12
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
13
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
14
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
15
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
16
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
17
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
18
+ ]);
19
+
20
+ const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
21
+
22
+ export function sha256(data: Uint8Array): Uint8Array {
23
+ const length = data.length;
24
+ // Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
25
+ const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
26
+ padded.set(data);
27
+ padded[length] = 0x80;
28
+ const view = new DataView(padded.buffer);
29
+ view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
30
+ view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
31
+
32
+ const h = new Uint32Array([
33
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
34
+ ]);
35
+ const w = new Uint32Array(64);
36
+ for (let offset = 0; offset < padded.length; offset += 64) {
37
+ for (let i = 0; i < 16; i += 1) {
38
+ w[i] = view.getUint32(offset + i * 4, false);
39
+ }
40
+ for (let i = 16; i < 64; i += 1) {
41
+ const a = w[i - 15] as number;
42
+ const b = w[i - 2] as number;
43
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
44
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
45
+ w[i] = ((w[i - 16] as number) + s0 + (w[i - 7] as number) + s1) >>> 0;
46
+ }
47
+ let [a, b, c, d, e, f, g, hh] = h as unknown as number[] as [
48
+ number,
49
+ number,
50
+ number,
51
+ number,
52
+ number,
53
+ number,
54
+ number,
55
+ number,
56
+ ];
57
+ for (let i = 0; i < 64; i += 1) {
58
+ const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
59
+ const ch = (e & f) ^ (~e & g);
60
+ const t1 = (hh + s1 + ch + (K[i] as number) + (w[i] as number)) >>> 0;
61
+ const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
62
+ const maj = (a & b) ^ (a & c) ^ (b & c);
63
+ const t2 = (s0 + maj) >>> 0;
64
+ hh = g;
65
+ g = f;
66
+ f = e;
67
+ e = (d + t1) >>> 0;
68
+ d = c;
69
+ c = b;
70
+ b = a;
71
+ a = (t1 + t2) >>> 0;
72
+ }
73
+ const next = [a, b, c, d, e, f, g, hh];
74
+ for (let i = 0; i < 8; i += 1) {
75
+ h[i] = ((h[i] as number) + (next[i] as number)) >>> 0;
76
+ }
77
+ }
78
+ const out = new Uint8Array(32);
79
+ const outView = new DataView(out.buffer);
80
+ for (let i = 0; i < 8; i += 1) {
81
+ outView.setUint32(i * 4, h[i] as number, false);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ const BLOCK = 64;
87
+
88
+ export function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array {
89
+ const block = new Uint8Array(BLOCK);
90
+ block.set(key.length > BLOCK ? sha256(key) : key);
91
+ const inner = new Uint8Array(BLOCK + message.length);
92
+ const outer = new Uint8Array(BLOCK + 32);
93
+ for (let i = 0; i < BLOCK; i += 1) {
94
+ inner[i] = (block[i] as number) ^ 0x36;
95
+ outer[i] = (block[i] as number) ^ 0x5c;
96
+ }
97
+ inner.set(message, BLOCK);
98
+ outer.set(sha256(inner), BLOCK);
99
+ return sha256(outer);
100
+ }
101
+
102
+ const encoder = new TextEncoder();
103
+ const decoder = new TextDecoder();
104
+
105
+ export const utf8 = (text: string): Uint8Array => encoder.encode(text);
106
+ export const fromUtf8 = (bytes: Uint8Array): string => decoder.decode(bytes);
107
+
108
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
109
+
110
+ /** Base64url without padding (RFC 4648 §5). */
111
+ export function base64url(bytes: Uint8Array): string {
112
+ let out = "";
113
+ for (let i = 0; i < bytes.length; i += 3) {
114
+ const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
115
+ out += B64[(n >>> 18) & 63];
116
+ out += B64[(n >>> 12) & 63];
117
+ if (i + 1 < bytes.length) {
118
+ out += B64[(n >>> 6) & 63];
119
+ }
120
+ if (i + 2 < bytes.length) {
121
+ out += B64[n & 63];
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Decodes base64url without padding; null for anything else. */
128
+ export function fromBase64url(text: string): Uint8Array | null {
129
+ if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
130
+ return null;
131
+ }
132
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
133
+ let bits = 0;
134
+ let buffer = 0;
135
+ let index = 0;
136
+ for (const char of text) {
137
+ buffer = (buffer << 6) | B64.indexOf(char);
138
+ bits += 6;
139
+ if (bits >= 8) {
140
+ bits -= 8;
141
+ out[index] = (buffer >>> bits) & 0xff;
142
+ index += 1;
143
+ }
144
+ }
145
+ // The leftover bits of a canonical encoding are zero.
146
+ return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
147
+ }
148
+
149
+ /** Equal bytes, compared in time that depends only on the length. */
150
+ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
151
+ if (a.length !== b.length) {
152
+ return false;
153
+ }
154
+ let difference = 0;
155
+ for (let i = 0; i < a.length; i += 1) {
156
+ difference |= (a[i] as number) ^ (b[i] as number);
157
+ }
158
+ return difference === 0;
159
+ }
160
+
161
+ /** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
162
+ export function randomBytes(length: number): Uint8Array {
163
+ const bytes = new Uint8Array(length);
164
+ globalThis.crypto.getRandomValues(bytes);
165
+ return bytes;
166
+ }