diff --git a/.gitignore b/.gitignore index ad60fd8c1..cc8873c68 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ packages/files/dist packages/models/dist packages/services/dist packages/utils/dist +packages/api/dist **/.pnp.* **/.yarn/* diff --git a/.yarn/cache/@standardnotes-api-npm-1.1.19-6a6d650ec9-cca168245a.zip b/.yarn/cache/@standardnotes-api-npm-1.1.19-6a6d650ec9-cca168245a.zip deleted file mode 100644 index dd20ae1a2..000000000 Binary files a/.yarn/cache/@standardnotes-api-npm-1.1.19-6a6d650ec9-cca168245a.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-security-npm-1.1.0-9d90b8c189-2098584cd3.zip b/.yarn/cache/@standardnotes-security-npm-1.1.0-9d90b8c189-2098584cd3.zip new file mode 100644 index 000000000..535e70507 Binary files /dev/null and b/.yarn/cache/@standardnotes-security-npm-1.1.0-9d90b8c189-2098584cd3.zip differ diff --git a/packages/api/.eslintignore b/packages/api/.eslintignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/api/.eslintignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/api/.eslintrc b/packages/api/.eslintrc new file mode 100644 index 000000000..cb7136174 --- /dev/null +++ b/packages/api/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + } +} diff --git a/packages/api/CHANGELOG.md b/packages/api/CHANGELOG.md new file mode 100644 index 000000000..b23bc8e16 --- /dev/null +++ b/packages/api/CHANGELOG.md @@ -0,0 +1,118 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [1.3.0](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.2.1...@standardnotes/api@1.3.0) (2022-07-06) + +### Features + +* remove filepicker, features, services and models packages in favor of standardnotes/app repository ([27f474e](https://github.com/standardnotes/snjs/commit/27f474e859701c5713c8b6ed27cd1a4d5e4392bb)) + +## [1.2.1](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.2.0...@standardnotes/api@1.2.1) (2022-07-05) + +**Note:** Version bump only for package @standardnotes/api + +# [1.2.0](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.20...@standardnotes/api@1.2.0) (2022-07-05) + +### Features + +* remove encryption package in favor of standardnotes/app repository ([f6d1c9e](https://github.com/standardnotes/snjs/commit/f6d1c9ee538bb59ee7ac28c0d49ca682d4eb4d38)) + +## [1.1.20](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.19...@standardnotes/api@1.1.20) (2022-07-04) + +### Bug Fixes + +* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b)) + +## [1.1.19](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.18...@standardnotes/api@1.1.19) (2022-06-29) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.18](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.17...@standardnotes/api@1.1.18) (2022-06-28) + +### Bug Fixes + +* setting custom host ([#773](https://github.com/standardnotes/snjs/issues/773)) ([2fe27b0](https://github.com/standardnotes/snjs/commit/2fe27b0324486fad915a91096142579e649995b8)) + +## [1.1.17](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.16...@standardnotes/api@1.1.17) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.16](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.15...@standardnotes/api@1.1.16) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.15](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.14...@standardnotes/api@1.1.15) (2022-06-23) + +### Bug Fixes + +* set host on user server ([#771](https://github.com/standardnotes/snjs/issues/771)) ([62d2c60](https://github.com/standardnotes/snjs/commit/62d2c60834b20b386b8c60f0ee172aec3e57ec05)) + +## [1.1.14](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.13...@standardnotes/api@1.1.14) (2022-06-22) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.13](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.12...@standardnotes/api@1.1.13) (2022-06-22) + +### Bug Fixes + +* missing dependency ([b748571](https://github.com/standardnotes/snjs/commit/b748571c3288ecec3a2c0aed333ceaaf718832cd)) + +## [1.1.12](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.11...@standardnotes/api@1.1.12) (2022-06-20) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.11](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.10...@standardnotes/api@1.1.11) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.10](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.9...@standardnotes/api@1.1.10) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.9](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.8...@standardnotes/api@1.1.9) (2022-06-15) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.8](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.7...@standardnotes/api@1.1.8) (2022-06-10) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.7](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.6...@standardnotes/api@1.1.7) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.6](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.5...@standardnotes/api@1.1.6) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.5](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.4...@standardnotes/api@1.1.5) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.4](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.3...@standardnotes/api@1.1.4) (2022-06-06) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.3](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.2...@standardnotes/api@1.1.3) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/api + +## [1.1.2](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.1...@standardnotes/api@1.1.2) (2022-06-03) + +### Bug Fixes + +* add option to define additional params to the user registration request if needed ([78f169f](https://github.com/standardnotes/snjs/commit/78f169f993cb0d1950e553fe251f6bc903dd6da8)) + +## [1.1.1](https://github.com/standardnotes/snjs/compare/@standardnotes/api@1.1.0...@standardnotes/api@1.1.1) (2022-06-03) + +### Bug Fixes + +* make response headers optional for ease of defining on the server side ([#755](https://github.com/standardnotes/snjs/issues/755)) ([b5c2092](https://github.com/standardnotes/snjs/commit/b5c2092fc33dbd389fa67c122b3907956ca2eafa)) + +# 1.1.0 (2022-06-03) + +### Features + +* api service refactor -extract registration ([#733](https://github.com/standardnotes/snjs/issues/733)) ([1d7fac8](https://github.com/standardnotes/snjs/commit/1d7fac8c9dbb0fdb78a88743965a33c6d6a7d7d3)) diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js new file mode 100644 index 000000000..aabae3703 --- /dev/null +++ b/packages/api/jest.config.js @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../node_modules/@standardnotes/config/src/jest.json'); + +module.exports = { + ...base, + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json', + }, + }, + coverageThreshold: { + global: { + branches: 17, + functions: 43, + lines: 46, + statements: 46 + } + } +}; diff --git a/packages/api/linter.tsconfig.json b/packages/api/linter.tsconfig.json new file mode 100644 index 000000000..c1a7d22c5 --- /dev/null +++ b/packages/api/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist"] +} diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 000000000..a9328c5c1 --- /dev/null +++ b/packages/api/package.json @@ -0,0 +1,44 @@ +{ + "name": "@standardnotes/api", + "version": "1.3.0", + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "description": "Interfaces for dealing with SN server-side API", + "main": "dist/src/index.js", + "author": "Standard Notes", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src" + ], + "publishConfig": { + "access": "public" + }, + "license": "AGPL-3.0-or-later", + "scripts": { + "clean": "rm -fr dist", + "prestart": "yarn clean", + "start": "tsc -p tsconfig.json --watch", + "prebuild": "yarn clean", + "build": "tsc -p tsconfig.json", + "lint": "eslint . --ext .ts", + "test:unit": "jest spec --coverage" + }, + "devDependencies": { + "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.182", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "eslint-plugin-prettier": "^4.2.1", + "jest": "^27.5.1", + "ts-jest": "^27.1.3" + }, + "dependencies": { + "@standardnotes/common": "^1.23.1", + "@standardnotes/encryption": "workspace:*", + "@standardnotes/models": "workspace:*", + "@standardnotes/responses": "^1.6.39", + "@standardnotes/security": "^1.1.0", + "@standardnotes/services": "workspace:*", + "reflect-metadata": "^0.1.13" + } +} diff --git a/packages/api/src/Domain/Api/ApiVersion.ts b/packages/api/src/Domain/Api/ApiVersion.ts new file mode 100644 index 000000000..b69ba8f1a --- /dev/null +++ b/packages/api/src/Domain/Api/ApiVersion.ts @@ -0,0 +1,3 @@ +export enum ApiVersion { + v0 = '20200115', +} diff --git a/packages/api/src/Domain/Api/index.ts b/packages/api/src/Domain/Api/index.ts new file mode 100644 index 000000000..0a84a596b --- /dev/null +++ b/packages/api/src/Domain/Api/index.ts @@ -0,0 +1 @@ +export * from './ApiVersion' diff --git a/packages/api/src/Domain/Client/User/UserApiService.spec.ts b/packages/api/src/Domain/Client/User/UserApiService.spec.ts new file mode 100644 index 000000000..f9737d7b6 --- /dev/null +++ b/packages/api/src/Domain/Client/User/UserApiService.spec.ts @@ -0,0 +1,77 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { RootKeyParamsInterface } from '@standardnotes/models' +import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' +import { UserServerInterface } from '../../Server' +import { UserApiService } from './UserApiService' + +describe('UserApiService', () => { + let userServer: UserServerInterface + let keyParams: RootKeyParamsInterface + + const createService = () => new UserApiService(userServer) + + beforeEach(() => { + userServer = {} as jest.Mocked + userServer.register = jest.fn().mockReturnValue({ + data: { user: { email: 'test@test.te', uuid: '1-2-3' } }, + } as jest.Mocked) + + keyParams = {} as jest.Mocked + keyParams.getPortableValue = jest.fn().mockReturnValue({ + identifier: 'test@test.te', + version: ProtocolVersion.V004, + }) + }) + + it('should register a user', async () => { + const response = await createService().register('test@test.te', 'testpasswd', keyParams, 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, 'registering', { + get: () => true, + }) + + let error = null + try { + await service.register('test@test.te', 'testpasswd', keyParams, 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('test@test.te', 'testpasswd', keyParams, false) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) +}) diff --git a/packages/api/src/Domain/Client/User/UserApiService.ts b/packages/api/src/Domain/Client/User/UserApiService.ts new file mode 100644 index 000000000..b4a261b1c --- /dev/null +++ b/packages/api/src/Domain/Client/User/UserApiService.ts @@ -0,0 +1,45 @@ +import { RootKeyParamsInterface } from '@standardnotes/models' + +import { ErrorMessage } from '../../Error/ErrorMessage' +import { ApiCallError } from '../../Error/ApiCallError' +import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' +import { UserServerInterface } from '../../Server/User/UserServerInterface' +import { ApiVersion } from '../../Api/ApiVersion' +import { ApiEndpointParam } from '../../Request/ApiEndpointParam' +import { UserApiServiceInterface } from './UserApiServiceInterface' + +export class UserApiService implements UserApiServiceInterface { + private registering: boolean + + constructor(private userServer: UserServerInterface) { + this.registering = false + } + + async register( + email: string, + serverPassword: string, + keyParams: RootKeyParamsInterface, + ephemeral: boolean, + ): Promise { + if (this.registering) { + throw new ApiCallError(ErrorMessage.RegistrationInProgress) + } + this.registering = true + + try { + const response = await this.userServer.register({ + [ApiEndpointParam.ApiVersion]: ApiVersion.v0, + password: serverPassword, + email, + ephemeral, + ...keyParams.getPortableValue(), + }) + + this.registering = false + + return response + } catch (error) { + throw new ApiCallError(ErrorMessage.GenericRegistrationFail) + } + } +} diff --git a/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts new file mode 100644 index 000000000..35952b73b --- /dev/null +++ b/packages/api/src/Domain/Client/User/UserApiServiceInterface.ts @@ -0,0 +1,11 @@ +import { RootKeyParamsInterface } from '@standardnotes/models' +import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' + +export interface UserApiServiceInterface { + register( + email: string, + serverPassword: string, + keyParams: RootKeyParamsInterface, + ephemeral: boolean, + ): Promise +} diff --git a/packages/api/src/Domain/Client/index.ts b/packages/api/src/Domain/Client/index.ts new file mode 100644 index 000000000..8b0797259 --- /dev/null +++ b/packages/api/src/Domain/Client/index.ts @@ -0,0 +1,2 @@ +export * from './User/UserApiService' +export * from './User/UserApiServiceInterface' diff --git a/packages/api/src/Domain/Error/ApiCallError.ts b/packages/api/src/Domain/Error/ApiCallError.ts new file mode 100644 index 000000000..5726f5961 --- /dev/null +++ b/packages/api/src/Domain/Error/ApiCallError.ts @@ -0,0 +1,6 @@ +export class ApiCallError extends Error { + constructor(message: string) { + super(message) + Object.setPrototypeOf(this, ApiCallError.prototype) + } +} diff --git a/packages/api/src/Domain/Error/ErrorMessage.ts b/packages/api/src/Domain/Error/ErrorMessage.ts new file mode 100644 index 000000000..292e98d2b --- /dev/null +++ b/packages/api/src/Domain/Error/ErrorMessage.ts @@ -0,0 +1,7 @@ +export enum ErrorMessage { + RegistrationInProgress = 'An existing registration request is already in progress.', + GenericRegistrationFail = 'A server error occurred while trying to register. Please try again.', + RateLimited = 'Too many successive server requests. Please wait a few minutes and try again.', + InsufficientPasswordMessage = 'Your password must be at least %LENGTH% characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.', + PasscodeRequired = 'Your passcode is required in order to register for an account.', +} diff --git a/packages/api/src/Domain/Error/index.ts b/packages/api/src/Domain/Error/index.ts new file mode 100644 index 000000000..f7961bf34 --- /dev/null +++ b/packages/api/src/Domain/Error/index.ts @@ -0,0 +1,2 @@ +export * from './ApiCallError' +export * from './ErrorMessage' diff --git a/packages/api/src/Domain/Http/ErrorTag.ts b/packages/api/src/Domain/Http/ErrorTag.ts new file mode 100644 index 000000000..1ddc90d31 --- /dev/null +++ b/packages/api/src/Domain/Http/ErrorTag.ts @@ -0,0 +1,11 @@ +export enum ErrorTag { + MfaInvalid = 'mfa-invalid', + MfaRequired = 'mfa-required', + RefreshTokenInvalid = 'invalid-refresh-token', + RefreshTokenExpired = 'expired-refresh-token', + AccessTokenExpired = 'expired-access-token', + ParametersInvalid = 'invalid-parameters', + RevokedSession = 'revoked-session', + AuthInvalid = 'invalid-auth', + ReadOnlyAccess = 'read-only-access', +} diff --git a/packages/api/src/Domain/Http/HttpErrorResponseBody.ts b/packages/api/src/Domain/Http/HttpErrorResponseBody.ts new file mode 100644 index 000000000..788a8d46e --- /dev/null +++ b/packages/api/src/Domain/Http/HttpErrorResponseBody.ts @@ -0,0 +1,8 @@ +import { ErrorTag } from './ErrorTag' + +export type HttpErrorResponseBody = { + error: { + message: string + tag?: ErrorTag + } +} diff --git a/packages/api/src/Domain/Http/HttpHeaders.ts b/packages/api/src/Domain/Http/HttpHeaders.ts new file mode 100644 index 000000000..e93603ae0 --- /dev/null +++ b/packages/api/src/Domain/Http/HttpHeaders.ts @@ -0,0 +1 @@ +export type HttpHeaders = Map diff --git a/packages/api/src/Domain/Http/HttpRequest.ts b/packages/api/src/Domain/Http/HttpRequest.ts new file mode 100644 index 000000000..1f191b69d --- /dev/null +++ b/packages/api/src/Domain/Http/HttpRequest.ts @@ -0,0 +1,13 @@ +import { HttpRequestParams } from './HttpRequestParams' +import { HttpVerb } from './HttpVerb' + +export type HttpRequest = { + url: string + params?: HttpRequestParams + rawBytes?: Uint8Array + verb: HttpVerb + authentication?: string + customHeaders?: Record[] + responseType?: XMLHttpRequestResponseType + external?: boolean +} diff --git a/packages/api/src/Domain/Http/HttpRequestParams.ts b/packages/api/src/Domain/Http/HttpRequestParams.ts new file mode 100644 index 000000000..f0b6807cf --- /dev/null +++ b/packages/api/src/Domain/Http/HttpRequestParams.ts @@ -0,0 +1 @@ +export type HttpRequestParams = Record diff --git a/packages/api/src/Domain/Http/HttpResponse.ts b/packages/api/src/Domain/Http/HttpResponse.ts new file mode 100644 index 000000000..4fa6a6a92 --- /dev/null +++ b/packages/api/src/Domain/Http/HttpResponse.ts @@ -0,0 +1,12 @@ +import { HttpStatusCode } from './HttpStatusCode' +import { HttpResponseBody } from './HttpResponseBody' +import { HttpErrorResponseBody } from './HttpErrorResponseBody' +import { HttpResponseMeta } from './HttpResponseMeta' +import { HttpHeaders } from './HttpHeaders' + +export interface HttpResponse { + status: HttpStatusCode + data?: HttpResponseBody | HttpErrorResponseBody + meta?: HttpResponseMeta + headers?: HttpHeaders +} diff --git a/packages/api/src/Domain/Http/HttpResponseBody.ts b/packages/api/src/Domain/Http/HttpResponseBody.ts new file mode 100644 index 000000000..cc2dcc5de --- /dev/null +++ b/packages/api/src/Domain/Http/HttpResponseBody.ts @@ -0,0 +1 @@ +export type HttpResponseBody = Record diff --git a/packages/api/src/Domain/Http/HttpResponseMeta.ts b/packages/api/src/Domain/Http/HttpResponseMeta.ts new file mode 100644 index 000000000..baebf9db6 --- /dev/null +++ b/packages/api/src/Domain/Http/HttpResponseMeta.ts @@ -0,0 +1,12 @@ +import { Role } from '@standardnotes/security' +import { Uuid } from '@standardnotes/common' + +export type HttpResponseMeta = { + auth: { + userUuid?: Uuid + roles?: Role[] + } + server: { + filesServerUrl?: string + } +} diff --git a/packages/api/src/Domain/Http/HttpService.spec.ts b/packages/api/src/Domain/Http/HttpService.spec.ts new file mode 100644 index 000000000..c0d07669e --- /dev/null +++ b/packages/api/src/Domain/Http/HttpService.spec.ts @@ -0,0 +1,27 @@ +import { Environment } from '@standardnotes/services' +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 = () => new HttpService(environment, appVersion, snjsVersion, host, updateMetaCallback) + + 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') + }) +}) diff --git a/packages/api/src/Domain/Http/HttpService.ts b/packages/api/src/Domain/Http/HttpService.ts new file mode 100644 index 000000000..f2dd9bd73 --- /dev/null +++ b/packages/api/src/Domain/Http/HttpService.ts @@ -0,0 +1,190 @@ +import { isString, joinPaths } from '@standardnotes/utils' +import { Environment } from '@standardnotes/services' +import { HttpRequestParams } from './HttpRequestParams' +import { HttpVerb } from './HttpVerb' +import { HttpRequest } from './HttpRequest' +import { HttpResponse } from './HttpResponse' +import { HttpServiceInterface } from './HttpServiceInterface' +import { HttpStatusCode } from './HttpStatusCode' +import { XMLHttpRequestState } from './XMLHttpRequestState' +import { ErrorMessage } from '../Error/ErrorMessage' +import { HttpResponseMeta } from './HttpResponseMeta' +import { HttpErrorResponseBody } from './HttpErrorResponseBody' + +export class HttpService implements HttpServiceInterface { + constructor( + private environment: Environment, + private appVersion: string, + private snjsVersion: string, + private host: string, + private updateMetaCallback: (meta: HttpResponseMeta) => void, + ) {} + + setHost(host: string): void { + this.host = host + } + + async get(path: string, params?: HttpRequestParams, authentication?: string): Promise { + return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Get, authentication }) + } + + async post(path: string, params?: HttpRequestParams, authentication?: string): Promise { + return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Post, authentication }) + } + + async put(path: string, params?: HttpRequestParams, authentication?: string): Promise { + return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Put, authentication }) + } + + async patch(path: string, params: HttpRequestParams, authentication?: string): Promise { + return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Patch, authentication }) + } + + async delete(path: string, params?: HttpRequestParams, authentication?: string): Promise { + return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Delete, authentication }) + } + + private async runHttp(httpRequest: HttpRequest): Promise { + const request = this.createXmlRequest(httpRequest) + + const response = await this.runRequest(request, this.createRequestBody(httpRequest)) + + if (response.meta) { + this.updateMetaCallback(response.meta) + } + + return response + } + + private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined { + if ( + httpRequest.params !== undefined && + [HttpVerb.Post, HttpVerb.Put, HttpVerb.Patch, HttpVerb.Delete].includes(httpRequest.verb) + ) { + return JSON.stringify(httpRequest.params) + } + + return httpRequest.rawBytes + } + + private createXmlRequest(httpRequest: HttpRequest) { + const request = new XMLHttpRequest() + if (httpRequest.params && httpRequest.verb === HttpVerb.Get && Object.keys(httpRequest.params).length > 0) { + httpRequest.url = this.urlForUrlAndParams(httpRequest.url, httpRequest.params) + } + request.open(httpRequest.verb, httpRequest.url, true) + request.responseType = httpRequest.responseType ?? '' + + if (!httpRequest.external) { + request.setRequestHeader('X-SNJS-Version', this.snjsVersion) + + const appVersionHeaderValue = `${Environment[this.environment]}-${this.appVersion}` + request.setRequestHeader('X-Application-Version', appVersionHeaderValue) + + if (httpRequest.authentication) { + request.setRequestHeader('Authorization', 'Bearer ' + httpRequest.authentication) + } + } + + let contenTypeIsSet = false + if (httpRequest.customHeaders && httpRequest.customHeaders.length > 0) { + httpRequest.customHeaders.forEach(({ key, value }) => { + request.setRequestHeader(key, value) + if (key === 'Content-Type') { + contenTypeIsSet = true + } + }) + } + if (!contenTypeIsSet && !httpRequest.external) { + request.setRequestHeader('Content-Type', 'application/json') + } + + return request + } + + private async runRequest(request: XMLHttpRequest, body?: string | Uint8Array): Promise { + return new Promise((resolve, reject) => { + request.onreadystatechange = () => { + this.stateChangeHandlerForRequest(request, resolve, reject) + } + request.send(body) + }) + } + + private stateChangeHandlerForRequest( + request: XMLHttpRequest, + resolve: (response: HttpResponse) => void, + reject: (response: HttpResponse) => void, + ) { + if (request.readyState !== XMLHttpRequestState.Completed) { + return + } + const httpStatus = request.status + const response: HttpResponse = { + status: httpStatus, + headers: new Map(), + } + + const responseHeaderLines = request + .getAllResponseHeaders() + ?.trim() + .split(/[\r\n]+/) + responseHeaderLines?.forEach((responseHeaderLine) => { + const parts = responseHeaderLine.split(': ') + const name = parts.shift() as string + const value = parts.join(': ') + + ;(>response.headers).set(name, value) + }) + + try { + if (httpStatus !== HttpStatusCode.NoContent) { + let body + + const contentTypeHeader = response.headers?.get('content-type') || response.headers?.get('Content-Type') + + if (contentTypeHeader?.includes('application/json')) { + body = JSON.parse(request.responseText) + } else { + body = request.response + } + /** + * v0 APIs do not have a `data` top-level object. In such cases, mimic + * the newer response body style by putting all the top-level + * properties inside a `data` object. + */ + if (!body.data) { + response.data = body + } + if (!isString(body)) { + Object.assign(response, body) + } + } + } catch (error) { + console.error(error) + } + if (httpStatus >= HttpStatusCode.Success && httpStatus < HttpStatusCode.MultipleChoices) { + resolve(response) + } else { + if (httpStatus === HttpStatusCode.Forbidden && response.data && response.data.error !== undefined) { + ;(response.data as HttpErrorResponseBody).error.message = ErrorMessage.RateLimited + } + + reject(response) + } + } + + private urlForUrlAndParams(url: string, params: HttpRequestParams) { + const keyValueString = Object.keys(params) + .map((key) => { + return key + '=' + encodeURIComponent(params[key] as string) + }) + .join('&') + + if (url.includes('?')) { + return url + '&' + keyValueString + } else { + return url + '?' + keyValueString + } + } +} diff --git a/packages/api/src/Domain/Http/HttpServiceInterface.ts b/packages/api/src/Domain/Http/HttpServiceInterface.ts new file mode 100644 index 000000000..281aacd81 --- /dev/null +++ b/packages/api/src/Domain/Http/HttpServiceInterface.ts @@ -0,0 +1,11 @@ +import { HttpRequestParams } from './HttpRequestParams' +import { HttpResponse } from './HttpResponse' + +export interface HttpServiceInterface { + setHost(host: string): void + get(path: string, params?: HttpRequestParams, authentication?: string): Promise + post(path: string, params?: HttpRequestParams, authentication?: string): Promise + put(path: string, params?: HttpRequestParams, authentication?: string): Promise + patch(path: string, params: HttpRequestParams, authentication?: string): Promise + delete(path: string, params?: HttpRequestParams, authentication?: string): Promise +} diff --git a/packages/api/src/Domain/Http/HttpStatusCode.ts b/packages/api/src/Domain/Http/HttpStatusCode.ts new file mode 100644 index 000000000..3a8e7c54f --- /dev/null +++ b/packages/api/src/Domain/Http/HttpStatusCode.ts @@ -0,0 +1,9 @@ +export enum HttpStatusCode { + Success = 200, + NoContent = 204, + MultipleChoices = 300, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + ExpiredAccessToken = 498, +} diff --git a/packages/api/src/Domain/Http/HttpVerb.ts b/packages/api/src/Domain/Http/HttpVerb.ts new file mode 100644 index 000000000..2593b47bd --- /dev/null +++ b/packages/api/src/Domain/Http/HttpVerb.ts @@ -0,0 +1,7 @@ +export enum HttpVerb { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', +} diff --git a/packages/api/src/Domain/Http/XMLHttpRequestState.ts b/packages/api/src/Domain/Http/XMLHttpRequestState.ts new file mode 100644 index 000000000..89aeefa5f --- /dev/null +++ b/packages/api/src/Domain/Http/XMLHttpRequestState.ts @@ -0,0 +1,3 @@ +export enum XMLHttpRequestState { + Completed = 4, +} diff --git a/packages/api/src/Domain/Http/index.ts b/packages/api/src/Domain/Http/index.ts new file mode 100644 index 000000000..9b4c93dda --- /dev/null +++ b/packages/api/src/Domain/Http/index.ts @@ -0,0 +1,13 @@ +export * from './ErrorTag' +export * from './HttpErrorResponseBody' +export * from './HttpHeaders' +export * from './HttpRequest' +export * from './HttpRequestParams' +export * from './HttpResponse' +export * from './HttpResponseBody' +export * from './HttpResponseMeta' +export * from './HttpService' +export * from './HttpServiceInterface' +export * from './HttpStatusCode' +export * from './HttpVerb' +export * from './XMLHttpRequestState' diff --git a/packages/api/src/Domain/Request/ApiEndpointParam.ts b/packages/api/src/Domain/Request/ApiEndpointParam.ts new file mode 100644 index 000000000..007ded326 --- /dev/null +++ b/packages/api/src/Domain/Request/ApiEndpointParam.ts @@ -0,0 +1,7 @@ +export enum ApiEndpointParam { + LastSyncToken = 'sync_token', + PaginationToken = 'cursor_token', + SyncDlLimit = 'limit', + SyncPayloads = 'items', + ApiVersion = 'api', +} diff --git a/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts b/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts new file mode 100644 index 000000000..eee43049e --- /dev/null +++ b/packages/api/src/Domain/Request/User/UserRegistrationRequestParams.ts @@ -0,0 +1,11 @@ +import { AnyKeyParamsContent } from '@standardnotes/common' +import { ApiEndpointParam } from '../ApiEndpointParam' +import { ApiVersion } from '../../Api/ApiVersion' + +export type UserRegistrationRequestParams = AnyKeyParamsContent & { + [ApiEndpointParam.ApiVersion]: ApiVersion.v0 + password: string + email: string + ephemeral: boolean + [additionalParam: string]: unknown +} diff --git a/packages/api/src/Domain/Request/index.ts b/packages/api/src/Domain/Request/index.ts new file mode 100644 index 000000000..c3324f422 --- /dev/null +++ b/packages/api/src/Domain/Request/index.ts @@ -0,0 +1,2 @@ +export * from './ApiEndpointParam' +export * from './User/UserRegistrationRequestParams' diff --git a/packages/api/src/Domain/Response/User/UserRegistrationResponse.ts b/packages/api/src/Domain/Response/User/UserRegistrationResponse.ts new file mode 100644 index 000000000..9f83b2960 --- /dev/null +++ b/packages/api/src/Domain/Response/User/UserRegistrationResponse.ts @@ -0,0 +1,7 @@ +import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody' +import { HttpResponse } from '../../Http/HttpResponse' +import { UserRegistrationResponseBody } from './UserRegistrationResponseBody' + +export interface UserRegistrationResponse extends HttpResponse { + data: UserRegistrationResponseBody | HttpErrorResponseBody +} diff --git a/packages/api/src/Domain/Response/User/UserRegistrationResponseBody.ts b/packages/api/src/Domain/Response/User/UserRegistrationResponseBody.ts new file mode 100644 index 000000000..57cbd340f --- /dev/null +++ b/packages/api/src/Domain/Response/User/UserRegistrationResponseBody.ts @@ -0,0 +1,11 @@ +import { Uuid } from '@standardnotes/common' +import { KeyParamsData, SessionBody } from '@standardnotes/responses' + +export type UserRegistrationResponseBody = { + session: SessionBody + key_params: KeyParamsData + user: { + uuid: Uuid + email: string + } +} diff --git a/packages/api/src/Domain/Response/index.ts b/packages/api/src/Domain/Response/index.ts new file mode 100644 index 000000000..a2ed09315 --- /dev/null +++ b/packages/api/src/Domain/Response/index.ts @@ -0,0 +1,2 @@ +export * from './User/UserRegistrationResponse' +export * from './User/UserRegistrationResponseBody' diff --git a/packages/api/src/Domain/Server/User/Paths.ts b/packages/api/src/Domain/Server/User/Paths.ts new file mode 100644 index 000000000..5b9a09fc5 --- /dev/null +++ b/packages/api/src/Domain/Server/User/Paths.ts @@ -0,0 +1,9 @@ +const UserPaths = { + register: '/v1/users', +} + +export const Paths = { + v1: { + ...UserPaths, + }, +} diff --git a/packages/api/src/Domain/Server/User/UserServer.spec.ts b/packages/api/src/Domain/Server/User/UserServer.spec.ts new file mode 100644 index 000000000..43251cae7 --- /dev/null +++ b/packages/api/src/Domain/Server/User/UserServer.spec.ts @@ -0,0 +1,39 @@ +import { ProtocolVersion } from '@standardnotes/common' +import { ApiVersion } from '../../Api' +import { HttpServiceInterface } from '../../Http' +import { UserRegistrationResponse } from '../../Response' +import { UserServer } from './UserServer' + +describe('UserServer', () => { + let httpService: HttpServiceInterface + + const createServer = () => new UserServer(httpService) + + beforeEach(() => { + httpService = {} as jest.Mocked + httpService.post = jest.fn().mockReturnValue({ + data: { user: { email: 'test@test.te', uuid: '1-2-3' } }, + } as jest.Mocked) + }) + + 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', + }, + }, + }) + }) +}) diff --git a/packages/api/src/Domain/Server/User/UserServer.ts b/packages/api/src/Domain/Server/User/UserServer.ts new file mode 100644 index 000000000..ca8f796f7 --- /dev/null +++ b/packages/api/src/Domain/Server/User/UserServer.ts @@ -0,0 +1,15 @@ +import { HttpServiceInterface } from '../../Http/HttpServiceInterface' +import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams' +import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' +import { Paths } from './Paths' +import { UserServerInterface } from './UserServerInterface' + +export class UserServer implements UserServerInterface { + constructor(private httpService: HttpServiceInterface) {} + + async register(params: UserRegistrationRequestParams): Promise { + const response = await this.httpService.post(Paths.v1.register, params) + + return response as UserRegistrationResponse + } +} diff --git a/packages/api/src/Domain/Server/User/UserServerInterface.ts b/packages/api/src/Domain/Server/User/UserServerInterface.ts new file mode 100644 index 000000000..e531f38d9 --- /dev/null +++ b/packages/api/src/Domain/Server/User/UserServerInterface.ts @@ -0,0 +1,6 @@ +import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams' +import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse' + +export interface UserServerInterface { + register(params: UserRegistrationRequestParams): Promise +} diff --git a/packages/api/src/Domain/Server/index.ts b/packages/api/src/Domain/Server/index.ts new file mode 100644 index 000000000..a2af11233 --- /dev/null +++ b/packages/api/src/Domain/Server/index.ts @@ -0,0 +1,3 @@ +export * from './User/Paths' +export * from './User/UserServer' +export * from './User/UserServerInterface' diff --git a/packages/api/src/Domain/index.ts b/packages/api/src/Domain/index.ts new file mode 100644 index 000000000..9fb72a2d0 --- /dev/null +++ b/packages/api/src/Domain/index.ts @@ -0,0 +1,7 @@ +export * from './Api' +export * from './Client' +export * from './Error' +export * from './Http' +export * from './Request' +export * from './Response' +export * from './Server' diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 000000000..920deacdb --- /dev/null +++ b/packages/api/src/index.ts @@ -0,0 +1 @@ +export * from './Domain' diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 000000000..f3dac14ef --- /dev/null +++ b/packages/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../node_modules/@standardnotes/config/src/tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "rootDir": "./src", + "outDir": "./dist", + }, + "include": [ + "src/**/*" + ], + "references": [], + "exclude": ["**/*.spec.ts", "dist", "node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 730b82bb3..6b9b2d58b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6167,19 +6167,25 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/api@npm:^1.1.19": - version: 1.1.19 - resolution: "@standardnotes/api@npm:1.1.19" +"@standardnotes/api@^1.1.19, @standardnotes/api@workspace:packages/api": + version: 0.0.0-use.local + resolution: "@standardnotes/api@workspace:packages/api" dependencies: - "@standardnotes/auth": ^3.19.4 "@standardnotes/common": ^1.23.1 - "@standardnotes/encryption": ^1.8.23 + "@standardnotes/encryption": "workspace:*" + "@standardnotes/models": "workspace:*" "@standardnotes/responses": ^1.6.39 - "@standardnotes/services": ^1.13.23 - "@standardnotes/utils": ^1.6.12 - checksum: cca168245a80d333ca6433799a7cbe4a233956cace92b9e9ec45b3f67e4e907ef4f08a9573008bdf2b11a09100dc0381cff820ee5bea384407c2107c494913ba - languageName: node - linkType: hard + "@standardnotes/security": ^1.1.0 + "@standardnotes/services": "workspace:*" + "@types/jest": ^27.4.1 + "@types/lodash": ^4.14.182 + "@typescript-eslint/eslint-plugin": ^5.30.0 + eslint-plugin-prettier: ^4.2.1 + jest: ^27.5.1 + reflect-metadata: ^0.1.13 + ts-jest: ^27.1.3 + languageName: unknown + linkType: soft "@standardnotes/app-monorepo@workspace:.": version: 0.0.0-use.local @@ -7081,6 +7087,17 @@ __metadata: languageName: node linkType: hard +"@standardnotes/security@npm:^1.1.0": + version: 1.1.0 + resolution: "@standardnotes/security@npm:1.1.0" + dependencies: + "@standardnotes/common": ^1.23.1 + jsonwebtoken: ^8.5.1 + reflect-metadata: ^0.1.13 + checksum: 2098584cd3fae7b89c13aba6b110a7717dbcce7121162a29478b0c2e754e37d349d1e869f6d0040b709377ec83d0ab0eeef668e234dedba1652fb760ffdf57cc + languageName: node + linkType: hard + "@standardnotes/services@^1.13.23, @standardnotes/services@workspace:*, @standardnotes/services@workspace:packages/services": version: 0.0.0-use.local resolution: "@standardnotes/services@workspace:packages/services"