@mdbase-dev/connect 0.1.0-beta.100

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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +696 -0
  3. package/dist/advanced.d.ts +9 -0
  4. package/dist/advanced.js +6 -0
  5. package/dist/application-contract.d.ts +22 -0
  6. package/dist/application-contract.js +75 -0
  7. package/dist/application-identity.d.ts +34 -0
  8. package/dist/application-identity.js +148 -0
  9. package/dist/application-session.d.ts +167 -0
  10. package/dist/application-session.js +722 -0
  11. package/dist/async.d.ts +1 -0
  12. package/dist/async.js +19 -0
  13. package/dist/authority-proof.d.ts +3 -0
  14. package/dist/authority-proof.js +24 -0
  15. package/dist/authority-sync-request.d.ts +3 -0
  16. package/dist/authority-sync-request.js +20 -0
  17. package/dist/authorization-refresh.d.ts +14 -0
  18. package/dist/authorization-refresh.js +41 -0
  19. package/dist/authorization-types.d.ts +7 -0
  20. package/dist/authorization-types.js +1 -0
  21. package/dist/authorization-url.d.ts +5 -0
  22. package/dist/authorization-url.js +34 -0
  23. package/dist/base64.d.ts +3 -0
  24. package/dist/base64.js +21 -0
  25. package/dist/browser/integrity.json +4 -0
  26. package/dist/browser/mdbase-connect.min.js +3 -0
  27. package/dist/browser/mdbase-connect.min.js.sha384 +1 -0
  28. package/dist/browser.d.ts +3 -0
  29. package/dist/browser.js +5 -0
  30. package/dist/capabilities.d.ts +24 -0
  31. package/dist/capabilities.js +91 -0
  32. package/dist/collection-client.d.ts +66 -0
  33. package/dist/collection-client.js +686 -0
  34. package/dist/connect-authorization-helpers.d.ts +4 -0
  35. package/dist/connect-authorization-helpers.js +25 -0
  36. package/dist/connect-options.d.ts +35 -0
  37. package/dist/connect-options.js +1 -0
  38. package/dist/connect.d.ts +66 -0
  39. package/dist/connect.js +711 -0
  40. package/dist/connection-file-transport.d.ts +34 -0
  41. package/dist/connection-file-transport.js +85 -0
  42. package/dist/connection-index.d.ts +3 -0
  43. package/dist/connection-index.js +20 -0
  44. package/dist/connection-notifications.d.ts +31 -0
  45. package/dist/connection-notifications.js +197 -0
  46. package/dist/connection-transport-internals.d.ts +24 -0
  47. package/dist/connection-transport-internals.js +1 -0
  48. package/dist/connection-transport.d.ts +64 -0
  49. package/dist/connection-transport.js +725 -0
  50. package/dist/connection-types.d.ts +38 -0
  51. package/dist/connection-types.js +1 -0
  52. package/dist/connection.d.ts +175 -0
  53. package/dist/connection.js +606 -0
  54. package/dist/crypto-entry.d.ts +7 -0
  55. package/dist/crypto-entry.js +4 -0
  56. package/dist/crypto.d.ts +63 -0
  57. package/dist/crypto.js +409 -0
  58. package/dist/direct-access.d.ts +3 -0
  59. package/dist/direct-access.js +29 -0
  60. package/dist/errors.d.ts +45 -0
  61. package/dist/errors.js +142 -0
  62. package/dist/external-store.d.ts +10 -0
  63. package/dist/external-store.js +10 -0
  64. package/dist/file-crypto.d.ts +25 -0
  65. package/dist/file-crypto.js +132 -0
  66. package/dist/file-sha256.d.ts +12 -0
  67. package/dist/file-sha256.js +143 -0
  68. package/dist/file-stream-source.d.ts +17 -0
  69. package/dist/file-stream-source.js +157 -0
  70. package/dist/file-transfer-internals.d.ts +23 -0
  71. package/dist/file-transfer-internals.js +228 -0
  72. package/dist/files.d.ts +115 -0
  73. package/dist/files.js +563 -0
  74. package/dist/fresh-encrypted-request.d.ts +4 -0
  75. package/dist/fresh-encrypted-request.js +28 -0
  76. package/dist/grant-key-leases.d.ts +24 -0
  77. package/dist/grant-key-leases.js +144 -0
  78. package/dist/hosted-file-request.d.ts +6 -0
  79. package/dist/hosted-file-request.js +63 -0
  80. package/dist/hosted-operation-response.d.ts +5 -0
  81. package/dist/hosted-operation-response.js +26 -0
  82. package/dist/index.d.ts +27 -0
  83. package/dist/index.js +10 -0
  84. package/dist/internal-types.d.ts +100 -0
  85. package/dist/internal-types.js +1 -0
  86. package/dist/local-file-transport.d.ts +37 -0
  87. package/dist/local-file-transport.js +457 -0
  88. package/dist/mdbase-connect.d.ts +28 -0
  89. package/dist/mdbase-connect.js +53 -0
  90. package/dist/notifications.d.ts +48 -0
  91. package/dist/notifications.js +52 -0
  92. package/dist/operation-helpers.d.ts +27 -0
  93. package/dist/operation-helpers.js +144 -0
  94. package/dist/operation-types.d.ts +707 -0
  95. package/dist/operation-types.js +1 -0
  96. package/dist/outcomes.d.ts +51 -0
  97. package/dist/outcomes.js +194 -0
  98. package/dist/pending-mutation-store.d.ts +13 -0
  99. package/dist/pending-mutation-store.js +64 -0
  100. package/dist/pending-recovery-transports.d.ts +5 -0
  101. package/dist/pending-recovery-transports.js +52 -0
  102. package/dist/popup-authorization.d.ts +31 -0
  103. package/dist/popup-authorization.js +100 -0
  104. package/dist/query-pagination.d.ts +6 -0
  105. package/dist/query-pagination.js +119 -0
  106. package/dist/request-budget.d.ts +34 -0
  107. package/dist/request-budget.js +104 -0
  108. package/dist/request-coordinator.d.ts +29 -0
  109. package/dist/request-coordinator.js +213 -0
  110. package/dist/retired-grant-keys.d.ts +9 -0
  111. package/dist/retired-grant-keys.js +56 -0
  112. package/dist/runtime-utils.d.ts +52 -0
  113. package/dist/runtime-utils.js +368 -0
  114. package/dist/saved-view-pagination.d.ts +6 -0
  115. package/dist/saved-view-pagination.js +76 -0
  116. package/dist/selection.d.ts +48 -0
  117. package/dist/selection.js +112 -0
  118. package/dist/session.d.ts +114 -0
  119. package/dist/session.js +569 -0
  120. package/dist/shared-operation.d.ts +7 -0
  121. package/dist/shared-operation.js +41 -0
  122. package/dist/stored-token.d.ts +10 -0
  123. package/dist/stored-token.js +64 -0
  124. package/dist/structured-readiness.d.ts +77 -0
  125. package/dist/structured-readiness.js +126 -0
  126. package/dist/token-response.d.ts +12 -0
  127. package/dist/token-response.js +81 -0
  128. package/dist/transient-retry.d.ts +22 -0
  129. package/dist/transient-retry.js +59 -0
  130. package/dist/watch-policy.d.ts +8 -0
  131. package/dist/watch-policy.js +12 -0
  132. package/package.json +51 -0
