@cef-ai/widget-runtime 2.1.1 → 2.2.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,217 @@
1
+ // packages/widget-runtime/src/sign-in-ui.ts
2
+ //
3
+ // The standalone sign-in surface, in the RUNTIME rather than in each widget.
4
+ //
5
+ // Every widget that can be shared by link needs the same three things — a
6
+ // prompt, a way in for someone who has an account, a way in for someone who
7
+ // does not — and none of them is the widget author's problem. Putting this here
8
+ // is what makes "send somebody the link" a property of widgets rather than a
9
+ // feature each one reimplements (and reimplements differently).
10
+ //
11
+ // ── The gesture is the whole design ────────────────────────────────────────
12
+ // `login()`/`register()` open the wallet surface, and `window.open` outside
13
+ // user activation is blocked. The previous standalone path called connect() at
14
+ // boot; the browser swallowed the popup and the promise never settled, so the
15
+ // widget sat on "Loading…" with nothing on screen to click. So the ceremony is
16
+ // started from inside the button's own click handler — never from an effect,
17
+ // never from boot — and that is the reason this is a DOM overlay and not a
18
+ // promise the runtime resolves on its own.
19
+ const OVERLAY_ID = 'cef-widget-signin';
20
+ const DEFAULT_COPY = {
21
+ title: 'Sign in to view this',
22
+ body: 'This page reads data you need permission for. Sign in with your CEF '
23
+ + 'account — the same one you use for the console.',
24
+ signIn: 'Sign in',
25
+ register: 'Create an account',
26
+ };
27
+ function el(tag, style, text) {
28
+ const node = document.createElement(tag);
29
+ node.setAttribute('style', style);
30
+ if (text !== undefined)
31
+ node.textContent = text;
32
+ return node;
33
+ }
34
+ const SHELL = [
35
+ // JUST UNDER THE MAXIMUM, because the wallet's own ceremony panel sits AT it.
36
+ // At equal z-index the winner is DOM order, and this overlay won: the wallet
37
+ // opened its 420x640 panel and this card was painted across the middle of it,
38
+ // so the reader got a grey slab with a sign-in prompt stuck in it and no way
39
+ // to reach the passkey. Hiding during the ceremony (below) is the actual fix;
40
+ // this makes the stacking unambiguous if the two are ever up together.
41
+ 'position:fixed', 'inset:0', 'z-index:2147483000',
42
+ 'display:flex', 'align-items:center', 'justify-content:center',
43
+ 'padding:24px', 'background:rgba(15,18,25,.55)',
44
+ 'font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif',
45
+ ].join(';');
46
+ const CARD = [
47
+ 'max-width:420px', 'width:100%', 'box-sizing:border-box',
48
+ 'background:#fff', 'color:#11151c', 'border-radius:14px',
49
+ 'padding:28px', 'box-shadow:0 18px 48px rgba(15,18,25,.28)',
50
+ ].join(';');
51
+ const BUTTON = [
52
+ 'appearance:none', 'border:0', 'border-radius:9px',
53
+ 'padding:11px 18px', 'font:inherit', 'font-weight:600',
54
+ 'cursor:pointer', 'background:#6b3df5', 'color:#fff',
55
+ ].join(';');
56
+ const SECONDARY = [
57
+ 'appearance:none', 'border:1px solid #d7dae2', 'border-radius:9px',
58
+ 'padding:11px 18px', 'font:inherit', 'font-weight:600',
59
+ 'cursor:pointer', 'background:#fff', 'color:#11151c',
60
+ ].join(';');
61
+ /**
62
+ * Show the sign-in overlay and resolve once the reader is authenticated.
63
+ *
64
+ * Never rejects on a dismissed or failed ceremony: the reader is still sitting
65
+ * in front of the widget and can try the other button, so the overlay reports
66
+ * what went wrong and stays up. It settles only on success — or when
67
+ * `resume()`, retried on each attempt, turns out to have a session after all.
68
+ */
69
+ export function requestSignIn(gate, copy = {},
70
+ /**
71
+ * A second gate to try once, when the first one's ceremony is refused.
72
+ *
73
+ * WHAT THIS IS FOR: the framed ceremony needs the page's origin in
74
+ * wallet-api's embed allowlist, and a widget served off the shared content
75
+ * gateway is not going to be in it — allowlisting that origin would admit
76
+ * every bundle anyone has ever published, which is a much larger grant than a
77
+ * sharing feature should ask for. A popup is not framing, so it works
78
+ * unlisted. Retried rather than chosen up front because a widget cannot know
79
+ * whether its own origin is listed, and the listed case is worth having: it
80
+ * is the only one where a session resumes without a click.
81
+ */
82
+ fallback) {
83
+ const text = { ...DEFAULT_COPY, ...copy };
84
+ return new Promise((resolve) => {
85
+ const existing = document.getElementById(OVERLAY_ID);
86
+ if (existing)
87
+ existing.remove();
88
+ const shell = el('div', SHELL);
89
+ shell.id = OVERLAY_ID;
90
+ const card = el('div', CARD);
91
+ card.appendChild(el('h2', 'margin:0 0 8px;font-size:18px;font-weight:700', text.title));
92
+ card.appendChild(el('p', 'margin:0 0 20px;color:#4a5160', text.body));
93
+ const row = el('div', 'display:flex;gap:10px;flex-wrap:wrap');
94
+ const primary = el('button', BUTTON, text.signIn);
95
+ const secondary = el('button', SECONDARY, text.register);
96
+ primary.type = 'button';
97
+ secondary.type = 'button';
98
+ row.appendChild(primary);
99
+ row.appendChild(secondary);
100
+ card.appendChild(row);
101
+ const error = el('p', 'margin:16px 0 0;color:#b00020;display:none');
102
+ card.appendChild(error);
103
+ shell.appendChild(card);
104
+ document.body.appendChild(shell);
105
+ let busy = false;
106
+ /**
107
+ * The gate every attempt uses, REPLACED for good the first time a framed
108
+ * ceremony is refused.
109
+ *
110
+ * Switching permanently rather than retrying per click is the difference
111
+ * between "the second button works" and "every click re-runs the refusal
112
+ * before falling back" — and on the unlisted origin the refusal is not a
113
+ * fluke, it is the standing answer.
114
+ */
115
+ let active = gate;
116
+ let switched = false;
117
+ const attempt = (pick) => () => {
118
+ if (busy)
119
+ return;
120
+ busy = true;
121
+ error.style.display = 'none';
122
+ primary.disabled = true;
123
+ secondary.disabled = true;
124
+ /*
125
+ * OUT OF THE WAY WHILE THE WALLET IS UP.
126
+ *
127
+ * This panel exists to collect one click; the moment it has one, the
128
+ * ceremony owns the screen. Leaving it mounted put it on top of the
129
+ * wallet's own panel — the reader saw a grey slab with a sign-in prompt
130
+ * across the middle and could not reach the passkey. It comes back only
131
+ * if the ceremony fails, which is the one case there is something to
132
+ * click again.
133
+ */
134
+ shell.style.display = 'none';
135
+ // STARTED INSIDE THE CLICK. Everything awaited happens after the wallet
136
+ // call has already been made, so the ceremony opens under the gesture —
137
+ // INCLUDING the retry, which is chained onto this same activation rather
138
+ // than started from a later tick.
139
+ pick(active)
140
+ .catch((err) => {
141
+ if (!fallback || switched)
142
+ throw err;
143
+ switched = true;
144
+ active = fallback();
145
+ console.info('[widget-runtime] framed ceremony refused; retrying in a popup', err);
146
+ return pick(active);
147
+ })
148
+ .then((session) => {
149
+ shell.remove();
150
+ resolve(session);
151
+ })
152
+ .catch((err) => {
153
+ busy = false;
154
+ shell.style.display = 'flex';
155
+ primary.disabled = false;
156
+ secondary.disabled = false;
157
+ error.textContent = err instanceof Error ? err.message : String(err);
158
+ error.style.display = 'block';
159
+ });
160
+ };
161
+ primary.addEventListener('click', attempt((g) => g.signIn()));
162
+ secondary.addEventListener('click', attempt((g) => g.register()));
163
+ });
164
+ }
165
+ /**
166
+ * Sign the reader in WITHOUT ASKING FIRST.
167
+ *
168
+ * The overlay above collects a click so a popup can open under it. That made
169
+ * the shared-link experience two ceremonies deep: a card saying "Sign in to
170
+ * view this" with a button, and then the wallet's own panel with ITS button.
171
+ * The first of those is not the wallet's requirement — it is this file's,
172
+ * and only for the popup. So it is now the last resort, not the front door.
173
+ *
174
+ * In order, stopping at the first that works:
175
+ *
176
+ * 1. `resume()` — a session from an earlier visit. No ceremony at all.
177
+ * 2. The framed ceremony, started immediately. An iframe needs no gesture,
178
+ * and the wallet's panel carries its own "Continue" — one click, the
179
+ * wallet's, not two. This is what a listed origin gets.
180
+ * 3. The popup ceremony, also started immediately. A browser that has been
181
+ * told to allow popups here opens it; one that has not refuses, and the
182
+ * refusal comes back as a rejection rather than a hang.
183
+ * 4. The overlay, on the popup gate — the one case a gesture is genuinely
184
+ * needed, because the browser demands one for `window.open`.
185
+ *
186
+ * A `resume()` that THROWS, as opposed to resolving null, is a surface that
187
+ * never came up (origin not in the embed allowlist, most likely). Step 2 is
188
+ * skipped then: it would only wait out the same handshake a second time.
189
+ */
190
+ export async function autoSignIn(gate, fallback, copy = {}) {
191
+ let surfaceUp = true;
192
+ try {
193
+ const resumed = await gate.resume();
194
+ if (resumed)
195
+ return resumed;
196
+ }
197
+ catch (err) {
198
+ surfaceUp = false;
199
+ console.info('[widget-runtime] wallet surface did not come up; skipping the framed ceremony', err);
200
+ }
201
+ if (surfaceUp) {
202
+ try {
203
+ return await gate.signIn();
204
+ }
205
+ catch (err) {
206
+ console.info('[widget-runtime] framed ceremony refused; trying a popup', err);
207
+ }
208
+ }
209
+ const popup = fallback();
210
+ try {
211
+ return await popup.signIn();
212
+ }
213
+ catch (err) {
214
+ console.info('[widget-runtime] popup did not open on its own; asking for a click', err);
215
+ }
216
+ return requestSignIn(popup, copy);
217
+ }
@@ -6,6 +6,17 @@ export interface StandaloneWallet {
6
6
  sigType: 'ed25519';
7
7
  /** Sign the given bytes; returns a 64-byte ed25519 signature. */
8
8
  signRawBytes(bytes: Uint8Array): Promise<Uint8Array>;
9
+ /**
10
+ * A bearer delegation for data-plane reads, when the wallet can mint one.
11
+ *
12
+ * OPTIONAL BECAUSE NOT EVERY WALLET HAS THE CONCEPT — the Cere embed-wallet
13
+ * in this file does not. Where it exists it is strictly better than
14
+ * `signRawBytes` for reads: a passkey session is keyless after every page
15
+ * load, so signing per request raises a step-up per request, while a
16
+ * delegation is minted once with no ceremony and authenticates the same
17
+ * calls. See ./cef-wallet.
18
+ */
19
+ delegation?: () => Promise<string>;
9
20
  }
