@botiverse/porthole 0.1.0 → 0.1.2
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/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- 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 +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- 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/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +31 -3
- package/src/react/frame.tsx +78 -14
- 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 +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- package/src/shell/runtime.ts +20 -4
package/src/host/types.ts
CHANGED
|
@@ -52,9 +52,44 @@ export interface MountOptions {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** What a display reports, by event name. */
|
|
55
|
+
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
56
|
+
export type FrameBlock =
|
|
57
|
+
/** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
|
|
58
|
+
| "framing"
|
|
59
|
+
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
60
|
+
| "cookies"
|
|
61
|
+
/** Its ticket was used or its session ended. */
|
|
62
|
+
| "session"
|
|
63
|
+
/** Its app didn't answer. */
|
|
64
|
+
| "upstream"
|
|
65
|
+
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
66
|
+
| "denied"
|
|
67
|
+
| "not-found"
|
|
68
|
+
| "error";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
|
|
72
|
+
* app's document loaded), it was blocked (and why), or nothing settled in
|
|
73
|
+
* `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
|
|
74
|
+
* after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
|
|
75
|
+
* tab") on anything but loaded.
|
|
76
|
+
*/
|
|
77
|
+
export type FrameOutcome =
|
|
78
|
+
| { readonly state: "loaded" }
|
|
79
|
+
| {
|
|
80
|
+
readonly state: "blocked";
|
|
81
|
+
readonly reason: FrameBlock;
|
|
82
|
+
readonly status?: number;
|
|
83
|
+
/** The host's own reason code, for `denied` (`Authorizer`). */
|
|
84
|
+
readonly code?: string;
|
|
85
|
+
}
|
|
86
|
+
| { readonly state: "timeout" };
|
|
87
|
+
|
|
55
88
|
export interface DisplayEvents {
|
|
56
|
-
/** The frame finished its handshake. */
|
|
89
|
+
/** The frame finished its handshake (url mode, which has none: its first document loaded). */
|
|
57
90
|
readonly initialized: undefined;
|
|
91
|
+
/** How the frame's first document went (`FrameOutcome`). */
|
|
92
|
+
readonly outcome: FrameOutcome;
|
|
58
93
|
/** The content's size, after the config's limits. */
|
|
59
94
|
readonly resize: { readonly height: number; readonly width?: number };
|
|
60
95
|
/** What the content threw, or what the sandbox blocked (its own words: untrusted). */
|
|
@@ -69,8 +104,13 @@ export interface DisplayEvents {
|
|
|
69
104
|
|
|
70
105
|
export interface DisplayControls {
|
|
71
106
|
readonly displayId: string;
|
|
72
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The theme flipped: the frame is re-themed in place, without a reload. The host's values are
|
|
109
|
+
* read two animation frames later, once a host that restyles its page asynchronously has.
|
|
110
|
+
*/
|
|
73
111
|
setTheme(scheme: ColorScheme): void;
|
|
112
|
+
/** The host's theme values changed (same scheme): read them now and re-theme the frame. */
|
|
113
|
+
refreshTheme(): void;
|
|
74
114
|
on<E extends keyof DisplayEvents>(
|
|
75
115
|
event: E,
|
|
76
116
|
listener: (detail: DisplayEvents[E]) => void,
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,21 @@ export type {
|
|
|
31
31
|
ThemeConfig,
|
|
32
32
|
ThemeValue,
|
|
33
33
|
} from "./config/schema.ts";
|
|
34
|
-
export {
|
|
34
|
+
export {
|
|
35
|
+
BUILT_IN_TOPICS,
|
|
36
|
+
ConfigError,
|
|
37
|
+
isOriginSource,
|
|
38
|
+
originSourceProblem,
|
|
39
|
+
validateConfig,
|
|
40
|
+
} from "./config/validate.ts";
|
|
41
|
+
export {
|
|
42
|
+
URL_PERMISSIONS,
|
|
43
|
+
URL_SANDBOX_EXTRAS,
|
|
44
|
+
type UrlPermission,
|
|
45
|
+
type UrlSandboxExtra,
|
|
46
|
+
} from "./config/schema.ts";
|
|
47
|
+
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
48
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
35
49
|
|
|
36
50
|
// Presets.
|
|
37
51
|
export {
|
|
@@ -103,7 +117,9 @@ export type {
|
|
|
103
117
|
CapabilityHandler,
|
|
104
118
|
CapabilityHandlers,
|
|
105
119
|
Confirm,
|
|
120
|
+
ConfirmAnswer,
|
|
106
121
|
ConfirmRequest,
|
|
122
|
+
Proceed,
|
|
107
123
|
HostFunctionBridge,
|
|
108
124
|
Refusal,
|
|
109
125
|
} from "./bridge/types.ts";
|
|
@@ -111,6 +127,8 @@ export { createHost } from "./host/create-host.ts";
|
|
|
111
127
|
export type {
|
|
112
128
|
DisplayControls,
|
|
113
129
|
DisplayEvents,
|
|
130
|
+
FrameBlock,
|
|
131
|
+
FrameOutcome,
|
|
114
132
|
DisplayHandle,
|
|
115
133
|
HostOptions,
|
|
116
134
|
MountOptions,
|
package/src/isolation/served.ts
CHANGED
|
@@ -32,6 +32,30 @@ export function isExactOrigin(origin: unknown): origin is string {
|
|
|
32
32
|
return typeof origin === "string" && parseOrigin(origin) !== null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A host page's origin, exactly as `location.origin` and a message's `event.origin` give it:
|
|
37
|
+
* `scheme://host[:port]`, lowercase, any scheme (an Electron or Tauri app's own registered scheme,
|
|
38
|
+
* `ferry://app`, `app://local`), never `"null"` (an opaque origin isn't one origin: every opaque
|
|
39
|
+
* page says "null") and never a path. The shell takes messages only from these, exactly.
|
|
40
|
+
*/
|
|
41
|
+
const HOST_ORIGIN =
|
|
42
|
+
/^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;
|
|
43
|
+
|
|
44
|
+
export function isHostOrigin(origin: unknown): origin is string {
|
|
45
|
+
return typeof origin === "string" && origin.length <= 300 && HOST_ORIGIN.test(origin);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
|
|
49
|
+
export function hostOriginProblem(origin: unknown): string | null {
|
|
50
|
+
if (isHostOrigin(origin)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
if (origin === "null") {
|
|
54
|
+
return 'the host page\'s origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)';
|
|
55
|
+
}
|
|
56
|
+
return `${JSON.stringify(origin)} is not an exact origin (scheme://host[:port], lowercase, no path)`;
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
/**
|
|
36
60
|
* Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
|
|
37
61
|
* it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
|
package/src/isolation/types.ts
CHANGED
|
@@ -57,8 +57,17 @@ export interface FrameLocation {
|
|
|
57
57
|
readonly url: string;
|
|
58
58
|
/** Its origin: the host takes messages only from this origin, from this frame. */
|
|
59
59
|
readonly origin: string;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the pages there report how loading went (`porthole/notifications/frame-status`):
|
|
62
|
+
* Porthole's `/server` does, so a page that never ran (refused framing) can be told apart from
|
|
63
|
+
* one that loaded. A host's own gateway may send the same message without this.
|
|
64
|
+
*/
|
|
65
|
+
readonly reports?: boolean;
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
/** The message a served page sends its host about loading (own-prefixed, P040). */
|
|
69
|
+
export const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
|
|
70
|
+
|
|
62
71
|
/**
|
|
63
72
|
* Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
|
|
64
73
|
* own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
|
|
@@ -74,4 +83,9 @@ export interface InstanceLocator {
|
|
|
74
83
|
*/
|
|
75
84
|
export type FrameSource =
|
|
76
85
|
| { readonly kind: "srcdoc"; readonly html: string }
|
|
77
|
-
| {
|
|
86
|
+
| {
|
|
87
|
+
readonly kind: "url";
|
|
88
|
+
readonly url: string;
|
|
89
|
+
readonly origin: string;
|
|
90
|
+
readonly reports?: boolean;
|
|
91
|
+
};
|
package/src/platform/types.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface SurfaceSpec {
|
|
|
17
17
|
readonly allow: string;
|
|
18
18
|
readonly referrerPolicy: "no-referrer";
|
|
19
19
|
readonly colorScheme: ColorScheme;
|
|
20
|
+
/**
|
|
21
|
+
* Fill the container's height (a url-mode app, which reports no size), at least
|
|
22
|
+
* `initialHeightPx`, instead of taking the content's.
|
|
23
|
+
*/
|
|
24
|
+
readonly fill?: boolean;
|
|
20
25
|
/** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
|
|
21
26
|
readonly initialHeightPx: number;
|
|
22
27
|
}
|
|
@@ -38,6 +43,8 @@ export interface Surface {
|
|
|
38
43
|
* framework starts it again from its content and reports it.
|
|
39
44
|
*/
|
|
40
45
|
onNavigated(listener: () => void): () => void;
|
|
46
|
+
/** Every load of the frame's document, the first included. */
|
|
47
|
+
onLoad(listener: () => void): () => void;
|
|
41
48
|
destroy(): void;
|
|
42
49
|
}
|
|
43
50
|
|
package/src/platform/web.ts
CHANGED
|
@@ -26,7 +26,9 @@ export const webPlatform: PlatformHost = {
|
|
|
26
26
|
border: "0",
|
|
27
27
|
background: "transparent",
|
|
28
28
|
colorScheme: spec.colorScheme,
|
|
29
|
-
|
|
29
|
+
...(spec.fill === true
|
|
30
|
+
? { height: "100%", minHeight: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }
|
|
31
|
+
: { height: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }),
|
|
30
32
|
});
|
|
31
33
|
const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
|
|
32
34
|
if (spec.source.kind === "srcdoc") {
|
|
@@ -36,9 +38,13 @@ export const webPlatform: PlatformHost = {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
const navigated = new Set<() => void>();
|
|
41
|
+
const loaded = new Set<() => void>();
|
|
39
42
|
let loads = 0;
|
|
40
43
|
frame.addEventListener("load", () => {
|
|
41
44
|
loads += 1;
|
|
45
|
+
for (const listener of loaded) {
|
|
46
|
+
listener();
|
|
47
|
+
}
|
|
42
48
|
if (loads > 1) {
|
|
43
49
|
for (const listener of navigated) {
|
|
44
50
|
listener();
|
|
@@ -53,7 +59,9 @@ export const webPlatform: PlatformHost = {
|
|
|
53
59
|
transport,
|
|
54
60
|
element: frame,
|
|
55
61
|
setHeight(px: number) {
|
|
56
|
-
|
|
62
|
+
if (spec.fill !== true) {
|
|
63
|
+
frame.style.height = `${Math.max(0, Math.round(px))}px`;
|
|
64
|
+
}
|
|
57
65
|
},
|
|
58
66
|
setColorScheme(scheme: ColorScheme) {
|
|
59
67
|
frame.style.colorScheme = scheme;
|
|
@@ -64,12 +72,17 @@ export const webPlatform: PlatformHost = {
|
|
|
64
72
|
).userActivation;
|
|
65
73
|
return activation === undefined ? undefined : activation.isActive;
|
|
66
74
|
},
|
|
75
|
+
onLoad(listener) {
|
|
76
|
+
loaded.add(listener);
|
|
77
|
+
return () => loaded.delete(listener);
|
|
78
|
+
},
|
|
67
79
|
onNavigated(listener) {
|
|
68
80
|
navigated.add(listener);
|
|
69
81
|
return () => navigated.delete(listener);
|
|
70
82
|
},
|
|
71
83
|
destroy() {
|
|
72
84
|
navigated.clear();
|
|
85
|
+
loaded.clear();
|
|
73
86
|
transport.close();
|
|
74
87
|
frame.remove();
|
|
75
88
|
},
|
package/src/presets/presets.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
Limits,
|
|
11
11
|
PortholeConfig,
|
|
12
12
|
ThemeValue,
|
|
13
|
+
UrlPermission,
|
|
14
|
+
UrlSandboxExtra,
|
|
13
15
|
} from "../config/schema.ts";
|
|
14
16
|
import { validateConfig } from "../config/validate.ts";
|
|
15
17
|
import { DEFAULT_THEME_EVENT, DEFAULT_THEME_VARIABLES } from "../theme/variables.ts";
|
|
@@ -27,10 +29,14 @@ export interface PresetOptions {
|
|
|
27
29
|
readonly capabilities?: { readonly [N in CapabilityName]?: CapabilityConfig | false };
|
|
28
30
|
/** Origins to open, added to the preset's (closed) egress. */
|
|
29
31
|
readonly egress?: EgressConfig;
|
|
30
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Theme defaults to change, variables to add, or (null) defaults to drop; the theme event's
|
|
34
|
+
* name; and whether whole documents follow the person's scheme (`ThemeConfig.documents`).
|
|
35
|
+
*/
|
|
31
36
|
readonly theme?: {
|
|
32
|
-
readonly variables?: Readonly<Record<string, ThemeValue>>;
|
|
37
|
+
readonly variables?: Readonly<Record<string, ThemeValue | null>>;
|
|
33
38
|
readonly event?: string;
|
|
39
|
+
readonly documents?: "declared" | "always";
|
|
34
40
|
};
|
|
35
41
|
readonly limits?: Partial<Limits>;
|
|
36
42
|
readonly layout?: Partial<LayoutConfig>;
|
|
@@ -44,6 +50,10 @@ export interface PresetOptions {
|
|
|
44
50
|
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
45
51
|
export interface UrlPresetOptions {
|
|
46
52
|
readonly contentOrigins: readonly string[];
|
|
53
|
+
/** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
|
|
54
|
+
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
55
|
+
/** Features to delegate to the app (clipboard writes, full screen; P049). */
|
|
56
|
+
readonly permissions?: readonly UrlPermission[];
|
|
47
57
|
readonly limits?: Partial<Limits>;
|
|
48
58
|
readonly layout?: Partial<LayoutConfig>;
|
|
49
59
|
readonly embedder?: EmbedderConfig;
|
|
@@ -116,6 +126,21 @@ const mergeEgress = (base: EgressConfig, extra: EgressConfig | undefined): Egres
|
|
|
116
126
|
return merged;
|
|
117
127
|
};
|
|
118
128
|
|
|
129
|
+
/** The default variables, with the host's changes and additions, less those it dropped (null). */
|
|
130
|
+
function themeVariables(
|
|
131
|
+
changes: Readonly<Record<string, ThemeValue | null>> | undefined,
|
|
132
|
+
): Record<string, ThemeValue> {
|
|
133
|
+
const variables: Record<string, ThemeValue> = { ...DEFAULT_THEME_VARIABLES };
|
|
134
|
+
for (const [name, value] of Object.entries(changes ?? {})) {
|
|
135
|
+
if (value === null) {
|
|
136
|
+
delete variables[name];
|
|
137
|
+
} else {
|
|
138
|
+
variables[name] = value;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return variables;
|
|
142
|
+
}
|
|
143
|
+
|
|
119
144
|
function build(
|
|
120
145
|
mode: PortholeConfig["mode"],
|
|
121
146
|
defaults: { readonly limits?: Partial<Limits> },
|
|
@@ -140,8 +165,9 @@ function build(
|
|
|
140
165
|
capabilities,
|
|
141
166
|
egress: mergeEgress({}, options.egress),
|
|
142
167
|
theme: {
|
|
143
|
-
variables:
|
|
168
|
+
variables: themeVariables(options.theme?.variables),
|
|
144
169
|
event: options.theme?.event ?? DEFAULT_THEME_EVENT,
|
|
170
|
+
...(options.theme?.documents === undefined ? {} : { documents: options.theme.documents }),
|
|
145
171
|
},
|
|
146
172
|
limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
|
|
147
173
|
layout: { ...BASE_LAYOUT, ...options.layout },
|
|
@@ -187,6 +213,8 @@ export const presets = {
|
|
|
187
213
|
guide: {},
|
|
188
214
|
contentOrigins: options.contentOrigins,
|
|
189
215
|
embedder: options.embedder ?? {},
|
|
216
|
+
...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
|
|
217
|
+
...(options.permissions === undefined ? {} : { permissions: options.permissions }),
|
|
190
218
|
}),
|
|
191
219
|
} as const;
|
|
192
220
|
|
package/src/react/frame.tsx
CHANGED
|
@@ -18,13 +18,15 @@ import type {
|
|
|
18
18
|
CallContext,
|
|
19
19
|
CapabilityHandlers,
|
|
20
20
|
Confirm,
|
|
21
|
+
ConfirmAnswer,
|
|
21
22
|
ConfirmRequest,
|
|
23
|
+
Proceed,
|
|
22
24
|
Refusal,
|
|
23
25
|
} from "../bridge/types.ts";
|
|
24
26
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
25
27
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
26
28
|
import { createHost } from "../host/create-host.ts";
|
|
27
|
-
import type { DisplayEvents, DisplayHandle } from "../host/types.ts";
|
|
29
|
+
import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
|
|
28
30
|
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
29
31
|
import { ConfirmDialog } from "./dialog.tsx";
|
|
30
32
|
import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
@@ -72,7 +74,8 @@ export interface PortholeProviderProps {
|
|
|
72
74
|
|
|
73
75
|
interface Pending {
|
|
74
76
|
readonly request: ConfirmRequest;
|
|
75
|
-
readonly resolve: (
|
|
77
|
+
readonly resolve: (answer: ConfirmAnswer) => void;
|
|
78
|
+
readonly proceed: Proceed;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
@@ -85,11 +88,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
85
88
|
own.current = props.confirm;
|
|
86
89
|
});
|
|
87
90
|
const confirm = useCallback<Confirm>(
|
|
88
|
-
(request) =>
|
|
91
|
+
(request, proceed) =>
|
|
89
92
|
own.current !== undefined
|
|
90
|
-
? own.current(request)
|
|
91
|
-
: new Promise<
|
|
92
|
-
setQueue((waiting) => [...waiting, { request, resolve }]),
|
|
93
|
+
? own.current(request, proceed)
|
|
94
|
+
: new Promise<ConfirmAnswer>((resolve) =>
|
|
95
|
+
setQueue((waiting) => [...waiting, { request, resolve, proceed }]),
|
|
93
96
|
),
|
|
94
97
|
[],
|
|
95
98
|
);
|
|
@@ -113,7 +116,11 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
113
116
|
request={current?.request ?? null}
|
|
114
117
|
{...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName })}
|
|
115
118
|
onAnswer={(accepted) => {
|
|
116
|
-
|
|
119
|
+
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
120
|
+
if (accepted) {
|
|
121
|
+
current?.proceed();
|
|
122
|
+
}
|
|
123
|
+
current?.resolve(accepted ? "accepted" : "declined");
|
|
117
124
|
setQueue((waiting) => waiting.slice(1));
|
|
118
125
|
}}
|
|
119
126
|
/>
|
|
@@ -164,6 +171,21 @@ export interface PortholeFrameProps {
|
|
|
164
171
|
readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
|
|
165
172
|
/** Too many declines: the display stops asking until it runs again. */
|
|
166
173
|
readonly onMuted?: () => void;
|
|
174
|
+
/**
|
|
175
|
+
* How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
|
|
176
|
+
* own fallback (a url-mode app "Open in a new tab") on anything but loaded.
|
|
177
|
+
*/
|
|
178
|
+
readonly onOutcome?: (outcome: FrameOutcome) => void;
|
|
179
|
+
/**
|
|
180
|
+
* Other ids this display had (the call's id, before the display's own settled): its remembered
|
|
181
|
+
* height is found under them, for a frame that mounts again under its new id.
|
|
182
|
+
*/
|
|
183
|
+
readonly aliases?: readonly string[];
|
|
184
|
+
/**
|
|
185
|
+
* Change it when the host's theme values change without a scheme flip (or after an async
|
|
186
|
+
* restyle): the running frame reads them again.
|
|
187
|
+
*/
|
|
188
|
+
readonly themeKey?: string | number;
|
|
167
189
|
/** Draws the placeholder instead of the default one. */
|
|
168
190
|
readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
|
|
169
191
|
readonly className?: string;
|
|
@@ -198,6 +220,21 @@ function DefaultPlaceholder({ state, title, run }: PlaceholderProps): ReactEleme
|
|
|
198
220
|
);
|
|
199
221
|
}
|
|
200
222
|
|
|
223
|
+
/** A display's remembered height: under its id, else under the first of its other ids that has one. */
|
|
224
|
+
function rememberedHeight(
|
|
225
|
+
heights: ReadonlyMap<string, number>,
|
|
226
|
+
displayId: string,
|
|
227
|
+
aliases: readonly string[] | undefined,
|
|
228
|
+
): number {
|
|
229
|
+
for (const id of [displayId, ...(aliases ?? [])]) {
|
|
230
|
+
const height = heights.get(id);
|
|
231
|
+
if (height !== undefined) {
|
|
232
|
+
return height;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
201
238
|
export function PortholeFrame(props: PortholeFrameProps): ReactElement {
|
|
202
239
|
const page = useContext(Page);
|
|
203
240
|
const key = useId();
|
|
@@ -240,7 +277,17 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
|
|
|
240
277
|
};
|
|
241
278
|
}, [page, key, max, eager]);
|
|
242
279
|
|
|
243
|
-
const height = page.heights.
|
|
280
|
+
const height = rememberedHeight(page.heights, props.displayId, props.aliases);
|
|
281
|
+
// A display renamed while it runs (its id settled) keeps its height under its new id.
|
|
282
|
+
const previousId = useRef(props.displayId);
|
|
283
|
+
useLayoutEffect(() => {
|
|
284
|
+
const before = previousId.current;
|
|
285
|
+
previousId.current = props.displayId;
|
|
286
|
+
const kept = page.heights.get(before);
|
|
287
|
+
if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
|
|
288
|
+
page.heights.set(props.displayId, kept);
|
|
289
|
+
}
|
|
290
|
+
}, [page, props.displayId]);
|
|
244
291
|
const run = () => page.live.admit(key, max);
|
|
245
292
|
const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
|
|
246
293
|
return (
|
|
@@ -290,6 +337,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
290
337
|
// The height this display started at: later reports don't make it start again.
|
|
291
338
|
const [startHeight] = useState(initialHeightPx);
|
|
292
339
|
const { config, document: view, instance, content, partial, scheme } = frame;
|
|
340
|
+
const fills = config.mode === "url";
|
|
293
341
|
|
|
294
342
|
// The latest props, read when the display calls back (never during render).
|
|
295
343
|
const latest = useEffectEvent(() => frame);
|
|
@@ -320,6 +368,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
320
368
|
case "muted":
|
|
321
369
|
frame.onMuted?.();
|
|
322
370
|
return;
|
|
371
|
+
case "outcome":
|
|
372
|
+
frame.onOutcome?.(detail as DisplayEvents["outcome"]);
|
|
373
|
+
return;
|
|
323
374
|
}
|
|
324
375
|
},
|
|
325
376
|
);
|
|
@@ -353,11 +404,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
353
404
|
handlers,
|
|
354
405
|
...(asks
|
|
355
406
|
? {
|
|
356
|
-
confirm: (request: ConfirmRequest) =>
|
|
357
|
-
(latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))(
|
|
358
|
-
...request,
|
|
359
|
-
|
|
360
|
-
|
|
407
|
+
confirm: (request: ConfirmRequest, proceed: Proceed) =>
|
|
408
|
+
(latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))(
|
|
409
|
+
{ ...request, display: { id: latest().displayId, title: latest().title } },
|
|
410
|
+
proceed,
|
|
411
|
+
),
|
|
361
412
|
}
|
|
362
413
|
: {}),
|
|
363
414
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
@@ -383,6 +434,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
383
434
|
"navigated",
|
|
384
435
|
"refused",
|
|
385
436
|
"muted",
|
|
437
|
+
"outcome",
|
|
386
438
|
];
|
|
387
439
|
for (const event of events) {
|
|
388
440
|
display.on(event, (detail) => notify(event, detail));
|
|
@@ -406,16 +458,28 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
406
458
|
useEffect(() => {
|
|
407
459
|
handle.current?.setTheme(scheme);
|
|
408
460
|
}, [scheme]);
|
|
461
|
+
const { themeKey } = frame;
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (themeKey !== undefined) {
|
|
464
|
+
handle.current?.refreshTheme();
|
|
465
|
+
}
|
|
466
|
+
}, [themeKey]);
|
|
409
467
|
|
|
410
468
|
return (
|
|
411
469
|
<div
|
|
412
470
|
data-porthole-ready={String(ready)}
|
|
413
471
|
style={{
|
|
414
472
|
position: "relative",
|
|
473
|
+
// A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
|
|
474
|
+
...(fills ? { height: "100%" } : {}),
|
|
415
475
|
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
416
476
|
}}
|
|
417
477
|
>
|
|
418
|
-
<div
|
|
478
|
+
<div
|
|
479
|
+
ref={holder}
|
|
480
|
+
data-porthole-mount=""
|
|
481
|
+
style={{ opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) }}
|
|
482
|
+
/>
|
|
419
483
|
{ready ? null : placeholder({ state: "loading", title: frame.title, run })}
|
|
420
484
|
</div>
|
|
421
485
|
);
|
|
@@ -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
|
+
}
|