@protontech/drive-sdk 0.14.4 → 0.14.6

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 (61) hide show
  1. package/dist/interface/account.d.ts +2 -1
  2. package/dist/internal/batchLoading.js +3 -0
  3. package/dist/internal/batchLoading.js.map +1 -1
  4. package/dist/internal/batchLoading.test.js +27 -0
  5. package/dist/internal/batchLoading.test.js.map +1 -1
  6. package/dist/internal/nodes/cryptoService.d.ts +3 -2
  7. package/dist/internal/nodes/cryptoService.js +11 -1
  8. package/dist/internal/nodes/cryptoService.js.map +1 -1
  9. package/dist/internal/nodes/cryptoService.test.js +40 -2
  10. package/dist/internal/nodes/cryptoService.test.js.map +1 -1
  11. package/dist/internal/nodes/index.js +1 -1
  12. package/dist/internal/nodes/index.js.map +1 -1
  13. package/dist/internal/nodes/nodesManagement.d.ts +2 -2
  14. package/dist/internal/nodes/nodesManagement.js +1 -1
  15. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  16. package/dist/internal/nodes/nodesManagement.test.js +30 -0
  17. package/dist/internal/nodes/nodesManagement.test.js.map +1 -1
  18. package/dist/internal/photos/index.js +1 -1
  19. package/dist/internal/photos/index.js.map +1 -1
  20. package/dist/internal/photos/nodes.js +14 -1
  21. package/dist/internal/photos/nodes.js.map +1 -1
  22. package/dist/internal/photos/nodes.test.js +44 -0
  23. package/dist/internal/photos/nodes.test.js.map +1 -1
  24. package/dist/internal/sharing/apiService.d.ts +1 -1
  25. package/dist/internal/sharing/apiService.js +2 -2
  26. package/dist/internal/sharing/apiService.js.map +1 -1
  27. package/dist/internal/sharing/cryptoService.d.ts +1 -1
  28. package/dist/internal/sharing/cryptoService.js +2 -2
  29. package/dist/internal/sharing/cryptoService.js.map +1 -1
  30. package/dist/internal/sharing/sharingManagement.d.ts +2 -2
  31. package/dist/internal/sharing/sharingManagement.js +29 -2
  32. package/dist/internal/sharing/sharingManagement.js.map +1 -1
  33. package/dist/internal/sharing/sharingManagement.test.js +48 -0
  34. package/dist/internal/sharing/sharingManagement.test.js.map +1 -1
  35. package/dist/internal/sharingPublic/index.js +1 -1
  36. package/dist/internal/sharingPublic/index.js.map +1 -1
  37. package/dist/protonDriveClient.d.ts +11 -2
  38. package/dist/protonDriveClient.js +12 -0
  39. package/dist/protonDriveClient.js.map +1 -1
  40. package/dist/protonDrivePhotosClient.d.ts +10 -1
  41. package/dist/protonDrivePhotosClient.js +12 -0
  42. package/dist/protonDrivePhotosClient.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/interface/account.ts +2 -1
  45. package/src/internal/batchLoading.test.ts +31 -1
  46. package/src/internal/batchLoading.ts +4 -1
  47. package/src/internal/nodes/cryptoService.test.ts +52 -2
  48. package/src/internal/nodes/cryptoService.ts +16 -1
  49. package/src/internal/nodes/index.ts +1 -1
  50. package/src/internal/nodes/nodesManagement.test.ts +38 -2
  51. package/src/internal/nodes/nodesManagement.ts +15 -3
  52. package/src/internal/photos/index.ts +1 -1
  53. package/src/internal/photos/nodes.test.ts +63 -1
  54. package/src/internal/photos/nodes.ts +16 -1
  55. package/src/internal/sharing/apiService.ts +2 -1
  56. package/src/internal/sharing/cryptoService.ts +2 -1
  57. package/src/internal/sharing/sharingManagement.test.ts +73 -0
  58. package/src/internal/sharing/sharingManagement.ts +47 -3
  59. package/src/internal/sharingPublic/index.ts +7 -1
  60. package/src/protonDriveClient.ts +19 -1
  61. package/src/protonDrivePhotosClient.ts +17 -0
@@ -1,4 +1,4 @@
1
- import { ProtonDriveError } from '../errors';
1
+ import { AbortError, ProtonDriveError } from '../errors';
2
2
  import { BatchLoading } from './batchLoading';
3
3
 
