@keyneom/sync-kit 0.2.0-rc.0 → 0.2.0-rc.10

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 +15 -11
  2. package/dist/auth/google-web/cache.d.ts +46 -0
  3. package/dist/auth/google-web/cache.d.ts.map +1 -0
  4. package/dist/auth/google-web/cache.js +111 -0
  5. package/dist/auth/google-web/cache.js.map +1 -0
  6. package/dist/sharing/appdata-identity-store.d.ts +36 -0
  7. package/dist/sharing/appdata-identity-store.d.ts.map +1 -0
  8. package/dist/sharing/appdata-identity-store.js +49 -0
  9. package/dist/sharing/appdata-identity-store.js.map +1 -0
  10. package/dist/sharing/change-detector.d.ts +40 -0
  11. package/dist/sharing/change-detector.d.ts.map +1 -0
  12. package/dist/sharing/change-detector.js +96 -0
  13. package/dist/sharing/change-detector.js.map +1 -0
  14. package/dist/sharing/checkpoint.d.ts +32 -0
  15. package/dist/sharing/checkpoint.d.ts.map +1 -0
  16. package/dist/sharing/checkpoint.js +2 -0
  17. package/dist/sharing/checkpoint.js.map +1 -0
  18. package/dist/sharing/controller.d.ts +88 -3
  19. package/dist/sharing/controller.d.ts.map +1 -1
  20. package/dist/sharing/controller.js +453 -110
  21. package/dist/sharing/controller.js.map +1 -1
  22. package/dist/sharing/index.d.ts +5 -1
  23. package/dist/sharing/index.d.ts.map +1 -1
  24. package/dist/sharing/index.js +3 -0
  25. package/dist/sharing/index.js.map +1 -1
  26. package/dist/sharing/join.d.ts +56 -0
  27. package/dist/sharing/join.d.ts.map +1 -0
  28. package/dist/sharing/join.js +153 -0
  29. package/dist/sharing/join.js.map +1 -0
  30. package/dist/sharing/lifecycle.d.ts +20 -0
  31. package/dist/sharing/lifecycle.d.ts.map +1 -0
  32. package/dist/sharing/lifecycle.js +55 -0
  33. package/dist/sharing/lifecycle.js.map +1 -0
  34. package/dist/sharing/link-exchange.d.ts +51 -0
  35. package/dist/sharing/link-exchange.d.ts.map +1 -0
  36. package/dist/sharing/link-exchange.js +131 -0
  37. package/dist/sharing/link-exchange.js.map +1 -0
  38. package/dist/sharing/migrating-identity-store.d.ts +29 -0
  39. package/dist/sharing/migrating-identity-store.d.ts.map +1 -0
  40. package/dist/sharing/migrating-identity-store.js +37 -0
  41. package/dist/sharing/migrating-identity-store.js.map +1 -0
  42. package/dist/sharing/registry-indexeddb.d.ts +21 -0
  43. package/dist/sharing/registry-indexeddb.d.ts.map +1 -0
  44. package/dist/sharing/registry-indexeddb.js +60 -0
  45. package/dist/sharing/registry-indexeddb.js.map +1 -0
  46. package/dist/sharing/transport.d.ts +12 -0
  47. package/dist/sharing/transport.d.ts.map +1 -1
  48. package/dist/stores/google-drive/app-folders.d.ts +19 -0
  49. package/dist/stores/google-drive/app-folders.d.ts.map +1 -0
  50. package/dist/stores/google-drive/app-folders.js +38 -0
  51. package/dist/stores/google-drive/app-folders.js.map +1 -0
  52. package/dist/stores/google-drive/folder-name.d.ts +16 -0
  53. package/dist/stores/google-drive/folder-name.d.ts.map +1 -0
  54. package/dist/stores/google-drive/folder-name.js +37 -0
  55. package/dist/stores/google-drive/folder-name.js.map +1 -0
  56. package/dist/stores/google-drive/index.d.ts +22 -0
  57. package/dist/stores/google-drive/index.d.ts.map +1 -1
  58. package/dist/stores/google-drive/index.js +66 -0
  59. package/dist/stores/google-drive/index.js.map +1 -1
  60. package/dist/stores/google-drive/picker.d.ts +20 -0
  61. package/dist/stores/google-drive/picker.d.ts.map +1 -1
  62. package/dist/stores/google-drive/picker.js +64 -0
  63. package/dist/stores/google-drive/picker.js.map +1 -1
  64. package/dist/stores/google-drive/sharing.d.ts +5 -1
  65. package/dist/stores/google-drive/sharing.d.ts.map +1 -1
  66. package/dist/stores/google-drive/sharing.js +113 -6
  67. package/dist/stores/google-drive/sharing.js.map +1 -1
  68. package/package.json +27 -4
