@privos_ai/app-server 0.7.2 → 0.8.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.
@@ -5,12 +5,111 @@ import { buildHubUserTokenAuthOptions, extractRelayUserTokenCredential, } from '
5
5
  import { AppServerRuntime, DEFAULT_MAX_MESSAGE_BYTES, relayCallerAuthSurface, resolveCallerCredential, } from '../runtime.js';
6
6
  import { MessageTooLargeError, rawDataToText } from './message-adapter.js';
7
7
  import { isStandaloneControlMethod, } from './standalone-control.js';
8
- import { saveStandaloneIdentity, standaloneHubFingerprint, } from './standalone-identity.js';
8
+ import { consumeStandalonePendingIdentity, loadStandaloneIdentity, loadStandalonePendingIdentity, resolveIdentityFilePath, saveStandaloneIdentity, saveStandalonePendingIdentity, standaloneHubFingerprint, standaloneIdentityFileExists, StandaloneIdentityError, } from './standalone-identity.js';
9
+ import { lintManifest, sha256CanonicalJson } from '../manifest-tools.js';
9
10
  import { assertRuntimeDispatchTrustConfigurationV3, extractRuntimeDispatchRelayEnvelopeV3, verifyRuntimeDispatchAssertionV3, } from '../workload/dispatch-assertion.js';
10
11
  const BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
11
12
  const DEFAULT_PAIRING_TIMEOUT_MS = 30_000;
12
13
  const DEFAULT_OAUTH_TIMEOUT_MS = 15_000;
13
14
  const DEFAULT_OPEN_HANDSHAKE_TIMEOUT_MS = 15_000;