@@ -0,0 +1,711 @@
1
+ import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, authorizationContractRequirements, DEFAULT_LOOPBACK_PORT, GRANT_ENCRYPTION_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
2
+ import { authorizationOperations, authorizationFiles, semanticContractVersion, validateAuthorizationSelection } from "./application-contract.js";
3
+ import { abortableDelay } from "./async.js";
4
+ import { authorizationCallbackState } from "./authorization-url.js";
5
+ import { randomBase64Url } from "./base64.js";
6
+ import { ApplicationIdentityStoreError, IndexedDbApplicationIdentityStore, MemoryApplicationIdentityStore, applicationIdentity, applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
7
+ import { MdbaseConnection } from "./connection.js";
8
+ import { addConnectionId, connectionIds, removeConnectionId } from "./connection-index.js";
9
+ import { assertFreshV2AuthorizationSupport, authorizationAbort, declarationIdFromFamilyIdentity } from "./connect-authorization-helpers.js";
10
+ import { IndexedDbGrantKeyStore, MemoryGrantKeyStore } from "./crypto.js";
11
+ import { MdbaseConnectError, connectError, serverConnectError } from "./errors.js";
12
+ import { prepareAuthorizationWindow, publishPopupAuthorizationCompletion, waitForPopupAuthorization } from "./popup-authorization.js";
13
+ import { pendingRecoveryTransports, removePendingMutations } from "./pending-recovery-transports.js";
14
+ import { GrantKeyLeaseRegistry } from "./grant-key-leases.js";
15
+ import { resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
16
+ import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultStorage, decodeJsonResponse, isOpaquePortableManifest, resolveManifestSource, oauthErrorCode, parseDeviceAuthorization, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
17
+ import { storedTokenFromResponse } from "./token-response.js";
18
+ import { deleteUnusedGrantKey, planRetiredGrantKeys, storedTokenKeyHandles } from "./retired-grant-keys.js";
19
+ import { SharedOperationMap } from "./shared-operation.js";
20
+ export class MdbaseConnectInternals {
21
+ serverUrl;
22
+ manifest;
23
+ manifestSource;
24
+ redirectUri;
25
+ storage;
26
+ relayEncryption;
27
+ keyStore;
28
+ identityStore;
29
+ directAccessMode;
30
+ loopbackUrl;
31
+ navigate;
32
+ timeouts;
33
+ credentialStorage;
34
+ application = null;
35
+ manifestPromise = null;
36
+ completionPromises = new SharedOperationMap();
37
+ grantKeyLeases;
38
+ connectionCache = new Map();
39
+ listeners = new Set();
40
+ invalidatedConnections = new Map();
41
+ constructor(options) {
42
+ this.serverUrl = stripTrailingSlash(String(options.serverUrl));
43
+ const declaration = resolveManifestSource(options.manifest, options.redirectUri);
44
+ this.manifest = declaration.manifest;
45
+ this.manifestSource = declaration.manifestSource;
46
+ const opaquePortable = isOpaquePortableManifest(this.manifest);
47
+ this.redirectUri = declaration.redirectUri;
48
+ this.storage = options.storage ?? defaultStorage(opaquePortable);
49
+ this.relayEncryption = options.relayEncryption ?? "required";
50
+ this.keyStore = options.keyStore ?? (opaquePortable ? new MemoryGrantKeyStore() : new IndexedDbGrantKeyStore());
51
+ this.identityStore = options.identityStore ?? (opaquePortable
52
+ ? new MemoryApplicationIdentityStore()
53
+ : new IndexedDbApplicationIdentityStore());
54
+ this.credentialStorage = options.storage || options.keyStore || options.identityStore
55
+ ? "custom"
56
+ : this.storage instanceof MemoryStorage ? "memory" : "persistent";
57
+ this.directAccessMode = options.directAccess ?? "auto";
58
+ this.loopbackUrl = canonicalLoopbackUrl(String(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`));
59
+ this.navigate = options.navigate;
60
+ this.timeouts = resolveConnectTimeouts(options.timeouts);
61
+ this.grantKeyLeases = new GrantKeyLeaseRegistry(this.storagePrefix(), (collectionId, keyHandle) => this.deleteGrantKeyWhenUnused(collectionId, keyHandle));
62
+ if (typeof window !== "undefined" && this.storage === window.localStorage) {
63
+ window.addEventListener("storage", (event) => {
64
+ if (event.storageArea !== this.storage || !event.key?.startsWith(this.storagePrefix()))
65
+ return;
66
+ this.connectionCache.get(collectionIdFromTokenKey(event.key))?.notifyStorageChanged();
67
+ this.emitConnections();
68
+ });
69
+ }
70
+ }
71
+ environment() {
72
+ const distribution = typeof this.manifest !== "string"
73
+ && this.manifest.distribution === "portable"
74
+ ? "portable"
75
+ : this.application?.distribution ?? "web";
76
+ return {
77
+ distribution,
78
+ applicationOrigin: distribution === "portable"
79
+ ? "null"
80
+ : this.defaultApplicationOrigin(),
81
+ credentialStorage: this.credentialStorage
82
+ };
83
+ }
84
+ register(options = {}) {
85
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.registerWithinBudget(budget.signal));
86
+ }
87
+ async registerWithinBudget(signal) {
88
+ if (this.application)
89
+ return this.application;
90
+ const response = await connectFetch(`${this.serverUrl}/v1/apps/register`, {
91
+ method: "POST",
92
+ headers: { "content-type": "application/json" },
93
+ body: JSON.stringify({ manifest: await this.manifestDeclaration({ signal, timeoutMs: null }) }),
94
+ signal
95
+ }, "temporarily_unavailable", "Application registration is temporarily unavailable.");
96
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid application registration response.");
97
+ if (!response.ok)
98
+ throw apiError(body, "discovery_failed", "Application discovery failed.", response.status);
99
+ this.application = body.application;
100
+ return this.application;
101
+ }
102
+ manifestDeclaration(options = {}) {
103
+ if (this.manifestPromise)
104
+ return this.manifestPromise;
105
+ const loading = withRequestBudget(options, this.timeouts.requestMs, (budget) => this.loadManifest(budget.signal));
106
+ const shared = loading.catch((error) => {
107
+ if (this.manifestPromise === shared)
108
+ this.manifestPromise = null;
109
+ throw error;
110
+ });
111
+ this.manifestPromise = shared;
112
+ return this.manifestPromise;
113
+ }
114
+ async loadManifest(signal) {
115
+ if (typeof this.manifest !== "string")
116
+ return this.manifest;
117
+ const source = this.manifest;
118
+ let response;
119
+ try {
120
+ response = await fetch(source, {
121
+ headers: { accept: "application/json" },
122
+ signal
123
+ });
124
+ }
125
+ catch (cause) {
126
+ throw connectError("manifest_load_failed", "The bundled application declaration could not be loaded.", { cause });
127
+ }
128
+ if (!response.ok) {
129
+ throw connectError("manifest_load_failed", `The bundled application declaration returned HTTP ${response.status}.`, { status: response.status });
130
+ }
131
+ try {
132
+ return await decodeJsonResponse(response, "invalid_application_manifest", "The bundled application declaration is not valid JSON.");
133
+ }
134
+ catch (cause) {
135
+ throw connectError("invalid_application_manifest", "The bundled application declaration is not valid JSON.", {
136
+ cause,
137
+ details: {
138
+ issues: [{
139
+ path: "/",
140
+ keyword: "json",
141
+ message: "must be valid JSON",
142
+ params: {}
143
+ }]
144
+ }
145
+ });
146
+ }
147
+ }
148
+ async authorize(options = {}) {
149
+ const authorizationTimeout = options.presentation === "popup"
150
+ ? 10 * 60 * 1_000
151
+ : this.timeouts.requestMs;
152
+ return withRequestBudget(options, authorizationTimeout, (budget) => this.authorizeWithinBudget({ ...options, signal: budget.signal, timeoutMs: null }));
153
+ }
154
+ async authorizeWithinBudget(options) {
155
+ const portableDeclared = typeof this.manifest !== "string"
156
+ && this.manifest.distribution === "portable";
157
+ const { application, popup } = await prepareAuthorizationWindow({
158
+ options,
159
+ portableDeclared,
160
+ navigate: this.navigate,
161
+ register: (signal) => this.registerWithinBudget(signal)
162
+ });
163
+ try {
164
+ validateAuthorizationSelection(application.requirements, options);
165
+ if (application.requirements.capabilities?.contract_version === 2) {
166
+ await assertFreshV2AuthorizationSupport(this.serverUrl, options.signal);
167
+ }
168
+ }
169
+ catch (error) {
170
+ popup?.close();
171
+ throw error;
172
+ }
173
+ if (application.distribution === "portable") {
174
+ if (options.openVerification)
175
+ popup?.close();
176
+ return this.authorizePortable(application, options, options.openVerification ? null : popup);
177
+ }
178
+ return this.authorizeWeb(application, options, popup);
179
+ }
180
+ async authorizeWeb(application, options, popup) {
181
+ const { verifier, challenge } = await createPkce();
182
+ const state = randomBase64Url(24);
183
+ const authorizationId = crypto.randomUUID();
184
+ const targetCollectionId = options.target?.kind === "collection"
185
+ ? options.target.collectionId
186
+ : undefined;
187
+ const keyHandle = `grant:${application.id}:${state}`;
188
+ const grantKey = await this.keyStore.create(keyHandle);
189
+ let installation;
190
+ try {
191
+ installation = await this.loadApplicationIdentity(application);
192
+ }
193
+ catch (error) {
194
+ await this.keyStore.delete(keyHandle);
195
+ throw error;
196
+ }
197
+ const operations = authorizationOperations(application, options);
198
+ const requestedFiles = authorizationFiles(application.requirements);
199
+ const issuedAt = new Date();
200
+ const proof = await signApplicationAuthorization({
201
+ protocol_version: APPLICATION_AUTHORIZATION_PROTOCOL_VERSION,
202
+ authorization_id: authorizationId,
203
+ application_id: application.id,
204
+ application_declaration_id: declarationIdFromFamilyIdentity(application.family_identity),
205
+ application_manifest_digest: application.manifest_digest,
206
+ application_installation_id: await applicationInstallationId(installation),
207
+ installation_signing_public_key: installation.signingPublicKey,
208
+ grant_agreement_public_key: grantKey.agreementPublicKey,
209
+ grant_signing_public_key: grantKey.signingPublicKey,
210
+ flow: "authorization_code",
211
+ authorization_nonce: randomBase64Url(32),
212
+ issued_at: issuedAt.toISOString(),
213
+ expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
214
+ redirect_uri: this.redirectUri,
215
+ state,
216
+ code_challenge: challenge,
217
+ contracts: authorizationContractRequirements(operations, requestedFiles, pendingRecoveryTransports(this.storage, this.storagePrefix(), operations, targetCollectionId), semanticContractVersion(application.requirements)),
218
+ requested_operations: operations,
219
+ ...(requestedFiles ? { requested_files: requestedFiles } : {}),
220
+ ...(targetCollectionId ? { collection_id: targetCollectionId } : {})
221
+ }, installation);
222
+ const pending = {
223
+ version: 1,
224
+ verifier,
225
+ state,
226
+ clientId: application.id,
227
+ redirectUri: this.redirectUri,
228
+ relayEncryption: this.relayEncryption,
229
+ collectionId: targetCollectionId,
230
+ returnTo: options.returnTo,
231
+ keyHandle,
232
+ authorizationId,
233
+ applicationAgreementPublicKey: grantKey.agreementPublicKey,
234
+ applicationSigningPublicKey: grantKey.signingPublicKey,
235
+ ...(options.presentation === "popup" && popup ? { presentation: "popup" } : {})
236
+ };
237
+ this.storage.setItem(this.pendingKey(state), JSON.stringify(pending));
238
+ try {
239
+ const response = await fetch(`${this.serverUrl}/oauth/authorization_request`, {
240
+ method: "POST",
241
+ headers: { "content-type": "application/x-www-form-urlencoded" },
242
+ body: new URLSearchParams({
243
+ client_id: application.id,
244
+ redirect_uri: this.redirectUri,
245
+ code_challenge: challenge,
246
+ code_challenge_method: "S256",
247
+ state,
248
+ operations: operations.join(","),
249
+ ...(targetCollectionId ? { collection_id: targetCollectionId } : {}),
250
+ application_authorization: JSON.stringify(proof)
251
+ }),
252
+ signal: options.signal
253
+ });
254
+ const body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid authorization response.");
255
+ if (!response.ok) {
256
+ throw apiError(body, "device_authorization_failed", "Application authorization could not be started.", response.status);
257
+ }
258
+ if (typeof body.authorization_uri !== "string"
259
+ || body.authorization_id !== authorizationId
260
+ || !Number.isInteger(body.expires_in)
261
+ || body.expires_in <= 0) {
262
+ throw connectError("invalid_device_authorization_response", "The authorization service returned an invalid authorization response.");
263
+ }
264
+ if (options.presentation === "popup" && popup) {
265
+ popup.location.href = body.authorization_uri;
266
+ return await waitForPopupAuthorization({
267
+ storage: this.storage,
268
+ storagePrefix: this.storagePrefix(),
269
+ state,
270
+ pending,
271
+ popup,
272
+ signal: options.signal,
273
+ keyStore: this.keyStore,
274
+ connection: (collectionId) => this.connection(collectionId)
275
+ });
276
+ }
277
+ popup?.close();
278
+ if (this.navigate)
279
+ await this.navigate(body.authorization_uri);
280
+ else if (typeof location !== "undefined")
281
+ location.assign(body.authorization_uri);
282
+ else {
283
+ throw connectError("browser_required", "Authorization navigation requires a browser environment.");
284
+ }
285
+ return { kind: "redirecting" };
286
+ }
287
+ catch (error) {
288
+ popup?.close();
289
+ this.storage.removeItem(this.pendingKey(state));
290
+ await this.keyStore.delete(keyHandle);
291
+ if (options.signal?.aborted) {
292
+ throw authorizationAbort(options.signal, "Application authorization was cancelled.", error);
293
+ }
294
+ throw error;
295
+ }
296
+ }
297
+ async authorizePortable(application, options, popup) {
298
+ if (this.relayEncryption !== "required") {
299
+ popup?.close();
300
+ throw connectError("encryption_required", "Downloaded applications require encrypted relay authorization.");
301
+ }
302
+ const { verifier, challenge } = await createPkce();
303
+ const keyHandle = `grant:${application.id}:${randomBase64Url(24)}`;
304
+ const grantKey = await this.keyStore.create(keyHandle);
305
+ let installation;
306
+ try {
307
+ installation = await this.loadApplicationIdentity(application);
308
+ }
309
+ catch (error) {
310
+ popup?.close();
311
+ await this.keyStore.delete(keyHandle);
312
+ throw error;
313
+ }
314
+ const operations = authorizationOperations(application, options);
315
+ const requestedFiles = authorizationFiles(application.requirements);
316
+ const authorizationId = crypto.randomUUID();
317
+ const issuedAt = new Date();
318
+ const proof = await signApplicationAuthorization({
319
+ protocol_version: APPLICATION_AUTHORIZATION_PROTOCOL_VERSION,
320
+ authorization_id: authorizationId,
321
+ application_id: application.id,
322
+ application_declaration_id: declarationIdFromFamilyIdentity(application.family_identity),
323
+ application_manifest_digest: application.manifest_digest,
324
+ application_installation_id: await applicationInstallationId(installation),
325
+ installation_signing_public_key: installation.signingPublicKey,
326
+ grant_agreement_public_key: grantKey.agreementPublicKey,
327
+ grant_signing_public_key: grantKey.signingPublicKey,
328
+ flow: "device_code",
329
+ authorization_nonce: randomBase64Url(32),
330
+ issued_at: issuedAt.toISOString(),
331
+ expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
332
+ code_challenge: challenge,
333
+ contracts: authorizationContractRequirements(operations, requestedFiles, pendingRecoveryTransports(this.storage, this.storagePrefix(), operations, options.target?.kind === "collection" ? options.target.collectionId : undefined), semanticContractVersion(application.requirements)),
334
+ requested_operations: operations,
335
+ ...(requestedFiles ? { requested_files: requestedFiles } : {}),
336
+ ...(options.target?.kind === "collection"
337
+ ? { collection_id: options.target.collectionId }
338
+ : {})
339
+ }, installation);
340
+ let response;
341
+ let body;
342
+ try {
343
+ response = await fetch(`${this.serverUrl}/oauth/device_authorization`, {
344
+ method: "POST",
345
+ headers: { "content-type": "application/x-www-form-urlencoded" },
346
+ body: new URLSearchParams({
347
+ client_id: application.id,
348
+ operations: operations.join(","),
349
+ ...(options.target?.kind === "collection"
350
+ ? { collection_id: options.target.collectionId }
351
+ : {}),
352
+ code_challenge: challenge,
353
+ code_challenge_method: "S256",
354
+ application_authorization: JSON.stringify(proof)
355
+ }),
356
+ signal: options.signal
357
+ });
358
+ body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid device authorization response.");
359
+ }
360
+ catch (cause) {
361
+ popup?.close();
362
+ await this.keyStore.delete(keyHandle);
363
+ throw connectError("device_authorization_failed", "Downloaded application authorization could not be started.", { cause });
364
+ }
365
+ if (!response.ok) {
366
+ popup?.close();
367
+ await this.keyStore.delete(keyHandle);
368
+ throw apiError(body, "device_authorization_failed", "Downloaded application authorization could not be started.", response.status);
369
+ }
370
+ let authorization;
371
+ try {
372
+ authorization = parseDeviceAuthorization(body);
373
+ }
374
+ catch (error) {
375
+ popup?.close();
376
+ await this.keyStore.delete(keyHandle);
377
+ if (options.signal?.aborted) {
378
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
379
+ }
380
+ throw error;
381
+ }
382
+ options.onDeviceCode?.(authorization);
383
+ if (options.openVerification) {
384
+ await options.openVerification(authorization);
385
+ }
386
+ else if (popup) {
387
+ try {
388
+ popup.location.href = authorization.verificationUriComplete;
389
+ }
390
+ catch {
391
+ // The already-open verification page still accepts the displayed code.
392
+ }
393
+ }
394
+ else {
395
+ await this.keyStore.delete(keyHandle);
396
+ throw connectError("approval_window_blocked", "The approval window was blocked. Show the provided verification link and code, then try again.", {
397
+ details: {
398
+ user_code: authorization.userCode,
399
+ verification_uri: authorization.verificationUri,
400
+ verification_uri_complete: authorization.verificationUriComplete,
401
+ expires_at: authorization.expiresAt,
402
+ interval_seconds: authorization.intervalSeconds
403
+ }
404
+ });
405
+ }
406
+ let intervalSeconds = authorization.intervalSeconds;
407
+ try {
408
+ while (Date.now() < authorization.expiresAt) {
409
+ await abortableDelay(intervalSeconds * 1_000, options.signal);
410
+ if (options.signal?.aborted) {
411
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.");
412
+ }
413
+ let tokenResponse;
414
+ try {
415
+ tokenResponse = await fetch(`${this.serverUrl}/oauth/token`, {
416
+ method: "POST",
417
+ headers: { "content-type": "application/x-www-form-urlencoded" },
418
+ body: new URLSearchParams({
419
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
420
+ device_code: body.device_code,
421
+ client_id: application.id,
422
+ code_verifier: verifier
423
+ }),
424
+ signal: options.signal
425
+ });
426
+ }
427
+ catch (cause) {
428
+ if (options.signal?.aborted) {
429
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", cause);
430
+ }
431
+ continue;
432
+ }
433
+ const tokenBody = await decodeJsonResponse(tokenResponse, "invalid_token_response", "The authorization service returned an invalid token response.");
434
+ if (!tokenResponse.ok) {
435
+ const code = oauthErrorCode(tokenBody);
436
+ if (code === "authorization_pending") {
437
+ continue;
438
+ }
439
+ if (code === "slow_down") {
440
+ intervalSeconds += 5;
441
+ continue;
442
+ }
443
+ throw apiError(tokenBody, "token_exchange_failed", "Downloaded application authorization could not be completed.", tokenResponse.status);
444
+ }
445
+ const authority = validAuthorityTokenResponse(tokenBody.authority, tokenBody.collection_id);
446
+ const localEncryption = tokenBody.encryption
447
+ && tokenBody.encryption.protocol_version === GRANT_ENCRYPTION_PROTOCOL_VERSION
448
+ && tokenBody.encryption.suite === RELAY_ENCRYPTION_SUITE
449
+ && tokenBody.encryption.application_agreement_public_key
450
+ === grantKey.agreementPublicKey;
451
+ if (tokenBody.application_origin !== "null") {
452
+ throw connectError("invalid_token_response", "Authorization did not bind the portable grant to its opaque application origin.");
453
+ }
454
+ if (tokenBody.authority
455
+ && (!authority
456
+ || tokenBody.encryption != null
457
+ || tokenBody.authority.proof_public_key !== grantKey.signingPublicKey)) {
458
+ throw connectError("invalid_token_response", "Authorization returned a remote authority capability that is not bound to this portable grant key.");
459
+ }
460
+ if (!tokenBody.authority && !localEncryption) {
461
+ throw connectError("encryption_required", "Authorization did not establish the expected key-bound local portable grant.");
462
+ }
463
+ const token = this.storeTokenResponse(tokenBody, application.id, keyHandle);
464
+ if (options.target?.kind === "collection"
465
+ && options.target.collectionId !== token.collectionId) {
466
+ this.removeToken(token.collectionId, token.keyHandle);
467
+ throw connectError("collection_mismatch", "The approved collection does not match the collection requested by this link.");
468
+ }
469
+ popup?.close();
470
+ return {
471
+ kind: "connected",
472
+ connection: this.connection(token.collectionId),
473
+ ...(options.returnTo ? { returnTo: options.returnTo } : {})
474
+ };
475
+ }
476
+ throw connectError("expired_token", "The downloaded application authorization code expired.");
477
+ }
478
+ catch (error) {
479
+ popup?.close();
480
+ await this.keyStore.delete(keyHandle);
481
+ if (options.signal?.aborted) {
482
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
483
+ }
484
+ throw error;
485
+ }
486
+ }
487
+ completeAuthorization(callbackUrl, options = {}) {
488
+ const state = authorizationCallbackState(callbackUrl);
489
+ if (!state) {
490
+ return Promise.reject(connectError("invalid_callback", "Authorization callback is missing its state."));
491
+ }
492
+ const pending = parseStored(this.storage.getItem(this.pendingKey(state)));
493
+ const popupAttempt = pending?.presentation === "popup";
494
+ return this.completionPromises.run(state, options, this.timeouts.requestMs, (signal) => publishPopupAuthorizationCompletion({
495
+ storage: this.storage,
496
+ storagePrefix: this.storagePrefix(),
497
+ state,
498
+ pending,
499
+ popupAttempt,
500
+ completion: this.performAuthorizationCompletion(callbackUrl, state, signal)
501
+ }));
502
+ }
503
+ async performAuthorizationCompletion(callbackUrl, state, signal) {
504
+ const callback = new URL(callbackUrl);
505
+ const code = callback.searchParams.get("code");
506
+ const pendingKey = this.pendingKey(state);
507
+ const pending = parseStored(this.storage.getItem(pendingKey));
508
+ if (!pending || pending.version !== 1 || state !== pending.state) {
509
+ throw connectError("invalid_callback", "Authorization callback is missing or does not match this browser session.");
510
+ }
511
+ if (callback.searchParams.has("error")) {
512
+ if (pending.keyHandle)
513
+ await this.keyStore.delete(pending.keyHandle);
514
+ this.storage.removeItem(pendingKey);
515
+ throw serverConnectError(callback.searchParams.get("error") ?? "access_denied", callback.searchParams.get("error_description") ?? "Collection access was not approved.", { details: pending.returnTo ? { return_to: pending.returnTo } : undefined });
516
+ }
517
+ if (!code) {
518
+ throw connectError("invalid_callback", "Authorization callback is missing its code.");
519
+ }
520
+ const response = await connectFetch(`${this.serverUrl}/oauth/token`, {
521
+ method: "POST",
522
+ headers: { "content-type": "application/x-www-form-urlencoded" },
523
+ body: new URLSearchParams({
524
+ grant_type: "authorization_code",
525
+ code,
526
+ client_id: pending.clientId,
527
+ redirect_uri: pending.redirectUri,
528
+ code_verifier: pending.verifier
529
+ }),
530
+ signal
531
+ }, "temporarily_unavailable", "Authorization completion is temporarily unavailable.");
532
+ const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
533
+ if (!response.ok)
534
+ throw apiError(body, "token_exchange_failed", "Authorization could not be completed.", response.status);
535
+ if (pending.relayEncryption === "required" && !body.authority && (!body.encryption
536
+ || !pending.keyHandle
537
+ || body.encryption.application_agreement_public_key
538
+ !== pending.applicationAgreementPublicKey)) {
539
+ if (pending.keyHandle)
540
+ await this.keyStore.delete(pending.keyHandle);
541
+ this.storage.removeItem(pendingKey);
542
+ throw connectError("encryption_required", "Authorization did not establish the required encrypted relay grant.");
543
+ }
544
+ if (body.authority?.proof_public_key) {
545
+ if (!pending.keyHandle
546
+ || !pending.applicationSigningPublicKey
547
+ || body.authority.proof_public_key !== pending.applicationSigningPublicKey) {
548
+ if (pending.keyHandle)
549
+ await this.keyStore.delete(pending.keyHandle);
550
+ this.storage.removeItem(pendingKey);
551
+ throw connectError("invalid_token_response", "Authorization returned a remote authority capability bound to another application key.");
552
+ }
553
+ }
554
+ else if (body.authority && pending.keyHandle) {
555
+ await this.keyStore.delete(pending.keyHandle);
556
+ }
557
+ const token = this.storeTokenResponse(body, pending.clientId, body.authority?.proof_public_key ? pending.keyHandle : body.authority ? undefined : pending.keyHandle);
558
+ if (pending.collectionId && pending.collectionId !== token.collectionId) {
559
+ this.removeToken(token.collectionId, token.keyHandle);
560
+ this.storage.removeItem(pendingKey);
561
+ throw connectError("collection_mismatch", "The approved collection does not match the collection requested by this link.");
562
+ }
563
+ this.storage.removeItem(pendingKey);
564
+ return {
565
+ connection: this.connection(token.collectionId),
566
+ ...(pending.returnTo ? { returnTo: pending.returnTo } : {})
567
+ };
568
+ }
569
+ connections() {
570
+ const connections = [];
571
+ for (const collectionId of connectionIds(this.storage, this.storagePrefix())) {
572
+ const info = this.connection(collectionId)?.info();
573
+ if (info)
574
+ connections.push(info);
575
+ }
576
+ return connections.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.collectionId.localeCompare(right.collectionId));
577
+ }
578
+ connection(collectionId) {
579
+ if (!this.storage.getItem(this.tokenKey(collectionId)))
580
+ return null;
581
+ let connection = this.connectionCache.get(collectionId);
582
+ if (!connection) {
583
+ connection = new MdbaseConnection(this, collectionId);
584
+ this.connectionCache.set(collectionId, connection);
585
+ }
586
+ return connection.info() ? connection : null;
587
+ }
588
+ unavailableReason(collectionId) {
589
+ return this.invalidatedConnections.get(collectionId) ?? null;
590
+ }
591
+ connectionApplicationId(collectionId) {
592
+ // Null deliberately means the application must reauthorize.
593
+ if (!this.connection(collectionId))
594
+ return null;
595
+ const token = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
596
+ return typeof token?.clientId === "string" && token.clientId.length > 0 ? token.clientId : null;
597
+ }
598
+ onConnectionsChange(listener) {
599
+ this.listeners.add(listener);
600
+ listener(this.connections());
601
+ return () => this.listeners.delete(listener);
602
+ }
603
+ storeTokenResponse(body, clientId, keyHandle) {
604
+ const collectionId = body.collection_id;
605
+ if (typeof collectionId !== "string") {
606
+ throw connectError("invalid_token_response", "Authorization returned no collection ID.");
607
+ }
608
+ const previous = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
609
+ const retirement = planRetiredGrantKeys(this.storage, this.pendingMutationKey(collectionId), previous, keyHandle, this.grantKeyLeases.handles(collectionId), this.grantKeyLeases.retainAutomaticallyRetiredKeys);
610
+ let token;
611
+ try {
612
+ token = storedTokenFromResponse({
613
+ body,
614
+ clientId,
615
+ keyHandle,
616
+ previous,
617
+ retiredKeyHandles: retirement.retained,
618
+ defaultApplicationOrigin: this.defaultApplicationOrigin(),
619
+ pinConnectorIdentity: (connectorId, publicKey) => this.pinConnectorIdentity(connectorId, publicKey)
620
+ });
621
+ }
622
+ catch (error) {
623
+ if (error instanceof MdbaseConnectError
624
+ && error.code === "legacy_scope_reauthorization_required") {
625
+ this.removeToken(collectionId, keyHandle, error.code);
626
+ }
627
+ throw error;
628
+ }
629
+ this.storage.setItem(this.tokenKey(collectionId), JSON.stringify(token));
630
+ for (const handle of retirement.disposable)
631
+ this.deleteGrantKeyWhenUnused(collectionId, handle);
632
+ addConnectionId(this.storage, this.storagePrefix(), collectionId);
633
+ this.invalidatedConnections.delete(collectionId);
634
+ this.connectionCache.delete(collectionId);
635
+ this.emitConnections();
636
+ return token;
637
+ }
638
+ forgetConnectorIdentity(connectorId) {
639
+ this.storage.removeItem(this.connectorPinKey(connectorId));
640
+ }
641
+ pinConnectorIdentity(connectorId, publicKey) {
642
+ const key = this.connectorPinKey(connectorId);
643
+ const previous = parseStored(this.storage.getItem(key));
644
+ if (previous && (previous.version !== 1 || previous.publicKey !== publicKey)) {
645
+ throw connectError("connector_identity_changed", "The connector identity changed since this application first connected. Forget the saved connector identity only after verifying the computer, then authorize again.");
646
+ }
647
+ if (!previous)
648
+ this.storage.setItem(key, JSON.stringify({ version: 1, publicKey }));
649
+ }
650
+ connectorPinKey(connectorId) {
651
+ return `mdbase-connect:${this.serverUrl}:connector-pin:${connectorId}`;
652
+ }
653
+ async loadApplicationIdentity(application) {
654
+ try {
655
+ return await applicationIdentity(this.identityStore, this.serverUrl, application);
656
+ }
657
+ catch (cause) {
658
+ throw connectError("application_identity_unavailable", cause instanceof ApplicationIdentityStoreError
659
+ ? cause.message
660
+ : "The application installation identity is unavailable.", { cause });
661
+ }
662
+ }
663
+ removeToken(collectionId, keyHandle, reason = "authorization_lost", discardPending = false) {
664
+ this.invalidatedConnections.set(collectionId, reason);
665
+ const stored = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
666
+ const handles = storedTokenKeyHandles(stored, keyHandle);
667
+ this.storage.removeItem(this.tokenKey(collectionId));
668
+ if (discardPending)
669
+ removePendingMutations(this.storage, this.pendingMutationKey(collectionId), (pendingKeyHandle) => handles.add(pendingKeyHandle));
670
+ for (const handle of handles)
671
+ this.deleteGrantKeyWhenUnused(collectionId, handle);
672
+ for (const transport of ["web_push", "fcm"]) {
673
+ this.storage.removeItem(this.notificationKey(collectionId, transport));
674
+ }
675
+ removeConnectionId(this.storage, this.storagePrefix(), collectionId);
676
+ this.connectionCache.delete(collectionId);
677
+ this.emitConnections();
678
+ }
679
+ acquireGrantKeyLease(collectionId, keyHandle, signal) {
680
+ return this.grantKeyLeases.acquire(collectionId, keyHandle, signal);
681
+ }
682
+ deleteGrantKeyWhenUnused(collectionId, keyHandle) {
683
+ this.grantKeyLeases.deleteExclusive(collectionId, keyHandle, () => deleteUnusedGrantKey(this.storage, this.keyStore, this.pendingMutationKey(collectionId), this.tokenKey(collectionId), keyHandle));
684
+ }
685
+ tokenKey(collectionId) {
686
+ return `${this.storagePrefix()}:token:${collectionId}`;
687
+ }
688
+ pendingMutationKey(collectionId) {
689
+ return `${this.storagePrefix()}:pending-mutation:${collectionId}`;
690
+ }
691
+ notificationKey(collectionId, transport = "web_push") {
692
+ return `${this.storagePrefix()}:notifications:${collectionId}:${transport}`;
693
+ }
694
+ directPreferenceKey() {
695
+ return `mdbase-connect:direct:${this.defaultApplicationOrigin()}`;
696
+ }
697
+ defaultApplicationOrigin() {
698
+ return applicationStorageOrigin(this.manifest, this.manifestSource, this.redirectUri, this.application?.distribution === "portable");
699
+ }
700
+ pendingKey(state) {
701
+ return `${this.storagePrefix()}:pending:${state}`;
702
+ }
703
+ storagePrefix() {
704
+ return `mdbase-connect:${this.serverUrl}:${this.manifestSource}`;
705
+ }
706
+ emitConnections() {
707
+ const connections = this.connections();
708
+ for (const listener of this.listeners)
709
+ listener(connections);
710
+ }
711
+ }