feat: add services package

This commit is contained in:
Karol Sójko
2022-07-05 20:51:42 +02:00
parent b614c71e79
commit fbfed0a05c
85 changed files with 2418 additions and 28 deletions

View File

@@ -0,0 +1,28 @@
import { ClientDisplayableError } from '@standardnotes/responses'
/* istanbul ignore file */
export enum ButtonType {
Info = 0,
Danger = 1,
}
export type DismissBlockingDialog = () => void
export abstract class AlertService {
abstract confirm(
text: string,
title?: string,
confirmButtonText?: string,
confirmButtonType?: ButtonType,
cancelButtonText?: string,
): Promise<boolean>
abstract alert(text: string, title?: string, closeButtonText?: string): Promise<void>
abstract blockingDialog(text: string, title?: string): DismissBlockingDialog | Promise<DismissBlockingDialog>
showErrorAlert(error: ClientDisplayableError): Promise<void> {
return this.alert(error.text, error.title)
}
}

View File

@@ -0,0 +1,19 @@
import { AbstractService } from '../Service/AbstractService'
import { Uuid } from '@standardnotes/common'
import { Role } from '@standardnotes/auth'
import { FilesApiInterface } from '../Files/FilesApiInterface'
/* istanbul ignore file */
export enum ApiServiceEvent {
MetaReceived = 'MetaReceived',
}
export type MetaReceivedData = {
userUuid: Uuid
userRoles: Role[]
}
export interface ApiServiceInterface
extends AbstractService<ApiServiceEvent.MetaReceived, MetaReceivedData>,
FilesApiInterface {}

View File

@@ -0,0 +1,22 @@
import { ApplicationIdentifier } from '@standardnotes/common'
import { DeinitCallback } from './DeinitCallback'
import { DeinitMode } from './DeinitMode'
import { DeinitSource } from './DeinitSource'
import { UserClientInterface } from './UserClientInterface'
export interface ApplicationInterface {
deinit(mode: DeinitMode, source: DeinitSource): void
getDeinitMode(): DeinitMode
get user(): UserClientInterface
readonly identifier: ApplicationIdentifier
}
export interface AppGroupManagedApplication extends ApplicationInterface {
onDeinit: DeinitCallback
setOnDeinit(onDeinit: DeinitCallback): void
}

View File

@@ -0,0 +1,11 @@
/* istanbul ignore file */
export enum ApplicationStage {
PreparingForLaunch_0 = 0.0,
ReadyForLaunch_05 = 0.5,
StorageDecrypted_09 = 0.9,
Launched_10 = 1.0,
LoadingDatabase_11 = 1.1,
LoadedDatabase_12 = 1.2,
FullSyncCompleted_13 = 1.3,
SignedIn_30 = 3.0,
}

View File

@@ -0,0 +1,5 @@
import { DeinitSource } from './DeinitSource'
import { DeinitMode } from './DeinitMode'
import { AppGroupManagedApplication } from './ApplicationInterface'
export type DeinitCallback = (application: AppGroupManagedApplication, mode: DeinitMode, source: DeinitSource) => void

View File

@@ -0,0 +1,6 @@
/* istanbul ignore file */
export enum DeinitMode {
Soft = 'Soft',
Hard = 'Hard',
}

View File

@@ -0,0 +1,8 @@
/* istanbul ignore file */
export enum DeinitSource {
SignOut = 1,
Lock,
SwitchWorkspace,
SignOutAll,
}

View File

@@ -0,0 +1,9 @@
import { DeinitSource } from './DeinitSource'
export interface UserClientInterface {
deleteAccount(): Promise<{
error: boolean
message?: string
}>
signOut(force?: boolean, source?: DeinitSource): Promise<void>
}

View File

@@ -0,0 +1,21 @@
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
import { ChallengeReason } from './Types/ChallengeReason'
import { ChallengeValidation } from './Types/ChallengeValidation'
export interface ChallengeInterface {
readonly id: number
readonly prompts: ChallengePromptInterface[]
readonly reason: ChallengeReason
readonly cancelable: boolean
/** Outside of the modal, this is the title of the modal itself */
get modalTitle(): string
/** Inside of the modal, this is the H1 */
get heading(): string | undefined
/** Inside of the modal, this is the H2 */
get subheading(): string | undefined
hasPromptForValidationType(type: ChallengeValidation): boolean
}

View File

@@ -0,0 +1,13 @@
import { ChallengeInterface } from './ChallengeInterface'
import { ChallengeArtifacts } from './Types/ChallengeArtifacts'
import { ChallengeValidation } from './Types/ChallengeValidation'
import { ChallengeValue } from './Types/ChallengeValue'
export interface ChallengeResponseInterface {
readonly challenge: ChallengeInterface
readonly values: ChallengeValue[]
readonly artifacts?: ChallengeArtifacts
getValueForType(type: ChallengeValidation): ChallengeValue
getDefaultValue(): ChallengeValue
}

View File

@@ -0,0 +1,23 @@
import { AbstractService } from '../Service/AbstractService'
import { ChallengeInterface } from './ChallengeInterface'
import { ChallengePromptInterface } from './Prompt/ChallengePromptInterface'
import { ChallengeResponseInterface } from './ChallengeResponseInterface'
import { ChallengeReason } from './Types/ChallengeReason'
export interface ChallengeServiceInterface extends AbstractService {
/**
* Resolves when the challenge has been completed.
* For non-validated challenges, will resolve when the first value is submitted.
*/
promptForChallengeResponse(challenge: ChallengeInterface): Promise<ChallengeResponseInterface | undefined>
createChallenge(
prompts: ChallengePromptInterface[],
reason: ChallengeReason,
cancelable: boolean,
heading?: string,
subheading?: string,
): ChallengeInterface
completeChallenge(challenge: ChallengeInterface): void
}

View File

@@ -0,0 +1,55 @@
import { assertUnreachable } from '@standardnotes/utils'
import { ChallengeKeyboardType } from '../Types/ChallengeKeyboardType'
import { ChallengeRawValue } from '../Types/ChallengeRawValue'
import { ChallengeValidation } from '../Types/ChallengeValidation'
import { ChallengePromptInterface } from './ChallengePromptInterface'
import { ChallengePromptTitle } from './PromptTitles'
/* istanbul ignore file */
export class ChallengePrompt implements ChallengePromptInterface {
public readonly id = Math.random()
public readonly placeholder: string
public readonly title: string
public readonly validates: boolean
constructor(
public readonly validation: ChallengeValidation,
title?: string,
placeholder?: string,
public readonly secureTextEntry = true,
public readonly keyboardType?: ChallengeKeyboardType,
public readonly initialValue?: ChallengeRawValue,
) {
switch (this.validation) {
case ChallengeValidation.AccountPassword:
this.title = title ?? ChallengePromptTitle.AccountPassword
this.placeholder = placeholder ?? ChallengePromptTitle.AccountPassword
this.validates = true
break
case ChallengeValidation.LocalPasscode:
this.title = title ?? ChallengePromptTitle.LocalPasscode
this.placeholder = placeholder ?? ChallengePromptTitle.LocalPasscode
this.validates = true
break
case ChallengeValidation.Biometric:
this.title = title ?? ChallengePromptTitle.Biometrics
this.placeholder = placeholder ?? ''
this.validates = true
break
case ChallengeValidation.ProtectionSessionDuration:
this.title = title ?? ChallengePromptTitle.RememberFor
this.placeholder = placeholder ?? ''
this.validates = true
break
case ChallengeValidation.None:
this.title = title ?? ''
this.placeholder = placeholder ?? ''
this.validates = false
break
default:
assertUnreachable(this.validation)
}
Object.freeze(this)
}
}