15
+ function sortedUniqueStrings(value, field) {
16
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item)) {
17
+ throw new Error(`Pairing response ${field} is invalid.`);
18
+ }
19
+ const result = [...value].sort();
20
+ if (new Set(result).size !== result.length)
21
+ throw new Error(`Pairing response ${field} contains duplicates.`);
22
+ return result;
23
+ }
24
+ function manifestDeclaredPermissionCeiling(manifest) {
25
+ if (!Array.isArray(manifest.permissions))
26
+ throw new Error('Published v3 manifest must declare permissions.');
27
+ return sortedUniqueStrings(manifest.permissions.map((permission) => permission && typeof permission === 'object' && !Array.isArray(permission)
28
+ ? permission.scope
29
+ : undefined), 'manifest permission ceiling');
30
+ }
31
+ async function persistCompletedPairing(input) {
32
+ assertRuntimeDispatchTrustConfigurationV3(input.wire.trust);
33
+ const trust = input.wire.trust;
34
+ const fingerprint = standaloneHubFingerprint(trust.hubKid);
35
+ if (input.wire.fingerprint !== undefined && input.wire.fingerprint !== fingerprint) {
36
+ throw new Error('Pairing response fingerprint does not match the pinned Hub key.');
37
+ }
38
+ if (trust.affinity.mcpAppId !== input.mcpAppId)
39
+ throw new Error('Pairing completion changed the app identity.');
40
+ if (input.wire.manifestDigest && input.wire.manifestDigest !== trust.affinity.manifestDigest) {
41
+ throw new Error('Pairing completion manifest digest does not match dispatch affinity.');
42
+ }
43
+ let approvedPermissionCeiling;
44
+ if (input.wire.approvedPermissionCeiling !== undefined) {
45
+ approvedPermissionCeiling = sortedUniqueStrings(input.wire.approvedPermissionCeiling, 'approvedPermissionCeiling');
46
+ }
47
+ if (input.pending) {
48
+ if (input.pending.clientId !== input.clientId ||
49
+ input.pending.oauthClientId !== input.wire.oauthClientId ||
50
+ input.pending.mcpAppId !== input.mcpAppId ||
51
+ input.pending.pairingId !== input.wire.pairingId ||
52
+ input.pending.manifestDigest !== trust.affinity.manifestDigest ||
53
+ input.pending.permissionContractHash !== input.wire.permissionContractHash ||
54
+ input.pending.hubKid !== trust.hubKid ||
55
+ input.pending.fingerprint !== fingerprint) {
56
+ throw new Error('Pairing completion does not match the durable pending identity.');
57
+ }
58
+ if (!approvedPermissionCeiling)
59
+ throw new Error('Pairing completion omitted the approved permission ceiling.');
60
+ const declared = new Set(input.pending.declaredPermissionCeiling);
61
+ if (approvedPermissionCeiling.some((scope) => !declared.has(scope))) {
62
+ throw new Error('Pairing completion widened the published permission ceiling.');
63
+ }
64
+ }
65
+ (input.options?.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before trusting dispatch from this Hub.`);
66
+ let identityFilePath;
67
+ if (input.options?.persistIdentityFile ?? true) {
68
+ const identity = {
69
+ pairingVersion: 2,
70
+ relayUrl: input.privosUrl,
71
+ clientId: input.clientId,
72
+ clientSecret: input.clientSecret,
73
+ trust,
74
+ fingerprint,
75
+ mcpAppId: input.mcpAppId,
76
+ pairedAt: Date.now(),
77
+ };
78
+ try {
79
+ identityFilePath = await saveStandaloneIdentity(identity, { filePath: input.options?.identityFilePath });
80
+ }
81
+ catch (error) {
82
+ const existing = loadStandaloneIdentity({ filePath: input.options?.identityFilePath });
83
+ if (existing.identity.relayUrl !== identity.relayUrl ||
84
+ existing.identity.clientId !== identity.clientId ||
85
+ existing.identity.clientSecret !== identity.clientSecret ||
86
+ existing.identity.mcpAppId !== identity.mcpAppId ||
87
+ JSON.stringify(existing.identity.trust) !== JSON.stringify(identity.trust)) {
88
+ throw error;
89
+ }
90
+ identityFilePath = existing.filePath;
91
+ }
92
+ if (input.pending) {
93
+ await consumeStandalonePendingIdentity(input.pending, {
94
+ filePath: input.options?.pendingIdentityFilePath,
95
+ finalIdentityFilePath: input.options?.identityFilePath,
96
+ });
97
+ }
98
+ }
99
+ return {
100
+ state: 'complete',
101
+ privosUrl: input.privosUrl,
102
+ clientId: input.clientId,
103
+ clientSecret: input.clientSecret,
104
+ mcpAppId: input.mcpAppId,
105
+ pairingVersion: 2,
106
+ trust,
107
+ fingerprint,
108
+ manifestDigest: trust.affinity.manifestDigest,
109
+ ...(approvedPermissionCeiling ? { approvedPermissionCeiling } : {}),
110
+ ...(identityFilePath ? { identityFilePath } : {}),
111
+ };
112
+ }
14
113
  /**
15
114
  * Pair with Privos using a one-time pairing URL.
16
115
  *
@@ -21,10 +120,27 @@ const DEFAULT_OPEN_HANDSHAKE_TIMEOUT_MS = 15_000;
21
120
  * create — same discipline as the Hub's own identity file), then prints the
22
121
  * Hub fingerprint for out-of-band operator verification. Set
23
122
  * `persistIdentityFile: false` to opt out and handle persistence yourself.
123
+ *
124
+ * If the socket closes after the Hub has persisted registration but before
125
+ * this function receives the result, the caller may explicitly invoke this
126
+ * function again with the same pairing URL and byte-semantically identical
127
+ * published manifest during the Hub's bounded recovery window. There is no
128
+ * automatic retry or polling loop: a changed URL, manifest, or authority must
129
+ * fail at the Hub rather than being hidden by client-side retry behavior.
24
130
  */
25
131
  export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, options) {
26
132
  const timeoutMs = options?.timeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS;
27
133
  const persistIdentityFile = options?.persistIdentityFile ?? true;
134
+ // Fail fast BEFORE registering: an existing identity file makes the persist
135
+ // step (or the poll loop in pairAndAwaitApproval) throw at the very end, but
136
+ // only after the WebSocket handshake has already registered a fresh pending
137
+ // row on the Hub — every retry then churns a dead installation there. Refuse
138
+ // up front with the same error so re-pairing never touches the Hub until the
139
+ // operator has removed the stale file or chosen rotation.
140
+ if (persistIdentityFile && standaloneIdentityFileExists({ filePath: options?.identityFilePath })) {
141
+ const filePath = resolveIdentityFilePath(options?.identityFilePath);
142
+ return Promise.reject(new StandaloneIdentityError('IDENTITY_FILE_ALREADY_EXISTS', `Standalone identity file ${filePath} already exists. Remove it before re-pairing, or use rotation for an in-place credential change.`));
143
+ }
28
144
  return new Promise((resolve, reject) => {
29
145
  const ws = new WebSocketImpl(pairUrl);
30
146
  let settled = false;
@@ -47,26 +163,98 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
47
163
  });
48
164
  }, timeoutMs);
49
165
  async function finishPairing(input) {
50
- if (input.pairingVersion !== 2) {
51
- // Either a v1 Hub, or a manifest-announced registration still awaiting
52
- // approval: both hand back credentials and nothing to persist, because
166
+ if (input.wire.pairingVersion !== 2) {
167
+ // Either a v1 Hub, or a Hub generation that acknowledges a
168
+ // manifest-announced registration with only `awaitingApproval`:
169
+ // both hand back credentials and nothing to persist, because
53
170
  // trust is minted with the generation an approved ceiling creates.
54
171
  return {
172
+ state: 'legacy-complete',
173
+ privosUrl: input.privosUrl,
174
+ clientId: input.clientId,
175
+ clientSecret: input.clientSecret,
176
+ mcpAppId: input.mcpAppId,
177
+ ...(input.wire.awaitingApproval ? { awaitingApproval: true } : {}),
178
+ };
179
+ }
180
+ if (!input.mcpAppId)
181
+ throw new Error('Pairing v2 response missing app identity.');
182
+ if (input.wire.pairingState === 'pending-approval') {
183
+ if (!appMeta.manifest)
184
+ throw new Error('Pending manifest pairing response was not requested with an exact manifest.');
185
+ const declaredPermissionCeiling = sortedUniqueStrings(input.wire.declaredPermissionCeiling, 'declaredPermissionCeiling');
186
+ const localDeclaredPermissionCeiling = manifestDeclaredPermissionCeiling(appMeta.manifest);
187
+ const manifestDigest = sha256CanonicalJson(appMeta.manifest);
188
+ if (input.wire.manifestDigest !== manifestDigest ||
189
+ JSON.stringify(declaredPermissionCeiling) !== JSON.stringify(localDeclaredPermissionCeiling) ||
190
+ !input.wire.pairingId ||
191
+ !input.wire.oauthClientId ||
192
+ input.wire.oauthClientId !== input.clientId ||
193
+ !input.wire.permissionContractHash ||
194
+ !input.wire.hubKid) {
195
+ throw new Error('Pending pairing response does not match the exact published manifest or OAuth identity.');
196
+ }
197
+ const fingerprint = standaloneHubFingerprint(input.wire.hubKid);
198
+ if (input.wire.fingerprint !== fingerprint)
199
+ throw new Error('Pending pairing Hub fingerprint is invalid.');
200
+ (options?.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before approving this app.`);
201
+ const pending = {
202
+ pairingVersion: 2,
203
+ state: 'pending-approval',
204
+ relayUrl: input.privosUrl,
205
+ clientId: input.clientId,
206
+ clientSecret: input.clientSecret,
207
+ mcpAppId: input.mcpAppId,
208
+ pairingId: input.wire.pairingId,
209
+ oauthClientId: input.wire.oauthClientId,
210
+ manifestDigest,
211
+ permissionContractHash: input.wire.permissionContractHash,
212
+ declaredPermissionCeiling,
213
+ hubKid: input.wire.hubKid,
214
+ fingerprint,
215
+ createdAt: Date.now(),
216
+ };
217
+ const pendingIdentityFilePath = persistIdentityFile
218
+ ? await saveStandalonePendingIdentity(pending, {
219
+ filePath: options?.pendingIdentityFilePath,
220
+ finalIdentityFilePath: options?.identityFilePath,
221
+ })
222
+ : undefined;
223
+ return {
224
+ state: 'pending-approval',
55
225
  privosUrl: input.privosUrl,
56
226
  clientId: input.clientId,
57
227
  clientSecret: input.clientSecret,
58
228
  mcpAppId: input.mcpAppId,
59
- ...(input.awaitingApproval ? { awaitingApproval: true } : {}),
229
+ pairingVersion: 2,
230
+ pairingId: pending.pairingId,
231
+ oauthClientId: pending.oauthClientId,
232
+ manifestDigest,
233
+ permissionContractHash: pending.permissionContractHash,
234
+ declaredPermissionCeiling,
235
+ hubKid: pending.hubKid,
236
+ fingerprint,
237
+ awaitingApproval: true,
238
+ ...(pendingIdentityFilePath ? { pendingIdentityFilePath } : {}),
60
239
  };
61
240
  }