@@ -1,6 +1,15 @@
1
1
  import { SyncKitError } from "../core/errors.js";
2
2
  import { canAdministerSharedBackup, parseSharedBackupEnvelopeV1, sharedBackupParticipant, sharedBackupParticipants, } from "./index.js";
3
3
  import { acceptSharingPublicKeyResponseV1, createSharedBackupEnvelopeV1, createSharingInvitationV1, createSharingPublicKeyResponseV1, decryptSharedBackupEnvelopeV1, verifySharedBackupEnvelopeV1, verifySharingInvitationV1, } from "./web-crypto.js";
4
+ import { formatSharingInviteEmailMessage, appendSharingJoinParams } from "./join.js";
5
+ export { IndexedDbSharedBackupRegistry } from "./registry-indexeddb.js";
6
+ /**
7
+ * Placeholder recipient permission id for the link-carried exchange, which has
8
+ * no Drive exchange grant. Set identically on the invitation and passed to
9
+ * accept, so the anti-substitution equality check passes; account provenance in
10
+ * this flow comes from the response signature, not a Drive permission.
11
+ */
12
+ const SHARING_LINK_PERMISSION_ID = "link";
4
13
  export class MemorySharedBackupRegistry {
5
14
  records = new Map();
6
15
  get(datasetId) {
@@ -54,6 +63,49 @@ export class SharedBackupController {
54
63
  return result(stored, value, "created");
55
64
  });
56
65
  }