4
4
  describe('BatchLoading', () => {
@@ -124,6 +124,36 @@ describe('BatchLoading', () => {
124
124
  expect((thrown as ProtonDriveError).cause).toEqual([expect.objectContaining({ message: 'iterator failed' })]);
125
125
  });
126
126
 
127
+ it('should rethrow AbortError immediately without accumulating', async () => {
128
+ const abortError = new AbortError();
129
+ const result: string[] = [];
130
+ const iterateItems = jest.fn(async function* (items: string[]) {
131
+ if (items.includes('a')) {
132
+ throw abortError;
133
+ }
134
+ for (const item of items) {
135
+ yield `loaded:${item}`;
136
+ }
137
+ });
138
+
139
+ batchLoading = new BatchLoading<string, string>({ iterateItems, batchSize: 2 });
140
+
141
+ let thrown: unknown;
142
+ try {
143
+ for (const item of ['a', 'b', 'c', 'd']) {
144
+ for await (const loadedItem of batchLoading.load(item)) {
145
+ result.push(loadedItem);
146
+ }
147
+ }
148
+ } catch (e) {
149
+ thrown = e;
150
+ }
151
+
152
+ expect(result).toEqual([]);
153
+ expect(thrown).toBe(abortError);
154
+ expect(iterateItems).toHaveBeenCalledTimes(1);
155
+ });
156
+
127
157
  it('should throw ProtonDriveError with causes when multiple batches fail', async () => {
128
158
  const loadItems = jest.fn((items: string[]) => {
129
159
  if (items.includes('a') || items.includes('e')) {
@@ -1,6 +1,6 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { ProtonDriveError } from '../errors';
3
+ import { AbortError, ProtonDriveError } from '../errors';
4
4
 
5
5
  const DEFAULT_BATCH_LOADING = 10;
6
6
 
@@ -84,6 +84,9 @@ export class BatchLoading<ID, ITEM> {
84
84
  try {
85
85
  yield* this.iterateItems(items);
86
86
  } catch (error) {
87
+ if (error instanceof AbortError) {
88
+ throw error;
89
+ }
87
90
  this.errors.push(error);
88
91
  }
89
92
  }
@@ -86,6 +86,10 @@ describe('nodesCryptoService', () => {
86
86
  };
87
87
  // @ts-expect-error No need to implement all methods for mocking
88
88
  sharesService = {
89
+ getRootIDs: jest.fn(async () => ({
90
+ volumeId: 'volumeId',
91
+ rootNodeId: 'rootNodeId',
92
+ })),
89
93
  getMyFilesShareMemberEmailKey: jest.fn(async () => ({
90
94
  email: 'email',
91
95
  addressKey: 'key' as unknown as PrivateKey,
@@ -94,7 +98,7 @@ describe('nodesCryptoService', () => {
94
98
  };
95
99
 
96
100
  const nodesCryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
97
- cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, nodesCryptoReporter);
101
+ cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, nodesCryptoReporter);
98
102
  });
99
103
 
100
104
  const parentKey = 'parentKey' as unknown as PrivateKey;
@@ -579,6 +583,7 @@ describe('nodesCryptoService', () => {
579
583
  const encryptedNode = {
580
584
  uid: 'volumeId~nodeId',
581
585
  parentUid: 'volumeId~parentId',
586
+ creationTime: new Date('2026-01-01'),
582
587
  encryptedCrypto: {
583
588
  signatureEmail: 'signatureEmail',
584
589
  nameSignatureEmail: 'nameSignatureEmail',
@@ -786,7 +791,13 @@ describe('nodesCryptoService', () => {
786
791
  }) as any,
787
792
  );
788
793
 
789
- const result = await cryptoService.decryptNode(encryptedNode, parentKey);
794
+ const result = await cryptoService.decryptNode(
795
+ {
796
+ ...encryptedNode,
797
+ creationTime: new Date('2026-01-01'),
798
+ },
799
+ parentKey,
800
+ );
790
801
  verifyResult(result, {
791
802
  keyAuthor: {
792
803
  ok: false,
@@ -796,12 +807,50 @@ describe('nodesCryptoService', () => {
796
807
  },
797
808
  },
798
809
  });
810
+ expect(account.getOwnAddresses).not.toHaveBeenCalled();
799
811
  verifyLogEventVerificationError({
800
812
  field: 'nodeContentKey',
801
813
  error: 'verification error',
802
814
  });
803
815
  });
804
816
 
817
+ it('on content key packet with skipped fallback verification for non-own volume', async () => {
818
+ driveCrypto.decryptAndVerifySessionKey = jest.fn(
819
+ async () =>
820
+ Promise.resolve({
821
+ sessionKey: 'contentKeyPacketSessionKey',
822
+ verified: VERIFICATION_STATUS.SIGNED_AND_INVALID,
823
+ verificationErrors: [new Error('verification error')],
824
+ }) as any,
825
+ );
826
+
827
+ const result = await cryptoService.decryptNode(
828
+ {
829
+ ...encryptedNode,
830
+ uid: 'otherVolumeId~nodeId',
831
+ creationTime: new Date('2022-01-01'),
832
+ },
833
+ parentKey,
834
+ );
835
+
836
+ verifyResult(result, {
837
+ keyAuthor: {
838
+ ok: false,
839
+ error: {
840
+ claimedAuthor: 'signatureEmail',
841
+ error: 'Signature verification for content key failed: verification error',
842
+ },
843
+ },
844
+ });
845
+ expect(account.getOwnAddresses).not.toHaveBeenCalled();
846
+ verifyLogEventVerificationError({
847
+ field: 'nodeContentKey',
848
+ error: 'verification error',
849
+ uid: 'otherVolumeId~nodeId',
850
+ fromBefore2024: true,
851
+ });
852
+ });
853
+
805
854
  it('on content key packet with successful fallback verification', async () => {
806
855
  driveCrypto.decryptAndVerifySessionKey = jest
807
856
  .fn()
@@ -829,6 +878,7 @@ describe('nodesCryptoService', () => {
829
878
  parentKey,
830
879
  );
831
880
  verifyResult(result);
881
+ expect(account.getOwnAddresses).toHaveBeenCalled();
832
882
  expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledTimes(2);
833
883
  expect(driveCrypto.decryptAndVerifySessionKey).toHaveBeenCalledWith(
834
884
  'base64ContentKeyPacket',
@@ -33,7 +33,9 @@ import {
33
33
  DecryptedUnparsedRevision,
34
34
  NodeSigningKeys,
35
35
  EncryptedNodeFileCrypto,
36
+ SharesService,
36
37
  } from './interface';
38
+ import { splitNodeUid } from '../uids';
37
39
 
38
40
  export interface NodesCryptoReporter {
39
41
  handleClaimedAuthor(
@@ -76,6 +78,7 @@ export class NodesCryptoService {
76
78
  telemetry: ProtonDriveTelemetry,
77
79
  protected driveCrypto: DriveCrypto,
78
80
  private account: ProtonDriveAccount,
81
+ private sharesService: Pick<SharesService, 'getRootIDs'>,
79
82
  private reporter: NodesCryptoReporter,
80
83
  ) {
81
84
  this.logger = telemetry.getLogger('nodes-crypto');
@@ -538,6 +541,15 @@ export class NodesCryptoService {
538
541
  return result;
539
542
  }
540
543
 
544
+ const { volumeId: ownVolumeId } = await this.sharesService.getRootIDs();
545
+ const { volumeId: nodesVolumeId } = splitNodeUid(node.uid);
546
+
547
+ // If the node is not in the own volume, skip the fallback verification,
548
+ // because it is not possible to load all owners' address keys.
549
+ if (ownVolumeId !== nodesVolumeId) {
550
+ return result;
551
+ }
552
+
541
553
  const allAddresses = await this.account.getOwnAddresses();
542
554
  const allKeys = allAddresses.flatMap((address) => address.keys.map(({ key }) => key));
543
555
 
@@ -745,7 +757,10 @@ export class NodesCryptoService {
745
757
  };
746
758
  }
747
759
 
748
- async generateNameHashes(parentHashKey: Uint8Array<ArrayBuffer>, names: string[]): Promise<{ name: string; hash: string }[]> {
760
+ async generateNameHashes(
761
+ parentHashKey: Uint8Array<ArrayBuffer>,
762
+ names: string[],
763
+ ): Promise<{ name: string; hash: string }[]> {
749
764
  return Promise.all(
750
765
  names.map(async (name) => ({
751
766
  name,
@@ -43,7 +43,7 @@ export function initNodesModule(
43
43
  const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
44
44
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
45
45
  const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
46
- const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
46
+ const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter);
47
47
  const nodesAccess = new NodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
48
48
  const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
49
49
  const nodesManagement = new NodesManagement(api, cryptoCache, cryptoService, nodesAccess);
@@ -4,9 +4,9 @@ import { NodesCryptoService } from './cryptoService';
4
4
  import { NodesAccess } from './nodesAccess';
5
5
  import { DecryptedNode } from './interface';
6
6
  import { NodesManagement } from './nodesManagement';
7
- import { NodeResult } from '../../interface';
7
+ import { NodeResult, NodeResultWithError } from '../../interface';
8
8
  import { NodeOutOfSyncError } from './errors';
9
- import { ValidationError } from '../../errors';
9
+ import { NodeWithSameNameExistsValidationError, ValidationError } from '../../errors';
10
10
 
11
11
  describe('NodesManagement', () => {
12
12
  let apiService: NodeAPIService;
@@ -263,6 +263,42 @@ describe('NodesManagement', () => {
263
263
  );
264
264
  });
265
265
 
266
+ it('moveNodes yields NodeWithSameNameExistsValidationError in case of duplicate node name', async () => {
267
+ const encryptedCrypto = {
268
+ encryptedName: 'movedArmoredNodeName',
269
+ hash: 'movedHash',
270
+ armoredNodePassphrase: 'movedArmoredNodePassphrase',
271
+ armoredNodePassphraseSignature: 'movedArmoredNodePassphraseSignature',
272
+ signatureEmail: 'movedSignatureEmail',
273
+ nameSignatureEmail: 'movedNameSignatureEmail',
274
+ };
275
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockResolvedValue(encryptedCrypto);
276
+ const error = new NodeWithSameNameExistsValidationError('Node with same name exists', 2500, 'existingNodeUid');
277
+ apiService.moveNode = jest.fn().mockRejectedValue(error);
278
+
279
+ const results: NodeResultWithError[] = [];
280
+ for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
281
+ results.push(result);
282
+ }
283
+
284
+ expect(results).toHaveLength(1);
285
+ expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
286
+ expect(results[0].ok === false && results[0].error).toBeInstanceOf(NodeWithSameNameExistsValidationError);
287
+ });
288
+
289
+ it('moveNodes yields NodeResultWithError with Error on failure', async () => {
290
+ const error = new Error('move failed');
291
+ cryptoService.encryptNodeWithNewParent = jest.fn().mockRejectedValue(error);
292
+
293
+ const results: NodeResultWithError[] = [];
294
+ for await (const result of management.moveNodes(['nodeUid'], 'newParentNodeUid')) {
295
+ results.push(result);
296
+ }
297
+
298
+ expect(results).toHaveLength(1);
299
+ expect(results[0]).toEqual({ uid: 'nodeUid', ok: false, error });
300
+ });
301
+
266
302
  it('copyNode manages copy and updates cache', async () => {
267
303
  const encryptedCrypto = {
268
304
  encryptedName: 'copiedArmoredNodeName',
@@ -1,6 +1,14 @@
1
1
  import { c } from 'ttag';
2
2
 
3
- import { MemberRole, NodeType, NodeResult, NodeResultWithNewUid, resultOk, InvalidNameError } from '../../interface';
3
+ import {
4
+ MemberRole,
5
+ NodeType,
6
+ NodeResult,
7
+ NodeResultWithNewUid,
8
+ resultOk,
9
+ InvalidNameError,
10
+ NodeResultWithError,
11
+ } from '../../interface';
4
12
  import { AbortError, ValidationError } from '../../errors';
5
13
  import { createErrorFromUnknown, getErrorMessage } from '../errors';
6
14
  import { splitNodeUid } from '../uids';
@@ -107,7 +115,11 @@ export abstract class NodesManagementBase<
107
115
  }
108
116
 
109
117
  // Improvement requested: move nodes in parallel
110
- async *moveNodes(nodeUids: string[], newParentNodeUid: string, signal?: AbortSignal): AsyncGenerator<NodeResult> {
118
+ async *moveNodes(
119
+ nodeUids: string[],
120
+ newParentNodeUid: string,
121
+ signal?: AbortSignal,
122
+ ): AsyncGenerator<NodeResultWithError> {
111
123
  for (const nodeUid of nodeUids) {
112
124
  if (signal?.aborted) {
113
125
  throw new AbortError(c('Error').t`Move operation aborted`);
@@ -122,7 +134,7 @@ export abstract class NodesManagementBase<
122
134
  yield {
123
135
  uid: nodeUid,
124
136
  ok: false,
125
- error: getErrorMessage(error),
137
+ error: error instanceof Error ? error : new Error(getErrorMessage(error), { cause: error }),
126
138
  };
127
139
  }
128
140
  }
@@ -123,7 +123,7 @@ export function initPhotosNodesModule(
123
123
  const cache = new PhotosNodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
124
124
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
125
125
  const cryptoReporter = new NodesCryptoReporter(telemetry, sharesService);
126
- const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
126
+ const cryptoService = new NodesCryptoService(telemetry, driveCrypto, account, sharesService, cryptoReporter);
127
127
  const nodesAccess = new PhotosNodesAccess(telemetry, api, cache, cryptoCache, cryptoService, sharesService);
128
128
  const nodesEventHandler = new NodesEventsHandler(telemetry.getLogger('nodes-events'), cache);
129
129
  const nodesManagement = new PhotosNodesManagement(api, cryptoCache, cryptoService, nodesAccess);
@@ -206,6 +206,61 @@ describe('PhotosNodesCache', () => {
206
206
  });
207
207
 
208
208
  describe('PhotosNodesAccess', () => {
209
+ describe('getParentKeys', () => {
210
+ let access: PhotosNodesAccess;
211
+ let getNodeKeysMock: jest.Mock;
212
+ let getSharePrivateKeyMock: jest.Mock;
213
+
214
+ beforeEach(() => {
215
+ getNodeKeysMock = jest.fn().mockResolvedValue({ key: 'key', hashKey: 'hashKey' });
216
+ getSharePrivateKeyMock = jest.fn().mockResolvedValue('shareKey');
217
+ access = new PhotosNodesAccess(
218
+ getMockTelemetry(),
219
+ // @ts-expect-error No need to implement for this test
220
+ {},
221
+ {},
222
+ { getNodeKeys: jest.fn().mockRejectedValue(new Error()) },
223
+ {},
224
+ { getSharePrivateKey: getSharePrivateKeyMock },
225
+ );
226
+ jest.spyOn(access, 'getNodeKeys').mockImplementation(getNodeKeysMock);
227
+ });
228
+
229
+ it('should use parentUid path when set, ignoring shareId', async () => {
230
+ await access.getParentKeys({
231
+ uid: 'v~node',
232
+ parentUid: 'v~parent',
233
+ shareId: 'publicLinkShareId',
234
+ photo: undefined,
235
+ });
236
+ expect(getNodeKeysMock).toHaveBeenCalledWith('v~parent');
237
+ expect(getSharePrivateKeyMock).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('should use album key when no parentUid but has albums, even when shareId is set', async () => {
241
+ await access.getParentKeys({
242
+ uid: 'v~node',
243
+ parentUid: undefined,
244
+ shareId: 'publicLinkShareId',
245
+ // @ts-expect-error No need to implement for this test
246
+ photo: { albums: [{ nodeUid: 'v~album' }] },
247
+ });
248
+ expect(getNodeKeysMock).toHaveBeenCalledWith('v~album');
249
+ expect(getSharePrivateKeyMock).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('should fall back to shareId when no parentUid and no albums', async () => {
253
+ await access.getParentKeys({
254
+ uid: 'v~node',
255
+ parentUid: undefined,
256
+ shareId: 'rootShareId',
257
+ // @ts-expect-error No need to implement for this test
258
+ photo: { albums: [] },
259
+ });
260
+ expect(getSharePrivateKeyMock).toHaveBeenCalledWith('rootShareId');
261
+ });
262
+ });
263
+
209
264
  describe('parseNode', () => {
210
265
  it('should keep photo type and add photo object', async () => {
211
266
  const telemetry = getMockTelemetry();
@@ -222,7 +277,14 @@ describe('PhotosNodesAccess', () => {
222
277
  const sharesService: SharesService = {};
223
278
 
224
279
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
- const nodesAccess = new PhotosNodesAccess(telemetry, apiService, cacheService, cryptoCache, cryptoService, sharesService);
280
+ const nodesAccess = new PhotosNodesAccess(
281
+ telemetry,
282
+ apiService,
283
+ cacheService,
284
+ cryptoCache,
285
+ cryptoService,
286
+ sharesService,
287
+ );
226
288
 
227
289
  const unparsedNode = {
228
290
  uid: 'volumeId~linkId',
@@ -151,7 +151,18 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
151
151
  async getParentKeys(
152
152
  node: Pick<EncryptedPhotoNode, 'uid' | 'parentUid' | 'shareId' | 'photo'>,
153
153
  ): Promise<Pick<DecryptedNodeKeys, 'key' | 'hashKey'>> {
154
- if (node.parentUid || node.shareId) {
154
+ // In regular case, the parent should be used first as it is guaranteed that
155
+ // the root node without parent will have a share with direct membership for
156
+ // the user that can be used to decrypt the node.
157
+ // For photos, the parent might be missing but then an album (or more) plays
158
+ // the role of the parent. It must be used first before fallbacking to share
159
+ // because the node might be shared but user is not directly invited and thus
160
+ // cannot decrypt via the share (user's address cannot decrypt).
161
+ // Using parent path first should stay as if present, it will be fastest way
162
+ // to decrypt for the owner - all photos in the timeline can use already
163
+ // cached key without the need to load albums as well.
164
+
165
+ if (node.parentUid) {
155
166
  return super.getParentKeys(node);
156
167
  }
157
168
 
@@ -176,6 +187,10 @@ export class PhotosNodesAccess extends NodesAccessBase<EncryptedPhotoNode, Decry
176
187
  return this.getNodeKeys(albumNodeUid);
177
188
  }
178
189
 
190
+ if (node.shareId) {
191
+ return super.getParentKeys(node);
192
+ }
193
+
179
194
  // This is bug that should not happen.
180
195
  // API cannot provide node without parent or share or album.
181
196
  throw new Error(`Node has neither parent node nor share nor album: ${node.uid}`);
@@ -433,6 +433,7 @@ export class SharingAPIService {
433
433
  shareId: string,
434
434
  invitation: EncryptedInvitationRequest,
435
435
  emailDetails: { message?: string; nodeName?: string } = {},
436
+ externalInvitationId: string | null = null,
436
437
  ): Promise<EncryptedInvitation> {
437
438
  const response = await this.apiService.post<PostInviteProtonUserRequest, PostInviteProtonUserResponse>(
438
439
  `drive/v2/shares/${shareId}/invitations`,
@@ -443,7 +444,7 @@ export class SharingAPIService {
443
444
  Permissions: memberRoleToPermission(invitation.role),
444
445
  KeyPacket: invitation.base64KeyPacket,
445
446
  KeyPacketSignature: invitation.base64KeyPacketSignature,
446
- ExternalInvitationID: null,
447
+ ExternalInvitationID: externalInvitationId,
447
448
  },
448
449
  EmailDetails: {
449
450
  Message: emailDetails.message,
@@ -182,11 +182,12 @@ export class SharingCryptoService {
182
182
  shareSessionKey: SessionKey,
183
183
  inviterKey: PrivateKey,
184
184
  inviteeEmail: string,
185
+ forceRefreshKeys?: boolean,
185
186
  ): Promise<{
186
187
  base64KeyPacket: string;
187
188
  base64KeyPacketSignature: string;
188
189
  }> {
189
- const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail);
190
+ const inviteePublicKeys = await this.account.getPublicKeys(inviteeEmail, forceRefreshKeys);
190
191
  const result = await this.driveCrypto.encryptInvitation(shareSessionKey, inviteePublicKeys[0], inviterKey);
191
192
  return result;
192
193
  }
@@ -1138,4 +1138,77 @@ describe('SharingManagement', () => {
1138
1138
  expect(apiService.resendExternalInvitationEmail).not.toHaveBeenCalled();
1139
1139
  });
1140
1140
  });
1141
+
1142
+ describe('convertNonProtonInvitation', () => {
1143
+ const nodeUid = 'volumeId~nodeId';
1144
+ const externalInvitationId = 'inv123';
1145
+ const externalInvitationUid = `${DEFAULT_SHARE_ID}~${externalInvitationId}`;
1146
+ const externalInvitation: NonProtonInvitation = {
1147
+ uid: externalInvitationUid,
1148
+ inviteeEmail: 'external@example.com',
1149
+ addedByEmail: resultOk('inviter@example.com'),
1150
+ role: MemberRole.Viewer,
1151
+ invitationTime: new Date(),
1152
+ state: NonProtonInvitationState.Pending,
1153
+ };
1154
+
1155
+ beforeEach(() => {
1156
+ nodesService.getNode = jest.fn().mockResolvedValue({
1157
+ nodeUid,
1158
+ shareId: DEFAULT_SHARE_ID,
1159
+ directRole: MemberRole.Admin,
1160
+ name: { ok: true, value: 'name' },
1161
+ });
1162
+ apiService.getShareExternalInvitations = jest.fn().mockResolvedValue([externalInvitation]);
1163
+ });
1164
+
1165
+ it('should throw if caller is not admin', async () => {
1166
+ nodesService.getNode = jest.fn().mockResolvedValue({
1167
+ nodeUid,
1168
+ shareId: DEFAULT_SHARE_ID,
1169
+ directRole: MemberRole.Viewer,
1170
+ name: { ok: true, value: 'name' },
1171
+ });
1172
+
1173
+ await expect(
1174
+ sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
1175
+ ).rejects.toThrow(ValidationError);
1176
+ });
1177
+
1178
+ it('should throw if no sharing info found', async () => {
1179
+ nodesService.getNode = jest.fn().mockResolvedValue({
1180
+ nodeUid,
1181
+ shareId: undefined,
1182
+ directRole: MemberRole.Admin,
1183
+ name: { ok: true, value: 'name' },
1184
+ });
1185
+
1186
+ await expect(
1187
+ sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid),
1188
+ ).rejects.toThrow(ValidationError);
1189
+ });
1190
+
1191
+ it('should throw if external invitation ID is not found', async () => {
1192
+ await expect(
1193
+ sharingManagement.convertNonProtonInvitation(nodeUid, 'unknownShareId~unknownInvId'),
1194
+ ).rejects.toThrow(ValidationError);
1195
+ });
1196
+
1197
+ it('should invite proton user with force-refreshed keys and the external invitation ID', async () => {
1198
+ await sharingManagement.convertNonProtonInvitation(nodeUid, externalInvitationUid);
1199
+
1200
+ expect(cryptoService.encryptInvitation).toHaveBeenCalledWith(
1201
+ expect.anything(),
1202
+ expect.anything(),
1203
+ externalInvitation.inviteeEmail,
1204
+ true,
1205
+ );
1206
+ expect(apiService.inviteProtonUser).toHaveBeenCalledWith(
1207
+ DEFAULT_SHARE_ID,
1208
+ expect.objectContaining({ inviteeEmail: externalInvitation.inviteeEmail, role: externalInvitation.role }),
1209
+ {},
1210
+ externalInvitationId,
1211
+ );
1212
+ });
1213
+ });
1141
1214
  });
@@ -16,7 +16,7 @@ import {
16
16
  SharePublicLinkSettingsObject,
17
17
  } from '../../interface';
18
18
  import { ErrorCode } from '../apiService';
19
- import { splitNodeUid } from '../uids';
19
+ import { splitNodeUid, splitInvitationUid } from '../uids';
20
20
  import { getErrorMessage } from '../errors';
21
21
  import { SharingAPIService } from './apiService';
22
22
  import { PUBLIC_LINK_GENERATED_PASSWORD_LENGTH, SharingCryptoService } from './cryptoService';
@@ -596,8 +596,52 @@ export class SharingManagement {
596
596
  await this.apiService.deleteExternalInvitation(invitationUid);
597
597
  }
598
598
 
599
- private async convertExternalInvitationsToInternal(): Promise<void> {
600
- // FIXME
599
+ async convertNonProtonInvitation(nodeUid: string, nonProtonInvitationUid: string): Promise<ProtonInvitation> {
600
+ const { invitationId: externalInvitationId } = splitInvitationUid(nonProtonInvitationUid);
601
+
602
+ const node = await this.nodesService.getNode(nodeUid);
603
+ if (node.directRole !== MemberRole.Admin) {
604
+ throw new ValidationError(c('Error').t`Only admins can convert non-Proton invitations`);
605
+ }
606
+
607
+ const [currentSharing, inviter] = await Promise.all([
608
+ this.getInternalSharingInfo(nodeUid),
609
+ this.nodesService.getRootNodeEmailKey(nodeUid),
610
+ ]);
611
+ if (!currentSharing) {
612
+ throw new ValidationError(c('Error').t`The node is not shared anymore`);
613
+ }
614
+
615
+ const externalInvitation = currentSharing.nonProtonInvitations.find(
616
+ (invitation) => invitation.uid === nonProtonInvitationUid,
617
+ );
618
+ if (!externalInvitation) {
619
+ throw new ValidationError(c('Error').t`Invitation not found`);
620
+ }
621
+ this.logger.info(
622
+ `Converting non-Proton invitation for ${externalInvitation.inviteeEmail} to internal for node ${nodeUid}`,
623
+ );
624
+ const invitationCrypto = await this.cryptoService.encryptInvitation(
625
+ currentSharing.share.passphraseSessionKey,
626
+ inviter.addressKey,
627
+ externalInvitation.inviteeEmail,
628
+ true, // Force refresh keys: the invitee just created a Proton account, so we have "absent" keys in cache
629
+ );
630
+ const encryptedInvitation = await this.apiService.inviteProtonUser(
631
+ currentSharing.share.shareId,
632
+ {
633
+ addedByEmail: inviter.email,
634
+ inviteeEmail: externalInvitation.inviteeEmail,
635
+ role: externalInvitation.role,
636
+ ...invitationCrypto,
637
+ },
638
+ {},
639
+ externalInvitationId,
640
+ );
641
+ return {
642
+ ...encryptedInvitation,
643
+ addedByEmail: resultOk(encryptedInvitation.addedByEmail),
644
+ };
601
645
  }
602
646
 
603
647
  private async removeMember(memberUid: string): Promise<void> {
@@ -100,7 +100,13 @@ export function initSharingPublicNodesModule(
100
100
  const cache = new NodesCache(telemetry.getLogger('nodes-cache'), driveEntitiesCache);
101
101
  const cryptoCache = new NodesCryptoCache(telemetry.getLogger('nodes-cache'), driveCryptoCache);
102
102
  const cryptoReporter = new SharingPublicCryptoReporter(telemetry);
103
- const cryptoService = new SharingPublicNodesCryptoService(telemetry, driveCrypto, account, cryptoReporter);
103
+ const cryptoService = new SharingPublicNodesCryptoService(
104
+ telemetry,
105
+ driveCrypto,
106
+ account,
107
+ sharesService,
108
+ cryptoReporter,
109
+ );
104
110
  const nodesAccess = new SharingPublicNodesAccess(
105
111
  telemetry,
106
112
  api,
@@ -8,11 +8,13 @@ import {
8
8
  MaybeNode,
9
9
  MaybeMissingNode,
10
10
  NodeResult,
11
+ NodeResultWithError,
11
12
  NodeResultWithNewUid,
12
13
  Revision,
13
14
  RevisionOrUid,
14
15
  ShareNodeSettings,
15
16
  UnshareNodeSettings,
17
+ ProtonInvitation,
16
18
  ProtonInvitationOrUid,
17
19
  NonProtonInvitationOrUid,
18
20
  ProtonInvitationWithNode,
@@ -420,7 +422,7 @@ export class ProtonDriveClient {
420
422
  nodeUids: NodeOrUid[],
421
423
  newParentNodeUid: NodeOrUid,
422
424
  signal?: AbortSignal,
423
- ): AsyncGenerator<NodeResult> {
425
+ ): AsyncGenerator<NodeResultWithError> {
424
426
  this.logger.info(`Moving ${nodeUids.length} nodes to ${getUid(newParentNodeUid)}`);
425
427
  yield* this.nodes.management.moveNodes(getUids(nodeUids), getUid(newParentNodeUid), signal);
426
428
  }
@@ -749,6 +751,22 @@ export class ProtonDriveClient {
749
751
  return this.sharing.management.unshareNode(getUid(nodeUid), settings);
750
752
  }
751
753
 
754
+ /**
755
+ * Convert a non-Proton invitation to an internal invitation.
756
+ * This is called automatically in the background when the SDK receives
757
+ * a metadata update event, but can also be triggered manually.
758
+ *
759
+ * @param nodeUid - Node entity or its UID string.
760
+ * @param invitationOrUid - Non-Proton invitation entity or its UID string.
761
+ */
762
+ async convertNonProtonInvitation(
763
+ nodeUid: NodeOrUid,
764
+ invitationOrUid: NonProtonInvitationOrUid,
765
+ ): Promise<ProtonInvitation> {
766
+ this.logger.info(`Converting non-Proton invitation ${getUid(invitationOrUid)} for node ${getUid(nodeUid)}`);
767
+ return this.sharing.management.convertNonProtonInvitation(getUid(nodeUid), getUid(invitationOrUid));
768
+ }
769
+
752
770
  /**
753
771
  * Resend the invitation email to shared node.
754
772
  *