View File

@@ -0,0 +1,19 @@
import { ChallengeKeyboardType } from '../Types/ChallengeKeyboardType'
import { ChallengeRawValue } from '../Types/ChallengeRawValue'
import { ChallengeValidation } from '../Types/ChallengeValidation'
/**
* A Challenge can have many prompts. Each prompt represents a unique input,
* such as a text field, or biometric scanner.
*/
export interface ChallengePromptInterface {
readonly id: number
readonly placeholder: string
readonly title: string
readonly validates: boolean
readonly validation: ChallengeValidation
readonly secureTextEntry: boolean
readonly keyboardType?: ChallengeKeyboardType
readonly initialValue?: ChallengeRawValue
}

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
export const ChallengePromptTitle = {
AccountPassword: 'Account Password',
LocalPasscode: 'Application Passcode',
Biometrics: 'Biometrics',
RememberFor: 'Remember For',
Mfa: 'Two-factor Authentication Code',
}

View File

@@ -0,0 +1,8 @@
import { RootKeyInterface } from '@standardnotes/models'
/* istanbul ignore file */
export type ChallengeArtifacts = {
wrappingKey?: RootKeyInterface
rootKey?: RootKeyInterface
}

View File

@@ -0,0 +1,7 @@
/* istanbul ignore file */
/** For mobile */
export enum ChallengeKeyboardType {
Alphanumeric = 'default',
Numeric = 'numeric',
}

View File

@@ -0,0 +1,3 @@
/* istanbul ignore file */
export type ChallengeRawValue = number | string | boolean

View File

@@ -0,0 +1,27 @@
/* istanbul ignore file */
export enum ChallengeReason {
AccessProtectedFile,
AccessProtectedNote,
AddPasscode,
ApplicationUnlock,
ChangeAutolockInterval,
ChangePasscode,
CreateDecryptedBackupWithProtectedItems,
Custom,
DecryptEncryptedFile,
DisableBiometrics,
DisableMfa,
ExportBackup,
ImportFile,
Migration,
ProtocolUpgrade,
RemovePasscode,
ResaveRootKey,
RevokeSession,
SearchProtectedNotesText,
SelectProtectedNote,
UnprotectFile,
UnprotectNote,
DeleteAccount,
}

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
export enum ChallengeValidation {
None = 0,
LocalPasscode = 1,
AccountPassword = 2,
Biometric = 3,
ProtectionSessionDuration = 4,
}

View File

@@ -0,0 +1,13 @@
import { ChallengePromptInterface } from '../Prompt/ChallengePromptInterface'
import { ChallengeRawValue } from './ChallengeRawValue'
export interface ChallengeValue {
readonly prompt: ChallengePromptInterface
readonly value: ChallengeRawValue
}
/* istanbul ignore file */
export function CreateChallengeValue(prompt: ChallengePromptInterface, value: ChallengeRawValue): ChallengeValue {
return { prompt, value }
}

View File

@@ -0,0 +1,12 @@
export * from './ChallengeInterface'
export * from './ChallengeResponseInterface'
export * from './ChallengeServiceInterface'
export * from './Prompt/ChallengePrompt'
export * from './Prompt/ChallengePromptInterface'
export * from './Prompt/PromptTitles'
export * from './Types/ChallengeArtifacts'
export * from './Types/ChallengeKeyboardType'
export * from './Types/ChallengeRawValue'
export * from './Types/ChallengeReason'
export * from './Types/ChallengeValidation'
export * from './Types/ChallengeValue'

View File

@@ -0,0 +1,14 @@
import { WebClientRequiresDesktopMethods } from './DesktopWebCommunication'
import { DeviceInterface } from './DeviceInterface'
import { Environment } from './Environments'
import { WebOrDesktopDeviceInterface } from './WebOrDesktopDeviceInterface'
/* istanbul ignore file */
export function isDesktopDevice(x: DeviceInterface): x is DesktopDeviceInterface {
return x.environment === Environment.Desktop
}
export interface DesktopDeviceInterface extends WebOrDesktopDeviceInterface, WebClientRequiresDesktopMethods {
environment: Environment.Desktop
}

View File

@@ -0,0 +1,38 @@
import { DecryptedTransferPayload } from '@standardnotes/models'
import { FileBackupsDevice } from './FileBackupsDevice'
export interface WebClientRequiresDesktopMethods extends FileBackupsDevice {
localBackupsCount(): Promise<number>
viewlocalBackups(): void
deleteLocalBackups(): Promise<void>
syncComponents(payloads: unknown[]): void
onMajorDataChange(): void
onInitialDataLoad(): void
onSearch(text?: string): void
downloadBackup(): void | Promise<void>
get extensionsServerHost(): string
}
export interface DesktopClientRequiresWebMethods {
updateAvailable(): void
windowGainedFocus(): void
windowLostFocus(): void
onComponentInstallationComplete(componentData: DecryptedTransferPayload, error: unknown): Promise<void>
requestBackupFile(): Promise<string | undefined>
didBeginBackup(): void
didFinishBackup(success: boolean): void
}

View File

@@ -0,0 +1,84 @@
import { Environment } from './Environments'
import { ApplicationIdentifier } from '@standardnotes/common'
import {
FullyFormedTransferPayload,
TransferPayload,
LegacyRawKeychainValue,
NamespacedRootKeyInKeychain,
} from '@standardnotes/models'
/**
* Platforms must override this class to provide platform specific utilities
* and access to the migration service, such as exposing an interface to read
* raw values from the database or value storage.
*/
export interface DeviceInterface {
environment: Environment
deinit(): void
getRawStorageValue(key: string): Promise<string | undefined>
getJsonParsedRawStorageValue(key: string): Promise<unknown | undefined>
getAllRawStorageKeyValues(): Promise<{ key: string; value: unknown }[]>
setRawStorageValue(key: string, value: string): Promise<void>
removeRawStorageValue(key: string): Promise<void>
removeAllRawStorageValues(): Promise<void>
/**
* On web platforms, databased created may be new.
* New databases can be because of new sessions, or if the browser deleted it.
* In this case, callers should orchestrate with the server to redownload all items
* from scratch.
* @returns { isNewDatabase } - True if the database was newly created
*/
openDatabase(identifier: ApplicationIdentifier): Promise<{ isNewDatabase?: boolean } | undefined>
/**
* In a key/value database, this function returns just the keys.
*/
getDatabaseKeys(): Promise<string[]>
/**
* Remove all keychain and database data from device.
* @param workspaceIdentifiers An array of identifiers present during time of function call. Used in case
* caller needs to reference the identifiers. This param should not be used to selectively clear workspaces.
* @returns true for killsApplication if the clear data operation kills the application process completely.
* This tends to be the case for the desktop application.
*/
clearAllDataFromDevice(workspaceIdentifiers: ApplicationIdentifier[]): Promise<{ killsApplication: boolean }>
getAllRawDatabasePayloads<T extends FullyFormedTransferPayload = FullyFormedTransferPayload>(
identifier: ApplicationIdentifier,
): Promise<T[]>
saveRawDatabasePayload(payload: TransferPayload, identifier: ApplicationIdentifier): Promise<void>
saveRawDatabasePayloads(payloads: TransferPayload[], identifier: ApplicationIdentifier): Promise<void>
removeRawDatabasePayloadWithId(id: string, identifier: ApplicationIdentifier): Promise<void>
removeAllRawDatabasePayloads(identifier: ApplicationIdentifier): Promise<void>
getNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<NamespacedRootKeyInKeychain | undefined>
setNamespacedKeychainValue(value: NamespacedRootKeyInKeychain, identifier: ApplicationIdentifier): Promise<void>
clearNamespacedKeychainValue(identifier: ApplicationIdentifier): Promise<void>
setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void>
clearRawKeychainValue(): Promise<void>
openUrl(url: string): void
performSoftReset(): void
performHardReset(): void
isDeviceDestroyed(): boolean
}