10
21
  /**
11
22
  * Initialise + connect an interactive Cere embed-wallet and return a
package/dist/types.d.ts CHANGED
@@ -53,9 +53,22 @@ export interface WidgetManifest {
53
53
  agentId: string;
54
54
  scope: string;
55
55
  cubbyAlias: string;
56
+ /**
57
+ * How a STANDALONE widget signs its reader in. Ignored when framed — there a
58
+ * host supplies the identity over the handshake and no wallet is built.
59
+ *
60
+ * `origin` is the CEF passkey wallet SPA (e.g.
61
+ * `https://wallet.compute.test.ddcdragon.com`), the same one ROC, Vault and
62
+ * the marketplace authenticate against, so a reader who can see the data in
63
+ * the console is the same principal here. `appId`/`env` are the older Cere
64
+ * embed-wallet coordinates, kept so a bundle published before `origin`
65
+ * existed still parses — such a bundle simply has no standalone sign-in, and
66
+ * says so rather than hanging. See ./cef-wallet.
67
+ */
56
68
  wallet: {
57
69
  appId: string;
58
70
  env: string;
71
+ origin?: string;
59
72
  };
60
73
  endpoints: {
61
74
  vault: string;
@@ -23,4 +23,4 @@ import type { WidgetManifest } from './types.js';
23
23
  * mainnet), so the runtime inherits the deployment's chain instead of
