@mdbase-dev/connect 0.1.0-beta.31 → 0.1.0-beta.33

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 (68) hide show
  1. package/README.md +112 -34
  2. package/dist/advanced.d.ts +9 -0
  3. package/dist/advanced.js +6 -0
  4. package/dist/application-session.d.ts +39 -14
  5. package/dist/application-session.js +201 -70
  6. package/dist/async.js +11 -5
  7. package/dist/authority-proof.d.ts +3 -0
  8. package/dist/authority-proof.js +24 -0
  9. package/dist/browser/integrity.json +1 -1
  10. package/dist/browser/mdbase-connect.min.js +3 -3
  11. package/dist/browser/mdbase-connect.min.js.sha384 +1 -1
  12. package/dist/browser.d.ts +3 -0
  13. package/dist/browser.js +5 -0
  14. package/dist/capabilities.js +2 -1
  15. package/dist/collection-client.d.ts +36 -33
  16. package/dist/collection-client.js +549 -104
  17. package/dist/connect-authorization-helpers.d.ts +3 -0
  18. package/dist/connect-authorization-helpers.js +13 -0
  19. package/dist/connect-options.d.ts +12 -4
  20. package/dist/connect.d.ts +12 -4
  21. package/dist/connect.js +102 -103
  22. package/dist/connection-file-transport.d.ts +2 -2
  23. package/dist/connection-file-transport.js +4 -4
  24. package/dist/connection-notifications.d.ts +10 -4
  25. package/dist/connection-notifications.js +41 -23
  26. package/dist/connection-transport.d.ts +19 -15
  27. package/dist/connection-transport.js +202 -202
  28. package/dist/connection-types.d.ts +5 -4
  29. package/dist/connection.d.ts +47 -46
  30. package/dist/connection.js +320 -162
  31. package/dist/crypto-entry.d.ts +7 -0
  32. package/dist/crypto-entry.js +4 -0
  33. package/dist/crypto.js +4 -4
  34. package/dist/external-store.d.ts +10 -0
  35. package/dist/external-store.js +10 -0
  36. package/dist/file-transfer-internals.d.ts +23 -0
  37. package/dist/file-transfer-internals.js +224 -0
  38. package/dist/files.d.ts +36 -14
  39. package/dist/files.js +92 -222
  40. package/dist/hosted-file-request.js +3 -3
  41. package/dist/index.d.ts +24 -19
  42. package/dist/index.js +10 -18
  43. package/dist/internal-types.d.ts +9 -1
  44. package/dist/local-file-transport.d.ts +2 -2
  45. package/dist/local-file-transport.js +13 -13
  46. package/dist/mdbase-connect.d.ts +6 -4
  47. package/dist/mdbase-connect.js +9 -8
  48. package/dist/notifications.d.ts +3 -2
  49. package/dist/operation-helpers.d.ts +3 -3
  50. package/dist/operation-helpers.js +17 -34
  51. package/dist/operation-types.d.ts +435 -45
  52. package/dist/outcomes.d.ts +15 -15
  53. package/dist/outcomes.js +13 -1
  54. package/dist/pending-mutation-store.d.ts +13 -0
  55. package/dist/pending-mutation-store.js +64 -0
  56. package/dist/request-budget.d.ts +27 -0
  57. package/dist/request-budget.js +95 -0
  58. package/dist/runtime-utils.d.ts +2 -0
  59. package/dist/runtime-utils.js +19 -0
  60. package/dist/session.d.ts +5 -4
  61. package/dist/session.js +6 -5
  62. package/dist/stored-token.d.ts +10 -0
  63. package/dist/stored-token.js +60 -0
  64. package/dist/token-response.d.ts +11 -0
  65. package/dist/token-response.js +77 -0
  66. package/dist/watch-policy.d.ts +8 -0
  67. package/dist/watch-policy.js +12 -0
  68. package/package.json +12 -2
