@cef-ai/widget-runtime 2.1.1 → 2.2.0

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,80 @@
1
+ /** A connected standalone identity plus the credentials the runtime needs. */
2
+ export interface CefWalletSession {
3
+ /** 0x-prefixed hex public key. */
4
+ pubkey(): string;
5
+ /** Signature scheme. The CEF wallet's raw-signing key is ed25519. */
6
+ sigType: 'ed25519';
7
+ /** Sign raw bytes. May raise a step-up when the wallet is keyless. */
8
+ signRawBytes(bytes: Uint8Array): Promise<Uint8Array>;
9
+ /**
10
+ * A bearer delegation for vault reads, minted lazily and replaced before it
11
+ * expires. Prefer it over {@link signRawBytes} for every request — see the
12
+ * header note on keyless sessions.
13
+ */
14
+ delegation(): Promise<string>;
15
+ }
16
+ /**
17
+ * How a ceremony would be surfaced right now. `'popup'` means the call MUST
18
+ * happen inside a user gesture, because `window.open` needs transient
19
+ * activation even though the passkey ceremony itself does not.
20
+ */
21
+ export type CeremonySurface = 'iframe' | 'popup';
22
+ /** The standalone sign-in surface the runtime drives. */
23
+ export interface CefWalletGate {
24
+ /** Silent, keyless, gesture-free. `null` when there is no session to resume. */
25
+ resume(): Promise<CefWalletSession | null>;
26
+ /** Authenticate an existing passkey. CALL FROM A CLICK. */
27
+ signIn(): Promise<CefWalletSession>;
28
+ /** Create an account (email → one-time code → passkey). CALL FROM A CLICK. */
29
+ register(): Promise<CefWalletSession>;
30
+ /** Which surface a ceremony would use right now. */
31
+ ceremonySurface(): CeremonySurface;
32
+ }
33
+ export interface CefWalletConfig {
34
+ /** Origin the wallet SPA is deployed to, e.g. https://wallet.compute.test.ddcdragon.com */
35
+ walletOrigin: string;
36
+ /** Shown to the reader on the wallet's consent surface. */
37
+ appName?: string;
38
+ /**
39
+ * Which surface the ceremony uses.
40
+ *
41
+ * `'iframe'` (the SDK's default over https) embeds the wallet inline and is
42
+ * the better experience — it is the only transport where a session can be
43
+ * resumed silently on the next page load. It requires the page's origin to be
44
+ * in wallet-api's embed allowlist.
45
+ *
46
+ * `'popup'` opens a top-level wallet window, which is NOT framing, so it
47
+ * works from an origin the allowlist does not name — a widget served off the
48
+ * shared content gateway, say, where allowlisting would admit every bundle
49
+ * anyone has ever published. The cost is that nothing resumes silently: the
50
+ * reader clicks once per page load.
51
+ */
52
+ transport?: CeremonySurface;
53
+ }
54
+ /**
55
+ * The browser refused to open the wallet window — `window.open` outside a user
56
+ * gesture, on a page that has not been told to allow popups.
57
+ *
58
+ * Named so the caller can tell "no popup" from "the wallet said no": the first
59
+ * is answered by asking for a click, the second is not.
60
+ */
61
+ export declare class PopupBlockedError extends Error {
62
+ constructor();
63
+ }
64
+ /**
65
+ * A widget published to the content CDN has no wallet origin of its own to
66
+ * fall back on, and guessing one would point the reader's passkey at a host
67
+ * nobody vetted. Absent config is therefore an error with a fix in it, raised
68
+ * where a person can act on it rather than as a silent no-sign-in-button.
69
+ */
70
+ export declare class WidgetWalletUnconfiguredError extends Error {
71
+ constructor();
72
+ }
73
+ /**
74
+ * Build the standalone sign-in gate.
75
+ *
76
+ * ONE WALLET PER PAGE. Two `EmbedWallet`s would mean two transports racing over
77
+ * one session — the same reason ROC keeps a single connector for the page
78
+ * lifetime.
79
+ */
80
+ export declare function createCefWalletGate(cfg: CefWalletConfig): CefWalletGate;
@@ -0,0 +1,186 @@
1
+ // packages/widget-runtime/src/cef-wallet.ts
2
+ //
3
+ // The standalone (top-level) identity source: the SAME passkey wallet the rest
4
+ // of CEF signs in with.
5
+ //
6
+ // ── Why this replaced the Cere embed-wallet ────────────────────────────────
7
+ // The old standalone path built a `@cere/embed-wallet` from
8
+ // `manifest.wallet.{appId,env}`, which resolves to `wallet.stage.cere.io` — the
9
+ // Cere consumer (Torus) wallet. ROC, Vault and the marketplace authenticate
10
+ // against `@cef-ai/wallet` at `walletOrigin` (e.g.
11
+ // `https://wallet.compute.test.ddcdragon.com`). Those are different identity
12
+ // systems: a person who signs in to the Cere wallet is a DIFFERENT principal,
13
+ // with no membership of the org whose vault the widget reads, so even a
14
+ // successful connect could only ever show them an empty database.
15
+ //
16
+ // Observed before the change: opening a published widget by direct link loaded
17
+ // `wallet.stage.cere.io`, left its popup iframe at 0x0 `display:none`, and
18
+ // `connect()` never settled — the widget sat on "Loading…" forever with no
19
+ // sign-in prompt at all.
20
+ //
21
+ // ── The three things this gets right that the old path did not ─────────────
22
+ //
23
+ // 1. SILENT RESUME FIRST. `resume()` is keyless and gesture-free, so a reader
24
+ // who already has a session sees data with no ceremony. Only when it
25
+ // returns null does the runtime show a sign-in control.
26
+ //
27
+ // 2. A GESTURE FOR THE CEREMONY. `login()`/`register()` open the wallet
28
+ // surface, and `window.open` outside user activation is blocked — which is
29
+ // precisely how the old path hung. Both are therefore exposed as separate
30
+ // calls for the runtime to make FROM A CLICK, never at boot.
31
+ //
32
+ // 3. A DELEGATION, NOT A SIGNATURE, FOR READS. After any page load the wallet
33
+ // is authenticated but KEYLESS, so a raw signature raises a step-up popup —
34
+ // once per request for a polling widget. `requestDelegation` needs no key
35
+ // material and settles bearer-active, so the runtime authenticates every
36
+ // cubby read with a bearer token and signs nothing. This is the same
37
+ // credential, minted with the same capabilities and the same `cef-widget`
38
+ // app id, that ROC hands a framed widget today.
39
+ import { EmbedWallet } from '@cef-ai/wallet';
40
+ /** Capabilities a widget's delegation carries: it reads cubbies and publishes events. */
41
+ const WIDGET_CAPABILITIES = ['ddc-read', 'ddc-write'];
42
+ /**
43
+ * The wallet app id a widget's delegation is minted under.
44
+ *
45
+ * NOT the widget's own id, and not the host's. vault-api reads the app id off
46
+ * the delegation, and ROC already mints the token it hands a framed widget
47
+ * under `cef-widget` — so a widget that mints its own uses the same one, and
48
+ * the server sees one kind of credential however the widget was opened.
49
+ */
50
+ const WIDGET_APP_ID = 'cef-widget';
51
+ /** Delegation lifetime requested from the wallet, and when to replace one. */
52
+ const TOKEN_TTL = '1h';
53
+ const TOKEN_TTL_MS = 60 * 60 * 1000;
54
+ const RENEW_BEFORE_MS = 10 * 60 * 1000;
55
+ /**
56
+ * The browser refused to open the wallet window — `window.open` outside a user
57
+ * gesture, on a page that has not been told to allow popups.
58
+ *
59
+ * Named so the caller can tell "no popup" from "the wallet said no": the first
60
+ * is answered by asking for a click, the second is not.
61
+ */
62
+ export class PopupBlockedError extends Error {
63
+ constructor() {
64
+ super('PopupBlockedError: the browser did not open the wallet window');
65
+ this.name = 'PopupBlockedError';
66
+ }
67
+ }
68
+ /**
69
+ * Open the wallet window, and SAY SO AT ONCE when the browser will not.
70
+ *
71
+ * The wallet's own opener hands back whatever `window.open` returns, and a
72
+ * blocked popup returns null — which the transport then waits out as "surface
73
+ * never signalled ready", ten seconds of blank page before the reader is
74
+ * offered a button. A popup the browser refused is known the instant it is
75
+ * refused; this turns that null into a rejection the same tick.
76
+ */
77
+ const openPopupOrThrow = (url, features) => {
78
+ const opened = window.open(url, '_blank', features);
79
+ if (!opened)
80
+ throw new PopupBlockedError();
81
+ return opened;
82
+ };
83
+ /**
84
+ * A widget published to the content CDN has no wallet origin of its own to
85
+ * fall back on, and guessing one would point the reader's passkey at a host
86
+ * nobody vetted. Absent config is therefore an error with a fix in it, raised
87
+ * where a person can act on it rather than as a silent no-sign-in-button.
88
+ */
89
+ export class WidgetWalletUnconfiguredError extends Error {
90
+ constructor() {
91
+ super('WidgetWalletUnconfiguredError: this widget was published without a '
92
+ + '`wallet.origin`, so there is no wallet to sign in to. Rebuild and '
93
+ + 'republish it with a CLI new enough to stamp one (`cef push --kind widget`).');
94
+ this.name = 'WidgetWalletUnconfiguredError';
95
+ }
96
+ }
97
+ function hexPubkey(signer) {
98
+ const bytes = signer.publicKey;
99
+ let out = '0x';
100
+ for (const b of bytes)
101
+ out += b.toString(16).padStart(2, '0');
102
+ return out;
103
+ }
104
+ /**
105
+ * Build the standalone sign-in gate.
106
+ *
107
+ * ONE WALLET PER PAGE. Two `EmbedWallet`s would mean two transports racing over
108
+ * one session — the same reason ROC keeps a single connector for the page
109
+ * lifetime.
110
+ */
111
+ export function createCefWalletGate(cfg) {
112
+ if (!cfg.walletOrigin)
113
+ throw new WidgetWalletUnconfiguredError();
114
+ const wallet = new EmbedWallet({
115
+ appId: WIDGET_APP_ID,
116
+ appName: cfg.appName ?? 'CEF widget',
117
+ walletOrigin: cfg.walletOrigin,
118
+ ...(cfg.transport ? { transport: cfg.transport } : {}),
119
+ ...(cfg.transport === 'popup' ? { popupOpener: openPopupOrThrow } : {}),
120
+ });
121
+ /*
122
+ * ONE TOKEN, REPLACED BEFORE IT DIES — not one per read and not one per page.
123
+ *
124
+ * A widget that polls would otherwise mint a delegation per request; a widget
125
+ * left open longer than the hour would otherwise present the same dead one
126
+ * forever, and its 401s happen inside the frame where nothing else can notice.
127
+ * Minting is silent, so an age check costs nothing and removes both.
128
+ */
129
+ let held = null;
130
+ const delegation = () => {
131
+ const current = held;
132
+ if (current && Date.now() - current.mintedAt < TOKEN_TTL_MS - RENEW_BEFORE_MS) {
133
+ return current.token;
134
+ }
135
+ const token = wallet
136
+ .requestDelegation({
137
+ capabilities: [...WIDGET_CAPABILITIES],
138
+ appId: WIDGET_APP_ID,
139
+ ttl: TOKEN_TTL,
140
+ })
141
+ .then((r) => r.token)
142
+ .catch((err) => {
143
+ // A rejected promise left in the cache would brick every later read
144
+ // after one network blip or one dismissed ceremony.
145
+ held = null;
146
+ throw err;
147
+ });
148
+ held = { token, mintedAt: Date.now() };
149
+ return token;
150
+ };
151
+ const session = () => {
152
+ const signer = wallet.asSigner();
153
+ const pub = hexPubkey(signer);
154
+ return {
155
+ pubkey: () => pub,
156
+ sigType: 'ed25519',
157
+ signRawBytes: (bytes) => signer.sign(bytes),
158
+ delegation,
159
+ };
160
+ };
161
+ return {
162
+ async resume() {
163
+ const result = await wallet.resume();
164
+ if (!result)
165
+ return null;
166
+ held = null; // a new session invalidates any token minted for the old one
167
+ return session();
168
+ },
169
+ async signIn() {
170
+ await wallet.login();
171
+ held = null;
172
+ return session();
173
+ },
174
+ async register() {
175
+ await wallet.register();
176
+ held = null;
177
+ return session();
178
+ },
179
+ // A GETTER ON THE WALLET, read live. Its value is only knowable once a
180
+ // framed ceremony has actually been refused, so anything cached at
181
+ // construction would be wrong for exactly the case it exists for.
182
+ ceremonySurface() {
183
+ return wallet.ceremonySurface;
184
+ },
185
+ };
186
+ }
package/dist/entry.js CHANGED
@@ -3,6 +3,9 @@ import { VaultSDK } from '@cef-ai/vault-sdk';
3
3
  import { createHostBridge } from './host-bridge.js';