View File

@@ -0,0 +1,16 @@
export enum Environment {
Web = 1,
Desktop = 2,
Mobile = 3,
}
export enum Platform {
Ios = 1,
Android = 2,
MacWeb = 3,
MacDesktop = 4,
WindowsWeb = 5,
WindowsDesktop = 6,
LinuxWeb = 7,
LinuxDesktop = 8,
}

View File

@@ -0,0 +1,51 @@
import { Uuid } from '@standardnotes/common'
import { BackupFileEncryptedContextualPayload } from '@standardnotes/models'
/* istanbul ignore file */
export const FileBackupsConstantsV1 = {
Version: '1.0.0',
MetadataFileName: 'metadata.sn.json',
BinaryFileName: 'file.encrypted',
}
export interface FileBackupMetadataFile {
info: Record<string, string>
file: BackupFileEncryptedContextualPayload
itemsKey: BackupFileEncryptedContextualPayload
version: '1.0.0'
}
export interface FileBackupsMapping {
version: typeof FileBackupsConstantsV1.Version
files: Record<
Uuid,
{
backedUpOn: Date
absolutePath: string
relativePath: string
metadataFileName: typeof FileBackupsConstantsV1.MetadataFileName
binaryFileName: typeof FileBackupsConstantsV1.BinaryFileName
version: typeof FileBackupsConstantsV1.Version
}
>
}
export interface FileBackupsDevice {
getFilesBackupsMappingFile(): Promise<FileBackupsMapping>
saveFilesBackupsFile(
uuid: Uuid,
metaFile: string,
downloadRequest: {
chunkSizes: number[]
valetToken: string
url: string
},
): Promise<'success' | 'failed'>
isFilesBackupsEnabled(): Promise<boolean>
enableFilesBackups(): Promise<void>
disableFilesBackups(): Promise<void>
changeFilesBackupsLocation(): Promise<string | undefined>
getFilesBackupsLocation(): Promise<string>
openFilesBackupsLocation(): Promise<void>
}

View File

@@ -0,0 +1,9 @@
import { DeviceInterface } from './DeviceInterface'
import { Environment } from './Environments'
import { RawKeychainValue } from '@standardnotes/models'
export interface MobileDeviceInterface extends DeviceInterface {
environment: Environment.Mobile
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
}

View File

@@ -0,0 +1,18 @@
import { DeviceInterface } from './DeviceInterface'
import { Environment } from './Environments'
import { MobileDeviceInterface } from './MobileDeviceInterface'
import { isMobileDevice } from './TypeCheck'
describe('device type check', () => {
it('should return true for mobile devices', () => {
const device = { environment: Environment.Mobile } as jest.Mocked<MobileDeviceInterface>
expect(isMobileDevice(device)).toBeTruthy()
})
it('should return false for non mobile devices', () => {
const device = { environment: Environment.Web } as jest.Mocked<DeviceInterface>
expect(isMobileDevice(device)).toBeFalsy()
})
})

View File

@@ -0,0 +1,9 @@
import { Environment } from './Environments'
import { MobileDeviceInterface } from './MobileDeviceInterface'
import { DeviceInterface } from './DeviceInterface'
/* istanbul ignore file */
export function isMobileDevice(x: DeviceInterface): x is MobileDeviceInterface {
return x.environment === Environment.Mobile
}

View File

@@ -0,0 +1,10 @@
import { DeviceInterface } from './DeviceInterface'
import { RawKeychainValue } from '@standardnotes/models'
export interface WebOrDesktopDeviceInterface extends DeviceInterface {
readonly appVersion: string
getKeychainValue(): Promise<RawKeychainValue>
setKeychainValue(value: RawKeychainValue): Promise<void>
}

View File

@@ -0,0 +1,17 @@
type DiagnosticValue =
| string
| number
| Date
| boolean
| null
| undefined
| DiagnosticValue[]
| { [key: string]: DiagnosticValue }
export type DiagnosticInfo = {
[key: string]: Record<string, DiagnosticValue>
}
export interface ServiceDiagnostics {
getDiagnostics(): Promise<DiagnosticInfo | undefined>
}

View File

@@ -0,0 +1 @@
export type EventObserver<E, D> = (eventName: E, data?: D) => Promise<void> | void

View File

@@ -0,0 +1,25 @@
/* istanbul ignore file */
export enum SyncEvent {
/**
* A potentially multi-round trip that keeps syncing until all items have been uploaded.
* However, this event will still trigger if there are more items waiting to be downloaded on the
* server
*/
SyncCompletedWithAllItemsUploaded = 'SyncCompletedWithAllItemsUploaded',
SyncCompletedWithAllItemsUploadedAndDownloaded = 'SyncCompletedWithAllItemsUploadedAndDownloaded',
SingleRoundTripSyncCompleted = 'SingleRoundTripSyncCompleted',
SyncWillBegin = 'sync:will-begin',
DownloadFirstSyncCompleted = 'sync:download-first-completed',
SyncTakingTooLong = 'sync:taking-too-long',
SyncError = 'sync:error',
InvalidSession = 'sync:invalid-session',
MajorDataChange = 'major-data-change',
LocalDataIncrementalLoad = 'local-data-incremental-load',
LocalDataLoaded = 'local-data-loaded',
EnterOutOfSync = 'enter-out-of-sync',
ExitOutOfSync = 'exit-out-of-sync',
StatusChanged = 'status-changed',
DatabaseWriteError = 'database-write-error',
DatabaseReadError = 'database-read-error',
SyncRequestsIntegrityCheck = 'sync:requests-integrity-check',
}

View File

@@ -0,0 +1,3 @@
import { SyncEvent } from './SyncEvent'
export type SyncEventReceiver = (event: SyncEvent) => void

View File

@@ -0,0 +1,27 @@
export interface DirectoryHandle {
nativeHandle: unknown
}
export interface FileHandleReadWrite {
nativeHandle: unknown
writableStream: unknown
}
export interface FileHandleRead {
nativeHandle: unknown
}
export type FileSystemResult = 'aborted' | 'success' | 'failed'
export type FileSystemNoSelection = 'aborted' | 'failed'
export interface FileSystemApi {
selectDirectory(): Promise<DirectoryHandle | FileSystemNoSelection>
selectFile(): Promise<FileHandleRead | FileSystemNoSelection>
readFile(
file: FileHandleRead,
onBytes: (bytes: Uint8Array, isLast: boolean) => Promise<void>,
): Promise<FileSystemResult>
createDirectory(parentDirectory: DirectoryHandle, name: string): Promise<DirectoryHandle | FileSystemNoSelection>
createFile(directory: DirectoryHandle, name: string): Promise<FileHandleReadWrite | FileSystemNoSelection>
saveBytes(file: FileHandleReadWrite, bytes: Uint8Array): Promise<'success' | 'failed'>
saveString(file: FileHandleReadWrite, contents: string): Promise<'success' | 'failed'>
closeFileWriteStream(file: FileHandleReadWrite): Promise<'success' | 'failed'>
}