@@ -0,0 +1,3 @@
1
+ import { MdbaseConnectError } from "./errors.js";
2
+ export declare function declarationIdFromFamilyIdentity(familyIdentity: string): string;
3
+ export declare function authorizationAbort(signal: AbortSignal, message: string, cause?: unknown): MdbaseConnectError;
@@ -0,0 +1,13 @@
1
+ import { MdbaseConnectError, connectError } from "./errors.js";
2
+ export function declarationIdFromFamilyIdentity(familyIdentity) {
3
+ const prefix = "bundle:";
4
+ if (!familyIdentity.startsWith(prefix) || familyIdentity.length === prefix.length) {
5
+ throw new Error("The registered application has no valid declaration identity.");
6
+ }
7
+ return familyIdentity.slice(prefix.length);
8
+ }
9
+ export function authorizationAbort(signal, message, cause) {
10
+ if (signal.reason instanceof MdbaseConnectError)
11
+ return signal.reason;
12
+ return connectError("authorization_cancelled", message, { cause });
13
+ }
@@ -1,14 +1,20 @@
1
1
  import type { JsonObject, MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
2
2
  import type { GrantKeyStore } from "./crypto.js";
3
3
  import type { ApplicationIdentityStore } from "./application-identity.js";
4
+ export interface MdbaseConnectTimeouts {
5
+ requestMs?: number | null;
6
+ watchStartMs?: number | null;
7
+ uploadMs?: number | null;
8
+ syncMs?: number | null;
9
+ }
4
10
  export interface MdbaseConnectOptions {
5
- serverUrl: string;
11
+ serverUrl: string | URL;
6
12
  /**
7
13
  * A bundled v1 application manifest or its app-local URL. String values
8
14
  * are loaded by this SDK and posted inline; Connect never fetches them.
9
15
  */
10
- manifest?: MdbaseAppManifest | string;
11
- redirectUri?: string;
16
+ manifest?: MdbaseAppManifest | string | URL;
17
+ redirectUri?: string | URL;
12
18
  storage?: Storage;
13
19
  /** Encrypted relay is required by default for newly authorized grants. */
14
20
  relayEncryption?: "required" | "disabled";
@@ -18,8 +24,10 @@ export interface MdbaseConnectOptions {
18
24
  /** Prefer same-computer connector access when the browser permits it. */
19
25
  directAccess?: "auto" | "disabled";
20
26
  /** Loopback origin override for development and automated testing. */
21
- loopbackUrl?: string;
27
+ loopbackUrl?: string | URL;
22
28
  /** Override browser navigation, for example to use a native system browser. */
23
29
  navigate?: (url: string) => void | Promise<void>;
30
+ /** Workload-specific defaults. Per-call timeoutMs always wins. */
31
+ timeouts?: MdbaseConnectTimeouts;
24
32
  }
25
33
  export type MdbaseFrontmatter = JsonObject;
package/dist/connect.d.ts CHANGED
@@ -5,6 +5,8 @@ import type { MdbaseConnectOptions } from "./connect-options.js";
5
5
  import type { MdbaseConnectionInfo } from "./connection-types.js";
6
6
  import { type GrantKeyStore } from "./crypto.js";
7
7
  import { type Application, type StoredToken } from "./internal-types.js";
8
+ import type { ConnectRequestOptions } from "./operation-types.js";
9
+ import { type ResolvedConnectTimeouts } from "./request-budget.js";
8
10
  import type { MdbaseUnavailableReason } from "./session.js";
9
11
  export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
10
12
  readonly serverUrl: string;
@@ -18,6 +20,7 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
18
20
  readonly directAccessMode: "auto" | "disabled";
19
21
  readonly loopbackUrl: string;
20
22
  readonly navigate?: (url: string) => void | Promise<void>;
23
+ readonly timeouts: ResolvedConnectTimeouts;
21
24
  readonly credentialStorage: MdbaseConnectEnvironment["credentialStorage"];
22
25
  private application;
23
26
  private manifestPromise;
@@ -27,13 +30,16 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
27
30
  private readonly invalidatedConnections;
28
31
  constructor(options: MdbaseConnectOptions);
29
32
  environment(): MdbaseConnectEnvironment;
30
- register(): Promise<Application>;
31
- manifestDeclaration(): Promise<MdbaseAppManifest>;
33
+ register(options?: ConnectRequestOptions): Promise<Application>;
34
+ private registerWithinBudget;
35
+ manifestDeclaration(options?: ConnectRequestOptions): Promise<MdbaseAppManifest>;
32
36
  private loadManifest;
33
37
  authorize(options?: MdbaseAuthorizeOptions): Promise<MdbaseAuthorizationOutcome<Frontmatter>>;
38
+ private authorizeWithinBudget;
34
39
  private authorizeWeb;
35
40
  private authorizePortable;
36
- completeAuthorization(callbackUrl: string): Promise<MdbaseAuthorizationResult<Frontmatter>>;
41
+ completeAuthorization(callbackUrl: string, options?: ConnectRequestOptions): Promise<MdbaseAuthorizationResult<Frontmatter>>;
42
+ private completeAuthorizationWithinBudget;
37
43
  private performAuthorizationCompletion;
38
44
  connections(): MdbaseConnectionInfo[];
39
45
  connection(collectionId: string): MdbaseConnection<Frontmatter> | null;
@@ -44,9 +50,11 @@ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
44
50
  private pinConnectorIdentity;
45
51
  private connectorPinKey;
46
52
  private loadApplicationIdentity;
47
- removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason): void;
53
+ removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason, discardPending?: boolean): void;
48
54
  tokenKey(collectionId: string): string;
