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 000000000..b61ac92a5 Binary files /dev/null and b/.yarn/cache/@standardnotes-domain-core-npm-1.11.0-f473ba8bc0-cf4c9b7534.zip differ 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 000000000..4132e34d4 Binary files /dev/null and b/.yarn/cache/shallow-equal-object-npm-1.1.1-a41b289b2e-e925aa4511.zip differ 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"