@cef-ai/widget-runtime 1.0.5 → 1.1.1

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.
@@ -0,0 +1,26 @@
1
+ import type { HostBridge } from './host-bridge.js';
2
+ import type { StandaloneWallet } from './standalone-wallet.js';
3
+ /**
4
+ * A resolved signing identity plus its raw-bytes signer. Shared by both the
5
+ * vault client (cubby reads / connect / publish) and the audio uploader.
6
+ */
7
+ export interface ResolvedSigningIdentity {
8
+ source: 'host' | 'wallet';
9
+ publicKey: string;
10
+ sigType: 'ed25519' | 'sr25519';
11
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
12
+ }
13
+ /**
14
+ * The widget is framed by a host, but the host never supplied an identity —
15
+ * it did not answer the handshake within ~8 s, or answered malformed.
16
+ */
17
+ export declare class WidgetSignedOutError extends Error {
18
+ constructor(message?: string);
19
+ }
20
+ export interface IdentityResolverDeps {
21
+ bridge: HostBridge;
22
+ /** Opens the interactive wallet. Only ever called when NOT framed. */
23
+ connectWallet: () => Promise<StandaloneWallet>;
24
+ }
25
+ /** Build the memoized identity resolver. A failure clears the memo so a later action can retry. */
26
+ export declare function createIdentityResolver(deps: IdentityResolverDeps): () => Promise<ResolvedSigningIdentity>;
@@ -0,0 +1,58 @@
1
+ // packages/widget-runtime/src/resolve-identity.ts
2
+ //
3
+ // Multi-source identity resolution for the widget runtime, memoized so the
4
+ // (possibly interactive) resolution runs at most once per session.
5
+ //
6
+ // Framed → the embedding host answers the postMessage handshake.
7
+ // Top-level → connect an interactive wallet (standalone / direct-link boot).
8
+ //
9
+ // A FRAMED widget whose handshake fails does NOT fall through to the
10
+ // standalone wallet: the host meant to supply the identity and didn't, so we
11
+ // surface WidgetSignedOutError instead of silently opening a second, different
12
+ // wallet the host knows nothing about.
13
+ /**
14
+ * The widget is framed by a host, but the host never supplied an identity —
15
+ * it did not answer the handshake within ~8 s, or answered malformed.
16
+ */
17
+ export class WidgetSignedOutError extends Error {
18
+ constructor(message = 'WidgetSignedOutError: the embedding host did not supply an identity ' +
19
+ '(no cef-widget:identity-response within 8s, or a malformed one). ' +
20
+ 'Mount createWidgetHost() on the host page.') {
21
+ super(message);
22
+ this.name = 'WidgetSignedOutError';
23
+ }
24
+ }
25
+ /** Build the memoized identity resolver. A failure clears the memo so a later action can retry. */
26
+ export function createIdentityResolver(deps) {
27
+ let identityP = null;
28
+ return function resolveSigningIdentity() {
29
+ if (identityP)
30
+ return identityP;
31
+ identityP = (async () => {
32
+ const hostId = await deps.bridge.requestIdentity();
33
+ if (hostId) {
34
+ return {
35
+ source: 'host',
36
+ publicKey: hostId.pubkey,
37
+ sigType: hostId.sigType,
38
+ sign: (bytes) => deps.bridge.sign(bytes),
39
+ };
40
+ }
41
+ // Framed but unanswered: the host owns the identity. Do not downgrade.
42
+ if (window.parent !== window)
43
+ throw new WidgetSignedOutError();
44
+ // Top-level boot → interactive wallet.
45
+ const wallet = await deps.connectWallet();
46
+ return {
47
+ source: 'wallet',
48
+ publicKey: wallet.pubkey(),
49
+ sigType: wallet.sigType,
50
+ sign: (bytes) => wallet.signRawBytes(bytes),
51
+ };
52
+ })().catch((err) => {
53
+ identityP = null; // let a later action retry (e.g. user reopens the wallet)
54
+ throw err;
55
+ });
56
+ return identityP;
57
+ };
58
+ }
@@ -0,0 +1,62 @@
1
+ import { type SigType } from './host-bridge.js';
2
+ export interface WidgetHostIdentity {
3
+ /** 0x-hex public key of the already-authenticated user. */
4
+ pubkey: string;
5
+ /** Defaults to 'ed25519'. */
6
+ sigType?: SigType;
7
+ }
8
+ export interface WidgetHostOptions {
9
+ /**
10
+ * REQUIRED, non-empty. The origins this host answers — the only gate that
11
+ * pins WHICH DOCUMENT is talking to you.
12
+ *
13
+ * Each entry must be a canonical origin exactly as `event.origin` spells it:
14
+ * scheme + host + non-default port, lower-case, no trailing slash, no path
15
+ * (`'https://widget.example'`, `'http://localhost:5173'`). A default port is
16
+ * NOT optional spelling — an origin omits `:443` on https and `:80` on http,
17
+ * so `'https://widget.example:443'` throws. A sandboxed
18
+ * widget's `event.origin` is the literal string `"null"`, so pass `'null'`
19
+ * to frame one. `'*'` is NOT accepted — it is a `postMessage` targetOrigin
20
+ * wildcard, never an `event.origin`, so it would match nothing.
21
+ */
22
+ allowedOrigins: string[];
23
+ /**
24
+ * Optional, recommended: narrow further to ONE frame — the `<iframe>`
25
+ * element or its `contentWindow`. When given, a message must pass BOTH this
26
+ * and `allowedOrigins`.
27
+ *
28
+ * It cannot replace `allowedOrigins`. A `WindowProxy` keeps its identity
29
+ * across navigation, cross-origin included, so `event.source ===
30
+ * iframe.contentWindow` still holds after the framed page follows an open
31
+ * redirect or navigates itself — the handle names the SLOT, not the document
32
+ * in it.
33
+ */
34
+ widget?: HTMLIFrameElement | Window;
35
+ /**
36
+ * The identity to hand the widget. Return null/undefined when the host has
37
+ * no signed-in user yet — the widget retries for ~8 s before giving up.
38
+ */
39
+ getIdentity: () => WidgetHostIdentity | null | undefined | Promise<WidgetHostIdentity | null | undefined>;
40
+ /**
41
+ * Sign `bytes` VERBATIM — `ed25519_signRaw`, never the high-level
42
+ * `signMessage` / `wallet_signMessage`, which wrap the payload in Substrate's
43
+ * `<Bytes>…</Bytes>` envelope; a signature over the wrapped form verifies
44
+ * nowhere. Return null when the user declines; throw to report a failure.
45
+ *
46
+ * Your signer only ever sees a Uint8Array — this module converts to and from
47
+ * the `number[]` wire form.
48
+ */
49
+ sign: (bytes: Uint8Array) => Promise<Uint8Array | null> | Uint8Array | null;
50
+ }
51
+ /**
52
+ * Attach the widget-host message listener to `window`.
53
+ *
54
+ * Returns a disposer that removes it (call it on unmount — a leaked listener
55
+ * keeps answering identity requests from whatever the iframe navigated to).
56
+ *
57
+ * Throws unless `allowedOrigins` is non-empty and every entry is a canonical
58
+ * origin: an ungated host is a signing oracle for every frame on the page, and
59
+ * an entry that can never match is the same thing with a false sense of
60
+ * safety.
61
+ */
62
+ export declare function createWidgetHost(opts: WidgetHostOptions): () => void;
@@ -0,0 +1,153 @@
1
+ // packages/widget-runtime/src/widget-host.ts
2
+ //
3
+ // Host side of the widget↔host contract: the few lines a page that frames a
4
+ // CEF widget needs in order to hand the widget an identity it has ALREADY
5
+ // authenticated (an extension panel with SCP Wallet, a partner app, the Cere
6
+ // Vault). Mount it once; it answers `cef-widget:identity-request` and routes
7
+ // `cef-widget:sign-request` to your signer.
8
+ //
9
+ // This module holds the user's signing key, so it is the trust boundary: it
10
+ // answers ONLY the origins you name (optionally narrowed to one frame), and
11
+ // validates every byte it is asked to sign. The full contract is in the
12
+ // package README.
13
+ import { IDENTITY_REQUEST_TYPE, IDENTITY_RESPONSE_TYPE, SIGN_REQUEST_TYPE, SIGN_RESPONSE_TYPE, PROTOCOL_VERSION, UNSUPPORTED_VERSION_CODE, MAX_SIGN_BYTES, WidgetProtocolVersionError, WidgetMalformedMessageError, isUnsupportedVersion, isByteArray, } from './host-bridge.js';
14
+ /** Type guard for the postMessage call below — NOT the identity check; that is
15
+ * `allowedOrigins`. A Window is self-referential; a MessagePort or
16
+ * ServiceWorker is not (and its postMessage would throw on our string
17
+ * targetOrigin). `event.source` is browser-set and cannot be forged. */
18
+ function isWindow(source) {
19
+ return (!!source &&
20
+ typeof source.postMessage === 'function' &&
21
+ source.window === source);
22
+ }
23
+ /** Read lazily: an iframe's contentWindow is null before it is attached, and
24
+ * changes across navigations. */
25
+ function widgetWindow(widget) {
26
+ return 'contentWindow' in widget ? widget.contentWindow : widget;
27
+ }
28
+ /** `event.origin` is a canonical serialized origin — or the literal "null" for
29
+ * a sandboxed/opaque one. Anything that does not round-trip through URL exactly
30
+ * can never equal an event.origin, so it is a mount-time mistake, not a
31
+ * runtime miss. */
32
+ function assertCanonicalOrigin(entry) {
33
+ const shown = typeof entry === 'string' ? `'${entry}'` : String(entry);
34
+ const reject = (why) => {
35
+ throw new Error(`createWidgetHost: allowedOrigins entry ${shown} ${why}`);
36
+ };
37
+ if (typeof entry !== 'string' || !entry)
38
+ reject('is not a string origin');
39
+ if (entry === '*') {
40
+ reject('is a wildcard. postMessage accepts `*` as a targetOrigin, but `event.origin` is ' +
41
+ 'never the string "*", so this would mount clean and then answer nobody. List the ' +
42
+ "origins you actually frame, or 'null' for a sandboxed widget.");
43
+ }
44
+ // 'null' is what a sandboxed / opaque origin reports; URL cannot parse it.
45
+ if (entry === 'null')
46
+ return;
47
+ let origin;
48
+ try {
49
+ origin = new URL(entry).origin;
50
+ }
51
+ catch {
52
+ return reject('is not a URL. Use scheme + host + optional port, e.g. https://widget.example');
53
+ }
54
+ if (origin !== entry) {
55
+ reject(`is not a canonical origin — event.origin would read '${origin}'. Drop any trailing ` +
56
+ 'slash, path, upper-case in the host, or default port — an origin omits :443 on https ' +
57
+ 'and :80 on http.');
58
+ }
59
+ }
60
+ /**
61
+ * Attach the widget-host message listener to `window`.
62
+ *
63
+ * Returns a disposer that removes it (call it on unmount — a leaked listener
64
+ * keeps answering identity requests from whatever the iframe navigated to).
65
+ *
66
+ * Throws unless `allowedOrigins` is non-empty and every entry is a canonical
67
+ * origin: an ungated host is a signing oracle for every frame on the page, and
68
+ * an entry that can never match is the same thing with a false sense of
69
+ * safety.
70
+ */
71
+ export function createWidgetHost(opts) {
72
+ if (!opts.allowedOrigins?.length) {
73
+ throw new Error('createWidgetHost: `allowedOrigins` is required and must be non-empty. An ungated host ' +
74
+ 'signs arbitrary bytes for any frame on the page. `widget` alone is NOT sufficient: a ' +
75
+ 'WindowProxy keeps its identity across navigation (cross-origin included), so the frame ' +
76
+ 'handle still matches after the widget page navigates or follows an open redirect — it ' +
77
+ "names the slot, not the document. Pass 'null' for a sandboxed widget.");
78
+ }
79
+ opts.allowedOrigins.forEach(assertCanonicalOrigin);
80
+ const origins = new Set(opts.allowedOrigins);
81
+ const onMessage = (event) => {
82
+ // ── Trust boundary: who is allowed to ask us to sign ─────────────────────
83
+ // Origin first and always: it is the only check that pins the document.
84
+ if (!origins.has(event.origin))
85
+ return;
86
+ const source = event.source;
87
+ if (!isWindow(source))
88
+ return;
89
+ // Optional extra narrowing to one frame; never a substitute for the above.
90
+ if (opts.widget && source !== widgetWindow(opts.widget))
91
+ return;
92
+ const data = event.data;
93
+ if (!data || typeof data !== 'object')
94
+ return;
95
+ const { type, requestId, v } = data;
96
+ if (typeof requestId !== 'string')
97
+ return;
98
+ if (type !== IDENTITY_REQUEST_TYPE && type !== SIGN_REQUEST_TYPE)
99
+ return;
100
+ const responseType = type === IDENTITY_REQUEST_TYPE ? IDENTITY_RESPONSE_TYPE : SIGN_RESPONSE_TYPE;
101
+ // Reply to the exact frame that asked, on its own origin. A sandboxed
102
+ // widget has origin "null", which is not a valid targetOrigin — only then
103
+ // fall back to '*'.
104
+ const target = event.origin && event.origin !== 'null' ? event.origin : '*';
105
+ const reply = (fields) => {
106
+ // A frame can be torn down between its request and our reply; a throw
107
+ // here would escape the listener (fail() runs outside the try below).
108
+ try {
109
+ source.postMessage({ type: responseType, requestId, v: PROTOCOL_VERSION, ...fields }, target);
110
+ }
111
+ catch { /* frame gone — nothing to report to */ }
112
+ };
113
+ // Sign failures carry `signature: null` alongside the error so the widget
114
+ // sees a well-formed SignResponse either way.
115
+ const fail = (message, extra) => reply(type === SIGN_REQUEST_TYPE
116
+ ? { signature: null, error: message, ...extra }
117
+ : { error: message, ...extra });
118
+ if (isUnsupportedVersion(v)) {
119
+ // errorCode, not `v`: this reply IS a v1 message, so echoing the bad
120
+ // version would lie. Without the code the widget sees a plain error
121
+ // string and retries to its deadline.
122
+ fail(new WidgetProtocolVersionError(`the widget requested protocol v${String(v)}; this host speaks v${PROTOCOL_VERSION}`).message, { errorCode: UNSUPPORTED_VERSION_CODE });
123
+ return;
124
+ }
125
+ void (async () => {
126
+ try {
127
+ if (type === IDENTITY_REQUEST_TYPE) {
128
+ const id = await opts.getIdentity();
129
+ if (!id?.pubkey) {
130
+ fail('host has no signed-in identity');
131
+ return;
132
+ }
133
+ reply({ pubkey: id.pubkey, sigType: id.sigType ?? 'ed25519' });
134
+ return;
135
+ }
136
+ const bytes = data.bytes;
137
+ if (!isByteArray(bytes)) {
138
+ // Uint8Array.from would coerce silently — 300 → 44, 'zz' → 0 — and
139
+ // hand the signer a payload the widget never sent.
140
+ fail(new WidgetMalformedMessageError(`sign-request.bytes must be an array of at most ${MAX_SIGN_BYTES} integers 0-255`).message);
141
+ return;
142
+ }
143
+ const signature = await opts.sign(Uint8Array.from(bytes));
144
+ reply({ signature: signature ? Array.from(signature) : null });
145
+ }
146
+ catch (err) {
147
+ fail(err instanceof Error ? err.message : String(err));
148
+ }
149
+ })();
150
+ };
151
+ window.addEventListener('message', onMessage);
152
+ return () => window.removeEventListener('message', onMessage);
153
+ }