24
24
  * hard-coding one.
25
25
  */
26
- export declare function toVaultSdkConfig(endpoints: WidgetManifest['endpoints'], signer: Signer, token?: string): VaultSDKConfig;
26
+ export declare function toVaultSdkConfig(endpoints: WidgetManifest['endpoints'], signer: Signer, token?: string, renewToken?: () => Promise<string>): VaultSDKConfig;
@@ -23,7 +23,7 @@ import { DelegationAuthProvider } from '@cef-ai/vault-sdk';
23
23
  * mainnet), so the runtime inherits the deployment's chain instead of
24
24
  * hard-coding one.
25
25
  */
26
- export function toVaultSdkConfig(endpoints, signer, token) {
26
+ export function toVaultSdkConfig(endpoints, signer, token, renewToken) {
27
27
  const base = {
28
28
  endpoint: endpoints.vault,
29
29
  garEndpoint: endpoints.gar,
@@ -31,7 +31,17 @@ export function toVaultSdkConfig(endpoints, signer, token) {
31
31
  s3GatewayAuthInfoUrl: endpoints.s3GatewayAuthInfo,
32
32
  chainUrl: endpoints.rpc,
33
33
  };
34
+ // A renewable token is asked for on every request, never captured: the
35
+ // captured string dies after an hour and every later request 401s.
36
+ if (renewToken)
37
+ return { ...base, auth: renewingDelegation(renewToken) };
34
38
  if (token)
35
39
  return { ...base, auth: new DelegationAuthProvider(token) };
36
40
  return { ...base, signer };
37
41
  }
42
+ /** A delegation provider that takes the CURRENT token from `mint` on each request. */
43
+ function renewingDelegation(mint) {
44
+ return {
45
+ authenticate: async (req) => new DelegationAuthProvider(await mint()).authenticate(req),
46
+ };
47
+ }