View File

@@ -0,0 +1,28 @@
import { StartUploadSessionResponse, MinimalHttpResponse, ClientDisplayableError } from '@standardnotes/responses'
import { FileContent } from '@standardnotes/models'
export interface FilesApiInterface {
startUploadSession(apiToken: string): Promise<StartUploadSessionResponse>
uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise<boolean>
closeUploadSession(apiToken: string): Promise<boolean>
downloadFile(
file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] },
chunkIndex: number,
apiToken: string,
contentRangeStart: number,
onBytesReceived: (bytes: Uint8Array) => Promise<void>,
): Promise<ClientDisplayableError | undefined>
deleteFile(apiToken: string): Promise<MinimalHttpResponse>
createFileValetToken(
remoteIdentifier: string,
operation: 'write' | 'read' | 'delete',
unencryptedFileSize?: number,
): Promise<string | ClientDisplayableError>
getFilesDownloadUrl(): string
}

View File

@@ -0,0 +1,5 @@
import { CheckIntegrityResponse, IntegrityPayload } from '@standardnotes/responses'
export interface IntegrityApiInterface {
checkIntegrity(integrityPayloads: IntegrityPayload[]): Promise<CheckIntegrityResponse>
}

View File

@@ -0,0 +1,4 @@
/* istanbul ignore file */
export enum IntegrityEvent {
IntegrityCheckCompleted = 'IntegrityCheckCompleted',
}

View File

@@ -0,0 +1,7 @@
import { ServerItemResponse } from '@standardnotes/responses'
import { SyncSource } from '../Sync/SyncSource'
export type IntegrityEventPayload = {
rawPayloads: ServerItemResponse[]
source: SyncSource
}

View File

@@ -0,0 +1,160 @@
import { TransferPayload } from '@standardnotes/models'
import { SyncEvent } from '../Event/SyncEvent'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ItemsServerInterface } from '../Item/ItemsServerInterface'
import { SyncSource } from '../Sync/SyncSource'
import { IntegrityApiInterface } from './IntegrityApiInterface'
import { IntegrityService } from './IntegrityService'
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
import { IntegrityPayload } from '@standardnotes/responses'
describe('IntegrityService', () => {
let integrityApi: IntegrityApiInterface
let itemApi: ItemsServerInterface
let payloadManager: PayloadManagerInterface
let internalEventBus: InternalEventBusInterface
const createService = () => new IntegrityService(integrityApi, itemApi, payloadManager, internalEventBus)
beforeEach(() => {
integrityApi = {} as jest.Mocked<IntegrityApiInterface>
integrityApi.checkIntegrity = jest.fn()
itemApi = {} as jest.Mocked<ItemsServerInterface>
itemApi.getSingleItem = jest.fn()
payloadManager = {} as jest.Mocked<PayloadManagerInterface>
payloadManager.integrityPayloads = []
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
internalEventBus.publishSync = jest.fn()
})
it('should check integrity of payloads and publish mismatches', async () => {
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
data: {
mismatches: [{ uuid: '1-2-3', updated_at_timestamp: 234 } as IntegrityPayload],
},
})
itemApi.getSingleItem = jest.fn().mockReturnValue({
data: {
item: {
uuid: '1-2-3',
content: 'foobar',
} as Partial<TransferPayload>,
},
})
await createService().handleEvent({
type: SyncEvent.SyncRequestsIntegrityCheck,
payload: {
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
source: SyncSource.AfterDownloadFirst,
},
})
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
{
payload: {
rawPayloads: [
{
content: 'foobar',
uuid: '1-2-3',
},
],
source: 5,
},
type: 'IntegrityCheckCompleted',
},
'SEQUENCE',
)
})
it('should publish empty mismatches if everything is in sync', async () => {
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
data: {
mismatches: [],
},
})
await createService().handleEvent({
type: SyncEvent.SyncRequestsIntegrityCheck,
payload: {
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
source: SyncSource.AfterDownloadFirst,
},
})
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
{
payload: {
rawPayloads: [],
source: 5,
},
type: 'IntegrityCheckCompleted',
},
'SEQUENCE',
)
})
it('should not publish mismatches if checking integrity fails', async () => {
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
error: 'Ooops',
})
await createService().handleEvent({
type: SyncEvent.SyncRequestsIntegrityCheck,
payload: {
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
source: SyncSource.AfterDownloadFirst,
},
})
expect(internalEventBus.publishSync).not.toHaveBeenCalled()
})
it('should publish empty mismatches if fetching items fails', async () => {
integrityApi.checkIntegrity = jest.fn().mockReturnValue({
data: {
mismatches: [{ uuid: '1-2-3', updated_at_timestamp: 234 } as IntegrityPayload],
},
})
itemApi.getSingleItem = jest.fn().mockReturnValue({
error: 'Ooops',
})
await createService().handleEvent({
type: SyncEvent.SyncRequestsIntegrityCheck,
payload: {
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
source: SyncSource.AfterDownloadFirst,
},
})
expect(internalEventBus.publishSync).toHaveBeenCalledWith(
{
payload: {
rawPayloads: [],
source: 5,
},
type: 'IntegrityCheckCompleted',
},
'SEQUENCE',
)
})
it('should not handle different event types', async () => {
await createService().handleEvent({
type: SyncEvent.SyncCompletedWithAllItemsUploaded,
payload: {
integrityPayloads: [{ uuid: '1-2-3', updated_at_timestamp: 123 } as IntegrityPayload],
source: SyncSource.AfterDownloadFirst,
},
})
expect(integrityApi.checkIntegrity).not.toHaveBeenCalled()
expect(itemApi.getSingleItem).not.toHaveBeenCalled()
expect(internalEventBus.publishSync).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,62 @@
import { IntegrityEvent } from './IntegrityEvent'
import { AbstractService } from '../Service/AbstractService'
import { ItemsServerInterface } from '../Item/ItemsServerInterface'
import { IntegrityApiInterface } from './IntegrityApiInterface'
import { GetSingleItemResponse } from '@standardnotes/responses'
import { InternalEventHandlerInterface } from '../Internal/InternalEventHandlerInterface'
import { InternalEventInterface } from '../Internal/InternalEventInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { SyncEvent } from '../Event/SyncEvent'
import { IntegrityEventPayload } from './IntegrityEventPayload'
import { SyncSource } from '../Sync/SyncSource'
import { PayloadManagerInterface } from '../Payloads/PayloadManagerInterface'
export class IntegrityService
extends AbstractService<IntegrityEvent, IntegrityEventPayload>
implements InternalEventHandlerInterface
{
constructor(
private integrityApi: IntegrityApiInterface,
private itemApi: ItemsServerInterface,
private payloadManager: PayloadManagerInterface,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
}
async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type !== SyncEvent.SyncRequestsIntegrityCheck) {
return
}
const integrityCheckResponse = await this.integrityApi.checkIntegrity(this.payloadManager.integrityPayloads)
if (integrityCheckResponse.error !== undefined) {
this.log(`Could not obtain integrity check: ${integrityCheckResponse.error}`)
return
}
const serverItemResponsePromises: Promise<GetSingleItemResponse>[] = []
for (const mismatch of integrityCheckResponse.data.mismatches) {
serverItemResponsePromises.push(this.itemApi.getSingleItem(mismatch.uuid))
}
const serverItemResponses = await Promise.all(serverItemResponsePromises)
const rawPayloads = []
for (const serverItemResponse of serverItemResponses) {
if (serverItemResponse.data === undefined || serverItemResponse.error || !('item' in serverItemResponse.data)) {
this.log(`Could not obtain item for integrity adjustments: ${serverItemResponse.error}`)
continue
}
rawPayloads.push(serverItemResponse.data.item)
}
await this.notifyEventSync(IntegrityEvent.IntegrityCheckCompleted, {
rawPayloads: rawPayloads,
source: (event.payload as { source: SyncSource }).source,
})
}
}

