@keyneom/sync-kit 0.1.1 → 0.2.0-rc.1

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 (100) hide show
  1. package/README.md +103 -8
  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/auth/google-web/identity.d.ts +49 -0
  7. package/dist/auth/google-web/identity.d.ts.map +1 -0
  8. package/dist/auth/google-web/identity.js +102 -0
  9. package/dist/auth/google-web/identity.js.map +1 -0
  10. package/dist/auth/google-web/index.d.ts +1 -0
  11. package/dist/auth/google-web/index.d.ts.map +1 -1
  12. package/dist/auth/google-web/index.js +1 -0
  13. package/dist/auth/google-web/index.js.map +1 -1
  14. package/dist/core/errors.d.ts +1 -1
  15. package/dist/core/errors.d.ts.map +1 -1
  16. package/dist/core/errors.js +22 -1
  17. package/dist/core/errors.js.map +1 -1
  18. package/dist/crypto/canonical.d.ts +5 -0
  19. package/dist/crypto/canonical.d.ts.map +1 -1
  20. package/dist/crypto/canonical.js +8 -1
  21. package/dist/crypto/canonical.js.map +1 -1
  22. package/dist/crypto/envelope.d.ts.map +1 -1
  23. package/dist/crypto/envelope.js +1 -0
  24. package/dist/crypto/envelope.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/keys/web-passkey/index.d.ts +8 -2
  30. package/dist/keys/web-passkey/index.d.ts.map +1 -1
  31. package/dist/keys/web-passkey/index.js +32 -10
  32. package/dist/keys/web-passkey/index.js.map +1 -1
  33. package/dist/sharing/account-binding.d.ts +57 -0
  34. package/dist/sharing/account-binding.d.ts.map +1 -0
  35. package/dist/sharing/account-binding.js +305 -0
  36. package/dist/sharing/account-binding.js.map +1 -0
  37. package/dist/sharing/change-detector.d.ts +40 -0
  38. package/dist/sharing/change-detector.d.ts.map +1 -0
  39. package/dist/sharing/change-detector.js +96 -0
  40. package/dist/sharing/change-detector.js.map +1 -0
  41. package/dist/sharing/checkpoint.d.ts +32 -0
  42. package/dist/sharing/checkpoint.d.ts.map +1 -0
  43. package/dist/sharing/checkpoint.js +2 -0
  44. package/dist/sharing/checkpoint.js.map +1 -0
  45. package/dist/sharing/controller.d.ts +169 -0
  46. package/dist/sharing/controller.d.ts.map +1 -0
  47. package/dist/sharing/controller.js +693 -0
  48. package/dist/sharing/controller.js.map +1 -0
  49. package/dist/sharing/index.d.ts +122 -0
  50. package/dist/sharing/index.d.ts.map +1 -0
  51. package/dist/sharing/index.js +362 -0
  52. package/dist/sharing/index.js.map +1 -0
  53. package/dist/sharing/join.d.ts +56 -0
  54. package/dist/sharing/join.d.ts.map +1 -0
  55. package/dist/sharing/join.js +153 -0
  56. package/dist/sharing/join.js.map +1 -0
  57. package/dist/sharing/lifecycle.d.ts +20 -0
  58. package/dist/sharing/lifecycle.d.ts.map +1 -0
  59. package/dist/sharing/lifecycle.js +55 -0
  60. package/dist/sharing/lifecycle.js.map +1 -0
  61. package/dist/sharing/registry-indexeddb.d.ts +21 -0
  62. package/dist/sharing/registry-indexeddb.d.ts.map +1 -0
  63. package/dist/sharing/registry-indexeddb.js +60 -0
  64. package/dist/sharing/registry-indexeddb.js.map +1 -0
  65. package/dist/sharing/transport.d.ts +75 -0
  66. package/dist/sharing/transport.d.ts.map +1 -0
  67. package/dist/sharing/transport.js +2 -0
  68. package/dist/sharing/transport.js.map +1 -0
  69. package/dist/sharing/web-crypto.d.ts +68 -0
  70. package/dist/sharing/web-crypto.d.ts.map +1 -0
  71. package/dist/sharing/web-crypto.js +645 -0
  72. package/dist/sharing/web-crypto.js.map +1 -0
  73. package/dist/sharing/web-passkey.d.ts +68 -0
  74. package/dist/sharing/web-passkey.d.ts.map +1 -0
  75. package/dist/sharing/web-passkey.js +303 -0
  76. package/dist/sharing/web-passkey.js.map +1 -0
  77. package/dist/snapshot/controller.d.ts.map +1 -1
  78. package/dist/snapshot/controller.js +4 -8
  79. package/dist/snapshot/controller.js.map +1 -1
  80. package/dist/stores/google-drive/app-folders.d.ts +19 -0
  81. package/dist/stores/google-drive/app-folders.d.ts.map +1 -0
  82. package/dist/stores/google-drive/app-folders.js +38 -0
  83. package/dist/stores/google-drive/app-folders.js.map +1 -0
  84. package/dist/stores/google-drive/folder-name.d.ts +16 -0
  85. package/dist/stores/google-drive/folder-name.d.ts.map +1 -0
  86. package/dist/stores/google-drive/folder-name.js +37 -0
  87. package/dist/stores/google-drive/folder-name.js.map +1 -0
  88. package/dist/stores/google-drive/index.d.ts +149 -1
  89. package/dist/stores/google-drive/index.d.ts.map +1 -1
  90. package/dist/stores/google-drive/index.js +399 -0
  91. package/dist/stores/google-drive/index.js.map +1 -1
  92. package/dist/stores/google-drive/picker.d.ts +89 -0
  93. package/dist/stores/google-drive/picker.d.ts.map +1 -0
  94. package/dist/stores/google-drive/picker.js +179 -0
  95. package/dist/stores/google-drive/picker.js.map +1 -0
  96. package/dist/stores/google-drive/sharing.d.ts +57 -0
  97. package/dist/stores/google-drive/sharing.d.ts.map +1 -0
  98. package/dist/stores/google-drive/sharing.js +325 -0
  99. package/dist/stores/google-drive/sharing.js.map +1 -0
  100. package/package.json +78 -10