4
4
  import { toVaultSdkSigner } from './bridge-wallet.js';
5
5
  import { connectStandaloneWallet } from './standalone-wallet.js';
6
+ import { createCefWalletGate } from './cef-wallet.js';
7
+ import { autoSignIn } from './sign-in-ui.js';
8
+ import { currentLinkContext } from './share-link.js';
6
9
  import { createIdentityResolver } from './resolve-identity.js';
7
10
  import { createVaultClient } from './vault-client.js';
8
11
  import { toVaultSdkConfig } from './vault-sdk-config.js';
@@ -23,30 +26,64 @@ function onDomReady(fn) {
23
26
  fn();
24
27
  }
25
28
  }
29
+ /**
30
+ * Sign the reader in for a STANDALONE (direct-link) widget.
31
+ *
32
+ * RESUME, THEN ASK — in that order, and the order is the feature. `resume()` is
33
+ * keyless and needs no gesture, so a reader who already has a session never
34
+ * sees a prompt at all; only when there is nothing to resume does the runtime
35
+ * put a control on screen. The ceremony then starts inside that button's own
36
+ * click, because `window.open` outside user activation is blocked — which is
37
+ * exactly how the previous standalone path hung: it called connect() at boot,
38
+ * the browser swallowed the popup, and the promise never settled.
39
+ *
40
+ * Falls back to the legacy Cere embed-wallet only for a bundle published
41
+ * before `wallet.origin` existed, so old widgets keep whatever behaviour they
42
+ * had rather than losing their sign-in to this change.
43
+ */
44
+ async function connectStandaloneWalletForReader(manifest) {
45
+ const origin = manifest.wallet?.origin;
46
+ if (!origin)
47
+ return connectStandaloneWallet(manifest.wallet);
48
+ const build = (transport) => createCefWalletGate({
49
+ walletOrigin: origin,
50
+ appName: manifest.name,
51
+ ...(transport ? { transport } : {}),
52
+ });
53
+ // Framed first: it is the only transport that resumes silently, and the one
54
+ // that needs no click of ours. The popup is the retry for an origin wallet-api
55
+ // will not frame, and a button is the retry for a popup the browser will not
56
+ // open on its own. See `autoSignIn`.
57
+ return autoSignIn(build(), () => build('popup'));
58
+ }
26
59
  function boot() {
27
60
  const manifest = window.WidgetSandbox?.manifest;
28
61
  if (!manifest)
29
62
  return;
30
63
  const bridge = createHostBridge();
31
64
  // ── Multi-source identity ─────────────────────────────────────────────────
32
- // Framed → the embedding host (Cere Vault, or any page mounting
33
- // `createWidgetHost`) answers the postMessage handshake. Top-level → an
34
- // interactive Cere embed-wallet built from `manifest.wallet.{appId,env}`.
35
- // Both paths yield a pubkey + raw-bytes signer feeding the SAME vault-sdk
36
- // client. A framed widget whose handshake fails throws WidgetSignedOutError
37
- // rather than downgrading to the wallet — see ./resolve-identity.
65
+ // Framed → the embedding host (Cere Vault, ROC, or any page mounting
66
+ // `createWidgetHost`) answers the postMessage handshake. Top-level → the CEF
67
+ // passkey wallet at `manifest.wallet.origin`. Both paths yield a pubkey + a
68
+ // credential feeding the SAME vault-sdk client. A framed widget whose
69
+ // handshake fails throws WidgetSignedOutError rather than downgrading to the
70
+ // wallet — see ./resolve-identity.
38
71
  const resolveSigningIdentity = createIdentityResolver({
39
72
  bridge,
40
- connectWallet: () => connectStandaloneWallet(manifest.wallet),
73
+ connectWallet: () => connectStandaloneWalletForReader(manifest),
74
+ linkContext: currentLinkContext,
41
75
  });
42
76
  /** The host's delegation, captured when identity resolved. See resolveIdentity. */
43
77
  let hostDelegation;
78
+ /** The wallet's renewing mint, when the wallet minted that delegation. */
79
+ let renewDelegation;
44
80
  const deps = {
45
81
  resolveIdentity: async () => {
46
82
  const id = await resolveSigningIdentity();
47
83
  // Remembered for buildVaultClient, which is synchronous and runs after
48
84
  // this. The resolver is memoised, so this is the same identity.
49
85
  hostDelegation = id.token;
86
+ renewDelegation = id.renewToken;
50
87
  /*
51
88
  * THE CONTEXT TRAVELS THE LAST STEP TOO.
52
89
  *
@@ -68,6 +105,7 @@ function boot() {
68
105
  buildVaultClient: (m, id) => {
69
106
  const sigType = id.sigType ?? 'ed25519';
70
107
  const hostToken = hostDelegation;
108
+ const renewToken = renewDelegation;
71
109
  // Defer to the memoized identity for signing — resolveIdentity() has
72
110
  // already run by the time buildVaultClient is called, so this resolves
73
111
  // to the same (host or wallet) signer without a second handshake/popup.
@@ -84,7 +122,7 @@ function boot() {
84
122
  // session steps up on every signature, so a widget that polls a query
85
123
  // asked for a passkey forever. `sign` stays wired for hosts that supply
86
124
  // no token.
87
- const makeSdk = () => new VaultSDK(toVaultSdkConfig(m.endpoints, vaultSdkSigner, hostToken));
125
+ const makeSdk = () => new VaultSDK(toVaultSdkConfig(m.endpoints, vaultSdkSigner, hostToken, renewToken));
88
126
  // WHICH VAULT, when the host named one. It rides `context` rather than
89
127
  // a field of its own because it is the same kind of fact as the run id —
90
128
  // why this widget was opened — and because an optional key on an
@@ -121,7 +159,7 @@ function boot() {
121
159
  // Same config builder as the main vault client above — in particular
122
160
  // the same `chainUrl`, since this `ensure({ onboard: false })` runs
123
161
  // the vault-delegate grant step too.
124
- const sdk = new VaultSDK(toVaultSdkConfig(manifest.endpoints, vaultSdkSigner, id.token));
162
+ const sdk = new VaultSDK(toVaultSdkConfig(manifest.endpoints, vaultSdkSigner, id.token, id.renewToken));
125
163
  // With a token that SDK has no signer, and `ensure()` does not read
126
164
  // `auth`: it needs the token handed to it, or it throws before the
127
165
  // first chunk. Without one the key stays absent so the signer path runs.
package/dist/index.d.ts CHANGED
@@ -29,3 +29,6 @@ export { createWidgetHost, type WidgetHostOptions, type WidgetHostIdentity } fro
29
29
  export { WidgetSignedOutError, type ResolvedSigningIdentity } from './resolve-identity.js';
30
30
  export { toVaultSdkSigner, toClientSdkWallet, toUploaderSignerPair, type VaultSignedWallet } from './bridge-wallet.js';
31
31
  export { connectStandaloneWallet, type StandaloneWallet } from './standalone-wallet.js';
32
+ export { createCefWalletGate, WidgetWalletUnconfiguredError, type CefWalletGate, type CefWalletSession, type CefWalletConfig, type CeremonySurface } from './cef-wallet.js';
33
+ export { requestSignIn, autoSignIn, type SignInCopy } from './sign-in-ui.js';
34
+ export { contextFromLocation, currentLinkContext } from './share-link.js';
package/dist/index.js CHANGED
@@ -29,3 +29,6 @@ export { createWidgetHost } from './widget-host.js';
29
29
  export { WidgetSignedOutError } from './resolve-identity.js';
30
30
  export { toVaultSdkSigner, toClientSdkWallet, toUploaderSignerPair } from './bridge-wallet.js';
31
31
  export { connectStandaloneWallet } from './standalone-wallet.js';
32
+ export { createCefWalletGate, WidgetWalletUnconfiguredError } from './cef-wallet.js';
33
+ export { requestSignIn, autoSignIn } from './sign-in-ui.js';
34
+ export { contextFromLocation, currentLinkContext } from './share-link.js';
@@ -8,6 +8,7 @@ export interface BuildManifestInput {
8
8
  wallet: {
9
9
  appId: string;
10
10
  env: string;
11
+ origin?: string;
11
12
  };
12
13
  endpoints: WidgetManifest['endpoints'];
13
14
  /** Single-query shorthand — kept for backwards compat. Either query or queries may be supplied. */
@@ -22,6 +22,18 @@ export interface ResolvedSigningIdentity {
22
22
  * genuinely cannot carry.
23
23
  */
24
24
  token?: string;
25
+ /**
26
+ * A fresh delegation on demand, when the WALLET minted `token`.
27
+ *
28
+ * `token` is one string minted at boot, and it dies after an hour. A widget
29
+ * left open longer — a polling query, a dashboard on a wall — would present
30
+ * the dead one forever, as a 401 inside the frame that nothing else notices.
31
+ * The wallet's mint already caches the token and replaces it before expiry,
32
+ * so asking on every request costs nothing and is what keeps it alive.
33
+ *
34
+ * Absent on the host path: the host owns its token's lifetime.
35
+ */
36
+ renewToken?: () => Promise<string>;
25
37
  /**
26
38
  * Why the host opened this widget — the run being viewed, or the record.
27
39
  *
@@ -41,6 +53,14 @@ export interface IdentityResolverDeps {
41
53
  bridge: HostBridge;
42
54
  /** Opens the interactive wallet. Only ever called when NOT framed. */
43
55
  connectWallet: () => Promise<StandaloneWallet>;
56
+ /**
57
+ * Why this widget was opened, as the LINK said it — the standalone twin of
58
+ * the handshake's `context`, and only consulted on the standalone path.
59
+ *
60
+ * Absent (or returning undefined) leaves a link-less standalone boot behaving
61
+ * exactly as it did before: the reader's own vault, the latest record.
62
+ */
63
+ linkContext?: () => WidgetHostContext | undefined;
44
64
  }
45
65
  /** Build the memoized identity resolver. A failure clears the memo so a later action can retry. */
46
66
  export declare function createIdentityResolver(deps: IdentityResolverDeps): () => Promise<ResolvedSigningIdentity>;
@@ -45,11 +45,35 @@ export function createIdentityResolver(deps) {
45
45
  throw new WidgetSignedOutError();
46
46
  // Top-level boot → interactive wallet.
47
47
  const wallet = await deps.connectWallet();
48
+ /*
49
+ * THE DELEGATION, WHERE THE WALLET MINTS ONE — for the same reason the
50
+ * framed path prefers the host's. A passkey session is keyless after
51
+ * every page load, so a widget that signs each request raises a step-up
52
+ * each request, and a polling query asks forever.
53
+ *
54
+ * Best-effort on purpose: a wallet with no delegation concept, or a mint
55
+ * that fails, leaves `sign` wired and the widget behaves as it did
56
+ * before — a ceremony where a token would have been. Failing boot over a
57
+ * missing optimisation would turn a slower path into no path.
58
+ */
59
+ let token;
60
+ const mint = wallet.delegation?.bind(wallet);
61
+ if (mint) {
62
+ try {
63
+ token = await mint();
64
+ }
65
+ catch (err) {
66
+ console.warn('[widget-runtime] no delegation minted; falling back to signing', err);
67
+ }
68
+ }
69
+ const context = deps.linkContext?.();
48
70
  return {
49
71
  source: 'wallet',
50
72
  publicKey: wallet.pubkey(),
51
73
  sigType: wallet.sigType,
52
74
  sign: (bytes) => wallet.signRawBytes(bytes),
75
+ ...(token && mint ? { token, renewToken: mint } : {}),
76
+ ...(context ? { context } : {}),
53
77
  };
54
78
  })().catch((err) => {
55
79
  identityP = null; // let a later action retry (e.g. user reopens the wallet)
@@ -0,0 +1,15 @@
1
+ import type { WidgetHostContext } from './host-bridge.js';
2
+ /**
3
+ * Parse the link's own parameters into a host context.
4
+ *
5
+ * Returns `undefined` rather than `{}` when the link names nothing, for the
6
+ * reason the handshake does: a widget doing `'context' in id` must be able to
7
+ * tell "nobody told me anything" from "I was told something empty", and the
8
+ * standalone-with-no-link case has to stay byte-identical to what shipped.
9
+ */
10
+ export declare function contextFromLocation(loc: {
11
+ search?: string;
12
+ hash?: string;
13
+ }): WidgetHostContext | undefined;
14
+ /** Convenience for the browser: the current page's link context. */
15
+ export declare function currentLinkContext(): WidgetHostContext | undefined;
@@ -0,0 +1,72 @@
1
+ // packages/widget-runtime/src/share-link.ts
2
+ //
3
+ // The third source of {@link WidgetHostContext} — a link.
4
+ //
5
+ // A framed widget is told why it was opened over the postMessage handshake. A
6
+ // widget opened by a shared URL has no host to ask, and the two facts it is
7
+ // missing are exactly the two the handshake carries: WHICH VAULT to read, and
8
+ // WHICH RUN to show. So a share link supplies the same structure by another
9
+ // route, and nothing downstream has to learn a second concept — the runtime
10
+ // hands `context` to `identity()` and `vaultId` to the vault client either way.
11
+ //
12
+ // WHY THE VAULT ID IS SAFE IN A URL. It is an identifier, not a credential.
13
+ // Every read is still authorised by vault-api against the READER's own
14
+ // signed-in identity, so naming a vault in a link grants nothing: a person who
15
+ // is not a member gets the same refusal they would get by typing the id in
16
+ // themselves. What the link removes is the guessing — the sharer knows which
17
+ // vault they mean, and making the reader pick from a list is a worse product
18
+ // than being told.
19
+ //
20
+ // Read from the FRAGMENT by preference and the query as a fallback. The
21
+ // fragment is not sent to the CDN that serves the bundle, which keeps the run
22
+ // a reader is looking at out of someone else's access logs; the query is
23
+ // accepted because links get rewritten by tools that do not preserve fragments.
24
+ /** Accepted spellings, in precedence order, for each field of the context. */
25
+ const KEYS = {
26
+ vaultId: ['vaultId', 'vault'],
27
+ runId: ['runId', 'run'],
28
+ recordId: ['recordId', 'record'],
29
+ };
30
+ function firstOf(params, names) {
31
+ for (const name of names) {
32
+ const v = params.get(name);
33
+ if (v && v.trim())
34
+ return v.trim();
35
+ }
36
+ return undefined;
37
+ }
38
+ /**
39
+ * Parse the link's own parameters into a host context.
40
+ *
41
+ * Returns `undefined` rather than `{}` when the link names nothing, for the
42
+ * reason the handshake does: a widget doing `'context' in id` must be able to
43
+ * tell "nobody told me anything" from "I was told something empty", and the
44
+ * standalone-with-no-link case has to stay byte-identical to what shipped.
45
+ */
46
+ export function contextFromLocation(loc) {
47
+ const hash = loc.hash ?? '';
48
+ // `#a=b`, and also `#/route?a=b` — take whatever follows the first `?` when
49
+ // the fragment carries one, else the fragment itself.
50
+ const q = hash.indexOf('?');
51
+ const fragment = q >= 0 ? hash.slice(q + 1) : hash.replace(/^#/, '');
52
+ const fromHash = new URLSearchParams(fragment);
53
+ const fromQuery = new URLSearchParams((loc.search ?? '').replace(/^\?/, ''));
54
+ const pick = (names) => firstOf(fromHash, names) ?? firstOf(fromQuery, names);
55
+ const ctx = {};
56
+ const vaultId = pick(KEYS.vaultId);
57
+ const runId = pick(KEYS.runId);
58
+ const recordId = pick(KEYS.recordId);
59
+ if (vaultId)
60
+ ctx.vaultId = vaultId;
61
+ if (runId)
62
+ ctx.runId = runId;
63
+ if (recordId)
64
+ ctx.recordId = recordId;
65
+ return Object.keys(ctx).length > 0 ? ctx : undefined;
66
+ }
67
+ /** Convenience for the browser: the current page's link context. */
68
+ export function currentLinkContext() {
69
+ if (typeof window === 'undefined' || !window.location)
70
+ return undefined;
71
+ return contextFromLocation(window.location);
72
+ }
@@ -0,0 +1,55 @@
1
+ import type { CefWalletGate, CefWalletSession } from './cef-wallet.js';
2
+ export interface SignInCopy {
3
+ title: string;
4
+ body: string;
5
+ signIn: string;
6
+ register: string;
7
+ }
8
+ /**
9
+ * Show the sign-in overlay and resolve once the reader is authenticated.
10
+ *
11
+ * Never rejects on a dismissed or failed ceremony: the reader is still sitting
12
+ * in front of the widget and can try the other button, so the overlay reports
13
+ * what went wrong and stays up. It settles only on success — or when
14
+ * `resume()`, retried on each attempt, turns out to have a session after all.
15
+ */
16
+ export declare function requestSignIn(gate: CefWalletGate, copy?: Partial<SignInCopy>,
17
+ /**
18
+ * A second gate to try once, when the first one's ceremony is refused.
19
+ *
20
+ * WHAT THIS IS FOR: the framed ceremony needs the page's origin in
21
+ * wallet-api's embed allowlist, and a widget served off the shared content
22
+ * gateway is not going to be in it — allowlisting that origin would admit
23
+ * every bundle anyone has ever published, which is a much larger grant than a
24
+ * sharing feature should ask for. A popup is not framing, so it works
25
+ * unlisted. Retried rather than chosen up front because a widget cannot know
26
+ * whether its own origin is listed, and the listed case is worth having: it
27
+ * is the only one where a session resumes without a click.
28
+ */
29
+ fallback?: () => CefWalletGate): Promise<CefWalletSession>;
30
+ /**
31
+ * Sign the reader in WITHOUT ASKING FIRST.
32
+ *
33
+ * The overlay above collects a click so a popup can open under it. That made
34
+ * the shared-link experience two ceremonies deep: a card saying "Sign in to
35
+ * view this" with a button, and then the wallet's own panel with ITS button.
36
+ * The first of those is not the wallet's requirement — it is this file's,
37
+ * and only for the popup. So it is now the last resort, not the front door.
38
+ *
39
+ * In order, stopping at the first that works:
40
+ *
41
+ * 1. `resume()` — a session from an earlier visit. No ceremony at all.
42
+ * 2. The framed ceremony, started immediately. An iframe needs no gesture,
43
+ * and the wallet's panel carries its own "Continue" — one click, the
44
+ * wallet's, not two. This is what a listed origin gets.
45
+ * 3. The popup ceremony, also started immediately. A browser that has been
46
+ * told to allow popups here opens it; one that has not refuses, and the
47
+ * refusal comes back as a rejection rather than a hang.
48
+ * 4. The overlay, on the popup gate — the one case a gesture is genuinely
49
+ * needed, because the browser demands one for `window.open`.
50
+ *
51
+ * A `resume()` that THROWS, as opposed to resolving null, is a surface that
52
+ * never came up (origin not in the embed allowlist, most likely). Step 2 is
53
+ * skipped then: it would only wait out the same handshake a second time.
54
+ */
55
+ export declare function autoSignIn(gate: CefWalletGate, fallback: () => CefWalletGate, copy?: Partial<SignInCopy>): Promise<CefWalletSession>;