View File

@@ -0,0 +1,117 @@
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
import { InternalEventBus } from './InternalEventBus'
import { InternalEventPublishStrategy } from './InternalEventPublishStrategy'
describe('InternalEventBus', () => {
let eventHandler1: InternalEventHandlerInterface
let eventHandler2: InternalEventHandlerInterface
let eventHandler3: InternalEventHandlerInterface
const createEventBus = () => new InternalEventBus()
beforeEach(() => {
eventHandler1 = {} as jest.Mocked<InternalEventHandlerInterface>
eventHandler1.handleEvent = jest.fn()
eventHandler2 = {} as jest.Mocked<InternalEventHandlerInterface>
eventHandler2.handleEvent = jest.fn()
eventHandler3 = {} as jest.Mocked<InternalEventHandlerInterface>
eventHandler3.handleEvent = jest.fn()
})
it('should trigger appropriate event handlers upon event publishing', () => {
const eventBus = createEventBus()
eventBus.addEventHandler(eventHandler1, 'test_event_1')
eventBus.addEventHandler(eventHandler2, 'test_event_2')
eventBus.addEventHandler(eventHandler1, 'test_event_3')
eventBus.addEventHandler(eventHandler3, 'test_event_2')
eventBus.publish({ type: 'test_event_2', payload: { foo: 'bar' } })
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
})
it('should do nothing if there are no appropriate event handlers', () => {
const eventBus = createEventBus()
eventBus.addEventHandler(eventHandler1, 'test_event_1')
eventBus.addEventHandler(eventHandler2, 'test_event_2')
eventBus.addEventHandler(eventHandler1, 'test_event_3')
eventBus.addEventHandler(eventHandler3, 'test_event_2')
eventBus.publish({ type: 'test_event_4', payload: { foo: 'bar' } })
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
expect(eventHandler2.handleEvent).not.toHaveBeenCalled()
expect(eventHandler3.handleEvent).not.toHaveBeenCalled()
})
it('should handle event synchronously in a sequential order', async () => {
const eventBus = createEventBus()
eventBus.addEventHandler(eventHandler1, 'test_event_1')
eventBus.addEventHandler(eventHandler2, 'test_event_2')
eventBus.addEventHandler(eventHandler1, 'test_event_3')
eventBus.addEventHandler(eventHandler3, 'test_event_2')
await eventBus.publishSync({ type: 'test_event_2', payload: { foo: 'bar' } }, InternalEventPublishStrategy.SEQUENCE)
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
})
it('should handle event synchronously in a random order', async () => {
const eventBus = createEventBus()
eventBus.addEventHandler(eventHandler1, 'test_event_1')
eventBus.addEventHandler(eventHandler2, 'test_event_2')
eventBus.addEventHandler(eventHandler1, 'test_event_3')
eventBus.addEventHandler(eventHandler3, 'test_event_2')
await eventBus.publishSync({ type: 'test_event_2', payload: { foo: 'bar' } }, InternalEventPublishStrategy.ASYNC)
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
expect(eventHandler2.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
expect(eventHandler3.handleEvent).toHaveBeenCalledWith({
type: 'test_event_2',
payload: { foo: 'bar' },
})
})
it('should do nothing if there are no appropriate event handlers for synchronous handling', async () => {
const eventBus = createEventBus()
eventBus.addEventHandler(eventHandler1, 'test_event_1')
eventBus.addEventHandler(eventHandler2, 'test_event_2')
eventBus.addEventHandler(eventHandler1, 'test_event_3')
eventBus.addEventHandler(eventHandler3, 'test_event_2')
await eventBus.publishSync({ type: 'test_event_4', payload: { foo: 'bar' } }, InternalEventPublishStrategy.ASYNC)
expect(eventHandler1.handleEvent).not.toHaveBeenCalled()
expect(eventHandler2.handleEvent).not.toHaveBeenCalled()
expect(eventHandler3.handleEvent).not.toHaveBeenCalled()
})
it('should clear event observers on deinit', async () => {
const eventBus = createEventBus()
eventBus.deinit()
expect(eventBus['eventHandlers']).toBeUndefined
})
})

View File

@@ -0,0 +1,61 @@
import { InternalEventBusInterface } from './InternalEventBusInterface'
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
import { InternalEventInterface } from './InternalEventInterface'
import { InternalEventPublishStrategy } from './InternalEventPublishStrategy'
import { InternalEventType } from './InternalEventType'
export class InternalEventBus implements InternalEventBusInterface {
private eventHandlers: Map<InternalEventType, InternalEventHandlerInterface[]>
constructor() {
this.eventHandlers = new Map<InternalEventType, InternalEventHandlerInterface[]>()
}
deinit(): void {
;(this.eventHandlers as unknown) = undefined
}
addEventHandler(handler: InternalEventHandlerInterface, eventType: string): void {
let handlersForEventType = this.eventHandlers.get(eventType)
if (handlersForEventType === undefined) {
handlersForEventType = []
}
handlersForEventType.push(handler)
this.eventHandlers.set(eventType, handlersForEventType)
}
publish(event: InternalEventInterface): void {
const handlersForEventType = this.eventHandlers.get(event.type)
if (handlersForEventType === undefined) {
return
}
for (const handlerForEventType of handlersForEventType) {
void handlerForEventType.handleEvent(event)
}
}
async publishSync(event: InternalEventInterface, strategy: InternalEventPublishStrategy): Promise<void> {
const handlersForEventType = this.eventHandlers.get(event.type)
if (handlersForEventType === undefined) {
return
}
if (strategy === InternalEventPublishStrategy.SEQUENCE) {
for (const handlerForEventType of handlersForEventType) {
await handlerForEventType.handleEvent(event)
}
}
if (strategy === InternalEventPublishStrategy.ASYNC) {
const handlerPromises = []
for (const handlerForEventType of handlersForEventType) {
handlerPromises.push(handlerForEventType.handleEvent(event))
}
await Promise.all(handlerPromises)
}
}
}

View File

@@ -0,0 +1,28 @@
import { InternalEventInterface } from './InternalEventInterface'
import { InternalEventType } from './InternalEventType'
import { InternalEventHandlerInterface } from './InternalEventHandlerInterface'
import { InternalEventPublishStrategy } from '..'
export interface InternalEventBusInterface {
/**
* Associate an event handler with a certain event type
* @param handler event handler instance
* @param eventType event type to associate with
*/
addEventHandler(handler: InternalEventHandlerInterface, eventType: InternalEventType): void
/**
* Asynchronously publish an event for handling
* @param event internal event object
*/
publish(event: InternalEventInterface): void
/**
* Synchronously publish an event for handling.
* This will await for all handlers to finish processing the event.
* @param event internal event object
* @param strategy strategy with which the handlers will process the event.
* Either all handlers will start at once or they will do it sequentially.
*/
publishSync(event: InternalEventInterface, strategy: InternalEventPublishStrategy): Promise<void>
deinit(): void
}

View File

@@ -0,0 +1,5 @@
import { InternalEventInterface } from './InternalEventInterface'
export interface InternalEventHandlerInterface {
handleEvent(event: InternalEventInterface): Promise<void>
}

View File

@@ -0,0 +1,6 @@
import { InternalEventType } from './InternalEventType'
export interface InternalEventInterface {
type: InternalEventType
payload: unknown
}

View File

@@ -0,0 +1,4 @@
export enum InternalEventPublishStrategy {
ASYNC = 'ASYNC',
SEQUENCE = 'SEQUENCE',
}

View File

@@ -0,0 +1 @@
export type InternalEventType = string

View File

@@ -0,0 +1,133 @@
import { ContentType } from '@standardnotes/common'
import {
MutationType,
ItemsKeyInterface,
ItemsKeyMutatorInterface,
DecryptedItemInterface,
DecryptedItemMutator,
DecryptedPayloadInterface,
PayloadEmitSource,
EncryptedItemInterface,
DeletedItemInterface,
ItemContent,
PredicateInterface,
} from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
export type ItemManagerChangeData<I extends DecryptedItemInterface = DecryptedItemInterface> = {
/** The items are pre-existing but have been changed */
changed: I[]
/** The items have been newly inserted */
inserted: I[]
/** The items should no longer be displayed in the interface, either due to being deleted, or becoming error-encrypted */
removed: (EncryptedItemInterface | DeletedItemInterface)[]
/** Items for which encrypted overwrite protection is enabled and enacted */
ignored: EncryptedItemInterface[]
/** Items which were previously error decrypting but now successfully decrypted */
unerrored: I[]
source: PayloadEmitSource
sourceKey?: string
}
export type ItemManagerChangeObserverCallback<I extends DecryptedItemInterface = DecryptedItemInterface> = (
data: ItemManagerChangeData<I>,
) => void
export interface ItemManagerInterface extends AbstractService {
addObserver<I extends DecryptedItemInterface = DecryptedItemInterface>(
contentType: ContentType | ContentType[],
callback: ItemManagerChangeObserverCallback<I>,
): () => void
/**
* Marks the item as deleted and needing sync.
*/
setItemToBeDeleted(itemToLookupUuidFor: DecryptedItemInterface, source?: PayloadEmitSource): Promise<void>
setItemsToBeDeleted(itemsToLookupUuidsFor: DecryptedItemInterface[]): Promise<void>
setItemsDirty(
itemsToLookupUuidsFor: DecryptedItemInterface[],
isUserModified?: boolean,
): Promise<DecryptedItemInterface[]>
get items(): DecryptedItemInterface[]
/**
* Inserts the item as-is by reading its payload value. This function will not
* modify item in any way (such as marking it as dirty). It is up to the caller
* to pass in a dirtied item if that is their intention.
*/
insertItem(item: DecryptedItemInterface): Promise<DecryptedItemInterface>
emitItemFromPayload(payload: DecryptedPayloadInterface, source: PayloadEmitSource): Promise<DecryptedItemInterface>
getItems<T extends DecryptedItemInterface>(contentType: ContentType | ContentType[]): T[]
/**
* Returns all non-deleted items keys
*/
getDisplayableItemsKeys(): ItemsKeyInterface[]
/**
* Creates an item and conditionally maps it and marks it as dirty.
* @param needsSync - Whether to mark the item as needing sync
*/
createItem<T extends DecryptedItemInterface, C extends ItemContent = ItemContent>(
contentType: ContentType,
content: C,
needsSync?: boolean,
): Promise<T>
/**
* Create an unmanaged item that can later be inserted via `insertItem`
*/
createTemplateItem<
C extends ItemContent = ItemContent,
I extends DecryptedItemInterface<C> = DecryptedItemInterface<C>,
>(
contentType: ContentType,
content?: C,
): I
/**
* Consumers wanting to modify an item should run it through this block,
* so that data is properly mapped through our function, and latest state
* is properly reconciled.
*/
changeItem<
M extends DecryptedItemMutator = DecryptedItemMutator,
I extends DecryptedItemInterface = DecryptedItemInterface,
>(
itemToLookupUuidFor: I,
mutate?: (mutator: M) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<I>
changeItemsKey(
itemToLookupUuidFor: ItemsKeyInterface,
mutate: (mutator: ItemsKeyMutatorInterface) => void,
mutationType?: MutationType,
emitSource?: PayloadEmitSource,
payloadSourceKey?: string,
): Promise<ItemsKeyInterface>
itemsMatchingPredicate<T extends DecryptedItemInterface>(
contentType: ContentType,
predicate: PredicateInterface<T>,
): T[]
itemsMatchingPredicates<T extends DecryptedItemInterface>(
contentType: ContentType,
predicates: PredicateInterface<T>[],
): T[]
subItemsMatchingPredicates<T extends DecryptedItemInterface>(items: T[], predicates: PredicateInterface<T>[]): T[]
}

View File

@@ -0,0 +1,6 @@
import { Uuid } from '@standardnotes/common'
import { GetSingleItemResponse } from '@standardnotes/responses'
export interface ItemsServerInterface {
getSingleItem(itemUuid: Uuid): Promise<GetSingleItemResponse>
}

View File

@@ -0,0 +1,24 @@
import {
PayloadInterface,
EncryptedPayloadInterface,
FullyFormedPayloadInterface,
PayloadEmitSource,
} from '@standardnotes/models'
import { IntegrityPayload } from '@standardnotes/responses'
export interface PayloadManagerInterface {
emitPayloads(
payloads: PayloadInterface[],
emitSource: PayloadEmitSource,
sourceKey?: string,
): Promise<PayloadInterface[]>
integrityPayloads: IntegrityPayload[]
get invalidPayloads(): EncryptedPayloadInterface[]
/**
* Returns a detached array of all items which are not deleted
*/
get nonDeletedItems(): FullyFormedPayloadInterface[]
}

View File

@@ -0,0 +1,15 @@
import { PrefKey, PrefValue } from '@standardnotes/models'
import { AbstractService } from '../Service/AbstractService'
/* istanbul ignore file */
export enum PreferencesServiceEvent {
PreferencesChanged = 'PreferencesChanged',
}
export interface PreferenceServiceInterface extends AbstractService<PreferencesServiceEvent> {
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined
getValue<K extends PrefKey>(key: K, defaultValue: PrefValue[K]): PrefValue[K]
getValue<K extends PrefKey>(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined
setValue<K extends PrefKey>(key: K, value: PrefValue[K]): Promise<void>
}

View File

@@ -0,0 +1,108 @@
/* istanbul ignore file */
import { log, removeFromArray } from '@standardnotes/utils'
import { EventObserver } from '../Event/EventObserver'
import { ServiceInterface } from './ServiceInterface'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { ApplicationStage } from '../Application/ApplicationStage'
import { InternalEventPublishStrategy } from '../Internal/InternalEventPublishStrategy'
import { DiagnosticInfo } from '../Diagnostics/ServiceDiagnostics'
export abstract class AbstractService<EventName = string, EventData = undefined>
implements ServiceInterface<EventName, EventData>
{
private eventObservers: EventObserver<EventName, EventData>[] = []
public loggingEnabled = false
private criticalPromises: Promise<unknown>[] = []
constructor(protected internalEventBus: InternalEventBusInterface) {}
public addEventObserver(observer: EventObserver<EventName, EventData>): () => void {
this.eventObservers.push(observer)
const thislessEventObservers = this.eventObservers
return () => {
removeFromArray(thislessEventObservers, observer)
}
}
protected async notifyEvent(eventName: EventName, data?: EventData): Promise<void> {
for (const observer of this.eventObservers) {
await observer(eventName, data)
}
this.internalEventBus?.publish({
type: eventName as unknown as string,
payload: data,
})
}
protected async notifyEventSync(eventName: EventName, data?: EventData): Promise<void> {
for (const observer of this.eventObservers) {
await observer(eventName, data)
}
await this.internalEventBus?.publishSync(
{
type: eventName as unknown as string,
payload: data,
},
InternalEventPublishStrategy.SEQUENCE,
)
}
getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve(undefined)
}
/**
* Called by application to allow services to momentarily block deinit until
* sensitive operations complete.
*/
public async blockDeinit(): Promise<void> {
await Promise.all(this.criticalPromises)
}
/**
* Called by application before restart.
* Subclasses should deregister any observers/timers
*/
public deinit(): void {
this.eventObservers.length = 0
;(this.internalEventBus as unknown) = undefined
;(this.criticalPromises as unknown) = undefined
}
/**
* A critical function is one that should block signing out or destroying application
* session until the crticial function has completed. For example, persisting keys to
* disk is a critical operation, and should be wrapped in this function call. The
* parent application instance will await all criticial functions via the `blockDeinit`
* function before signing out and deiniting.
*/
protected async executeCriticalFunction<T = void>(func: () => Promise<T>): Promise<T> {
const promise = func()
this.criticalPromises.push(promise)
return promise
}
/**
* Application instances will call this function directly when they arrive
* at a certain migratory state.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async handleApplicationStage(_stage: ApplicationStage): Promise<void> {
// optional override
}
getServiceName(): string {
return this.constructor.name
}
log(..._args: unknown[]): void {
if (this.loggingEnabled) {
// eslint-disable-next-line prefer-rest-params
log(this.getServiceName(), ...arguments)
}
}
}

View File

@@ -0,0 +1,12 @@
import { ApplicationStage } from '../Application/ApplicationStage'
import { ServiceDiagnostics } from '../Diagnostics/ServiceDiagnostics'
import { EventObserver } from '../Event/EventObserver'
export interface ServiceInterface<E, D> extends ServiceDiagnostics {
loggingEnabled: boolean
addEventObserver(observer: EventObserver<E, D>): () => void
blockDeinit(): Promise<void>
deinit(): void
handleApplicationStage(stage: ApplicationStage): Promise<void>
log(message: string, ...args: unknown[]): void
}

View File

@@ -0,0 +1,62 @@
import { removeFromArray } from '@standardnotes/utils'
import { AbstractService } from '../Service/AbstractService'
import { StatusServiceEvent, StatusServiceInterface, StatusMessageIdentifier } from './StatusServiceInterface'
/* istanbul ignore file */
export class StatusService extends AbstractService<StatusServiceEvent, string> implements StatusServiceInterface {
private _message = ''
private directSetMessage?: string
private dynamicMessages: string[] = []
get message(): string {
return this._message
}
setMessage(message: string | undefined): void {
this.directSetMessage = message
this.recomputeMessage()
}
addMessage(message: string): StatusMessageIdentifier {
this.dynamicMessages.push(message)
this.recomputeMessage()
return message
}
removeMessage(message: StatusMessageIdentifier): void {
removeFromArray(this.dynamicMessages, message)
this.recomputeMessage()
}
private recomputeMessage(): void {
const messages = [...this.dynamicMessages]
if (this.directSetMessage) {
messages.unshift(this.directSetMessage)
}
this._message = this.messageFromArray(messages)
void this.notifyEvent(StatusServiceEvent.MessageChanged, this._message)
}
private messageFromArray(messages: string[]): string {
let message = ''
messages.forEach((value, index) => {
const isLast = index === messages.length - 1
message += value
if (!isLast) {
message += ', '
}
})
return message
}
}

View File

@@ -0,0 +1,16 @@
import { AbstractService } from '../Service/AbstractService'
/* istanbul ignore file */
export enum StatusServiceEvent {
MessageChanged = 'MessageChanged',
}
export type StatusMessageIdentifier = string
export interface StatusServiceInterface extends AbstractService<StatusServiceEvent, string> {
get message(): string
setMessage(message: string | undefined): void
addMessage(message: string): StatusMessageIdentifier
removeMessage(message: StatusMessageIdentifier): void
}

View File

@@ -0,0 +1,24 @@
import { InMemoryStore } from './InMemoryStore'
import { StorageKey } from './StorageKeys'
describe('InMemoryStore', () => {
const createStore = () => new InMemoryStore()
it('should set and retrieve a value', () => {
const store = createStore()
store.setValue(StorageKey.CodeVerifier, 'test')
expect(store.getValue(StorageKey.CodeVerifier)).toEqual('test')
})
it('should remove a value', () => {
const store = createStore()
store.setValue(StorageKey.CodeVerifier, 'test')
store.removeValue(StorageKey.CodeVerifier)
expect(store.getValue(StorageKey.CodeVerifier)).toBeUndefined()
})
})

View File

@@ -0,0 +1,22 @@
import { KeyValueStoreInterface } from './KeyValueStoreInterface'
import { StorageKey } from './StorageKeys'
export class InMemoryStore implements KeyValueStoreInterface<string> {
private values: Map<StorageKey, string>
constructor() {
this.values = new Map<StorageKey, string>()
}
setValue(key: StorageKey, value: string): void {
this.values.set(key, value)
}
getValue(key: StorageKey): string | undefined {
return this.values.get(key)
}
removeValue(key: StorageKey): void {
this.values.delete(key)
}
}

View File

@@ -0,0 +1,7 @@
import { StorageKey } from './StorageKeys'
export interface KeyValueStoreInterface<T> {
setValue(key: StorageKey, value: T): void
getValue(key: StorageKey): T | undefined
removeValue(key: StorageKey): void
}

View File

@@ -0,0 +1,7 @@
import { namespacedKey } from './StorageKeys'
describe('StorageKeys', () => {
it('namespacedKey', () => {
expect(namespacedKey('namespace', 'key')).toEqual('namespace-key')
})
})

View File

@@ -0,0 +1,66 @@
/**
* Unmanaged keys stored in root storage.
* Raw storage keys exist outside of StorageManager domain
*/
export enum RawStorageKey {
StorageObject = 'storage',
DescriptorRecord = 'descriptors',
SnjsVersion = 'snjs_version',
}
/**
* Keys used for retrieving and saving simple key/value pairs.
* These keys are managed and are embedded inside RawStorageKey.StorageObject
*/
export enum StorageKey {
RootKeyParams = 'ROOT_KEY_PARAMS',
WrappedRootKey = 'WRAPPED_ROOT_KEY',
RootKeyWrapperKeyParams = 'ROOT_KEY_WRAPPER_KEY_PARAMS',
Session = 'session',
User = 'user',
ServerHost = 'server',
LegacyUuid = 'uuid',
LastSyncToken = 'syncToken',
PaginationToken = 'cursorToken',
BiometricsState = 'biometrics_state',
MobilePasscodeTiming = 'passcode_timing',
MobileBiometricsTiming = 'biometrics_timing',
MobilePasscodeKeyboardType = 'passcodeKeyboardType',
MobilePreferences = 'preferences',
MobileScreenshotPrivacyEnabled = 'screenshotPrivacy_enabled',
ProtectionExpirey = 'SessionExpiresAtKey',
ProtectionSessionLength = 'SessionLengthKey',
KeyRecoveryUndecryptableItems = 'key_recovery_undecryptable',
StorageEncryptionPolicy = 'storage_policy',
WebSocketUrl = 'webSocket_url',
UserRoles = 'user_roles',
UserFeatures = 'user_features',
ExperimentalFeatures = 'experimental_features',
DeinitMode = 'deinit_mode',
CodeVerifier = 'code_verifier',
}
export enum NonwrappedStorageKey {
MobileFirstRun = 'first_run',
}
export function namespacedKey(namespace: string, key: string) {
return `${namespace}-${key}`
}
export const LegacyKeys1_0_0 = {
WebPasscodeParamsKey: 'offlineParams',
MobilePasscodeParamsKey: 'pc_params',
AllAccountKeyParamsKey: 'auth_params',
WebEncryptedStorageKey: 'encryptedStorage',
MobileWrappedRootKeyKey: 'encrypted_account_keys',
MobileBiometricsPrefs: 'biometrics_prefs',
AllMigrations: 'migrations',
MobileThemesCache: 'ThemePreferencesKey',
MobileLightTheme: 'lightTheme',
MobileDarkTheme: 'darkTheme',
MobileLastExportDate: 'LastExportDateKey',
MobileDoNotWarnUnsupportedEditors: 'DoNotShowAgainUnsupportedEditorsKey',
MobileOptionsState: 'options',
MobilePasscodeKeyboardType: 'passcodeKeyboardType',
}

View File

@@ -0,0 +1,16 @@
import { PayloadInterface, RootKeyInterface } from '@standardnotes/models'
import { StorageValueModes } from './StorageTypes'
export interface StorageServiceInterface {
getValue<T>(key: string, mode?: StorageValueModes, defaultValue?: T): T
canDecryptWithKey(key: RootKeyInterface): Promise<boolean>
savePayload(payload: PayloadInterface): Promise<void>
savePayloads(decryptedPayloads: PayloadInterface[]): Promise<void>
setValue(key: string, value: unknown, mode?: StorageValueModes): void
removeValue(key: string, mode?: StorageValueModes): Promise<void>
}

View File

@@ -0,0 +1,39 @@
import { LocalStorageEncryptedContextualPayload, LocalStorageDecryptedContextualPayload } from '@standardnotes/models'
/* istanbul ignore file */
export enum StoragePersistencePolicies {
Default = 1,
Ephemeral = 2,
}
export enum StorageEncryptionPolicy {
Default = 1,
Disabled = 2,
}
export enum StorageValueModes {
/** Stored inside wrapped encrpyed storage object */
Default = 1,
/** Stored outside storage object, unencrypted */
Nonwrapped = 2,
}
export enum ValueModesKeys {
/* Is encrypted */
Wrapped = 'wrapped',
/* Is decrypted */
Unwrapped = 'unwrapped',
/* Lives outside of wrapped/unwrapped */
Nonwrapped = 'nonwrapped',
}
export type ValuesObjectRecord = Record<string, unknown>
export type WrappedStorageValue = LocalStorageEncryptedContextualPayload | LocalStorageDecryptedContextualPayload
export type StorageValuesObject = {
[ValueModesKeys.Wrapped]: WrappedStorageValue
[ValueModesKeys.Unwrapped]: ValuesObjectRecord
[ValueModesKeys.Nonwrapped]: ValuesObjectRecord
}

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
export enum SyncMode {
/**
* Performs a standard sync, uploading any dirty items and retrieving items.
*/
Default = 1,
/**
* The first sync for an account, where we first want to download all remote items first
* before uploading any dirty items. This allows a consumer, for example, to download
* all data to see if user has an items key, and if not, only then create a new one.
*/
DownloadFirst = 2,
}

View File

@@ -0,0 +1,21 @@
/* istanbul ignore file */
import { SyncMode } from './SyncMode'
import { SyncQueueStrategy } from './SyncQueueStrategy'
import { SyncSource } from './SyncSource'
export type SyncOptions = {
queueStrategy?: SyncQueueStrategy
mode?: SyncMode
/** Whether the server should compute and return an integrity hash. */
checkIntegrity?: boolean
/** Internally used to keep track of how sync requests were spawned. */
source: SyncSource
/** Whether to await any sync requests that may be queued from this call. */
awaitAll?: boolean
/**
* A callback that is triggered after pre-sync save completes,
* and before the sync request is network dispatched
*/
onPresyncSave?: () => void
}

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
export enum SyncQueueStrategy {
/**
* Promise will be resolved on the next sync request after the current one completes.
* If there is no scheduled sync request, one will be scheduled.
*/
ResolveOnNext = 1,
/**
* A new sync request is guarenteed to be generated for your request, no matter how long it takes.
* Promise will be resolved whenever this sync request is processed in the serial queue.
*/
ForceSpawnNew = 2,
}

View File

@@ -0,0 +1,7 @@
/* istanbul ignore file */
import { SyncOptions } from './SyncOptions'
export interface SyncServiceInterface {
sync(options?: Partial<SyncOptions>): Promise<unknown>
}

View File

@@ -0,0 +1,11 @@
/* istanbul ignore file */
export enum SyncSource {
External = 1,
SpawnQueue = 2,
ResolveQueue = 3,
MoreDirtyItems = 4,
AfterDownloadFirst = 5,
IntegrityCheck = 6,
ResolveOutOfSync = 7,
}

View File

@@ -0,0 +1,51 @@
export * from './Alert/AlertService'
export * from './Api/ApiServiceInterface'
export * from './Application/ApplicationStage'
export * from './Application/DeinitCallback'
export * from './Application/DeinitSource'
export * from './Application/DeinitMode'
export * from './Application/UserClientInterface'
export * from './Application/ApplicationInterface'
export * from './Challenge'
export * from './Device/DesktopDeviceInterface'
export * from './Device/DesktopWebCommunication'
export * from './Device/DeviceInterface'
export * from './Device/Environments'
export * from './Device/FileBackupsDevice'
export * from './Device/MobileDeviceInterface'
export * from './Device/TypeCheck'
export * from './Device/WebOrDesktopDeviceInterface'
export * from './Diagnostics/ServiceDiagnostics'
export * from './Event/EventObserver'
export * from './Event/SyncEvent'
export * from './Event/SyncEventReceiver'
export * from './Files/FilesApiInterface'
export * from './FileSystem/FileSystemApi'
export * from './Integrity/IntegrityApiInterface'
export * from './Integrity/IntegrityEvent'
export * from './Integrity/IntegrityEventPayload'
export * from './Integrity/IntegrityService'
export * from './Internal/InternalEventBus'
export * from './Internal/InternalEventBusInterface'
export * from './Internal/InternalEventHandlerInterface'
export * from './Internal/InternalEventInterface'
export * from './Internal/InternalEventPublishStrategy'
export * from './Internal/InternalEventType'
export * from './Item/ItemManagerInterface'
export * from './Item/ItemsServerInterface'
export * from './Payloads/PayloadManagerInterface'
export * from './Preferences/PreferenceServiceInterface'
export * from './Service/AbstractService'
export * from './Service/ServiceInterface'
export * from './Status/StatusService'
export * from './Status/StatusServiceInterface'
export * from './Storage/StorageKeys'
export * from './Storage/InMemoryStore'
export * from './Storage/KeyValueStoreInterface'
export * from './Storage/StorageServiceInterface'
export * from './Storage/StorageTypes'
export * from './Sync/SyncMode'
export * from './Sync/SyncOptions'
export * from './Sync/SyncQueueStrategy'
export * from './Sync/SyncServiceInterface'
export * from './Sync/SyncSource'