49
55
  pendingMutationKey(collectionId: string): string;
56
+ private removePendingMutations;
57
+ private pendingMutationsUseKey;
50
58
  notificationKey(collectionId: string, transport?: "web_push" | "fcm"): string;
51
59
  directPreferenceKey(): string;
52
60
  defaultApplicationOrigin(): string;
package/dist/connect.js CHANGED
@@ -1,13 +1,16 @@
1
- import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
1
+ import { APPLICATION_AUTHORIZATION_PROTOCOL_VERSION, authorizationContractRequirements, DEFAULT_LOOPBACK_PORT, GRANT_ENCRYPTION_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
2
2
  import { abortableDelay } from "./async.js";
3
3
  import { randomBase64Url } from "./base64.js";
4
4
  import { ApplicationIdentityStoreError, IndexedDbApplicationIdentityStore, MemoryApplicationIdentityStore, applicationIdentity, applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
5
5
  import { MdbaseConnection } from "./connection.js";
6
- import { IndexedDbGrantKeyStore, MemoryGrantKeyStore, validateGrantEncryption } from "./crypto.js";
6
+ import { authorizationAbort, declarationIdFromFamilyIdentity } from "./connect-authorization-helpers.js";
7
+ import { IndexedDbGrantKeyStore, MemoryGrantKeyStore } from "./crypto.js";
7
8
  import { connectError, serverConnectError } from "./errors.js";
8
9
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
9
10
  import { uniqueOperations } from "./operation-helpers.js";
10
- import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
11
+ import { resolveConnectTimeouts, withRequestBudget } from "./request-budget.js";
12
+ import { MemoryStorage, apiError, applicationStorageOrigin, canonicalLoopbackUrl, collectionIdFromTokenKey, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, decodeJsonResponse, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseStored, stripTrailingSlash, validAuthorityTokenResponse } from "./runtime-utils.js";
13
+ import { storedTokenFromResponse } from "./token-response.js";
11
14
  export class MdbaseConnectInternals {
12
15
  serverUrl;
13
16
  manifest;
@@ -20,6 +23,7 @@ export class MdbaseConnectInternals {
20
23
  directAccessMode;
21
24
  loopbackUrl;
22
25
  navigate;
26
+ timeouts;
23
27
  credentialStorage;
24
28
  application = null;
25
29
  manifestPromise = null;
@@ -28,17 +32,19 @@ export class MdbaseConnectInternals {
28
32
  listeners = new Set();
29
33
  invalidatedConnections = new Map();
30
34
  constructor(options) {
31
- this.serverUrl = stripTrailingSlash(options.serverUrl);
32
- this.manifest = options.manifest ?? defaultManifestSource();
35
+ this.serverUrl = stripTrailingSlash(String(options.serverUrl));
36
+ this.manifest = options.manifest instanceof URL
37
+ ? options.manifest.href
38
+ : options.manifest ?? defaultManifestSource();
33
39
  this.manifestSource = typeof this.manifest === "string"
34
40
  ? this.manifest
35
41
  : this.manifest.distribution === "portable"
36
42
  ? `bundle:${this.manifest.id}:${manifestStorageFingerprint(this.manifest)}`
37
43
  : `bundle:${this.manifest.id}`;
38
44
  const opaquePortable = isOpaquePortableManifest(this.manifest);
39
- this.redirectUri = options.redirectUri ?? (typeof this.manifest !== "string" && this.manifest.distribution === "portable"
45
+ this.redirectUri = options.redirectUri === undefined ? (typeof this.manifest !== "string" && this.manifest.distribution === "portable"
40
46
  ? ""
41
- : defaultRedirectUri());
47
+ : defaultRedirectUri()) : String(options.redirectUri);
42
48
  this.storage = options.storage ?? defaultStorage(opaquePortable);
43
49
  this.relayEncryption = options.relayEncryption ?? "required";
44
50
  this.keyStore = options.keyStore ?? (opaquePortable ? new MemoryGrantKeyStore() : new IndexedDbGrantKeyStore());
@@ -49,8 +55,9 @@ export class MdbaseConnectInternals {
49
55
  ? "custom"
50
56
  : this.storage instanceof MemoryStorage ? "memory" : "persistent";
51
57
  this.directAccessMode = options.directAccess ?? "auto";
52
- this.loopbackUrl = canonicalLoopbackUrl(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`);
58
+ this.loopbackUrl = canonicalLoopbackUrl(String(options.loopbackUrl ?? `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}`));
53
59
  this.navigate = options.navigate;
60
+ this.timeouts = resolveConnectTimeouts(options.timeouts);
54
61
  if (typeof window !== "undefined" && this.storage === window.localStorage) {
55
62
  window.addEventListener("storage", (event) => {
56
63
  if (event.storageArea !== this.storage || !event.key?.startsWith(this.storagePrefix()))
@@ -73,34 +80,45 @@ export class MdbaseConnectInternals {
73
80
  credentialStorage: this.credentialStorage
74
81
  };
75
82
  }
76
- async register() {
83
+ register(options = {}) {
84
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.registerWithinBudget(budget.signal));
85
+ }
86
+ async registerWithinBudget(signal) {
77
87
  if (this.application)
78
88
  return this.application;
79
89
  const response = await connectFetch(`${this.serverUrl}/v1/apps/register`, {
80
90
  method: "POST",
81
91
  headers: { "content-type": "application/json" },
82
- body: JSON.stringify({ manifest: await this.manifestDeclaration() })
92
+ body: JSON.stringify({ manifest: await this.manifestDeclaration({ signal, timeoutMs: null }) }),
93
+ signal
83
94
  }, "temporarily_unavailable", "Application registration is temporarily unavailable.");
84
- const body = await response.json();
95
+ const body = await decodeJsonResponse(response, "invalid_operation_response", "Connect returned an invalid application registration response.");
85
96
  if (!response.ok)
86
97
  throw apiError(body, "discovery_failed", "Application discovery failed.", response.status);
87
98
  this.application = body.application;
88
99
  return this.application;
89
100
  }
90
- manifestDeclaration() {
101
+ manifestDeclaration(options = {}) {
91
102
  if (this.manifestPromise)
92
103
  return this.manifestPromise;
93
- this.manifestPromise = this.loadManifest();
104
+ const loading = withRequestBudget(options, this.timeouts.requestMs, (budget) => this.loadManifest(budget.signal));
105
+ const shared = loading.catch((error) => {
106
+ if (this.manifestPromise === shared)
107
+ this.manifestPromise = null;
108
+ throw error;
109
+ });
110
+ this.manifestPromise = shared;
94
111
  return this.manifestPromise;
95
112
  }
96
- async loadManifest() {
113
+ async loadManifest(signal) {
97
114
  if (typeof this.manifest !== "string")
98
115
  return this.manifest;
99
116
  const source = this.manifest;
100
117
  let response;
101
118
  try {
102
119
  response = await fetch(source, {
103
- headers: { accept: "application/json" }
120
+ headers: { accept: "application/json" },
121
+ signal
104
122
  });
105
123
  }
106
124
  catch (cause) {
@@ -110,7 +128,7 @@ export class MdbaseConnectInternals {
110
128
  throw connectError("manifest_load_failed", `The bundled application declaration returned HTTP ${response.status}.`, { status: response.status });
111
129
  }
112
130
  try {
113
- return await response.json();
131
+ return await decodeJsonResponse(response, "invalid_application_manifest", "The bundled application declaration is not valid JSON.");
114
132
  }
115
133
  catch (cause) {
116
134
  throw connectError("invalid_application_manifest", "The bundled application declaration is not valid JSON.", {
@@ -127,6 +145,9 @@ export class MdbaseConnectInternals {
127
145
  }
128
146
  }
129
147
  async authorize(options = {}) {
148
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.authorizeWithinBudget({ ...options, signal: budget.signal, timeoutMs: null }));
149
+ }
150
+ async authorizeWithinBudget(options) {
130
151
  if (typeof location === "undefined" && !this.navigate && !options.openVerification) {
131
152
  throw connectError("browser_required", "Authorization navigation requires a browser environment.");
132
153
  }
@@ -140,7 +161,7 @@ export class MdbaseConnectInternals {
140
161
  : null;
141
162
  let application;
142
163
  try {
143
- application = await this.register();
164
+ application = await this.registerWithinBudget(options.signal);
144
165
  }
145
166
  catch (error) {
146
167
  popup?.close();
@@ -177,6 +198,7 @@ export class MdbaseConnectInternals {
177
198
  protocol_version: APPLICATION_AUTHORIZATION_PROTOCOL_VERSION,
178
199
  authorization_id: authorizationId,
179
200
  application_id: application.id,
201
+ application_declaration_id: declarationIdFromFamilyIdentity(application.family_identity),
180
202
  application_manifest_digest: application.manifest_digest,
181
203
  application_installation_id: await applicationInstallationId(installation),
182
204
  installation_signing_public_key: installation.signingPublicKey,
@@ -189,6 +211,7 @@ export class MdbaseConnectInternals {
189
211
  redirect_uri: this.redirectUri,
190
212
  state,
191
213
  code_challenge: challenge,
214
+ contracts: authorizationContractRequirements(operations, application.requirements?.files),
192
215
  requested_operations: operations,
193
216
  ...(application.requirements?.files
194
217
  ? { requested_files: application.requirements.files }
@@ -226,7 +249,7 @@ export class MdbaseConnectInternals {
226
249
  }),
227
250
  signal: options.signal
228
251
  });
229
- const body = await response.json();
252
+ const body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid authorization response.");
230
253
  if (!response.ok) {
231
254
  throw apiError(body, "device_authorization_failed", "Application authorization could not be started.", response.status);
232
255
  }
@@ -249,7 +272,7 @@ export class MdbaseConnectInternals {
249
272
  this.storage.removeItem(this.pendingKey(state));
250
273
  await this.keyStore.delete(keyHandle);
251
274
  if (options.signal?.aborted) {
252
- throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause: error });
275
+ throw authorizationAbort(options.signal, "Application authorization was cancelled.", error);
253
276
  }
254
277
  throw error;
255
278
  }
@@ -278,6 +301,7 @@ export class MdbaseConnectInternals {
278
301
  protocol_version: APPLICATION_AUTHORIZATION_PROTOCOL_VERSION,
279
302
  authorization_id: authorizationId,
280
303
  application_id: application.id,
304
+ application_declaration_id: declarationIdFromFamilyIdentity(application.family_identity),
281
305
  application_manifest_digest: application.manifest_digest,
282
306
  application_installation_id: await applicationInstallationId(installation),
283
307
  installation_signing_public_key: installation.signingPublicKey,
@@ -288,6 +312,7 @@ export class MdbaseConnectInternals {
288
312
  issued_at: issuedAt.toISOString(),
289
313
  expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
290
314
  code_challenge: challenge,
315
+ contracts: authorizationContractRequirements(operations, application.requirements?.files),
291
316
  requested_operations: operations,
292
317
  ...(application.requirements?.files
293
318
  ? { requested_files: application.requirements.files }
@@ -311,9 +336,10 @@ export class MdbaseConnectInternals {
311
336
  code_challenge: challenge,
312
337
  code_challenge_method: "S256",
313
338
  application_authorization: JSON.stringify(proof)
314
- })
339
+ }),
340
+ signal: options.signal
315
341
  });
316
- body = await response.json();
342
+ body = await decodeJsonResponse(response, "invalid_device_authorization_response", "The authorization service returned an invalid device authorization response.");
317
343
  }
318
344
  catch (cause) {
319
345
  popup?.close();
@@ -333,7 +359,7 @@ export class MdbaseConnectInternals {
333
359
  popup?.close();
334
360
  await this.keyStore.delete(keyHandle);
335
361
  if (options.signal?.aborted) {
336
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
362
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
337
363
  }
338
364
  throw error;
339
365
  }
@@ -366,7 +392,7 @@ export class MdbaseConnectInternals {
366
392
  while (Date.now() < authorization.expiresAt) {
367
393
  await abortableDelay(intervalSeconds * 1_000, options.signal);
368
394
  if (options.signal?.aborted) {
369
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.");
395
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.");
370
396
  }
371
397
  let tokenResponse;
372
398
  try {
@@ -384,11 +410,11 @@ export class MdbaseConnectInternals {
384
410
  }
385
411
  catch (cause) {
386
412
  if (options.signal?.aborted) {
387
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause });
413
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", cause);
388
414
  }
389
415
  continue;
390
416
  }
391
- const tokenBody = await tokenResponse.json();
417
+ const tokenBody = await decodeJsonResponse(tokenResponse, "invalid_token_response", "The authorization service returned an invalid token response.");
392
418
  if (!tokenResponse.ok) {
393
419
  const code = oauthErrorCode(tokenBody);
394
420
  if (code === "authorization_pending") {
@@ -402,7 +428,7 @@ export class MdbaseConnectInternals {
402
428
  }
403
429
  const authority = validAuthorityTokenResponse(tokenBody.authority, tokenBody.collection_id);
404
430
  const localEncryption = tokenBody.encryption
405
- && tokenBody.encryption.protocol_version === ENCRYPTED_RELAY_PROTOCOL_VERSION
431
+ && tokenBody.encryption.protocol_version === GRANT_ENCRYPTION_PROTOCOL_VERSION
406
432
  && tokenBody.encryption.suite === RELAY_ENCRYPTION_SUITE
407
433
  && tokenBody.encryption.application_agreement_public_key
408
434
  === grantKey.agreementPublicKey;
@@ -437,12 +463,15 @@ export class MdbaseConnectInternals {
437
463
  popup?.close();
438
464
  await this.keyStore.delete(keyHandle);
439
465
  if (options.signal?.aborted) {
440
- throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
466
+ throw authorizationAbort(options.signal, "Downloaded application authorization was cancelled.", error);
441
467
  }
442
468
  throw error;
443
469
  }
444
470
  }
445
- completeAuthorization(callbackUrl) {
471
+ completeAuthorization(callbackUrl, options = {}) {
472
+ return withRequestBudget(options, this.timeouts.requestMs, (budget) => this.completeAuthorizationWithinBudget(callbackUrl, budget.signal));
473
+ }
474
+ completeAuthorizationWithinBudget(callbackUrl, signal) {
446
475
  const state = new URL(callbackUrl).searchParams.get("state");
447
476
  if (!state) {
448
477
  return Promise.reject(connectError("invalid_callback", "Authorization callback is missing its state."));
@@ -450,7 +479,7 @@ export class MdbaseConnectInternals {
450
479
  const existing = this.completionPromises.get(state);
451
480
  if (existing)
452
481
  return existing;
453
- const completion = this.performAuthorizationCompletion(callbackUrl, state);
482
+ const completion = this.performAuthorizationCompletion(callbackUrl, state, signal);
454
483
  const shared = completion.finally(() => {
455
484
  if (this.completionPromises.get(state) === shared)
456
485
  this.completionPromises.delete(state);
@@ -458,7 +487,7 @@ export class MdbaseConnectInternals {
458
487
  this.completionPromises.set(state, shared);
459
488
  return shared;
460
489
  }
461
- async performAuthorizationCompletion(callbackUrl, state) {
490
+ async performAuthorizationCompletion(callbackUrl, state, signal) {
462
491
  const callback = new URL(callbackUrl);
463
492
  const code = callback.searchParams.get("code");
464
493
  const pendingKey = this.pendingKey(state);
@@ -484,9 +513,10 @@ export class MdbaseConnectInternals {
484
513
  client_id: pending.clientId,
485
514
  redirect_uri: pending.redirectUri,
486
515
  code_verifier: pending.verifier
487
- })
516
+ }),
517
+ signal
488
518
  }, "temporarily_unavailable", "Authorization completion is temporarily unavailable.");
489
- const body = await response.json();
519
+ const body = await decodeJsonResponse(response, "invalid_token_response", "The authorization service returned an invalid token response.");
490
520
  if (!response.ok)
491
521
  throw apiError(body, "token_exchange_failed", "Authorization could not be completed.", response.status);
492
522
  if (pending.relayEncryption === "required" && !body.authority && (!body.encryption
@@ -555,78 +585,18 @@ export class MdbaseConnectInternals {
555
585
  if (typeof collectionId !== "string") {
556
586
  throw connectError("invalid_token_response", "Authorization returned no collection ID.");
557
587
  }
558
- const scope = parseGrantScope(body.scope);
559
- if (!scope) {
560
- throw connectError("invalid_token_response", "Authorization returned no valid collection scope.");
561
- }
562
- if (body.authority && !validAuthorityTokenResponse(body.authority, collectionId)) {
563
- throw connectError("invalid_token_response", "Authorization returned an invalid remote authority capability.");
564
- }
565
- if (body.authority && body.encryption) {
566
- throw connectError("invalid_token_response", "Authorization returned conflicting collection transports.");
567
- }
568
- if (body.file_capability !== null && body.file_capability !== undefined
569
- && !validFileCapability(body.file_capability)) {
570
- throw connectError("invalid_token_response", "Authorization returned an invalid file capability.");
571
- }
572
- if (body.encryption) {
573
- try {
574
- validateGrantEncryption(body.encryption);
575
- }
576
- catch {
577
- throw connectError("invalid_token_response", "Authorization returned an invalid encrypted relay binding.");
578
- }
579
- this.pinConnectorIdentity(body.encryption.connector_id, body.encryption.connector_agreement_public_key);
580
- if (body.encryption.collection_id !== collectionId
581
- || typeof body.grant_id !== "string"
582
- || body.grant_id.length === 0) {
583
- throw connectError("invalid_token_response", "Authorization returned an encrypted relay binding for another grant.");
584
- }
585
- }
586
588
  const previous = parseStored(this.storage.getItem(this.tokenKey(collectionId)));
587
- if (previous?.encryption
588
- && body.encryption
589
- && previous.keyHandle
590
- && previous.keyHandle === keyHandle
591
- && (previous.grantId !== body.grant_id
592
- || previous.encryption.connector_id !== body.encryption.connector_id
593
- || previous.encryption.connector_agreement_public_key
594
- !== body.encryption.connector_agreement_public_key
595
- || previous.encryption.application_agreement_public_key
596
- !== body.encryption.application_agreement_public_key)) {
597
- throw connectError("connector_identity_changed", "The connector identity changed during authorization renewal. Reauthorize before sending collection data.");
598
- }
589
+ const token = storedTokenFromResponse({
590
+ body,
591
+ clientId,
592
+ keyHandle,
593
+ previous,
594
+ defaultApplicationOrigin: this.defaultApplicationOrigin(),
595
+ pinConnectorIdentity: (connectorId, publicKey) => this.pinConnectorIdentity(connectorId, publicKey)
596
+ });
599
597
  if (previous?.keyHandle && previous.keyHandle !== keyHandle) {
600
598
  void this.keyStore.delete(previous.keyHandle).catch(() => undefined);
601
599
  }
602
- const token = {
603
- version: 1,
604
- accessToken: body.access_token,
605
- refreshToken: body.refresh_token,
606
- clientId,
607
- collectionId,
608
- collectionName: body.collection_name ?? `Collection ${collectionId.slice(0, 8)}`,
609
- operations: body.operations,
610
- scope,
611
- expiresAt: Date.now() + body.expires_in * 1_000,
612
- refreshExpiresAt: body.refresh_expires_in
613
- ? Date.now() + body.refresh_expires_in * 1_000
614
- : undefined,
615
- grantId: body.grant_id,
616
- encryption: body.encryption ?? undefined,
617
- fileCapability: body.file_capability ?? undefined,
618
- applicationOrigin: body.application_origin ?? this.defaultApplicationOrigin(),
619
- keyHandle,
620
- savedAt: Date.now(),
621
- authority: body.authority ? {
622
- operationsUrl: body.authority.operations_url,
623
- syncUrl: body.authority.sync_url,
624
- filesUrl: body.authority.files_url,
625
- replicaId: body.authority.replica_id,
626
- accessToken: body.authority.access_token,
627
- proofPublicKey: body.authority.proof_public_key
628
- } : undefined
629
- };
630
600
  this.storage.setItem(this.tokenKey(collectionId), JSON.stringify(token));
631
601
  this.addConnectionId(collectionId);
632
602
  this.invalidatedConnections.delete(collectionId);
@@ -659,12 +629,14 @@ export class MdbaseConnectInternals {
659
629
  : "The application installation identity is unavailable.", { cause });
660
630
  }
661
631
  }
662
- removeToken(collectionId, keyHandle, reason = "authorization_lost") {
632
+ removeToken(collectionId, keyHandle, reason = "authorization_lost", discardPending = false) {
663
633
  this.invalidatedConnections.set(collectionId, reason);
664
- if (keyHandle)
634
+ if (keyHandle && (discardPending || !this.pendingMutationsUseKey(collectionId, keyHandle))) {
665
635
  void this.keyStore.delete(keyHandle).catch(() => undefined);
636
+ }
666
637
  this.storage.removeItem(this.tokenKey(collectionId));
667
- this.storage.removeItem(this.pendingMutationKey(collectionId));
638
+ if (discardPending)
639
+ this.removePendingMutations(collectionId);
668
640
  for (const transport of ["web_push", "fcm"]) {
669
641
  this.storage.removeItem(this.notificationKey(collectionId, transport));
670
642
  }
@@ -681,6 +653,33 @@ export class MdbaseConnectInternals {
681
653
  pendingMutationKey(collectionId) {
682
654
  return `${this.storagePrefix()}:pending-mutation:${collectionId}`;
683
655
  }
656
+ removePendingMutations(collectionId) {
657
+ const baseKey = this.pendingMutationKey(collectionId);
658
+ const keys = [baseKey];
659
+ for (let index = 0; index < this.storage.length; index += 1) {
660
+ const key = this.storage.key(index);
661
+ if (key?.startsWith(`${baseKey}:`))
662
+ keys.push(key);
663
+ }
664
+ for (const key of keys) {
665
+ const pending = parseStored(this.storage.getItem(key));
666
+ if (pending?.keyHandle)
667
+ void this.keyStore.delete(pending.keyHandle).catch(() => undefined);
668
+ this.storage.removeItem(key);
669
+ }
670
+ }
671
+ pendingMutationsUseKey(collectionId, keyHandle) {
672
+ const baseKey = this.pendingMutationKey(collectionId);
673
+ for (let index = 0; index < this.storage.length; index += 1) {
674
+ const key = this.storage.key(index);
675
+ if (key !== baseKey && !key?.startsWith(`${baseKey}:`))
676
+ continue;
677
+ const pending = parseStored(this.storage.getItem(key));
678
+ if (pending?.keyHandle === keyHandle)
679
+ return true;
680
+ }
681
+ return false;
682
+ }
684
683
  notificationKey(collectionId, transport = "web_push") {
685
684
  return `${this.storagePrefix()}:notifications:${collectionId}:${transport}`;
686
685
  }
@@ -6,8 +6,8 @@ export interface ConnectionFileTransportOptions {
6
6
  keyStore: GrantKeyStore;
7
7
  serverUrl: string;
8
8
  loopbackUrl: string;
9
- authorizedToken(): Promise<StoredToken | null>;
10
- refreshAuthorization(): Promise<StoredToken>;
9
+ authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
10
+ refreshAuthorization(signal?: AbortSignal): Promise<StoredToken>;
11
11
  shouldAttemptDirect(token: StoredToken): Promise<boolean>;
12
12
  onDirectAvailable(): void;
13
13
  onDirectUnavailable(): void;
@@ -13,7 +13,7 @@ export class ConnectionFileTransport {
13
13
  this.local = new LocalFileTransport(options);
14
14
  }
15
15
  async control(method, path = "", input, signal) {
16
- const token = await this.requireFileToken();
16
+ const token = await this.requireFileToken(signal);
17
17
  if (!token.authority) {
18
18
  return this.local.control(token, method, path, input, signal);
19
19
  }
@@ -26,14 +26,14 @@ export class ConnectionFileTransport {
26
26
  return this.local.downloadChunk(session, chunkIndex, signal);
27
27
  }
28
28
  async downloadHostedPart(session, partIndex, expectedLength, signal) {
29
- const token = await this.requireFileToken();
29
+ const token = await this.requireFileToken(signal);
30
30
  if (!token.authority) {
31
31
  throw connectError("not_remote_authority", "Hosted file delivery requires a remote authority endpoint.");
32
32
  }
33
33
  return performHostedFilePartRequest(token, `downloads/${encodeURIComponent(session.transfer_id)}/parts/${partIndex}`, expectedLength, signal, this.options.refreshAuthorization, this.options.authorityProofHeaders);
34
34
  }
35
- async requireFileToken() {
36
- const token = await this.options.authorizedToken();
35
+ async requireFileToken(signal) {
36
+ const token = await this.options.authorizedToken(signal);
37
37
  if (!token?.fileCapability) {
38
38
  throw connectError("not_authorized", "This connection has no file capability.");
39
39
  }
@@ -1,17 +1,20 @@
1
1
  import type { Application } from "./internal-types.js";
2
2
  import type { StoredToken } from "./internal-types.js";
3
+ import type { ConnectRequestOptions } from "./operation-types.js";
3
4
  import type { MdbaseNativeNotificationRegistration, MdbaseNativeNotificationRegistrationOptions, MdbaseNotificationRegistration, MdbaseNotificationRegistrationOptions } from "./notifications.js";
4
5
  export interface ConnectionNotificationContext {
5
6
  serverUrl: string;
6
7
  storage: Storage;
7
- authorizedToken(): Promise<StoredToken | null>;
8
- register(): Promise<Application>;
8
+ authorizedToken(signal?: AbortSignal): Promise<StoredToken | null>;
9
+ register(signal?: AbortSignal): Promise<Application>;
9
10
  notificationKey(transport?: "web_push" | "fcm"): string;
11
+ requestTimeoutMs: number | null;
10
12
  }
11
13
  export declare class ConnectionNotifications {
12
14
  private readonly context;
13
15
  constructor(context: ConnectionNotificationContext);
14
16
  registerNotifications(options: MdbaseNotificationRegistrationOptions): Promise<MdbaseNotificationRegistration>;
17
+ private registerNotificationsWithinBudget;
15
18
  /**
16
19
  * Register an iOS or Android installation for Connect-managed FCM.
17
20
  *
@@ -19,7 +22,10 @@ export declare class ConnectionNotifications {
19
22
  * same installation whenever Firebase refreshes its token.
20
23
  */
21
24
  registerNativeNotifications(options: MdbaseNativeNotificationRegistrationOptions): Promise<MdbaseNativeNotificationRegistration>;
22
- unregisterNativeNotifications(): Promise<void>;
23
- unregisterNotifications(serviceWorker?: ServiceWorkerRegistration): Promise<void>;
25
+ private registerNativeNotificationsWithinBudget;
26
+ unregisterNativeNotifications(options?: ConnectRequestOptions): Promise<void>;
27
+ private unregisterNativeNotificationsWithinBudget;
28
+ unregisterNotifications(serviceWorker?: ServiceWorkerRegistration, options?: ConnectRequestOptions): Promise<void>;
29
+ private unregisterNotificationsWithinBudget;
24
30
  private deleteNotificationChannel;
25
31
  }