66
+ /**
67
+ * Reconnect to an existing dataset this identity can already decrypt —
68
+ * e.g. after a reinstall, or when an interrupted setup left the remote
69
+ * file without a local registry record. The owner key is pinned from the
70
+ * envelope itself (trust-on-first-use), so callers recovering their own
71
+ * dataset should pass `requireOwned` to insist this identity is the
72
+ * dataset's owner.
73
+ */
74
+ adoptDataset(datasetId, options) {
75
+ return this.serialized(async () => {
76
+ const stored = await this.readDatasetById(datasetId);
77
+ const previous = await this.options.registry.get(datasetId);
78
+ const record = previous ?? this.initialOwnerRecord(stored);
79
+ await verifySharedBackupEnvelopeV1(stored.envelope, this.crypto(), {
80
+ trustedOwnerKeyId: record.trustedOwnerKeyId,
81
+ });
82
+ const identity = await this.options.identity();
83
+ if (options?.requireOwned) {
84
+ const self = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
85
+ if (self?.role !== "owner") {
86
+ throw new SyncKitError("authorization", `This identity does not own dataset ${datasetId}.`);
87
+ }
88
+ }
89
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
90
+ await this.persistHead(stored, record.trustedOwnerKeyId, previous ?? undefined);
91
+ return result(stored, value, "adopted");
92
+ });
93
+ }
94
+ /** Delete a dataset file from the transport and forget its local record. */
95
+ deleteDataset(datasetId) {
96
+ return this.serialized(async () => {
97
+ requireNonEmpty(datasetId, "datasetId");
98
+ const fileId = (await this.options.registry.get(datasetId))?.fileId ??
99
+ (await this.options.transport.listDatasets()).find((candidate) => candidate.datasetId === datasetId)?.fileId;
100
+ if (fileId) {
101
+ if (!this.options.transport.deleteDataset) {
102
+ throw new SyncKitError("state", "This transport does not support deleting datasets.");
103
+ }
104
+ await this.options.transport.deleteDataset(fileId);
105
+ }
106
+ await this.options.registry.delete(datasetId);
107
+ });
108
+ }
57
109
  loadDataset(datasetId) {
58
110
  return this.serialized(async () => {
59
111
  const stored = await this.readDatasetById(datasetId);
@@ -121,11 +173,27 @@ export class SharedBackupController {
121
173
  if (!trustedOwnerKeyId) {
122
174
  throw new SyncKitError("state", "The invitation has no owner trust root.");
123
175
  }
176
+ const storage = await this.options.transport.ensureStorage();
177
+ const appDisplayName = input.appDisplayName?.trim() ?? this.options.appId;
178
+ const emailMessage = input.emailMessage ??
179
+ (input.joinLandingUrl
180
+ ? formatSharingInviteEmailMessage({
181
+ joinUrl: appendSharingJoinParams(input.joinLandingUrl, {
182
+ appFolderId: storage.appFolderId,
183
+ }),
184
+ appDisplayName,
185
+ })
186
+ : input.joinUrl
187
+ ? formatSharingInviteEmailMessage({
188
+ joinUrl: input.joinUrl,
189
+ appDisplayName,
190
+ })
191
+ : undefined);
124
192
  const access = await this.options.transport.grantExchangeAccess(input.emailAddress, {
125
193
  ...(input.sendNotificationEmail === undefined
126
194
  ? {}
127
195
  : { sendNotificationEmail: input.sendNotificationEmail }),
128
- ...(input.emailMessage ? { emailMessage: input.emailMessage } : {}),
196
+ ...(emailMessage ? { emailMessage } : {}),
129
197
  });
130
198
  const invitation = await createSharingInvitationV1(identity, {
131
199
  appId: this.options.appId,
@@ -228,128 +296,301 @@ export class SharedBackupController {
228
296
  if (verifiedAccount) {
229
297
  await this.options.transport.deleteExchange(input.responseFileId);
230
298
  }
231
- const results = [];
232
- for (const grant of accepted) {
233
- try {
234
- const stored = await this.readDatasetById(grant.datasetId);
235
- const record = (await this.options.registry.get(grant.datasetId)) ??
236
- this.initialOwnerRecord(stored);
237
- await this.verifyHead(stored, record);
238
- const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
239
- const participants = participantInputs(stored.envelope).filter((participant) => participant.publicKey.keyId !==
240
- grant.participant.publicKey.keyId);
241
- participants.push(grant.participant);
242
- const next = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
243
- appId: this.options.appId,
244
- backupId: grant.datasetId,
245
- participants,
246
- previous: stored.envelope,
247
- }, this.cryptoOptions());
248
- const updated = await this.options.transport.writeDataset(stored, next);
249
- const permission = await this.options.transport.setDatasetPermission(stored.fileId, input.recipientEmailAddress, grant.participant.role === "owner"
250
- ? "admin"
251
- : grant.participant.role, {
252
- hasInheritedReadAccess: true,
253
- });
254
- const participantPermissionIds = {
255
- ...record.participantPermissionIds,
256
- ...(permission.permissionId
257
- ? {
258
- [grant.participant.publicKey.keyId]: permission.permissionId,
259
- }
260
- : {}),
261
- };
262
- const nextRecord = {
263
- ...record,
264
- fileId: updated.fileId,
265
- lastRevisionId: updated.envelope.revisionId,
266
- participantPermissionIds,
267
- };
268
- await this.options.registry.set(nextRecord);
269
- results.push({
270
- datasetId: grant.datasetId,
271
- fileId: updated.fileId,
272
- revisionId: updated.envelope.revisionId,
273
- ...(permission.permissionId
274
- ? { permissionId: permission.permissionId }
275
- : {}),
276
- status: "accepted",
277
- });
299
+ return this.applyAcceptedGrants(accepted, identity, input.recipientEmailAddress);
300
+ });
301
+ }
302
+ /**
303
+ * Applies verified acceptance grants to each dataset: add the participant,
304
+ * re-encrypt, write, and per-email-share the dataset file. Shared by the
305
+ * Drive-file accept path and the link-payload accept path.
306
+ */
307
+ async applyAcceptedGrants(accepted, identity, recipientEmailAddress) {
308
+ const results = [];
309
+ for (const grant of accepted) {
310
+ try {
311
+ const stored = await this.readDatasetById(grant.datasetId);
312
+ const record = (await this.options.registry.get(grant.datasetId)) ??
313
+ this.initialOwnerRecord(stored);
314
+ await this.verifyHead(stored, record);
315
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
316
+ const participants = participantInputs(stored.envelope).filter((participant) => participant.publicKey.keyId !==
317
+ grant.participant.publicKey.keyId);
318
+ participants.push(grant.participant);
319
+ const next = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
320
+ appId: this.options.appId,
321
+ backupId: grant.datasetId,
322
+ participants,
323
+ previous: stored.envelope,
324
+ }, this.cryptoOptions());
325
+ const updated = await this.options.transport.writeDataset(stored, next);
326
+ const permission = await this.options.transport.setDatasetPermission(stored.fileId, recipientEmailAddress, grant.participant.role === "owner"
327
+ ? "admin"
328
+ : grant.participant.role, {
329
+ hasInheritedReadAccess: true,
330
+ });
331
+ const participantPermissionIds = {
332
+ ...record.participantPermissionIds,
333
+ ...(permission.permissionId
334
+ ? {
335
+ [grant.participant.publicKey.keyId]: permission.permissionId,
336
+ }
337
+ : {}),
338
+ };
339
+ const nextRecord = {
340
+ ...record,
341
+ fileId: updated.fileId,
342
+ lastRevisionId: updated.envelope.revisionId,
343
+ participantPermissionIds,
344
+ };
345
+ await this.options.registry.set(nextRecord);
346
+ results.push({
347
+ datasetId: grant.datasetId,
348
+ fileId: updated.fileId,
349
+ revisionId: updated.envelope.revisionId,
350
+ ...(permission.permissionId
351
+ ? { permissionId: permission.permissionId }
352
+ : {}),
353
+ status: "accepted",
354
+ });
355
+ }
356
+ catch (error) {
357
+ results.push({
358
+ datasetId: grant.datasetId,
359
+ status: "failed",
360
+ error,
361
+ });
362
+ }
363
+ }
364
+ return results;
365
+ }
366
+ /**
367
+ * Owner side of the link-carried exchange. Shares each granted dataset file
368
+ * with the recipient's email (so it lands in their Picker) and returns the
369
+ * signed invitation plus the file list to embed in the join link. Unlike
370
+ * {@link inviteParticipant} it writes no Drive `exchanges/` invitation file.
371
+ */
372
+ inviteParticipantForLink(input) {
373
+ return this.serialized(async () => {
374
+ const identity = await this.options.identity();
375
+ const trustedOwnerKeyIds = new Set();
376
+ const files = [];
377
+ for (const grant of input.requestedGrants) {
378
+ const stored = await this.readDatasetById(grant.datasetId);
379
+ const record = await this.requiredRegistry(grant.datasetId);
380
+ const verified = await verifySharedBackupEnvelopeV1(stored.envelope, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
381
+ const actor = sharedBackupParticipant(verified, identity.publicKey.keyId);
382
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
383
+ throw new SyncKitError("authorization", `This identity cannot invite participants to ${grant.datasetId}.`);
278
384
  }
279
- catch (error) {
280
- results.push({
385
+ trustedOwnerKeyIds.add(record.trustedOwnerKeyId);
386
+ await this.options.transport.setDatasetPermission(stored.fileId, input.emailAddress, grant.role, { hasInheritedReadAccess: false });
387
+ files.push({
388
+ datasetId: grant.datasetId,
389
+ fileId: stored.fileId,
390
+ role: grant.role,
391
+ });
392
+ }
393
+ if (trustedOwnerKeyIds.size !== 1) {
394
+ throw new SyncKitError("configuration", "One invitation cannot combine datasets with different owner trust roots.");
395
+ }
396
+ const trustedOwnerKeyId = [...trustedOwnerKeyIds][0];
397
+ if (!trustedOwnerKeyId) {
398
+ throw new SyncKitError("state", "The invitation has no owner trust root.");
399
+ }
400
+ const storage = await this.options.transport.ensureStorage();
401
+ const invitation = await createSharingInvitationV1(identity, {
402
+ appId: this.options.appId,
403
+ appFolderId: storage.appFolderId,
404
+ recipientDrivePermissionId: SHARING_LINK_PERMISSION_ID,
405
+ requestedGrants: input.requestedGrants,
406
+ trustedOwnerKeyId,
407
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
408
+ }, this.cryptoOptions());
409
+ return { invitation, files };
410
+ });
411
+ }
412
+ /**
413
+ * Recipient side of the link-carried exchange. Verifies the invitation carried
414
+ * in the join link, registers the granted datasets with their file ids (so
415
+ * later reads/writes resolve without listing the owner's folder), and returns
416
+ * a signed key response to send back to the owner. Reads/writes no Drive
417
+ * `exchanges/` file.
418
+ */
419
+ submitKeyResponseFromInvitation(invitation, datasetFiles) {
420
+ return this.serialized(async () => {
421
+ const verified = await verifySharingInvitationV1(invitation, {
422
+ crypto: this.crypto(),
423
+ ...(this.options.now ? { now: this.options.now } : {}),
424
+ });
425
+ if (verified.appId !== this.options.appId) {
426
+ throw new SyncKitError("compatibility", "The invitation belongs to another app.");
427
+ }
428
+ const identity = await this.options.identity();
429
+ const accountBinding = await this.options.createAccountBinding?.({
430
+ appId: this.options.appId,
431
+ exchangeId: verified.exchangeId,
432
+ sharingKeyId: identity.publicKey.keyId,
433
+ });
434
+ const response = await createSharingPublicKeyResponseV1(identity, {
435
+ appId: this.options.appId,
436
+ exchangeId: verified.exchangeId,
437
+ ...(accountBinding ? { accountBinding } : {}),
438
+ }, this.cryptoOptions());
439
+ const fileById = new Map(datasetFiles.map((file) => [file.datasetId, file.fileId]));
440
+ for (const grant of verified.requestedGrants) {
441
+ const existing = await this.options.registry.get(grant.datasetId);
442
+ if (existing &&
443
+ existing.trustedOwnerKeyId !== verified.trustedOwnerKeyId) {
444
+ throw new SyncKitError("conflict", `Dataset ${grant.datasetId} is already pinned to another owner key.`);
445
+ }
446
+ const fileId = fileById.get(grant.datasetId);
447
+ await this.options.registry.set(existing
448
+ ? { ...existing, ...(fileId ? { fileId } : {}) }
449
+ : {
281
450
  datasetId: grant.datasetId,
282
- status: "failed",
283
- error,
451
+ ...(fileId ? { fileId } : {}),
452
+ trustedOwnerKeyId: verified.trustedOwnerKeyId,
284
453
  });
285
- }
286
454
  }
287
- return results;
455
+ return response;
456
+ });
457
+ }
458
+ /**
459
+ * Owner side. Accepts a key response carried in a response link (no Drive
460
+ * `exchanges/` read), adds the recipient to each granted dataset, and
461
+ * per-email shares the dataset files (re-affirming the invite-time share).
462
+ * The invitation must be supplied by the caller (persisted at invite time,
463
+ * keyed by exchange id).
464
+ */
465
+ acceptKeyResponseFromPayload(input) {
466
+ return this.serialized(async () => {
467
+ const identity = await this.options.identity();
468
+ const binding = input.response.accountBinding;
469
+ if (this.options.requireAccountBinding && !binding) {
470
+ throw new SyncKitError("authorization", "The key response has no required account binding.");
471
+ }
472
+ const verifiedAccount = binding && this.options.verifyAccountBinding
473
+ ? await this.options.verifyAccountBinding(binding, {
474
+ appId: this.options.appId,
475
+ exchangeId: input.invitation.exchangeId,
476
+ sharingKeyId: input.response.keyId,
477
+ credentialId: binding.passkey.credentialId,
478
+ })
479
+ : undefined;
480
+ const accepted = await acceptSharingPublicKeyResponseV1(input.invitation, input.response, {
481
+ acceptedByKeyId: identity.publicKey.keyId,
482
+ drivePermissionId: input.invitation.recipientDrivePermissionId,
483
+ ...(verifiedAccount ? { googleSubject: verifiedAccount.subject } : {}),
484
+ }, this.cryptoOptions());
485
+ return this.applyAcceptedGrants(accepted, identity, input.recipientEmailAddress);
288
486
  });
289
487
  }
290
488
  setDatasetRole(input) {
291
- return this.changeParticipants(input.datasetId, (stored, value) => {
489
+ return this.serialized(async () => {
490
+ requireNonEmpty(input.datasetId, "datasetId");
491
+ requireNonEmpty(input.keyId, "keyId");
492
+ requireNonEmpty(input.emailAddress, "emailAddress");
493
+ const stored = await this.readDatasetById(input.datasetId);
494
+ const record = await this.requiredRegistry(input.datasetId);
495
+ await this.verifyHead(stored, record);
496
+ const identity = await this.options.identity();
497
+ const actor = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
498
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
499
+ throw new SyncKitError("authorization", "Only a current owner or admin can change dataset access.");
500
+ }
501
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
292
502
  const participants = participantInputs(stored.envelope);
293
503
  const participant = participants.find((candidate) => candidate.publicKey.keyId === input.keyId);
294
504
  if (!participant) {
295
505
  throw new SyncKitError("not-found", `Participant ${input.keyId} is not in this dataset.`);
296
506
  }
297
507
  participant.role = input.role;
298
- return Promise.resolve({
508
+ const existingPermission = await this.findDirectDatasetPermission(stored.fileId, record.participantPermissionIds?.[input.keyId], input.emailAddress);
509
+ const permission = await this.options.transport.setDatasetPermission(stored.fileId, input.emailAddress, input.role, {
510
+ ...(existingPermission
511
+ ? { existingDirectPermissionId: existingPermission.permissionId }
512
+ : {}),
513
+ ...(input.role === "viewer"
514
+ ? { hasInheritedReadAccess: true }
515
+ : {}),
516
+ });
517
+ const participantPermissionIds = permission.permissionId
518
+ ? {
519
+ ...record.participantPermissionIds,
520
+ [input.keyId]: permission.permissionId,
521
+ }
522
+ : Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== input.keyId));
523
+ const next = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
524
+ appId: this.options.appId,
525
+ backupId: input.datasetId,
299
526
  participants,
300
- value,
301
- afterWrite: async (updated, record) => {
302
- const permission = await this.options.transport.setDatasetPermission(updated.fileId, input.emailAddress, input.role, {
303
- ...(record.participantPermissionIds?.[input.keyId]
304
- ? {
305
- existingDirectPermissionId: record.participantPermissionIds[input.keyId],
306
- }
307
- : {}),
308
- ...(input.role === "viewer"
309
- ? { hasInheritedReadAccess: true }
310
- : {}),
311
- });
312
- const participantPermissionIds = {
313
- ...record.participantPermissionIds,
314
- ...(permission.permissionId
315
- ? { [input.keyId]: permission.permissionId }
316
- : {}),
317
- };
318
- await this.options.registry.set({
319
- ...record,
320
- participantPermissionIds,
321
- });
322
- },
527
+ previous: stored.envelope,
528
+ }, this.cryptoOptions());
529
+ const updated = await this.options.transport.writeDataset(stored, next);
530
+ await this.persistHead(updated, record.trustedOwnerKeyId, {
531
+ ...record,
532
+ participantPermissionIds,
323
533
  });
534
+ return result(updated, value, "updated");
324
535
  });
325
536
  }
326
537
  revokeDatasetKey(input) {
327
- return this.changeParticipants(input.datasetId, (stored, value) => {
538
+ return this.serialized(async () => {
539
+ const stored = await this.readDatasetById(input.datasetId);
540
+ const record = await this.requiredRegistry(input.datasetId);
541
+ await this.verifyHead(stored, record);
542
+ const identity = await this.options.identity();
543
+ const actor = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
544
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
545
+ throw new SyncKitError("authorization", "Only a current owner or admin can change dataset access.");
546
+ }
547
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
328
548
  const participant = sharedBackupParticipant(stored.envelope, input.keyId);
329
549
  if (!participant) {
330
- throw new SyncKitError("not-found", `Participant ${input.keyId} is not in this dataset.`);
550
+ const permission = await this.findDirectDatasetPermission(stored.fileId, record.participantPermissionIds?.[input.keyId], input.emailAddress);
551
+ if (permission) {
552
+ await this.options.transport.removeDatasetPermission(stored.fileId, permission.permissionId);
553
+ }
554
+ await this.options.registry.set({
555
+ ...record,
556
+ participantPermissionIds: Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== input.keyId)),
557
+ });
558
+ return result(stored, value, "unchanged");
331
559
  }
332
560
  if (participant.role === "owner") {
333
561
  throw new SyncKitError("authorization", "Owner transfer or removal is not supported by sharing v1.");
334
562
  }
563
+ const permission = await this.findDirectDatasetPermission(stored.fileId, record.participantPermissionIds?.[input.keyId], input.emailAddress);
564
+ if (permission) {
565
+ await this.options.transport.removeDatasetPermission(stored.fileId, permission.permissionId);
566
+ }
335
567
  const participants = participantInputs(stored.envelope).filter((candidate) => candidate.publicKey.keyId !== input.keyId);
336
- return Promise.resolve({
568
+ const next = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
569
+ appId: this.options.appId,
570
+ backupId: input.datasetId,
337
571
  participants,
338
- value,
339
- afterWrite: async (_updated, record) => {
340
- const permissionId = record.participantPermissionIds?.[input.keyId];
341
- if (permissionId) {
342
- await this.options.transport.removeDatasetPermission(stored.fileId, permissionId);
343
- }
344
- const participantPermissionIds = Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== input.keyId));
345
- await this.options.registry.set({
346
- ...record,
347
- participantPermissionIds,
348
- });
349
- },
572
+ previous: stored.envelope,
573
+ }, this.cryptoOptions());
574
+ const updated = await this.options.transport.writeDataset(stored, next);
575
+ await this.persistHead(updated, record.trustedOwnerKeyId, {
576
+ ...record,
577
+ participantPermissionIds: Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== input.keyId)),
350
578
  });
579
+ return result(updated, value, "updated");
351
580
  });