@@ -0,0 +1,693 @@
1
+ import { SyncKitError } from "../core/errors.js";
2
+ import { canAdministerSharedBackup, parseSharedBackupEnvelopeV1, sharedBackupParticipant, sharedBackupParticipants, } from "./index.js";
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
+ export class MemorySharedBackupRegistry {
7
+ records = new Map();
8
+ get(datasetId) {
9
+ const record = this.records.get(datasetId);
10
+ return Promise.resolve(record ? structuredClone(record) : null);
11
+ }
12
+ set(record) {
13
+ this.records.set(record.datasetId, structuredClone(record));
14
+ return Promise.resolve();
15
+ }
16
+ delete(datasetId) {
17
+ this.records.delete(datasetId);
18
+ return Promise.resolve();
19
+ }
20
+ }
21
+ /**
22
+ * Headless, backend-independent sharing orchestration. Consumers own UI,
23
+ * persistence, lifecycle policy, and conflict resolution.
24
+ */
25
+ export class SharedBackupController {
26
+ options;
27
+ queue = Promise.resolve();
28
+ constructor(options) {
29
+ this.options = options;
30
+ if (!options.appId.trim())
31
+ throw new TypeError("appId must not be empty.");
32
+ }
33
+ ensureStorage() {
34
+ return this.options.transport.ensureStorage();
35
+ }
36
+ listDatasets() {
37
+ return this.options.transport.listDatasets();
38
+ }
39
+ createDataset(datasetId, value) {
40
+ return this.serialized(async () => {
41
+ requireNonEmpty(datasetId, "datasetId");
42
+ const duplicate = (await this.options.transport.listDatasets()).find((dataset) => dataset.datasetId === datasetId);
43
+ if (duplicate) {
44
+ throw new SyncKitError("conflict", `Dataset ${datasetId} already exists.`);
45
+ }
46
+ const identity = await this.options.identity();
47
+ const envelope = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
48
+ appId: this.options.appId,
49
+ backupId: datasetId,
50
+ participants: [
51
+ { publicKey: identity.publicKey, role: "owner" },
52
+ ],
53
+ }, this.cryptoOptions());
54
+ const stored = await this.options.transport.createDataset(datasetId, envelope);
55
+ await this.persistHead(stored, identity.publicKey.keyId);
56
+ return result(stored, value, "created");
57
+ });
58
+ }
59
+ loadDataset(datasetId) {
60
+ return this.serialized(async () => {
61
+ const stored = await this.readDatasetById(datasetId);
62
+ const record = await this.requiredRegistry(datasetId);
63
+ await this.verifyHead(stored, record);
64
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, await this.options.identity(), this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
65
+ await this.persistHead(stored, record.trustedOwnerKeyId, record);
66
+ return result(stored, value, "loaded");
67
+ });
68
+ }
69
+ syncDataset(datasetId, localValue) {
70
+ return this.serialized(async () => {
71
+ const stored = await this.readDatasetById(datasetId);
72
+ const record = await this.requiredRegistry(datasetId);
73
+ const forked = await this.verifyHead(stored, record, true);
74
+ const identity = await this.options.identity();
75
+ const remoteValue = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
76
+ if (forked) {
77
+ const decision = await this.options.resolveFork?.({
78
+ datasetId,
79
+ lastVerifiedRevisionId: record.lastRevisionId ?? "",
80
+ remoteRevisionId: stored.envelope.revisionId,
81
+ localValue,
82
+ remoteValue,
83
+ });
84
+ if (decision !== "merge") {
85
+ throw new SyncKitError("conflict", `Dataset ${datasetId} has a divergent signed head.`);
86
+ }
87
+ }
88
+ const merged = this.options.codec.merge(localValue, remoteValue);
89
+ if (this.options.codec.fingerprint(merged) ===
90
+ this.options.codec.fingerprint(remoteValue)) {
91
+ await this.persistHead(stored, record.trustedOwnerKeyId, record);
92
+ return result(stored, merged, "unchanged");
93
+ }
94
+ const next = await createSharedBackupEnvelopeV1(merged, this.options.codec, identity, {
95
+ appId: this.options.appId,
96
+ backupId: datasetId,
97
+ participants: participantInputs(stored.envelope),
98
+ previous: stored.envelope,
99
+ }, this.cryptoOptions());
100
+ const updated = await this.options.transport.writeDataset(stored, next);
101
+ await this.persistHead(updated, record.trustedOwnerKeyId, record);
102
+ return result(updated, merged, "updated");
103
+ });
104
+ }
105
+ inviteParticipant(input) {
106
+ return this.serialized(async () => {
107
+ const identity = await this.options.identity();
108
+ const trustedOwnerKeyIds = new Set();
109
+ for (const grant of input.requestedGrants) {
110
+ const stored = await this.readDatasetById(grant.datasetId);
111
+ const record = await this.requiredRegistry(grant.datasetId);
112
+ const verified = await verifySharedBackupEnvelopeV1(stored.envelope, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
113
+ const actor = sharedBackupParticipant(verified, identity.publicKey.keyId);
114
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
115
+ throw new SyncKitError("authorization", `This identity cannot invite participants to ${grant.datasetId}.`);
116
+ }
117
+ trustedOwnerKeyIds.add(record.trustedOwnerKeyId);
118
+ }
119
+ if (trustedOwnerKeyIds.size !== 1) {
120
+ throw new SyncKitError("configuration", "One invitation cannot combine datasets with different owner trust roots.");
121
+ }
122
+ const trustedOwnerKeyId = [...trustedOwnerKeyIds][0];
123
+ if (!trustedOwnerKeyId) {
124
+ throw new SyncKitError("state", "The invitation has no owner trust root.");
125
+ }
126
+ const storage = await this.options.transport.ensureStorage();
127
+ const appDisplayName = input.appDisplayName?.trim() ?? this.options.appId;
128
+ const emailMessage = input.emailMessage ??
129
+ (input.joinLandingUrl
130
+ ? formatSharingInviteEmailMessage({
131
+ joinUrl: appendSharingJoinParams(input.joinLandingUrl, {
132
+ appFolderId: storage.appFolderId,
133
+ }),
134
+ appDisplayName,
135
+ })
136
+ : input.joinUrl
137
+ ? formatSharingInviteEmailMessage({
138
+ joinUrl: input.joinUrl,
139
+ appDisplayName,
140
+ })
141
+ : undefined);
142
+ const access = await this.options.transport.grantExchangeAccess(input.emailAddress, {
143
+ ...(input.sendNotificationEmail === undefined
144
+ ? {}
145
+ : { sendNotificationEmail: input.sendNotificationEmail }),
146
+ ...(emailMessage ? { emailMessage } : {}),
147
+ });
148
+ const invitation = await createSharingInvitationV1(identity, {
149
+ appId: this.options.appId,
150
+ appFolderId: access.appFolderId,
151
+ recipientDrivePermissionId: access.drivePermissionId,
152
+ requestedGrants: input.requestedGrants,
153
+ trustedOwnerKeyId,
154
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
155
+ }, this.cryptoOptions());
156
+ return {
157
+ invitation,
158
+ invitationFileId: await this.options.transport.createInvitation(invitation),
159
+ drivePermissionId: access.drivePermissionId,
160
+ };
161
+ });
162
+ }
163
+ listExchanges(options) {
164
+ return this.options.transport.listExchanges(options);
165
+ }
166
+ submitKeyResponse(invitationFileId) {
167
+ return this.serialized(async () => {
168
+ const invitation = await verifySharingInvitationV1(await this.options.transport.readInvitation(invitationFileId), {
169
+ crypto: this.crypto(),
170
+ ...(this.options.now ? { now: this.options.now } : {}),
171
+ });
172
+ const storage = await this.options.transport.ensureStorage();
173
+ if (invitation.appId !== this.options.appId ||
174
+ invitation.appFolderId !== storage.appFolderId) {
175
+ throw new SyncKitError("compatibility", "The invitation belongs to another app storage hierarchy.");
176
+ }
177
+ const identity = await this.options.identity();
178
+ const accountBinding = await this.options.createAccountBinding?.({
179
+ appId: this.options.appId,
180
+ exchangeId: invitation.exchangeId,
181
+ sharingKeyId: identity.publicKey.keyId,
182
+ });
183
+ const response = await createSharingPublicKeyResponseV1(identity, {
184
+ appId: this.options.appId,
185
+ exchangeId: invitation.exchangeId,
186
+ ...(accountBinding ? { accountBinding } : {}),
187
+ }, this.cryptoOptions());
188
+ const datasets = await this.options.transport.listDatasets();
189
+ const registrations = await Promise.all(invitation.requestedGrants.map(async (grant) => {
190
+ const file = datasets.find((dataset) => dataset.datasetId === grant.datasetId);
191
+ const existing = await this.options.registry.get(grant.datasetId);
192
+ return { grant, file, existing };
193
+ }));
194
+ for (const { grant, existing } of registrations) {
195
+ if (existing &&
196
+ existing.trustedOwnerKeyId !== invitation.trustedOwnerKeyId) {
197
+ throw new SyncKitError("conflict", `Dataset ${grant.datasetId} is already pinned to another owner key.`);
198
+ }
199
+ }
200
+ for (const { grant, file, existing } of registrations) {
201
+ await this.options.registry.set(existing
202
+ ? {
203
+ ...existing,
204
+ ...(file ? { fileId: file.fileId } : {}),
205
+ }
206
+ : {
207
+ datasetId: grant.datasetId,
208
+ ...(file ? { fileId: file.fileId } : {}),
209
+ trustedOwnerKeyId: invitation.trustedOwnerKeyId,
210
+ });
211
+ }
212
+ return {
213
+ responseFileId: await this.options.transport.createKeyResponse(response),
214
+ exchangeId: invitation.exchangeId,
215
+ };
216
+ });
217
+ }
218
+ acceptKeyResponse(input) {
219
+ return this.serialized(async () => {
220
+ const identity = await this.options.identity();
221
+ const responseFile = await this.options.transport.readKeyResponse(input.responseFileId, input.invitation.recipientDrivePermissionId);
222
+ const binding = responseFile.response.accountBinding;
223
+ if (this.options.requireAccountBinding && !binding) {
224
+ throw new SyncKitError("authorization", "The key response has no required Google/passkey account binding.");
225
+ }
226
+ const verifiedAccount = binding && this.options.verifyAccountBinding
227
+ ? await this.options.verifyAccountBinding(binding, {
228
+ appId: this.options.appId,
229
+ exchangeId: input.invitation.exchangeId,
230
+ sharingKeyId: responseFile.response.keyId,
231
+ credentialId: binding.passkey.credentialId,
232
+ })
233
+ : undefined;
234
+ if (binding &&
235
+ this.options.requireAccountBinding &&
236
+ !this.options.verifyAccountBinding) {
237
+ throw new SyncKitError("configuration", "Account binding is required but no verifier is configured.");
238
+ }
239
+ const accepted = await acceptSharingPublicKeyResponseV1(input.invitation, responseFile.response, {
240
+ acceptedByKeyId: identity.publicKey.keyId,
241
+ drivePermissionId: responseFile.ownerPermissionId,
242
+ ...(verifiedAccount
243
+ ? { googleSubject: verifiedAccount.subject }
244
+ : {}),
245
+ }, this.cryptoOptions());
246
+ if (verifiedAccount) {
247
+ await this.options.transport.deleteExchange(input.responseFileId);
248
+ }
249
+ const results = [];
250
+ for (const grant of accepted) {
251
+ try {
252
+ const stored = await this.readDatasetById(grant.datasetId);
253
+ const record = (await this.options.registry.get(grant.datasetId)) ??
254
+ this.initialOwnerRecord(stored);
255
+ await this.verifyHead(stored, record);
256
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
257
+ const participants = participantInputs(stored.envelope).filter((participant) => participant.publicKey.keyId !==
258
+ grant.participant.publicKey.keyId);
259
+ participants.push(grant.participant);
260
+ const next = await createSharedBackupEnvelopeV1(value, this.options.codec, identity, {
261
+ appId: this.options.appId,
262
+ backupId: grant.datasetId,
263
+ participants,
264
+ previous: stored.envelope,
265
+ }, this.cryptoOptions());
266
+ const updated = await this.options.transport.writeDataset(stored, next);
267
+ const permission = await this.options.transport.setDatasetPermission(stored.fileId, input.recipientEmailAddress, grant.participant.role === "owner"
268
+ ? "admin"
269
+ : grant.participant.role, {
270
+ hasInheritedReadAccess: true,
271
+ });
272
+ const participantPermissionIds = {
273
+ ...record.participantPermissionIds,
274
+ ...(permission.permissionId
275
+ ? {
276
+ [grant.participant.publicKey.keyId]: permission.permissionId,
277
+ }
278
+ : {}),
279
+ };
280
+ const nextRecord = {
281
+ ...record,
282
+ fileId: updated.fileId,
283
+ lastRevisionId: updated.envelope.revisionId,
284
+ participantPermissionIds,
285
+ };
286
+ await this.options.registry.set(nextRecord);
287
+ results.push({
288
+ datasetId: grant.datasetId,
289
+ fileId: updated.fileId,
290
+ revisionId: updated.envelope.revisionId,
291
+ ...(permission.permissionId
292
+ ? { permissionId: permission.permissionId }
293
+ : {}),
294
+ status: "accepted",
295
+ });
296
+ }
297
+ catch (error) {
298
+ results.push({
299
+ datasetId: grant.datasetId,
300
+ status: "failed",
301
+ error,
302
+ });
303
+ }
304
+ }
305
+ return results;
306
+ });
307
+ }
308
+ setDatasetRole(input) {
309
+ return this.changeParticipants(input.datasetId, (stored, value) => {
310
+ const participants = participantInputs(stored.envelope);
311
+ const participant = participants.find((candidate) => candidate.publicKey.keyId === input.keyId);
312
+ if (!participant) {
313
+ throw new SyncKitError("not-found", `Participant ${input.keyId} is not in this dataset.`);
314
+ }
315
+ participant.role = input.role;
316
+ return Promise.resolve({
317
+ participants,
318
+ value,
319
+ afterWrite: async (updated, record) => {
320
+ const permission = await this.options.transport.setDatasetPermission(updated.fileId, input.emailAddress, input.role, {
321
+ ...(record.participantPermissionIds?.[input.keyId]
322
+ ? {
323
+ existingDirectPermissionId: record.participantPermissionIds[input.keyId],
324
+ }
325
+ : {}),
326
+ ...(input.role === "viewer"
327
+ ? { hasInheritedReadAccess: true }
328
+ : {}),
329
+ });
330
+ const participantPermissionIds = {
331
+ ...record.participantPermissionIds,
332
+ ...(permission.permissionId
333
+ ? { [input.keyId]: permission.permissionId }
334
+ : {}),
335
+ };
336
+ await this.options.registry.set({
337
+ ...record,
338
+ participantPermissionIds,
339
+ });
340
+ },
341
+ });
342
+ });
343
+ }
344
+ revokeDatasetKey(input) {
345
+ return this.changeParticipants(input.datasetId, (stored, value) => {
346
+ const participant = sharedBackupParticipant(stored.envelope, input.keyId);
347
+ if (!participant) {
348
+ throw new SyncKitError("not-found", `Participant ${input.keyId} is not in this dataset.`);
349
+ }
350
+ if (participant.role === "owner") {
351
+ throw new SyncKitError("authorization", "Owner transfer or removal is not supported by sharing v1.");
352
+ }
353
+ const participants = participantInputs(stored.envelope).filter((candidate) => candidate.publicKey.keyId !== input.keyId);
354
+ return Promise.resolve({
355
+ participants,
356
+ value,
357
+ afterWrite: async (_updated, record) => {
358
+ const permissionId = record.participantPermissionIds?.[input.keyId];
359
+ if (permissionId) {
360
+ await this.options.transport.removeDatasetPermission(stored.fileId, permissionId);
361
+ }
362
+ const participantPermissionIds = Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== input.keyId));
363
+ await this.options.registry.set({
364
+ ...record,
365
+ participantPermissionIds,
366
+ });
367
+ },
368
+ });
369
+ });
370
+ }
371
+ rotateLocalKey(replacementIdentity, datasetIds) {
372
+ return this.serialized(async () => {
373
+ const currentIdentity = await this.options.identity();
374
+ const results = [];
375
+ for (const datasetId of datasetIds) {
376
+ try {
377
+ const stored = await this.readDatasetById(datasetId);
378
+ const record = await this.requiredRegistry(datasetId);
379
+ await this.verifyHead(stored, record);
380
+ const current = sharedBackupParticipant(stored.envelope, currentIdentity.publicKey.keyId);
381
+ if (!current || current.role === "viewer") {
382
+ throw new SyncKitError("authorization", "Only a current owner, admin, or writer can rotate its own key.");
383
+ }
384
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, currentIdentity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
385
+ const participants = participantInputs(stored.envelope).map((participant) => participant.publicKey.keyId === currentIdentity.publicKey.keyId
386
+ ? {
387
+ publicKey: replacementIdentity.publicKey,
388
+ role: participant.role,
389
+ ...(participant.accepted
390
+ ? { accepted: participant.accepted }
391
+ : {}),
392
+ }
393
+ : participant);
394
+ const next = await createSharedBackupEnvelopeV1(value, this.options.codec, replacementIdentity, {
395
+ appId: this.options.appId,
396
+ backupId: datasetId,
397
+ participants,
398
+ previous: stored.envelope,
399
+ keyRotation: { previousIdentity: currentIdentity },
400
+ }, this.cryptoOptions());
401
+ const updated = await this.options.transport.writeDataset(stored, next);
402
+ const permission = record.participantPermissionIds?.[currentIdentity.publicKey.keyId];
403
+ const permissions = Object.fromEntries(Object.entries(record.participantPermissionIds ?? {}).filter(([keyId]) => keyId !== currentIdentity.publicKey.keyId));
404
+ if (permission) {
405
+ permissions[replacementIdentity.publicKey.keyId] = permission;
406
+ }
407
+ await this.persistHead(updated, record.trustedOwnerKeyId, {
408
+ ...record,
409
+ participantPermissionIds: permissions,
410
+ });
411
+ results.push({
412
+ datasetId,
413
+ status: "rotated",
414
+ revisionId: updated.envelope.revisionId,
415
+ });
416
+ }
417
+ catch (error) {
418
+ results.push({ datasetId, status: "failed", error });
419
+ }
420
+ }
421
+ return results;
422
+ });
423
+ }
424
+ reconcileDrivePermissions(input) {
425
+ return this.serialized(async () => {
426
+ const stored = await this.readDatasetById(input.datasetId);
427
+ const record = await this.requiredRegistry(input.datasetId);
428
+ await this.verifyHead(stored, record);
429
+ const identity = await this.options.identity();
430
+ const actor = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
431
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
432
+ throw new SyncKitError("authorization", "Only a current owner or admin can reconcile Drive permissions.");
433
+ }
434
+ const livePermissions = await this.options.transport.listDatasetPermissions(stored.fileId);
435
+ const directPermissions = livePermissions.filter((permission) => !permission.inherited);
436
+ const actions = [];
437
+ const expectedPermissionIds = new Set();
438
+ let participantPermissionIds = {
439
+ ...record.participantPermissionIds,
440
+ };
441
+ const participants = sharedBackupParticipants(stored.envelope).filter((participant) => participant.role !== "owner");
442
+ for (const participant of participants) {
443
+ const emailAddress = input.participantEmails[participant.keyId]?.trim();
444
+ const expectedRole = sharingRoleToDriveRole(participant.role);
445
+ const permissionId = participantPermissionIds[participant.keyId];
446
+ const live = permissionId
447
+ ? directPermissions.find((permission) => permission.permissionId === permissionId)
448
+ : emailAddress
449
+ ? directPermissions.find((permission) => permission.emailAddress?.toLowerCase() ===
450
+ emailAddress.toLowerCase())
451
+ : undefined;
452
+ if (participant.role === "viewer" &&
453
+ !permissionId &&
454
+ !emailAddress &&
455
+ livePermissions.some((permission) => permission.inherited && permission.role === "reader")) {
456
+ actions.push({
457
+ kind: "unchanged",
458
+ keyId: participant.keyId,
459
+ });
460
+ continue;
461
+ }
462
+ if (!emailAddress) {
463
+ if (live?.role === expectedRole) {
464
+ expectedPermissionIds.add(live.permissionId);
465
+ actions.push({ kind: "unchanged", keyId: participant.keyId });
466
+ }
467
+ else {
468
+ actions.push({
469
+ kind: "skipped",
470
+ keyId: participant.keyId,
471
+ reason: "No email address was provided for reconciliation.",
472
+ });
473
+ if (live) {
474
+ expectedPermissionIds.add(live.permissionId);
475
+ }
476
+ }
477
+ continue;
478
+ }
479
+ if (live?.role === expectedRole) {
480
+ expectedPermissionIds.add(live.permissionId);
481
+ if (permissionId !== live.permissionId) {
482
+ participantPermissionIds = {
483
+ ...participantPermissionIds,
484
+ [participant.keyId]: live.permissionId,
485
+ };
486
+ }
487
+ actions.push({ kind: "unchanged", keyId: participant.keyId });
488
+ continue;
489
+ }
490
+ const permission = await this.options.transport.setDatasetPermission(stored.fileId, emailAddress, participant.role, {
491
+ ...(live?.permissionId
492
+ ? { existingDirectPermissionId: live.permissionId }
493
+ : {}),
494
+ ...(participant.role === "viewer"
495
+ ? { hasInheritedReadAccess: true }
496
+ : {}),
497
+ });
498
+ if (!permission.permissionId) {
499
+ actions.push({
500
+ kind: "unchanged",
501
+ keyId: participant.keyId,
502
+ });
503
+ continue;
504
+ }
505
+ expectedPermissionIds.add(permission.permissionId);
506
+ participantPermissionIds = {
507
+ ...participantPermissionIds,
508
+ [participant.keyId]: permission.permissionId,
509
+ };
510
+ actions.push({
511
+ kind: live ? "updated" : "granted",
512
+ keyId: participant.keyId,
513
+ permissionId: permission.permissionId,
514
+ role: permission.role,
515
+ });
516
+ }
517
+ if (JSON.stringify(participantPermissionIds) !==
518
+ JSON.stringify(record.participantPermissionIds ?? {})) {
519
+ await this.options.registry.set({
520
+ ...record,
521
+ participantPermissionIds,
522
+ });
523
+ }
524
+ const removedPermissionIds = new Set();
525
+ for (const permission of directPermissions) {
526
+ const tracked = Object.values(participantPermissionIds).includes(permission.permissionId);
527
+ if (tracked &&
528
+ !expectedPermissionIds.has(permission.permissionId)) {
529
+ await this.options.transport.removeDatasetPermission(stored.fileId, permission.permissionId);
530
+ removedPermissionIds.add(permission.permissionId);
531
+ actions.push({
532
+ kind: "removed",
533
+ permissionId: permission.permissionId,
534
+ });
535
+ }
536
+ }
537
+ if (removedPermissionIds.size > 0) {
538
+ participantPermissionIds = Object.fromEntries(Object.entries(participantPermissionIds).filter(([, permissionId]) => !removedPermissionIds.has(permissionId)));
539
+ await this.options.registry.set({
540
+ ...record,
541
+ participantPermissionIds,
542
+ });
543
+ }
544
+ return { datasetId: input.datasetId, actions };
545
+ });
546
+ }
547
+ changeParticipants(datasetId, change) {
548
+ return this.serialized(async () => {
549
+ const stored = await this.readDatasetById(datasetId);
550
+ const record = await this.requiredRegistry(datasetId);
551
+ await this.verifyHead(stored, record);
552
+ const identity = await this.options.identity();
553
+ const actor = sharedBackupParticipant(stored.envelope, identity.publicKey.keyId);
554
+ if (!actor || !canAdministerSharedBackup(actor.role)) {
555
+ throw new SyncKitError("authorization", "Only a current owner or admin can change dataset access.");
556
+ }
557
+ const value = await decryptSharedBackupEnvelopeV1(stored.envelope, this.options.codec, identity, this.crypto(), { trustedOwnerKeyId: record.trustedOwnerKeyId });
558
+ const changed = await change(stored, value);
559
+ const next = await createSharedBackupEnvelopeV1(changed.value, this.options.codec, identity, {
560
+ appId: this.options.appId,
561
+ backupId: datasetId,
562
+ participants: changed.participants,
563
+ previous: stored.envelope,
564
+ }, this.cryptoOptions());
565
+ const updated = await this.options.transport.writeDataset(stored, next);
566
+ const nextRecord = await this.persistHead(updated, record.trustedOwnerKeyId, record);
567
+ await changed.afterWrite?.(updated, nextRecord);
568
+ return result(updated, changed.value, "updated");
569
+ });
570
+ }
571
+ async readDatasetById(datasetId) {
572
+ requireNonEmpty(datasetId, "datasetId");
573
+ const record = await this.options.registry.get(datasetId);
574
+ const file = record?.fileId ??
575
+ (await this.options.transport.listDatasets()).find((candidate) => candidate.datasetId === datasetId)?.fileId;
576
+ if (!file) {
577
+ throw new SyncKitError("not-found", `Dataset ${datasetId} was not found.`);
578
+ }
579
+ return this.options.transport.readDataset(file);
580
+ }
581
+ async requiredRegistry(datasetId) {
582
+ const record = await this.options.registry.get(datasetId);
583
+ if (!record) {
584
+ throw new SyncKitError("state", `Dataset ${datasetId} has no pinned owner key. Open it from a verified invitation first.`);
585
+ }
586
+ return record;
587
+ }
588
+ async verifyHead(stored, record, allowFork = false) {
589
+ await verifySharedBackupEnvelopeV1(stored.envelope, this.crypto(), {
590
+ trustedOwnerKeyId: record.trustedOwnerKeyId,
591
+ });
592
+ if (!record.lastRevisionId)
593
+ return false;
594
+ if (stored.envelope.revisionId !== record.lastRevisionId &&
595
+ record.seenRevisionIds?.includes(stored.envelope.revisionId)) {
596
+ throw new SyncKitError("conflict", `Dataset ${stored.datasetId} rolled back to a previously verified revision.`);
597
+ }
598
+ if (stored.envelope.revisionId === record.lastRevisionId ||
599
+ stored.envelope.parentRevisionId === record.lastRevisionId ||
600
+ stored.envelope.revisionAncestors?.includes(record.lastRevisionId)) {
601
+ return false;
602
+ }
603
+ if (allowFork)
604
+ return true;
605
+ throw new SyncKitError("conflict", `Dataset ${stored.datasetId} has a divergent signed head.`);
606
+ }
607
+ initialOwnerRecord(stored) {
608
+ const owner = sharedBackupParticipants(stored.envelope).find((participant) => participant.role === "owner");
609
+ if (!owner) {
610
+ throw new SyncKitError("compatibility", "The dataset has no owner.");
611
+ }
612
+ return {
613
+ datasetId: stored.datasetId,
614
+ fileId: stored.fileId,
615
+ trustedOwnerKeyId: owner.keyId,
616
+ };
617
+ }
618
+ async persistHead(stored, trustedOwnerKeyId, previous) {
619
+ const record = {
620
+ datasetId: stored.datasetId,
621
+ fileId: stored.fileId,
622
+ trustedOwnerKeyId,
623
+ lastRevisionId: stored.envelope.revisionId,
624
+ seenRevisionIds: [
625
+ ...new Set([
626
+ ...(previous?.seenRevisionIds ?? []),
627
+ ...(previous?.lastRevisionId ? [previous.lastRevisionId] : []),
628
+ stored.envelope.revisionId,
629
+ ]),
630
+ ].slice(-256),
631
+ ...(previous?.participantPermissionIds
632
+ ? { participantPermissionIds: previous.participantPermissionIds }
633
+ : {}),
634
+ };
635
+ await this.options.registry.set(record);
636
+ return record;
637
+ }
638
+ crypto() {
639
+ const implementation = this.options.crypto ?? globalThis.crypto;
640
+ if (!implementation?.subtle) {
641
+ throw new SyncKitError("configuration", "WebCrypto is required by the sharing controller.");
642
+ }
643
+ return implementation;
644
+ }
645
+ cryptoOptions() {
646
+ return {
647
+ crypto: this.crypto(),
648
+ ...(this.options.now ? { now: this.options.now } : {}),
649
+ ...(this.options.randomUUID
650
+ ? { randomUUID: this.options.randomUUID }
651
+ : {}),
652
+ };
653
+ }
654
+ serialized(operation) {
655
+ const run = this.queue.then(operation, operation);
656
+ this.queue = run.then(() => undefined, () => undefined);
657
+ return run;
658
+ }
659
+ }
660
+ export function createSharedBackupController(options) {
661
+ return new SharedBackupController(options);
662
+ }
663
+ function sharingRoleToDriveRole(role) {
664
+ return role === "viewer" ? "reader" : "writer";
665
+ }
666
+ function participantInputs(envelope) {
667
+ return sharedBackupParticipants(envelope).map((participant) => ({
668
+ publicKey: {
669
+ keyId: participant.keyId,
670
+ encryptionAlgorithm: participant.encryptionAlgorithm,
671
+ encryptionPublicKey: participant.encryptionPublicKey,
672
+ signatureAlgorithm: participant.signatureAlgorithm,
673
+ signingPublicKey: participant.signingPublicKey,
674
+ },
675
+ role: participant.role,
676
+ ...(participant.accepted ? { accepted: participant.accepted } : {}),
677
+ }));
678
+ }
679
+ function result(stored, value, outcome) {
680
+ const envelope = parseSharedBackupEnvelopeV1(stored.envelope);
681
+ return {
682
+ datasetId: stored.datasetId,
683
+ fileId: stored.fileId,
684
+ revisionId: envelope.revisionId,
685
+ value,
686
+ outcome,
687
+ };
688
+ }
689
+ function requireNonEmpty(value, name) {
690
+ if (!value.trim())
691
+ throw new TypeError(`${name} must not be empty.`);
692
+ }
693
+ //# sourceMappingURL=controller.js.map