@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.
- package/README.md +39 -6
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/relay/relay-client.d.ts +80 -20
- package/dist/relay/relay-client.d.ts.map +1 -1
- package/dist/relay/relay-client.js +352 -59
- package/dist/relay/relay-client.js.map +1 -1
- package/dist/relay/standalone-identity.d.ts +44 -0
- package/dist/relay/standalone-identity.d.ts.map +1 -1
- package/dist/relay/standalone-identity.js +121 -1
- package/dist/relay/standalone-identity.js.map +1 -1
- package/package.json +11 -12
|
@@ -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
|
|
52
|
-
//
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
92
|
-
const { clientId, clientSecret, relayUrl, app, mcpAppId, appId
|
|
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};
|
|
200
|
-
*
|
|
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
|
-
//
|
|
210
|
-
if (registered.
|
|
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
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
}
|