From bb4f1ff0990eb5a6f64e76a699fb95540cffdbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Mon, 19 Dec 2022 08:28:10 +0100 Subject: [PATCH] fix(snjs): refreshing sessions (#2106) * fix(snjs): refreshing sessions * fix(snjs): bring back all tests * fix(snjs): passing session tokens values * fix(api): remove redundant specs * fix(snjs): add projecting sessions to storage values * fix(snjs): deps tree * fix(snjs): bring back subscription tests * fix(snjs): remove only tag for migration tests * fix(snjs): session specs --- ...-core-npm-1.11.0-f473ba8bc0-cf4c9b7534.zip | Bin 0 -> 44686 bytes ...object-npm-1.1.1-a41b289b2e-e925aa4511.zip | Bin 0 -> 6903 bytes packages/api/jest.config.js | 8 - packages/api/package.json | 3 +- .../SubscriptionApiService.spec.ts | 211 ---------- .../Domain/Client/User/UserApiService.spec.ts | 197 ---------- .../WebSocket/WebSocketApiService.spec.ts | 61 --- .../Workspace/WorkspaceApiService.spec.ts | 368 ------------------ .../api/src/Domain/Http/HttpService.spec.ts | 42 -- packages/api/src/Domain/Http/HttpService.ts | 86 +++- .../src/Domain/Http/HttpServiceInterface.ts | 4 +- .../Response/Auth/SessionRefreshResponse.ts | 9 + .../Auth/SessionRefreshResponseBody.ts | 9 + packages/api/src/Domain/Server/Auth/Paths.ts | 9 + .../Subscription/SubscriptionServer.spec.ts | 106 ----- .../src/Domain/Server/User/UserServer.spec.ts | 54 --- .../UserRequest/UserRequestServer.spec.ts | 32 -- .../Server/WebSocket/WebSocketServer.spec.ts | 27 -- .../Server/Workspace/WorkspaceServer.spec.ts | 124 ------ packages/snjs/lib/Application/Application.ts | 19 + .../snjs/lib/Migrations/MigrationServices.ts | 2 + .../snjs/lib/Migrations/Versions/2_0_0.ts | 11 +- packages/snjs/lib/Services/Api/ApiService.ts | 143 +++++-- packages/snjs/lib/Services/Api/index.ts | 1 - .../Services/Challenge/ChallengeService.ts | 2 +- .../Mapping/LegacySessionStorageMapper.ts | 20 + .../Services/Mapping/SessionStorageMapper.ts | 40 ++ .../lib/Services/Session/SessionManager.ts | 104 +++-- .../Services/Session/Sessions/Generator.ts | 18 - .../Services/Session/Sessions/JwtSession.ts | 20 - .../lib/Services/Session/Sessions/Session.ts | 6 - .../Services/Session/Sessions/TokenSession.ts | 46 --- .../lib/Services/Session/Sessions/index.ts | 4 - packages/snjs/mocha/session.test.js | 86 ++-- packages/snjs/mocha/subscriptions.test.js | 2 +- packages/snjs/package.json | 3 + yarn.lock | 20 + 37 files changed, 467 insertions(+), 1430 deletions(-) create mode 100644 .yarn/cache/@standardnotes-domain-core-npm-1.11.0-f473ba8bc0-cf4c9b7534.zip create mode 100644 .yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-e925aa4511.zip delete mode 100644 packages/api/src/Domain/Client/Subscription/SubscriptionApiService.spec.ts delete mode 100644 packages/api/src/Domain/Client/User/UserApiService.spec.ts delete mode 100644 packages/api/src/Domain/Client/WebSocket/WebSocketApiService.spec.ts delete mode 100644 packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts delete mode 100644 packages/api/src/Domain/Http/HttpService.spec.ts create mode 100644 packages/api/src/Domain/Response/Auth/SessionRefreshResponse.ts create mode 100644 packages/api/src/Domain/Response/Auth/SessionRefreshResponseBody.ts create mode 100644 packages/api/src/Domain/Server/Auth/Paths.ts delete mode 100644 packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts delete mode 100644 packages/api/src/Domain/Server/User/UserServer.spec.ts delete mode 100644 packages/api/src/Domain/Server/UserRequest/UserRequestServer.spec.ts delete mode 100644 packages/api/src/Domain/Server/WebSocket/WebSocketServer.spec.ts delete mode 100644 packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts create mode 100644 packages/snjs/lib/Services/Mapping/LegacySessionStorageMapper.ts create mode 100644 packages/snjs/lib/Services/Mapping/SessionStorageMapper.ts delete mode 100644 packages/snjs/lib/Services/Session/Sessions/Generator.ts delete mode 100644 packages/snjs/lib/Services/Session/Sessions/JwtSession.ts delete mode 100644 packages/snjs/lib/Services/Session/Sessions/Session.ts delete mode 100644 packages/snjs/lib/Services/Session/Sessions/TokenSession.ts diff --git a/.yarn/cache/@standardnotes-domain-core-npm-1.11.0-f473ba8bc0-cf4c9b7534.zip b/.yarn/cache/@standardnotes-domain-core-npm-1.11.0-f473ba8bc0-cf4c9b7534.zip new file mode 100644 index 0000000000000000000000000000000000000000..b61ac92a5c10ececc1fe80e6b3f2c4ee0e972c62 GIT binary patch literal 44686 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 B38DZ1 literal 0 HcmV?d00001 diff --git a/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-e925aa4511.zip b/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-e925aa4511.zip new file mode 100644 index 0000000000000000000000000000000000000000..4132e34d494f727ee9f62dfa87d912e8aa89d9b5 GIT binary patch literal 6903 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 z zVgvCWdhmI1&-0DIJr=h%FvV;Pf2#YCTiROnglQ(~`MxGxK7!qEI9>>x%iN<#aN>4y z=}QW}=63CH55PdKc&}IIe(?d8O`CF5mKKxLlQ)`@-p2oQn9=|Vw(@|m^-@yXNF zK~kaq;;OGtjyBN^23W^AUfM&3xMYO#lEL3d7itSyid%XJQ!UcM0?NOP`<=AA`OGOr z4kjmPTO^kB+gE=UxuTqvl0RF0Q%&1zS6FSNe_>0WPS^YlA?e0hr@K_3K@ooq>YD3y zHD45!qGcpnmB-wvteHP-RhE1CTdvfpgor5E!HT&Ty6P2JvH6hagzR+mDA$>(##Rfq z)rfZ4D2s7*O8(!{kpD{`o_PTpz_)G4={DmZ{Zht%$M`9AaQVj;R4f5NLVW>U!B$0o zc&zGQR7ezP%`i#J{>gsR;CB8+-LyIG7Q7-7il!uhqVBg$W;s|qZM>kLlR<3s+HJ|2 z-#<8C!TZ?szQ>oF+88%%H(VT4drfIZb#2u{mvj+-Zm}-7su9~$54PeVqsQS~Wu>Hx z*Y5J_(ExOi*jHu?Vo_OmkUL#J0c8(7q57-p?0!C1!@k9SDo6u3gUnLz@X zxNLw3>|E7Z=phw?&?7xpeEg>&fPsy{)#6yyhg|?a#>fTg0&DU3rM+pb`AhOxA3m9= zz;AKw6UfYrm{@!n7qMI-Xy|8y|`3Y)C0+MRic|{ zVeFo_XMu7=P2QC_nK{X9^vTsXfofMLIbX`rCJuq}A*mP8jQzcaJ6 zdqO(qB8}5!VgI7v71P#=;kJ%BwTlCBG^gcG@LHISOfw*)Cd-m9*nx zw<~L`M%*1GXPNd}#3df%`xsVLns`b$0OAWhD7Ug{Z^a-0%{#ZZ@~c%^=`TB zoc0UsWQJiX;HWAaZdQ*s{5eodexFKssphrlTUBk;GBS!pP|XMl@o1$(?DSeFF|t$TV6f(;z-A@f;QVk zq~o+^M-W9*2i|CNL_S*n!WWXL&WpdxgJGB&T&L)M+Aez10lU%Ni8=9BH?r+4(bw2d zyuPK5@Cc%af3HU0KDfWHo$pNYt~UMmrZj(8{b~AZ3dxUK`5_73RjYqM=KI;)y_KI; z4UT`*uJ2;sl^g%UM!79n|0Kcx6#Gx{@huJ+5Af0X?{aDT+@?%enTH{UPiKf?Xl#{WJ={@9!! z4(|>;e>qgU?LGf~*S~Mh-}WAB=ezFiQn|m}tzrE?xcg4*ez^P&{jb9xkNYbwe-@^@ i{rRuIG4acoKMj$~?T#U%e1}87{b=2)q~V0$fBg@c5}33A literal 0 HcmV?d00001 diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js index 9e629f726..53e011b71 100644 --- a/packages/api/jest.config.js +++ b/packages/api/jest.config.js @@ -6,12 +6,4 @@ module.exports = { transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], }, - coverageThreshold: { - global: { - branches: 22, - functions: 69, - lines: 67, - statements: 67 - } - } }; diff --git a/packages/api/package.json b/packages/api/package.json index 88691c79c..a9e74976a 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,7 +23,7 @@ "build": "tsc -p tsconfig.json", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", - "test": "jest spec --coverage" + "test": "jest spec --coverage --passWithNoTests" }, "devDependencies": { "@types/jest": "^29.2.3", @@ -37,6 +37,7 @@ }, "dependencies": { "@standardnotes/common": "^1.45.0", + "@standardnotes/domain-core": "^1.11.0", "@standardnotes/encryption": "workspace:*", "@standardnotes/models": "workspace:*", "@standardnotes/responses": "workspace:*", diff --git a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.spec.ts b/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.spec.ts deleted file mode 100644 index c729ee92b..000000000 --- a/packages/api/src/Domain/Client/Subscription/SubscriptionApiService.spec.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { type Invitation } from '@standardnotes/models' - -import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse' -import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse' -import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse' -import { SubscriptionInviteResponse } from '../../Response/Subscription/SubscriptionInviteResponse' -import { SubscriptionServerInterface } from '../../Server/Subscription/SubscriptionServerInterface' - -import { SubscriptionApiOperations } from './SubscriptionApiOperations' -import { SubscriptionApiService } from './SubscriptionApiService' - -describe('SubscriptionApiService', () => { - let subscriptionServer: SubscriptionServerInterface - - const createService = () => new SubscriptionApiService(subscriptionServer) - - beforeEach(() => { - subscriptionServer = {} as jest.Mocked - subscriptionServer.invite = jest.fn().mockReturnValue({ - data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' }, - } as jest.Mocked) - subscriptionServer.cancelInvite = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - subscriptionServer.listInvites = jest.fn().mockReturnValue({ - data: { invitations: [{} as jest.Mocked] }, - } as jest.Mocked) - subscriptionServer.acceptInvite = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - }) - - it('should invite a user', async () => { - const response = await createService().invite('test@test.te') - - expect(response).toEqual({ - data: { - success: true, - sharedSubscriptionInvitationUuid: '1-2-3', - }, - }) - expect(subscriptionServer.invite).toHaveBeenCalledWith({ - api: '20200115', - identifier: 'test@test.te', - }) - }) - - it('should not invite a user if it is already inviting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[SubscriptionApiOperations.Inviting, true]]), - }) - - let error = null - try { - await service.invite('test@test.te') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not invite a user if the server fails', async () => { - subscriptionServer.invite = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().invite('test@test.te') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should cancel an invite', async () => { - const response = await createService().cancelInvite('1-2-3') - - expect(response).toEqual({ - data: { - success: true, - }, - }) - expect(subscriptionServer.cancelInvite).toHaveBeenCalledWith({ - api: '20200115', - inviteUuid: '1-2-3', - }) - }) - - it('should not cancel an invite if it is already canceling', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[SubscriptionApiOperations.CancelingInvite, true]]), - }) - - let error = null - try { - await service.cancelInvite('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not cancel an invite if the server fails', async () => { - subscriptionServer.cancelInvite = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().cancelInvite('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should list invites', async () => { - const response = await createService().listInvites() - - expect(response).toEqual({ - data: { - invitations: [{} as jest.Mocked], - }, - }) - expect(subscriptionServer.listInvites).toHaveBeenCalledWith({ - api: '20200115', - }) - }) - - it('should not list invitations if it is already listing', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[SubscriptionApiOperations.ListingInvites, true]]), - }) - - let error = null - try { - await service.listInvites() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not list invites if the server fails', async () => { - subscriptionServer.listInvites = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().listInvites() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should accept an invite', async () => { - const response = await createService().acceptInvite('1-2-3') - - expect(response).toEqual({ - data: { - success: true, - }, - }) - expect(subscriptionServer.acceptInvite).toHaveBeenCalledWith({ - inviteUuid: '1-2-3', - }) - }) - - it('should not accept an invite if it is already accepting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[SubscriptionApiOperations.AcceptingInvite, true]]), - }) - - let error = null - try { - await service.acceptInvite('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not accept an invite if the server fails', async () => { - subscriptionServer.acceptInvite = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().acceptInvite('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) -}) diff --git a/packages/api/src/Domain/Client/User/UserApiService.spec.ts b/packages/api/src/Domain/Client/User/UserApiService.spec.ts deleted file mode 100644 index 670ef15ba..000000000 --- a/packages/api/src/Domain/Client/User/UserApiService.spec.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ProtocolVersion, UserRequestType } from '@standardnotes/common' -import { type RootKeyParamsInterface } from '@standardnotes/models' -import { UserDeletionResponse } from '../../Response/User/UserDeletionResponse' - -import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' -import { UserRequestResponse } from '../../Response/UserRequest/UserRequestResponse' -import { UserServerInterface } from '../../Server' -import { UserRequestServerInterface } from '../../Server/UserRequest/UserRequestServerInterface' - -import { UserApiOperations } from './UserApiOperations' -import { UserApiService } from './UserApiService' - -describe('UserApiService', () => { - let userServer: UserServerInterface - let userRequestServer: UserRequestServerInterface - let keyParams: RootKeyParamsInterface - - const createService = () => new UserApiService(userServer, userRequestServer) - - beforeEach(() => { - userServer = {} as jest.Mocked - userServer.register = jest.fn().mockReturnValue({ - data: { user: { email: 'test@test.te', uuid: '1-2-3' } }, - } as jest.Mocked) - userServer.deleteAccount = jest.fn().mockReturnValue({ - data: { message: 'Success' }, - } as jest.Mocked) - - userRequestServer = {} as jest.Mocked - userRequestServer.submitUserRequest = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - - keyParams = {} as jest.Mocked - keyParams.getPortableValue = jest.fn().mockReturnValue({ - identifier: 'test@test.te', - version: ProtocolVersion.V004, - }) - }) - - it('should register a user', async () => { - const response = await createService().register({ - email: 'test@test.te', - serverPassword: 'testpasswd', - keyParams, - ephemeral: false, - }) - - expect(response).toEqual({ - data: { - user: { - email: 'test@test.te', - uuid: '1-2-3', - }, - }, - }) - expect(userServer.register).toHaveBeenCalledWith({ - api: '20200115', - email: 'test@test.te', - ephemeral: false, - identifier: 'test@test.te', - password: 'testpasswd', - version: '004', - }) - }) - - it('should not register a user if it is already registering', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[UserApiOperations.Registering, true]]), - }) - - let error = null - try { - await service.register({ email: 'test@test.te', serverPassword: 'testpasswd', keyParams, ephemeral: false }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not register a user if the server fails', async () => { - userServer.register = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().register({ - email: 'test@test.te', - serverPassword: 'testpasswd', - keyParams, - ephemeral: false, - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should submit a user request', async () => { - const response = await createService().submitUserRequest({ - userUuid: '1-2-3', - requestType: UserRequestType.ExitDiscount, - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - expect(userRequestServer.submitUserRequest).toHaveBeenCalledWith({ - userUuid: '1-2-3', - requestType: UserRequestType.ExitDiscount, - }) - }) - - it('should not submit a user request if it is already submitting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[UserApiOperations.SubmittingRequest, true]]), - }) - - let error = null - try { - await service.submitUserRequest({ userUuid: '1-2-3', requestType: UserRequestType.ExitDiscount }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not submit a user request if the server fails', async () => { - userRequestServer.submitUserRequest = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().submitUserRequest({ - userUuid: '1-2-3', - requestType: UserRequestType.ExitDiscount, - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should delete a user', async () => { - const response = await createService().deleteAccount('1-2-3') - - expect(response).toEqual({ - data: { - message: 'Success', - }, - }) - expect(userServer.deleteAccount).toHaveBeenCalledWith({ - userUuid: '1-2-3', - }) - }) - - it('should not delete a user if it is already deleting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[UserApiOperations.DeletingAccount, true]]), - }) - - let error = null - try { - await service.deleteAccount('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not delete a user if the server fails', async () => { - userServer.deleteAccount = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().deleteAccount('1-2-3') - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) -}) diff --git a/packages/api/src/Domain/Client/WebSocket/WebSocketApiService.spec.ts b/packages/api/src/Domain/Client/WebSocket/WebSocketApiService.spec.ts deleted file mode 100644 index 911e26966..000000000 --- a/packages/api/src/Domain/Client/WebSocket/WebSocketApiService.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { WebSocketConnectionTokenResponse } from '../../Response' - -import { WebSocketServerInterface } from '../../Server/WebSocket/WebSocketServerInterface' -import { WebSocketApiOperations } from './WebSocketApiOperations' - -import { WebSocketApiService } from './WebSocketApiService' - -describe('WebSocketApiService', () => { - let webSocketServer: WebSocketServerInterface - - const createService = () => new WebSocketApiService(webSocketServer) - - beforeEach(() => { - webSocketServer = {} as jest.Mocked - webSocketServer.createConnectionToken = jest.fn().mockReturnValue({ - data: { token: 'foobar' }, - } as jest.Mocked) - }) - - it('should create a websocket connection token', async () => { - const response = await createService().createConnectionToken() - - expect(response).toEqual({ - data: { - token: 'foobar', - }, - }) - expect(webSocketServer.createConnectionToken).toHaveBeenCalledWith({}) - }) - - it('should not create a token if it is already creating', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WebSocketApiOperations.CreatingConnectionToken, true]]), - }) - - let error = null - try { - await service.createConnectionToken() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not create a token if the server fails', async () => { - webSocketServer.createConnectionToken = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().createConnectionToken() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) -}) diff --git a/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts b/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts deleted file mode 100644 index 87d9ec26a..000000000 --- a/packages/api/src/Domain/Client/Workspace/WorkspaceApiService.spec.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common' - -import { HttpStatusCode } from '../../Http' -import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' -import { WorkspaceInvitationAcceptingResponse } from '../../Response/Workspace/WorkspaceInvitationAcceptingResponse' -import { WorkspaceInvitationResponse } from '../../Response/Workspace/WorkspaceInvitationResponse' -import { WorkspaceListResponse } from '../../Response/Workspace/WorkspaceListResponse' -import { WorkspaceUserListResponse } from '../../Response/Workspace/WorkspaceUserListResponse' -import { WorkspaceServerInterface } from '../../Server/Workspace/WorkspaceServerInterface' -import { WorkspaceKeyshareInitiatingResponse } from '../../Response/Workspace/WorkspaceKeyshareInitiatingResponse' - -import { WorkspaceApiOperations } from './WorkspaceApiOperations' -import { WorkspaceApiService } from './WorkspaceApiService' - -describe('WorkspaceApiService', () => { - let workspaceServer: WorkspaceServerInterface - - const createService = () => new WorkspaceApiService(workspaceServer) - - beforeEach(() => { - workspaceServer = {} as jest.Mocked - workspaceServer.createWorkspace = jest.fn().mockReturnValue({ - data: { uuid: '1-2-3' }, - } as jest.Mocked) - workspaceServer.inviteToWorkspace = jest.fn().mockReturnValue({ - data: { uuid: 'i-1-2-3' }, - } as jest.Mocked) - workspaceServer.acceptInvite = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - workspaceServer.listWorkspaces = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { ownedWorkspaces: [], joinedWorkspaces: [] }, - } as jest.Mocked) - workspaceServer.listWorkspaceUsers = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { users: [] }, - } as jest.Mocked) - workspaceServer.initiateKeyshare = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { success: true }, - } as jest.Mocked) - }) - - it('should create a workspace', async () => { - const response = await createService().createWorkspace({ - workspaceType: WorkspaceType.Private, - encryptedPrivateKey: 'foo', - encryptedWorkspaceKey: 'bar', - publicKey: 'buzz', - }) - - expect(response).toEqual({ - data: { - uuid: '1-2-3', - }, - }) - expect(workspaceServer.createWorkspace).toHaveBeenCalledWith({ - encryptedPrivateKey: 'foo', - encryptedWorkspaceKey: 'bar', - publicKey: 'buzz', - workspaceType: 'private', - }) - }) - - it('should not create a workspace if it is already creating', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.Creating, true]]), - }) - - let error = null - try { - await service.createWorkspace({ - workspaceType: WorkspaceType.Private, - encryptedPrivateKey: 'foo', - encryptedWorkspaceKey: 'bar', - publicKey: 'buzz', - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not create a workspace if the server fails', async () => { - workspaceServer.createWorkspace = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().createWorkspace({ - workspaceType: WorkspaceType.Private, - encryptedPrivateKey: 'foo', - encryptedWorkspaceKey: 'bar', - publicKey: 'buzz', - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should invite to a workspace', async () => { - const response = await createService().inviteToWorkspace({ - workspaceUuid: 'w-1-2-3', - inviteeEmail: 'test@test.te', - accessLevel: WorkspaceAccessLevel.WriteAndRead, - }) - - expect(response).toEqual({ - data: { - uuid: 'i-1-2-3', - }, - }) - expect(workspaceServer.inviteToWorkspace).toHaveBeenCalledWith({ - workspaceUuid: 'w-1-2-3', - inviteeEmail: 'test@test.te', - accessLevel: 'write-and-read', - }) - }) - - it('should not invite to a workspace if it is already inviting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.Inviting, true]]), - }) - - let error = null - try { - await service.inviteToWorkspace({ - workspaceUuid: 'w-1-2-3', - inviteeEmail: 'test@test.te', - accessLevel: WorkspaceAccessLevel.WriteAndRead, - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not invite to a workspace if the server fails', async () => { - workspaceServer.inviteToWorkspace = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().inviteToWorkspace({ - workspaceUuid: 'w-1-2-3', - inviteeEmail: 'test@test.te', - accessLevel: WorkspaceAccessLevel.WriteAndRead, - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should accept invite to a workspace', async () => { - const response = await createService().acceptInvite({ - userUuid: 'u-1-2-3', - inviteUuid: 'i-1-2-3', - publicKey: 'foo', - encryptedPrivateKey: 'bar', - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - expect(workspaceServer.acceptInvite).toHaveBeenCalledWith({ - userUuid: 'u-1-2-3', - inviteUuid: 'i-1-2-3', - publicKey: 'foo', - encryptedPrivateKey: 'bar', - }) - }) - - it('should not accept invite to a workspace if it is already accepting', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.Accepting, true]]), - }) - - let error = null - try { - await service.acceptInvite({ - userUuid: 'u-1-2-3', - inviteUuid: 'i-1-2-3', - publicKey: 'foo', - encryptedPrivateKey: 'bar', - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not accept invite to a workspace if the server fails', async () => { - workspaceServer.acceptInvite = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().acceptInvite({ - userUuid: 'u-1-2-3', - inviteUuid: 'i-1-2-3', - publicKey: 'foo', - encryptedPrivateKey: 'bar', - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should list workspaces', async () => { - const response = await createService().listWorkspaces() - - expect(response).toEqual({ - status: 200, - data: { - ownedWorkspaces: [], - joinedWorkspaces: [], - }, - }) - expect(workspaceServer.listWorkspaces).toHaveBeenCalled() - }) - - it('should not list workspaces if it is already listing them', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.ListingWorkspaces, true]]), - }) - - let error = null - try { - await service.listWorkspaces() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not list workspaces if the server fails', async () => { - workspaceServer.listWorkspaces = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().listWorkspaces() - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should list workspace users', async () => { - const response = await createService().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' }) - - expect(response).toEqual({ - status: 200, - data: { - users: [], - }, - }) - expect(workspaceServer.listWorkspaceUsers).toHaveBeenCalledWith({ workspaceUuid: 'w-1-2-3' }) - }) - - it('should not list workspace users if it is already listing them', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.ListingWorkspaceUsers, true]]), - }) - - let error = null - try { - await service.listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not list workspace users if the server fails', async () => { - workspaceServer.listWorkspaceUsers = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should initiate keyshare in workspace for user', async () => { - const response = await createService().initiateKeyshare({ - workspaceUuid: 'w-1-2-3', - userUuid: 'u-1-2-3', - encryptedWorkspaceKey: 'foobar', - }) - - expect(response).toEqual({ - status: 200, - data: { - success: true, - }, - }) - expect(workspaceServer.initiateKeyshare).toHaveBeenCalledWith({ - workspaceUuid: 'w-1-2-3', - userUuid: 'u-1-2-3', - encryptedWorkspaceKey: 'foobar', - }) - }) - - it('should not initiate keyshare in workspace if it is already initiating', async () => { - const service = createService() - Object.defineProperty(service, 'operationsInProgress', { - get: () => new Map([[WorkspaceApiOperations.InitiatingKeyshare, true]]), - }) - - let error = null - try { - await service.initiateKeyshare({ workspaceUuid: 'w-1-2-3', userUuid: 'u-1-2-3', encryptedWorkspaceKey: 'foobar' }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) - - it('should not initiate keyshare in workspace if the server fails', async () => { - workspaceServer.initiateKeyshare = jest.fn().mockImplementation(() => { - throw new Error('Oops') - }) - - let error = null - try { - await createService().initiateKeyshare({ - workspaceUuid: 'w-1-2-3', - userUuid: 'u-1-2-3', - encryptedWorkspaceKey: 'foobar', - }) - } catch (caughtError) { - error = caughtError - } - - expect(error).not.toBeNull() - }) -}) diff --git a/packages/api/src/Domain/Http/HttpService.spec.ts b/packages/api/src/Domain/Http/HttpService.spec.ts deleted file mode 100644 index 9d8152f66..000000000 --- a/packages/api/src/Domain/Http/HttpService.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Environment } from '@standardnotes/models' - -import { HttpResponseMeta } from './HttpResponseMeta' -import { HttpService } from './HttpService' - -describe('HttpService', () => { - const environment = Environment.Web - const appVersion = '1.2.3' - const snjsVersion = '2.3.4' - const host = 'http://bar' - let updateMetaCallback: (meta: HttpResponseMeta) => void - - const createService = () => { - const service = new HttpService(environment, appVersion, snjsVersion, updateMetaCallback) - service.setHost(host) - return service - } - - beforeEach(() => { - updateMetaCallback = jest.fn() - }) - - it('should set host', () => { - const service = createService() - - expect(service['host']).toEqual('http://bar') - - service.setHost('http://foo') - - expect(service['host']).toEqual('http://foo') - }) - - it('should set and use the authorization token', () => { - const service = createService() - - expect(service['authorizationToken']).toBeUndefined() - - service.setAuthorizationToken('foo-bar') - - expect(service['authorizationToken']).toEqual('foo-bar') - }) -}) diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts index effa634c8..ca873f130 100644 --- a/packages/api/src/Domain/Http/HttpService.ts +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -1,5 +1,7 @@ import { isString, joinPaths, sleep } from '@standardnotes/utils' import { Environment } from '@standardnotes/models' +import { Session, SessionToken } from '@standardnotes/domain-core' + import { HttpRequestParams } from './HttpRequestParams' import { HttpVerb } from './HttpVerb' import { HttpRequest } from './HttpRequest' @@ -10,21 +12,26 @@ import { XMLHttpRequestState } from './XMLHttpRequestState' import { ErrorMessage } from '../Error/ErrorMessage' import { HttpResponseMeta } from './HttpResponseMeta' import { HttpErrorResponseBody } from './HttpErrorResponseBody' +import { Paths } from '../Server/Auth/Paths' +import { SessionRefreshResponse } from '../Response/Auth/SessionRefreshResponse' export class HttpService implements HttpServiceInterface { - private authorizationToken?: string + private session: Session | null private __latencySimulatorMs?: number - private host!: string + private declare host: string constructor( private environment: Environment, private appVersion: string, private snjsVersion: string, private updateMetaCallback: (meta: HttpResponseMeta) => void, - ) {} + private refreshSessionCallback: (session: Session) => void, + ) { + this.session = null + } - setAuthorizationToken(authorizationToken: string): void { - this.authorizationToken = authorizationToken + setSession(session: Session): void { + this.session = session } setHost(host: string): void { @@ -36,7 +43,7 @@ export class HttpService implements HttpServiceInterface { url: joinPaths(this.host, path), params, verb: HttpVerb.Get, - authentication: authentication ?? this.authorizationToken, + authentication: authentication ?? this.session?.accessToken.value, }) } @@ -45,7 +52,7 @@ export class HttpService implements HttpServiceInterface { url: joinPaths(this.host, path), params, verb: HttpVerb.Post, - authentication: authentication ?? this.authorizationToken, + authentication: authentication ?? this.session?.accessToken.value, }) } @@ -54,7 +61,7 @@ export class HttpService implements HttpServiceInterface { url: joinPaths(this.host, path), params, verb: HttpVerb.Put, - authentication: authentication ?? this.authorizationToken, + authentication: authentication ?? this.session?.accessToken.value, }) } @@ -63,7 +70,7 @@ export class HttpService implements HttpServiceInterface { url: joinPaths(this.host, path), params, verb: HttpVerb.Patch, - authentication: authentication ?? this.authorizationToken, + authentication: authentication ?? this.session?.accessToken.value, }) } @@ -72,7 +79,7 @@ export class HttpService implements HttpServiceInterface { url: joinPaths(this.host, path), params, verb: HttpVerb.Delete, - authentication: authentication ?? this.authorizationToken, + authentication: authentication ?? this.session?.accessToken.value, }) } @@ -89,9 +96,68 @@ export class HttpService implements HttpServiceInterface { this.updateMetaCallback(response.meta) } + if (response.status === HttpStatusCode.ExpiredAccessToken) { + const isSessionRefreshed = await this.refreshSession() + if (!isSessionRefreshed) { + return response + } + + httpRequest.authentication = this.session?.accessToken.value + + return this.runHttp(httpRequest) + } + return response } + private async refreshSession(): Promise { + if (this.session === null) { + return false + } + + const response = (await this.post(joinPaths(this.host, Paths.v1.refreshSession), { + access_token: this.session.accessToken.value, + refresh_token: this.session.refreshToken.value, + })) as SessionRefreshResponse + + if (response.data.error) { + return false + } + + if (response.meta) { + this.updateMetaCallback(response.meta) + } + + const accessTokenOrError = SessionToken.create( + response.data.session.access_token, + response.data.session.access_expiration, + ) + if (accessTokenOrError.isFailed()) { + return false + } + const accessToken = accessTokenOrError.getValue() + + const refreshTokenOrError = SessionToken.create( + response.data.session.refresh_token, + response.data.session.refresh_expiration, + ) + if (refreshTokenOrError.isFailed()) { + return false + } + const refreshToken = refreshTokenOrError.getValue() + + const sessionOrError = Session.create(accessToken, refreshToken, response.data.session.readonly_access) + if (sessionOrError.isFailed()) { + return false + } + + this.setSession(sessionOrError.getValue()) + + this.refreshSessionCallback(this.session) + + return true + } + private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined { if ( httpRequest.params !== undefined && diff --git a/packages/api/src/Domain/Http/HttpServiceInterface.ts b/packages/api/src/Domain/Http/HttpServiceInterface.ts index e35fc52fe..db1e0db22 100644 --- a/packages/api/src/Domain/Http/HttpServiceInterface.ts +++ b/packages/api/src/Domain/Http/HttpServiceInterface.ts @@ -1,9 +1,11 @@ +import { Session } from '@standardnotes/domain-core' + import { HttpRequestParams } from './HttpRequestParams' import { HttpResponse } from './HttpResponse' export interface HttpServiceInterface { setHost(host: string): void - setAuthorizationToken(authorizationToken: string): void + setSession(session: Session): void get(path: string, params?: HttpRequestParams, authentication?: string): Promise post(path: string, params?: HttpRequestParams, authentication?: string): Promise put(path: string, params?: HttpRequestParams, authentication?: string): Promise diff --git a/packages/api/src/Domain/Response/Auth/SessionRefreshResponse.ts b/packages/api/src/Domain/Response/Auth/SessionRefreshResponse.ts new file mode 100644 index 000000000..0fd8b6287 --- /dev/null +++ b/packages/api/src/Domain/Response/Auth/SessionRefreshResponse.ts @@ -0,0 +1,9 @@ +import { Either } from '@standardnotes/common' + +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' +import { SessionRefreshResponseBody } from './SessionRefreshResponseBody' + +export interface SessionRefreshResponse extends HttpResponse { + data: Either +} diff --git a/packages/api/src/Domain/Response/Auth/SessionRefreshResponseBody.ts b/packages/api/src/Domain/Response/Auth/SessionRefreshResponseBody.ts new file mode 100644 index 000000000..58b65b483 --- /dev/null +++ b/packages/api/src/Domain/Response/Auth/SessionRefreshResponseBody.ts @@ -0,0 +1,9 @@ +export type SessionRefreshResponseBody = { + session: { + access_token: string + refresh_token: string + access_expiration: number + refresh_expiration: number + readonly_access: boolean + } +} diff --git a/packages/api/src/Domain/Server/Auth/Paths.ts b/packages/api/src/Domain/Server/Auth/Paths.ts new file mode 100644 index 000000000..71ad817be --- /dev/null +++ b/packages/api/src/Domain/Server/Auth/Paths.ts @@ -0,0 +1,9 @@ +const SessionPaths = { + refreshSession: '/v1/sessions/refresh', +} + +export const Paths = { + v1: { + ...SessionPaths, + }, +} diff --git a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts b/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts deleted file mode 100644 index 3af42cfa7..000000000 --- a/packages/api/src/Domain/Server/Subscription/SubscriptionServer.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { type Invitation } from '@standardnotes/models' - -import { ApiVersion } from '../../Api' -import { HttpServiceInterface } from '../../Http' -import { SubscriptionInviteResponse } from '../../Response' -import { SubscriptionInviteAcceptResponse } from '../../Response/Subscription/SubscriptionInviteAcceptResponse' -import { SubscriptionInviteCancelResponse } from '../../Response/Subscription/SubscriptionInviteCancelResponse' -import { SubscriptionInviteDeclineResponse } from '../../Response/Subscription/SubscriptionInviteDeclineResponse' -import { SubscriptionInviteListResponse } from '../../Response/Subscription/SubscriptionInviteListResponse' - -import { SubscriptionServer } from './SubscriptionServer' - -describe('SubscriptionServer', () => { - let httpService: HttpServiceInterface - - const createServer = () => new SubscriptionServer(httpService) - - beforeEach(() => { - httpService = {} as jest.Mocked - }) - - it('should invite a user to a shared subscription', async () => { - httpService.post = jest.fn().mockReturnValue({ - data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' }, - } as jest.Mocked) - - const response = await createServer().invite({ - api: ApiVersion.v0, - identifier: 'test@test.te', - }) - - expect(response).toEqual({ - data: { - success: true, - sharedSubscriptionInvitationUuid: '1-2-3', - }, - }) - }) - - it('should accept an invite to a shared subscription', async () => { - httpService.post = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - - const response = await createServer().acceptInvite({ - api: ApiVersion.v0, - inviteUuid: '1-2-3', - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - }) - - it('should decline an invite to a shared subscription', async () => { - httpService.get = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - - const response = await createServer().declineInvite({ - api: ApiVersion.v0, - inviteUuid: '1-2-3', - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - }) - - it('should cancel an invite to a shared subscription', async () => { - httpService.delete = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - - const response = await createServer().cancelInvite({ - api: ApiVersion.v0, - inviteUuid: '1-2-3', - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - }) - - it('should list invitations to a shared subscription', async () => { - httpService.get = jest.fn().mockReturnValue({ - data: { invitations: [{} as jest.Mocked] }, - } as jest.Mocked) - - const response = await createServer().listInvites({ - api: ApiVersion.v0, - }) - - expect(response).toEqual({ - data: { - invitations: [{} as jest.Mocked], - }, - }) - }) -}) diff --git a/packages/api/src/Domain/Server/User/UserServer.spec.ts b/packages/api/src/Domain/Server/User/UserServer.spec.ts deleted file mode 100644 index 38f890388..000000000 --- a/packages/api/src/Domain/Server/User/UserServer.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ProtocolVersion } from '@standardnotes/common' -import { ApiVersion } from '../../Api' -import { HttpServiceInterface } from '../../Http' -import { UserDeletionResponse, UserRegistrationResponse } from '../../Response' -import { UserServer } from './UserServer' - -describe('UserServer', () => { - let httpService: HttpServiceInterface - - const createServer = () => new UserServer(httpService) - - beforeEach(() => { - httpService = {} as jest.Mocked - httpService.post = jest.fn().mockReturnValue({ - data: { user: { email: 'test@test.te', uuid: '1-2-3' } }, - } as jest.Mocked) - httpService.delete = jest.fn().mockReturnValue({ - data: { message: 'Success' }, - } as jest.Mocked) - }) - - it('should register a user', async () => { - const response = await createServer().register({ - password: 'test', - api: ApiVersion.v0, - email: 'test@test.te', - ephemeral: false, - version: ProtocolVersion.V004, - pw_nonce: 'test', - identifier: 'test@test.te', - }) - - expect(response).toEqual({ - data: { - user: { - email: 'test@test.te', - uuid: '1-2-3', - }, - }, - }) - }) - - it('should delete a user', async () => { - const response = await createServer().deleteAccount({ - userUuid: '1-2-3', - }) - - expect(response).toEqual({ - data: { - message: 'Success', - }, - }) - }) -}) diff --git a/packages/api/src/Domain/Server/UserRequest/UserRequestServer.spec.ts b/packages/api/src/Domain/Server/UserRequest/UserRequestServer.spec.ts deleted file mode 100644 index aec4210d7..000000000 --- a/packages/api/src/Domain/Server/UserRequest/UserRequestServer.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { UserRequestType } from '@standardnotes/common' - -import { HttpServiceInterface } from '../../Http' -import { UserRequestResponse } from '../../Response/UserRequest/UserRequestResponse' - -import { UserRequestServer } from './UserRequestServer' - -describe('UserRequestServer', () => { - let httpService: HttpServiceInterface - - const createServer = () => new UserRequestServer(httpService) - - beforeEach(() => { - httpService = {} as jest.Mocked - httpService.post = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - }) - - it('should submit a user request', async () => { - const response = await createServer().submitUserRequest({ - userUuid: '1-2-3', - requestType: UserRequestType.ExitDiscount, - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - }) -}) diff --git a/packages/api/src/Domain/Server/WebSocket/WebSocketServer.spec.ts b/packages/api/src/Domain/Server/WebSocket/WebSocketServer.spec.ts deleted file mode 100644 index 9469b135e..000000000 --- a/packages/api/src/Domain/Server/WebSocket/WebSocketServer.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { HttpServiceInterface } from '../../Http' -import { WebSocketConnectionTokenResponse } from '../../Response' - -import { WebSocketServer } from './WebSocketServer' - -describe('WebSocketServer', () => { - let httpService: HttpServiceInterface - - const createServer = () => new WebSocketServer(httpService) - - beforeEach(() => { - httpService = {} as jest.Mocked - httpService.post = jest.fn().mockReturnValue({ - data: { token: 'foobar' }, - } as jest.Mocked) - }) - - it('should create a websocket connection token', async () => { - const response = await createServer().createConnectionToken({}) - - expect(response).toEqual({ - data: { - token: 'foobar', - }, - }) - }) -}) diff --git a/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts b/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts deleted file mode 100644 index b3632cf2a..000000000 --- a/packages/api/src/Domain/Server/Workspace/WorkspaceServer.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { WorkspaceAccessLevel, WorkspaceType } from '@standardnotes/common' - -import { HttpServiceInterface, HttpStatusCode } from '../../Http' -import { WorkspaceCreationResponse } from '../../Response/Workspace/WorkspaceCreationResponse' -import { WorkspaceInvitationAcceptingResponse } from '../../Response/Workspace/WorkspaceInvitationAcceptingResponse' -import { WorkspaceInvitationResponse } from '../../Response/Workspace/WorkspaceInvitationResponse' -import { WorkspaceKeyshareInitiatingResponse } from '../../Response/Workspace/WorkspaceKeyshareInitiatingResponse' -import { WorkspaceListResponse } from '../../Response/Workspace/WorkspaceListResponse' -import { WorkspaceUserListResponse } from '../../Response/Workspace/WorkspaceUserListResponse' - -import { WorkspaceServer } from './WorkspaceServer' - -describe('WorkspaceServer', () => { - let httpService: HttpServiceInterface - - const createServer = () => new WorkspaceServer(httpService) - - beforeEach(() => { - httpService = {} as jest.Mocked - httpService.post = jest.fn().mockReturnValue({ - data: { uuid: '1-2-3' }, - } as jest.Mocked) - }) - - it('should create a workspace', async () => { - const response = await createServer().createWorkspace({ - workspaceType: WorkspaceType.Private, - encryptedPrivateKey: 'foo', - encryptedWorkspaceKey: 'bar', - publicKey: 'buzz', - }) - - expect(response).toEqual({ - data: { - uuid: '1-2-3', - }, - }) - }) - - it('should inivte to a workspace', async () => { - httpService.post = jest.fn().mockReturnValue({ - data: { uuid: 'i-1-2-3' }, - } as jest.Mocked) - - const response = await createServer().inviteToWorkspace({ - inviteeEmail: 'test@test.te', - workspaceUuid: 'w-1-2-3', - accessLevel: WorkspaceAccessLevel.WriteAndRead, - }) - - expect(response).toEqual({ - data: { - uuid: 'i-1-2-3', - }, - }) - }) - - it('should accept invitation to a workspace', async () => { - httpService.post = jest.fn().mockReturnValue({ - data: { success: true }, - } as jest.Mocked) - - const response = await createServer().acceptInvite({ - encryptedPrivateKey: 'foo', - inviteUuid: 'i-1-2-3', - publicKey: 'bar', - userUuid: 'u-1-2-3', - }) - - expect(response).toEqual({ - data: { - success: true, - }, - }) - }) - - it('should list workspaces', async () => { - httpService.get = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { ownedWorkspaces: [], joinedWorkspaces: [] }, - } as jest.Mocked) - - const response = await createServer().listWorkspaces({}) - - expect(response).toEqual({ - status: 200, - data: { ownedWorkspaces: [], joinedWorkspaces: [] }, - }) - }) - - it('should list workspace users', async () => { - httpService.get = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { users: [] }, - } as jest.Mocked) - - const response = await createServer().listWorkspaceUsers({ workspaceUuid: 'w-1-2-3' }) - - expect(response).toEqual({ - status: 200, - data: { users: [] }, - }) - }) - - it('should initiate keyshare for user in a workspace', async () => { - httpService.post = jest.fn().mockReturnValue({ - status: HttpStatusCode.Success, - data: { success: true }, - } as jest.Mocked) - - const response = await createServer().initiateKeyshare({ - workspaceUuid: 'w-1-2-3', - userUuid: 'u-1-2-3', - encryptedWorkspaceKey: 'foobar', - }) - - expect(response).toEqual({ - status: 200, - data: { - success: true, - }, - }) - }) -}) diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts index e81560d15..ce8180ff4 100644 --- a/packages/snjs/lib/Application/Application.ts +++ b/packages/snjs/lib/Application/Application.ts @@ -82,6 +82,9 @@ import { SNLog } from '../Log' import { ChallengeResponse, ListedClientInterface } from '../Services' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationOptionsDefaults } from './Options/Defaults' +import { LegacySession, MapperInterface, Session } from '@standardnotes/domain-core' +import { SessionStorageMapper } from '@Lib/Services/Mapping/SessionStorageMapper' +import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionStorageMapper' /** How often to automatically sync, in milliseconds */ const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 @@ -154,6 +157,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli private integrityService!: ExternalServices.IntegrityService private statusService!: ExternalServices.StatusService private filesBackupService?: FilesBackupService + private declare sessionStorageMapper: MapperInterface> + private declare legacySessionStorageMapper: MapperInterface> private internalEventBus!: ExternalServices.InternalEventBusInterface @@ -1078,6 +1083,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli } private constructServices() { + this.createMappers() this.createPayloadManager() this.createItemManager() this.createDiskStorageManager() @@ -1169,6 +1175,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli ;(this.mutatorService as unknown) = undefined ;(this.filesBackupService as unknown) = undefined ;(this.statusService as unknown) = undefined + ;(this.sessionStorageMapper as unknown) = undefined + ;(this.legacySessionStorageMapper as unknown) = undefined this.services = [] } @@ -1289,6 +1297,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli environment: this.environment, identifier: this.identifier, internalEventBus: this.internalEventBus, + legacySessionStorageMapper: this.legacySessionStorageMapper, }) this.services.push(this.migrationService) } @@ -1335,6 +1344,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.options.defaultHost, this.inMemoryStore, this.options.crypto, + this.sessionStorageMapper, + this.legacySessionStorageMapper, this.internalEventBus, ) this.services.push(this.apiService) @@ -1419,9 +1430,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.options.appVersion, SnjsVersion, this.apiService.processMetaObject.bind(this.apiService), + this.apiService.setSession.bind(this.apiService), ) } + private createMappers() { + this.sessionStorageMapper = new SessionStorageMapper() + this.legacySessionStorageMapper = new LegacySessionStorageMapper() + } + private createPayloadManager() { this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus) this.services.push(this.payloadManager) @@ -1497,6 +1514,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli this.challengeService, this.webSocketsService, this.httpService, + this.sessionStorageMapper, + this.legacySessionStorageMapper, this.internalEventBus, ) this.serviceObservers.push( diff --git a/packages/snjs/lib/Migrations/MigrationServices.ts b/packages/snjs/lib/Migrations/MigrationServices.ts index 6abec7418..f774515bf 100644 --- a/packages/snjs/lib/Migrations/MigrationServices.ts +++ b/packages/snjs/lib/Migrations/MigrationServices.ts @@ -5,6 +5,7 @@ import { SNSessionManager } from '../Services/Session/SessionManager' import { ApplicationIdentifier } from '@standardnotes/common' import { ItemManager } from '@Lib/Services/Items/ItemManager' import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services' +import { LegacySession, MapperInterface } from '@standardnotes/domain-core' export type MigrationServices = { protocolService: EncryptionService @@ -17,5 +18,6 @@ export type MigrationServices = { featuresService: SNFeaturesService environment: Environment identifier: ApplicationIdentifier + legacySessionStorageMapper: MapperInterface> internalEventBus: InternalEventBusInterface } diff --git a/packages/snjs/lib/Migrations/Versions/2_0_0.ts b/packages/snjs/lib/Migrations/Versions/2_0_0.ts index 7a4186653..ec032f7c8 100644 --- a/packages/snjs/lib/Migrations/Versions/2_0_0.ts +++ b/packages/snjs/lib/Migrations/Versions/2_0_0.ts @@ -1,5 +1,4 @@ import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common' -import { JwtSession } from '../../Services/Session/Sessions/JwtSession' import { Migration } from '@Lib/Migrations/Migration' import { MigrationServices } from '../MigrationServices' import { PreviousSnjsVersion2_0_0 } from '../../Version' @@ -16,6 +15,7 @@ import { PayloadTimestampDefaults, } from '@standardnotes/models' import { isMobileDevice } from '@standardnotes/services' +import { LegacySession } from '@standardnotes/domain-core' interface LegacyStorageContent extends Models.ItemContent { storage: unknown @@ -673,8 +673,13 @@ export class Migration2_0_0 extends Migration { } } - const session = new JwtSession(currentToken) - this.services.storageService.setValue(Services.StorageKey.Session, session) + const sessionOrError = LegacySession.create(currentToken) + if (!sessionOrError.isFailed()) { + this.services.storageService.setValue( + Services.StorageKey.Session, + this.services.legacySessionStorageMapper.toProjection(sessionOrError.getValue()), + ) + } /** Server has to be migrated separately on mobile */ if (isEnvironmentMobile(this.services.environment)) { diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts index ecdbb9757..ecaa7da72 100644 --- a/packages/snjs/lib/Services/Api/ApiService.ts +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -34,23 +34,22 @@ import { API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS, } from '@standardnotes/services' import { FilesApiInterface } from '@standardnotes/files' - import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models' import * as Responses from '@standardnotes/responses' +import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' +import { HttpResponseMeta } from '@standardnotes/api' +import { SNRootKeyParams } from '@standardnotes/encryption' +import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + import { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService' import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' import { Paths } from './Paths' -import { Session } from '../Session/Sessions/Session' -import { TokenSession } from '../Session/Sessions/TokenSession' import { DiskStorageService } from '../Storage/DiskStorageService' -import { HttpResponseMeta } from '@standardnotes/api' import { UuidString } from '../../Types/UuidString' import merge from 'lodash/merge' import { SettingsServerInterface } from '../Settings/SettingsServerInterface' import { Strings } from '@Lib/Strings' -import { SNRootKeyParams } from '@standardnotes/encryption' -import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses' -import { PureCryptoInterface } from '@standardnotes/sncrypto-common' /** Legacy api version field to be specified in params when calling v0 APIs. */ const V0_API_VERSION = '20200115' @@ -66,7 +65,7 @@ export class SNApiService ItemsServerInterface, SettingsServerInterface { - private session?: Session + private session: Session | LegacySession | null public user?: Responses.User private registering = false private authenticating = false @@ -81,16 +80,20 @@ export class SNApiService private host: string, private inMemoryStore: KeyValueStoreInterface, private crypto: PureCryptoInterface, + private sessionStorageMapper: MapperInterface>, + private legacySessionStorageMapper: MapperInterface>, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) + + this.session = null } override deinit(): void { ;(this.httpService as unknown) = undefined ;(this.storageService as unknown) = undefined this.invalidSessionObserver = undefined - this.session = undefined + this.session = null super.deinit() } @@ -145,14 +148,21 @@ export class SNApiService return this.filesHost } - public setSession(session: Session, persist = true): void { + public setSession(session: Session | LegacySession, persist = true): void { this.session = session if (persist) { - this.storageService.setValue(StorageKey.Session, session) + let sessionProjection: Record + if (session instanceof Session) { + sessionProjection = this.sessionStorageMapper.toProjection(session) + } else { + sessionProjection = this.legacySessionStorageMapper.toProjection(session) + } + + this.storageService.setValue(StorageKey.Session, sessionProjection) } } - public getSession(): Session | undefined { + public getSession(): Session | LegacySession | null { return this.session } @@ -252,7 +262,7 @@ export class SNApiService fallbackErrorMessage: API_MESSAGE_GENERIC_INVALID_LOGIN, params, /** A session is optional here, if valid, endpoint bypasses 2FA and returns additional params */ - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) } @@ -289,7 +299,7 @@ export class SNApiService signOut(): Promise { const url = joinPaths(this.host, Paths.v1.signOut) - return this.httpService.postAbsolute(url, undefined, this.session?.authorizationValue).catch((errorResponse) => { + return this.httpService.postAbsolute(url, undefined, this.getSessionAccessToken()).catch((errorResponse) => { return errorResponse }) as Promise } @@ -317,7 +327,7 @@ export class SNApiService ...parameters.newKeyParams.getPortableValue(), }) const response = await this.httpService - .putAbsolute(url, params, this.session?.authorizationValue) + .putAbsolute(url, params, this.getSessionAccessToken()) .catch(async (errorResponse) => { if (Responses.isErrorResponseExpiredToken(errorResponse)) { return this.refreshSessionThenRetryRequest({ @@ -353,7 +363,7 @@ export class SNApiService [ApiEndpointParam.SyncDlLimit]: limit, }) const response = await this.httpService - .postAbsolute(url, params, this.session?.authorizationValue) + .postAbsolute(url, params, this.getSessionAccessToken()) .catch(async (errorResponse) => { this.preprocessAuthenticatedErrorResponse(errorResponse) if (Responses.isErrorResponseExpiredToken(errorResponse)) { @@ -378,7 +388,7 @@ export class SNApiService return this.httpService .runHttp({ ...httpRequest, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) .catch((errorResponse) => { return errorResponse @@ -393,16 +403,54 @@ export class SNApiService } this.refreshingSession = true const url = joinPaths(this.host, Paths.v1.refreshSession) - const session = this.session as TokenSession + const session = this.session as Session const params = this.params({ - access_token: session.accessToken, - refresh_token: session.refreshToken, + access_token: session.accessToken.value, + refresh_token: session.refreshToken.value, }) const result = await this.httpService .postAbsolute(url, params) .then(async (response) => { - const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse) - await this.setSession(session) + const sessionRenewalResponse = response as Responses.SessionRenewalResponse + if ( + sessionRenewalResponse.error || + sessionRenewalResponse.data?.error || + !sessionRenewalResponse.data.session + ) { + return null + } + + const accessTokenOrError = SessionToken.create( + sessionRenewalResponse.data.session.access_token, + sessionRenewalResponse.data.session.access_expiration, + ) + if (accessTokenOrError.isFailed()) { + return null + } + const accessToken = accessTokenOrError.getValue() + + const refreshTokenOrError = SessionToken.create( + sessionRenewalResponse.data.session.refresh_token, + sessionRenewalResponse.data.session.refresh_expiration, + ) + if (refreshTokenOrError.isFailed()) { + return null + } + const refreshToken = refreshTokenOrError.getValue() + + const sessionOrError = Session.create( + accessToken, + refreshToken, + sessionRenewalResponse.data.session.readonly_access, + ) + if (sessionOrError.isFailed()) { + return null + } + const session = sessionOrError.getValue() + + this.session = session + + this.setSession(session) this.processResponse(response) return response }) @@ -411,6 +459,11 @@ export class SNApiService return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL) }) this.refreshingSession = false + + if (result === null) { + return this.createErrorResponse(API_MESSAGE_INVALID_SESSION) + } + return result } @@ -421,7 +474,7 @@ export class SNApiService } const url = joinPaths(this.host, Paths.v1.sessions) const response = await this.httpService - .getAbsolute(url, {}, this.session?.authorizationValue) + .getAbsolute(url, {}, this.getSessionAccessToken()) .catch(async (errorResponse) => { this.preprocessAuthenticatedErrorResponse(errorResponse) if (Responses.isErrorResponseExpiredToken(errorResponse)) { @@ -444,7 +497,7 @@ export class SNApiService } const url = joinPaths(this.host, Paths.v1.session(sessionId)) const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService - .deleteAbsolute(url, { uuid: sessionId }, this.session?.authorizationValue) + .deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken()) .catch((error: Responses.HttpResponse) => { const errorResponse = error as Responses.HttpResponse this.preprocessAuthenticatedErrorResponse(errorResponse) @@ -467,7 +520,7 @@ export class SNApiService } const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId)) const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService - .getAbsolute(url, undefined, this.session?.authorizationValue) + .getAbsolute(url, undefined, this.getSessionAccessToken()) .catch((errorResponse: Responses.HttpResponse) => { this.preprocessAuthenticatedErrorResponse(errorResponse) if (Responses.isErrorResponseExpiredToken(errorResponse)) { @@ -492,7 +545,7 @@ export class SNApiService } const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid)) const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService - .getAbsolute(url, undefined, this.session?.authorizationValue) + .getAbsolute(url, undefined, this.getSessionAccessToken()) .catch((errorResponse: Responses.HttpResponse) => { this.preprocessAuthenticatedErrorResponse(errorResponse) if (Responses.isErrorResponseExpiredToken(errorResponse)) { @@ -510,7 +563,7 @@ export class SNApiService async getUserFeatures(userUuid: UuidString): Promise { const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid)) const response = await this.httpService - .getAbsolute(url, undefined, this.session?.authorizationValue) + .getAbsolute(url, undefined, this.getSessionAccessToken()) .catch((errorResponse: Responses.HttpResponse) => { this.preprocessAuthenticatedErrorResponse(errorResponse) if (Responses.isErrorResponseExpiredToken(errorResponse)) { @@ -550,7 +603,7 @@ export class SNApiService verb: HttpVerb.Get, url: joinPaths(this.host, Paths.v1.settings(userUuid)), fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) } @@ -568,7 +621,7 @@ export class SNApiService return this.tokenRefreshableRequest({ verb: HttpVerb.Put, url: joinPaths(this.host, Paths.v1.settings(userUuid)), - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, params, }) @@ -578,7 +631,7 @@ export class SNApiService return await this.tokenRefreshableRequest({ verb: HttpVerb.Get, url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)), - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, }) } @@ -593,7 +646,7 @@ export class SNApiService this.host, Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName), ), - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, }) } @@ -602,7 +655,7 @@ export class SNApiService return this.tokenRefreshableRequest({ verb: HttpVerb.Delete, url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)), - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, }) } @@ -616,7 +669,7 @@ export class SNApiService verb: HttpVerb.Delete, url, fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) return response } @@ -635,7 +688,7 @@ export class SNApiService const response = await this.tokenRefreshableRequest({ verb: HttpVerb.Get, url, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO, }) return response @@ -658,7 +711,7 @@ export class SNApiService const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({ verb: HttpVerb.Post, url, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_ACCESS_PURCHASE, }) return (response as Responses.PostSubscriptionTokensResponse).data?.token @@ -706,7 +759,7 @@ export class SNApiService verb: HttpVerb.Post, url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)), fallbackErrorMessage: API_MESSAGE_FAILED_LISTED_REGISTRATION, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) } @@ -725,7 +778,7 @@ export class SNApiService const response = await this.tokenRefreshableRequest({ verb: HttpVerb.Post, url: url, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), fallbackErrorMessage: API_MESSAGE_FAILED_CREATE_FILE_TOKEN, params, }) @@ -860,7 +913,7 @@ export class SNApiService integrityPayloads, }, fallbackErrorMessage: API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) } @@ -869,7 +922,7 @@ export class SNApiService verb: HttpVerb.Get, url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)), fallbackErrorMessage: API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL, - authentication: this.session?.authorizationValue, + authentication: this.getSessionAccessToken(), }) } @@ -890,6 +943,18 @@ export class SNApiService } } + private getSessionAccessToken(): string | undefined { + if (!this.session) { + return undefined + } + + if (this.session instanceof Session) { + return this.session.accessToken.value + } + + return this.session.accessToken + } + override getDiagnostics(): Promise { return Promise.resolve({ api: { diff --git a/packages/snjs/lib/Services/Api/index.ts b/packages/snjs/lib/Services/Api/index.ts index 32f79dc41..3e63ccbf8 100644 --- a/packages/snjs/lib/Services/Api/index.ts +++ b/packages/snjs/lib/Services/Api/index.ts @@ -1,6 +1,5 @@ export * from './ApiService' export * from './HttpService' export * from './Paths' -export * from '../Session/Sessions/Session' export * from '../Session/SessionManager' export * from './WebsocketsService' diff --git a/packages/snjs/lib/Services/Challenge/ChallengeService.ts b/packages/snjs/lib/Services/Challenge/ChallengeService.ts index 0f418c005..189ce400d 100644 --- a/packages/snjs/lib/Services/Challenge/ChallengeService.ts +++ b/packages/snjs/lib/Services/Challenge/ChallengeService.ts @@ -290,7 +290,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic if (operation.isFinished()) { this.deleteChallengeOperation(operation) - const observers = this.challengeObservers[challenge.id] + const observers = this.challengeObservers[challenge.id] || [] observers.forEach(clearChallengeObserver) observers.length = 0 diff --git a/packages/snjs/lib/Services/Mapping/LegacySessionStorageMapper.ts b/packages/snjs/lib/Services/Mapping/LegacySessionStorageMapper.ts new file mode 100644 index 000000000..f9d637942 --- /dev/null +++ b/packages/snjs/lib/Services/Mapping/LegacySessionStorageMapper.ts @@ -0,0 +1,20 @@ +import { LegacySession, MapperInterface } from '@standardnotes/domain-core' + +export class LegacySessionStorageMapper implements MapperInterface> { + toDomain(projection: Record): LegacySession { + const { jwt } = projection + + const legacySessionOrError = LegacySession.create(jwt as string) + if (legacySessionOrError.isFailed()) { + throw new Error(legacySessionOrError.getError()) + } + + return legacySessionOrError.getValue() + } + + toProjection(domain: LegacySession): Record { + return { + jwt: domain.accessToken, + } + } +} diff --git a/packages/snjs/lib/Services/Mapping/SessionStorageMapper.ts b/packages/snjs/lib/Services/Mapping/SessionStorageMapper.ts new file mode 100644 index 000000000..426a59c94 --- /dev/null +++ b/packages/snjs/lib/Services/Mapping/SessionStorageMapper.ts @@ -0,0 +1,40 @@ +import { MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' + +export class SessionStorageMapper implements MapperInterface> { + toDomain(projection: Record): Session { + const accessTokenOrError = SessionToken.create( + projection.accessToken as string, + projection.accessExpiration as number, + ) + if (accessTokenOrError.isFailed()) { + throw new Error(accessTokenOrError.getError()) + } + const accessToken = accessTokenOrError.getValue() + + const refreshTokenOrError = SessionToken.create( + projection.refreshToken as string, + projection.refreshExpiration as number, + ) + if (refreshTokenOrError.isFailed()) { + throw new Error(refreshTokenOrError.getError()) + } + const refreshToken = refreshTokenOrError.getValue() + + const session = Session.create(accessToken, refreshToken, projection.readonlyAccess as boolean) + if (session.isFailed()) { + throw new Error(session.getError()) + } + + return session.getValue() + } + + toProjection(domain: Session): Record { + return { + accessToken: domain.accessToken.value, + refreshToken: domain.refreshToken.value, + accessExpiration: domain.accessToken.expiresAt, + refreshExpiration: domain.refreshToken.expiresAt, + readonlyAccess: domain.isReadOnly(), + } + } +} diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts index 438e26a56..258fd156d 100644 --- a/packages/snjs/lib/Services/Session/SessionManager.ts +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -27,21 +27,19 @@ import { Base64String } from '@standardnotes/sncrypto-common' import { ClientDisplayableError } from '@standardnotes/responses' import { CopyPayloadWithContentOverride } from '@standardnotes/models' import { isNullOrUndefined } from '@standardnotes/utils' -import { JwtSession } from './Sessions/JwtSession' +import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey, CreateNewRootKey } from '@standardnotes/encryption' +import * as Responses from '@standardnotes/responses' +import { Subscription } from '@standardnotes/security' +import * as Common from '@standardnotes/common' + import { RemoteSession, RawStorageValue } from './Sessions/Types' -import { Session } from './Sessions/Session' -import { SessionFromRawStorageValue } from './Sessions/Generator' import { ShareToken } from './ShareToken' import { SNApiService } from '../Api/ApiService' import { DiskStorageService } from '../Storage/DiskStorageService' import { SNWebSocketsService } from '../Api/WebsocketsService' import { Strings } from '@Lib/Strings' -import { Subscription } from '@standardnotes/security' -import { TokenSession } from './Sessions/TokenSession' import { UuidString } from '@Lib/Types/UuidString' -import * as Common from '@standardnotes/common' -import * as Responses from '@standardnotes/responses' import { ChallengeService } from '../Challenge' import { ApiCallError, @@ -82,6 +80,8 @@ export class SNSessionManager extends AbstractService implements S private challengeService: ChallengeService, private webSocketsService: SNWebSocketsService, private httpService: HttpServiceInterface, + private sessionStorageMapper: MapperInterface>, + private legacySessionStorageMapper: MapperInterface>, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) @@ -122,13 +122,23 @@ export class SNSessionManager extends AbstractService implements S const rawSession = this.diskStorageService.getValue(StorageKey.Session) if (rawSession) { - const session = SessionFromRawStorageValue(rawSession) - this.setSession(session, false) + try { + const session = + 'jwt' in rawSession + ? this.legacySessionStorageMapper.toDomain(rawSession) + : this.sessionStorageMapper.toDomain(rawSession) + + this.setSession(session, false) + } catch (error) { + console.error(`Could not deserialize session from storage: ${(error as Error).message}`) + } } } - private setSession(session: Session, persist = true): void { - this.httpService.setAuthorizationToken(session.authorizationValue) + private setSession(session: Session | LegacySession, persist = true): void { + if (session instanceof Session) { + this.httpService.setSession(session) + } this.apiService.setSession(session, persist) @@ -158,7 +168,7 @@ export class SNSessionManager extends AbstractService implements S public async signOut() { this.setUser(undefined) const session = this.apiService.getSession() - if (session && session.canExpire()) { + if (session && session instanceof Session) { await this.apiService.signOut() this.webSocketsService.closeWebSocketConnection() } @@ -560,17 +570,17 @@ export class SNSessionManager extends AbstractService implements S if (!session) { return new ClientDisplayableError('Cannot generate share token without active session') } - if (!(session instanceof TokenSession)) { + if (!(session instanceof Session)) { return new ClientDisplayableError('Cannot generate share token with non-token session') } const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams const payload: ShareToken = { - accessToken: session.accessToken, - refreshToken: session.refreshToken, - accessExpiration: session.accessExpiration, - refreshExpiration: session.refreshExpiration, + accessToken: session.accessToken.value, + refreshToken: session.refreshToken.value, + accessExpiration: session.accessToken.expiresAt, + refreshExpiration: session.refreshToken.expiresAt, readonlyAccess: true, masterKey: this.protocolService.getRootKey()?.masterKey as string, keyParams: keyParams.content, @@ -597,7 +607,7 @@ export class SNSessionManager extends AbstractService implements S const user = sharePayload.user - const session = new TokenSession( + const session = this.createSession( sharePayload.accessToken, sharePayload.accessExpiration, sharePayload.refreshToken, @@ -605,13 +615,15 @@ export class SNSessionManager extends AbstractService implements S sharePayload.readonlyAccess, ) - await this.populateSession(rootKey, user, session, sharePayload.host) + if (session !== null) { + await this.populateSession(rootKey, user, session, sharePayload.host) + } } private async populateSession( rootKey: SNRootKey, user: Responses.User, - session: Session, + session: Session | LegacySession, host: string, wrappingKey?: SNRootKey, ) { @@ -629,14 +641,17 @@ export class SNSessionManager extends AbstractService implements S } private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) { - const session = new TokenSession( + const session = this.createSession( body.session.access_token, body.session.access_expiration, body.session.refresh_token, body.session.refresh_expiration, body.session.readonly_access, ) - await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey) + + if (session !== null) { + await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey) + } } /** @@ -652,14 +667,51 @@ export class SNSessionManager extends AbstractService implements S const isLegacyJwtResponse = data.token != undefined if (isLegacyJwtResponse) { - const session = new JwtSession(data.token as string) - await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) + const sessionOrError = LegacySession.create(data.token as string) + if (!sessionOrError.isFailed()) { + await this.populateSession(rootKey, user, sessionOrError.getValue(), this.apiService.getHost(), wrappingKey) + } } else if (data.session) { - const session = TokenSession.FromApiResponse(response) - await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) + const session = this.createSession( + data.session.access_token, + data.session.access_expiration, + data.session.refresh_token, + data.session.refresh_expiration, + data.session.readonly_access, + ) + if (session !== null) { + await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) + } } } + private createSession( + accessTokenValue: string, + accessExpiration: number, + refreshTokenValue: string, + refreshExpiration: number, + readonlyAccess: boolean, + ): Session | null { + const accessTokenOrError = SessionToken.create(accessTokenValue, accessExpiration) + if (accessTokenOrError.isFailed()) { + return null + } + const accessToken = accessTokenOrError.getValue() + + const refreshTokenOrError = SessionToken.create(refreshTokenValue, refreshExpiration) + if (refreshTokenOrError.isFailed()) { + return null + } + const refreshToken = refreshTokenOrError.getValue() + + const sessionOrError = Session.create(accessToken, refreshToken, readonlyAccess) + if (sessionOrError.isFailed()) { + return null + } + + return sessionOrError.getValue() + } + override getDiagnostics(): Promise { return Promise.resolve({ session: { diff --git a/packages/snjs/lib/Services/Session/Sessions/Generator.ts b/packages/snjs/lib/Services/Session/Sessions/Generator.ts deleted file mode 100644 index e232808e5..000000000 --- a/packages/snjs/lib/Services/Session/Sessions/Generator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { JwtSession } from './JwtSession' -import { TokenSession } from './TokenSession' -import { RawSessionPayload, RawStorageValue } from './Types' - -export function SessionFromRawStorageValue(raw: RawStorageValue): JwtSession | TokenSession { - if ('jwt' in raw) { - return new JwtSession(raw.jwt as string) - } else { - const rawSession = raw as RawSessionPayload - return new TokenSession( - rawSession.accessToken, - rawSession.accessExpiration, - rawSession.refreshToken, - rawSession.refreshExpiration, - rawSession.readonlyAccess, - ) - } -} diff --git a/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts b/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts deleted file mode 100644 index f881e4477..000000000 --- a/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Session } from './Session' - -/** Legacy, for protocol versions <= 003 */ - -export class JwtSession extends Session { - public jwt: string - - constructor(jwt: string) { - super() - this.jwt = jwt - } - - public get authorizationValue(): string { - return this.jwt - } - - public canExpire(): false { - return false - } -} diff --git a/packages/snjs/lib/Services/Session/Sessions/Session.ts b/packages/snjs/lib/Services/Session/Sessions/Session.ts deleted file mode 100644 index 23a8f7d29..000000000 --- a/packages/snjs/lib/Services/Session/Sessions/Session.ts +++ /dev/null @@ -1,6 +0,0 @@ -export abstract class Session { - public abstract canExpire(): boolean - - /** Return the token that should be included in the header of authorized network requests */ - public abstract get authorizationValue(): string -} diff --git a/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts b/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts deleted file mode 100644 index c53a34a56..000000000 --- a/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { SessionBody, SessionRenewalResponse } from '@standardnotes/responses' -import { Session } from './Session' - -/** For protocol versions >= 004 */ -export class TokenSession extends Session { - static FromApiResponse(response: SessionRenewalResponse) { - const body = response.data.session as SessionBody - const accessToken: string = body.access_token - const refreshToken: string = body.refresh_token - const accessExpiration: number = body.access_expiration - const refreshExpiration: number = body.refresh_expiration - const readonlyAccess: boolean = body.readonly_access - - return new TokenSession(accessToken, accessExpiration, refreshToken, refreshExpiration, readonlyAccess) - } - - constructor( - public accessToken: string, - public accessExpiration: number, - public refreshToken: string, - public refreshExpiration: number, - private readonlyAccess: boolean, - ) { - super() - } - - isReadOnly() { - return this.readonlyAccess - } - - private getExpireAt() { - return this.accessExpiration || 0 - } - - public get authorizationValue() { - return this.accessToken - } - - public canExpire() { - return true - } - - public isExpired() { - return this.getExpireAt() < Date.now() - } -} diff --git a/packages/snjs/lib/Services/Session/Sessions/index.ts b/packages/snjs/lib/Services/Session/Sessions/index.ts index d80a6ea0a..cf701220b 100644 --- a/packages/snjs/lib/Services/Session/Sessions/index.ts +++ b/packages/snjs/lib/Services/Session/Sessions/index.ts @@ -1,5 +1 @@ -export * from './Generator' -export * from './JwtSession' -export * from './Session' -export * from './TokenSession' export * from './Types' diff --git a/packages/snjs/mocha/session.test.js b/packages/snjs/mocha/session.test.js index 63193b335..688822ac6 100644 --- a/packages/snjs/mocha/session.test.js +++ b/packages/snjs/mocha/session.test.js @@ -31,7 +31,7 @@ describe('server session', function () { async function sleepUntilSessionExpires(application, basedOnAccessToken = true) { const currentSession = application.apiService.session - const timestamp = basedOnAccessToken ? currentSession.accessExpiration : currentSession.refreshExpiration + const timestamp = basedOnAccessToken ? currentSession.accessToken.expiresAt : currentSession.refreshToken.expiresAt const timeRemaining = (timestamp - Date.now()) / 1000 // in ms /* If the token has not expired yet, we will return the remaining time. @@ -98,12 +98,12 @@ describe('server session', function () { // After the above sync request is completed, we obtain the session information. const sessionAfterSync = this.application.apiService.getSession() - expect(sessionBeforeSync).to.not.equal(sessionAfterSync) - expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken) - expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken) - expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration) + expect(sessionBeforeSync.equals(sessionAfterSync)).to.not.equal(true) + expect(sessionBeforeSync.accessToken.value).to.not.equal(sessionAfterSync.accessToken.value) + expect(sessionBeforeSync.refreshToken.value).to.not.equal(sessionAfterSync.refreshToken.value) + expect(sessionBeforeSync.accessToken.expiresAt).to.be.lessThan(sessionAfterSync.accessToken.expiresAt) // New token should expire in the future. - expect(sessionAfterSync.accessExpiration).to.be.greaterThan(Date.now()) + expect(sessionAfterSync.accessToken.expiresAt).to.be.greaterThan(Date.now()) }) it('should succeed when a sync request is perfomed after signing into an ephemeral session', async function () { @@ -142,14 +142,22 @@ describe('server session', function () { const sessionFromStorage = await getSessionFromStorage(this.application) const sessionFromApiService = this.application.apiService.getSession() - expect(sessionFromStorage).to.equal(sessionFromApiService) + expect(sessionFromStorage.accessToken).to.equal(sessionFromApiService.accessToken.value) + expect(sessionFromStorage.refreshToken).to.equal(sessionFromApiService.refreshToken.value) + expect(sessionFromStorage.accessExpiration).to.equal(sessionFromApiService.accessToken.expiresAt) + expect(sessionFromStorage.refreshExpiration).to.equal(sessionFromApiService.refreshToken.expiresAt) + expect(sessionFromStorage.readonlyAccess).to.equal(sessionFromApiService.isReadOnly()) await this.application.apiService.refreshSession() const updatedSessionFromStorage = await getSessionFromStorage(this.application) const updatedSessionFromApiService = this.application.apiService.getSession() - expect(updatedSessionFromStorage).to.equal(updatedSessionFromApiService) + expect(updatedSessionFromStorage.accessToken).to.equal(updatedSessionFromApiService.accessToken.value) + expect(updatedSessionFromStorage.refreshToken).to.equal(updatedSessionFromApiService.refreshToken.value) + expect(updatedSessionFromStorage.accessExpiration).to.equal(updatedSessionFromApiService.accessToken.expiresAt) + expect(updatedSessionFromStorage.refreshExpiration).to.equal(updatedSessionFromApiService.refreshToken.expiresAt) + expect(updatedSessionFromStorage.readonlyAccess).to.equal(updatedSessionFromApiService.isReadOnly()) }) it('should be performed successfully and terminate session with a valid access token', async function () { @@ -221,8 +229,16 @@ describe('server session', function () { let { application, password } = await Factory.createAndInitSimpleAppContext({ registerUser: true, }) - const fakeSession = application.apiService.getSession() - fakeSession.accessToken = 'this-is-a-fake-token-1234' + + application.diskStorageService.setValue(StorageKey.Session, { + accessToken: 'this-is-a-fake-token-1234', + refreshToken: 'this-is-a-fake-token-1234', + accessExpiration: 999999999999999, + refreshExpiration: 99999999999999, + readonlyAccess: false, + }) + application.sessions.initializeFromDisk() + Factory.ignoreChallenges(application) const newEmail = UuidGenerator.GenerateUuid() @@ -311,8 +327,15 @@ describe('server session', function () { password: this.password, }) - const fakeSession = this.application.apiService.getSession() - fakeSession.accessToken = 'this-is-a-fake-token-1234' + this.application.diskStorageService.setValue(StorageKey.Session, { + accessToken: 'this-is-a-fake-token-1234', + refreshToken: 'this-is-a-fake-token-1234', + accessExpiration: 999999999999999, + refreshExpiration: 99999999999999, + readonlyAccess: false, + }) + this.application.sessions.initializeFromDisk() + Factory.ignoreChallenges(this.application) const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.') @@ -354,7 +377,7 @@ describe('server session', function () { expect(currentSession).to.be.ok expect(currentSession.accessToken).to.be.ok expect(currentSession.refreshToken).to.be.ok - expect(currentSession.accessExpiration).to.be.greaterThan(Date.now()) + expect(currentSession.accessToken.expiresAt).to.be.greaterThan(Date.now()) }) it('should fail when renewing a session with an expired refresh token', async function () { @@ -392,10 +415,16 @@ describe('server session', function () { password: this.password, }) - const fakeSession = this.application.apiService.getSession() - fakeSession.refreshToken = 'this-is-a-fake-token-1234' + const originalSession = this.application.apiService.getSession() - await this.application.apiService.setSession(fakeSession, true) + this.application.diskStorageService.setValue(StorageKey.Session, { + accessToken: originalSession.accessToken.value, + refreshToken: 'this-is-a-fake-token-1234', + accessExpiration: originalSession.accessToken.expiresAt, + refreshExpiration: originalSession.refreshToken.expiresAt, + readonlyAccess: false, + }) + this.application.sessions.initializeFromDisk() const refreshSessionResponse = await this.application.apiService.refreshSession() @@ -530,8 +559,14 @@ describe('server session', function () { const oldRootKey = await appA.protocolService.getRootKey() /** Set the session as nonsense */ - appA.apiService.session.accessToken = 'foo' - appA.apiService.session.refreshToken = 'bar' + appA.diskStorageService.setValue(StorageKey.Session, { + accessToken: 'foo', + refreshToken: 'bar', + accessExpiration: 999999999999999, + refreshExpiration: 999999999999999, + readonlyAccess: false, + }) + appA.sessions.initializeFromDisk() /** Perform an authenticated network request */ await appA.sync.sync() @@ -540,8 +575,8 @@ describe('server session', function () { await Factory.sleep(5.0) expect(didPromptForSignIn).to.equal(true) - expect(appA.apiService.session.accessToken).to.not.equal('foo') - expect(appA.apiService.session.refreshToken).to.not.equal('bar') + expect(appA.apiService.session.accessToken.value).to.not.equal('foo') + expect(appA.apiService.session.refreshToken.value).to.not.equal('bar') /** Expect that the session recovery replaces the global root key */ const newRootKey = await appA.protocolService.getRootKey() @@ -646,9 +681,14 @@ describe('server session', function () { password: this.password, }) - const invalidSession = this.application.apiService.getSession() - invalidSession.accessToken = undefined - invalidSession.refreshToken = undefined + this.application.diskStorageService.setValue(StorageKey.Session, { + accessToken: undefined, + refreshToken: undefined, + accessExpiration: 999999999999999, + refreshExpiration: 999999999999999, + readonlyAccess: false, + }) + this.application.sessions.initializeFromDisk() const storageKey = this.application.diskStorageService.getPersistenceKey() expect(localStorage.getItem(storageKey)).to.be.ok diff --git a/packages/snjs/mocha/subscriptions.test.js b/packages/snjs/mocha/subscriptions.test.js index 4c5687a53..a415055de 100644 --- a/packages/snjs/mocha/subscriptions.test.js +++ b/packages/snjs/mocha/subscriptions.test.js @@ -2,7 +2,7 @@ import * as Factory from './lib/factory.js' chai.use(chaiAsPromised) const expect = chai.expect -describe.skip('subscriptions', function () { +describe('subscriptions', function () { this.timeout(Factory.TwentySecondTimeout) let application diff --git a/packages/snjs/package.json b/packages/snjs/package.json index 4409cec63..d1ecf7761 100644 --- a/packages/snjs/package.json +++ b/packages/snjs/package.json @@ -83,5 +83,8 @@ "webpack": "*", "webpack-cli": "*", "webpack-merge": "^5.8.0" + }, + "dependencies": { + "@standardnotes/domain-core": "^1.11.0" } } diff --git a/yarn.lock b/yarn.lock index c020de9ee..b3a4af3d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5384,6 +5384,7 @@ __metadata: resolution: "@standardnotes/api@workspace:packages/api" dependencies: "@standardnotes/common": ^1.45.0 + "@standardnotes/domain-core": ^1.11.0 "@standardnotes/encryption": "workspace:*" "@standardnotes/models": "workspace:*" "@standardnotes/responses": "workspace:*" @@ -5610,6 +5611,17 @@ __metadata: languageName: unknown linkType: soft +"@standardnotes/domain-core@npm:^1.11.0": + version: 1.11.0 + resolution: "@standardnotes/domain-core@npm:1.11.0" + dependencies: + reflect-metadata: ^0.1.13 + shallow-equal-object: ^1.1.1 + uuid: ^9.0.0 + checksum: cf4c9b7534338a8d5b8322a472621a2b2dde3cc3fe2d3c3c0eb12a3bdb7ba5f58bdfcc900338e5ba509311eeca6f163d95bffd46c40301532d974f397b57554e + languageName: node + linkType: hard + "@standardnotes/domain-events@npm:^2.88.0": version: 2.88.0 resolution: "@standardnotes/domain-events@npm:2.88.0" @@ -6088,6 +6100,7 @@ __metadata: "@babel/preset-env": "*" "@standardnotes/api": "workspace:*" "@standardnotes/common": ^1.45.0 + "@standardnotes/domain-core": ^1.11.0 "@standardnotes/domain-events": ^2.88.0 "@standardnotes/encryption": "workspace:*" "@standardnotes/features": "workspace:*" @@ -27198,6 +27211,13 @@ __metadata: languageName: node linkType: hard +"shallow-equal-object@npm:^1.1.1": + version: 1.1.1 + resolution: "shallow-equal-object@npm:1.1.1" + checksum: e925aa4511bdf246a10577c983b9c540ea3ea443dbc8f2f336fd398c4ddee682389f19a9f0c2c9e7a99ab62baff5f2f716fbe108729454ff7b9f787d09743cc9 + languageName: node + linkType: hard + "shallowequal@npm:^1.1.0": version: 1.1.0 resolution: "shallowequal@npm:1.1.0"