62
- return persistPairedV2Identity(input, {
63
- onFingerprint: options?.onFingerprint,
64
- persistIdentityFile,
65
- identityFilePath: options?.identityFilePath,
241
+ return persistCompletedPairing({
242
+ wire: input.wire,
243
+ privosUrl: input.privosUrl,
244
+ clientId: input.clientId,
245
+ clientSecret: input.clientSecret,
246
+ mcpAppId: input.mcpAppId,
247
+ options,
66
248
  });
67
249
  }
68
250
  ws.on('open', () => {
69
251
  try {
252
+ if (appMeta.manifest) {
253
+ const lint = lintManifest(appMeta.manifest);
254
+ if (appMeta.manifest.schemaVersion !== 3 || !lint.valid) {
255
+ throw new Error(`Exact standalone manifest is not a valid schema-v3 manifest: ${lint.errors.join('; ')}`);
256
+ }
257
+ }
70
258
  ws.send(JSON.stringify({
71
259
  name: appMeta.name,
72
260
  description: appMeta.description || '',
@@ -74,7 +262,7 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
74
262
  ...(appMeta.icon && { icon: appMeta.icon }),
75
263
  ...(appMeta.scopes?.length && { scopes: appMeta.scopes }),
76
264
  ...(appMeta.permissions?.length && { permissions: appMeta.permissions }),
77
- ...(appMeta.manifest !== undefined && { manifest: appMeta.manifest }),
265
+ ...(appMeta.manifest ? { manifest: appMeta.manifest } : {}),
78
266
  }));
79
267
  }
80
268
  catch (err) {
@@ -88,8 +276,8 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
88
276
  settle(() => reject(new Error(msg.error?.message || 'Pairing failed')));
89
277
  return;
90
278
  }
91
- if (msg.result?.paired) {
92
- const { clientId, clientSecret, relayUrl, app, mcpAppId, appId, pairingVersion, trust, fingerprint, awaitingApproval } = msg.result;
279
+ if (msg.result && (msg.result.paired || msg.result.pairingState === 'pending-approval')) {
280
+ const { clientId, clientSecret, relayUrl, app, mcpAppId, appId } = msg.result;
93
281
  if (!clientId || !clientSecret || !relayUrl) {
94
282
  settle(() => reject(new Error('Pairing response missing credentials')));
95
283
  return;
@@ -101,14 +289,11 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
101
289
  undefined;
102
290
  settle(() => {
103
291
  void finishPairing({
292
+ wire: msg.result,
104
293
  privosUrl,
105
294
  clientId,
106
295
  clientSecret,
107
296
  mcpAppId: resolvedAppId,
108
- pairingVersion,
109
- trust,
110
- fingerprint,
111
- awaitingApproval: awaitingApproval === true,
112
297
  })
113
298
  .then(resolve)
114
299
  .catch(reject);
@@ -142,40 +327,6 @@ export function pairOverWebSocket(pairUrl, appMeta, WebSocketImpl = WebSocket, o
142
327
  });
143
328
  }
144
329
  const DEFAULT_APPROVAL_TIMEOUT_MS = 30 * 60_000;
145
- /** Validate a v2 trust payload, verify the fingerprint, and persist the identity file. */
146
- async function persistPairedV2Identity(input, options) {
147
- assertRuntimeDispatchTrustConfigurationV3(input.trust);
148
- const trust = input.trust;
149
- const fingerprint = standaloneHubFingerprint(trust.hubKid);
150
- if (input.fingerprint !== undefined && input.fingerprint !== fingerprint) {
151
- throw new Error('Pairing response fingerprint does not match the pinned Hub key.');
152
- }
153
- (options.onFingerprint ?? ((line) => console.log(line)))(`PrivOS Hub fingerprint: ${fingerprint} — verify this out-of-band before trusting dispatch from this Hub.`);
154
- let identityFilePath;
155
- if (options.persistIdentityFile) {
156
- const identity = {
157
- pairingVersion: 2,
158
- relayUrl: input.privosUrl,
159
- clientId: input.clientId,
160
- clientSecret: input.clientSecret,
161
- trust,
162
- fingerprint,
163
- ...(input.mcpAppId ? { mcpAppId: input.mcpAppId } : {}),
164
- pairedAt: Date.now(),
165
- };
166
- identityFilePath = await saveStandaloneIdentity(identity, { filePath: options.identityFilePath });
167
- }
168
- return {
169
- privosUrl: input.privosUrl,
170
- clientId: input.clientId,
171
- clientSecret: input.clientSecret,
172
- mcpAppId: input.mcpAppId,
173
- pairingVersion: 2,
174
- trust,
175
- fingerprint,
176
- ...(identityFilePath ? { identityFilePath } : {}),
177
- };
178
- }
179
330
  /**
180
331
  * Normalize a Hub-supplied relay URL (`wss://host/api/v1/mcp-apps.relay`) to the
181
332
  * bare Hub HTTP origin the identity file stores as `relayUrl`/`privosUrl`. serveApp
@@ -196,8 +347,9 @@ function pairPollTarget(pairUrl) {
196
347
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
197
348
  /**
198
349
  * One-command pairing (device-authorization flow). Registers exactly like
199
- * {@link pairOverWebSocket}; if the Hub answers `awaitingApproval`, this then
200
- * POLLS the Hub with the SAME pairing token until an admin approves the
350
+ * {@link pairOverWebSocket}; while the registration awaits an admin's approval
351
+ * (`pending-approval` state, or a legacy-shaped `awaitingApproval` response),
352
+ * this POLLS the Hub with the SAME pairing token until the admin approves the
201
353
  * permission ceiling, receives the pairing-v2 trust payload, and writes the
202
354
  * standalone identity file — no second pairing URL. A Hub that returns trust
203
355
  * immediately (already-approved linked pairing, or a v1 Hub) is passed straight
@@ -206,8 +358,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
206
358
  */
207
359
  export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = WebSocket, options) {
208
360
  const registered = await pairOverWebSocket(pairUrl, appMeta, WebSocketImpl, options);
209
- // A Hub that already handed trust (linked pairing) or a v1 Hub: nothing to await.
210
- if (registered.pairingVersion === 2 || !registered.awaitingApproval)
361
+ // Trust already delivered (linked pairing), or a v1 Hub with nothing pending: done.
362
+ if (registered.state === 'complete' || !registered.awaitingApproval)
211
363
  return registered;
212
364
  const { origin, pairToken } = pairPollTarget(pairUrl);
213
365
  if (!pairToken)
@@ -227,14 +379,36 @@ export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = Web
227
379
  if (body.success === false)
228
380
  throw new Error(body.error || 'Pairing poll was rejected');
229
381
  if (body.status === 'approved') {
230
- return persistPairedV2Identity({
382
+ const mcpAppId = body.appId ?? registered.mcpAppId;
383
+ if (!mcpAppId)
384
+ throw new Error('Pairing approval response missing app identity.');
385
+ const completed = await persistCompletedPairing({
386
+ wire: { trust: body.trust, fingerprint: body.fingerprint },
231
387
  privosUrl: body.relayUrl ? relayUrlToPrivosOrigin(body.relayUrl) : registered.privosUrl,
232
388
  clientId: body.clientId ?? registered.clientId,
233
389
  clientSecret: body.clientSecret ?? registered.clientSecret,
234
- mcpAppId: body.appId ?? registered.mcpAppId,
235
- trust: body.trust,
236
- fingerprint: body.fingerprint,
237
- }, { onFingerprint: options?.onFingerprint, persistIdentityFile, identityFilePath: options?.identityFilePath });
390
+ mcpAppId,
391
+ options,
392
+ });
393
+ // The pair-poll response carries no pending-contract echo to cross-check,
394
+ // so the durable pending half is consumed only after trust verification
395
+ // above — best-effort, the completed identity file is authoritative.
396
+ if (registered.state === 'pending-approval' && persistIdentityFile) {
397
+ try {
398
+ const stalePending = loadStandalonePendingIdentity({
399
+ filePath: options?.pendingIdentityFilePath,
400
+ finalIdentityFilePath: options?.identityFilePath,
401
+ });
402
+ await consumeStandalonePendingIdentity(stalePending.identity, {
403
+ filePath: options?.pendingIdentityFilePath,
404
+ finalIdentityFilePath: options?.identityFilePath,
405
+ });
406
+ }
407
+ catch {
408
+ /* pending file already gone — nothing left to consume */
409
+ }
410
+ }
411
+ return completed;
238
412
  }
239
413
  if (body.status === 'rejected')
240
414
  throw new Error('Pairing was rejected — the app was removed. Re-pair from scratch.');
@@ -247,6 +421,125 @@ export async function pairAndAwaitApproval(pairUrl, appMeta, WebSocketImpl = Web
247
421
  delayMs = Math.min(Math.round(delayMs * 1.5), 10_000);
248
422
  }
249
423
  }
424
+ /**
425
+ * Explicitly resumes one durable app-announced-manifest pairing. This is a
426
+ * single operator/app action, not a polling loop: a pending Hub replies
427
+ * pending; an approved Hub returns trust for the same OAuth/app/pairing
428
+ * identity and the SDK atomically promotes it to the production identity.
429
+ */
430
+ export async function resumeStandalonePairing(options) {
431
+ const loaded = loadStandalonePendingIdentity({
432
+ filePath: options?.pendingIdentityFilePath,
433
+ finalIdentityFilePath: options?.identityFilePath,
434
+ });
435
+ const pending = loaded.identity;
436
+ const fetchImpl = options?.fetchImpl ?? fetch;
437
+ const WebSocketImpl = options?.WebSocketImpl ?? WebSocket;
438
+ const controller = new AbortController();
439
+ const oauthTimer = setTimeout(() => controller.abort(), options?.oauthTimeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS);
440
+ let accessToken;
441
+ try {
442
+ const response = await fetchImpl(`${pending.relayUrl}/oauth/token`, {
443
+ method: 'POST',
444
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
445
+ body: `grant_type=client_credentials&client_id=${encodeURIComponent(pending.clientId)}&client_secret=${encodeURIComponent(pending.clientSecret)}`,
446
+ signal: controller.signal,
447
+ });
448
+ if (!response.ok)
449
+ throw new Error(`Pairing completion OAuth failed: ${response.status} ${response.statusText}`);
450
+ const token = (await response.json());
451
+ if (!token.access_token)
452
+ throw new Error('Pairing completion OAuth response omitted access_token.');
453
+ accessToken = token.access_token;
454
+ }
455
+ finally {
456
+ clearTimeout(oauthTimer);
457
+ }
458
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_PAIRING_TIMEOUT_MS;
459
+ const wire = await new Promise((resolve, reject) => {
460
+ const wsUrl = `${pending.relayUrl.replace(/^http/, 'ws')}/api/v1/mcp-apps.relay?pairingCompletion=1`;
461
+ const ws = new WebSocketImpl(wsUrl, { headers: { Authorization: `Bearer ${accessToken}` } });
462
+ let settled = false;
463
+ const settle = (fn) => {
464
+ if (settled)
465
+ return;
466
+ settled = true;
467
+ clearTimeout(timer);
468
+ fn();
469
+ };
470
+ const timer = setTimeout(() => {
471
+ settle(() => reject(new Error(`Pairing completion timed out after ${timeoutMs}ms`)));
472
+ try {
473
+ ws.close();
474
+ }
475
+ catch { /* ignore */ }
476
+ }, timeoutMs);
477
+ ws.on('message', (raw) => {
478
+ try {
479
+ const message = JSON.parse(rawDataToText(raw));
480
+ if (message.error)
481
+ return settle(() => reject(new Error(message.error?.message || 'Pairing completion failed')));
482
+ if (message.result?.pairingVersion !== 2)
483
+ return settle(() => reject(new Error('Pairing completion response is not protocol v2.')));
484
+ settle(() => resolve(message.result));
485
+ try {
486
+ ws.close();
487
+ }
488
+ catch { /* ignore */ }
489
+ }
490
+ catch (error) {
491
+ settle(() => reject(error instanceof Error ? error : new Error(String(error))));
492
+ }
493
+ });
494
+ ws.on('error', (error) => settle(() => reject(new Error(`Pairing completion failed: ${error.message}`))));
495
+ ws.on('close', (code, reason) => settle(() => reject(new Error(`Pairing completion closed: ${code} ${reason}`))));
496
+ });
497
+ if (wire.clientId !== pending.clientId ||
498
+ wire.oauthClientId !== pending.oauthClientId ||
499
+ wire.appId !== pending.mcpAppId ||
500
+ wire.pairingId !== pending.pairingId ||
501
+ wire.manifestDigest !== pending.manifestDigest ||
502
+ wire.permissionContractHash !== pending.permissionContractHash ||
503
+ wire.hubKid !== pending.hubKid ||
504
+ wire.fingerprint !== pending.fingerprint) {
505
+ throw new Error('Pairing resume response does not match the durable pending identity.');
506
+ }
507
+ if (wire.pairingState === 'pending-approval') {
508
+ const declaredPermissionCeiling = sortedUniqueStrings(wire.declaredPermissionCeiling, 'declaredPermissionCeiling');
509
+ if (JSON.stringify(declaredPermissionCeiling) !== JSON.stringify(pending.declaredPermissionCeiling)) {
510
+ throw new Error('Pairing resume changed the declared permission ceiling.');
511
+ }
512
+ return {
513
+ state: 'pending-approval',
514
+ privosUrl: pending.relayUrl,
515
+ clientId: pending.clientId,
516
+ clientSecret: pending.clientSecret,
517
+ mcpAppId: pending.mcpAppId,
518
+ pairingVersion: 2,
519
+ pairingId: pending.pairingId,
520
+ oauthClientId: pending.oauthClientId,
521
+ manifestDigest: pending.manifestDigest,
522
+ permissionContractHash: pending.permissionContractHash,
523
+ declaredPermissionCeiling: pending.declaredPermissionCeiling,
524
+ hubKid: pending.hubKid,
525
+ fingerprint: pending.fingerprint,
526
+ awaitingApproval: true,
527
+ pendingIdentityFilePath: loaded.filePath,
528
+ };
529
+ }
530
+ if (wire.pairingState !== 'complete' || !wire.clientSecret || wire.clientSecret !== pending.clientSecret) {
531
+ throw new Error('Pairing resume did not return an exact completed identity.');
532
+ }
533
+ return persistCompletedPairing({
534
+ wire,
535
+ privosUrl: pending.relayUrl,
536
+ clientId: pending.clientId,
537
+ clientSecret: pending.clientSecret,
538
+ mcpAppId: pending.mcpAppId,
539
+ options,
540
+ pending,
541
+ });
542
+ }
250
543
  export async function pairFromDescriptor(pairUrl, descriptor, WebSocketImpl, options) {
251
544
  return pairOverWebSocket(pairUrl, buildPairingMetadata(descriptor), WebSocketImpl, options);
252
545
  }