352
581
  }
582
+ async findDirectDatasetPermission(fileId, permissionId, emailAddress) {
583
+ const directPermissions = (await this.options.transport.listDatasetPermissions(fileId)).filter((permission) => !permission.inherited);
584
+ if (permissionId) {
585
+ const byId = directPermissions.find((permission) => permission.permissionId === permissionId);
586
+ if (byId)
587
+ return byId;
588
+ }
589
+ const normalizedEmail = emailAddress?.trim().toLowerCase();
590
+ if (!normalizedEmail)
591
+ return undefined;
592
+ return directPermissions.find((permission) => permission.emailAddress?.toLowerCase() === normalizedEmail);
593
+ }
353
594
  rotateLocalKey(replacementIdentity, datasetIds) {
354
595
  return this.serialized(async () => {
355
596
  const currentIdentity = await this.options.identity();
@@ -403,28 +644,127 @@ export class SharedBackupController {
403
644
  return results;
404
645
  });
405
646
  }
406
- changeParticipants(datasetId, change) {
647
+ reconcileDrivePermissions(input) {
407
648
  return this.serialized(async () => {
408
- const stored = await this.readDatasetById(datasetId);
409
- const record = await this.requiredRegistry(datasetId);
649
+ const stored = await this.readDatasetById(input.datasetId);
650
+ const record = await this.requiredRegistry(input.datasetId);
410
651
  await this.verifyHead(stored, record);
411
652
  const identity = await this.options.identity();
412
653
  const actor = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
413
654
  if (!actor || !canAdministerSharedBackup(actor.role)) {
414
- throw new SyncKitError("authorization", "Only a current owner or admin can change dataset access.");
655
+ throw new SyncKitError("authorization", "Only a current owner or admin can reconcile Drive permissions.");
415
656
  }
416
- const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
417
- const changed = await change(stored, value);
418
- const next = await createSharedBackupEnvelopeV1(changed.value, this.options.codec, identity, {
419
- appId: this.options.appId,
420
- backupId: datasetId,
421
- participants: changed.participants,
422
- previous: stored.envelope,
423
- }, this.cryptoOptions());
424
- const updated = await this.options.transport.writeDataset(stored, next);
425
- const nextRecord = await this.persistHead(updated, record.trustedOwnerKeyId, record);
426
- await changed.afterWrite?.(updated, nextRecord);
427
- return result(updated, changed.value, "updated");
657
+ const livePermissions = await this.options.transport.listDatasetPermissions(stored.fileId);
658
+ const directPermissions = livePermissions.filter((permission) => !permission.inherited);
659
+ const actions = [];
660
+ const expectedPermissionIds = new Set();
661
+ let participantPermissionIds = {
662
+ ...record.participantPermissionIds,
663
+ };
664
+ const participants = sharedBackupParticipants(stored.envelope).filter((participant) => participant.role !== "owner");
665
+ for (const participant of participants) {
666
+ const emailAddress = input.participantEmails[participant.keyId]?.trim();
667
+ const expectedRole = sharingRoleToDriveRole(participant.role);
668
+ const permissionId = participantPermissionIds[participant.keyId];
669
+ const live = permissionId
670
+ ? directPermissions.find((permission) => permission.permissionId === permissionId)
671
+ : emailAddress
672
+ ? directPermissions.find((permission) => permission.emailAddress?.toLowerCase() ===
673
+ emailAddress.toLowerCase())
674
+ : undefined;
675
+ if (participant.role === "viewer" &&
676
+ !permissionId &&
677
+ !emailAddress &&
678
+ livePermissions.some((permission) => permission.inherited && permission.role === "reader")) {
679
+ actions.push({
680
+ kind: "unchanged",
681
+ keyId: participant.keyId,
682
+ });
683
+ continue;
684
+ }
685
+ if (!emailAddress) {
686
+ if (live?.role === expectedRole) {
687
+ expectedPermissionIds.add(live.permissionId);
688
+ actions.push({ kind: "unchanged", keyId: participant.keyId });
689
+ }
690
+ else {
691
+ actions.push({
692
+ kind: "skipped",
693
+ keyId: participant.keyId,
694
+ reason: "No email address was provided for reconciliation.",
695
+ });
696
+ if (live) {
697
+ expectedPermissionIds.add(live.permissionId);
698
+ }
699
+ }
700
+ continue;
701
+ }
702
+ if (live?.role === expectedRole) {
703
+ expectedPermissionIds.add(live.permissionId);
704
+ if (permissionId !== live.permissionId) {
705
+ participantPermissionIds = {
706
+ ...participantPermissionIds,
707
+ [participant.keyId]: live.permissionId,
708
+ };
709
+ }
710
+ actions.push({ kind: "unchanged", keyId: participant.keyId });
711
+ continue;
712
+ }
713
+ const permission = await this.options.transport.setDatasetPermission(stored.fileId, emailAddress, participant.role, {
714
+ ...(live?.permissionId
715
+ ? { existingDirectPermissionId: live.permissionId }
716
+ : {}),
717
+ ...(participant.role === "viewer"
718
+ ? { hasInheritedReadAccess: true }
719
+ : {}),
720
+ });
721
+ if (!permission.permissionId) {
722
+ actions.push({
723
+ kind: "unchanged",
724
+ keyId: participant.keyId,
725
+ });
726
+ continue;
727
+ }
728
+ expectedPermissionIds.add(permission.permissionId);
729
+ participantPermissionIds = {
730
+ ...participantPermissionIds,
731
+ [participant.keyId]: permission.permissionId,
732
+ };
733
+ actions.push({
734
+ kind: live ? "updated" : "granted",
735
+ keyId: participant.keyId,
736
+ permissionId: permission.permissionId,
737
+ role: permission.role,
738
+ });
739
+ }
740
+ if (JSON.stringify(participantPermissionIds) !==
741
+ JSON.stringify(record.participantPermissionIds ?? {})) {
742
+ await this.options.registry.set({
743
+ ...record,
744
+ participantPermissionIds,
745
+ });
746
+ }
747
+ const removedPermissionIds = new Set();
748
+ for (const permission of directPermissions) {
749
+ const tracked = Object.values(participantPermissionIds).includes(permission.permissionId);
750
+ if (tracked &&
751
+ !expectedPermissionIds.has(permission.permissionId)) {
752
+ await this.options.transport.removeDatasetPermission(stored.fileId, permission.permissionId);
753
+ removedPermissionIds.add(permission.permissionId);
754
+ actions.push({
755
+ kind: "removed",
756
+ permissionId: permission.permissionId,
757
+ });
758
+ }
759
+ }
760
+ if (removedPermissionIds.size > 0) {
761
+ participantPermissionIds = Object.fromEntries(Object.entries(participantPermissionIds).filter(([, permissionId]) => !removedPermissionIds.has(permissionId)));
762
+ await this.options.registry.set({
763
+ ...record,
764
+ participantPermissionIds,
765
+ });
766
+ }
767
+ return { datasetId: input.datasetId, actions };
428
768
  });
429
769
  }
430
770
  async readDatasetById(datasetId) {
@@ -519,6 +859,9 @@ export class SharedBackupController {
519
859
  export function createSharedBackupController(options) {
520
860
  return new SharedBackupController(options);
521
861
  }
862
+ function sharingRoleToDriveRole(role) {
863
+ return role === "viewer" ? "reader" : "writer";
864
+ }
522
865
  function participantInputs(envelope) {
523
866
  return sharedBackupParticipants(envelope).map((participant) => ({
524
867
  publicKey: {