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
This commit is contained in:
Karol Sójko
2022-12-19 08:28:10 +01:00
committed by GitHub
parent abdaec89b7
commit bb4f1ff099
37 changed files with 467 additions and 1430 deletions

View File

@@ -6,12 +6,4 @@ module.exports = {
transform: { transform: {
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],
}, },
coverageThreshold: {
global: {
branches: 22,
functions: 69,
lines: 67,
statements: 67
}
}
}; };

View File

@@ -23,7 +23,7 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"lint": "eslint src --ext .ts", "lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix", "lint:fix": "eslint src --ext .ts --fix",
"test": "jest spec --coverage" "test": "jest spec --coverage --passWithNoTests"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.2.3", "@types/jest": "^29.2.3",
@@ -37,6 +37,7 @@
}, },
"dependencies": { "dependencies": {
"@standardnotes/common": "^1.45.0", "@standardnotes/common": "^1.45.0",
"@standardnotes/domain-core": "^1.11.0",
"@standardnotes/encryption": "workspace:*", "@standardnotes/encryption": "workspace:*",
"@standardnotes/models": "workspace:*", "@standardnotes/models": "workspace:*",
"@standardnotes/responses": "workspace:*", "@standardnotes/responses": "workspace:*",

View File

@@ -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<SubscriptionServerInterface>
subscriptionServer.invite = jest.fn().mockReturnValue({
data: { success: true, sharedSubscriptionInvitationUuid: '1-2-3' },
} as jest.Mocked<SubscriptionInviteResponse>)
subscriptionServer.cancelInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteCancelResponse>)
subscriptionServer.listInvites = jest.fn().mockReturnValue({
data: { invitations: [{} as jest.Mocked<Invitation>] },
} as jest.Mocked<SubscriptionInviteListResponse>)
subscriptionServer.acceptInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<SubscriptionInviteAcceptResponse>)
})
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<Invitation>],
},
})
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()
})
})

View File

@@ -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<UserServerInterface>
userServer.register = jest.fn().mockReturnValue({
data: { user: { email: 'test@test.te', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
userServer.deleteAccount = jest.fn().mockReturnValue({
data: { message: 'Success' },
} as jest.Mocked<UserDeletionResponse>)
userRequestServer = {} as jest.Mocked<UserRequestServerInterface>
userRequestServer.submitUserRequest = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<UserRequestResponse>)
keyParams = {} as jest.Mocked<RootKeyParamsInterface>
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()
})
})

View File

@@ -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<WebSocketServerInterface>
webSocketServer.createConnectionToken = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
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()
})
})

View File

@@ -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<WorkspaceServerInterface>
workspaceServer.createWorkspace = jest.fn().mockReturnValue({
data: { uuid: '1-2-3' },
} as jest.Mocked<WorkspaceCreationResponse>)
workspaceServer.inviteToWorkspace = jest.fn().mockReturnValue({
data: { uuid: 'i-1-2-3' },
} as jest.Mocked<WorkspaceInvitationResponse>)
workspaceServer.acceptInvite = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<WorkspaceInvitationAcceptingResponse>)
workspaceServer.listWorkspaces = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { ownedWorkspaces: [], joinedWorkspaces: [] },
} as jest.Mocked<WorkspaceListResponse>)
workspaceServer.listWorkspaceUsers = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { users: [] },
} as jest.Mocked<WorkspaceUserListResponse>)
workspaceServer.initiateKeyshare = jest.fn().mockReturnValue({
status: HttpStatusCode.Success,
data: { success: true },
} as jest.Mocked<WorkspaceKeyshareInitiatingResponse>)
})
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()
})
})

View File

@@ -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')
})
})

View File

