@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.
- package/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +5 -3
- package/dist/compile/policy.js +8 -4
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.js +5 -0
- package/dist/config/validate.js +15 -1
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/guide/guide.js +8 -4
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +141 -0
- package/dist/host/outcome.d.ts +1 -0
- package/dist/host/outcome.js +19 -6
- package/dist/host/types.d.ts +34 -5
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +18 -0
- package/dist/react/frame.js +82 -2
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +1 -1
- package/dist/server/pages.js +13 -5
- package/dist/server/server.js +162 -18
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +669 -0
- package/dist/shell/document.js +4 -1
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +38 -2
- package/package.json +2 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +8 -4
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +10 -0
- package/src/config/validate.ts +16 -1
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/guide/guide.ts +19 -4
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +155 -0
- package/src/host/outcome.ts +25 -7
- package/src/host/types.ts +39 -5
- package/src/index.ts +31 -1
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +117 -1
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +13 -4
- package/src/server/server.ts +190 -20
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/annotate.ts +669 -0
- package/src/shell/document.ts +6 -1
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +38 -2
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
|
|
3
3
|
* frame's policies, the shell, and the agent manual; the host mounts displays under it.
|
|
4
4
|
*/
|
|
5
|
-
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
5
|
+
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
6
6
|
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
7
7
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
|
|
8
|
-
export { URL_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
|
|
8
|
+
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
|
|
9
9
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
10
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
10
11
|
export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
|
|
11
12
|
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
12
13
|
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
@@ -19,7 +20,10 @@ export type { FrameLocation, FrameSource, InstanceLocator, InstanceRef, ServedDo
|
|
|
19
20
|
export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
|
|
20
21
|
export type { Transport } from "./transport/types.ts";
|
|
21
22
|
export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
|
|
22
|
-
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
|
|
23
|
+
export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
|
|
23
24
|
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
24
25
|
export { createHost } from "./host/create-host.ts";
|
|
25
|
-
export
|
|
26
|
+
export { writeClipboard } from "./host/clipboard.ts";
|
|
27
|
+
export type { AnnotationControls, DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
|
28
|
+
export { ANNOTATE_METHODS, ANNOTATION_LIMITS, type AnchorResult, type AnchorState, type AnnotateMode, type AnnotationAnchor, type AnnotationMark, type AnnotationRect, type AnnotationReport, type AnnotationSelector, type CssSelector, type TextPositionSelector, type TextQuoteSelector, } from "./annotate/types.ts";
|
|
29
|
+
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* frame's policies, the shell, and the agent manual; the host mounts displays under it.
|
|
4
4
|
*/
|
|
5
5
|
// The config and its vocabulary.
|
|
6
|
-
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
6
|
+
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
7
7
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
|
|
8
|
-
export { URL_SANDBOX_EXTRAS } from "./config/schema.js";
|
|
8
|
+
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
|
|
9
9
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
|
|
10
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.js";
|
|
10
11
|
// Presets.
|
|
11
12
|
export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
|
|
12
13
|
// The compiler.
|
|
@@ -17,5 +18,9 @@ export { generateGuide, generateToolDescription, } from "./guide/guide.js";
|
|
|
17
18
|
export { GUIDE_EXAMPLES } from "./guide/examples.js";
|
|
18
19
|
// Theme.
|
|
19
20
|
export { CHART_PALETTE, DEFAULT_THEME, DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES, resolveTheme, themeDefault, } from "./theme/variables.js";
|
|
20
|
-
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
|
|
21
|
+
export { COPY_TEXT_METHOD, MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "./protocol/mcp-apps.js";
|
|
21
22
|
export { createHost } from "./host/create-host.js";
|
|
23
|
+
export { writeClipboard } from "./host/clipboard.js";
|
|
24
|
+
// Annotations (P053): anchors, what a frame reports, and the host's checks on both.
|
|
25
|
+
export { ANNOTATE_METHODS, ANNOTATION_LIMITS, } from "./annotate/types.js";
|
|
26
|
+
export { parseAnchor, parseRect, parseReport } from "./annotate/validate.js";
|
package/dist/mcp/adapter.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { egressOrigins } from "../compile/policy.js";
|
|
2
2
|
import { JSON_RPC_ERRORS, parseJsonRpc, } from "../protocol/jsonrpc.js";
|
|
3
3
|
import { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, } from "../protocol/mcp-apps.js";
|
|
4
|
-
import { MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
|
|
4
|
+
import { COPY_TEXT_METHOD, MCP_APPS_STYLE_VARIABLES } from "../protocol/mcp-apps.js";
|
|
5
5
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
6
|
/** Levels of `notifications/message` that are errors: they go to the host as `reportError`. */
|
|
7
7
|
const ERROR_LEVELS = new Set(["error", "critical", "alert", "emergency"]);
|
|
@@ -183,6 +183,15 @@ function connect(session, options) {
|
|
|
183
183
|
: { structuredContent: params.structuredContent }),
|
|
184
184
|
}, () => ({}));
|
|
185
185
|
return;
|
|
186
|
+
case COPY_TEXT_METHOD:
|
|
187
|
+
// Porthole's own request (the standard has no clipboard request): the host copies, and
|
|
188
|
+
// shows the person what (P052).
|
|
189
|
+
if (typeof params.text !== "string") {
|
|
190
|
+
invalid("needs text");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
await call(request, "copyText", { text: params.text }, done);
|
|
194
|
+
return;
|
|
186
195
|
case MCP_APPS_METHODS.requestDisplayMode: {
|
|
187
196
|
if (typeof params.mode !== "string" || !DISPLAY_MODES.has(params.mode)) {
|
|
188
197
|
invalid("needs a display mode");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
-
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlSandboxExtra } from "../config/schema.ts";
|
|
2
|
+
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
|
|
3
3
|
/**
|
|
4
4
|
* The preset policy layer (P001): named configs with safe defaults. Every preset starts with
|
|
5
5
|
* egress closed and only the capabilities that carry nothing out of the frame (its size, its
|
|
@@ -36,6 +36,8 @@ export interface UrlPresetOptions {
|
|
|
36
36
|
readonly contentOrigins: readonly string[];
|
|
37
37
|
/** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
|
|
38
38
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
39
|
+
/** Features to delegate to the app (clipboard writes, full screen; P049). */
|
|
40
|
+
readonly permissions?: readonly UrlPermission[];
|
|
39
41
|
readonly limits?: Partial<Limits>;
|
|
40
42
|
readonly layout?: Partial<LayoutConfig>;
|
|
41
43
|
readonly embedder?: EmbedderConfig;
|
package/dist/presets/presets.js
CHANGED
|
@@ -136,5 +136,6 @@ export const presets = {
|
|
|
136
136
|
contentOrigins: options.contentOrigins,
|
|
137
137
|
embedder: options.embedder ?? {},
|
|
138
138
|
...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
|
|
139
|
+
...(options.permissions === undefined ? {} : { permissions: options.permissions }),
|
|
139
140
|
}),
|
|
140
141
|
};
|
|
@@ -47,6 +47,11 @@ export declare const MCP_APPS_STYLE_VARIABLES: readonly string[];
|
|
|
47
47
|
* that doesn't know the field ignores it (the host context is open to extra fields).
|
|
48
48
|
*/
|
|
49
49
|
export declare const PORTHOLE_STYLES = "porthole/styles";
|
|
50
|
+
/**
|
|
51
|
+
* Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
|
|
52
|
+
* host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
|
|
53
|
+
*/
|
|
54
|
+
export declare const COPY_TEXT_METHOD = "porthole/copy-text";
|
|
50
55
|
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
51
56
|
export declare const SHELL_LOGGER = "porthole";
|
|
52
57
|
/**
|
|
@@ -95,5 +95,10 @@ export const MCP_APPS_STYLE_VARIABLES = [
|
|
|
95
95
|
* that doesn't know the field ignores it (the host context is open to extra fields).
|
|
96
96
|
*/
|
|
97
97
|
export const PORTHOLE_STYLES = "porthole/styles";
|
|
98
|
+
/**
|
|
99
|
+
* Porthole's own request for copying text (`copyText`, P052): `{ text }`, answered `{}` when the
|
|
100
|
+
* host copied it and `{ isError: true }` when it didn't. The standard has no clipboard request.
|
|
101
|
+
*/
|
|
102
|
+
export const COPY_TEXT_METHOD = "porthole/copy-text";
|
|
98
103
|
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
99
104
|
export const SHELL_LOGGER = "porthole";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { JSON_RPC_ERRORS, parseJsonRpc, } from "./jsonrpc.js";
|
|
2
|
-
import { MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION } from "./mcp-apps.js";
|
|
2
|
+
import { COPY_TEXT_METHOD, MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION, } from "./mcp-apps.js";
|
|
3
3
|
/**
|
|
4
4
|
* The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
|
|
5
5
|
* the official App SDK speaks too. Every message is checked before use; every power goes through
|
|
@@ -28,6 +28,7 @@ const REQUESTS = {
|
|
|
28
28
|
}),
|
|
29
29
|
},
|
|
30
30
|
[M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
|
|
31
|
+
[COPY_TEXT_METHOD]: { capability: "copyText", args: (params) => ({ text: params.text }) },
|
|
31
32
|
[M.downloadFile]: {
|
|
32
33
|
capability: "downloadFile",
|
|
33
34
|
args: (params) => ({ contents: params.contents }),
|
|
@@ -44,7 +45,10 @@ const REQUESTS = {
|
|
|
44
45
|
};
|
|
45
46
|
/** What a capability's outcome looks like to the view. */
|
|
46
47
|
function answer(capability, outcome) {
|
|
47
|
-
const flagged = capability === "sendPrompt" ||
|
|
48
|
+
const flagged = capability === "sendPrompt" ||
|
|
49
|
+
capability === "openLink" ||
|
|
50
|
+
capability === "downloadFile" ||
|
|
51
|
+
capability === "copyText";
|
|
48
52
|
if (!outcome.ok) {
|
|
49
53
|
return flagged
|
|
50
54
|
? { result: { isError: true } }
|
package/dist/react/dialog.js
CHANGED
|
@@ -47,6 +47,8 @@ function words(request) {
|
|
|
47
47
|
return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
|
|
48
48
|
case "readData":
|
|
49
49
|
return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
|
|
50
|
+
case "copyText":
|
|
51
|
+
return { heading: "Copy this text?", accept: "Copy", detail: request.args.text };
|
|
50
52
|
default:
|
|
51
53
|
return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
|
|
52
54
|
}
|
package/dist/react/frame.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactElement, type ReactNode } from "react";
|
|
2
|
+
import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
|
|
2
3
|
import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
|
|
3
4
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
4
5
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
@@ -21,6 +22,8 @@ export interface PortholeProviderProps {
|
|
|
21
22
|
readonly locator?: InstanceLocator;
|
|
22
23
|
/** A class for the default dialog, to restyle it. */
|
|
23
24
|
readonly dialogClassName?: string;
|
|
25
|
+
/** A class for the default copy toast, to restyle it. */
|
|
26
|
+
readonly toastClassName?: string;
|
|
24
27
|
readonly children?: ReactNode;
|
|
25
28
|
}
|
|
26
29
|
export declare function PortholeProvider(props: PortholeProviderProps): ReactElement;
|
|
@@ -83,6 +86,21 @@ export interface PortholeFrameProps {
|
|
|
83
86
|
* restyle): the running frame reads them again.
|
|
84
87
|
*/
|
|
85
88
|
readonly themeKey?: string | number;
|
|
89
|
+
/**
|
|
90
|
+
* Annotations, where the config declares `annotate` (P053): what the person may point at now
|
|
91
|
+
* (elements, text, or both), or null to stop. A picked element ends element picking: set it
|
|
92
|
+
* again to pick again.
|
|
93
|
+
*/
|
|
94
|
+
readonly annotating?: AnnotateMode | null;
|
|
95
|
+
/** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
|
|
96
|
+
readonly annotations?: readonly AnnotationMark[];
|
|
97
|
+
/** The mark to bring into view and make the active one; each new id focuses it. */
|
|
98
|
+
readonly focusedAnnotation?: string | null;
|
|
99
|
+
/**
|
|
100
|
+
* What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
|
|
101
|
+
* pick, where marks are. The content's word: UI hints, never permissions.
|
|
102
|
+
*/
|
|
103
|
+
readonly onAnnotation?: (report: AnnotationReport) => void;
|
|
86
104
|
/** Draws the placeholder instead of the default one. */
|
|
87
105
|
readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
|
|
88
106
|
readonly className?: string;
|
package/dist/react/frame.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
3
|
+
import { writeClipboard } from "../host/clipboard.js";
|
|
3
4
|
import { createHost } from "../host/create-host.js";
|
|
4
5
|
import { ConfirmDialog } from "./dialog.js";
|
|
5
6
|
import { createLiveFrames } from "./live.js";
|
|
7
|
+
import { CopyToast } from "./toast.js";
|
|
6
8
|
/**
|
|
7
9
|
* The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
|
|
8
10
|
* run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
|
|
@@ -28,14 +30,24 @@ export function PortholeProvider(props) {
|
|
|
28
30
|
const confirm = useCallback((request, proceed) => own.current !== undefined
|
|
29
31
|
? own.current(request, proceed)
|
|
30
32
|
: new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
|
|
33
|
+
// The default copyText: the host's page writes the clipboard (in the frame's click, which
|
|
34
|
+
// activated this page too) and shows the person what went on it, or offers to copy it.
|
|
35
|
+
const [notice, setNotice] = useState(null);
|
|
36
|
+
const copyText = useCallback(async ({ text }, context) => {
|
|
37
|
+
const copied = await writeClipboard(text, globalThis.document);
|
|
38
|
+
setNotice({ text, copied, title: context.title });
|
|
39
|
+
return copied;
|
|
40
|
+
}, []);
|
|
41
|
+
const closeNotice = useCallback(() => setNotice(null), []);
|
|
31
42
|
const { hostInfo, locator } = props;
|
|
32
43
|
const value = useMemo(() => ({
|
|
33
44
|
live,
|
|
34
45
|
heights,
|
|
35
46
|
confirm,
|
|
47
|
+
copyText,
|
|
36
48
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
37
49
|
...(locator === undefined ? {} : { locator }),
|
|
38
|
-
}), [live, heights, confirm, hostInfo, locator]);
|
|
50
|
+
}), [live, heights, confirm, copyText, hostInfo, locator]);
|
|
39
51
|
const current = queue[0] ?? null;
|
|
40
52
|
return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
|
|
41
53
|
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
@@ -44,7 +56,10 @@ export function PortholeProvider(props) {
|
|
|
44
56
|
}
|
|
45
57
|
current?.resolve(accepted ? "accepted" : "declined");
|
|
46
58
|
setQueue((waiting) => waiting.slice(1));
|
|
47
|
-
} })) : null
|
|
59
|
+
} })) : null, _jsx(CopyToast, { notice: notice, ...(props.toastClassName === undefined ? {} : { className: props.toastClassName }), onClose: closeNotice, onCopy: (failed) => {
|
|
60
|
+
// A click on the host's own page: the write has the gesture it needs now.
|
|
61
|
+
void writeClipboard(failed.text, globalThis.document).then((copied) => setNotice({ ...failed, copied }));
|
|
62
|
+
} })] }));
|
|
48
63
|
}
|
|
49
64
|
const PLACEHOLDER_STYLE = {
|
|
50
65
|
position: "absolute",
|
|
@@ -163,6 +178,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
163
178
|
case "outcome":
|
|
164
179
|
frame.onOutcome?.(detail);
|
|
165
180
|
return;
|
|
181
|
+
case "annotation":
|
|
182
|
+
frame.onAnnotation?.(detail);
|
|
183
|
+
return;
|
|
166
184
|
}
|
|
167
185
|
});
|
|
168
186
|
useEffect(() => {
|
|
@@ -183,6 +201,13 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
183
201
|
for (const name of Object.keys(first.handlers ?? {})) {
|
|
184
202
|
handlers[name] = (args, context) => latest().handlers?.[name]?.(args, named(context));
|
|
185
203
|
}
|
|
204
|
+
// Copy buttons without a handler of the host's: the provider's, which shows a toast.
|
|
205
|
+
if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
|
|
206
|
+
const fallback = page.copyText;
|
|
207
|
+
if (fallback !== undefined) {
|
|
208
|
+
handlers.copyText = (args, context) => fallback(args, named(context));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
186
211
|
const asks = first.confirm !== undefined || page.confirm !== undefined;
|
|
187
212
|
let display;
|
|
188
213
|
try {
|
|
@@ -218,10 +243,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
218
243
|
"refused",
|
|
219
244
|
"muted",
|
|
220
245
|
"outcome",
|
|
246
|
+
"annotation",
|
|
221
247
|
];
|
|
222
248
|
for (const event of events) {
|
|
223
249
|
display.on(event, (detail) => notify(event, detail));
|
|
224
250
|
}
|
|
251
|
+
if (config.capabilities.annotate !== undefined) {
|
|
252
|
+
applyAnnotations(display, first);
|
|
253
|
+
}
|
|
225
254
|
if (first.content !== undefined) {
|
|
226
255
|
display.setContent(first.content, { partial: first.partial === true });
|
|
227
256
|
}
|
|
@@ -237,6 +266,34 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
237
266
|
handle.current?.setContent(content, { partial: partial === true });
|
|
238
267
|
}
|
|
239
268
|
}, [content, partial]);
|
|
269
|
+
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
270
|
+
const annotates = config.capabilities.annotate !== undefined;
|
|
271
|
+
const { annotating, annotations, focusedAnnotation } = frame;
|
|
272
|
+
const mode = annotating === undefined || annotating === null
|
|
273
|
+
? ""
|
|
274
|
+
: JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const display = handle.current;
|
|
277
|
+
if (!annotates || display === null) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const wanted = mode === "" ? null : JSON.parse(mode);
|
|
281
|
+
attempt(() => wanted === null
|
|
282
|
+
? display.annotations.stop()
|
|
283
|
+
: display.annotations.start({ elements: wanted[0], text: wanted[1] }), latest());
|
|
284
|
+
}, [annotates, mode]);
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
const display = handle.current;
|
|
287
|
+
if (annotates && display !== null) {
|
|
288
|
+
attempt(() => display.annotations.show(annotations ?? []), latest());
|
|
289
|
+
}
|
|
290
|
+
}, [annotates, annotations]);
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
const display = handle.current;
|
|
293
|
+
if (annotates && display !== null && typeof focusedAnnotation === "string") {
|
|
294
|
+
attempt(() => display.annotations.focus(focusedAnnotation), latest());
|
|
295
|
+
}
|
|
296
|
+
}, [annotates, focusedAnnotation]);
|
|
240
297
|
useEffect(() => {
|
|
241
298
|
handle.current?.setTheme(scheme);
|
|
242
299
|
}, [scheme]);
|
|
@@ -253,3 +310,26 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
253
310
|
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
254
311
|
}, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
|
|
255
312
|
}
|
|
313
|
+
/** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
|
|
314
|
+
function attempt(run, frame) {
|
|
315
|
+
try {
|
|
316
|
+
run();
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
frame.onError?.(error instanceof Error ? error.message : String(error));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/** A new display's annotations, from the props as they are. */
|
|
323
|
+
function applyAnnotations(display, frame) {
|
|
324
|
+
attempt(() => {
|
|
325
|
+
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
326
|
+
display.annotations.show(frame.annotations);
|
|
327
|
+
}
|
|
328
|
+
if (typeof frame.focusedAnnotation === "string") {
|
|
329
|
+
display.annotations.focus(frame.focusedAnnotation);
|
|
330
|
+
}
|
|
331
|
+
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
332
|
+
display.annotations.start(frame.annotating);
|
|
333
|
+
}
|
|
334
|
+
}, frame);
|
|
335
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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} />
|
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, type PlaceholderProps, type PortholeFrameProps, type PortholeProviderProps, } from "./frame.tsx";
|
|
12
12
|
export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
|
|
13
|
+
export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, type CopyNotice, type CopyToastProps, } from "./toast.tsx";
|
|
13
14
|
export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
package/dist/react/index.js
CHANGED
|
@@ -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} />
|
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, } from "./frame.js";
|
|
12
12
|
export { ARM_DELAY_MS, ConfirmDialog } from "./dialog.js";
|
|
13
|
+
export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, } from "./toast.js";
|
|
13
14
|
export { admitFrame, createLiveFrames } from "./live.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The default copy notice (P052): when content's `copyText` copied something, the host's page
|
|
4
|
+
* shows the person what went on their clipboard, clipped, in a small toast outside every frame.
|
|
5
|
+
* If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
|
|
6
|
+
* host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
|
|
7
|
+
* styles it through the `porthole-toast` class and data attributes.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a toast for a copy that worked stays. */
|
|
10
|
+
export declare const TOAST_MS = 5000;
|
|
11
|
+
/** The most of the copied text a toast shows. */
|
|
12
|
+
export declare const TOAST_CHARS = 280;
|
|
13
|
+
export interface CopyNotice {
|
|
14
|
+
/** The whole text copied (or to copy). */
|
|
15
|
+
readonly text: string;
|
|
16
|
+
/** Whether it is on the clipboard. */
|
|
17
|
+
readonly copied: boolean;
|
|
18
|
+
/** The display's title, as the host named it. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CopyToastProps {
|
|
22
|
+
readonly notice: CopyNotice | null;
|
|
23
|
+
/** The person asked to copy it again (it failed), from this click. */
|
|
24
|
+
readonly onCopy: (notice: CopyNotice) => void;
|
|
25
|
+
readonly onClose: () => void;
|
|
26
|
+
readonly className?: string;
|
|
27
|
+
}
|
|
28
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
29
|
+
export declare function clipped(text: string): {
|
|
30
|
+
readonly shown: string;
|
|
31
|
+
readonly clipped: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from "react";
|
|
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
|
+
/** How long a toast for a copy that worked stays. */
|
|
11
|
+
export const TOAST_MS = 5_000;
|
|
12
|
+
/** The most of the copied text a toast shows. */
|
|
13
|
+
export const TOAST_CHARS = 280;
|
|
14
|
+
const TOAST_LINES = 4;
|
|
15
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
16
|
+
export function clipped(text) {
|
|
17
|
+
const lines = text.split("\n");
|
|
18
|
+
let shown = lines.slice(0, TOAST_LINES).join("\n");
|
|
19
|
+
if (shown.length > TOAST_CHARS) {
|
|
20
|
+
shown = shown.slice(0, TOAST_CHARS);
|
|
21
|
+
}
|
|
22
|
+
return shown.length < text.length
|
|
23
|
+
? { shown: `${shown}…`, clipped: true }
|
|
24
|
+
: { shown, clipped: false };
|
|
25
|
+
}
|
|
26
|
+
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}
|
|
27
|
+
.porthole-toast p{margin:0 0 6px}
|
|
28
|
+
.porthole-toast [data-porthole-source]{opacity:.72}
|
|
29
|
+
.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}
|
|
30
|
+
.porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
|
|
31
|
+
.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}`;
|
|
32
|
+
export function CopyToast({ notice, onCopy, onClose, className }) {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (notice === null || !notice.copied) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const timer = setTimeout(onClose, TOAST_MS);
|
|
38
|
+
return () => clearTimeout(timer);
|
|
39
|
+
}, [notice, onClose]);
|
|
40
|
+
const view = notice === null ? null : clipped(notice.text);
|
|
41
|
+
return (_jsxs("div", { role: "status", "aria-live": "polite", className: className === undefined ? "porthole-toast" : `porthole-toast ${className}`, "data-porthole-toast": "", "data-porthole-copied": notice === null ? "" : String(notice.copied), hidden: notice === null, children: [_jsx("style", { children: STYLE }), notice === null || view === null ? null : (_jsxs(_Fragment, { children: [_jsxs("p", { children: [_jsx("strong", { children: notice.copied ? "Copied" : "Not copied yet" }), " ", _jsx("span", { "data-porthole-source": "", children: `from “${notice.title}”` })] }), _jsx("pre", { "data-porthole-copied-text": "", children: view.shown }), view.clipped ? (_jsx("p", { "data-porthole-length": "", children: `${notice.text.length.toLocaleString("en-US")} characters in all` })) : null, _jsxs("menu", { children: [notice.copied ? null : (_jsx("button", { type: "button", "data-porthole-copy": "", onClick: () => onCopy(notice), children: "Copy" })), _jsx("button", { type: "button", "data-porthole-close": "", onClick: onClose, children: notice.copied ? "OK" : "Close" })] })] }))] }));
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export declare function sha256(data: Uint8Array): Uint8Array;
|
|
9
|
+
export declare function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array;
|
|
10
|
+
export declare const utf8: (text: string) => Uint8Array;
|
|
11
|
+
export declare const fromUtf8: (bytes: Uint8Array) => string;
|
|
12
|
+
/** Base64url without padding (RFC 4648 §5). */
|
|
13
|
+
export declare function base64url(bytes: Uint8Array): string;
|
|
14
|
+
/** Decodes base64url without padding; null for anything else. */
|
|
15
|
+
export declare function fromBase64url(text: string): Uint8Array | null;
|
|
16
|
+
/** Equal bytes, compared in time that depends only on the length. */
|
|
17
|
+
export declare function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean;
|
|
18
|
+
/** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
|
|
19
|
+
export declare function randomBytes(length: number): Uint8Array;
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
const K = new Uint32Array([
|
|
9
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
10
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
11
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
12
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
13
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
14
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
15
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
16
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
17
|
+
]);
|
|
18
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
19
|
+
export function sha256(data) {
|
|
20
|
+
const length = data.length;
|
|
21
|
+
// Padded to a multiple of 64 bytes: a 1 bit, zeros, then the length in bits (64-bit big-endian).
|
|
22
|
+
const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64);
|
|
23
|
+
padded.set(data);
|
|
24
|
+
padded[length] = 0x80;
|
|
25
|
+
const view = new DataView(padded.buffer);
|
|
26
|
+
view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false);
|
|
27
|
+
view.setUint32(padded.length - 4, (length * 8) >>> 0, false);
|
|
28
|
+
const h = new Uint32Array([
|
|
29
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
30
|
+
]);
|
|
31
|
+
const w = new Uint32Array(64);
|
|
32
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
33
|
+
for (let i = 0; i < 16; i += 1) {
|
|
34
|
+
w[i] = view.getUint32(offset + i * 4, false);
|
|
35
|
+
}
|
|
36
|
+
for (let i = 16; i < 64; i += 1) {
|
|
37
|
+
const a = w[i - 15];
|
|
38
|
+
const b = w[i - 2];
|
|
39
|
+
const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
|
|
40
|
+
const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
|
|
41
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
42
|
+
}
|
|
43
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
44
|
+
for (let i = 0; i < 64; i += 1) {
|
|
45
|
+
const s1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
46
|
+
const ch = (e & f) ^ (~e & g);
|
|
47
|
+
const t1 = (hh + s1 + ch + K[i] + w[i]) >>> 0;
|
|
48
|
+
const s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
49
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
50
|
+
const t2 = (s0 + maj) >>> 0;
|
|
51
|
+
hh = g;
|
|
52
|
+
g = f;
|
|
53
|
+
f = e;
|
|
54
|
+
e = (d + t1) >>> 0;
|
|
55
|
+
d = c;
|
|
56
|
+
c = b;
|
|
57
|
+
b = a;
|
|
58
|
+
a = (t1 + t2) >>> 0;
|
|
59
|
+
}
|
|
60
|
+
const next = [a, b, c, d, e, f, g, hh];
|
|
61
|
+
for (let i = 0; i < 8; i += 1) {
|
|
62
|
+
h[i] = (h[i] + next[i]) >>> 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const out = new Uint8Array(32);
|
|
66
|
+
const outView = new DataView(out.buffer);
|
|
67
|
+
for (let i = 0; i < 8; i += 1) {
|
|
68
|
+
outView.setUint32(i * 4, h[i], false);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const BLOCK = 64;
|
|
73
|
+
export function hmacSha256(key, message) {
|
|
74
|
+
const block = new Uint8Array(BLOCK);
|
|
75
|
+
block.set(key.length > BLOCK ? sha256(key) : key);
|
|
76
|
+
const inner = new Uint8Array(BLOCK + message.length);
|
|
77
|
+
const outer = new Uint8Array(BLOCK + 32);
|
|
78
|
+
for (let i = 0; i < BLOCK; i += 1) {
|
|
79
|
+
inner[i] = block[i] ^ 0x36;
|
|
80
|
+
outer[i] = block[i] ^ 0x5c;
|
|
81
|
+
}
|
|
82
|
+
inner.set(message, BLOCK);
|
|
83
|
+
outer.set(sha256(inner), BLOCK);
|
|
84
|
+
return sha256(outer);
|
|
85
|
+
}
|
|
86
|
+
const encoder = new TextEncoder();
|
|
87
|
+
const decoder = new TextDecoder();
|
|
88
|
+
export const utf8 = (text) => encoder.encode(text);
|
|
89
|
+
export const fromUtf8 = (bytes) => decoder.decode(bytes);
|
|
90
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
91
|
+
/** Base64url without padding (RFC 4648 §5). */
|
|
92
|
+
export function base64url(bytes) {
|
|
93
|
+
let out = "";
|
|
94
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
95
|
+
const n = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
96
|
+
out += B64[(n >>> 18) & 63];
|
|
97
|
+
out += B64[(n >>> 12) & 63];
|
|
98
|
+
if (i + 1 < bytes.length) {
|
|
99
|
+
out += B64[(n >>> 6) & 63];
|
|
100
|
+
}
|
|
101
|
+
if (i + 2 < bytes.length) {
|
|
102
|
+
out += B64[n & 63];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Decodes base64url without padding; null for anything else. */
|
|
108
|
+
export function fromBase64url(text) {
|
|
109
|
+
if (!/^[A-Za-z0-9_-]*$/u.test(text) || text.length % 4 === 1) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const out = new Uint8Array(Math.floor((text.length * 3) / 4));
|
|
113
|
+
let bits = 0;
|
|
114
|
+
let buffer = 0;
|
|
115
|
+
let index = 0;
|
|
116
|
+
for (const char of text) {
|
|
117
|
+
buffer = (buffer << 6) | B64.indexOf(char);
|
|
118
|
+
bits += 6;
|
|
119
|
+
if (bits >= 8) {
|
|
120
|
+
bits -= 8;
|
|
121
|
+
out[index] = (buffer >>> bits) & 0xff;
|
|
122
|
+
index += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// The leftover bits of a canonical encoding are zero.
|
|
126
|
+
return (buffer & ((1 << bits) - 1)) === 0 ? out : null;
|
|
127
|
+
}
|
|
128
|
+
/** Equal bytes, compared in time that depends only on the length. */
|
|
129
|
+
export function timingSafeEqual(a, b) {
|
|
130
|
+
if (a.length !== b.length) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
let difference = 0;
|
|
134
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
135
|
+
difference |= a[i] ^ b[i];
|
|
136
|
+
}
|
|
137
|
+
return difference === 0;
|
|
138
|
+
}
|
|
139
|
+
/** Random bytes from the runtime's CSPRNG (`crypto.getRandomValues`: Node, Workers, browsers). */
|
|
140
|
+
export function randomBytes(length) {
|
|
141
|
+
const bytes = new Uint8Array(length);
|
|
142
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
143
|
+
return bytes;
|
|
144
|
+
}
|