@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.
- package/dist/cef-wallet.d.ts +80 -0
- package/dist/cef-wallet.js +186 -0
- package/dist/entry.js +47 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/manifest.d.ts +1 -0
- package/dist/resolve-identity.d.ts +20 -0
- package/dist/resolve-identity.js +24 -0
- package/dist/share-link.d.ts +15 -0
- package/dist/share-link.js +72 -0
- package/dist/sign-in-ui.d.ts +55 -0
- package/dist/sign-in-ui.js +217 -0
- package/dist/standalone-wallet.d.ts +11 -0
- package/dist/types.d.ts +13 -0
- package/dist/vault-sdk-config.d.ts +1 -1
- package/dist/vault-sdk-config.js +11 -1
- package/dist/widget-runtime.js +95 -94
- package/package.json +2 -1
|
@@ -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 →
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
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: () =>
|
|
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';
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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>;
|
package/dist/resolve-identity.js
CHANGED
|
@@ -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>;
|