@mdbase-dev/connect 0.1.0-beta.22 → 0.1.0-beta.24

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.
@@ -1 +1 @@
1
- sha384-5i/BVtYUAqTluHzqWtHkb2tI4mKjuOsQMdAKhtXjddN95BAkTPOpub7++MQX+fU0
1
+ sha384-Rejwyx0zN9iv54K0UaKMWs09sQLXcxnOBtKT574KVKlC7aR4FZ7CqpPRMd7mKlNl
@@ -0,0 +1,22 @@
1
+ import type { JsonObject, MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
2
+ import type { GrantKeyStore } from "./crypto.js";
3
+ export interface MdbaseConnectOptions {
4
+ serverUrl: string;
5
+ /**
6
+ * A bundled v1 application manifest or its app-local URL. String values
7
+ * are loaded by this SDK and posted inline; Connect never fetches them.
8
+ */
9
+ manifest?: MdbaseAppManifest | string;
10
+ redirectUri?: string;
11
+ storage?: Storage;
12
+ /** Encrypted relay is required by default for newly authorized grants. */
13
+ relayEncryption?: "required" | "disabled";
14
+ keyStore?: GrantKeyStore;
15
+ /** Prefer same-computer connector access when the browser permits it. */
16
+ directAccess?: "auto" | "disabled";
17
+ /** Loopback origin override for development and automated testing. */
18
+ loopbackUrl?: string;
19
+ /** Override browser navigation, for example to use a native system browser. */
20
+ navigate?: (url: string) => void | Promise<void>;
21
+ }
22
+ export type MdbaseFrontmatter = JsonObject;
@@ -0,0 +1 @@
1
+ export {};
package/dist/connect.d.ts CHANGED
@@ -1,40 +1,51 @@
1
1
  import type { JsonObject, MdbaseAppManifest } from "@mdbase-dev/connect-protocol";
2
2
  import { MdbaseConnection, type MdbaseAuthorizationOutcome, type MdbaseAuthorizationResult, type MdbaseAuthorizeOptions, type MdbaseConnectEnvironment } from "./connection.js";
3
+ import type { MdbaseConnectOptions } from "./connect-options.js";
3
4
  import type { MdbaseConnectionInfo } from "./connection-types.js";
4
5
  import { type GrantKeyStore } from "./crypto.js";
5
- import { type Application } from "./internal-types.js";
6
- import { type AuthorizationProblemCode, type ConnectOutcome, type RegistrationProblemCode } from "./outcomes.js";
7
- import { MdbaseSession, type MdbaseSessionOptions, type MdbaseUnavailableReason } from "./session.js";
8
- export interface MdbaseConnectOptions {
9
- serverUrl: string;
10
- /**
11
- * A bundled v1 application manifest or its app-local URL. String values
12
- * are loaded by this SDK and posted inline; Connect never fetches them.
13
- */
14
- manifest?: MdbaseAppManifest | string;
15
- redirectUri?: string;
16
- storage?: Storage;
17
- /** Encrypted relay is required by default for newly authorized grants. */
18
- relayEncryption?: "required" | "disabled";
19
- keyStore?: GrantKeyStore;
20
- /** Prefer same-computer connector access when the browser permits it. */
21
- directAccess?: "auto" | "disabled";
22
- /** Loopback origin override for development and automated testing. */
23
- loopbackUrl?: string;
24
- /** Override browser navigation, for example to use a native system browser. */
25
- navigate?: (url: string) => void | Promise<void>;
26
- }
27
- export declare class MdbaseConnect<Frontmatter extends JsonObject = JsonObject> {
28
- private readonly internals;
6
+ import { type Application, type StoredToken } from "./internal-types.js";
7
+ import type { MdbaseUnavailableReason } from "./session.js";
8
+ export declare class MdbaseConnectInternals<Frontmatter extends JsonObject> {
9
+ readonly serverUrl: string;
10
+ readonly manifest: MdbaseAppManifest | string;
11
+ readonly manifestSource: string;
12
+ readonly redirectUri: string;
13
+ readonly storage: Storage;
14
+ readonly relayEncryption: "required" | "disabled";
15
+ readonly keyStore: GrantKeyStore;
16
+ readonly directAccessMode: "auto" | "disabled";
17
+ readonly loopbackUrl: string;
18
+ readonly navigate?: (url: string) => void | Promise<void>;
19
+ readonly credentialStorage: MdbaseConnectEnvironment["credentialStorage"];
20
+ private application;
21
+ private readonly completionPromises;
22
+ private readonly connectionCache;
23
+ private readonly listeners;
24
+ private readonly invalidatedConnections;
29
25
  constructor(options: MdbaseConnectOptions);
30
- register(): Promise<ConnectOutcome<Application, RegistrationProblemCode>>;
31
- authorize(options?: MdbaseAuthorizeOptions): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, AuthorizationProblemCode>>;
32
- createSession(options: MdbaseSessionOptions): MdbaseSession<Frontmatter>;
33
26
  environment(): MdbaseConnectEnvironment;
34
- completeAuthorization(callbackUrl?: string): Promise<ConnectOutcome<MdbaseAuthorizationResult<Frontmatter>, AuthorizationProblemCode>>;
27
+ register(): Promise<Application>;
28
+ private loadManifest;
29
+ authorize(options?: MdbaseAuthorizeOptions): Promise<MdbaseAuthorizationOutcome<Frontmatter>>;
30
+ private authorizeWeb;
31
+ private authorizePortable;
32
+ completeAuthorization(callbackUrl: string): Promise<MdbaseAuthorizationResult<Frontmatter>>;
33
+ private performAuthorizationCompletion;
35
34
  connections(): MdbaseConnectionInfo[];
36
35
  connection(collectionId: string): MdbaseConnection<Frontmatter> | null;
37
36
  unavailableReason(collectionId: string): MdbaseUnavailableReason | null;
38
37
  onConnectionsChange(listener: (connections: MdbaseConnectionInfo[]) => void): () => void;
39
- forgetAll(): void;
38
+ storeTokenResponse(body: any, clientId: string, keyHandle?: string): StoredToken;
39
+ removeToken(collectionId: string, keyHandle?: string, reason?: MdbaseUnavailableReason): void;
40
+ tokenKey(collectionId: string): string;
41
+ pendingMutationKey(collectionId: string): string;
42
+ notificationKey(collectionId: string, transport?: "web_push" | "fcm"): string;
43
+ directPreferenceKey(): string;
44
+ defaultApplicationOrigin(): string;
45
+ private pendingKey;
46
+ private storagePrefix;
47
+ private connectionsKey;
48
+ private connectionIds;
49
+ private addConnectionId;
50
+ private emitConnections;
40
51
  }
package/dist/connect.js CHANGED
@@ -1,53 +1,15 @@
1
- import { DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_PROTOCOL_VERSION, RELAY_ENCRYPTION_SUITE } from "@mdbase-dev/connect-protocol";
1
+ import { FIRST_CONTACT_PROTOCOL_VERSION, DEFAULT_LOOPBACK_PORT, ENCRYPTED_RELAY_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
+ import { applicationInstallationId, signApplicationAuthorization } from "./application-identity.js";
4
5
  import { MdbaseConnection } from "./connection.js";
5
6
  import { IndexedDbGrantKeyStore, MemoryGrantKeyStore, validateGrantEncryption } from "./crypto.js";
7
+ import { applicationIdentity, presentFirstContact } from "./first-contact-presentation.js";
6
8
  import { connectError, serverConnectError } from "./errors.js";
7
9
  import { DEFAULT_OPERATIONS } from "./internal-types.js";
8
10
  import { uniqueOperations } from "./operation-helpers.js";
9
- import { AUTHORIZATION_PROBLEM_CODES, REGISTRATION_PROBLEM_CODES, captureConnectOutcome } from "./outcomes.js";
10
- import { MdbaseSession } from "./session.js";
11
- import { MemoryStorage, apiError, canonicalLoopbackUrl, connectFetch, createPkce, defaultCallbackUrl, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
12
- export class MdbaseConnect {
13
- internals;
14
- constructor(options) {
15
- this.internals = new MdbaseConnectInternals(options);
16
- }
17
- register() {
18
- return captureConnectOutcome(() => this.internals.register(), REGISTRATION_PROBLEM_CODES);
19
- }
20
- authorize(options = {}) {
21
- return captureConnectOutcome(() => this.internals.authorize(options), AUTHORIZATION_PROBLEM_CODES);
22
- }
23
- createSession(options) {
24
- return new MdbaseSession(this, options);
25
- }
26
- environment() {
27
- return this.internals.environment();
28
- }
29
- completeAuthorization(callbackUrl) {
30
- return captureConnectOutcome(() => this.internals.completeAuthorization(callbackUrl ?? defaultCallbackUrl()), AUTHORIZATION_PROBLEM_CODES);
31
- }
32
- connections() {
33
- return this.internals.connections();
34
- }
35
- connection(collectionId) {
36
- return this.internals.connection(collectionId);
37
- }
38
- unavailableReason(collectionId) {
39
- return this.internals.unavailableReason(collectionId);
40
- }
41
- onConnectionsChange(listener) {
42
- return this.internals.onConnectionsChange(listener);
43
- }
44
- forgetAll() {
45
- for (const connection of this.connections()) {
46
- this.connection(connection.collectionId)?.forget();
47
- }
48
- }
49
- }
50
- class MdbaseConnectInternals {
11
+ import { MemoryStorage, apiError, canonicalLoopbackUrl, connectFetch, createPkce, defaultManifestSource, defaultRedirectUri, defaultStorage, isOpaquePortableManifest, manifestStorageFingerprint, oauthErrorCode, parseDeviceAuthorization, parseGrantScope, parseStored, stripTrailingSlash, validFileCapability, validAuthorityTokenResponse } from "./runtime-utils.js";
12
+ export class MdbaseConnectInternals {
51
13
  serverUrl;
52
14
  manifest;
53
15
  manifestSource;
@@ -150,10 +112,10 @@ class MdbaseConnectInternals {
150
112
  }
151
113
  const portableDeclared = typeof this.manifest !== "string"
152
114
  && this.manifest.distribution === "portable";
153
- const popup = portableDeclared
154
- && !options.openVerification
115
+ const popup = !(portableDeclared && options.openVerification)
116
+ && !this.navigate
155
117
  && typeof window !== "undefined"
156
- ? window.open(`${this.serverUrl}/device`, "mdbase-connect-authorization", "popup,width=620,height=760")
118
+ ? window.open("", "mdbase-connect-authorization", "popup,width=620,height=760")
157
119
  : null;
158
120
  let application;
159
121
  try {
@@ -164,18 +126,47 @@ class MdbaseConnectInternals {
164
126
  throw error;
165
127
  }
166
128
  if (application.distribution === "portable") {
167
- return this.authorizePortable(application, options, popup);
129
+ if (options.openVerification)
130
+ popup?.close();
131
+ return this.authorizePortable(application, options, options.openVerification ? null : popup);
168
132
  }
169
- popup?.close();
133
+ return this.authorizeWeb(application, options, popup);
134
+ }
135
+ async authorizeWeb(application, options, popup) {
170
136
  const { verifier, challenge } = await createPkce();
171
137
  const state = randomBase64Url(24);
138
+ const authorizationId = crypto.randomUUID();
172
139
  const targetCollectionId = options.target?.kind === "collection"
173
140
  ? options.target.collectionId
174
141
  : undefined;
175
- const keyHandle = this.relayEncryption === "required"
176
- ? `grant:${application.id}:${state}`
177
- : undefined;
178
- const grantKey = keyHandle ? await this.keyStore.create(keyHandle) : undefined;
142
+ const keyHandle = `grant:${application.id}:${state}`;
143
+ const grantKey = await this.keyStore.create(keyHandle);
144
+ const installation = await applicationIdentity(this.keyStore, this.serverUrl, application);
145
+ const operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
146
+ const issuedAt = new Date();
147
+ const proof = await signApplicationAuthorization({
148
+ protocol_version: FIRST_CONTACT_PROTOCOL_VERSION,
149
+ authorization_id: authorizationId,
150
+ application_id: application.id,
151
+ application_manifest_digest: application.manifest_digest,
152
+ application_installation_id: await applicationInstallationId(installation),
153
+ installation_agreement_public_key: installation.agreementPublicKey,
154
+ installation_signing_public_key: installation.signingPublicKey,
155
+ grant_agreement_public_key: grantKey.agreementPublicKey,
156
+ grant_signing_public_key: grantKey.signingPublicKey,
157
+ flow: "authorization_code",
158
+ authorization_nonce: randomBase64Url(32),
159
+ issued_at: issuedAt.toISOString(),
160
+ expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
161
+ redirect_uri: this.redirectUri,
162
+ state,
163
+ code_challenge: challenge,
164
+ requested_operations: operations,
165
+ ...(application.requirements?.files
166
+ ? { requested_files: application.requirements.files }
167
+ : {}),
168
+ ...(targetCollectionId ? { collection_id: targetCollectionId } : {})
169
+ }, installation);
179
170
  const pending = {
180
171
  version: 1,
181
172
  verifier,
@@ -186,30 +177,107 @@ class MdbaseConnectInternals {
186
177
  collectionId: targetCollectionId,
187
178
  returnTo: options.returnTo,
188
179
  keyHandle,
189
- applicationAgreementPublicKey: grantKey?.agreementPublicKey,
190
- applicationSigningPublicKey: grantKey?.signingPublicKey
180
+ installationKeyHandle: installation.handle,
181
+ authorizationId,
182
+ applicationAgreementPublicKey: grantKey.agreementPublicKey,
183
+ applicationSigningPublicKey: grantKey.signingPublicKey
191
184
  };
192
185
  this.storage.setItem(this.pendingKey(state), JSON.stringify(pending));
193
- const authorize = new URL(`${this.serverUrl}/oauth/authorize`);
194
- authorize.searchParams.set("client_id", application.id);
195
- authorize.searchParams.set("redirect_uri", this.redirectUri);
196
- authorize.searchParams.set("code_challenge", challenge);
197
- authorize.searchParams.set("code_challenge_method", "S256");
198
- authorize.searchParams.set("state", state);
199
- authorize.searchParams.set("operations", uniqueOperations(options.operations ?? DEFAULT_OPERATIONS).join(","));
200
- if (targetCollectionId) {
201
- authorize.searchParams.set("collection_id", targetCollectionId);
202
- }
203
- if (grantKey) {
204
- authorize.searchParams.set("relay_protocol", "1");
205
- authorize.searchParams.set("application_agreement_public_key", grantKey.agreementPublicKey);
206
- authorize.searchParams.set("application_signing_public_key", grantKey.signingPublicKey);
207
- }
208
- if (this.navigate)
209
- await this.navigate(authorize.href);
210
- else
211
- location.assign(authorize.href);
212
- return { kind: "redirecting" };
186
+ try {
187
+ const response = await fetch(`${this.serverUrl}/oauth/authorization_request`, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/x-www-form-urlencoded" },
190
+ body: new URLSearchParams({
191
+ client_id: application.id,
192
+ redirect_uri: this.redirectUri,
193
+ code_challenge: challenge,
194
+ code_challenge_method: "S256",
195
+ state,
196
+ operations: operations.join(","),
197
+ ...(targetCollectionId ? { collection_id: targetCollectionId } : {}),
198
+ application_authorization: JSON.stringify(proof)
199
+ }),
200
+ signal: options.signal
201
+ });
202
+ const body = await response.json();
203
+ if (!response.ok) {
204
+ throw apiError(body, "device_authorization_failed", "Application authorization could not be started.", response.status);
205
+ }
206
+ if (typeof body.authorization_uri !== "string"
207
+ || body.authorization_id !== authorizationId
208
+ || !Number.isInteger(body.expires_in)
209
+ || !Number.isInteger(body.interval)
210
+ || body.expires_in <= 0
211
+ || body.interval <= 0) {
212
+ throw connectError("invalid_device_authorization_response", "The authorization service returned an invalid polling response.");
213
+ }
214
+ if (this.navigate)
215
+ await this.navigate(body.authorization_uri);
216
+ else if (popup)
217
+ popup.location.href = body.authorization_uri;
218
+ else {
219
+ throw connectError("browser_required", "The approval window was blocked. Allow popups for this application and try again.");
220
+ }
221
+ const expiresAt = Date.now() + body.expires_in * 1_000;
222
+ let intervalSeconds = body.interval;
223
+ let shownFirstContact = "";
224
+ while (Date.now() < expiresAt) {
225
+ await abortableDelay(intervalSeconds * 1_000, options.signal);
226
+ if (options.signal?.aborted) {
227
+ throw connectError("authorization_cancelled", "Application authorization was cancelled.");
228
+ }
229
+ let statusResponse;
230
+ try {
231
+ statusResponse = await fetch(`${this.serverUrl}/oauth/authorization_status`, {
232
+ method: "POST",
233
+ headers: { "content-type": "application/x-www-form-urlencoded" },
234
+ body: new URLSearchParams({
235
+ client_id: application.id,
236
+ authorization_id: authorizationId,
237
+ code_verifier: verifier
238
+ }),
239
+ signal: options.signal
240
+ });
241
+ }
242
+ catch (cause) {
243
+ if (options.signal?.aborted) {
244
+ throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause });
245
+ }
246
+ continue;
247
+ }
248
+ const statusBody = await statusResponse.json();
249
+ if (!statusResponse.ok) {
250
+ const code = oauthErrorCode(statusBody);
251
+ if (code === "authorization_pending") {
252
+ if (statusBody.first_contact) {
253
+ shownFirstContact = await presentFirstContact(statusBody.first_contact, application, installation, options, shownFirstContact);
254
+ }
255
+ continue;
256
+ }
257
+ if (code === "slow_down") {
258
+ intervalSeconds += 2;
259
+ continue;
260
+ }
261
+ throw apiError(statusBody, "token_exchange_failed", "Application authorization could not be completed.", statusResponse.status);
262
+ }
263
+ if (typeof statusBody.authorization_redirect !== "string") {
264
+ throw connectError("invalid_token_response", "The authorization service returned an invalid completion response.");
265
+ }
266
+ const result = await this.performAuthorizationCompletion(statusBody.authorization_redirect, state);
267
+ popup?.close();
268
+ return { kind: "connected", ...result };
269
+ }
270
+ throw connectError("expired_token", "The application authorization expired.");
271
+ }
272
+ catch (error) {
273
+ popup?.close();
274
+ this.storage.removeItem(this.pendingKey(state));
275
+ await this.keyStore.delete(keyHandle);
276
+ if (options.signal?.aborted) {
277
+ throw connectError("authorization_cancelled", "Application authorization was cancelled.", { cause: error });
278
+ }
279
+ throw error;
280
+ }
213
281
  }
214
282
  async authorizePortable(application, options, popup) {
215
283
  if (this.relayEncryption !== "required") {
@@ -219,6 +287,33 @@ class MdbaseConnectInternals {
219
287
  const { verifier, challenge } = await createPkce();
220
288
  const keyHandle = `grant:${application.id}:${randomBase64Url(24)}`;
221
289
  const grantKey = await this.keyStore.create(keyHandle);
290
+ const installation = await applicationIdentity(this.keyStore, this.serverUrl, application);
291
+ const operations = uniqueOperations(options.operations ?? DEFAULT_OPERATIONS);
292
+ const authorizationId = crypto.randomUUID();
293
+ const issuedAt = new Date();
294
+ const proof = await signApplicationAuthorization({
295
+ protocol_version: FIRST_CONTACT_PROTOCOL_VERSION,
296
+ authorization_id: authorizationId,
297
+ application_id: application.id,
298
+ application_manifest_digest: application.manifest_digest,
299
+ application_installation_id: await applicationInstallationId(installation),
300
+ installation_agreement_public_key: installation.agreementPublicKey,
301
+ installation_signing_public_key: installation.signingPublicKey,
302
+ grant_agreement_public_key: grantKey.agreementPublicKey,
303
+ grant_signing_public_key: grantKey.signingPublicKey,
304
+ flow: "device_code",
305
+ authorization_nonce: randomBase64Url(32),
306
+ issued_at: issuedAt.toISOString(),
307
+ expires_at: new Date(issuedAt.getTime() + 10 * 60 * 1_000).toISOString(),
308
+ code_challenge: challenge,
309
+ requested_operations: operations,
310
+ ...(application.requirements?.files
311
+ ? { requested_files: application.requirements.files }
312
+ : {}),
313
+ ...(options.target?.kind === "collection"
314
+ ? { collection_id: options.target.collectionId }
315
+ : {})
316
+ }, installation);
222
317
  let response;
223
318
  let body;
224
319
  try {
@@ -227,15 +322,13 @@ class MdbaseConnectInternals {
227
322
  headers: { "content-type": "application/x-www-form-urlencoded" },
228
323
  body: new URLSearchParams({
229
324
  client_id: application.id,
230
- operations: uniqueOperations(options.operations ?? DEFAULT_OPERATIONS).join(","),
325
+ operations: operations.join(","),
231
326
  ...(options.target?.kind === "collection"
232
327
  ? { collection_id: options.target.collectionId }
233
328
  : {}),
234
329
  code_challenge: challenge,
235
330
  code_challenge_method: "S256",
236
- relay_protocol: "1",
237
- application_agreement_public_key: grantKey.agreementPublicKey,
238
- application_signing_public_key: grantKey.signingPublicKey
331
+ application_authorization: JSON.stringify(proof)
239
332
  })
240
333
  });
241
334
  body = await response.json();
@@ -257,6 +350,9 @@ class MdbaseConnectInternals {
257
350
  catch (error) {
258
351
  popup?.close();
259
352
  await this.keyStore.delete(keyHandle);
353
+ if (options.signal?.aborted) {
354
+ throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
355
+ }
260
356
  throw error;
261
357
  }
262
358
  options.onDeviceCode?.(authorization);
@@ -284,6 +380,7 @@ class MdbaseConnectInternals {
284
380
  });
285
381
  }
286
382
  let intervalSeconds = authorization.intervalSeconds;
383
+ let shownFirstContact = "";
287
384
  try {
288
385
  while (Date.now() < authorization.expiresAt) {
289
386
  await abortableDelay(intervalSeconds * 1_000, options.signal);
@@ -313,8 +410,12 @@ class MdbaseConnectInternals {
313
410
  const tokenBody = await tokenResponse.json();
314
411
  if (!tokenResponse.ok) {
315
412
  const code = oauthErrorCode(tokenBody);
316
- if (code === "authorization_pending")
413
+ if (code === "authorization_pending") {
414
+ if (tokenBody.first_contact) {
415
+ shownFirstContact = await presentFirstContact(tokenBody.first_contact, application, installation, options, shownFirstContact);
416
+ }
317
417
  continue;
418
+ }
318
419
  if (code === "slow_down") {
319
420
  intervalSeconds += 5;
320
421
  continue;
@@ -357,6 +458,9 @@ class MdbaseConnectInternals {
357
458
  catch (error) {
358
459
  popup?.close();
359
460
  await this.keyStore.delete(keyHandle);
461
+ if (options.signal?.aborted) {
462
+ throw connectError("authorization_cancelled", "Downloaded application authorization was cancelled.", { cause: error });
463
+ }
360
464
  throw error;
361
465
  }
362
466
  }
@@ -1,4 +1,4 @@
1
- import type { CollectionChange, CollectionChangesPage, CollectionDescription, CollectionOperation, CollectionTypeDocument, CreateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, FileCapability, GrantScope, JsonObject, ReadViewSourceInput, RecordDocument, SavedViewExecution, SavedViewList, SavedViewSourceDocument, TypePackInstallResult, TypePackProvision, UpdateViewSourceInput } from "@mdbase-dev/connect-protocol";
1
+ import type { CollectionChange, CollectionChangesPage, CollectionDescription, CollectionOperation, FirstContactBinding, CollectionTypeDocument, CreateViewSourceInput, DeleteViewSourceInput, DeleteViewSourceResult, ExecuteViewInput, FileCapability, GrantScope, JsonObject, ReadViewSourceInput, RecordDocument, SavedViewExecution, SavedViewList, SavedViewSourceDocument, TypePackInstallResult, TypePackProvision, UpdateViewSourceInput } from "@mdbase-dev/connect-protocol";
2
2
  import { type ConnectionTransportInternals } from "./connection-transport.js";
3
3
  import type { DirectAccessStatus, MdbaseAuthorizationCapabilities, MdbaseConnectionInfo, MdbaseConnectionRoute, MdbaseSyncConnection } from "./connection-types.js";
4
4
  import type { GrantKeyStore } from "./crypto.js";
@@ -22,6 +22,11 @@ export interface MdbaseAuthorizeOptions {
22
22
  returnTo?: string;
23
23
  /** Receives the short code even when the SDK also opens the approval page. */
24
24
  onDeviceCode?: (authorization: MdbaseDeviceAuthorization) => void;
25
+ /**
26
+ * Displays the independently derived first-contact code. Keep it visible
27
+ * while the user compares and accepts the same code on the connector.
28
+ */
29
+ onFirstContact?: (challenge: MdbaseFirstContactChallenge) => void | Promise<void>;
25
30
  /** Replace the default popup for a downloaded application's approval page. */
26
31
  openVerification?: (authorization: MdbaseDeviceAuthorization) => void | Promise<void>;
27
32
  /** Stop polling and discard the unapproved, in-memory key. */
@@ -31,9 +36,16 @@ export interface MdbaseConnectionAuthorizeOptions {
31
36
  operations?: CollectionOperation[];
32
37
  returnTo?: string;
33
38
  onDeviceCode?: (authorization: MdbaseDeviceAuthorization) => void;
39
+ onFirstContact?: (challenge: MdbaseFirstContactChallenge) => void | Promise<void>;
34
40
  openVerification?: (authorization: MdbaseDeviceAuthorization) => void | Promise<void>;
35
41
  signal?: AbortSignal;
36
42
  }
43
+ export interface MdbaseFirstContactChallenge {
44
+ binding: FirstContactBinding;
45
+ authenticationString: string;
46
+ applicationId: string;
47
+ connectorId: string;
48
+ }
37
49
  export interface MdbaseConnectEnvironment {
38
50
  distribution: "web" | "portable";
39
51
  applicationOrigin: string;
@@ -78,7 +90,7 @@ export declare class MdbaseConnection<Frontmatter extends JsonObject = JsonObjec
78
90
  authorizationCapabilities(requiredOperations?: CollectionOperation[]): MdbaseAuthorizationCapabilities;
79
91
  hasOperations(requiredOperations: CollectionOperation[]): boolean;
80
92
  authorize(options?: MdbaseConnectionAuthorizeOptions): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter>, AuthorizationProblemCode>>;
81
- requestOperations(requiredOperations: CollectionOperation[], options?: Pick<MdbaseConnectionAuthorizeOptions, "returnTo">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter> | {
93
+ requestOperations(requiredOperations: CollectionOperation[], options?: Omit<MdbaseConnectionAuthorizeOptions, "operations">): Promise<ConnectOutcome<MdbaseAuthorizationOutcome<Frontmatter> | {
82
94
  kind: "unchanged";
83
95
  connection: MdbaseConnection<Frontmatter>;
84
96
  }, AuthorizationProblemCode>>;
@@ -0,0 +1,5 @@
1
+ import type { MdbaseAuthorizeOptions } from "./connection.js";
2
+ import type { GrantKeyRecord, GrantKeyStore } from "./crypto.js";
3
+ import type { Application } from "./internal-types.js";
4
+ export declare function presentFirstContact(value: unknown, application: Application, installation: GrantKeyRecord, options: MdbaseAuthorizeOptions, previousFingerprint: string): Promise<string>;
5
+ export declare function applicationIdentity(keyStore: GrantKeyStore, serverUrl: string, application: Application): Promise<GrantKeyRecord>;
@@ -0,0 +1,51 @@
1
+ import { applicationInstallationId } from "./application-identity.js";
2
+ import { deriveFirstContactSas } from "./first-contact.js";
3
+ import { connectError } from "./errors.js";
4
+ export async function presentFirstContact(value, application, installation, options, previousFingerprint) {
5
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
6
+ throw connectError("invalid_first_contact", "The authorization service returned an invalid first-contact challenge.");
7
+ }
8
+ const binding = value;
9
+ const installationId = await applicationInstallationId(installation);
10
+ if (binding.application_id !== application.id
11
+ || binding.application_installation_id !== installationId
12
+ || binding.application_agreement_public_key !== installation.agreementPublicKey
13
+ || binding.application_signing_public_key !== installation.signingPublicKey) {
14
+ throw connectError("invalid_first_contact", "The first-contact challenge does not match this application installation.");
15
+ }
16
+ const fingerprint = JSON.stringify(binding);
17
+ if (fingerprint === previousFingerprint)
18
+ return previousFingerprint;
19
+ if (!options.onFirstContact) {
20
+ throw connectError("first_contact_handler_required", "Local access requires the application to display a first-contact comparison code.");
21
+ }
22
+ let authenticationString;
23
+ try {
24
+ authenticationString = await deriveFirstContactSas(binding, "application", installation);
25
+ }
26
+ catch (cause) {
27
+ throw connectError("invalid_first_contact", "The authorization service returned an invalid first-contact challenge.", { cause });
28
+ }
29
+ await options.onFirstContact({
30
+ binding,
31
+ authenticationString,
32
+ applicationId: binding.application_id,
33
+ connectorId: binding.connector_id
34
+ });
35
+ return fingerprint;
36
+ }
37
+ export async function applicationIdentity(keyStore, serverUrl, application) {
38
+ const handle = `application-installation:${serverUrl}:${application.id}`;
39
+ const existing = await keyStore.get(handle);
40
+ if (existing)
41
+ return existing;
42
+ try {
43
+ return await keyStore.create(handle);
44
+ }
45
+ catch (error) {
46
+ const raced = await keyStore.get(handle);
47
+ if (raced)
48
+ return raced;
49
+ throw error;
50
+ }
51
+ }
@@ -0,0 +1,14 @@
1
+ import { type FirstContactBinding } from "@mdbase-dev/connect-protocol";
2
+ export type FirstContactRole = "application" | "connector";
3
+ export declare class FirstContactCryptoError extends Error {
4
+ readonly code: string;
5
+ constructor(code: string, message: string);
6
+ }
7
+ /**
8
+ * Derive the independently displayed first-contact authentication string.
9
+ * The private key must belong to the endpoint role named in the transcript.
10
+ */
11
+ export declare function deriveFirstContactSas(binding: FirstContactBinding, role: FirstContactRole, identity: {
12
+ agreementPrivateKey: CryptoKey;
13
+ agreementPublicKey: string;
14
+ }): Promise<string>;