@cef-ai/widget-runtime 0.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.
Files changed (56) hide show
  1. package/README.md +34 -0
  2. package/dist/audio/capture-upload.d.ts +116 -0
  3. package/dist/audio/capture-upload.js +347 -0
  4. package/dist/audio/clip-registry.d.ts +20 -0
  5. package/dist/audio/clip-registry.js +47 -0
  6. package/dist/audio/ddc-delegation.d.ts +18 -0
  7. package/dist/audio/ddc-delegation.js +64 -0
  8. package/dist/audio/mic.d.ts +1 -0
  9. package/dist/audio/mic.js +23 -0
  10. package/dist/audio/segment.d.ts +3 -0
  11. package/dist/audio/segment.js +24 -0
  12. package/dist/audio/wav.d.ts +2 -0
  13. package/dist/audio/wav.js +27 -0
  14. package/dist/bridge-wallet.d.ts +46 -0
  15. package/dist/bridge-wallet.js +80 -0
  16. package/dist/bridge.d.ts +6 -0
  17. package/dist/bridge.js +15 -0
  18. package/dist/build-tools.d.ts +4 -0
  19. package/dist/build-tools.js +12 -0
  20. package/dist/entry.d.ts +11 -0
  21. package/dist/entry.js +159 -0
  22. package/dist/host-bridge.d.ts +19 -0
  23. package/dist/host-bridge.js +214 -0
  24. package/dist/index.d.ts +25 -0
  25. package/dist/index.js +21 -0
  26. package/dist/kinds/composite.d.ts +7 -0
  27. package/dist/kinds/composite.js +206 -0
  28. package/dist/kinds/conversation.d.ts +7 -0
  29. package/dist/kinds/conversation.js +667 -0
  30. package/dist/kinds/dashboard.d.ts +2 -0
  31. package/dist/kinds/dashboard.js +53 -0
  32. package/dist/kinds/list.d.ts +2 -0
  33. package/dist/kinds/list.js +22 -0
  34. package/dist/kinds/record.d.ts +2 -0
  35. package/dist/kinds/record.js +14 -0
  36. package/dist/kinds/submit.d.ts +6 -0
  37. package/dist/kinds/submit.js +481 -0
  38. package/dist/manifest.d.ts +32 -0
  39. package/dist/manifest.js +47 -0
  40. package/dist/mount.d.ts +19 -0
  41. package/dist/mount.js +48 -0
  42. package/dist/render-helpers.d.ts +9 -0
  43. package/dist/render-helpers.js +42 -0
  44. package/dist/runtime.d.ts +12 -0
  45. package/dist/runtime.js +36 -0
  46. package/dist/standalone-wallet.d.ts +21 -0
  47. package/dist/standalone-wallet.js +102 -0
  48. package/dist/types.d.ts +245 -0
  49. package/dist/types.js +7 -0
  50. package/dist/validate-config.d.ts +2 -0
  51. package/dist/validate-config.js +74 -0
  52. package/dist/vault-client.d.ts +45 -0
  53. package/dist/vault-client.js +122 -0
  54. package/dist/widget-runtime.js +348 -0
  55. package/dist/widget.html +33 -0
  56. package/package.json +43 -0
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @cef-ai/widget-runtime
2
+
3
+ Browser runtime for CEF widgets. It boots from `window.WidgetSandbox.manifest`,
4
+ resolves the user's identity, exposes `window.WidgetRuntime`
5
+ (`query` / `publish` / `connect` / `connectAgent` / `agentStatus` / `identity`),
6
+ and renders config-driven widget kinds (record / list / dashboard / submit /
7
+ conversation / composite) plus custom widgets. It also carries the audio
8
+ capture/upload path. The runtime **reads** a manifest; the `@cef-ai/cli` build
9
+ tooling **builds** one.
10
+
11
+ The esbuild IIFE build emits `dist/widget-runtime.js` (the browser bundle) and
12
+ `dist/widget.html` (the shell); `tsc` emits the library entry (`dist/index.js`)
13
+ for consumers that import the helpers directly.
14
+
15
+ ## Identity (multi-source)
16
+
17
+ The runtime resolves a signing identity from the best available source:
18
+
19
+ 1. **Embedded (host):** when iframed by the Cere Vault, it signs through the
20
+ host's postMessage sign-bridge.
21
+ 2. **Standalone (interactive wallet):** when there is no responding host — the
22
+ widget is opened top-level via a direct link — it connects an interactive
23
+ Cere embed-wallet built from `manifest.wallet.{appId,env}` (opens the wallet
24
+ UI) and signs locally.
25
+
26
+ Both paths feed the same `@cef-ai/vault-sdk` client, so cubby reads, connect,
27
+ publish, and audio uploads behave identically embedded or standalone.
28
+
29
+ ## Build
30
+
31
+ ```sh
32
+ pnpm --filter @cef-ai/widget-runtime build # esbuild bundle + tsc + fix-extensions
33
+ pnpm --filter @cef-ai/widget-runtime test
34
+ ```
@@ -0,0 +1,116 @@
1
+ export interface CaptureUploadDeps {
2
+ decode: (blob: Blob) => Promise<Float32Array>;
3
+ upload: ((wav: ArrayBuffer, index: number) => Promise<string>) & {
4
+ finalizeUrls?: (urls: string[]) => Promise<string[]>;
5
+ };
6
+ sampleRate: number;
7
+ }
8
+ export interface CaptureUploadInput {
9
+ blob: Blob;
10
+ segmentSeconds?: number;
11
+ softCapSeconds?: number;
12
+ onProgress?: (done: number, total: number) => void;
13
+ onWarn?: (message: string) => void;
14
+ }
15
+ export declare function captureAndUploadAudio(input: CaptureUploadInput, deps: CaptureUploadDeps): Promise<string[]>;
16
+ /** Minimal AwsClient surface we use — kept narrow so tests can inject a fake. */
17
+ type AwsLike = {
18
+ fetch: (url: string, init: RequestInit) => Promise<Response>;
19
+ sign: (input: string, init?: {
20
+ aws?: {
21
+ signQuery?: boolean;
22
+ };
23
+ }) => Promise<Request>;
24
+ };
25
+ /** Shared config for any DDC vault-bucket client (uploader or object store). */
26
+ export interface VaultStoreConfig {
27
+ /**
28
+ * Signer pair from the Vault host bridge (or any other source):
29
+ * publicKey — raw Ed25519 public key bytes
30
+ * sign — async raw-bytes signer
31
+ */
32
+ signerPair: {
33
+ publicKey: Uint8Array;
34
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
35
+ };
36
+ /** Signature scheme reported by the host wallet ('ed25519' | 'sr25519'). */
37
+ sigType: 'ed25519' | 'sr25519';
38
+ /** Public gateway base URL — no trailing slash, no /auth/info suffix. */
39
+ gatewayUrl: string;
40
+ bucketId: string;
41
+ region: string;
42
+ /** Injectable fetch for tests; defaults to globalThis.fetch. */
43
+ fetchImpl?: typeof fetch;
44
+ /**
45
+ * Injectable AwsClient factory for tests.
46
+ * Defaults to: (creds) => new AwsClient({ ...creds, service: 's3' })
47
+ */
48
+ awsClientFactory?: (creds: {
49
+ accessKeyId: string;
50
+ secretAccessKey: string;
51
+ sessionToken?: string;
52
+ region: string;
53
+ }) => AwsLike;
54
+ /**
55
+ * TTL (seconds) for presigned GET URLs handed back to callers. Whisper reads
56
+ * the object via this URL, so it must outlive upload→fetch latency. Default
57
+ * 21600 (6h); SigV4's ceiling is 7d.
58
+ */
59
+ readTtlSeconds?: number;
60
+ /**
61
+ * Label stored on the gateway credential this client mints (≤64 chars).
62
+ * Cap recovery only reclaims rows carrying this label. Default "widget-runtime".
63
+ */
64
+ credentialLabel?: string;
65
+ }
66
+ export interface VaultUploaderConfig extends VaultStoreConfig {
67
+ /** Path prefix inside the bucket. Defaults to `widget-audio/<uuid>`. */
68
+ keyPrefix?: string;
69
+ /**
70
+ * Read URL mode for downstream model fetches.
71
+ *
72
+ * Default `signed` keeps Vault audio private; the URL is bound to this
73
+ * client's own credential (revoking that credential invalidates it).
74
+ * `public` writes `x-amz-acl: public-read` and returns the plain object URL;
75
+ * use only for controlled dev/test flows or a deliberate public asset policy.
76
+ */
77
+ readMode?: 'signed' | 'public';
78
+ }
79
+ export type VaultUploadFn = ((wav: ArrayBuffer, index: number) => Promise<string>) & {
80
+ finalizeUrls: (urls: string[]) => Promise<string[]>;
81
+ };
82
+ /**
83
+ * Creates a WAV uploader that writes to a DDC S3-compatible vault bucket using
84
+ * the proven in-browser flow from the spike:
85
+ * 1. Derive signer from embed wallet (ed25519_signRaw).
86
+ * 2. GET /auth/info → gateway_public_key.
87
+ * 3. Mint delegation token (subject = gateway_public_key).
88
+ * 4. POST /auth/credentials with signed headers — always mints a NEW,
89
+ * labeled credential for this uploader (cap-400 → reclaim our oldest
90
+ * labeled row and retry once).
91
+ * 5. Per segment: SHA-256 WAV bytes, PUT via SigV4-signed AwsClient.
92
+ *
93
+ * S3 credentials are memoized (shared credential core) so the signer is only
94
+ * invoked once per uploader instance under normal operation.
95
+ */
96
+ export declare function createVaultUploader(cfg: VaultUploaderConfig): VaultUploadFn;
97
+ /** Read/write whole objects at caller-chosen keys in a DDC vault bucket. */
98
+ export interface VaultObjectStore {
99
+ /**
100
+ * PUT raw bytes at `key` (bucket-relative, no leading slash) with the given
101
+ * content-type. Returns the stored key — persist it to re-sign a GET later.
102
+ */
103
+ put(bytes: ArrayBuffer | Uint8Array, key: string, contentType: string): Promise<string>;
104
+ /** Re-sign a fresh presigned GET URL for a previously-stored key. */
105
+ signGet(key: string, ttlSeconds?: number): Promise<string>;
106
+ }
107
+ /**
108
+ * Whole-file companion to createVaultUploader: PUT an object at a STABLE,
109
+ * caller-chosen key and re-sign GET URLs for it on demand. Used to persist a
110
+ * durable interview recording (store the key, not the expiring presigned URL)
111
+ * and re-sign playback URLs from the candidate modal — same private bucket,
112
+ * same wallet, so the grant can always be re-minted. Shares the auth flow with
113
+ * the segment uploader via createCredCore.
114
+ */
115
+ export declare function createVaultObjectStore(cfg: VaultStoreConfig): VaultObjectStore;
116
+ export {};
@@ -0,0 +1,347 @@
1
+ // packages/widget-runtime/src/audio/capture-upload.ts
2
+ import { AwsClient } from 'aws4fetch';
3
+ import { hexToU8a, u8aToHex } from '@polkadot/util';
4
+ import { encodeWav } from './wav.js';
5
+ import { segmentPcm } from './segment.js';
6
+ import { mintDelegationToken } from './ddc-delegation.js';
7
+ export async function captureAndUploadAudio(input, deps) {
8
+ const segSecs = input.segmentSeconds ?? 25;
9
+ const pcm = await deps.decode(input.blob);
10
+ const durationSecs = pcm.length / deps.sampleRate;
11
+ if (input.softCapSeconds && durationSecs > input.softCapSeconds) {
12
+ input.onWarn?.(`Recording is ${Math.round(durationSecs / 60)} min; long uploads may be slow.`);
13
+ }
14
+ const segments = segmentPcm(pcm, deps.sampleRate, segSecs);
15
+ const urls = [];
16
+ for (let i = 0; i < segments.length; i++) {
17
+ urls.push(await deps.upload(encodeWav(segments[i], deps.sampleRate), i));
18
+ input.onProgress?.(i + 1, segments.length);
19
+ }
20
+ return deps.upload.finalizeUrls ? await deps.upload.finalizeUrls(urls) : urls;
21
+ }
22
+ // ── Internal helpers ──────────────────────────────────────────────────────────
23
+ /** Compute SHA-256 hex of bytes using the Web Crypto API (available in browsers and Node >= 15). */
24
+ async function sha256Hex(bytes) {
25
+ const buf = await crypto.subtle.digest('SHA-256', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
26
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
27
+ }
28
+ /**
29
+ * Build signed HTTP headers for DDC S3-gateway /auth/* calls.
30
+ * Replicates ddc-onboarding.ts signedHeaders exactly:
31
+ * X-Public-Key: 0x-prefixed hex
32
+ * X-Signature: hex WITHOUT 0x prefix
33
+ * X-Signature-Type: the wallet's signature scheme (ed25519 | sr25519)
34
+ * Content-Type: application/json
35
+ */
36
+ async function buildSignedHeaders(sign, publicKey, sigType, body = '') {
37
+ const bytes = new TextEncoder().encode(body);
38
+ const sig = await sign(bytes);
39
+ return {
40
+ 'Content-Type': 'application/json',
41
+ 'X-Public-Key': u8aToHex(publicKey), // includes 0x prefix
42
+ 'X-Signature': u8aToHex(sig).slice(2), // strip 0x
43
+ 'X-Signature-Type': sigType,
44
+ };
45
+ }
46
+ // ── Gateway credential API (multi-credential contract) ───────────────────────
47
+ //
48
+ // Gateway contract (ddc-s3-gateway PR #20):
49
+ // - POST /auth/credentials ALWAYS mints a new credential (409 is gone).
50
+ // Optional `label` (≤64 chars) and `expires_in_seconds` in the body.
51
+ // - A wallet holds at most 10 live credentials; past the cap the POST fails
52
+ // with 400 "credential limit reached" (expired rows don't count).
53
+ // - GET /auth/credentials (signed empty body) lists rows, never secrets.
54
+ // - DELETE /auth/credentials with a wallet-signed body {"access_key_id"}
55
+ // revokes one row; a bare empty-body DELETE only touches the legacy
56
+ // suffix-less row — never use it here.
57
+ /** Label stamped on every credential this package mints (unless overridden). */
58
+ const CREDENTIAL_LABEL = 'widget-runtime';
59
+ const CAP_ERROR_RE = /credential limit reached/i;
60
+ function isLiveRow(row, now = Date.now()) {
61
+ if (row.revoked)
62
+ return false;
63
+ if (row.expires_at && Date.parse(row.expires_at) <= now)
64
+ return false;
65
+ return true;
66
+ }
67
+ async function listCredentials(ctx) {
68
+ const res = await ctx.fetchImpl(`${ctx.gatewayUrl}/auth/credentials`, {
69
+ headers: await buildSignedHeaders(ctx.sign, ctx.publicKey, ctx.sigType),
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`GET ${ctx.gatewayUrl}/auth/credentials → ${res.status} ${await res.text()}`);
73
+ // The gateway returns a bare JSON array; tolerate an object envelope too.
74
+ const json = (await res.json());
75
+ return Array.isArray(json) ? json : (json.credentials ?? []);
76
+ }
77
+ /** Targeted revoke: DELETE with a wallet-signed JSON body naming the row. */
78
+ async function revokeCredentialById(ctx, accessKeyId) {
79
+ const body = JSON.stringify({ access_key_id: accessKeyId });
80
+ const res = await ctx.fetchImpl(`${ctx.gatewayUrl}/auth/credentials`, {
81
+ method: 'DELETE', headers: await buildSignedHeaders(ctx.sign, ctx.publicKey, ctx.sigType, body), body,
82
+ });
83
+ if (!res.ok)
84
+ throw new Error(`DELETE ${ctx.gatewayUrl}/auth/credentials (${accessKeyId}) → ${res.status} ${await res.text()}`);
85
+ }
86
+ /**
87
+ * Mint S3 credentials for a delegation token under our label. On a cap-400
88
+ * ("credential limit reached") reclaim the OLDEST live row carrying our label
89
+ * (an abandoned earlier session) via targeted revoke and retry the POST once;
90
+ * rows minted by other tools/sessions are left alone. If nothing can be
91
+ * reclaimed, fail with an actionable error.
92
+ */
93
+ async function registerCredentials(ctx, delegationToken, label) {
94
+ const body = JSON.stringify({ delegation_token: delegationToken, label });
95
+ const post = async () => ctx.fetchImpl(`${ctx.gatewayUrl}/auth/credentials`, {
96
+ method: 'POST', headers: await buildSignedHeaders(ctx.sign, ctx.publicKey, ctx.sigType, body), body,
97
+ });
98
+ let res = await post();
99
+ if (res.status === 400) {
100
+ const text = await res.text();
101
+ if (!CAP_ERROR_RE.test(text))
102
+ throw new Error(`POST ${ctx.gatewayUrl}/auth/credentials → 400 ${text}`);
103
+ const rows = await listCredentials(ctx);
104
+ const own = rows
105
+ .filter((r) => isLiveRow(r) && r.label === label)
106
+ .sort((a, b) => Date.parse(a.created_at ?? '') - Date.parse(b.created_at ?? ''));
107
+ const victim = own[0];
108
+ if (!victim) {
109
+ throw new Error(`POST ${ctx.gatewayUrl}/auth/credentials → 400 ${text}. The wallet is at its live-credential cap and no row is ` +
110
+ `labeled "${label}", so nothing can be reclaimed automatically. Revoke an unused credential ` +
111
+ `(GET /auth/credentials to list; DELETE with a signed {"access_key_id"} body), then retry.`);
112
+ }
113
+ console.warn(`[widget-runtime] credential cap reached — revoking oldest "${label}" credential ${victim.access_key_id} and retrying`);
114
+ await revokeCredentialById(ctx, victim.access_key_id);
115
+ res = await post();
116
+ }
117
+ if (!res.ok)
118
+ throw new Error(`POST ${ctx.gatewayUrl}/auth/credentials → ${res.status} ${await res.text()}`);
119
+ return (await res.json());
120
+ }
121
+ /**
122
+ * Acquire vault-bucket S3 credentials once and expose a memoized AwsClient.
123
+ * Shared by createVaultObjectStore AND createVaultUploader; the in-browser
124
+ * auth flow —
125
+ * 1. GET /auth/info → gateway_public_key.
126
+ * 2. Mint delegation token (subject = gateway_public_key).
127
+ * 3. POST /auth/credentials with signed headers — always mints a NEW,
128
+ * labeled credential (cap-400 → reclaim our oldest labeled row, retry once).
129
+ * Credentials are minted on first use, so the signer is invoked only once per
130
+ * client under normal operation. Concurrent sessions each hold their own
131
+ * credential — the gateway supports many per wallet, so there is no
132
+ * revoke-each-other race anymore.
133
+ */
134
+ function createCredCore(cfg) {
135
+ const fetchImpl = cfg.fetchImpl ?? globalThis.fetch;
136
+ const awsClientFactory = cfg.awsClientFactory ?? ((creds) => new AwsClient({ accessKeyId: creds.accessKeyId, secretAccessKey: creds.secretAccessKey, region: creds.region, service: 's3', ...(creds.sessionToken ? { sessionToken: creds.sessionToken } : {}) }));
137
+ const label = cfg.credentialLabel ?? CREDENTIAL_LABEL;
138
+ const ctx = {
139
+ fetchImpl, gatewayUrl: cfg.gatewayUrl,
140
+ sign: cfg.signerPair.sign, publicKey: cfg.signerPair.publicKey, sigType: cfg.sigType,
141
+ };
142
+ let credsPromise = null;
143
+ let awsPromise = null;
144
+ async function acquireCreds() {
145
+ const infoRes = await fetchImpl(`${cfg.gatewayUrl}/auth/info`, {
146
+ headers: await buildSignedHeaders(ctx.sign, ctx.publicKey, cfg.sigType),
147
+ });
148
+ if (!infoRes.ok)
149
+ throw new Error(`GET ${cfg.gatewayUrl}/auth/info → ${infoRes.status} ${await infoRes.text()}`);
150
+ const info = (await infoRes.json());
151
+ const token = await mintDelegationToken({
152
+ signerPair: { sign: ctx.sign, publicKey: ctx.publicKey }, signerType: cfg.sigType, subject: hexToU8a(info.gateway_public_key),
153
+ });
154
+ return registerCredentials(ctx, token, label);
155
+ }
156
+ function aws() {
157
+ if (!awsPromise) {
158
+ awsPromise = (async () => {
159
+ // reset on failure so a transient creds error doesn't permanently poison this client
160
+ if (!credsPromise)
161
+ credsPromise = acquireCreds().catch((err) => { credsPromise = null; throw err; });
162
+ const creds = await credsPromise;
163
+ // SigV4 region: use the bucket's region (cfg.region), NOT creds.region —
164
+ // /auth/credentials may report a generic region (e.g. us-east-1) that
165
+ // mismatches the bucket's SigV4 credential scope → SignatureDoesNotMatch.
166
+ return awsClientFactory({
167
+ accessKeyId: creds.access_key_id, secretAccessKey: creds.secret_access_key, region: cfg.region,
168
+ ...(creds.session_token ? { sessionToken: creds.session_token } : {}),
169
+ });
170
+ })().catch((err) => { awsPromise = null; throw err; });
171
+ }
172
+ return awsPromise;
173
+ }
174
+ return {
175
+ gatewayUrl: cfg.gatewayUrl, bucketId: cfg.bucketId, region: cfg.region,
176
+ readTtl: cfg.readTtlSeconds ?? 21600,
177
+ aws,
178
+ async refresh() {
179
+ const stale = credsPromise ? await credsPromise.catch(() => null) : null;
180
+ credsPromise = null;
181
+ awsPromise = null;
182
+ // Mint the replacement FIRST so the wallet never goes credential-less
183
+ // (anonymous public reads are served via the newest live credential).
184
+ await aws();
185
+ if (stale) {
186
+ try {
187
+ await revokeCredentialById(ctx, stale.access_key_id);
188
+ }
189
+ catch (err) {
190
+ console.warn(`[widget-runtime] revoke of replaced credential failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
191
+ }
192
+ }
193
+ },
194
+ };
195
+ }
196
+ /**
197
+ * PUT bytes with bounded retries on 401/403. The gateway now holds MANY
198
+ * credentials per wallet, so a 403 no longer means "another session revoked
199
+ * my credential" — first retry with the SAME credential (transient gateway /
200
+ * propagation errors). Only if the failure persists, refresh the credential
201
+ * once: the underlying DDC delegation token may have expired, and minting a
202
+ * replacement (revoking the old row by id) is the only way to renew it.
203
+ */
204
+ async function putWithRetry(core, url, body, contentType, onAttempt) {
205
+ const send = async () => {
206
+ const aws = await core.aws();
207
+ const sha = await sha256Hex(body);
208
+ onAttempt?.();
209
+ return aws.fetch(url, { method: 'PUT', body: body, headers: { 'Content-Type': contentType, 'X-Amz-Content-Sha256': sha } });
210
+ };
211
+ let res = await send();
212
+ if (res.status === 401 || res.status === 403) {
213
+ await res.text().catch(() => undefined); // drain the error body
214
+ console.warn('[widget-runtime] PUT 401/403 — retrying with the same credential');
215
+ res = await send();
216
+ }
217
+ if (res.status === 401 || res.status === 403) {
218
+ await res.text().catch(() => undefined);
219
+ console.warn('[widget-runtime] PUT still unauthorized — refreshing the credential and retrying once');
220
+ await core.refresh();
221
+ res = await send();
222
+ }
223
+ if (!res.ok)
224
+ throw new Error(`PUT ${url} → ${res.status} ${await res.text()}`);
225
+ }
226
+ /**
227
+ * Creates a WAV uploader that writes to a DDC S3-compatible vault bucket using
228
+ * the proven in-browser flow from the spike:
229
+ * 1. Derive signer from embed wallet (ed25519_signRaw).
230
+ * 2. GET /auth/info → gateway_public_key.
231
+ * 3. Mint delegation token (subject = gateway_public_key).
232
+ * 4. POST /auth/credentials with signed headers — always mints a NEW,
233
+ * labeled credential for this uploader (cap-400 → reclaim our oldest
234
+ * labeled row and retry once).
235
+ * 5. Per segment: SHA-256 WAV bytes, PUT via SigV4-signed AwsClient.
236
+ *
237
+ * S3 credentials are memoized (shared credential core) so the signer is only
238
+ * invoked once per uploader instance under normal operation.
239
+ */
240
+ export function createVaultUploader(cfg) {
241
+ const core = createCredCore(cfg);
242
+ // Compute keyPrefix once per instance (not per call).
243
+ const keyPrefix = cfg.keyPrefix ?? `widget-audio/${crypto.randomUUID()}`;
244
+ const uploadedObjects = new Map();
245
+ // Presign (signed mode) or return the plain public URL. We deliberately do NOT
246
+ // read-back-verify each chunk: a credential refresh mid-batch (the PUT-retry
247
+ // path below) invalidates URLs presigned with the replaced credential — an
248
+ // intermittent SignatureDoesNotMatch on a verify GET that has no business
249
+ // failing the upload. `finalizeUrls` re-signs every object with the final
250
+ // stable credential after all chunks land, and the ASR stage retries its own
251
+ // reads; that is the stable path the pre-refactor uploader used (see
252
+ // docs/engineering/known-issues/2026-06-01-widget-audio-private-bucket-asr-403.md).
253
+ async function readableUrlForObject(aws, objectUrl) {
254
+ if (cfg.readMode === 'public')
255
+ return objectUrl;
256
+ const signed = await aws.sign(`${objectUrl}?X-Amz-Expires=${core.readTtl}`, { aws: { signQuery: true } });
257
+ return signed.url;
258
+ }
259
+ const uploadWav = (async function uploadWav(wav, index) {
260
+ const wavBytes = new Uint8Array(wav);
261
+ const contentSha256 = await sha256Hex(wavBytes);
262
+ // SigV4 region from the bucket (cfg.region), not creds.region.
263
+ const region = cfg.region;
264
+ const url = `${cfg.gatewayUrl}/${cfg.bucketId}/${keyPrefix}/${index}.wav`;
265
+ // Bounded 401/403 retry with backoff. Under the multi-credential gateway a
266
+ // 403 no longer means a concurrent session revoked our credential, so the
267
+ // first retry RE-USES the already-minted credential (transient gateway /
268
+ // propagation hiccups). If the failure persists past attempt 2, refresh the
269
+ // credential (the underlying DDC delegation token may have expired); the
270
+ // replaced row is revoked by id, so retries never leak rows toward the cap.
271
+ const maxAttempts = 4;
272
+ let lastStatus = 0;
273
+ let lastText = '';
274
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
275
+ const aws = await core.aws();
276
+ // NOTE: never log the RETURNED presigned url — it carries a bearer SigV4 sig.
277
+ console.log('[wr-up] PUT region=', region, 'bucket=', cfg.bucketId, 'index=', index, 'attempt=', attempt, 'bytes=', wavBytes.length, 'sha=', contentSha256.slice(0, 16));
278
+ const putRes = await aws.fetch(url, {
279
+ method: 'PUT',
280
+ // Send a Blob (an immutable, exact byte sequence) rather than the Uint8Array
281
+ // view: the browser transmits EXACTLY the bytes we hashed, so the gateway's
282
+ // recomputed SHA256(body) matches the signed X-Amz-Content-Sha256. Passing a
283
+ // typed-array view to fetch() can transmit its whole backing ArrayBuffer in
284
+ // the browser (not in Node) — that skew was the SignatureDoesNotMatch on
285
+ // segmented chunks. The gateway rejects UNSIGNED-PAYLOAD, so we sign the real
286
+ // content hash.
287
+ body: new Blob([wavBytes], { type: 'audio/wav' }),
288
+ headers: {
289
+ 'Content-Type': 'audio/wav',
290
+ 'X-Amz-Content-Sha256': contentSha256,
291
+ ...(cfg.readMode === 'public' ? { 'x-amz-acl': 'public-read' } : {}),
292
+ },
293
+ });
294
+ if (putRes.ok) {
295
+ uploadedObjects.set(index, url);
296
+ return await readableUrlForObject(aws, url);
297
+ }
298
+ lastStatus = putRes.status;
299
+ lastText = await putRes.text();
300
+ if ((putRes.status === 403 || putRes.status === 401) && attempt < maxAttempts) {
301
+ if (attempt >= 2)
302
+ await core.refresh();
303
+ await new Promise((r) => setTimeout(r, 600 * attempt));
304
+ continue;
305
+ }
306
+ break;
307
+ }
308
+ throw new Error(`PUT ${url} → ${lastStatus} ${lastText}`);
309
+ });
310
+ uploadWav.finalizeUrls = async (urls) => {
311
+ if (uploadedObjects.size === 0)
312
+ return urls;
313
+ const aws = await core.aws();
314
+ const entries = [...uploadedObjects.entries()].sort(([a], [b]) => a - b);
315
+ const signed = [];
316
+ for (const [, objectUrl] of entries) {
317
+ signed.push(await readableUrlForObject(aws, objectUrl));
318
+ }
319
+ return signed;
320
+ };
321
+ return uploadWav;
322
+ }
323
+ /**
324
+ * Whole-file companion to createVaultUploader: PUT an object at a STABLE,
325
+ * caller-chosen key and re-sign GET URLs for it on demand. Used to persist a
326
+ * durable interview recording (store the key, not the expiring presigned URL)
327
+ * and re-sign playback URLs from the candidate modal — same private bucket,
328
+ * same wallet, so the grant can always be re-minted. Shares the auth flow with
329
+ * the segment uploader via createCredCore.
330
+ */
331
+ export function createVaultObjectStore(cfg) {
332
+ const core = createCredCore(cfg);
333
+ const objectUrl = (key) => `${core.gatewayUrl}/${core.bucketId}/${key.replace(/^\/+/, '')}`;
334
+ return {
335
+ async put(bytes, key, contentType) {
336
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
337
+ const url = objectUrl(key);
338
+ await putWithRetry(core, url, u8, contentType, () => console.log('[wr-up] PUT object region=', core.region, 'bucket=', core.bucketId, 'bytes=', u8.length, 'type=', contentType));
339
+ return key;
340
+ },
341
+ async signGet(key, ttlSeconds) {
342
+ const aws = await core.aws();
343
+ const signed = await aws.sign(`${objectUrl(key)}?X-Amz-Expires=${ttlSeconds ?? core.readTtl}`, { aws: { signQuery: true } });
344
+ return signed.url;
345
+ },
346
+ };
347
+ }
@@ -0,0 +1,20 @@
1
+ /** A playable clip: an object URL bound to the conversation it belongs to. */
2
+ export interface Clip {
3
+ conversationId: string;
4
+ url: string;
5
+ }
6
+ export interface ClipRegistry {
7
+ /** Record (or replace) the clip for a conversation. Replacing revokes the stale url for that id only. */
8
+ set(conversationId: string, url: string): void;
9
+ /** The clip for a conversation, or null if none is registered. */
10
+ get(conversationId: string): Clip | null;
11
+ /** The most recently registered clip, or null. */
12
+ last(): Clip | null;
13
+ /** Revoke every clip url and empty the registry (e.g. on teardown). */
14
+ clear(): void;
15
+ }
16
+ export interface ClipRegistryOptions {
17
+ /** Object-URL reclaimer; defaults to URL.revokeObjectURL. Injectable for tests. */
18
+ revoke?: (url: string) => void;
19
+ }
20
+ export declare function createClipRegistry(opts?: ClipRegistryOptions): ClipRegistry;
@@ -0,0 +1,47 @@
1
+ // packages/widget-runtime/src/audio/clip-registry.ts
2
+ //
3
+ // Keeps every uploaded/recorded clip playable for the lifetime of the session,
4
+ // keyed by conversation_id. The uploader previously held a SINGLE clip slot and
5
+ // revoked the prior clip's object URL on each new upload, so only the latest
6
+ // upload stayed playable — an older run's audio player vanished as soon as you
7
+ // uploaded a new one. A registry lets each conversation keep its own object URL,
8
+ // so the host can render a player per run without depending on IndexedDB
9
+ // (which is best-effort and unavailable in some sandboxed/private contexts).
10
+ export function createClipRegistry(opts = {}) {
11
+ const revoke = opts.revoke ??
12
+ ((url) => {
13
+ try {
14
+ URL.revokeObjectURL(url);
15
+ }
16
+ catch {
17
+ /* already revoked / unsupported */
18
+ }
19
+ });
20
+ // Insertion-ordered map; the last entry is the most recent clip.
21
+ const clips = new Map();
22
+ return {
23
+ set(conversationId, url) {
24
+ const prev = clips.get(conversationId);
25
+ if (prev !== undefined && prev !== url)
26
+ revoke(prev);
27
+ // Re-insert so this conversation becomes the most-recent entry.
28
+ clips.delete(conversationId);
29
+ clips.set(conversationId, url);
30
+ },
31
+ get(conversationId) {
32
+ const url = clips.get(conversationId);
33
+ return url === undefined ? null : { conversationId, url };
34
+ },
35
+ last() {
36
+ let lastEntry = null;
37
+ for (const [conversationId, url] of clips)
38
+ lastEntry = { conversationId, url };
39
+ return lastEntry;
40
+ },
41
+ clear() {
42
+ for (const url of clips.values())
43
+ revoke(url);
44
+ clips.clear();
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,18 @@
1
+ export declare const Operation: Readonly<{
2
+ PUT: 1;
3
+ GET: 2;
4
+ DELETE: 3;
5
+ }>;
6
+ export interface AsyncSignerPair {
7
+ sign: (bytes: Uint8Array) => Promise<Uint8Array>;
8
+ publicKey: Uint8Array;
9
+ }
10
+ export interface MintOpts {
11
+ signerPair: AsyncSignerPair;
12
+ signerType: 'ed25519' | 'sr25519';
13
+ subject: Uint8Array;
14
+ bucketId?: bigint | number;
15
+ expiresIn?: number;
16
+ operations?: number[];
17
+ }
18
+ export declare function mintDelegationToken(opts: MintOpts): Promise<string>;
@@ -0,0 +1,64 @@
1
+ // packages/widget-runtime/src/audio/ddc-delegation.ts
2
+ // Async DDC delegation-token mint — ported VERBATIM from
3
+ // agents/dating-coach/scripts/lib/ddc-delegation-token.ts
4
+ // The only change from the agent version: mintDelegationToken is async and
5
+ // awaits signerPair.sign(…), matching the in-browser signing path where the
6
+ // embed-wallet provider.request() is always Promise-based.
7
+ import { base58Encode } from '@polkadot/util-crypto';
8
+ function encodeVarint(value) {
9
+ const bytes = [];
10
+ let v = typeof value === 'bigint' ? value : BigInt(value);
11
+ if (v < 0n)
12
+ v &= 0xffffffffffffffffn;
13
+ do {
14
+ const b = Number(v & 0x7fn);
15
+ v >>= 7n;
16
+ bytes.push(v > 0n ? b | 0x80 : b);
17
+ } while (v > 0n);
18
+ return new Uint8Array(bytes);
19
+ }
20
+ function concat(...arrays) {
21
+ const total = arrays.reduce((n, a) => n + a.length, 0);
22
+ const out = new Uint8Array(total);
23
+ let off = 0;
24
+ for (const a of arrays) {
25
+ out.set(a, off);
26
+ off += a.length;
27
+ }
28
+ return out;
29
+ }
30
+ const fieldTag = (num, wireType) => encodeVarint(num * 8 + wireType);
31
+ const fVarint = (num, value) => concat(fieldTag(num, 0), encodeVarint(value));
32
+ const fBytes = (num, bytes) => concat(fieldTag(num, 2), encodeVarint(bytes.length), bytes);
33
+ // Signature { algorithm(1), signer(2), value(3) }
34
+ function encodeSignature(algorithm, signerBytes, sigBytes) {
35
+ return concat(fVarint(1, algorithm), fBytes(2, signerBytes), fBytes(3, sigBytes));
36
+ }
37
+ // Payload { subject(2), canDelegate(3), bucketId(4)?, operations(5), expiresAt(6) }
38
+ function encodePayload({ subject, canDelegate, bucketId, operations, expiresAt }) {
39
+ const parts = [fBytes(2, subject), fVarint(3, canDelegate ? 1 : 0)];
40
+ if (bucketId !== undefined)
41
+ parts.push(fVarint(4, typeof bucketId === 'bigint' ? bucketId : BigInt(bucketId)));
42
+ // operations is a proto3 `repeated Operation` field, packed encoding — matches token.mjs.
43
+ if (operations.length > 0)
44
+ parts.push(fBytes(5, concat(...operations.map((op) => encodeVarint(op)))));
45
+ parts.push(fVarint(6, expiresAt));
46
+ return concat(...parts);
47
+ }
48
+ // AuthToken { signature(1), payload(2) }
49
+ const encodeAuthToken = (sigMsgBytes, payloadBytes) => concat(fBytes(1, sigMsgBytes), fBytes(2, payloadBytes));
50
+ export const Operation = Object.freeze({ PUT: 1, GET: 2, DELETE: 3 });
51
+ const Algorithm = Object.freeze({ ED_25519: 0, SR_25519: 1 });
52
+ export async function mintDelegationToken(opts) {
53
+ const { signerPair, signerType, subject, bucketId, expiresIn = 30 * 24 * 60 * 60 * 1000, operations = [Operation.PUT, Operation.GET, Operation.DELETE], } = opts;
54
+ const expiresAt = Date.now() + expiresIn;
55
+ const payloadBytes = encodePayload({
56
+ subject, canDelegate: true, operations, expiresAt,
57
+ ...(bucketId !== undefined ? { bucketId } : {}),
58
+ });
59
+ const tokenBodyForSigning = fBytes(2, payloadBytes);
60
+ const sigBytes = await signerPair.sign(tokenBodyForSigning);
61
+ const algorithm = signerType === 'ed25519' ? Algorithm.ED_25519 : Algorithm.SR_25519;
62
+ const sigMsg = encodeSignature(algorithm, signerPair.publicKey, sigBytes);
63
+ return base58Encode(encodeAuthToken(sigMsg, payloadBytes));
64
+ }
@@ -0,0 +1 @@
1
+ export declare function requestMicStream(): Promise<MediaStream>;