@@ -1,5 +1,7 @@
import { isString, joinPaths, sleep } from '@standardnotes/utils' import { isString, joinPaths, sleep } from '@standardnotes/utils'
import { Environment } from '@standardnotes/models' import { Environment } from '@standardnotes/models'
import { Session, SessionToken } from '@standardnotes/domain-core'
import { HttpRequestParams } from './HttpRequestParams' import { HttpRequestParams } from './HttpRequestParams'
import { HttpVerb } from './HttpVerb' import { HttpVerb } from './HttpVerb'
import { HttpRequest } from './HttpRequest' import { HttpRequest } from './HttpRequest'
@@ -10,21 +12,26 @@ import { XMLHttpRequestState } from './XMLHttpRequestState'
import { ErrorMessage } from '../Error/ErrorMessage' import { ErrorMessage } from '../Error/ErrorMessage'
import { HttpResponseMeta } from './HttpResponseMeta' import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpErrorResponseBody } from './HttpErrorResponseBody' import { HttpErrorResponseBody } from './HttpErrorResponseBody'
import { Paths } from '../Server/Auth/Paths'
import { SessionRefreshResponse } from '../Response/Auth/SessionRefreshResponse'
export class HttpService implements HttpServiceInterface { export class HttpService implements HttpServiceInterface {
private authorizationToken?: string private session: Session | null
private __latencySimulatorMs?: number private __latencySimulatorMs?: number
private host!: string private declare host: string
constructor( constructor(
private environment: Environment, private environment: Environment,
private appVersion: string, private appVersion: string,
private snjsVersion: string, private snjsVersion: string,
private updateMetaCallback: (meta: HttpResponseMeta) => void, private updateMetaCallback: (meta: HttpResponseMeta) => void,
) {} private refreshSessionCallback: (session: Session) => void,
) {
this.session = null
}
setAuthorizationToken(authorizationToken: string): void { setSession(session: Session): void {
this.authorizationToken = authorizationToken this.session = session
} }
setHost(host: string): void { setHost(host: string): void {
@@ -36,7 +43,7 @@ export class HttpService implements HttpServiceInterface {
url: joinPaths(this.host, path), url: joinPaths(this.host, path),
params, params,
verb: HttpVerb.Get, 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), url: joinPaths(this.host, path),
params, params,
verb: HttpVerb.Post, 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), url: joinPaths(this.host, path),
params, params,
verb: HttpVerb.Put, 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), url: joinPaths(this.host, path),
params, params,
verb: HttpVerb.Patch, 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), url: joinPaths(this.host, path),
params, params,
verb: HttpVerb.Delete, 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) 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 return response
} }
private async refreshSession(): Promise<boolean> {
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 { private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined {
if ( if (
httpRequest.params !== undefined && httpRequest.params !== undefined &&

View File

@@ -1,9 +1,11 @@
import { Session } from '@standardnotes/domain-core'
import { HttpRequestParams } from './HttpRequestParams' import { HttpRequestParams } from './HttpRequestParams'
import { HttpResponse } from './HttpResponse' import { HttpResponse } from './HttpResponse'
export interface HttpServiceInterface { export interface HttpServiceInterface {
setHost(host: string): void setHost(host: string): void
setAuthorizationToken(authorizationToken: string): void setSession(session: Session): void
get(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> get(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
post(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> post(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
put(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> put(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>

View File

@@ -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<SessionRefreshResponseBody, HttpErrorResponseBody>
}

View File

@@ -0,0 +1,9 @@
export type SessionRefreshResponseBody = {
session: {
access_token: string
refresh_token: string
access_expiration: number
refresh_expiration: number
readonly_access: boolean
}
}

View File

@@ -0,0 +1,9 @@
const SessionPaths = {
refreshSession: '/v1/sessions/refresh',
}
export const Paths = {
v1: {
...SessionPaths,
},
}

View File

@@ -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<HttpServiceInterface>
})
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<SubscriptionInviteResponse>)
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<SubscriptionInviteAcceptResponse>)
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<SubscriptionInviteDeclineResponse>)
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<SubscriptionInviteCancelResponse>)
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<Invitation>] },
} as jest.Mocked<SubscriptionInviteListResponse>)
const response = await createServer().listInvites({
api: ApiVersion.v0,
})
expect(response).toEqual({
data: {
invitations: [{} as jest.Mocked<Invitation>],
},
})
})
})

View File

@@ -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<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { user: { email: 'test@test.te', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
httpService.delete = jest.fn().mockReturnValue({
data: { message: 'Success' },
} as jest.Mocked<UserDeletionResponse>)
})
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',
},
})
})
})

View File

@@ -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<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { success: true },
} as jest.Mocked<UserRequestResponse>)
})
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,
},
})
})
})

View File

@@ -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<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
it('should create a websocket connection token', async () => {
const response = await createServer().createConnectionToken({})
expect(response).toEqual({
data: {
token: 'foobar',
},
})
})
})

View File

@@ -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<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { uuid: '1-2-3' },
} as jest.Mocked<WorkspaceCreationResponse>)
})
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<WorkspaceInvitationResponse>)
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<WorkspaceInvitationAcceptingResponse>)
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<WorkspaceListResponse>)
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<WorkspaceUserListResponse>)
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<WorkspaceKeyshareInitiatingResponse>)
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,
},
})
})
})

View File

@@ -82,6 +82,9 @@ import { SNLog } from '../Log'
import { ChallengeResponse, ListedClientInterface } from '../Services' import { ChallengeResponse, ListedClientInterface } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults' 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 */ /** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -154,6 +157,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private integrityService!: ExternalServices.IntegrityService private integrityService!: ExternalServices.IntegrityService
private statusService!: ExternalServices.StatusService private statusService!: ExternalServices.StatusService
private filesBackupService?: FilesBackupService private filesBackupService?: FilesBackupService
private declare sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
private internalEventBus!: ExternalServices.InternalEventBusInterface private internalEventBus!: ExternalServices.InternalEventBusInterface
@@ -1078,6 +1083,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
} }
private constructServices() { private constructServices() {
this.createMappers()
this.createPayloadManager() this.createPayloadManager()
this.createItemManager() this.createItemManager()
this.createDiskStorageManager() this.createDiskStorageManager()
@@ -1169,6 +1175,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this.mutatorService as unknown) = undefined ;(this.mutatorService as unknown) = undefined
;(this.filesBackupService as unknown) = undefined ;(this.filesBackupService as unknown) = undefined
;(this.statusService as unknown) = undefined ;(this.statusService as unknown) = undefined
;(this.sessionStorageMapper as unknown) = undefined
;(this.legacySessionStorageMapper as unknown) = undefined
this.services = [] this.services = []
} }
@@ -1289,6 +1297,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
environment: this.environment, environment: this.environment,
identifier: this.identifier, identifier: this.identifier,
internalEventBus: this.internalEventBus, internalEventBus: this.internalEventBus,
legacySessionStorageMapper: this.legacySessionStorageMapper,
}) })
this.services.push(this.migrationService) this.services.push(this.migrationService)
} }
@@ -1335,6 +1344,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.defaultHost, this.options.defaultHost,
this.inMemoryStore, this.inMemoryStore,
this.options.crypto, this.options.crypto,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus, this.internalEventBus,
) )
this.services.push(this.apiService) this.services.push(this.apiService)
@@ -1419,9 +1430,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.appVersion, this.options.appVersion,
SnjsVersion, SnjsVersion,
this.apiService.processMetaObject.bind(this.apiService), this.apiService.processMetaObject.bind(this.apiService),
this.apiService.setSession.bind(this.apiService),
) )
} }
private createMappers() {
this.sessionStorageMapper = new SessionStorageMapper()
this.legacySessionStorageMapper = new LegacySessionStorageMapper()
}
private createPayloadManager() { private createPayloadManager() {
this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus) this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus)
this.services.push(this.payloadManager) this.services.push(this.payloadManager)
@@ -1497,6 +1514,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.challengeService, this.challengeService,
this.webSocketsService, this.webSocketsService,
this.httpService, this.httpService,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus, this.internalEventBus,
) )
this.serviceObservers.push( this.serviceObservers.push(

View File

@@ -5,6 +5,7 @@ import { SNSessionManager } from '../Services/Session/SessionManager'
import { ApplicationIdentifier } from '@standardnotes/common' import { ApplicationIdentifier } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager' import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services' import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services'
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export type MigrationServices = { export type MigrationServices = {
protocolService: EncryptionService protocolService: EncryptionService
@@ -17,5 +18,6 @@ export type MigrationServices = {
featuresService: SNFeaturesService featuresService: SNFeaturesService
environment: Environment environment: Environment
identifier: ApplicationIdentifier identifier: ApplicationIdentifier
legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
internalEventBus: InternalEventBusInterface internalEventBus: InternalEventBusInterface
} }

View File

@@ -1,5 +1,4 @@
import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common' import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common'
import { JwtSession } from '../../Services/Session/Sessions/JwtSession'
import { Migration } from '@Lib/Migrations/Migration' import { Migration } from '@Lib/Migrations/Migration'
import { MigrationServices } from '../MigrationServices' import { MigrationServices } from '../MigrationServices'
import { PreviousSnjsVersion2_0_0 } from '../../Version' import { PreviousSnjsVersion2_0_0 } from '../../Version'
@@ -16,6 +15,7 @@ import {
PayloadTimestampDefaults, PayloadTimestampDefaults,
} from '@standardnotes/models' } from '@standardnotes/models'
import { isMobileDevice } from '@standardnotes/services' import { isMobileDevice } from '@standardnotes/services'
import { LegacySession } from '@standardnotes/domain-core'
interface LegacyStorageContent extends Models.ItemContent { interface LegacyStorageContent extends Models.ItemContent {
storage: unknown storage: unknown
@@ -673,8 +673,13 @@ export class Migration2_0_0 extends Migration {
} }
} }
const session = new JwtSession(currentToken) const sessionOrError = LegacySession.create(currentToken)
this.services.storageService.setValue(Services.StorageKey.Session, session) if (!sessionOrError.isFailed()) {
this.services.storageService.setValue(
Services.StorageKey.Session,
this.services.legacySessionStorageMapper.toProjection(sessionOrError.getValue()),
)
}
/** Server has to be migrated separately on mobile */ /** Server has to be migrated separately on mobile */
if (isEnvironmentMobile(this.services.environment)) { if (isEnvironmentMobile(this.services.environment)) {

View File

@@ -34,23 +34,22 @@ import {
API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS, API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS,
} from '@standardnotes/services' } from '@standardnotes/services'
import { FilesApiInterface } from '@standardnotes/files' import { FilesApiInterface } from '@standardnotes/files'
import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models' import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models'
import * as Responses from '@standardnotes/responses' 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 { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService'
import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
import { Paths } from './Paths' import { Paths } from './Paths'
import { Session } from '../Session/Sessions/Session'
import { TokenSession } from '../Session/Sessions/TokenSession'
import { DiskStorageService } from '../Storage/DiskStorageService' import { DiskStorageService } from '../Storage/DiskStorageService'
import { HttpResponseMeta } from '@standardnotes/api'
import { UuidString } from '../../Types/UuidString' import { UuidString } from '../../Types/UuidString'
import merge from 'lodash/merge' import merge from 'lodash/merge'
import { SettingsServerInterface } from '../Settings/SettingsServerInterface' import { SettingsServerInterface } from '../Settings/SettingsServerInterface'
import { Strings } from '@Lib/Strings' 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. */ /** Legacy api version field to be specified in params when calling v0 APIs. */
const V0_API_VERSION = '20200115' const V0_API_VERSION = '20200115'
@@ -66,7 +65,7 @@ export class SNApiService
ItemsServerInterface, ItemsServerInterface,
SettingsServerInterface SettingsServerInterface
{ {
private session?: Session private session: Session | LegacySession | null
public user?: Responses.User public user?: Responses.User
private registering = false private registering = false
private authenticating = false private authenticating = false
@@ -81,16 +80,20 @@ export class SNApiService
private host: string, private host: string,
private inMemoryStore: KeyValueStoreInterface<string>, private inMemoryStore: KeyValueStoreInterface<string>,
private crypto: PureCryptoInterface, private crypto: PureCryptoInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface, protected override internalEventBus: InternalEventBusInterface,
) { ) {
super(internalEventBus) super(internalEventBus)
this.session = null
} }
override deinit(): void { override deinit(): void {
;(this.httpService as unknown) = undefined ;(this.httpService as unknown) = undefined
;(this.storageService as unknown) = undefined ;(this.storageService as unknown) = undefined
this.invalidSessionObserver = undefined this.invalidSessionObserver = undefined
this.session = undefined this.session = null
super.deinit() super.deinit()
} }
@@ -145,14 +148,21 @@ export class SNApiService
return this.filesHost return this.filesHost
} }
public setSession(session: Session, persist = true): void { public setSession(session: Session | LegacySession, persist = true): void {
this.session = session this.session = session
if (persist) { if (persist) {
this.storageService.setValue(StorageKey.Session, session) let sessionProjection: Record<string, unknown>
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 return this.session
} }
@@ -252,7 +262,7 @@ export class SNApiService
fallbackErrorMessage: API_MESSAGE_GENERIC_INVALID_LOGIN, fallbackErrorMessage: API_MESSAGE_GENERIC_INVALID_LOGIN,
params, params,
/** A session is optional here, if valid, endpoint bypasses 2FA and returns additional 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<Responses.SignOutResponse> { signOut(): Promise<Responses.SignOutResponse> {
const url = joinPaths(this.host, Paths.v1.signOut) 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 return errorResponse
}) as Promise<Responses.SignOutResponse> }) as Promise<Responses.SignOutResponse>
} }
@@ -317,7 +327,7 @@ export class SNApiService
...parameters.newKeyParams.getPortableValue(), ...parameters.newKeyParams.getPortableValue(),
}) })
const response = await this.httpService const response = await this.httpService
.putAbsolute(url, params, this.session?.authorizationValue) .putAbsolute(url, params, this.getSessionAccessToken())
.catch(async (errorResponse) => { .catch(async (errorResponse) => {
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({ return this.refreshSessionThenRetryRequest({
@@ -353,7 +363,7 @@ export class SNApiService
[ApiEndpointParam.SyncDlLimit]: limit, [ApiEndpointParam.SyncDlLimit]: limit,
}) })
const response = await this.httpService const response = await this.httpService
.postAbsolute(url, params, this.session?.authorizationValue) .postAbsolute(url, params, this.getSessionAccessToken())
.catch<Responses.HttpResponse>(async (errorResponse) => { .catch<Responses.HttpResponse>(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -378,7 +388,7 @@ export class SNApiService
return this.httpService return this.httpService
.runHttp({ .runHttp({
...httpRequest, ...httpRequest,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
}) })
.catch((errorResponse) => { .catch((errorResponse) => {
return errorResponse return errorResponse
@@ -393,16 +403,54 @@ export class SNApiService
} }
this.refreshingSession = true this.refreshingSession = true
const url = joinPaths(this.host, Paths.v1.refreshSession) const url = joinPaths(this.host, Paths.v1.refreshSession)
const session = this.session as TokenSession const session = this.session as Session
const params = this.params({ const params = this.params({
access_token: session.accessToken, access_token: session.accessToken.value,
refresh_token: session.refreshToken, refresh_token: session.refreshToken.value,
}) })
const result = await this.httpService const result = await this.httpService
.postAbsolute(url, params) .postAbsolute(url, params)
.then(async (response) => { .then(async (response) => {
const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse) const sessionRenewalResponse = response as Responses.SessionRenewalResponse
await this.setSession(session) 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) this.processResponse(response)
return response return response
}) })
@@ -411,6 +459,11 @@ export class SNApiService
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL) return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL)
}) })
this.refreshingSession = false this.refreshingSession = false
if (result === null) {
return this.createErrorResponse(API_MESSAGE_INVALID_SESSION)
}
return result return result
} }
@@ -421,7 +474,7 @@ export class SNApiService
} }
const url = joinPaths(this.host, Paths.v1.sessions) const url = joinPaths(this.host, Paths.v1.sessions)
const response = await this.httpService const response = await this.httpService
.getAbsolute(url, {}, this.session?.authorizationValue) .getAbsolute(url, {}, this.getSessionAccessToken())
.catch(async (errorResponse) => { .catch(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -444,7 +497,7 @@ export class SNApiService
} }
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId)) const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService 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) => { .catch((error: Responses.HttpResponse) => {
const errorResponse = error as Responses.HttpResponse const errorResponse = error as Responses.HttpResponse
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
@@ -467,7 +520,7 @@ export class SNApiService
} }
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId)) const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue) .getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => { .catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -492,7 +545,7 @@ export class SNApiService
} }
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid)) const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue) .getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => { .catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -510,7 +563,7 @@ export class SNApiService
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> { async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid)) const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
const response = await this.httpService const response = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue) .getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => { .catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse) this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) { if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -550,7 +603,7 @@ export class SNApiService
verb: HttpVerb.Get, verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.settings(userUuid)), url: joinPaths(this.host, Paths.v1.settings(userUuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
}) })
} }
@@ -568,7 +621,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.UpdateSettingResponse>({ return this.tokenRefreshableRequest<Responses.UpdateSettingResponse>({
verb: HttpVerb.Put, verb: HttpVerb.Put,
url: joinPaths(this.host, Paths.v1.settings(userUuid)), url: joinPaths(this.host, Paths.v1.settings(userUuid)),
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
params, params,
}) })
@@ -578,7 +631,7 @@ export class SNApiService
return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({ return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({
verb: HttpVerb.Get, verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)), 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, fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
}) })
} }
@@ -593,7 +646,7 @@ export class SNApiService
this.host, this.host,
Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName), Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName),
), ),
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
}) })
} }
@@ -602,7 +655,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.DeleteSettingResponse>({ return this.tokenRefreshableRequest<Responses.DeleteSettingResponse>({
verb: HttpVerb.Delete, verb: HttpVerb.Delete,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)), url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)),
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS, fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
}) })
} }
@@ -616,7 +669,7 @@ export class SNApiService
verb: HttpVerb.Delete, verb: HttpVerb.Delete,
url, url,
fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION, fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
}) })
return response return response
} }
@@ -635,7 +688,7 @@ export class SNApiService
const response = await this.tokenRefreshableRequest({ const response = await this.tokenRefreshableRequest({
verb: HttpVerb.Get, verb: HttpVerb.Get,
url, url,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO, fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
}) })
return response return response
@@ -658,7 +711,7 @@ export class SNApiService
const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({ const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({
verb: HttpVerb.Post, verb: HttpVerb.Post,
url, url,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_ACCESS_PURCHASE, fallbackErrorMessage: API_MESSAGE_FAILED_ACCESS_PURCHASE,
}) })
return (response as Responses.PostSubscriptionTokensResponse).data?.token return (response as Responses.PostSubscriptionTokensResponse).data?.token
@@ -706,7 +759,7 @@ export class SNApiService
verb: HttpVerb.Post, verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)), url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_LISTED_REGISTRATION, 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<Responses.CreateValetTokenResponse>({ const response = await this.tokenRefreshableRequest<Responses.CreateValetTokenResponse>({
verb: HttpVerb.Post, verb: HttpVerb.Post,
url: url, url: url,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_CREATE_FILE_TOKEN, fallbackErrorMessage: API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
params, params,
}) })
@@ -860,7 +913,7 @@ export class SNApiService
integrityPayloads, integrityPayloads,
}, },
fallbackErrorMessage: API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL, fallbackErrorMessage: API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL,
authentication: this.session?.authorizationValue, authentication: this.getSessionAccessToken(),
}) })
} }
@@ -869,7 +922,7 @@ export class SNApiService
verb: HttpVerb.Get, verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)), url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)),
fallbackErrorMessage: API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL, 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<DiagnosticInfo | undefined> { override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({ return Promise.resolve({
api: { api: {

View File

@@ -1,6 +1,5 @@
export * from './ApiService' export * from './ApiService'
export * from './HttpService' export * from './HttpService'
export * from './Paths' export * from './Paths'
export * from '../Session/Sessions/Session'
export * from '../Session/SessionManager' export * from '../Session/SessionManager'
export * from './WebsocketsService' export * from './WebsocketsService'

View File

@@ -290,7 +290,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
if (operation.isFinished()) { if (operation.isFinished()) {
this.deleteChallengeOperation(operation) this.deleteChallengeOperation(operation)
const observers = this.challengeObservers[challenge.id] const observers = this.challengeObservers[challenge.id] || []
observers.forEach(clearChallengeObserver) observers.forEach(clearChallengeObserver)
observers.length = 0 observers.length = 0

View File

@@ -0,0 +1,20 @@
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export class LegacySessionStorageMapper implements MapperInterface<LegacySession, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): 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<string, unknown> {
return {
jwt: domain.accessToken,
}
}
}

View File

@@ -0,0 +1,40 @@
import { MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
export class SessionStorageMapper implements MapperInterface<Session, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): 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<string, unknown> {
return {
accessToken: domain.accessToken.value,
refreshToken: domain.refreshToken.value,
accessExpiration: domain.accessToken.expiresAt,
refreshExpiration: domain.refreshToken.expiresAt,
readonlyAccess: domain.isReadOnly(),
}
}
}

View File

@@ -27,21 +27,19 @@ import { Base64String } from '@standardnotes/sncrypto-common'
import { ClientDisplayableError } from '@standardnotes/responses' import { ClientDisplayableError } from '@standardnotes/responses'
import { CopyPayloadWithContentOverride } from '@standardnotes/models' import { CopyPayloadWithContentOverride } from '@standardnotes/models'
import { isNullOrUndefined } from '@standardnotes/utils' 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 { 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 { RemoteSession, RawStorageValue } from './Sessions/Types'
import { Session } from './Sessions/Session'
import { SessionFromRawStorageValue } from './Sessions/Generator'
import { ShareToken } from './ShareToken' import { ShareToken } from './ShareToken'
import { SNApiService } from '../Api/ApiService' import { SNApiService } from '../Api/ApiService'
import { DiskStorageService } from '../Storage/DiskStorageService' import { DiskStorageService } from '../Storage/DiskStorageService'
import { SNWebSocketsService } from '../Api/WebsocketsService' import { SNWebSocketsService } from '../Api/WebsocketsService'
import { Strings } from '@Lib/Strings' import { Strings } from '@Lib/Strings'
import { Subscription } from '@standardnotes/security'
import { TokenSession } from './Sessions/TokenSession'
import { UuidString } from '@Lib/Types/UuidString' import { UuidString } from '@Lib/Types/UuidString'
import * as Common from '@standardnotes/common'
import * as Responses from '@standardnotes/responses'
import { ChallengeService } from '../Challenge' import { ChallengeService } from '../Challenge'
import { import {
ApiCallError, ApiCallError,
@@ -82,6 +80,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
private challengeService: ChallengeService, private challengeService: ChallengeService,
private webSocketsService: SNWebSocketsService, private webSocketsService: SNWebSocketsService,
private httpService: HttpServiceInterface, private httpService: HttpServiceInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface, protected override internalEventBus: InternalEventBusInterface,
) { ) {
super(internalEventBus) super(internalEventBus)
@@ -122,13 +122,23 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const rawSession = this.diskStorageService.getValue<RawStorageValue>(StorageKey.Session) const rawSession = this.diskStorageService.getValue<RawStorageValue>(StorageKey.Session)
if (rawSession) { if (rawSession) {
const session = SessionFromRawStorageValue(rawSession) try {
this.setSession(session, false) 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 { private setSession(session: Session | LegacySession, persist = true): void {
this.httpService.setAuthorizationToken(session.authorizationValue) if (session instanceof Session) {
this.httpService.setSession(session)
}
this.apiService.setSession(session, persist) this.apiService.setSession(session, persist)
@@ -158,7 +168,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
public async signOut() { public async signOut() {
this.setUser(undefined) this.setUser(undefined)
const session = this.apiService.getSession() const session = this.apiService.getSession()
if (session && session.canExpire()) { if (session && session instanceof Session) {
await this.apiService.signOut() await this.apiService.signOut()
this.webSocketsService.closeWebSocketConnection() this.webSocketsService.closeWebSocketConnection()
} }
@@ -560,17 +570,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
if (!session) { if (!session) {
return new ClientDisplayableError('Cannot generate share token without active 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') return new ClientDisplayableError('Cannot generate share token with non-token session')
} }
const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams
const payload: ShareToken = { const payload: ShareToken = {
accessToken: session.accessToken, accessToken: session.accessToken.value,
refreshToken: session.refreshToken, refreshToken: session.refreshToken.value,
accessExpiration: session.accessExpiration, accessExpiration: session.accessToken.expiresAt,
refreshExpiration: session.refreshExpiration, refreshExpiration: session.refreshToken.expiresAt,
readonlyAccess: true, readonlyAccess: true,
masterKey: this.protocolService.getRootKey()?.masterKey as string, masterKey: this.protocolService.getRootKey()?.masterKey as string,
keyParams: keyParams.content, keyParams: keyParams.content,
@@ -597,7 +607,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const user = sharePayload.user const user = sharePayload.user
const session = new TokenSession( const session = this.createSession(
sharePayload.accessToken, sharePayload.accessToken,
sharePayload.accessExpiration, sharePayload.accessExpiration,
sharePayload.refreshToken, sharePayload.refreshToken,
@@ -605,13 +615,15 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
sharePayload.readonlyAccess, sharePayload.readonlyAccess,
) )
await this.populateSession(rootKey, user, session, sharePayload.host) if (session !== null) {
await this.populateSession(rootKey, user, session, sharePayload.host)
}
} }
private async populateSession( private async populateSession(
rootKey: SNRootKey, rootKey: SNRootKey,
user: Responses.User, user: Responses.User,
session: Session, session: Session | LegacySession,
host: string, host: string,
wrappingKey?: SNRootKey, wrappingKey?: SNRootKey,
) { ) {
@@ -629,14 +641,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
} }
private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) { private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) {
const session = new TokenSession( const session = this.createSession(
body.session.access_token, body.session.access_token,
body.session.access_expiration, body.session.access_expiration,
body.session.refresh_token, body.session.refresh_token,
body.session.refresh_expiration, body.session.refresh_expiration,
body.session.readonly_access, 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<SessionEvent> implements S
const isLegacyJwtResponse = data.token != undefined const isLegacyJwtResponse = data.token != undefined
if (isLegacyJwtResponse) { if (isLegacyJwtResponse) {
const session = new JwtSession(data.token as string) const sessionOrError = LegacySession.create(data.token as string)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) if (!sessionOrError.isFailed()) {
await this.populateSession(rootKey, user, sessionOrError.getValue(), this.apiService.getHost(), wrappingKey)
}
} else if (data.session) { } else if (data.session) {
const session = TokenSession.FromApiResponse(response) const session = this.createSession(
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) 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<DiagnosticInfo | undefined> { override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({ return Promise.resolve({
session: { session: {

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -1,5 +1 @@
export * from './Generator'
export * from './JwtSession'
export * from './Session'
export * from './TokenSession'
export * from './Types' export * from './Types'

View File

@@ -31,7 +31,7 @@ describe('server session', function () {
async function sleepUntilSessionExpires(application, basedOnAccessToken = true) { async function sleepUntilSessionExpires(application, basedOnAccessToken = true) {
const currentSession = application.apiService.session 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 const timeRemaining = (timestamp - Date.now()) / 1000 // in ms
/* /*
If the token has not expired yet, we will return the remaining time. 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. // After the above sync request is completed, we obtain the session information.
const sessionAfterSync = this.application.apiService.getSession() const sessionAfterSync = this.application.apiService.getSession()
expect(sessionBeforeSync).to.not.equal(sessionAfterSync) expect(sessionBeforeSync.equals(sessionAfterSync)).to.not.equal(true)
expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken) expect(sessionBeforeSync.accessToken.value).to.not.equal(sessionAfterSync.accessToken.value)
expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken) expect(sessionBeforeSync.refreshToken.value).to.not.equal(sessionAfterSync.refreshToken.value)
expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration) expect(sessionBeforeSync.accessToken.expiresAt).to.be.lessThan(sessionAfterSync.accessToken.expiresAt)
// New token should expire in the future. // 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 () { 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 sessionFromStorage = await getSessionFromStorage(this.application)
const sessionFromApiService = this.application.apiService.getSession() 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() await this.application.apiService.refreshSession()
const updatedSessionFromStorage = await getSessionFromStorage(this.application) const updatedSessionFromStorage = await getSessionFromStorage(this.application)
const updatedSessionFromApiService = this.application.apiService.getSession() 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 () { 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({ let { application, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true, 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) Factory.ignoreChallenges(application)
const newEmail = UuidGenerator.GenerateUuid() const newEmail = UuidGenerator.GenerateUuid()
@@ -311,8 +327,15 @@ describe('server session', function () {
password: this.password, password: this.password,
}) })
const fakeSession = this.application.apiService.getSession() this.application.diskStorageService.setValue(StorageKey.Session, {
fakeSession.accessToken = 'this-is-a-fake-token-1234' 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) Factory.ignoreChallenges(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.') expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.')
@@ -354,7 +377,7 @@ describe('server session', function () {
expect(currentSession).to.be.ok expect(currentSession).to.be.ok
expect(currentSession.accessToken).to.be.ok expect(currentSession.accessToken).to.be.ok
expect(currentSession.refreshToken).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 () { 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, password: this.password,
}) })
const fakeSession = this.application.apiService.getSession() const originalSession = this.application.apiService.getSession()
fakeSession.refreshToken = 'this-is-a-fake-token-1234'
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() const refreshSessionResponse = await this.application.apiService.refreshSession()
@@ -530,8 +559,14 @@ describe('server session', function () {
const oldRootKey = await appA.protocolService.getRootKey() const oldRootKey = await appA.protocolService.getRootKey()
/** Set the session as nonsense */ /** Set the session as nonsense */
appA.apiService.session.accessToken = 'foo' appA.diskStorageService.setValue(StorageKey.Session, {
appA.apiService.session.refreshToken = 'bar' accessToken: 'foo',
refreshToken: 'bar',
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
appA.sessions.initializeFromDisk()
/** Perform an authenticated network request */ /** Perform an authenticated network request */
await appA.sync.sync() await appA.sync.sync()
@@ -540,8 +575,8 @@ describe('server session', function () {
await Factory.sleep(5.0) await Factory.sleep(5.0)
expect(didPromptForSignIn).to.equal(true) expect(didPromptForSignIn).to.equal(true)
expect(appA.apiService.session.accessToken).to.not.equal('foo') expect(appA.apiService.session.accessToken.value).to.not.equal('foo')
expect(appA.apiService.session.refreshToken).to.not.equal('bar') expect(appA.apiService.session.refreshToken.value).to.not.equal('bar')
/** Expect that the session recovery replaces the global root key */ /** Expect that the session recovery replaces the global root key */
const newRootKey = await appA.protocolService.getRootKey() const newRootKey = await appA.protocolService.getRootKey()
@@ -646,9 +681,14 @@ describe('server session', function () {
password: this.password, password: this.password,
}) })
const invalidSession = this.application.apiService.getSession() this.application.diskStorageService.setValue(StorageKey.Session, {
invalidSession.accessToken = undefined accessToken: undefined,
invalidSession.refreshToken = undefined refreshToken: undefined,
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
const storageKey = this.application.diskStorageService.getPersistenceKey() const storageKey = this.application.diskStorageService.getPersistenceKey()
expect(localStorage.getItem(storageKey)).to.be.ok expect(localStorage.getItem(storageKey)).to.be.ok

View File

@@ -2,7 +2,7 @@ import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised) chai.use(chaiAsPromised)
const expect = chai.expect const expect = chai.expect
describe.skip('subscriptions', function () { describe('subscriptions', function () {
this.timeout(Factory.TwentySecondTimeout) this.timeout(Factory.TwentySecondTimeout)
let application let application

View File

@@ -83,5 +83,8 @@
"webpack": "*", "webpack": "*",
"webpack-cli": "*", "webpack-cli": "*",
"webpack-merge": "^5.8.0" "webpack-merge": "^5.8.0"
},
"dependencies": {
"@standardnotes/domain-core": "^1.11.0"
} }
} }

View File

@@ -5384,6 +5384,7 @@ __metadata:
resolution: "@standardnotes/api@workspace:packages/api" resolution: "@standardnotes/api@workspace:packages/api"
dependencies: dependencies:
"@standardnotes/common": ^1.45.0 "@standardnotes/common": ^1.45.0
"@standardnotes/domain-core": ^1.11.0
"@standardnotes/encryption": "workspace:*" "@standardnotes/encryption": "workspace:*"
"@standardnotes/models": "workspace:*" "@standardnotes/models": "workspace:*"
"@standardnotes/responses": "workspace:*" "@standardnotes/responses": "workspace:*"
@@ -5610,6 +5611,17 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft 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": "@standardnotes/domain-events@npm:^2.88.0":
version: 2.88.0 version: 2.88.0
resolution: "@standardnotes/domain-events@npm:2.88.0" resolution: "@standardnotes/domain-events@npm:2.88.0"
@@ -6088,6 +6100,7 @@ __metadata:
"@babel/preset-env": "*" "@babel/preset-env": "*"
"@standardnotes/api": "workspace:*" "@standardnotes/api": "workspace:*"
"@standardnotes/common": ^1.45.0 "@standardnotes/common": ^1.45.0
"@standardnotes/domain-core": ^1.11.0
"@standardnotes/domain-events": ^2.88.0 "@standardnotes/domain-events": ^2.88.0
"@standardnotes/encryption": "workspace:*" "@standardnotes/encryption": "workspace:*"
"@standardnotes/features": "workspace:*" "@standardnotes/features": "workspace:*"
@@ -27198,6 +27211,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "shallowequal@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "shallowequal@npm:1.1.0" resolution: "shallowequal@npm:1.1.0"