From 51ef5d180f808ba5aea5b042466dca5c5b641de7 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 1 Oct 2024 16:49:23 +0100 Subject: [PATCH 001/118] Initial server implementation Co-authored-by: me@evanhahn.com --- package-lock.json | 66 ++++++++++++++++++++++- package.json | 3 ++ src/server/app.js | 21 ++++++++ src/server/comapeo-plugin.js | 13 +++++ src/server/routes.js | 89 ++++++++++++++++++++++++++++++++ src/server/types.ts | 12 +++++ src/server/ws-core-replicator.js | 44 ++++++++++++++++ 7 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/server/app.js create mode 100644 src/server/comapeo-plugin.js create mode 100644 src/server/routes.js create mode 100644 src/server/types.ts create mode 100644 src/server/ws-core-replicator.js diff --git a/package-lock.json b/package-lock.json index 35f5c4a86..43720be6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@fastify/error": "^3.4.1", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", + "@fastify/websocket": "^11.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", @@ -55,6 +56,7 @@ "type-fest": "^4.5.0", "undici": "^6.13.0", "varint": "^6.0.0", + "ws": "^8.18.0", "yauzl-promise": "^4.0.0" }, "devDependencies": { @@ -75,6 +77,7 @@ "@types/sub-encoder": "^2.1.0", "@types/throttle-debounce": "^5.0.0", "@types/varint": "^6.0.1", + "@types/ws": "^8.5.12", "@types/yauzl-promise": "^4.0.0", "@types/yazl": "^2.4.5", "bitfield": "^4.2.0", @@ -637,6 +640,21 @@ "@sinclair/typebox": ">=0.26 <=0.33" } }, + "node_modules/@fastify/websocket": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.0.1.tgz", + "integrity": "sha512-44yam5+t1I9v09hWBYO+ezV88+mb9Se2BjgERtzB/68+0mGeTfFkjBeDBe2y+ZdiPpeO2rhevhdnfrBm5mqH+Q==", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^5.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@fastify/websocket/node_modules/fastify-plugin": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz", + "integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -1215,7 +1233,8 @@ }, "node_modules/@sinclair/typebox": { "version": "0.29.6", - "license": "MIT" + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.29.6.tgz", + "integrity": "sha512-aX5IFYWlMa7tQ8xZr3b2gtVReCvg7f3LEhjir/JAjX2bJCMVJA5tIPv30wTD4KDfcwMd7DDYY3hFDeGmOgtrZQ==" }, "node_modules/@sinonjs/commons": { "version": "2.0.0", @@ -1378,6 +1397,15 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/yauzl-promise/-/yauzl-promise-4.0.0.tgz", @@ -2833,6 +2861,17 @@ } } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "license": "MIT" @@ -7494,6 +7533,11 @@ "node": ">= 0.8" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" + }, "node_modules/streamx": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.19.0.tgz", @@ -8352,6 +8396,26 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xache": { "version": "1.1.0", "license": "MIT" diff --git a/package.json b/package.json index 7281d8671..7d2d773d3 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "@types/sub-encoder": "^2.1.0", "@types/throttle-debounce": "^5.0.0", "@types/varint": "^6.0.1", + "@types/ws": "^8.5.12", "@types/yauzl-promise": "^4.0.0", "@types/yazl": "^2.4.5", "bitfield": "^4.2.0", @@ -158,6 +159,7 @@ "@fastify/error": "^3.4.1", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", + "@fastify/websocket": "^11.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", @@ -198,6 +200,7 @@ "type-fest": "^4.5.0", "undici": "^6.13.0", "varint": "^6.0.0", + "ws": "^8.18.0", "yauzl-promise": "^4.0.0" } } diff --git a/src/server/app.js b/src/server/app.js new file mode 100644 index 000000000..0c4d4dd3a --- /dev/null +++ b/src/server/app.js @@ -0,0 +1,21 @@ +import fastifyWebsocket from '@fastify/websocket' +import createFastify from 'fastify' + +import routes from './routes.js' +import comapeoPlugin from './comapeo-plugin.js' + +/** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ +/** @typedef {import('fastify').FastifyServerOptions['logger']} FastifyLogger */ +/** @typedef {{ logger: FastifyLogger } & ComapeoPluginOptions} ServerOptions */ + +/** + * @param {ServerOptions} opts + * @returns + */ +export default function createServer({ logger, ...comapeoPluginOpts }) { + const fastify = createFastify({ logger }) + fastify.register(fastifyWebsocket) + fastify.register(comapeoPlugin, comapeoPluginOpts) + fastify.register(routes) + return fastify +} diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js new file mode 100644 index 000000000..5b02fa4b2 --- /dev/null +++ b/src/server/comapeo-plugin.js @@ -0,0 +1,13 @@ +import { MapeoManager } from '../mapeo-manager.js' +import createFastifyPlugin from 'fastify-plugin' + +/** + * @typedef {ConstructorParameters[0]} ComapeoPluginOptions + */ + +/** @type {import('fastify').FastifyPluginAsync} */ +const comapeoPlugin = async function (fastify, opts) { + fastify.decorate('comapeo', new MapeoManager(opts)) +} + +export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/src/server/routes.js b/src/server/routes.js new file mode 100644 index 000000000..d7a3274af --- /dev/null +++ b/src/server/routes.js @@ -0,0 +1,89 @@ +import { Type } from '@sinclair/typebox' + +/** @import {FastifyPluginAsync, RawServerDefault} from 'fastify' */ +/** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ +/** @typedef {Record} RouteOptions */ + +const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$' +const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) + +/** @type {FastifyPluginAsync} */ +export default async function routes(fastify) { + fastify.get( + '/sync/:projectPublicId', + { + schema: { + params: Type.Object({ + projectPublicId: HEX_STRING_32_BYTES, + }), + }, + }, + async function (req, reply) { + /** @type {import('../mapeo-project.js').MapeoProject | undefined} */ + let _project + try { + _project = await this.comapeo.getProject(req.params.projectPublicId) + } catch (err) { + return reply.status(404).send() + } + return reply.status(200).send('TODO: Implement this route') + } + ) + + fastify.post( + '/projects', + { + schema: { + body: Type.Object({ + projectKey: HEX_STRING_32_BYTES, + encryptionKeys: Type.Object({ + auth: HEX_STRING_32_BYTES, + config: HEX_STRING_32_BYTES, + data: HEX_STRING_32_BYTES, + blobIndex: HEX_STRING_32_BYTES, + blob: HEX_STRING_32_BYTES, + }), + }), + response: { + 200: Type.Object({ + deviceId: HEX_STRING_32_BYTES, + }), + 400: Type.Object({ + message: Type.String(), + }), + }, + }, + }, + async function (req, reply) { + const hasExistingProject = await this.comapeo + .listProjects() + .then((projects) => projects.length > 0) + + if (hasExistingProject) { + reply.status(400) + reply.send({ message: 'Only one project is allowed' }) + return reply + } + + const projectKey = Buffer.from(req.body.projectKey, 'hex') + + await this.comapeo.addProject( + { + projectKey, + projectName: 'TODO: Figure out if this should be named', + encryptionKeys: { + auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), + config: Buffer.from(req.body.encryptionKeys.config, 'hex'), + data: Buffer.from(req.body.encryptionKeys.data, 'hex'), + blobIndex: Buffer.from(req.body.encryptionKeys.blobIndex, 'hex'), + blob: Buffer.from(req.body.encryptionKeys.blob, 'hex'), + }, + }, + { waitForSync: false } + ) + + reply.send({ deviceId: this.comapeo.deviceId }) + return reply + } + ) +} diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 000000000..e4812fb5e --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,12 @@ +// This file should be read by Typescript and augments the FastifyInstance +// Unfortunately it does this globally, which is a limitation of fastify +// typescript support currently, so need to be careful about using this where it +// is not in scope. + +import { type MapeoManager } from '../mapeo-manager.js' + +declare module 'fastify' { + interface FastifyInstance { + comapeo: MapeoManager + } +} diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js new file mode 100644 index 000000000..da2f900f1 --- /dev/null +++ b/src/server/ws-core-replicator.js @@ -0,0 +1,44 @@ +import { pipeline } from 'node:stream/promises' +import { Transform } from 'node:stream' +import { createWebSocketStream } from 'ws' + +/** + * @param {import('ws').WebSocket} ws + * @param {import('../types.js').ReplicationStream} replicationStream + */ +export function wsCoreReplicator(ws, replicationStream) { + // This is purely to satisfy typescript at its worst. `pipeline` expects a + // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex + // stream. The difference is that streamx does not implement the + // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline` + // function does not depend on any of these methods (I have read through the + // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely + // cast the stream to a NodeJS ReadWriteStream. + const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ ( + /** @type {unknown} */ (replicationStream) + ) + return pipeline( + _replicationStream, + createWebSocketStream(ws), + wsSafetyTransform(ws), + _replicationStream + ) +} + +/** + * Avoid writing data to a closing or closed websocket, which would result in an + * error. Instead we drop the data and wait for the stream close/end events to + * propogate and close the streams cleanly. + * + * @param {import('ws').WebSocket} ws + */ +function wsSafetyTransform(ws) { + return new Transform({ + transform(chunk, encoding, callback) { + if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + return callback() + } + callback(null, chunk) + }, + }) +} From 76387c99bd362f551c7b460dbf890174e9ec9097 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 1 Oct 2024 18:22:16 +0100 Subject: [PATCH 002/118] WIP --- package-lock.json | 19 ++--- package.json | 2 +- src/lib/is-valid-host.js | 52 ++++++++++++ src/mapeo-manager.js | 2 +- src/mapeo-project.js | 34 +++++--- src/member-api.js | 134 +++++++++++++++++++++++++++++- src/server/app.js | 2 +- src/server/comapeo-plugin.js | 1 + src/server/routes.js | 34 +++++--- src/sync/peer-sync-controller.js | 3 + test-e2e/server-temp.js | 10 +++ test-e2e/server.js | 46 ++++++++++ test-e2e/utils.js | 18 ++++ test/lib/is-valid-host.js | 72 ++++++++++++++++ test/server/ws-core-replicator.js | 0 15 files changed, 390 insertions(+), 39 deletions(-) create mode 100644 src/lib/is-valid-host.js create mode 100644 test-e2e/server-temp.js create mode 100644 test-e2e/server.js create mode 100644 test/lib/is-valid-host.js create mode 100644 test/server/ws-core-replicator.js diff --git a/package-lock.json b/package-lock.json index 43720be6b..360ceaf6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@fastify/error": "^3.4.1", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", - "@fastify/websocket": "^11.0.1", + "@fastify/websocket": "^10.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", @@ -641,20 +641,15 @@ } }, "node_modules/@fastify/websocket": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.0.1.tgz", - "integrity": "sha512-44yam5+t1I9v09hWBYO+ezV88+mb9Se2BjgERtzB/68+0mGeTfFkjBeDBe2y+ZdiPpeO2rhevhdnfrBm5mqH+Q==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz", + "integrity": "sha512-8/pQIxTPRD8U94aILTeJ+2O3el/r19+Ej5z1O1mXlqplsUH7KzCjAI0sgd5DM/NoPjAi5qLFNIjgM5+9/rGSNw==", "dependencies": { - "duplexify": "^4.1.3", - "fastify-plugin": "^5.0.0", - "ws": "^8.16.0" + "duplexify": "^4.1.2", + "fastify-plugin": "^4.0.0", + "ws": "^8.0.0" } }, - "node_modules/@fastify/websocket/node_modules/fastify-plugin": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz", - "integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", diff --git a/package.json b/package.json index 7d2d773d3..569c6b243 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,7 @@ "@fastify/error": "^3.4.1", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", - "@fastify/websocket": "^11.0.1", + "@fastify/websocket": "^10.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", diff --git a/src/lib/is-valid-host.js b/src/lib/is-valid-host.js new file mode 100644 index 000000000..1ecc58dc9 --- /dev/null +++ b/src/lib/is-valid-host.js @@ -0,0 +1,52 @@ +import * as net from 'node:net' + +/** + * @param {string} str + * @returns {boolean} + */ +function isIpAddress(str) { + return Boolean(net.isIP(str)) +} + +/** + * @param {string} host + * @returns {boolean} + */ +export function isValidHost(host) { + if (isIpAddress(host)) return true + + // At this point, we either have a domain (like `example.com`), an IP address + // with a port (like `192.0.2.1:1234`), or something invalid. + + // According to [RFC 1034][0], "the total number of octets that represent a + // domain name [...] is limited to 255." Offer a lot of wiggle room, but avoid + // performance issues with super long inputs. + // + // [0]: https://tools.ietf.org/html/rfc1034 + // [1]: https://stackoverflow.com/a/7477384/804100 + if (host.length > 4096) return false + + /** @type {URL} */ let url + try { + url = new URL('https://' + host) + } catch (_err) { + // If we can't parse it, it's not valid. + return false + } + + // The parsed URL should be exactly as we expect. It's possible to "trick" + // this by adding empty things to the end of the URL (like `example.com#`), so + // also check the string. + const lastChar = host[host.length - 1] + return ( + lastChar !== '/' && + lastChar !== '?' && + lastChar !== '#' && + url.protocol === 'https:' && + !url.username && + !url.password && + url.pathname === '/' && + url.search === '' && + !url.hash + ) +} diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 6457c7cc7..94a7a46f2 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -550,7 +550,7 @@ export class MapeoManager extends TypedEmitter { * downloaded their proof of project membership and the project config. * * @param {Pick & { projectName: string }} projectJoinDetails - * @param {{ waitForSync?: boolean }} [opts] For internal use in tests, set opts.waitForSync = false to not wait for sync during addProject() + * @param {{ waitForSync?: boolean }} [opts] Set opts.waitForSync = false to not wait for sync during addProject() * @returns {Promise} */ addProject = async ( diff --git a/src/mapeo-project.js b/src/mapeo-project.js index ab574ae16..e9097f5f9 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -301,6 +301,13 @@ export class MapeoProject extends TypedEmitter { encryptionKeys, projectKey, rpc: localPeers, + getReplicationStream: this[kProjectReplicate].bind( + this, + // TODO: See if we can fix these + /** @type {any} */ (true) + ), + // TODO: This should be scoped to a single peer, not all peers + waitForInitialSync: () => this.$sync.waitForSync('initial'), dataTypes: { deviceInfo: this.#dataTypes.deviceInfo, project: this.#dataTypes.projectSettings, @@ -580,20 +587,23 @@ export class MapeoProject extends TypedEmitter { * and only this project will replicate (to replicate multiple projects you * need to replicate the manager instance via manager[kManagerReplicate]) * - * @param {Parameters[0]} stream A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance + * @param {Parameters[0]} isInitiatorOrStream A duplex stream, a @hyperswarm/secret-stream, or a Protomux instance, or a boolean */ - [kProjectReplicate](stream) { + [kProjectReplicate](isInitiatorOrStream) { // @ts-expect-error - hypercore types need updating - const replicationStream = this.#coreManager.creatorCore.replicate(stream, { - // @ts-ignore - hypercore types do not currently include this option - ondiscoverykey: async (discoveryKey) => { - const protomux = - /** @type {import('protomux')} */ ( - replicationStream.noiseStream.userData - ) - this.#syncApi[kHandleDiscoveryKey](discoveryKey, protomux) - }, - }) + const replicationStream = this.#coreManager.creatorCore.replicate( + isInitiatorOrStream, + { + // @ts-ignore - hypercore types do not currently include this option + ondiscoverykey: async (discoveryKey) => { + const protomux = + /** @type {import('protomux')} */ ( + replicationStream.noiseStream.userData + ) + this.#syncApi[kHandleDiscoveryKey](discoveryKey, protomux) + }, + } + ) return replicationStream } diff --git a/src/member-api.js b/src/member-api.js index e6cea8ccb..c98094fe2 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -1,4 +1,6 @@ +import * as b4a from 'b4a' import * as crypto from 'node:crypto' +import WebSocket from 'ws' import { TypedEmitter } from 'tiny-typed-emitter' import { pEvent } from 'p-event' import { InviteResponse_Decision } from './generated/rpc.js' @@ -8,11 +10,14 @@ import { ExhaustivenessError, projectKeyToId, projectKeyToProjectInviteId, + projectKeyToPublicId, } from './utils.js' import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' -import { ROLES, isRoleIdForNewInvite } from './roles.js' +import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' +import { isValidHost } from './lib/is-valid-host.js' +import { wsCoreReplicator } from './server/ws-core-replicator.js' /** * @import { * DeviceInfo, @@ -26,6 +31,7 @@ import { ROLES, isRoleIdForNewInvite } from './roles.js' /** @import { DataStore } from './datastore/index.js' */ /** @import { deviceInfoTable } from './schema/project.js' */ /** @import { projectSettingsTable } from './schema/client.js' */ +/** @import { ReplicationStream } from './types.js' */ /** @typedef {DataType, typeof deviceInfoTable, "deviceInfo", DeviceInfo, DeviceInfoValue>} DeviceInfoDataType */ /** @typedef {DataType, typeof projectSettingsTable, "projectSettings", ProjectSettings, ProjectSettingsValue>} ProjectDataType */ @@ -45,6 +51,8 @@ export class MemberApi extends TypedEmitter { #encryptionKeys #projectKey #rpc + #getReplicationStream + #waitForInitialSync #dataTypes /** @type {Map} */ @@ -58,6 +66,8 @@ export class MemberApi extends TypedEmitter { * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc + * @param {() => ReplicationStream} opts.getReplicationStream + * @param {() => Promise} opts.waitForInitialSync * @param {Object} opts.dataTypes * @param {Pick} opts.dataTypes.deviceInfo * @param {Pick} opts.dataTypes.project @@ -69,6 +79,8 @@ export class MemberApi extends TypedEmitter { encryptionKeys, projectKey, rpc, + getReplicationStream, + waitForInitialSync, dataTypes, }) { super() @@ -78,6 +90,8 @@ export class MemberApi extends TypedEmitter { this.#encryptionKeys = encryptionKeys this.#projectKey = projectKey this.#rpc = rpc + this.#getReplicationStream = getReplicationStream + this.#waitForInitialSync = waitForInitialSync this.#dataTypes = dataTypes } @@ -245,6 +259,116 @@ export class MemberApi extends TypedEmitter { this.#outboundInvitesByDevice.get(deviceId)?.abortController.abort() } + /** + * Add a server peer. + * + * @param {string} host + * @param {object} [options] + * @param {boolean} [options.dangerouslyAllowInsecureConnections] + * @returns {Promise} + */ + async addServerPeer(host, { dangerouslyAllowInsecureConnections } = {}) { + console.log('@@@@', 'starting addServerPeer') + assert(isValidHost(host), 'Hostname must be valid') + + const requestProtocol = dangerouslyAllowInsecureConnections + ? 'http:' + : 'https:' + const requestUrl = `${requestProtocol}//${host}/projects` + + const requestBody = { + projectKey: encodeBufferForServer(this.#projectKey), + encryptionKeys: { + auth: encodeBufferForServer(this.#encryptionKeys.auth), + data: encodeBufferForServer(this.#encryptionKeys.data), + config: encodeBufferForServer(this.#encryptionKeys.config), + blobIndex: encodeBufferForServer(this.#encryptionKeys.blobIndex), + blob: encodeBufferForServer(this.#encryptionKeys.blob), + }, + } + + console.log('@@@@', 'making request to ' + requestUrl) + + /** @type {Response} */ let response + try { + response = await fetch(requestUrl, { + method: 'POST', + body: JSON.stringify(requestBody), + headers: { 'Content-Type': 'application/json' }, + }) + } catch (err) { + const message = + err && typeof err === 'object' && 'message' in err + ? err.message + : String(err) + console.log('@@@@', err) + throw new Error( + `Failed to add server peer due to network error: ${message}` + ) + } + + if (response.status !== 200) { + // TODO: Better error handling here + console.log('@@@@', 'bad response', response.status) + throw new Error( + `Failed to add server peer due to HTTP status code ${response.status}` + ) + } + + /** @type {string} */ let deviceId + try { + const responseBody = await response.json() + assert( + responseBody && + typeof responseBody === 'object' && + 'deviceId' in responseBody && + typeof responseBody.deviceId === 'string', + 'Response body is valid' + ) + ;({ deviceId } = responseBody) + } catch (err) { + console.log('@@@@', 'failed to parse json', err) + throw new Error( + "Failed to add server peer because we couldn't parse the response" + ) + } + + console.log('@@@@', 'about to assign the role...') + const roleId = MEMBER_ROLE_ID + await this.#roles.assignRole(deviceId, roleId) + console.log('@@@@', 'assigned the role.') + + const projectPublicId = projectKeyToPublicId(this.#projectKey) + const websocketProtocol = dangerouslyAllowInsecureConnections + ? 'ws:' + : 'wss:' + console.log( + '@@@@', + 'opening websocket to', + `${websocketProtocol}//${host}/sync/${projectPublicId}` + ) + const websocket = new WebSocket( + `${websocketProtocol}//${host}/sync/${projectPublicId}` + ) + const replicationStream = this.#getReplicationStream() + console.log('@@@@', 'got a replication stream') + wsCoreReplicator(websocket, replicationStream) + console.log('@@@@', 'made the ws core replicator') + + // TODO: remove this + await new Promise((resolve) => { + setTimeout(resolve, 3000) + }) + + // TODO: This should be scoped to a single peer + await this.#waitForInitialSync() + console.log('@@@@', 'initial sync done') + + websocket.close() + + console.log('@@@@', 'closed websocket') + } + /** * @param {string} deviceId * @returns {Promise} @@ -324,3 +448,11 @@ export class MemberApi extends TypedEmitter { return this.#roles.assignRole(deviceId, roleId) } } + +/** + * @param {undefined | Uint8Array} buffer + * @returns {undefined | string} + */ +function encodeBufferForServer(buffer) { + return buffer ? b4a.toString(buffer, 'hex') : undefined +} diff --git a/src/server/app.js b/src/server/app.js index 0c4d4dd3a..8c5a399b7 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -6,7 +6,7 @@ import comapeoPlugin from './comapeo-plugin.js' /** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ /** @typedef {import('fastify').FastifyServerOptions['logger']} FastifyLogger */ -/** @typedef {{ logger: FastifyLogger } & ComapeoPluginOptions} ServerOptions */ +/** @typedef {{ logger?: FastifyLogger } & ComapeoPluginOptions} ServerOptions */ /** * @param {ServerOptions} opts diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index 5b02fa4b2..bc3bda6f2 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -8,6 +8,7 @@ import createFastifyPlugin from 'fastify-plugin' /** @type {import('fastify').FastifyPluginAsync} */ const comapeoPlugin = async function (fastify, opts) { fastify.decorate('comapeo', new MapeoManager(opts)) + // TODO: Check if deviceInfo is already set, and if not, set it. } export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/src/server/routes.js b/src/server/routes.js index d7a3274af..893fc7517 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,4 +1,6 @@ import { Type } from '@sinclair/typebox' +import { kProjectReplicate } from '../mapeo-project.js' +import { wsCoreReplicator } from './ws-core-replicator.js' /** @import {FastifyPluginAsync, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ @@ -14,19 +16,27 @@ export default async function routes(fastify) { { schema: { params: Type.Object({ - projectPublicId: HEX_STRING_32_BYTES, + projectPublicId: Type.String(), }), }, + async preValidation(req, reply) { + const projectPublicId = req.params.projectPublicId + const project = await this.comapeo.getProject(projectPublicId) + if (!project) { + reply.status(404) + reply.send() + } + }, + websocket: true, }, - async function (req, reply) { - /** @type {import('../mapeo-project.js').MapeoProject | undefined} */ - let _project - try { - _project = await this.comapeo.getProject(req.params.projectPublicId) - } catch (err) { - return reply.status(404).send() - } - return reply.status(200).send('TODO: Implement this route') + async function (socket, req) { + // The preValidation hook ensures that the project exists + const project = await this.comapeo.getProject(req.params.projectPublicId) + const replicationStream = project[kProjectReplicate]( + // TODO: See if we can fix this type cast + /** @type {any} */ (false) + ) + wsCoreReplicator(socket, replicationStream) } ) @@ -67,7 +77,7 @@ export default async function routes(fastify) { const projectKey = Buffer.from(req.body.projectKey, 'hex') - await this.comapeo.addProject( + const projectId = await this.comapeo.addProject( { projectKey, projectName: 'TODO: Figure out if this should be named', @@ -81,6 +91,8 @@ export default async function routes(fastify) { }, { waitForSync: false } ) + const project = await this.comapeo.getProject(projectId) + project.$sync.start() reply.send({ deviceId: this.comapeo.deviceId }) return reply diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 7e9369776..8df976bc0 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -73,6 +73,8 @@ export class PeerSyncController { } get peerKey() { + // if (!this.#protomux.stream.remotePublicKey) + // console.log('peerKey proto stream', this.#protomux.stream) return this.#protomux.stream.remotePublicKey } @@ -163,6 +165,7 @@ export class PeerSyncController { if (didUpdate.auth) { try { + this.#log('reading role for %h', this.peerId) const cap = await this.#roles.getRole(this.peerId) this.#syncCapability = cap.sync } catch (e) { diff --git a/test-e2e/server-temp.js b/test-e2e/server-temp.js new file mode 100644 index 000000000..4bd9bd728 --- /dev/null +++ b/test-e2e/server-temp.js @@ -0,0 +1,10 @@ +import createServer from '../src/server/app.js' +import { getManagerOptions } from './utils.js' + +const fastify = createServer({ + ...getManagerOptions('test server'), + logger: true, +}) +fastify.listen(3000).then((address) => { + console.log(`Server listening on ${address}`) +}) diff --git a/test-e2e/server.js b/test-e2e/server.js new file mode 100644 index 000000000..ff807af0f --- /dev/null +++ b/test-e2e/server.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { MEMBER_ROLE_ID } from '../src/roles.js' +import { createManager, getManagerOptions } from './utils.js' +import createServer from '../src/server/app.js' + +// TODO: test invalid hostname +// TODO: test bad requests + +test('adding a server peer', async (t) => { + console.log('@@@@', 'adding a server peer') + const manager = createManager('device0', t) + await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) // TODO: necessary? + console.log('@@@@', 'created manager', manager.deviceId) + const projectId = await manager.createProject() + console.log('@@@@', 'created project') + const project = await manager.getProject(projectId) + + console.log('@@@@', 'creating the project') + + const server = createTestServer() + + const serverAddress = await server.listen() + console.log('@@@@', serverAddress) + + t.after(() => server.close()) + const serverHost = new URL(serverAddress).host + + await project.$member.addServerPeer(serverHost, { + dangerouslyAllowInsecureConnections: true, + }) + + const members = await project.$member.getMany() + console.log('@@@@', members) + // TODO: Ensure that this peer doesn't exist before adding? + const hasServerPeer = members.some( + (member) => + member.deviceType === 'selfHostedServer' && + member.role.roleId === MEMBER_ROLE_ID + ) + assert(hasServerPeer, 'expected a server peer to be found by the client') +}) + +function createTestServer() { + return createServer({ ...getManagerOptions('test server'), logger: true }) +} diff --git a/test-e2e/utils.js b/test-e2e/utils.js index 2ad7a80f8..22677fcb1 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -189,6 +189,24 @@ export async function createManagers( ) } +/** + * TODO: DRY this out with the below + * @param {string} seed + * @param {Partial[0]>} [overrides] + * @returns {ConstructorParameters[0]} + */ +export function getManagerOptions(seed, overrides = {}) { + return { + rootKey: getRootKey(seed), + projectMigrationsFolder, + clientMigrationsFolder, + dbFolder: ':memory:', + coreStorage: () => new RAM(), + fastify: Fastify(), + ...overrides, + } +} + /** * @param {string} seed * @param {import('node:test').TestContext} t diff --git a/test/lib/is-valid-host.js b/test/lib/is-valid-host.js new file mode 100644 index 000000000..06c4b7247 --- /dev/null +++ b/test/lib/is-valid-host.js @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isValidHost } from '../../src/lib/is-valid-host.js' + +test('too short', () => { + assert(!isValidHost('')) +}) + +test('too long', () => { + assert(!isValidHost('x'.repeat(4097))) +}) + +test('has auth', () => { + assert(!isValidHost('user@example.com')) + assert(!isValidHost(':password@example.com')) + assert(!isValidHost('user:password@example.com')) +}) + +test('has path', () => { + assert(!isValidHost('example.com/foo')) +}) + +test('has search', () => { + assert(!isValidHost('example.com?foo=bar')) +}) + +test('has hash', () => { + assert(!isValidHost('example.com#foo')) +}) + +test('has empty path, search, and/or hash', () => { + assert(!isValidHost('example.com/')) + assert(!isValidHost('example.com?')) + assert(!isValidHost('example.com#')) + assert(!isValidHost('example.com/#')) + assert(!isValidHost('example.com/?')) + assert(!isValidHost('example.com?#')) + assert(!isValidHost('example.com/?#')) +}) + +test('invalid as URLs', () => { + assert(!isValidHost(' ')) + assert(!isValidHost('')) +}) + +test('IPv4 is valid', () => { + assert(isValidHost('0.0.0.0')) + assert(isValidHost('127.0.0.1')) + assert(isValidHost('100.64.0.42')) + assert(isValidHost('100.64.0.42:1234')) +}) + +test('IPv6 is valid', () => { + assert(isValidHost('::')) + assert(isValidHost('2001:0db8:0000:0000:0000:0000:0000:0000')) + assert(isValidHost('[::]')) + assert(isValidHost('[2001:0db8:0000:0000:0000:0000:0000:0000]')) + + assert(isValidHost('[2001:0db8:0000:0000:0000:0000:0000:0000]:1234')) +}) + +test('IPv6-coded IPv4s are valid', () => { + assert(isValidHost('[0:0:0:0:0:ffff:6440:002a]')) + assert(isValidHost('[0:0:0:0:0:ffff:6440:002a]:1234')) +}) + +test('other hostnames are valid', () => { + assert(isValidHost('example')) + assert(isValidHost('example:1234')) + assert(isValidHost('example.com')) + assert(isValidHost('example.com:1234')) +}) diff --git a/test/server/ws-core-replicator.js b/test/server/ws-core-replicator.js new file mode 100644 index 000000000..e69de29bb From 1e568afbbb44d87c45845d3bbf7dab2f096b20b9 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 1 Oct 2024 20:29:52 +0100 Subject: [PATCH 003/118] fix: use identity keypair for replication --- src/mapeo-project.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index e9097f5f9..a61d47c8d 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -92,6 +92,7 @@ export class MapeoProject extends TypedEmitter { #loadingConfig static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS + #identityKeypair /** * @param {Object} opts @@ -275,7 +276,7 @@ export class MapeoProject extends TypedEmitter { }, }), } - const identityKeypair = keyManager.getIdentityKeypair() + this.#identityKeypair = keyManager.getIdentityKeypair() const coreKeypairs = getCoreKeypairs({ projectKey, projectSecretKey, @@ -284,14 +285,14 @@ export class MapeoProject extends TypedEmitter { this.#coreOwnership = new CoreOwnership({ dataType: this.#dataTypes.coreOwnership, coreKeypairs, - identityKeypair, + identityKeypair: this.#identityKeypair, }) this.#roles = new Roles({ dataType: this.#dataTypes.role, coreOwnership: this.#coreOwnership, coreManager: this.#coreManager, projectKey: projectKey, - deviceKey: keyManager.getIdentityKeypair().publicKey, + deviceKey: this.#identityKeypair.publicKey, }) this.#memberApi = new MemberApi({ @@ -594,6 +595,7 @@ export class MapeoProject extends TypedEmitter { const replicationStream = this.#coreManager.creatorCore.replicate( isInitiatorOrStream, { + keyPair: this.#identityKeypair, // @ts-ignore - hypercore types do not currently include this option ondiscoverykey: async (discoveryKey) => { const protomux = From 88fd5b13f399099fccca0246f89d93b2b448aca7 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 1 Oct 2024 20:49:41 +0100 Subject: [PATCH 004/118] server: set device info on initialization --- src/server/comapeo-plugin.js | 13 ++++++++++--- test-e2e/server.js | 10 +++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index bc3bda6f2..27bd179d2 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -2,13 +2,20 @@ import { MapeoManager } from '../mapeo-manager.js' import createFastifyPlugin from 'fastify-plugin' /** - * @typedef {ConstructorParameters[0]} ComapeoPluginOptions + * @typedef {ConstructorParameters[0] & { serverName: string }} ComapeoPluginOptions */ /** @type {import('fastify').FastifyPluginAsync} */ const comapeoPlugin = async function (fastify, opts) { - fastify.decorate('comapeo', new MapeoManager(opts)) - // TODO: Check if deviceInfo is already set, and if not, set it. + const comapeo = new MapeoManager(opts) + fastify.decorate('comapeo', comapeo) + const existingDeviceInfo = comapeo.getDeviceInfo() + if (existingDeviceInfo.deviceType === 'device_type_unspecified') { + await comapeo.setDeviceInfo({ + deviceType: 'desktop', + name: opts.serverName, + }) + } } export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/test-e2e/server.js b/test-e2e/server.js index ff807af0f..75b0df1d6 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -35,12 +35,16 @@ test('adding a server peer', async (t) => { // TODO: Ensure that this peer doesn't exist before adding? const hasServerPeer = members.some( (member) => - member.deviceType === 'selfHostedServer' && - member.role.roleId === MEMBER_ROLE_ID + // TODO: use server device type + member.deviceType === 'desktop' && member.role.roleId === MEMBER_ROLE_ID ) assert(hasServerPeer, 'expected a server peer to be found by the client') }) function createTestServer() { - return createServer({ ...getManagerOptions('test server'), logger: true }) + return createServer({ + ...getManagerOptions('test server'), + serverName: 'test server', + logger: true, + }) } From f9971011c725696cf0141030da75f94c2d8f3685 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 22:09:05 +0000 Subject: [PATCH 005/118] chore: move #identityKeypair member higher up --- src/mapeo-project.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index a61d47c8d..1d08c27e9 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -74,6 +74,7 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({}) export class MapeoProject extends TypedEmitter { #projectId #deviceId + #identityKeypair #coreManager #indexWriter #dataStores @@ -92,7 +93,6 @@ export class MapeoProject extends TypedEmitter { #loadingConfig static EMPTY_PROJECT_SETTINGS = EMPTY_PROJECT_SETTINGS - #identityKeypair /** * @param {Object} opts From d27266486e7be97d6d286f7d73c799ddaa38a78f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 17:31:47 -0500 Subject: [PATCH 006/118] chore: remove unused server script (#876) We wrote this to test something and no longer need it. --- test-e2e/server-temp.js | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 test-e2e/server-temp.js diff --git a/test-e2e/server-temp.js b/test-e2e/server-temp.js deleted file mode 100644 index 4bd9bd728..000000000 --- a/test-e2e/server-temp.js +++ /dev/null @@ -1,10 +0,0 @@ -import createServer from '../src/server/app.js' -import { getManagerOptions } from './utils.js' - -const fastify = createServer({ - ...getManagerOptions('test server'), - logger: true, -}) -fastify.listen(3000).then((address) => { - console.log(`Server listening on ${address}`) -}) From 72f3190d750285ca99fd98d471fd8c34cac32285 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 17:34:18 -0500 Subject: [PATCH 007/118] chore: use `await` instead of `then` in server route (#878) This is a minor change. I think it makes sense to use `await` in an async function, rather than using `then` to handle the promise. (I guess we're `await`ing in either case, but I think this is clearer.) --- src/server/routes.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 893fc7517..102eaf0a1 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -65,9 +65,7 @@ export default async function routes(fastify) { }, }, async function (req, reply) { - const hasExistingProject = await this.comapeo - .listProjects() - .then((projects) => projects.length > 0) + const hasExistingProject = (await this.comapeo.listProjects()).length > 0 if (hasExistingProject) { reply.status(400) From e0b4d4af090c1b3823c6f91063c8aaaa91e89eb5 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 17:34:26 -0500 Subject: [PATCH 008/118] chore: remove commented-out `PeerSyncController` lines (#877) --- src/sync/peer-sync-controller.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sync/peer-sync-controller.js b/src/sync/peer-sync-controller.js index 8df976bc0..1eec905a1 100644 --- a/src/sync/peer-sync-controller.js +++ b/src/sync/peer-sync-controller.js @@ -73,8 +73,6 @@ export class PeerSyncController { } get peerKey() { - // if (!this.#protomux.stream.remotePublicKey) - // console.log('peerKey proto stream', this.#protomux.stream) return this.#protomux.stream.remotePublicKey } From ab49173e8e151078623c947f83efbd41e6013726 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 17:34:35 -0500 Subject: [PATCH 009/118] chore: remove console.logs from server code (#879) --- src/member-api.js | 18 ------------------ test-e2e/server.js | 8 -------- 2 files changed, 26 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index c98094fe2..cc0757744 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -268,7 +268,6 @@ export class MemberApi extends TypedEmitter { * @returns {Promise} */ async addServerPeer(host, { dangerouslyAllowInsecureConnections } = {}) { - console.log('@@@@', 'starting addServerPeer') assert(isValidHost(host), 'Hostname must be valid') const requestProtocol = dangerouslyAllowInsecureConnections @@ -287,8 +286,6 @@ export class MemberApi extends TypedEmitter { }, } - console.log('@@@@', 'making request to ' + requestUrl) - /** @type {Response} */ let response try { response = await fetch(requestUrl, { @@ -301,7 +298,6 @@ export class MemberApi extends TypedEmitter { err && typeof err === 'object' && 'message' in err ? err.message : String(err) - console.log('@@@@', err) throw new Error( `Failed to add server peer due to network error: ${message}` ) @@ -309,7 +305,6 @@ export class MemberApi extends TypedEmitter { if (response.status !== 200) { // TODO: Better error handling here - console.log('@@@@', 'bad response', response.status) throw new Error( `Failed to add server peer due to HTTP status code ${response.status}` ) @@ -327,33 +322,23 @@ export class MemberApi extends TypedEmitter { ) ;({ deviceId } = responseBody) } catch (err) { - console.log('@@@@', 'failed to parse json', err) throw new Error( "Failed to add server peer because we couldn't parse the response" ) } - console.log('@@@@', 'about to assign the role...') const roleId = MEMBER_ROLE_ID await this.#roles.assignRole(deviceId, roleId) - console.log('@@@@', 'assigned the role.') const projectPublicId = projectKeyToPublicId(this.#projectKey) const websocketProtocol = dangerouslyAllowInsecureConnections ? 'ws:' : 'wss:' - console.log( - '@@@@', - 'opening websocket to', - `${websocketProtocol}//${host}/sync/${projectPublicId}` - ) const websocket = new WebSocket( `${websocketProtocol}//${host}/sync/${projectPublicId}` ) const replicationStream = this.#getReplicationStream() - console.log('@@@@', 'got a replication stream') wsCoreReplicator(websocket, replicationStream) - console.log('@@@@', 'made the ws core replicator') // TODO: remove this await new Promise((resolve) => { @@ -362,11 +347,8 @@ export class MemberApi extends TypedEmitter { // TODO: This should be scoped to a single peer await this.#waitForInitialSync() - console.log('@@@@', 'initial sync done') websocket.close() - - console.log('@@@@', 'closed websocket') } /** diff --git a/test-e2e/server.js b/test-e2e/server.js index 75b0df1d6..da06aa40f 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -8,20 +8,14 @@ import createServer from '../src/server/app.js' // TODO: test bad requests test('adding a server peer', async (t) => { - console.log('@@@@', 'adding a server peer') const manager = createManager('device0', t) await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) // TODO: necessary? - console.log('@@@@', 'created manager', manager.deviceId) const projectId = await manager.createProject() - console.log('@@@@', 'created project') const project = await manager.getProject(projectId) - console.log('@@@@', 'creating the project') - const server = createTestServer() const serverAddress = await server.listen() - console.log('@@@@', serverAddress) t.after(() => server.close()) const serverHost = new URL(serverAddress).host @@ -31,7 +25,6 @@ test('adding a server peer', async (t) => { }) const members = await project.$member.getMany() - console.log('@@@@', members) // TODO: Ensure that this peer doesn't exist before adding? const hasServerPeer = members.some( (member) => @@ -45,6 +38,5 @@ function createTestServer() { return createServer({ ...getManagerOptions('test server'), serverName: 'test server', - logger: true, }) } From e0ec6b80dd9fa730c7c47dc60a239f724a52a750 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 23:09:10 +0000 Subject: [PATCH 010/118] Add GET /deviceinfo endpoint --- src/server/routes.js | 22 ++++++++++++++++++++++ src/server/test/get-deviceinfo.js | 24 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/server/test/get-deviceinfo.js diff --git a/src/server/routes.js b/src/server/routes.js index 102eaf0a1..a22f648ce 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,4 +1,5 @@ import { Type } from '@sinclair/typebox' +import assert from 'node:assert/strict' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' @@ -11,6 +12,27 @@ const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) /** @type {FastifyPluginAsync} */ export default async function routes(fastify) { + fastify.get( + '/deviceinfo', + { + schema: { + response: { + 200: Type.Object({ + data: Type.Object({ + deviceId: Type.String(), + name: Type.String(), + }), + }), + }, + }, + }, + async function (_req, reply) { + const { deviceId, name } = this.comapeo.getDeviceInfo() + assert(name, 'Expected server to have a name') + reply.send({ data: { deviceId, name } }) + } + ) + fastify.get( '/sync/:projectPublicId', { diff --git a/src/server/test/get-deviceinfo.js b/src/server/test/get-deviceinfo.js new file mode 100644 index 000000000..4a9924e8d --- /dev/null +++ b/src/server/test/get-deviceinfo.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import createServer from '../app.js' +import { getManagerOptions } from '../../../test-e2e/utils.js' + +test('GET /deviceinfo', async () => { + const server = createServer({ + ...getManagerOptions('test server'), + serverName: 'test server', + }) + + const response = await server.inject({ + method: 'GET', + url: '/deviceinfo', + }) + + assert.equal(response.statusCode, 200) + + const responseBody = response.json() + assert.deepEqual(Object.keys(responseBody), ['data']) + assert.deepEqual(Object.keys(responseBody.data), ['deviceId', 'name']) + assert.equal(typeof responseBody.data.deviceId, 'string') + assert.equal(responseBody.data.name, 'test server') +}) From c6629d29aace4a4c6a9c6f8830b5a8744356dc90 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 1 Oct 2024 23:11:43 +0000 Subject: [PATCH 011/118] chore: move response body data to a `data` key --- src/member-api.js | 9 ++++++--- src/server/routes.js | 20 +++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index cc0757744..90aa56df9 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -316,11 +316,14 @@ export class MemberApi extends TypedEmitter { assert( responseBody && typeof responseBody === 'object' && - 'deviceId' in responseBody && - typeof responseBody.deviceId === 'string', + 'data' in responseBody && + responseBody.data && + typeof responseBody.data === 'object' && + 'deviceId' in responseBody.data && + typeof responseBody.data.deviceId === 'string', 'Response body is valid' ) - ;({ deviceId } = responseBody) + ;({ deviceId } = responseBody.data) } catch (err) { throw new Error( "Failed to add server peer because we couldn't parse the response" diff --git a/src/server/routes.js b/src/server/routes.js index a22f648ce..ec2dbb8e7 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -29,7 +29,9 @@ export default async function routes(fastify) { async function (_req, reply) { const { deviceId, name } = this.comapeo.getDeviceInfo() assert(name, 'Expected server to have a name') - reply.send({ data: { deviceId, name } }) + reply.send({ + data: { deviceId, name }, + }) } ) @@ -78,10 +80,14 @@ export default async function routes(fastify) { }), response: { 200: Type.Object({ - deviceId: HEX_STRING_32_BYTES, + data: Type.Object({ + deviceId: HEX_STRING_32_BYTES, + }), }), 400: Type.Object({ - message: Type.String(), + error: Type.Object({ + message: Type.String(), + }), }), }, }, @@ -91,7 +97,7 @@ export default async function routes(fastify) { if (hasExistingProject) { reply.status(400) - reply.send({ message: 'Only one project is allowed' }) + reply.send({ error: { message: 'Only one project is allowed' } }) return reply } @@ -114,7 +120,11 @@ export default async function routes(fastify) { const project = await this.comapeo.getProject(projectId) project.$sync.start() - reply.send({ deviceId: this.comapeo.deviceId }) + reply.send({ + data: { + deviceId: this.comapeo.deviceId, + }, + }) return reply } ) From f6f97f56f6533c0f6addf24868b46bb2c3d37e83 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 12:26:55 +0100 Subject: [PATCH 012/118] make createTestServer helper do more --- test-e2e/server.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index da06aa40f..e3dcdb79a 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -13,12 +13,7 @@ test('adding a server peer', async (t) => { const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const server = createTestServer() - - const serverAddress = await server.listen() - - t.after(() => server.close()) - const serverHost = new URL(serverAddress).host + const serverHost = await createTestServer(t) await project.$member.addServerPeer(serverHost, { dangerouslyAllowInsecureConnections: true, @@ -34,9 +29,17 @@ test('adding a server peer', async (t) => { assert(hasServerPeer, 'expected a server peer to be found by the client') }) -function createTestServer() { - return createServer({ +/** + * + * @param {import('node:test').TestContext} t + * @returns {Promise} server host + */ +async function createTestServer(t) { + const server = createServer({ ...getManagerOptions('test server'), serverName: 'test server', }) + const serverAddress = await server.listen() + t.after(() => server.close()) + return new URL(serverAddress).host } From d6128b009b4af05c61a631e0750de7dd8f269f81 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 12:40:55 +0100 Subject: [PATCH 013/118] use @fastify/sensible and more precise errors --- package-lock.json | 62 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/server/app.js | 2 ++ src/server/routes.js | 33 +++++++++++------------ 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 360ceaf6e..7fdb7bf04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@digidem/types": "^2.3.0", "@electron/asar": "^3.2.8", "@fastify/error": "^3.4.1", + "@fastify/sensible": "^5.6.0", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", "@fastify/websocket": "^10.0.1", @@ -576,6 +577,20 @@ "node": ">=10.0.0" } }, + "node_modules/@fastify/sensible": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-5.6.0.tgz", + "integrity": "sha512-Vq6Z2ZQy10GDqON+hvLF52K99s9et5gVVxTul5n3SIAf0Kq5QjPRUKkAMT3zPAiiGvoHtS3APa/3uaxfDgCODQ==", + "dependencies": { + "@lukeed/ms": "^2.0.1", + "fast-deep-equal": "^3.1.1", + "fastify-plugin": "^4.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^1.6.18", + "vary": "^1.1.2" + } + }, "node_modules/@fastify/static": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@fastify/static/-/static-7.0.3.tgz", @@ -5266,6 +5281,14 @@ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", "dev": true }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/memoizee": { "version": "0.4.15", "dev": true, @@ -5462,6 +5485,25 @@ "node": ">=16" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -8007,6 +8049,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.0", "dev": true, @@ -8292,6 +8346,14 @@ "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 569c6b243..cf0c80b2b 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "@digidem/types": "^2.3.0", "@electron/asar": "^3.2.8", "@fastify/error": "^3.4.1", + "@fastify/sensible": "^5.6.0", "@fastify/static": "^7.0.3", "@fastify/type-provider-typebox": "^4.1.0", "@fastify/websocket": "^10.0.1", diff --git a/src/server/app.js b/src/server/app.js index 8c5a399b7..0764eaa34 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -1,5 +1,6 @@ import fastifyWebsocket from '@fastify/websocket' import createFastify from 'fastify' +import fastifySensible from '@fastify/sensible' import routes from './routes.js' import comapeoPlugin from './comapeo-plugin.js' @@ -15,6 +16,7 @@ import comapeoPlugin from './comapeo-plugin.js' export default function createServer({ logger, ...comapeoPluginOpts }) { const fastify = createFastify({ logger }) fastify.register(fastifyWebsocket) + fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(comapeoPlugin, comapeoPluginOpts) fastify.register(routes) return fastify diff --git a/src/server/routes.js b/src/server/routes.js index ec2dbb8e7..ca43737d9 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,5 +1,4 @@ import { Type } from '@sinclair/typebox' -import assert from 'node:assert/strict' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' @@ -23,12 +22,13 @@ export default async function routes(fastify) { name: Type.String(), }), }), + 500: { $ref: 'HttpError' }, }, }, }, async function (_req, reply) { const { deviceId, name } = this.comapeo.getDeviceInfo() - assert(name, 'Expected server to have a name') + this.assert(name, 500, 'Server is missing a name') reply.send({ data: { deviceId, name }, }) @@ -42,14 +42,14 @@ export default async function routes(fastify) { params: Type.Object({ projectPublicId: Type.String(), }), + response: { + 404: { $ref: 'HttpError' }, + }, }, - async preValidation(req, reply) { + async preValidation(req) { const projectPublicId = req.params.projectPublicId const project = await this.comapeo.getProject(projectPublicId) - if (!project) { - reply.status(404) - reply.send() - } + this.assert(project, 404, 'Project not found') }, websocket: true, }, @@ -84,22 +84,19 @@ export default async function routes(fastify) { deviceId: HEX_STRING_32_BYTES, }), }), - 400: Type.Object({ - error: Type.Object({ - message: Type.String(), - }), - }), + 400: { $ref: 'HttpError' }, }, }, }, async function (req, reply) { const hasExistingProject = (await this.comapeo.listProjects()).length > 0 - - if (hasExistingProject) { - reply.status(400) - reply.send({ error: { message: 'Only one project is allowed' } }) - return reply - } + // TODO: Different error message, or 200 response, if a project with a + // matching projectToken already exists. + this.assert( + !hasExistingProject, + 403, + 'Server is already linked to a project' + ) const projectKey = Buffer.from(req.body.projectKey, 'hex') From d1dcc85cbc87d5edd975ee4acb92f02fc6c6e513 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 12:41:18 +0100 Subject: [PATCH 014/118] Test that doesn't work for some reason --- test-e2e/server.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index e3dcdb79a..d9b58494a 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { MEMBER_ROLE_ID } from '../src/roles.js' -import { createManager, getManagerOptions } from './utils.js' +import { createManager, createManagers, getManagerOptions } from './utils.js' import createServer from '../src/server/app.js' // TODO: test invalid hostname @@ -29,6 +29,26 @@ test('adding a server peer', async (t) => { assert(hasServerPeer, 'expected a server peer to be found by the client') }) +test("can't add a server to two different projects", async (t) => { + const [managerA, managerB] = await createManagers(2, t, 'mobile') + const projectIdA = await managerA.createProject() + const projectIdB = await managerB.createProject() + const projectA = await managerA.getProject(projectIdA) + const projectB = await managerB.getProject(projectIdB) + + const serverHost = await createTestServer(t) + + await projectA.$member.addServerPeer(serverHost, { + dangerouslyAllowInsecureConnections: true, + }) + + await assert.throws(async () => { + await projectB.$member.addServerPeer(serverHost, { + dangerouslyAllowInsecureConnections: true, + }) + }, Error) +}) + /** * * @param {import('node:test').TestContext} t From 59560b065b422377cadaf5a25c9c21759fda6cbd Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 14:44:10 +0100 Subject: [PATCH 015/118] fix test --- test-e2e/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index d9b58494a..5fc24a7e8 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -42,7 +42,7 @@ test("can't add a server to two different projects", async (t) => { dangerouslyAllowInsecureConnections: true, }) - await assert.throws(async () => { + await assert.rejects(async () => { await projectB.$member.addServerPeer(serverHost, { dangerouslyAllowInsecureConnections: true, }) From f018cf74ec5e42241d4df2999f44b2b9cea00ee5 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 16:13:30 +0100 Subject: [PATCH 016/118] server integration tests Co-authored-by: me@evanhahn.com --- src/lib/is-valid-host.js | 52 ------------ src/server/routes.js | 17 ++-- test-e2e/server-integration.js | 151 +++++++++++++++++++++++++++++++++ test/lib/is-valid-host.js | 72 ---------------- 4 files changed, 163 insertions(+), 129 deletions(-) delete mode 100644 src/lib/is-valid-host.js create mode 100644 test-e2e/server-integration.js delete mode 100644 test/lib/is-valid-host.js diff --git a/src/lib/is-valid-host.js b/src/lib/is-valid-host.js deleted file mode 100644 index 1ecc58dc9..000000000 --- a/src/lib/is-valid-host.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as net from 'node:net' - -/** - * @param {string} str - * @returns {boolean} - */ -function isIpAddress(str) { - return Boolean(net.isIP(str)) -} - -/** - * @param {string} host - * @returns {boolean} - */ -export function isValidHost(host) { - if (isIpAddress(host)) return true - - // At this point, we either have a domain (like `example.com`), an IP address - // with a port (like `192.0.2.1:1234`), or something invalid. - - // According to [RFC 1034][0], "the total number of octets that represent a - // domain name [...] is limited to 255." Offer a lot of wiggle room, but avoid - // performance issues with super long inputs. - // - // [0]: https://tools.ietf.org/html/rfc1034 - // [1]: https://stackoverflow.com/a/7477384/804100 - if (host.length > 4096) return false - - /** @type {URL} */ let url - try { - url = new URL('https://' + host) - } catch (_err) { - // If we can't parse it, it's not valid. - return false - } - - // The parsed URL should be exactly as we expect. It's possible to "trick" - // this by adding empty things to the end of the URL (like `example.com#`), so - // also check the string. - const lastChar = host[host.length - 1] - return ( - lastChar !== '/' && - lastChar !== '?' && - lastChar !== '#' && - url.protocol === 'https:' && - !url.username && - !url.password && - url.pathname === '/' && - url.search === '' && - !url.hash - ) -} diff --git a/src/server/routes.js b/src/server/routes.js index ca43737d9..668ad685b 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -8,11 +8,13 @@ import { wsCoreReplicator } from './ws-core-replicator.js' const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$' const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) +const BASE32_REGEX_32_BYTES = '^[0-9A-Za-z]{52}$' +const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) /** @type {FastifyPluginAsync} */ export default async function routes(fastify) { fastify.get( - '/deviceinfo', + '/info', { schema: { response: { @@ -40,16 +42,21 @@ export default async function routes(fastify) { { schema: { params: Type.Object({ - projectPublicId: Type.String(), + projectPublicId: BASE32_STRING_32_BYTES, }), response: { 404: { $ref: 'HttpError' }, }, }, - async preValidation(req) { + async preHandler(req) { const projectPublicId = req.params.projectPublicId - const project = await this.comapeo.getProject(projectPublicId) - this.assert(project, 404, 'Project not found') + try { + await this.comapeo.getProject(projectPublicId) + } catch (e) { + if (e instanceof Error && e.message.startsWith('NotFound')) { + throw this.httpErrors.notFound('Project not found') + } + } }, websocket: true, }, diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js new file mode 100644 index 000000000..5a289b0ce --- /dev/null +++ b/test-e2e/server-integration.js @@ -0,0 +1,151 @@ +import { getManagerOptions } from './utils.js' +import createServer from '../src/server/app.js' +import test from 'node:test' +import crypto, { randomBytes } from 'node:crypto' +import { KeyManager } from '@mapeo/crypto' +import assert from 'node:assert/strict' +import { projectKeyToPublicId } from '../src/utils.js' + +test('server info endpoint', async (t) => { + const serverName = 'test server' + const server = createTestServer(t, serverName) + const expectedResponseBody = { + data: { + deviceId: server.deviceId, + name: serverName, + }, + } + const response = await server.inject({ + method: 'GET', + url: '/info', + }) + assert.deepEqual(response.json(), expectedResponseBody) +}) + +test('add project, sync endpoint available', { only: false }, async (t) => { + const server = createTestServer(t) + const projectKeys = randomProjectKeys() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + + await t.test('add project', async () => { + const expectedResponseBody = { + data: { + deviceId: server.deviceId, + }, + } + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.deepEqual(response.json(), expectedResponseBody) + }) + + await t.test('sync endpoint available', async (t) => { + const ws = await server.injectWS('/sync/' + projectPublicId) + t.after(() => ws.terminate()) + assert.equal(ws.readyState, ws.OPEN, 'websocket is open') + }) +}) + +test('no project added, sync endpoint not available', async (t) => { + const server = createTestServer(t) + + const projectPublicId = projectKeyToPublicId(randomBytes(32)) + + const response = await server.inject({ + method: 'GET', + url: '/sync/' + projectPublicId, + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + assert.equal(response.statusCode, 404) + assert.equal(response.json().error, 'Not Found') +}) + +test('invalid project public id', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: '/sync/foobidoobi', + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + assert.equal(response.statusCode, 400) + assert.equal(response.json().code, 'FST_ERR_VALIDATION') +}) + +test('trying to add second project fails', { only: true }, async (t) => { + const server = createTestServer(t) + + await t.test('add first project', async () => { + const expectedResponseBody = { + data: { + deviceId: server.deviceId, + }, + } + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.deepEqual(response.json(), expectedResponseBody) + }) + + await t.test('attempt to add second project', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 403) + }) +}) + +function randomHexKey(length = 32) { + return Buffer.from(crypto.randomBytes(length)).toString('hex') +} + +function randomProjectKeys() { + return { + projectKey: randomHexKey(), + encryptionKeys: { + auth: randomHexKey(), + config: randomHexKey(), + data: randomHexKey(), + blobIndex: randomHexKey(), + blob: randomHexKey(), + }, + } +} + +/** + * + * @param {import('node:test').TestContext} t + * @returns {ReturnType & { deviceId: string }} + */ +function createTestServer(t, serverName = 'test server') { + const managerOptions = getManagerOptions(serverName) + const km = new KeyManager(managerOptions.rootKey) + const server = createServer({ + ...managerOptions, + logger: true, + serverName, + serverPublicBaseUrl: 'http://localhost:0', + }) + t.after(() => server.close()) + Object.defineProperty(server, 'deviceId', { + get() { + return km.getIdentityKeypair().publicKey.toString('hex') + }, + }) + // @ts-expect-error + return server +} diff --git a/test/lib/is-valid-host.js b/test/lib/is-valid-host.js deleted file mode 100644 index 06c4b7247..000000000 --- a/test/lib/is-valid-host.js +++ /dev/null @@ -1,72 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { isValidHost } from '../../src/lib/is-valid-host.js' - -test('too short', () => { - assert(!isValidHost('')) -}) - -test('too long', () => { - assert(!isValidHost('x'.repeat(4097))) -}) - -test('has auth', () => { - assert(!isValidHost('user@example.com')) - assert(!isValidHost(':password@example.com')) - assert(!isValidHost('user:password@example.com')) -}) - -test('has path', () => { - assert(!isValidHost('example.com/foo')) -}) - -test('has search', () => { - assert(!isValidHost('example.com?foo=bar')) -}) - -test('has hash', () => { - assert(!isValidHost('example.com#foo')) -}) - -test('has empty path, search, and/or hash', () => { - assert(!isValidHost('example.com/')) - assert(!isValidHost('example.com?')) - assert(!isValidHost('example.com#')) - assert(!isValidHost('example.com/#')) - assert(!isValidHost('example.com/?')) - assert(!isValidHost('example.com?#')) - assert(!isValidHost('example.com/?#')) -}) - -test('invalid as URLs', () => { - assert(!isValidHost(' ')) - assert(!isValidHost('')) -}) - -test('IPv4 is valid', () => { - assert(isValidHost('0.0.0.0')) - assert(isValidHost('127.0.0.1')) - assert(isValidHost('100.64.0.42')) - assert(isValidHost('100.64.0.42:1234')) -}) - -test('IPv6 is valid', () => { - assert(isValidHost('::')) - assert(isValidHost('2001:0db8:0000:0000:0000:0000:0000:0000')) - assert(isValidHost('[::]')) - assert(isValidHost('[2001:0db8:0000:0000:0000:0000:0000:0000]')) - - assert(isValidHost('[2001:0db8:0000:0000:0000:0000:0000:0000]:1234')) -}) - -test('IPv6-coded IPv4s are valid', () => { - assert(isValidHost('[0:0:0:0:0:ffff:6440:002a]')) - assert(isValidHost('[0:0:0:0:0:ffff:6440:002a]:1234')) -}) - -test('other hostnames are valid', () => { - assert(isValidHost('example')) - assert(isValidHost('example:1234')) - assert(isValidHost('example.com')) - assert(isValidHost('example.com:1234')) -}) From 77b73becc7732e24ad182ed3e3e00a55b5acec96 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 16:49:45 +0100 Subject: [PATCH 017/118] Change `host` to `baseUrl`, save server URL to members --- comapeo-schema-server.tgz | Bin 0 -> 98441 bytes drizzle/project/0001_gifted_donald_blake.sql | 1 + drizzle/project/meta/0001_snapshot.json | 1144 ++++++++++++++++++ drizzle/project/meta/_journal.json | 7 + package-lock.json | 195 ++- package.json | 4 +- src/constants.js | 2 +- src/mapeo-manager.js | 34 +- src/mapeo-project.js | 3 +- src/member-api.js | 59 +- src/server/comapeo-plugin.js | 16 +- src/server/test/get-deviceinfo.js | 24 - test-e2e/members.js | 11 +- test-e2e/server.js | 31 +- 14 files changed, 1444 insertions(+), 87 deletions(-) create mode 100644 comapeo-schema-server.tgz create mode 100644 drizzle/project/0001_gifted_donald_blake.sql create mode 100644 drizzle/project/meta/0001_snapshot.json delete mode 100644 src/server/test/get-deviceinfo.js diff --git a/comapeo-schema-server.tgz b/comapeo-schema-server.tgz new file mode 100644 index 0000000000000000000000000000000000000000..e85c8ba447bd0a71e72e7a19c7e6d1c2e9a5bbd0 GIT binary patch literal 98441 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

ao*q+^pLn= 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 RPCDeviceType + * @typedef {import('./schema/client.js').DeviceInfoParam['deviceType']} RPCDeviceType */ /** @@ -733,13 +731,21 @@ export class MapeoManager extends TypedEmitter { }) ) - await Promise.all( - this.#localPeers.peers - .filter(({ status }) => status === 'connected') - .map((peer) => - this.#localPeers.sendDeviceInfo(peer.deviceId, deviceInfo) - ) - ) + // TODO(evanhahn) + if (deviceInfo.deviceType !== 'selfHostedServer') { + await Promise.all( + this.#localPeers.peers + .filter(({ status }) => status === 'connected') + .map((peer) => + // TODO(evanhahn) TypeScript isn't smart enough to know that + // deviceInfo is okay here? + this.#localPeers.sendDeviceInfo( + peer.deviceId, + /** @type {any} */ (deviceInfo) + ) + ) + ) + } this.#l.log('set device info %o', deviceInfo) } diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 1d08c27e9..9a6424b9b 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -610,7 +610,7 @@ export class MapeoProject extends TypedEmitter { } /** - * @param {Pick} value + * @param {Pick} value * @returns {Promise} */ async [kSetOwnDeviceInfo](value) { @@ -623,6 +623,7 @@ export class MapeoProject extends TypedEmitter { const doc = { name: value.name, deviceType: value.deviceType, + selfHostedServerDetails: value.selfHostedServerDetails, schemaName: /** @type {const} */ ('deviceInfo'), } diff --git a/src/member-api.js b/src/member-api.js index 90aa56df9..7646e3f5b 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -16,7 +16,6 @@ import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' -import { isValidHost } from './lib/is-valid-host.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' /** * @import { @@ -42,6 +41,8 @@ import { wsCoreReplicator } from './server/ws-core-replicator.js' * @prop {DeviceInfo['name']} [name] * @prop {DeviceInfo['deviceType']} [deviceType] * @prop {DeviceInfo['createdAt']} [joinedAt] + * @prop {object} [selfHostedServerDetails] + * @prop {string} selfHostedServerDetails.baseUrl */ export class MemberApi extends TypedEmitter { @@ -262,19 +263,21 @@ export class MemberApi extends TypedEmitter { /** * Add a server peer. * - * @param {string} host + * @param {string} baseUrl * @param {object} [options] * @param {boolean} [options.dangerouslyAllowInsecureConnections] * @returns {Promise} */ - async addServerPeer(host, { dangerouslyAllowInsecureConnections } = {}) { - assert(isValidHost(host), 'Hostname must be valid') - - const requestProtocol = dangerouslyAllowInsecureConnections - ? 'http:' - : 'https:' - const requestUrl = `${requestProtocol}//${host}/projects` + async addServerPeer( + baseUrl, + { dangerouslyAllowInsecureConnections = false } = {} + ) { + assert( + isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections }), + 'Base URL is invalid' + ) + const requestUrl = new URL('projects', baseUrl) const requestBody = { projectKey: encodeBufferForServer(this.#projectKey), encryptionKeys: { @@ -334,12 +337,9 @@ export class MemberApi extends TypedEmitter { await this.#roles.assignRole(deviceId, roleId) const projectPublicId = projectKeyToPublicId(this.#projectKey) - const websocketProtocol = dangerouslyAllowInsecureConnections - ? 'ws:' - : 'wss:' - const websocket = new WebSocket( - `${websocketProtocol}//${host}/sync/${projectPublicId}` - ) + const websocketUrl = new URL('sync/' + projectPublicId, baseUrl) + websocketUrl.protocol = dangerouslyAllowInsecureConnections ? 'ws:' : 'wss:' + const websocket = new WebSocket(websocketUrl) const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) @@ -413,6 +413,8 @@ export class MemberApi extends TypedEmitter { memberInfo.name = deviceInfo?.name memberInfo.deviceType = deviceInfo?.deviceType memberInfo.joinedAt = deviceInfo?.createdAt + memberInfo.selfHostedServerDetails = + deviceInfo?.selfHostedServerDetails } catch (err) { // Attempting to get someone else may throw because sync hasn't occurred or completed // Only throw if attempting to get themself since the relevant information should be available @@ -434,6 +436,33 @@ export class MemberApi extends TypedEmitter { } } +/** + * @param {string} baseUrl + * @param {object} options + * @param {boolean} options.dangerouslyAllowInsecureConnections + * @returns {boolean} + */ +function isValidServerBaseUrl( + baseUrl, + { dangerouslyAllowInsecureConnections } +) { + /** @type {URL} */ let url + try { + url = new URL(baseUrl) + } catch (_err) { + return false + } + + const isProtocolValid = + url.protocol === 'https:' || + (dangerouslyAllowInsecureConnections && url.protocol === 'http:') + if (!isProtocolValid) return false + + // TODO: Validate that username/password is missing? + + return true +} + /** * @param {undefined | Uint8Array} buffer * @returns {undefined | string} diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index 27bd179d2..e5d78550f 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -2,18 +2,28 @@ import { MapeoManager } from '../mapeo-manager.js' import createFastifyPlugin from 'fastify-plugin' /** - * @typedef {ConstructorParameters[0] & { serverName: string }} ComapeoPluginOptions + * @typedef {ConstructorParameters[0] & { + * serverName: string; + * serverPublicBaseUrl: string; + * }} ComapeoPluginOptions */ /** @type {import('fastify').FastifyPluginAsync} */ const comapeoPlugin = async function (fastify, opts) { - const comapeo = new MapeoManager(opts) + const comapeo = new MapeoManager({ + ...opts, + // TODO(evanhahn) + // deviceType: 'selfHostedServer', + }) fastify.decorate('comapeo', comapeo) const existingDeviceInfo = comapeo.getDeviceInfo() if (existingDeviceInfo.deviceType === 'device_type_unspecified') { await comapeo.setDeviceInfo({ - deviceType: 'desktop', + deviceType: 'selfHostedServer', name: opts.serverName, + selfHostedServerDetails: { + baseUrl: opts.serverPublicBaseUrl, + }, }) } } diff --git a/src/server/test/get-deviceinfo.js b/src/server/test/get-deviceinfo.js deleted file mode 100644 index 4a9924e8d..000000000 --- a/src/server/test/get-deviceinfo.js +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import createServer from '../app.js' -import { getManagerOptions } from '../../../test-e2e/utils.js' - -test('GET /deviceinfo', async () => { - const server = createServer({ - ...getManagerOptions('test server'), - serverName: 'test server', - }) - - const response = await server.inject({ - method: 'GET', - url: '/deviceinfo', - }) - - assert.equal(response.statusCode, 200) - - const responseBody = response.json() - assert.deepEqual(Object.keys(responseBody), ['data']) - assert.deepEqual(Object.keys(responseBody.data), ['deviceId', 'name']) - assert.equal(typeof responseBody.data.deviceId, 'string') - assert.equal(responseBody.data.name, 'test server') -}) diff --git a/test-e2e/members.js b/test-e2e/members.js index 203be3330..1fa4fbb4a 100644 --- a/test-e2e/members.js +++ b/test-e2e/members.js @@ -14,6 +14,7 @@ import { connectPeers, createManagers, invite, + removeUndefinedFields, waitForPeers, waitForSync, } from './utils.js' @@ -36,7 +37,7 @@ test('getting yourself after creating project', async (t) => { 'time of joined project is close to now' ) assert.deepEqual( - me, + removeUndefinedFields(me), { deviceId: project.deviceId, deviceType: 'tablet', @@ -51,7 +52,7 @@ test('getting yourself after creating project', async (t) => { assert.equal(members.length, 1) assert.deepEqual( - member, + removeUndefinedFields(member), { deviceId: project.deviceId, deviceType: 'tablet', @@ -87,7 +88,7 @@ test('getting yourself after adding project (but not yet synced)', async (t) => ) assert.deepEqual( - me, + removeUndefinedFields(me), { deviceId: project.deviceId, deviceType: 'tablet', @@ -102,7 +103,7 @@ test('getting yourself after adding project (but not yet synced)', async (t) => assert.equal(members.length, 1) assert.deepEqual( - member, + removeUndefinedFields(member), { deviceId: project.deviceId, deviceType: 'tablet', @@ -178,7 +179,7 @@ test('getting invited member after invite accepted', async (t) => { ) assert.deepEqual( - invitedMemberWithoutJoinedAt, + removeUndefinedFields(invitedMemberWithoutJoinedAt), { deviceId: invitee.deviceId, deviceType: 'device_type_unspecified', diff --git a/test-e2e/server.js b/test-e2e/server.js index 5fc24a7e8..0c88bca53 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -4,8 +4,9 @@ import { MEMBER_ROLE_ID } from '../src/roles.js' import { createManager, createManagers, getManagerOptions } from './utils.js' import createServer from '../src/server/app.js' -// TODO: test invalid hostname +// TODO: test invalid base URL // TODO: test bad requests +// TODO: test other base URLs test('adding a server peer', async (t) => { const manager = createManager('device0', t) @@ -13,20 +14,29 @@ test('adding a server peer', async (t) => { const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const serverHost = await createTestServer(t) + const serverBaseUrl = await createTestServer(t) - await project.$member.addServerPeer(serverHost, { + await project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) const members = await project.$member.getMany() // TODO: Ensure that this peer doesn't exist before adding? - const hasServerPeer = members.some( - (member) => - // TODO: use server device type - member.deviceType === 'desktop' && member.role.roleId === MEMBER_ROLE_ID + const serverPeer = members.find( + (member) => member.deviceType === 'selfHostedServer' + ) + assert(serverPeer, 'expected a server peer to be found by the client') + assert.equal(serverPeer.name, 'test server', 'server peers have name') + assert.equal( + serverPeer.role.roleId, + MEMBER_ROLE_ID, + 'server peers are added as regular members' + ) + assert.equal( + serverPeer.selfHostedServerDetails?.baseUrl, + 'https://mapeo.example', + 'server peer stores base URL' ) - assert(hasServerPeer, 'expected a server peer to be found by the client') }) test("can't add a server to two different projects", async (t) => { @@ -52,14 +62,15 @@ test("can't add a server to two different projects", async (t) => { /** * * @param {import('node:test').TestContext} t - * @returns {Promise} server host + * @returns {Promise} server base URL */ async function createTestServer(t) { const server = createServer({ ...getManagerOptions('test server'), serverName: 'test server', + serverPublicBaseUrl: 'https://mapeo.example', }) const serverAddress = await server.listen() t.after(() => server.close()) - return new URL(serverAddress).host + return serverAddress } From 55f90d6ea91c5904c87a5f6452238c4e31f3e6d1 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 2 Oct 2024 19:52:53 +0100 Subject: [PATCH 018/118] WIP: connectServers and disconnectServers() Co-authored-by: me@evanhahn.com --- src/mapeo-project.js | 30 ++++++++-- src/member-api.js | 2 + src/sync/core-sync-state.js | 7 ++- src/sync/sync-api.js | 71 +++++++++++++++++++++- test-e2e/server.js | 114 +++++++++++++++++++++++++++++++++++- 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 9a6424b9b..e7a7f9007 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -131,6 +131,12 @@ export class MapeoProject extends TypedEmitter { this.#projectId = projectKeyToId(projectKey) this.#loadingConfig = false + const getReplicationStream = this[kProjectReplicate].bind( + this, + // TODO: See if we can fix these + /** @type {any} */ (true) + ) + ///////// 1. Setup database this.#sqlite = new Database(dbPath) const db = drizzle(this.#sqlite) @@ -302,11 +308,7 @@ export class MapeoProject extends TypedEmitter { encryptionKeys, projectKey, rpc: localPeers, - getReplicationStream: this[kProjectReplicate].bind( - this, - // TODO: See if we can fix these - /** @type {any} */ (true) - ), + getReplicationStream, // TODO: This should be scoped to a single peer, not all peers waitForInitialSync: () => this.$sync.waitForSync('initial'), dataTypes: { @@ -349,6 +351,24 @@ export class MapeoProject extends TypedEmitter { coreOwnership: this.#coreOwnership, roles: this.#roles, logger: this.#l, + getServerWebsocketUrls: async () => { + const members = await this.#memberApi.getMany() + /** @type {string[]} */ + const serverWebsocketUrls = [] + for (const member of members) { + if ( + member.deviceType === 'selfHostedServer' && + member.selfHostedServerDetails + ) { + const { baseUrl } = member.selfHostedServerDetails + const wsUrl = new URL(`/sync/${this.#projectId}`, baseUrl) + wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:' + serverWebsocketUrls.push(wsUrl.href) + } + } + return serverWebsocketUrls + }, + getReplicationStream, }) this.#translationApi = new TranslationApi({ diff --git a/src/member-api.js b/src/member-api.js index 7646e3f5b..49c25edc0 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -17,6 +17,7 @@ import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' +import { once } from 'node:events' /** * @import { * DeviceInfo, @@ -352,6 +353,7 @@ export class MemberApi extends TypedEmitter { await this.#waitForInitialSync() websocket.close() + await once(websocket, 'close') } /** diff --git a/src/sync/core-sync-state.js b/src/sync/core-sync-state.js index d9f6fa943..aba14a8fa 100644 --- a/src/sync/core-sync-state.js +++ b/src/sync/core-sync-state.js @@ -389,16 +389,19 @@ export function deriveState(coreState) { let iWantFromSomeoneElse = 0 for (const [peerId, peer] of peers.entries()) { + const remoteState = remoteStates[peerId] + const shouldAddToLocalState = remoteState?.status !== 'stopped' + const peerHaves = peer.haveWord(i) & truncate remoteStates[peerId].have += bitCount32(peerHaves) const theyWantFromMe = peer.wantWord(i) & ~peerHaves & localHaves remoteStates[peerId].want += bitCount32(theyWantFromMe) - someoneElseWantsFromMe |= theyWantFromMe + if (shouldAddToLocalState) someoneElseWantsFromMe |= theyWantFromMe const iWantFromThem = peerHaves & ~localHaves remoteStates[peerId].wanted += bitCount32(iWantFromThem) - iWantFromSomeoneElse |= iWantFromThem + if (shouldAddToLocalState) iWantFromSomeoneElse |= iWantFromThem } localState.wanted += bitCount32(someoneElseWantsFromMe) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 57c2c5f92..0a65ee4ba 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -1,4 +1,5 @@ import { TypedEmitter } from 'tiny-typed-emitter' +import WebSocket from 'ws' import { SyncState } from './sync-state.js' import { PeerSyncController } from './peer-sync-controller.js' import { Logger } from '../logger.js' @@ -9,9 +10,11 @@ import { } from '../constants.js' import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js' import { NO_ROLE_ID } from '../roles.js' +import { wsCoreReplicator } from '../server/ws-core-replicator.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ /** @import { CoreOwnership } from '../core-ownership.js' */ /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ +/** @import { ReplicationStream } from '../types.js' */ export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') @@ -79,20 +82,31 @@ export class SyncApi extends TypedEmitter { #l /** - * * @param {object} opts * @param {import('../core-manager/index.js').CoreManager} opts.coreManager * @param {CoreOwnership} opts.coreOwnership * @param {import('../roles.js').Roles} opts.roles + * @param {() => Promise>} opts.getServerWebsocketUrls + * @param {() => ReplicationStream} opts.getReplicationStream * @param {number} [opts.throttleMs] * @param {Logger} [opts.logger] */ - constructor({ coreManager, throttleMs = 200, roles, logger, coreOwnership }) { + constructor({ + coreManager, + throttleMs = 200, + roles, + getServerWebsocketUrls, + getReplicationStream, + logger, + coreOwnership, + }) { super() this.#l = Logger.create('syncApi', logger) this.#coreManager = coreManager this.#coreOwnership = coreOwnership this.#roles = roles + this.#getServerWebsocketUrls = getServerWebsocketUrls + this.#getReplicationStream = getReplicationStream this[kSyncState] = new SyncState({ coreManager, throttleMs, @@ -273,6 +287,59 @@ export class SyncApi extends TypedEmitter { this.emit('sync-state', this.#getState(namespaceSyncState)) } + // TODO: Move these higher up + #getServerWebsocketUrls + #getReplicationStream + /** @type {Map} */ + #serverWebsockets = new Map() + + /** + * @returns {void} + */ + connectServers() { + // TODO: decide how to handle this async stuff + this.#getServerWebsocketUrls() + .then((urls) => { + for (const url of urls) { + const existingWebsocket = this.#serverWebsockets.get(url) + console.log('@@@@', 'connecting to', url) + if ( + existingWebsocket && + (existingWebsocket.readyState === WebSocket.OPEN || + existingWebsocket.readyState === WebSocket.CONNECTING) + ) { + continue + } + + const websocket = new WebSocket(url) + + // TODO: Remove this after we've debugged why we're getting a 400 error + websocket.on('unexpected-respose', (req, res) => { + console.log('@@@@', 'unexpected response', req, res) + }) + + const replicationStream = this.#getReplicationStream() + wsCoreReplicator(websocket, replicationStream) + + this.#serverWebsockets.set(url, websocket) + websocket.once('close', () => { + this.#serverWebsockets.delete(url) + }) + } + }) + .catch(noop) + } + + /** + * @returns {void} + */ + disconnectServers() { + for (const websocket of this.#serverWebsockets.values()) { + websocket.close() + } + this.#serverWebsockets.clear() + } + /** * Start syncing data cores. * diff --git a/test-e2e/server.js b/test-e2e/server.js index 0c88bca53..be66c9a9f 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,8 +1,19 @@ import assert from 'node:assert/strict' import test from 'node:test' import { MEMBER_ROLE_ID } from '../src/roles.js' -import { createManager, createManagers, getManagerOptions } from './utils.js' +import { + connectPeers, + createManager, + createManagers, + getManagerOptions, + invite, + waitForPeers, + waitForSync, +} from './utils.js' import createServer from '../src/server/app.js' +import { valueOf } from '@comapeo/schema' +import { generate } from '@mapeo/mock-data' +/** @import { MapeoManager } from '../src/mapeo-manager.js' */ // TODO: test invalid base URL // TODO: test bad requests @@ -59,18 +70,115 @@ test("can't add a server to two different projects", async (t) => { }, Error) }) +test.only('TODO', { timeout: 2 ** 30 }, async (t) => { + // create two managers + const managers = await createManagers(2, t, 'mobile') + const [managerA, managerB] = managers + + // manager 1 sets up server + const projectId = await managerA.createProject({ name: 'foo' }) + const managerAProject = await managerA.getProject(projectId) + const serverBaseUrl = await createTestServer(t) + await managerAProject.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }) + + // TODO: remove this + await new Promise((resolve) => { + setTimeout(resolve, 3000) + }) + + // manager 1, invite manager 2 + const disconnect1 = connectPeers(managers) + t.after(disconnect1) + await waitForPeers(managers) + await invite({ invitor: managerA, invitees: [managerB], projectId }) + const managerBProject = await managerB.getProject(projectId) + + // sync managers (to tell manager 2 about server) + const projects = [managerAProject, managerBProject] + await waitForSync(projects, 'initial') + const members = await managerBProject.$member.getMany() // TODO: maybe rename this + const serverPeer = members.find( + (member) => member.deviceType === 'selfHostedServer' + ) + assert(serverPeer, 'expected a server peer to be found by the client') + + // disconnect managers + disconnect1() + await waitForNoPeersToBeConnected(managerA) + await waitForNoPeersToBeConnected(managerB) + + // Start both syncing data + managerAProject.$sync.start() + managerBProject.$sync.start() + console.log('@@@@', 'about to connect servers') + managerAProject.$sync.connectServers() + managerBProject.$sync.connectServers() + console.log('@@@@', 'connected servers') + await waitForSyncWithServer() // TODO this is bogus and silly, just used for waiting + console.log('@@@@', 'waited a bit') + + // manager 1 adds data, syncs with server + const observation = await managerAProject.observation.create( + valueOf(generate('observation')[0]) + ) + await waitForSyncWithServer() + + // Check manager 2 doesn't have the data + await assert.rejects(() => + managerBProject.observation.getByDocId(observation.docId) + ) + + // manager 2 now has data from manager 1, even though it wasn't connected to manager 1 directly + await waitForSyncWithServer() + assert( + await managerBProject.observation.getByDocId(observation.docId), + 'manager B now sees data' + ) +}) + /** * * @param {import('node:test').TestContext} t * @returns {Promise} server base URL */ async function createTestServer(t) { + // TODO: Use a port that's guaranteed to be open + const port = 9876 const server = createServer({ ...getManagerOptions('test server'), serverName: 'test server', - serverPublicBaseUrl: 'https://mapeo.example', + serverPublicBaseUrl: 'http://localhost:' + port, }) - const serverAddress = await server.listen() + const serverAddress = await server.listen({ port }) t.after(() => server.close()) return serverAddress } + +/** + * @param {MapeoManager} manager + * @returns {Promise} + */ +function waitForNoPeersToBeConnected(manager) { + return new Promise((resolve) => { + const checkIfDone = async () => { + const isDone = (await manager.listLocalPeers()).every( + (peer) => peer.status === 'disconnected' + ) + if (isDone) { + manager.off('local-peers', checkIfDone) + resolve() + } + } + manager.on('local-peers', checkIfDone) + checkIfDone() + }) +} + +function waitForSyncWithServer() { + // TODO: This is fake + return new Promise((resolve) => { + setTimeout(resolve, 3000) + }) +} From a9dad8875ed4bcf32da8b5798d3b97cc2e07235a Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 2 Oct 2024 19:24:48 +0000 Subject: [PATCH 019/118] Fix debugging event (still broken, but better debugging) --- src/sync/sync-api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 0a65ee4ba..6121a329e 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -314,8 +314,8 @@ export class SyncApi extends TypedEmitter { const websocket = new WebSocket(url) // TODO: Remove this after we've debugged why we're getting a 400 error - websocket.on('unexpected-respose', (req, res) => { - console.log('@@@@', 'unexpected response', req, res) + websocket.on('unexpected-response', (req, res) => { + console.log('@@@@', 'unexpected response', res) }) const replicationStream = this.#getReplicationStream() From e3cec3dda4a8b33c0aaafccae813d2d8cf07ad4c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:22:37 +0000 Subject: [PATCH 020/118] Revert sync changes --- src/sync/core-sync-state.js | 7 ++----- test-e2e/server.js | 17 ++++++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/sync/core-sync-state.js b/src/sync/core-sync-state.js index aba14a8fa..d9f6fa943 100644 --- a/src/sync/core-sync-state.js +++ b/src/sync/core-sync-state.js @@ -389,19 +389,16 @@ export function deriveState(coreState) { let iWantFromSomeoneElse = 0 for (const [peerId, peer] of peers.entries()) { - const remoteState = remoteStates[peerId] - const shouldAddToLocalState = remoteState?.status !== 'stopped' - const peerHaves = peer.haveWord(i) & truncate remoteStates[peerId].have += bitCount32(peerHaves) const theyWantFromMe = peer.wantWord(i) & ~peerHaves & localHaves remoteStates[peerId].want += bitCount32(theyWantFromMe) - if (shouldAddToLocalState) someoneElseWantsFromMe |= theyWantFromMe + someoneElseWantsFromMe |= theyWantFromMe const iWantFromThem = peerHaves & ~localHaves remoteStates[peerId].wanted += bitCount32(iWantFromThem) - if (shouldAddToLocalState) iWantFromSomeoneElse |= iWantFromThem + iWantFromSomeoneElse |= iWantFromThem } localState.wanted += bitCount32(someoneElseWantsFromMe) diff --git a/test-e2e/server.js b/test-e2e/server.js index be66c9a9f..0ed003877 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,6 +1,10 @@ +import { valueOf } from '@comapeo/schema' +import { generate } from '@mapeo/mock-data' import assert from 'node:assert/strict' import test from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' import { MEMBER_ROLE_ID } from '../src/roles.js' +import createServer from '../src/server/app.js' import { connectPeers, createManager, @@ -8,11 +12,7 @@ import { getManagerOptions, invite, waitForPeers, - waitForSync, } from './utils.js' -import createServer from '../src/server/app.js' -import { valueOf } from '@comapeo/schema' -import { generate } from '@mapeo/mock-data' /** @import { MapeoManager } from '../src/mapeo-manager.js' */ // TODO: test invalid base URL @@ -96,8 +96,11 @@ test.only('TODO', { timeout: 2 ** 30 }, async (t) => { const managerBProject = await managerB.getProject(projectId) // sync managers (to tell manager 2 about server) - const projects = [managerAProject, managerBProject] - await waitForSync(projects, 'initial') + // TODO: We should be calling this instead of `delay`, but there [is a bug][0] that prevents this. + // [0]: https://github.com/digidem/comapeo-core/pull/887 + // const projects = [managerAProject, managerBProject] + // await waitForSync(projects, 'initial') + await delay(3000) const members = await managerBProject.$member.getMany() // TODO: maybe rename this const serverPeer = members.find( (member) => member.deviceType === 'selfHostedServer' @@ -105,7 +108,7 @@ test.only('TODO', { timeout: 2 ** 30 }, async (t) => { assert(serverPeer, 'expected a server peer to be found by the client') // disconnect managers - disconnect1() + await disconnect1() await waitForNoPeersToBeConnected(managerA) await waitForNoPeersToBeConnected(managerB) From 7f7429706877bbff6f4cc487a7195515f0384a99 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:33:47 +0000 Subject: [PATCH 021/118] Sync test passing --- src/mapeo-project.js | 5 ++++- src/sync/sync-api.js | 5 +---- test-e2e/server.js | 22 ++++++++++------------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index e7a7f9007..93373031c 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -73,6 +73,7 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({}) */ export class MapeoProject extends TypedEmitter { #projectId + #projectPublicId #deviceId #identityKeypair #coreManager @@ -318,6 +319,8 @@ export class MapeoProject extends TypedEmitter { }) const projectPublicId = projectKeyToPublicId(projectKey) + // TODO: clean this up + this.#projectPublicId = projectPublicId this.#blobStore = new BlobStore({ coreManager: this.#coreManager, @@ -361,7 +364,7 @@ export class MapeoProject extends TypedEmitter { member.selfHostedServerDetails ) { const { baseUrl } = member.selfHostedServerDetails - const wsUrl = new URL(`/sync/${this.#projectId}`, baseUrl) + const wsUrl = new URL(`/sync/${this.#projectPublicId}`, baseUrl) wsUrl.protocol = wsUrl.protocol === 'http:' ? 'ws:' : 'wss:' serverWebsocketUrls.push(wsUrl.href) } diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 6121a329e..2aa83140e 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -313,10 +313,7 @@ export class SyncApi extends TypedEmitter { const websocket = new WebSocket(url) - // TODO: Remove this after we've debugged why we're getting a 400 error - websocket.on('unexpected-response', (req, res) => { - console.log('@@@@', 'unexpected response', res) - }) + // TODO: Handle errors (maybe with the `unexpected-response` event?) const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) diff --git a/test-e2e/server.js b/test-e2e/server.js index 0ed003877..0d4fe6528 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -112,27 +112,25 @@ test.only('TODO', { timeout: 2 ** 30 }, async (t) => { await waitForNoPeersToBeConnected(managerA) await waitForNoPeersToBeConnected(managerB) - // Start both syncing data - managerAProject.$sync.start() - managerBProject.$sync.start() - console.log('@@@@', 'about to connect servers') - managerAProject.$sync.connectServers() - managerBProject.$sync.connectServers() - console.log('@@@@', 'connected servers') - await waitForSyncWithServer() // TODO this is bogus and silly, just used for waiting - console.log('@@@@', 'waited a bit') - // manager 1 adds data, syncs with server const observation = await managerAProject.observation.create( valueOf(generate('observation')[0]) ) + managerAProject.$sync.start() + managerAProject.$sync.connectServers() await waitForSyncWithServer() + managerAProject.$sync.stop() // Check manager 2 doesn't have the data - await assert.rejects(() => - managerBProject.observation.getByDocId(observation.docId) + await assert.rejects( + () => managerBProject.observation.getByDocId(observation.docId), + "manager B doesn't see observation" ) + // manager 2 connects to server + managerBProject.$sync.connectServers() + managerBProject.$sync.start() + // manager 2 now has data from manager 1, even though it wasn't connected to manager 1 directly await waitForSyncWithServer() assert( From 400e305a74fcc0a36a0a73286de2ec247c3f6919 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:34:36 +0000 Subject: [PATCH 022/118] Remove a useless delay --- test-e2e/server.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 0d4fe6528..93ada743f 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -83,11 +83,6 @@ test.only('TODO', { timeout: 2 ** 30 }, async (t) => { dangerouslyAllowInsecureConnections: true, }) - // TODO: remove this - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) - // manager 1, invite manager 2 const disconnect1 = connectPeers(managers) t.after(disconnect1) From 54823a8c7efcb2ea9f4c036889820f61fa4b1891 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:40:58 +0000 Subject: [PATCH 023/118] Some server test cleanup --- test-e2e/server.js | 47 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 93ada743f..031346268 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -45,7 +45,7 @@ test('adding a server peer', async (t) => { ) assert.equal( serverPeer.selfHostedServerDetails?.baseUrl, - 'https://mapeo.example', + 'http://localhost:9876', 'server peer stores base URL' ) }) @@ -70,63 +70,60 @@ test("can't add a server to two different projects", async (t) => { }, Error) }) -test.only('TODO', { timeout: 2 ** 30 }, async (t) => { - // create two managers - const managers = await createManagers(2, t, 'mobile') +test('data can be synced via a server', async (t) => { + const [managers, serverBaseUrl] = await Promise.all([ + createManagers(2, t, 'mobile'), + createTestServer(t), + ]) const [managerA, managerB] = managers - // manager 1 sets up server + // Manager A: create project and add the server to it const projectId = await managerA.createProject({ name: 'foo' }) const managerAProject = await managerA.getProject(projectId) - const serverBaseUrl = await createTestServer(t) await managerAProject.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) - // manager 1, invite manager 2 - const disconnect1 = connectPeers(managers) - t.after(disconnect1) + // Add Manager B to project + const disconnectManagers = connectPeers(managers) + t.after(disconnectManagers) await waitForPeers(managers) await invite({ invitor: managerA, invitees: [managerB], projectId }) const managerBProject = await managerB.getProject(projectId) - // sync managers (to tell manager 2 about server) - // TODO: We should be calling this instead of `delay`, but there [is a bug][0] that prevents this. + // Sync managers to tell Manager B about the server + // TODO: We shouldn't use this `delay`, but [is a bug][0] that requires it. // [0]: https://github.com/digidem/comapeo-core/pull/887 + // + // The code should be: // const projects = [managerAProject, managerBProject] // await waitForSync(projects, 'initial') await delay(3000) - const members = await managerBProject.$member.getMany() // TODO: maybe rename this - const serverPeer = members.find( + const managerBMembers = await managerBProject.$member.getMany() + const serverPeer = managerBMembers.find( (member) => member.deviceType === 'selfHostedServer' ) assert(serverPeer, 'expected a server peer to be found by the client') - // disconnect managers - await disconnect1() + // Manager A adds data that Manager B doesn't know about + await disconnectManagers() await waitForNoPeersToBeConnected(managerA) await waitForNoPeersToBeConnected(managerB) - - // manager 1 adds data, syncs with server + managerAProject.$sync.start() + managerAProject.$sync.connectServers() const observation = await managerAProject.observation.create( valueOf(generate('observation')[0]) ) - managerAProject.$sync.start() - managerAProject.$sync.connectServers() await waitForSyncWithServer() managerAProject.$sync.stop() - - // Check manager 2 doesn't have the data await assert.rejects( () => managerBProject.observation.getByDocId(observation.docId), - "manager B doesn't see observation" + "manager B doesn't see observation yet" ) - // manager 2 connects to server + // Manager B sees observation after syncing managerBProject.$sync.connectServers() managerBProject.$sync.start() - - // manager 2 now has data from manager 1, even though it wasn't connected to manager 1 directly await waitForSyncWithServer() assert( await managerBProject.observation.getByDocId(observation.docId), From efdbee0f6c39217673d8506df0e2e9629b3ac509 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:43:05 +0000 Subject: [PATCH 024/118] More server test cleanup --- test-e2e/server.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 031346268..6de17ee0d 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -21,19 +21,21 @@ import { test('adding a server peer', async (t) => { const manager = createManager('device0', t) - await manager.setDeviceInfo({ name: 'device0', deviceType: 'mobile' }) // TODO: necessary? const projectId = await manager.createProject() const project = await manager.getProject(projectId) const serverBaseUrl = await createTestServer(t) + const hasServerPeerBeforeAdding = (await project.$member.getMany()).some( + (member) => member.deviceType === 'selfHostedServer' + ) + assert(!hasServerPeerBeforeAdding, 'no server peers before adding') + await project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) - const members = await project.$member.getMany() - // TODO: Ensure that this peer doesn't exist before adding? - const serverPeer = members.find( + const serverPeer = (await project.$member.getMany()).find( (member) => member.deviceType === 'selfHostedServer' ) assert(serverPeer, 'expected a server peer to be found by the client') @@ -170,7 +172,7 @@ function waitForNoPeersToBeConnected(manager) { } function waitForSyncWithServer() { - // TODO: This is fake + // TODO: This is fake! return new Promise((resolve) => { setTimeout(resolve, 3000) }) From e9d59334431db85d7944169fb210e3729a56175f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:43:11 +0000 Subject: [PATCH 025/118] Remove a debugging console.log --- src/sync/sync-api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 2aa83140e..9fd49eed2 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -302,7 +302,6 @@ export class SyncApi extends TypedEmitter { .then((urls) => { for (const url of urls) { const existingWebsocket = this.#serverWebsockets.get(url) - console.log('@@@@', 'connecting to', url) if ( existingWebsocket && (existingWebsocket.readyState === WebSocket.OPEN || From 6442c37b4e5e30a0e2e8e19bbf9259834b9f451a Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 14:44:04 +0000 Subject: [PATCH 026/118] Clean up server integration tests --- test-e2e/server-integration.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 5a289b0ce..f75a45625 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -22,7 +22,7 @@ test('server info endpoint', async (t) => { assert.deepEqual(response.json(), expectedResponseBody) }) -test('add project, sync endpoint available', { only: false }, async (t) => { +test('add project, sync endpoint available', async (t) => { const server = createTestServer(t) const projectKeys = randomProjectKeys() const projectPublicId = projectKeyToPublicId( @@ -82,7 +82,7 @@ test('invalid project public id', async (t) => { assert.equal(response.json().code, 'FST_ERR_VALIDATION') }) -test('trying to add second project fails', { only: true }, async (t) => { +test('trying to add second project fails', async (t) => { const server = createTestServer(t) await t.test('add first project', async () => { @@ -136,7 +136,6 @@ function createTestServer(t, serverName = 'test server') { const km = new KeyManager(managerOptions.rootKey) const server = createServer({ ...managerOptions, - logger: true, serverName, serverPublicBaseUrl: 'http://localhost:0', }) From 154a9f26f6e81d8f2eb69eb536219b0436e361ac Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 15:01:07 +0000 Subject: [PATCH 027/118] Skeleton GET /observations endpoint --- src/server/routes.js | 34 ++++++++++++++++++++++++++++++++++ test-e2e/server-integration.js | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 668ad685b..176c6fad1 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -132,4 +132,38 @@ export default async function routes(fastify) { return reply } ) + + fastify.get( + '/projects/:projectPublicId/observations', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + }), + response: { + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + const projectPublicId = req.params.projectPublicId + try { + await this.comapeo.getProject(projectPublicId) + } catch (e) { + if (e instanceof Error && e.message.startsWith('NotFound')) { + throw this.httpErrors.notFound('Project not found') + } + } + }, + }, + async function (req, reply) { + // The preValidation hook ensures that the project exists + const project = await this.comapeo.getProject(req.params.projectPublicId) + + reply.send({ + data: (await project.observation.getMany()).map((obs) => ({ + id: obs.docId, + })), + }) + } + ) } diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index f75a45625..e80a9ec31 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -1,4 +1,4 @@ -import { getManagerOptions } from './utils.js' +import { createManager, getManagerOptions } from './utils.js' import createServer from '../src/server/app.js' import test from 'node:test' import crypto, { randomBytes } from 'node:crypto' @@ -6,6 +6,9 @@ import { KeyManager } from '@mapeo/crypto' import assert from 'node:assert/strict' import { projectKeyToPublicId } from '../src/utils.js' +// TODO: Dynamically choose a port that's open +const PORT = 9875 + test('server info endpoint', async (t) => { const serverName = 'test server' const server = createTestServer(t, serverName) @@ -109,6 +112,31 @@ test('trying to add second project fails', async (t) => { }) }) +test.only('observations endpoint', async (t) => { + const server = createTestServer(t) + + const serverBaseUrl = await server.listen({ port: PORT }) + t.after(() => server.close()) + + const manager = await createManager('client', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }) + + const emptyResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + }) + assert.equal(emptyResponse.statusCode, 200) + assert.deepEqual(await emptyResponse.json(), { data: [] }) + + // TODO: Test if no project exists + // TODO: Test no observations +}) + function randomHexKey(length = 32) { return Buffer.from(crypto.randomBytes(length)).toString('hex') } @@ -137,7 +165,7 @@ function createTestServer(t, serverName = 'test server') { const server = createServer({ ...managerOptions, serverName, - serverPublicBaseUrl: 'http://localhost:0', + serverPublicBaseUrl: 'http://localhost:' + PORT, }) t.after(() => server.close()) Object.defineProperty(server, 'deviceId', { From 9d67113beb94d9e66b17af8c8be88d039e7de22f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 3 Oct 2024 16:14:14 +0000 Subject: [PATCH 028/118] Returning observations without attachments --- src/server/routes.js | 7 ++++++- test-e2e/server-integration.js | 29 +++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 176c6fad1..4c8c0c588 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -161,7 +161,12 @@ export default async function routes(fastify) { reply.send({ data: (await project.observation.getMany()).map((obs) => ({ - id: obs.docId, + docId: obs.docId, + createdAt: obs.createdAt, + updatedAt: obs.updatedAt, + lat: obs.lat, + lon: obs.lon, + // TODO: Attachments })), }) } diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index e80a9ec31..cb16a9e99 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -5,6 +5,8 @@ import crypto, { randomBytes } from 'node:crypto' import { KeyManager } from '@mapeo/crypto' import assert from 'node:assert/strict' import { projectKeyToPublicId } from '../src/utils.js' +import { generate } from '@mapeo/mock-data' +import { valueOf } from '@comapeo/schema' // TODO: Dynamically choose a port that's open const PORT = 9875 @@ -133,8 +135,31 @@ test.only('observations endpoint', async (t) => { assert.equal(emptyResponse.statusCode, 200) assert.deepEqual(await emptyResponse.json(), { data: [] }) - // TODO: Test if no project exists - // TODO: Test no observations + project.$sync.start() + project.$sync.connectServers() + const observations = await Promise.all( + generate('observation', { count: 3 }).map((observation) => + project.observation.create(valueOf(observation)) + ) + ) + await project.$sync.waitForSync('full') + + const fullResponse = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + }) + assert.equal(fullResponse.statusCode, 200) + const { data } = await fullResponse.json() + assert.equal(data.length, 3) + for (const observation of observations) { + const observationFromApi = data.find((o) => o.docId === observation.docId) + assert(observationFromApi, 'observation found in API response') + assert.equal(observationFromApi.createdAt, observation.createdAt) + assert.equal(observationFromApi.updatedAt, observation.updatedAt) + assert.equal(observationFromApi.lat, observation.lat) + assert.equal(observationFromApi.lon, observation.lon) + // TODO: Add attachments + } }) function randomHexKey(length = 32) { From 87934f63ff9be9e7bfb6bdb0c55af1eac8f10f2f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 15:13:03 +0000 Subject: [PATCH 029/118] Remove workaround for now-fixed bug --- test-e2e/server.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 6de17ee0d..f7d5ade5c 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -2,7 +2,6 @@ import { valueOf } from '@comapeo/schema' import { generate } from '@mapeo/mock-data' import assert from 'node:assert/strict' import test from 'node:test' -import { setTimeout as delay } from 'node:timers/promises' import { MEMBER_ROLE_ID } from '../src/roles.js' import createServer from '../src/server/app.js' import { @@ -12,6 +11,7 @@ import { getManagerOptions, invite, waitForPeers, + waitForSync, } from './utils.js' /** @import { MapeoManager } from '../src/mapeo-manager.js' */ @@ -94,13 +94,8 @@ test('data can be synced via a server', async (t) => { const managerBProject = await managerB.getProject(projectId) // Sync managers to tell Manager B about the server - // TODO: We shouldn't use this `delay`, but [is a bug][0] that requires it. - // [0]: https://github.com/digidem/comapeo-core/pull/887 - // - // The code should be: - // const projects = [managerAProject, managerBProject] - // await waitForSync(projects, 'initial') - await delay(3000) + const projects = [managerAProject, managerBProject] + await waitForSync(projects, 'initial') const managerBMembers = await managerBProject.$member.getMany() const serverPeer = managerBMembers.find( (member) => member.deviceType === 'selfHostedServer' From b82a69afb91754d707da34c83844996d7d24d90c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 15:19:12 +0000 Subject: [PATCH 030/118] Minor: fix typo in comment --- src/server/ws-core-replicator.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js index da2f900f1..07bd29f69 100644 --- a/src/server/ws-core-replicator.js +++ b/src/server/ws-core-replicator.js @@ -28,7 +28,7 @@ export function wsCoreReplicator(ws, replicationStream) { /** * Avoid writing data to a closing or closed websocket, which would result in an * error. Instead we drop the data and wait for the stream close/end events to - * propogate and close the streams cleanly. + * propagate and close the streams cleanly. * * @param {import('ws').WebSocket} ws */ From 42999c5864cad9fa6352d04ff740d8b5f930e439 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 16:19:21 +0000 Subject: [PATCH 031/118] Fix disconnect bug Co-Authored-By: Gregor MacLennan --- src/server/ws-core-replicator.js | 2 +- test-e2e/server.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js index 07bd29f69..55f45627f 100644 --- a/src/server/ws-core-replicator.js +++ b/src/server/ws-core-replicator.js @@ -19,8 +19,8 @@ export function wsCoreReplicator(ws, replicationStream) { ) return pipeline( _replicationStream, - createWebSocketStream(ws), wsSafetyTransform(ws), + createWebSocketStream(ws), _replicationStream ) } diff --git a/test-e2e/server.js b/test-e2e/server.js index f7d5ade5c..2842d98a2 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -82,6 +82,7 @@ test('data can be synced via a server', async (t) => { // Manager A: create project and add the server to it const projectId = await managerA.createProject({ name: 'foo' }) const managerAProject = await managerA.getProject(projectId) + t.after(() => managerAProject.$sync.disconnectServers()) await managerAProject.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) @@ -92,6 +93,7 @@ test('data can be synced via a server', async (t) => { await waitForPeers(managers) await invite({ invitor: managerA, invitees: [managerB], projectId }) const managerBProject = await managerB.getProject(projectId) + t.after(() => managerBProject.$sync.disconnectServers()) // Sync managers to tell Manager B about the server const projects = [managerAProject, managerBProject] @@ -112,6 +114,7 @@ test('data can be synced via a server', async (t) => { valueOf(generate('observation')[0]) ) await waitForSyncWithServer() + managerAProject.$sync.disconnectServers() managerAProject.$sync.stop() await assert.rejects( () => managerBProject.observation.getByDocId(observation.docId), From 2f2eea0de23367f0bb2ac2caaa9b13126ee6f375 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 16:22:51 +0000 Subject: [PATCH 032/118] Remove .only from server integration test --- test-e2e/server-integration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index cb16a9e99..06e354661 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -114,7 +114,7 @@ test('trying to add second project fails', async (t) => { }) }) -test.only('observations endpoint', async (t) => { +test('observations endpoint', async (t) => { const server = createTestServer(t) const serverBaseUrl = await server.listen({ port: PORT }) From 07b652034f1c826a4ecf6d57ecaa9a676966611e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 16:23:44 +0000 Subject: [PATCH 033/118] Fix test type error --- test-e2e/server-integration.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 06e354661..6625f9c61 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -152,7 +152,9 @@ test('observations endpoint', async (t) => { const { data } = await fullResponse.json() assert.equal(data.length, 3) for (const observation of observations) { - const observationFromApi = data.find((o) => o.docId === observation.docId) + const observationFromApi = data.find( + (/** @type {{ docId: string }} */ o) => o.docId === observation.docId + ) assert(observationFromApi, 'observation found in API response') assert.equal(observationFromApi.createdAt, observation.createdAt) assert.equal(observationFromApi.updatedAt, observation.updatedAt) From 981cb6ef6eae12a2ad6ded8b91011482cdb77266 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 17:04:48 +0000 Subject: [PATCH 034/118] GET /observations should authenticate --- src/server/app.js | 22 ++++++--- src/server/routes.js | 41 ++++++++++++++-- test-e2e/server-integration.js | 85 ++++++++++++++++++++++------------ 3 files changed, 108 insertions(+), 40 deletions(-) diff --git a/src/server/app.js b/src/server/app.js index 0764eaa34..0d0daf816 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -1,23 +1,33 @@ import fastifyWebsocket from '@fastify/websocket' import createFastify from 'fastify' import fastifySensible from '@fastify/sensible' - import routes from './routes.js' import comapeoPlugin from './comapeo-plugin.js' - +/** @import { FastifyServerOptions } from 'fastify' */ /** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ -/** @typedef {import('fastify').FastifyServerOptions['logger']} FastifyLogger */ -/** @typedef {{ logger?: FastifyLogger } & ComapeoPluginOptions} ServerOptions */ + +/** + * @internal + * @typedef {object} OtherServerOptions + * @prop {FastifyServerOptions['logger']} [logger] + * @prop {string} serverBearerToken + */ + +/** @typedef {ComapeoPluginOptions & OtherServerOptions} ServerOptions */ /** * @param {ServerOptions} opts * @returns */ -export default function createServer({ logger, ...comapeoPluginOpts }) { +export default function createServer({ + logger, + serverBearerToken, + ...comapeoPluginOpts +}) { const fastify = createFastify({ logger }) fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(comapeoPlugin, comapeoPluginOpts) - fastify.register(routes) + fastify.register(routes, { serverBearerToken }) return fastify } diff --git a/src/server/routes.js b/src/server/routes.js index 4c8c0c588..25bb8fe19 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,18 +1,32 @@ import { Type } from '@sinclair/typebox' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' - -/** @import {FastifyPluginAsync, RawServerDefault} from 'fastify' */ +import timingSafeEqual from '../lib/timing-safe-equal.js' +/** @import {FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ -/** @typedef {Record} RouteOptions */ +const BEARER_SPACE_LENGTH = 'Bearer '.length const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$' const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) const BASE32_REGEX_32_BYTES = '^[0-9A-Za-z]{52}$' const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) +/** + * @typedef {object} RouteOptions + * @prop {string} serverBearerToken + */ + /** @type {FastifyPluginAsync} */ -export default async function routes(fastify) { +export default async function routes(fastify, { serverBearerToken }) { + /** + * @param {FastifyRequest} req + */ + const verifyBearerAuth = (req) => { + if (!isBearerTokenValid(req.headers.authorization, serverBearerToken)) { + throw fastify.httpErrors.forbidden('Invalid bearer token') + } + } + fastify.get( '/info', { @@ -141,10 +155,12 @@ export default async function routes(fastify) { projectPublicId: BASE32_STRING_32_BYTES, }), response: { + 403: { $ref: 'HttpError' }, 404: { $ref: 'HttpError' }, }, }, async preHandler(req) { + verifyBearerAuth(req) const projectPublicId = req.params.projectPublicId try { await this.comapeo.getProject(projectPublicId) @@ -172,3 +188,20 @@ export default async function routes(fastify) { } ) } + +/** + * @param {undefined | string} headerValue + * @param {string} expectedBearerToken + * @returns {boolean} + */ +function isBearerTokenValid(headerValue = '', expectedBearerToken) { + // This check is not strictly required for correctness, but helps protect + // against long values. + const expectedLength = BEARER_SPACE_LENGTH + expectedBearerToken.length + if (headerValue.length !== expectedLength) return false + + if (!headerValue.startsWith('Bearer ')) return false + const actualBearerToken = headerValue.slice(BEARER_SPACE_LENGTH) + + return timingSafeEqual(actualBearerToken, expectedBearerToken) +} diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 6625f9c61..262753cfd 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -10,6 +10,7 @@ import { valueOf } from '@comapeo/schema' // TODO: Dynamically choose a port that's open const PORT = 9875 +const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') test('server info endpoint', async (t) => { const serverName = 'test server' @@ -128,40 +129,63 @@ test('observations endpoint', async (t) => { dangerouslyAllowInsecureConnections: true, }) - const emptyResponse = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, + await t.test('returns a 403 if no auth is provided', async () => { + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + }) + assert.equal(response.statusCode, 403) }) - assert.equal(emptyResponse.statusCode, 200) - assert.deepEqual(await emptyResponse.json(), { data: [] }) - - project.$sync.start() - project.$sync.connectServers() - const observations = await Promise.all( - generate('observation', { count: 3 }).map((observation) => - project.observation.create(valueOf(observation)) - ) - ) - await project.$sync.waitForSync('full') - const fullResponse = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, + await t.test('returns a 403 if incorrect auth is provided', async () => { + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) }) - assert.equal(fullResponse.statusCode, 200) - const { data } = await fullResponse.json() - assert.equal(data.length, 3) - for (const observation of observations) { - const observationFromApi = data.find( - (/** @type {{ docId: string }} */ o) => o.docId === observation.docId + + await t.test('no observations', async () => { + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(await response.json(), { data: [] }) + }) + + await t.test('returning observations', async () => { + project.$sync.start() + project.$sync.connectServers() + const observations = await Promise.all( + generate('observation', { count: 3 }).map((observation) => + project.observation.create(valueOf(observation)) + ) ) - assert(observationFromApi, 'observation found in API response') - assert.equal(observationFromApi.createdAt, observation.createdAt) - assert.equal(observationFromApi.updatedAt, observation.updatedAt) - assert.equal(observationFromApi.lat, observation.lat) - assert.equal(observationFromApi.lon, observation.lon) - // TODO: Add attachments - } + await project.$sync.waitForSync('full') + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + const { data } = await response.json() + assert.equal(data.length, 3) + for (const observation of observations) { + const observationFromApi = data.find( + (/** @type {{ docId: string }} */ o) => o.docId === observation.docId + ) + assert(observationFromApi, 'observation found in API response') + assert.equal(observationFromApi.createdAt, observation.createdAt) + assert.equal(observationFromApi.updatedAt, observation.updatedAt) + assert.equal(observationFromApi.lat, observation.lat) + assert.equal(observationFromApi.lon, observation.lon) + // TODO: Add attachments + } + }) }) function randomHexKey(length = 32) { @@ -193,6 +217,7 @@ function createTestServer(t, serverName = 'test server') { ...managerOptions, serverName, serverPublicBaseUrl: 'http://localhost:' + PORT, + serverBearerToken: BEARER_TOKEN, }) t.after(() => server.close()) Object.defineProperty(server, 'deviceId', { From 1f033a8c050be1eebc6561657a2b555db0bcf48c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 7 Oct 2024 20:19:28 +0100 Subject: [PATCH 035/118] feat: add server.js script (#892) --- package-lock.json | 55 ++++++++++++++++++ package.json | 1 + src/server/comapeo-plugin.js | 3 +- src/server/server.js | 104 +++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 src/server/server.js diff --git a/package-lock.json b/package-lock.json index 67490c3cd..6234657cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "debug": "^4.3.4", "dot-prop": "^9.0.0", "drizzle-orm": "^0.30.8", + "env-schema": "^6.0.0", "fastify": ">= 4", "fastify-plugin": "^4.5.1", "hyperblobs": "2.3.0", @@ -2765,6 +2766,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "engines": { + "node": ">=12" + } + }, "node_modules/dprint-node": { "version": "1.0.7", "dev": true, @@ -3037,6 +3057,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/env-schema": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/env-schema/-/env-schema-6.0.0.tgz", + "integrity": "sha512-/IHp1EmrfubUOfF1wfe8koDWM5/dxUDylHANPNrPyrsYWJ7KRiB8gXbjtqQBujmOhpSpXXOhhnaL+meb+MaGtA==", + "dependencies": { + "ajv": "^8.12.0", + "dotenv": "^16.4.5", + "dotenv-expand": "10.0.0" + } + }, + "node_modules/env-schema/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/env-schema/node_modules/fast-uri": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", + "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==" + }, + "node_modules/env-schema/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/error-ex": { "version": "1.3.2", "dev": true, diff --git a/package.json b/package.json index 3ee635c6b..e716f7ff8 100644 --- a/package.json +++ b/package.json @@ -177,6 +177,7 @@ "debug": "^4.3.4", "dot-prop": "^9.0.0", "drizzle-orm": "^0.30.8", + "env-schema": "^6.0.0", "fastify": ">= 4", "fastify-plugin": "^4.5.1", "hyperblobs": "2.3.0", diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index e5d78550f..f290ccb4d 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -2,7 +2,7 @@ import { MapeoManager } from '../mapeo-manager.js' import createFastifyPlugin from 'fastify-plugin' /** - * @typedef {ConstructorParameters[0] & { + * @typedef {Omit[0], 'fastify'> & { * serverName: string; * serverPublicBaseUrl: string; * }} ComapeoPluginOptions @@ -12,6 +12,7 @@ import createFastifyPlugin from 'fastify-plugin' const comapeoPlugin = async function (fastify, opts) { const comapeo = new MapeoManager({ ...opts, + fastify, // TODO(evanhahn) // deviceType: 'selfHostedServer', }) diff --git a/src/server/server.js b/src/server/server.js new file mode 100644 index 000000000..97c1a2f88 --- /dev/null +++ b/src/server/server.js @@ -0,0 +1,104 @@ +import createServer from './app.js' +import envSchema from 'env-schema' +import { Type } from '@sinclair/typebox' +import path from 'node:path' +import fsPromises from 'node:fs/promises' +import crypto from 'node:crypto' + +const DEFAULT_STORAGE = path.join(process.cwd(), 'data') +const CORE_DIR_NAME = 'core' +const DB_DIR_NAME = 'db' +const ROOT_KEY_FILE_NAME = 'root-key' + +const schema = Type.Object({ + PORT: Type.Number({ default: 3000 }), + API_TOKEN: Type.String({ + description: 'API token for accessing the server, can be any random string', + }), + SERVER_NAME: Type.String({ + description: 'name of the server', + default: 'CoMapeo Server', + }), + SERVER_PUBLIC_BASE_URL: Type.String({ + description: 'public base URL of the server', + }), + STORAGE_DIR: Type.String({ + description: 'path to directory where data is stored', + default: DEFAULT_STORAGE, + }), + ROOT_KEY: Type.Optional( + Type.String({ + description: + 'hex-encoded 16-byte random secret key, used for server keypairs', + }) + ), +}) + +/** @typedef {import('@sinclair/typebox').Static} Env */ +/** @type {ReturnType>} */ +const config = envSchema({ schema, dotenv: true }) + +const coreStorage = path.join(config.STORAGE_DIR, CORE_DIR_NAME) +const dbFolder = path.join(config.STORAGE_DIR, DB_DIR_NAME) +const rootKeyFile = path.join(config.STORAGE_DIR, ROOT_KEY_FILE_NAME) +const projectMigrationsFolder = new URL( + '../../drizzle/project', + import.meta.url +).pathname +const clientMigrationsFolder = new URL('../../drizzle/client', import.meta.url) + .pathname + +await Promise.all([ + fsPromises.mkdir(coreStorage, { recursive: true }), + fsPromises.mkdir(dbFolder, { recursive: true }), +]) + +/** @type {Buffer} */ +let rootKey +if (config.ROOT_KEY) { + rootKey = Buffer.from(config.ROOT_KEY, 'hex') +} else { + try { + rootKey = await fsPromises.readFile(rootKeyFile) + } catch (err) { + // @ts-expect-error + if (err?.code !== 'ENOENT') { + throw err + } + rootKey = crypto.randomBytes(16) + await fsPromises.writeFile(rootKeyFile, rootKey) + } +} + +if (!rootKey || rootKey.length !== 16) { + throw new Error('Root key must be 16 bytes') +} + +const fastify = createServer({ + // TODO: apiToken: config.API_TOKEN, + serverName: config.SERVER_NAME, + serverPublicBaseUrl: config.SERVER_PUBLIC_BASE_URL, + rootKey, + coreStorage, + dbFolder, + projectMigrationsFolder, + clientMigrationsFolder, + logger: true, +}) + +try { + await fastify.listen({ port: config.PORT, host: '::' }) +} catch (err) { + fastify.log.error(err) + process.exit(1) +} + +/** @param {NodeJS.Signals} signal*/ +async function closeGracefully(signal) { + console.log(`Received signal to terminate: ${signal}`) + await fastify.close() + console.log('Gracefully closed fastify') + process.kill(process.pid, signal) +} +process.once('SIGINT', closeGracefully) +process.once('SIGTERM', closeGracefully) From 4abd0023600aeeeea044fc0db523bc907c50c690 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 19:21:03 +0000 Subject: [PATCH 036/118] Fix type error when starting server --- src/server/server.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/server/server.js b/src/server/server.js index 97c1a2f88..4daffc83e 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -61,8 +61,12 @@ if (config.ROOT_KEY) { try { rootKey = await fsPromises.readFile(rootKeyFile) } catch (err) { - // @ts-expect-error - if (err?.code !== 'ENOENT') { + if ( + typeof err === 'object' && + err && + 'code' in err && + err.code !== 'ENOENT' + ) { throw err } rootKey = crypto.randomBytes(16) From aef518692169fb70e0188a6d68d1d0284af8801e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 20:27:58 +0000 Subject: [PATCH 037/118] GET /observations should return fetchable attachments --- src/server/app.js | 5 +- src/server/routes.js | 108 +++++++++++--- src/server/server.js | 9 +- src/server/test/fixtures/original.jpg | Bin 0 -> 16308 bytes src/server/test/fixtures/preview.jpg | Bin 0 -> 8461 bytes src/server/test/fixtures/thumbnail.jpg | Bin 0 -> 1661 bytes test-e2e/server-integration.js | 189 ++++++++++++++++++++----- test-e2e/server.js | 1 + 8 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 src/server/test/fixtures/original.jpg create mode 100644 src/server/test/fixtures/preview.jpg create mode 100644 src/server/test/fixtures/thumbnail.jpg diff --git a/src/server/app.js b/src/server/app.js index 0d0daf816..32622a636 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -28,6 +28,9 @@ export default function createServer({ fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(comapeoPlugin, comapeoPluginOpts) - fastify.register(routes, { serverBearerToken }) + fastify.register(routes, { + serverBearerToken, + serverPublicBaseUrl: comapeoPluginOpts.serverPublicBaseUrl, + }) return fastify } diff --git a/src/server/routes.js b/src/server/routes.js index 25bb8fe19..fdf581cf7 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -2,7 +2,7 @@ import { Type } from '@sinclair/typebox' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' import timingSafeEqual from '../lib/timing-safe-equal.js' -/** @import {FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ +/** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ const BEARER_SPACE_LENGTH = 'Bearer '.length @@ -14,10 +14,14 @@ const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) /** * @typedef {object} RouteOptions * @prop {string} serverBearerToken + * @prop {string} serverPublicBaseUrl */ /** @type {FastifyPluginAsync} */ -export default async function routes(fastify, { serverBearerToken }) { +export default async function routes( + fastify, + { serverBearerToken, serverPublicBaseUrl } +) { /** * @param {FastifyRequest} req */ @@ -63,14 +67,7 @@ export default async function routes(fastify, { serverBearerToken }) { }, }, async preHandler(req) { - const projectPublicId = req.params.projectPublicId - try { - await this.comapeo.getProject(projectPublicId) - } catch (e) { - if (e instanceof Error && e.message.startsWith('NotFound')) { - throw this.httpErrors.notFound('Project not found') - } - } + await ensureProjectExists(this, req) }, websocket: true, }, @@ -161,19 +158,12 @@ export default async function routes(fastify, { serverBearerToken }) { }, async preHandler(req) { verifyBearerAuth(req) - const projectPublicId = req.params.projectPublicId - try { - await this.comapeo.getProject(projectPublicId) - } catch (e) { - if (e instanceof Error && e.message.startsWith('NotFound')) { - throw this.httpErrors.notFound('Project not found') - } - } + await ensureProjectExists(this, req) }, }, async function (req, reply) { - // The preValidation hook ensures that the project exists - const project = await this.comapeo.getProject(req.params.projectPublicId) + const { projectPublicId } = req.params + const project = await this.comapeo.getProject(projectPublicId) reply.send({ data: (await project.observation.getMany()).map((obs) => ({ @@ -182,11 +172,87 @@ export default async function routes(fastify, { serverBearerToken }) { updatedAt: obs.updatedAt, lat: obs.lat, lon: obs.lon, - // TODO: Attachments + attachments: obs.attachments.map((attachment) => ({ + url: new URL( + // TODO: Support other variants + `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, + serverPublicBaseUrl + ), + })), })), }) } ) + + fastify.get( + '/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name', + { + schema: { + params: Type.Object({ + projectPublicId: BASE32_STRING_32_BYTES, + driveDiscoveryId: Type.String(), + // TODO: For now, only photos are supported. + type: Type.Literal('photo'), + name: Type.String(), + }), + querystring: Type.Object({ + variant: Type.Optional( + Type.Union([ + Type.Literal('original'), + Type.Literal('preview'), + Type.Literal('thumbnail'), + ]) + ), + }), + response: { + 403: { $ref: 'HttpError' }, + 404: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + await ensureProjectExists(this, req) + }, + }, + async function (req, reply) { + const project = await this.comapeo.getProject(req.params.projectPublicId) + + const blobUrl = await project.$blobs.getUrl({ + driveId: req.params.driveDiscoveryId, + name: req.params.name, + type: req.params.type, + variant: req.query.variant || 'original', + }) + + const proxiedResponse = await fetch(blobUrl) + reply.code(proxiedResponse.status) + // TODO: Are there other headers we want to pass through? + reply.header('Content-Type', proxiedResponse.headers.get('content-type')) + reply.header( + 'Content-Length', + proxiedResponse.headers.get('content-length') + ) + return reply.send(proxiedResponse.body) + } + ) +} + +/** + * @param {FastifyInstance} fastify + * @param {object} req + * @param {object} req.params + * @param {string} req.params.projectPublicId + * @returns {Promise} + */ +async function ensureProjectExists(fastify, req) { + try { + await fastify.comapeo.getProject(req.params.projectPublicId) + } catch (e) { + if (e instanceof Error && e.message.startsWith('NotFound')) { + throw fastify.httpErrors.notFound('Project not found') + } + throw e + } } /** diff --git a/src/server/server.js b/src/server/server.js index 4daffc83e..7f8b53fbe 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -12,13 +12,14 @@ const ROOT_KEY_FILE_NAME = 'root-key' const schema = Type.Object({ PORT: Type.Number({ default: 3000 }), - API_TOKEN: Type.String({ - description: 'API token for accessing the server, can be any random string', - }), SERVER_NAME: Type.String({ description: 'name of the server', default: 'CoMapeo Server', }), + SERVER_BEARER_TOKEN: Type.String({ + description: + 'Bearer token for accessing the server, can be any random string', + }), SERVER_PUBLIC_BASE_URL: Type.String({ description: 'public base URL of the server', }), @@ -79,8 +80,8 @@ if (!rootKey || rootKey.length !== 16) { } const fastify = createServer({ - // TODO: apiToken: config.API_TOKEN, serverName: config.SERVER_NAME, + serverBearerToken: config.SERVER_BEARER_TOKEN, serverPublicBaseUrl: config.SERVER_PUBLIC_BASE_URL, rootKey, coreStorage, diff --git a/src/server/test/fixtures/original.jpg b/src/server/test/fixtures/original.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5a3b430be3d9d35377d3a29a63c15ece9a66949b GIT binary patch literal 16308 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 literal 0 HcmV?d00001 diff --git a/src/server/test/fixtures/thumbnail.jpg b/src/server/test/fixtures/thumbnail.jpg new file mode 100644 index 0000000000000000000000000000000000000000..facee563c04409a1e9e9ab36f9bdce15621a4ae6 GIT binary patch literal 1661 zcmbW!doaFEEY43*n#?wX`o z2BYjMM96j;avMpK+_FiCvL`V~bg@(I**$0f+THJS-uIvPInQ}M=UpFJe+F!HvU9Wp zKo9^xNdfCm0BeAV!xQidM8YP5Dw(K8v)83*Yty`TSsB^~`i34m;tTsnCY5JK#$Jl| zhtIcVU%Gm&vAHoUT{PTPH&kBVSo38FNLE#)Y0}(vb=_-Dz$a?{bJm3bRvNGX{2)*b zKwv=#3$C{VDv~{=z^?)R42Xb`QX5dxXc<{aL&HV@0YVT02}wyIk&^C2$vc3=N-0qE zEH@~+`k~Y!aQf_=QfYOo+EzU4;TsJD|Hx#tjM64$!e&h^ZR%DXLnC7oQ!~1?jjf%% zgQJt1yN9ROK5w7H@R5MPprgUZqfSJ}oQ#c2Idk@0YFc_mZeD&t;RWu+B7RwUg`o1v z)vCH1^$j;0o0@O6-EHsa?CKWv3_KcqJTyEqIyU)yYI^3y?A*&&i%a6S%kNg+udaP@ zfdKT4CAq)B{^7z(TnHo*LZZI7Ktzn>Kv<*{MQ?+Gr7OxWLQzehEse9vDXncqs~fQ1 z;Qb>X$|z|VPHHZGp?#J8J6Q7nlKlny+cgGYAW-so5Ed{8x?(Ljw)L>_$(O5c+!Q(V zmK1E3rAu$if&66@q2kAILi)V$MM1m1J}%eYgrp3!6ws~69e%w0Us6ftJ~mbl*cOV| znXY=1#NPNg8IRQ-u-=@KPGZdY1_rz`PH8lpu?~$o&ACQDp5b77{f62$mK_8qjD}fW z(L{;%f7iydi^!Yz40CcOzlZbUz=H129?aRGtb5t!^h5N(fDFd{7W71e-g|t{; z2DV?uOPifCkd5UT^d-Ks@vr62&WKC}#^n=@=UbdbBqE{^y35Nh>? zVerxR>M_5R{=B{jW{F~^GMt)hbHy@z3+2L~hj;1H__dEoa^o@&FY>nH?;^UBV7WVP zkKR;yVfx(bW;vJ35Cs!1)Vx9juO;GKUqmM(=%}(TAY>#7rZ{hBwjMvozcJ_cO=gd_9!bMi^a@pRM27lV!=9a zfkV#k^vd$!2u4d2$R_q$aymONak&9XPnFr?Pm>wdq^59b5%KMm(J7|<Q;@(f;b0P5JG4tO z>T32Dxa-lHQQ|cZ*@ltHJxn}CfAyJ|wL7|})96qMF_#XnJ+`JyPv@RAE2X4)caPEI zU6_huH#Eu7+{w(yn#5kPH95M>PpaK1n(aN1&T@R&&k`xp7IK;y+-INNAJA=edPZoE zf{1Q0H)jICto`J5z-OCOlLqssk}ExBI5>GCwPN4xLf=VZOTOK%=PRP+<*J?ez13<7 zbX#5d3LIvA^ba?!;5+~T literal 0 HcmV?d00001 diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 262753cfd..a95149bcc 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -1,16 +1,26 @@ -import { createManager, getManagerOptions } from './utils.js' -import createServer from '../src/server/app.js' -import test from 'node:test' -import crypto, { randomBytes } from 'node:crypto' +import { valueOf } from '@comapeo/schema' import { KeyManager } from '@mapeo/crypto' +import { generate } from '@mapeo/mock-data' import assert from 'node:assert/strict' +import crypto, { randomBytes } from 'node:crypto' +import * as fs from 'node:fs/promises' +import test from 'node:test' +import createServer from '../src/server/app.js' import { projectKeyToPublicId } from '../src/utils.js' -import { generate } from '@mapeo/mock-data' -import { valueOf } from '@comapeo/schema' +import { blobMetadata } from '../test/helpers/blob-store.js' +import { createManager, getManagerOptions } from './utils.js' +import { map } from 'iterpal' +/** @import { ObservationValue } from '@comapeo/schema'*/ +/** @import { FastifyInstance } from 'fastify' */ // TODO: Dynamically choose a port that's open const PORT = 9875 +const BASE_URL = `http://localhost:${PORT}/` const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') +const FIXTURES_ROOT = new URL('../src/server/test/fixtures/', import.meta.url) +const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname +const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname +const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname test('server info endpoint', async (t) => { const serverName = 'test server' @@ -156,36 +166,69 @@ test('observations endpoint', async (t) => { assert.deepEqual(await response.json(), { data: [] }) }) - await t.test('returning observations', async () => { - project.$sync.start() - project.$sync.connectServers() - const observations = await Promise.all( - generate('observation', { count: 3 }).map((observation) => - project.observation.create(valueOf(observation)) - ) - ) - await project.$sync.waitForSync('full') + await t.test( + 'returning observations with fetchable attachments', + async () => { + project.$sync.start() + project.$sync.connectServers() - const response = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - const { data } = await response.json() - assert.equal(data.length, 3) - for (const observation of observations) { - const observationFromApi = data.find( - (/** @type {{ docId: string }} */ o) => o.docId === observation.docId + const observations = await Promise.all([ + (() => { + /** @type {ObservationValue} */ + const noAttachments = { + ...valueOf(generate('observation')[0]), + attachments: [], + } + return project.observation.create(noAttachments) + })(), + (async () => { + const blob = await project.$blobs.create( + { + original: FIXTURE_ORIGINAL_PATH, + preview: FIXTURE_PREVIEW_PATH, + thumbnail: FIXTURE_THUMBNAIL_PATH, + }, + blobMetadata({ mimeType: 'image/jpeg' }) + ) + /** @type {ObservationValue} */ + const withAttachment = { + ...valueOf(generate('observation')[0]), + attachments: [blobToAttachment(blob)], + } + return project.observation.create(withAttachment) + })(), + ]) + + await project.$sync.waitForSync('full') + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + + assert.equal(data.length, 2) + + await Promise.all( + observations.map(async (observation) => { + const observationFromApi = data.find( + (/** @type {{ docId: string }} */ o) => + o.docId === observation.docId + ) + assert(observationFromApi, 'observation found in API response') + assert.equal(observationFromApi.createdAt, observation.createdAt) + assert.equal(observationFromApi.updatedAt, observation.updatedAt) + assert.equal(observationFromApi.lat, observation.lat) + assert.equal(observationFromApi.lon, observation.lon) + await assertAttachmentsCanBeFetched({ server, observationFromApi }) + }) ) - assert(observationFromApi, 'observation found in API response') - assert.equal(observationFromApi.createdAt, observation.createdAt) - assert.equal(observationFromApi.updatedAt, observation.updatedAt) - assert.equal(observationFromApi.lat, observation.lat) - assert.equal(observationFromApi.lon, observation.lon) - // TODO: Add attachments } - }) + ) }) function randomHexKey(length = 32) { @@ -228,3 +271,83 @@ function createTestServer(t, serverName = 'test server') { // @ts-expect-error return server } + +/** + * TODO: Use a better type for `blob.type` + * @param {object} blob + * @param {string} blob.driveId + * @param {any} blob.type + * @param {string} blob.name + * @param {string} blob.hash + */ +function blobToAttachment(blob) { + return { + driveDiscoveryId: blob.driveId, + type: blob.type, + name: blob.name, + hash: blob.hash, + } +} + +/** + * @param {object} options + * @param {FastifyInstance} options.server + * @param {Record} options.observationFromApi + * @returns {Promise} + */ +async function assertAttachmentsCanBeFetched({ server, observationFromApi }) { + assert(Array.isArray(observationFromApi.attachments)) + await Promise.all( + observationFromApi.attachments.map( + /** @param {unknown} attachment */ + async (attachment) => { + assert(attachment && typeof attachment === 'object') + assert('url' in attachment && typeof attachment.url === 'string') + await assertAttachmentAndVariantsCanBeFetched(server, attachment.url) + } + ) + ) +} + +/** + * @param {FastifyInstance} server + * @param {string} url + * @returns {Promise} + */ +async function assertAttachmentAndVariantsCanBeFetched(server, url) { + assert(url.startsWith(BASE_URL)) + + /** @type {Map} */ + const variantsToCheck = new Map([ + [null, FIXTURE_ORIGINAL_PATH], + ['original', FIXTURE_ORIGINAL_PATH], + ['preview', FIXTURE_PREVIEW_PATH], + ['thumbnail', FIXTURE_THUMBNAIL_PATH], + ]) + + await Promise.all( + map(variantsToCheck, async ([variant, fixturePath]) => { + const expectedResponseBodyPromise = fs.readFile(fixturePath) + const attachmentResponse = await server.inject({ + method: 'GET', + url: url + (variant ? `?variant=${variant}` : ''), + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + attachmentResponse.statusCode, + 200, + `expected 200 when fetching ${variant} attachment` + ) + assert.equal( + attachmentResponse.headers['content-type'], + 'image/jpeg', + `expected ${variant} attachment to be a JPEG` + ) + assert.deepEqual( + attachmentResponse.rawPayload, + await expectedResponseBodyPromise, + `expected ${variant} attachment to match fixture` + ) + }) + ) +} diff --git a/test-e2e/server.js b/test-e2e/server.js index 2842d98a2..79e2d8aee 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -143,6 +143,7 @@ async function createTestServer(t) { ...getManagerOptions('test server'), serverName: 'test server', serverPublicBaseUrl: 'http://localhost:' + port, + serverBearerToken: 'ignored', }) const serverAddress = await server.listen({ port }) t.after(() => server.close()) From e72f342f0ae25e82de3760cf3e5a9b47e046f1f6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 7 Oct 2024 20:30:35 +0000 Subject: [PATCH 038/118] Quiet type errors --- src/datatype/index.js | 2 +- src/index-writer/index.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/datatype/index.js b/src/datatype/index.js index 82459ca2c..1a8623d3f 100644 --- a/src/datatype/index.js +++ b/src/datatype/index.js @@ -49,7 +49,7 @@ export const kDataStore = Symbol('dataStore') /** * @template {DataStore} TDataStore - * @template {TDataStore['schemas'][number]} TSchemaName + * @template {Exclude} TSchemaName TODO: Remove this exclusion * @template {MapeoDocTablesMap[TSchemaName]} TTable * @template {Exclude} TDoc * @template {Exclude} TValue diff --git a/src/index-writer/index.js b/src/index-writer/index.js index 54e0ea1c9..3b0682e9d 100644 --- a/src/index-writer/index.js +++ b/src/index-writer/index.js @@ -82,6 +82,7 @@ export class IndexWriter { continue } // Don't have an indexer for this type - silently ignore + if (doc.schemaName === 'remoteDetectionAlert') continue // TODO: Remove this line when remoteDetectionAlert is supported if (!this.#indexers.has(doc.schemaName)) continue if (queued[doc.schemaName]) { queued[doc.schemaName].push(doc) From 75be09c21a4a97078ad3e00e9722fca80124ebd7 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 8 Oct 2024 15:24:08 +0100 Subject: [PATCH 039/118] feat: use req.hostname instead of manually configured server host (#893) --- src/server/allowed-hosts-plugin.js | 19 ++++++++++++ src/server/app.js | 10 ++++++- src/server/base-url-plugin.js | 11 +++++++ src/server/comapeo-plugin.js | 15 +--------- src/server/routes.js | 24 +++++++++++---- src/server/server.js | 1 - src/server/types.ts | 3 ++ test-e2e/server-integration.js | 48 +++++++++++++++++++++++++----- test-e2e/server.js | 7 ++--- 9 files changed, 105 insertions(+), 33 deletions(-) create mode 100644 src/server/allowed-hosts-plugin.js create mode 100644 src/server/base-url-plugin.js diff --git a/src/server/allowed-hosts-plugin.js b/src/server/allowed-hosts-plugin.js new file mode 100644 index 000000000..732f05bea --- /dev/null +++ b/src/server/allowed-hosts-plugin.js @@ -0,0 +1,19 @@ +import createFastifyPlugin from 'fastify-plugin' + +/** + * @typedef {object} AllowedHostsPluginOptions + * @property {string[]} [allowedHosts] + */ + +/** @type {import('fastify').FastifyPluginAsync} */ +const comapeoPlugin = async function (fastify, { allowedHosts }) { + if (!allowedHosts) { + return + } + const allowedHostsSet = new Set(allowedHosts) + fastify.addHook('onRequest', async function (req) { + this.assert(allowedHostsSet.has(req.hostname), 403, 'Forbidden') + }) +} + +export default createFastifyPlugin(comapeoPlugin, { name: 'allowedHosts' }) diff --git a/src/server/app.js b/src/server/app.js index 32622a636..6ae1e7578 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -3,6 +3,8 @@ import createFastify from 'fastify' import fastifySensible from '@fastify/sensible' import routes from './routes.js' import comapeoPlugin from './comapeo-plugin.js' +import baseUrlPlugin from './base-url-plugin.js' +import allowedHostsPlugin from './allowed-hosts-plugin.js' /** @import { FastifyServerOptions } from 'fastify' */ /** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ @@ -11,6 +13,8 @@ import comapeoPlugin from './comapeo-plugin.js' * @typedef {object} OtherServerOptions * @prop {FastifyServerOptions['logger']} [logger] * @prop {string} serverBearerToken + * @prop {string} serverName + * @prop {string[]} [allowedHosts] */ /** @typedef {ComapeoPluginOptions & OtherServerOptions} ServerOptions */ @@ -22,15 +26,19 @@ import comapeoPlugin from './comapeo-plugin.js' export default function createServer({ logger, serverBearerToken, + serverName, + allowedHosts, ...comapeoPluginOpts }) { const fastify = createFastify({ logger }) fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) + fastify.register(allowedHostsPlugin, { allowedHosts }) + fastify.register(baseUrlPlugin) fastify.register(comapeoPlugin, comapeoPluginOpts) fastify.register(routes, { serverBearerToken, - serverPublicBaseUrl: comapeoPluginOpts.serverPublicBaseUrl, + serverName, }) return fastify } diff --git a/src/server/base-url-plugin.js b/src/server/base-url-plugin.js new file mode 100644 index 000000000..819049785 --- /dev/null +++ b/src/server/base-url-plugin.js @@ -0,0 +1,11 @@ +import createFastifyPlugin from 'fastify-plugin' + +/** @type {import('fastify').FastifyPluginAsync} */ +const baseUrlPlugin = async function (fastify) { + fastify.decorateRequest('baseUrl', null) + fastify.addHook('onRequest', async function (req) { + req.baseUrl = new URL(this.prefix, `${req.protocol}://${req.hostname}`) + }) +} + +export default createFastifyPlugin(baseUrlPlugin, { name: 'baseUrl' }) diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index f290ccb4d..9e352807e 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -2,10 +2,7 @@ import { MapeoManager } from '../mapeo-manager.js' import createFastifyPlugin from 'fastify-plugin' /** - * @typedef {Omit[0], 'fastify'> & { - * serverName: string; - * serverPublicBaseUrl: string; - * }} ComapeoPluginOptions + * @typedef {Omit[0], 'fastify'>} ComapeoPluginOptions */ /** @type {import('fastify').FastifyPluginAsync} */ @@ -17,16 +14,6 @@ const comapeoPlugin = async function (fastify, opts) { // deviceType: 'selfHostedServer', }) fastify.decorate('comapeo', comapeo) - const existingDeviceInfo = comapeo.getDeviceInfo() - if (existingDeviceInfo.deviceType === 'device_type_unspecified') { - await comapeo.setDeviceInfo({ - deviceType: 'selfHostedServer', - name: opts.serverName, - selfHostedServerDetails: { - baseUrl: opts.serverPublicBaseUrl, - }, - }) - } } export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/src/server/routes.js b/src/server/routes.js index fdf581cf7..32160b963 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -14,13 +14,13 @@ const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) /** * @typedef {object} RouteOptions * @prop {string} serverBearerToken - * @prop {string} serverPublicBaseUrl + * @prop {string} serverName */ /** @type {FastifyPluginAsync} */ export default async function routes( fastify, - { serverBearerToken, serverPublicBaseUrl } + { serverBearerToken, serverName } ) { /** * @param {FastifyRequest} req @@ -48,9 +48,8 @@ export default async function routes( }, async function (_req, reply) { const { deviceId, name } = this.comapeo.getDeviceInfo() - this.assert(name, 500, 'Server is missing a name') reply.send({ - data: { deviceId, name }, + data: { deviceId, name: name || serverName }, }) } ) @@ -115,6 +114,21 @@ export default async function routes( 403, 'Server is already linked to a project' ) + const baseUrl = req.baseUrl.toString() + + const existingDeviceInfo = this.comapeo.getDeviceInfo() + // We don't set device info until this point. We trust that `req.hostname` + // is the hostname we want clients to use to sync to the server. + if ( + existingDeviceInfo.deviceType === 'device_type_unspecified' || + existingDeviceInfo.selfHostedServerDetails?.baseUrl !== baseUrl + ) { + await this.comapeo.setDeviceInfo({ + deviceType: 'selfHostedServer', + name: serverName, + selfHostedServerDetails: { baseUrl }, + }) + } const projectKey = Buffer.from(req.body.projectKey, 'hex') @@ -176,7 +190,7 @@ export default async function routes( url: new URL( // TODO: Support other variants `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, - serverPublicBaseUrl + req.baseUrl ), })), })), diff --git a/src/server/server.js b/src/server/server.js index 7f8b53fbe..bb20dbe9b 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -82,7 +82,6 @@ if (!rootKey || rootKey.length !== 16) { const fastify = createServer({ serverName: config.SERVER_NAME, serverBearerToken: config.SERVER_BEARER_TOKEN, - serverPublicBaseUrl: config.SERVER_PUBLIC_BASE_URL, rootKey, coreStorage, dbFolder, diff --git a/src/server/types.ts b/src/server/types.ts index e4812fb5e..97844cb2e 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -9,4 +9,7 @@ declare module 'fastify' { interface FastifyInstance { comapeo: MapeoManager } + interface FastifyRequest { + baseUrl: URL + } } diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index a95149bcc..5c8dbd6aa 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -24,7 +24,7 @@ const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname test('server info endpoint', async (t) => { const serverName = 'test server' - const server = createTestServer(t, serverName) + const server = createTestServer(t, { serverName }) const expectedResponseBody = { data: { deviceId: server.deviceId, @@ -38,6 +38,31 @@ test('server info endpoint', async (t) => { assert.deepEqual(response.json(), expectedResponseBody) }) +test('allowedHosts valid', async (t) => { + const allowedHost = 'www.example.com' + const server = createTestServer(t, { + allowedHosts: [allowedHost], + }) + const response = await server.inject({ + authority: allowedHost, + method: 'GET', + url: '/info', + }) + assert.equal(response.statusCode, 200) +}) + +test('allowedHosts invalid', async (t) => { + const server = createTestServer(t, { + allowedHosts: ['www.example.com'], + }) + const response = await server.inject({ + authority: 'www.invalid-host.com', + method: 'GET', + url: '/info', + }) + assert.equal(response.statusCode, 403) +}) + test('add project, sync endpoint available', async (t) => { const server = createTestServer(t) const projectKeys = randomProjectKeys() @@ -128,14 +153,14 @@ test('trying to add second project fails', async (t) => { test('observations endpoint', async (t) => { const server = createTestServer(t) - const serverBaseUrl = await server.listen({ port: PORT }) + await server.listen({ port: PORT }) t.after(() => server.close()) const manager = await createManager('client', t) const projectId = await manager.createProject() const project = await manager.getProject(projectId) - await project.$member.addServerPeer(serverBaseUrl, { + await project.$member.addServerPeer(BASE_URL, { dangerouslyAllowInsecureConnections: true, }) @@ -202,6 +227,7 @@ test('observations endpoint', async (t) => { await project.$sync.waitForSync('full') const response = await server.inject({ + authority: `localhost:${PORT}`, method: 'GET', url: `/projects/${projectId}/observations`, headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, @@ -248,19 +274,25 @@ function randomProjectKeys() { } } +const TEST_SERVER_DEFAULTS = { + serverName: 'test server', + serverBearerToken: BEARER_TOKEN, +} + /** - * * @param {import('node:test').TestContext} t + * @param {Partial} [serverOptions] * @returns {ReturnType & { deviceId: string }} */ -function createTestServer(t, serverName = 'test server') { +function createTestServer(t, serverOptions) { + const serverName = + serverOptions?.serverName || TEST_SERVER_DEFAULTS.serverName const managerOptions = getManagerOptions(serverName) const km = new KeyManager(managerOptions.rootKey) const server = createServer({ ...managerOptions, - serverName, - serverPublicBaseUrl: 'http://localhost:' + PORT, - serverBearerToken: BEARER_TOKEN, + ...TEST_SERVER_DEFAULTS, + ...serverOptions, }) t.after(() => server.close()) Object.defineProperty(server, 'deviceId', { diff --git a/test-e2e/server.js b/test-e2e/server.js index 79e2d8aee..02b87e5a0 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -47,7 +47,7 @@ test('adding a server peer', async (t) => { ) assert.equal( serverPeer.selfHostedServerDetails?.baseUrl, - 'http://localhost:9876', + 'http://localhost:9876/', 'server peer stores base URL' ) }) @@ -142,12 +142,11 @@ async function createTestServer(t) { const server = createServer({ ...getManagerOptions('test server'), serverName: 'test server', - serverPublicBaseUrl: 'http://localhost:' + port, serverBearerToken: 'ignored', }) - const serverAddress = await server.listen({ port }) + await server.listen({ port }) t.after(() => server.close()) - return serverAddress + return `http://localhost:${port}` } /** From c5c4a35f2e6efe617accf2dedd97d42c96d1912c Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Tue, 8 Oct 2024 20:41:27 +0100 Subject: [PATCH 040/118] Add Dockerfile and fly.io deployment instructions and config (#894) * feat: use req.hostname instead of manually configured server host. * DRY: put baseURL on the request * use server.inject() with authority, rather than fetch * feat: allowedHosts option * remove unused server option * feat: Add Dockerfile * wip docker & fly * remove unused env * Move healthcheck out of routes - deployment specific * add allowedProjects option * don't allow same project to be added twice * support ALLOWED_PROJECTS in server script * add readme * remove rootkey config (this would create corrupt data) * remove unused dep * Update src/server/README.md Co-authored-by: Evan Hahn * Update src/server/README.md Co-authored-by: Evan Hahn * Apply suggestions from code review Co-authored-by: Evan Hahn * fix: dangerouslyAllow not dangerourlyEnforce * expose trustProxy * chore: add e2e tests on fly.io deployment --------- Co-authored-by: Evan Hahn --- .dockerignore | 16 ++ Dockerfile | 25 +++ fly.toml | 36 ++++ package-lock.json | 294 +++++++++++++++++++++++++++++---- package.json | 3 +- src/member-api.js | 5 +- src/server/README.md | 38 +++++ src/server/app.js | 11 +- src/server/routes.js | 42 +++-- src/server/server.js | 44 ++--- test-e2e/server-integration.js | 88 +++++++++- test-e2e/server.js | 47 +++++- 12 files changed, 577 insertions(+), 72 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 fly.toml create mode 100644 src/server/README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..5256b32a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# flyctl launch added from .gitignore +**/.DS_Store +**/node_modules +**/coverage +**/.tmp +**/tmp +**/proto/build +dist +!drizzle/**/*.sql +**/.eslintcache +**/docs/api/html/* +**/test/fixtures/config/*.zip + +# flyctl launch added from .husky/_/.gitignore +.husky/_/**/* +fly.toml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..e73311182 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Best practices from https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/ + +ARG NODE_VERSION=20.17.0 + +# --------------> The build image__ +FROM node:${NODE_VERSION} AS build +RUN apt-get update && apt-get install -y --no-install-recommends dumb-init +WORKDIR /usr/src/app +COPY package*.json /usr/src/app/ +# TODO: Remove this line when the package is published +COPY comapeo-schema-server.tgz /usr/src/app/ +RUN npm ci --omit=dev + +# --------------> The production image__ +FROM node:${NODE_VERSION}-bullseye-slim + +ENV NODE_ENV production +ENV PORT 8080 +EXPOSE 8080 +COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init +USER node +WORKDIR /usr/src/app +COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules +COPY --chown=node:node . /usr/src/app +CMD ["dumb-init", "node", "src/server/server.js"] diff --git a/fly.toml b/fly.toml new file mode 100644 index 000000000..cc24a0f86 --- /dev/null +++ b/fly.toml @@ -0,0 +1,36 @@ +# fly.toml app configuration file generated for comapeo-cloud on 2024-10-07T20:59:21+01:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'comapeo-cloud' +primary_region = 'ord' + +[env] + STORAGE_DIR = '/data' + +[build] + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = 'stop' + auto_start_machines = true + min_machines_running = 0 + max_machines_running = 1 + processes = ['app'] + +[[http_service.checks]] + grace_period = "10s" + interval = "30s" + method = "GET" + timeout = "5s" + path = "/healthcheck" + +[[vm]] + size = 'shared-cpu-1x' + +[mounts] + source = "myapp_data" + destination = "/data" + snapshot_retention = 14 diff --git a/package-lock.json b/package-lock.json index 6234657cb..a3af47f0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "cpy-cli": "^5.0.0", "drizzle-kit": "^0.20.14", "eslint": "^8.57.0", + "execa": "^9.4.0", "husky": "^8.0.0", "iterpal": "^0.4.0", "lint-staged": "^14.0.1", @@ -1307,6 +1308,12 @@ "version": "1.1.0", "license": "BSD-3-Clause" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true + }, "node_modules/@shikijs/core": { "version": "1.17.7", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.17.7.tgz", @@ -1363,6 +1370,18 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.29.6.tgz", "integrity": "sha512-aX5IFYWlMa7tQ8xZr3b2gtVReCvg7f3LEhjir/JAjX2bJCMVJA5tIPv30wTD4KDfcwMd7DDYY3hFDeGmOgtrZQ==" }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@sinonjs/commons": { "version": "2.0.0", "dev": true, @@ -3493,28 +3512,67 @@ } }, "node_modules/execa": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", - "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.4.0.tgz", + "integrity": "sha512-yKHlle2YGxZE842MERVIplWwNH5VYmqqcPFgtnlU//K8gxuFFXu0pwd/CrfXTumFpeEiufsP7+opT/bPJa1yVw==", "dev": true, "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.3", - "get-stream": "^6.0.1", - "human-signals": "^4.3.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^3.0.7", - "strip-final-newline": "^3.0.0" + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" }, "engines": { - "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/execa/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expand-template": { "version": "2.0.3", "license": "(MIT OR WTFPL)", @@ -3750,6 +3808,21 @@ "reusify": "^1.0.4" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "dev": true, @@ -3975,12 +4048,28 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4330,12 +4419,12 @@ } }, "node_modules/human-signals": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", - "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", + "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", "dev": true, "engines": { - "node": ">=14.18.0" + "node": ">=18.18.0" } }, "node_modules/husky": { @@ -4811,6 +4900,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "dev": true, @@ -5125,6 +5226,77 @@ "node": ">=16" } }, + "node_modules/lint-staged/node_modules/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lint-staged/node_modules/pidtree": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", @@ -5137,6 +5309,18 @@ "node": ">=0.10" } }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lint-staged/node_modules/yaml": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", @@ -6168,15 +6352,16 @@ } }, "node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "dependencies": { - "path-key": "^4.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6425,6 +6610,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -6646,6 +6843,21 @@ "node": ">= 0.8" } }, + "node_modules/pretty-ms": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz", + "integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==", + "dev": true, + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -7955,12 +8167,12 @@ } }, "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -8459,6 +8671,18 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unique-string": { "version": "3.0.0", "dev": true, @@ -8756,6 +8980,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.1.tgz", + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/z32": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/z32/-/z32-1.0.1.tgz", diff --git a/package.json b/package.json index e716f7ff8..1a04b62bd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "db:generate:project": "drizzle-kit generate:sqlite --schema src/schema/project.js --out drizzle/project", "db:generate:client": "drizzle-kit generate:sqlite --schema src/schema/client.js --out drizzle/client", "prepack": "npm run build:types", - "prepare": "husky install" + "prepare": "husky install || true" }, "files": [ "src", @@ -134,6 +134,7 @@ "cpy-cli": "^5.0.0", "drizzle-kit": "^0.20.14", "eslint": "^8.57.0", + "execa": "^9.4.0", "husky": "^8.0.0", "iterpal": "^0.4.0", "lint-staged": "^14.0.1", diff --git a/src/member-api.js b/src/member-api.js index 49c25edc0..c36d1b3bb 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -339,7 +339,10 @@ export class MemberApi extends TypedEmitter { const projectPublicId = projectKeyToPublicId(this.#projectKey) const websocketUrl = new URL('sync/' + projectPublicId, baseUrl) - websocketUrl.protocol = dangerouslyAllowInsecureConnections ? 'ws:' : 'wss:' + websocketUrl.protocol = + dangerouslyAllowInsecureConnections && websocketUrl.protocol === 'http:' + ? 'ws:' + : 'wss:' const websocket = new WebSocket(websocketUrl) const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) diff --git a/src/server/README.md b/src/server/README.md new file mode 100644 index 000000000..8ce9582c3 --- /dev/null +++ b/src/server/README.md @@ -0,0 +1,38 @@ +## Deploying CoMapeo Cloud + +CoMapeo Cloud comes with a [`Dockerfile`](../../Dockerfile) that can be used to build a Docker image. This image can be used to deploy CoMapeo Cloud on a server. + +Server configuration is done using environment variables. The following environment variables are available: + +| Environment Variable | Required | Description | Default Value | +| --------------------- | -------- | -------------------------------------------------------------------- | ---------------- | +| `SERVER_BEARER_TOKEN` | Yes | Token for authenticating API requests. Should be large random string | | +| `PORT` | No | Port on which the server runs | `8080` | +| `SERVER_NAME` | No | Friendly server name, seen by users when adding server | `CoMapeo Server` | +| `ALLOWED_PROJECTS` | No | Number of projects allowed to register with the server | `1` | +| `STORAGE_DIR` | No | Path for storing app & project data | `$CWD/data` | + +### Deploying with fly.io + +CoMapeo Cloud can be deployed on [fly.io](https://fly.io) using the following steps: + +1. Install the flyctl CLI tool by following the instructions [here](https://fly.io/docs/getting-started/installing-flyctl/). +2. Create a new app on fly.io by running `flyctl apps create`, take a note of the app name. +3. Set the SERVER_BEARER_TOKEN secret via: + ```sh + flyctl secrets set SERVER_BEARER_TOKEN= --app + ``` +4. Deploy the app by running (optionally setting the `ALLOWED_PROJECTS` environment variable): + ```sh + flyctl deploy --app -e ALLOWED_PROJECTS=10 + ``` +5. The app should now be running on fly.io. You can access it at `https://.fly.dev`. + +To destroy the app (delete all data and project invites), run: + +> [!WARNING] +> This action is irreversible and will permanently delete all data associated with the app, and projects that have already added the server will no longer be able to sync with it. + +```sh +flyctl destroy --app +``` diff --git a/src/server/app.js b/src/server/app.js index 6ae1e7578..6685793c4 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -7,17 +7,17 @@ import baseUrlPlugin from './base-url-plugin.js' import allowedHostsPlugin from './allowed-hosts-plugin.js' /** @import { FastifyServerOptions } from 'fastify' */ /** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ +/** @import { RouteOptions } from './routes.js' */ /** * @internal * @typedef {object} OtherServerOptions * @prop {FastifyServerOptions['logger']} [logger] - * @prop {string} serverBearerToken - * @prop {string} serverName + * @prop {FastifyServerOptions['trustProxy']} [trustProxy] * @prop {string[]} [allowedHosts] */ -/** @typedef {ComapeoPluginOptions & OtherServerOptions} ServerOptions */ +/** @typedef {ComapeoPluginOptions & OtherServerOptions & RouteOptions} ServerOptions */ /** * @param {ServerOptions} opts @@ -25,12 +25,14 @@ import allowedHostsPlugin from './allowed-hosts-plugin.js' */ export default function createServer({ logger, + trustProxy, serverBearerToken, serverName, allowedHosts, + allowedProjects = 1, ...comapeoPluginOpts }) { - const fastify = createFastify({ logger }) + const fastify = createFastify({ logger, trustProxy }) fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(allowedHostsPlugin, { allowedHosts }) @@ -39,6 +41,7 @@ export default function createServer({ fastify.register(routes, { serverBearerToken, serverName, + allowedProjects, }) return fastify } diff --git a/src/server/routes.js b/src/server/routes.js index 32160b963..e0cd1d082 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -2,6 +2,7 @@ import { Type } from '@sinclair/typebox' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' import timingSafeEqual from '../lib/timing-safe-equal.js' +import { projectKeyToPublicId } from '../utils.js' /** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ @@ -15,13 +16,18 @@ const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) * @typedef {object} RouteOptions * @prop {string} serverBearerToken * @prop {string} serverName + * @prop {string[] | number} [allowedProjects=1] */ /** @type {FastifyPluginAsync} */ export default async function routes( fastify, - { serverBearerToken, serverName } + { serverBearerToken, serverName, allowedProjects = 1 } ) { + /** @type {Set | number} */ + const allowedProjectsSetOrNumber = Array.isArray(allowedProjects) + ? new Set(allowedProjects) + : allowedProjects /** * @param {FastifyRequest} req */ @@ -106,14 +112,30 @@ export default async function routes( }, }, async function (req, reply) { - const hasExistingProject = (await this.comapeo.listProjects()).length > 0 - // TODO: Different error message, or 200 response, if a project with a - // matching projectToken already exists. - this.assert( - !hasExistingProject, - 403, - 'Server is already linked to a project' - ) + const projectKey = Buffer.from(req.body.projectKey, 'hex') + const projectPublicId = projectKeyToPublicId(projectKey) + const existingProjects = await this.comapeo.listProjects() + + if ( + typeof allowedProjectsSetOrNumber === 'number' && + existingProjects.length >= allowedProjectsSetOrNumber + ) { + throw fastify.httpErrors.forbidden( + 'Server is already linked to the maximum number of projects' + ) + } + + if ( + allowedProjectsSetOrNumber instanceof Set && + !allowedProjectsSetOrNumber.has(projectPublicId) + ) { + throw fastify.httpErrors.forbidden('Project not allowed') + } + + if (existingProjects.find((p) => p.projectId === projectPublicId)) { + throw fastify.httpErrors.badRequest('Project already exists') + } + const baseUrl = req.baseUrl.toString() const existingDeviceInfo = this.comapeo.getDeviceInfo() @@ -130,8 +152,6 @@ export default async function routes( }) } - const projectKey = Buffer.from(req.body.projectKey, 'hex') - const projectId = await this.comapeo.addProject( { projectKey, diff --git a/src/server/server.js b/src/server/server.js index bb20dbe9b..d6798faa6 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -11,7 +11,7 @@ const DB_DIR_NAME = 'db' const ROOT_KEY_FILE_NAME = 'root-key' const schema = Type.Object({ - PORT: Type.Number({ default: 3000 }), + PORT: Type.Number({ default: 8080 }), SERVER_NAME: Type.String({ description: 'name of the server', default: 'CoMapeo Server', @@ -27,10 +27,10 @@ const schema = Type.Object({ description: 'path to directory where data is stored', default: DEFAULT_STORAGE, }), - ROOT_KEY: Type.Optional( - Type.String({ - description: - 'hex-encoded 16-byte random secret key, used for server keypairs', + ALLOWED_PROJECTS: Type.Optional( + Type.Integer({ + minimum: 1, + description: 'number of projects allowed to join the server', }) ), }) @@ -56,23 +56,19 @@ await Promise.all([ /** @type {Buffer} */ let rootKey -if (config.ROOT_KEY) { - rootKey = Buffer.from(config.ROOT_KEY, 'hex') -} else { - try { - rootKey = await fsPromises.readFile(rootKeyFile) - } catch (err) { - if ( - typeof err === 'object' && - err && - 'code' in err && - err.code !== 'ENOENT' - ) { - throw err - } - rootKey = crypto.randomBytes(16) - await fsPromises.writeFile(rootKeyFile, rootKey) +try { + rootKey = await fsPromises.readFile(rootKeyFile) +} catch (err) { + if ( + typeof err === 'object' && + err && + 'code' in err && + err.code !== 'ENOENT' + ) { + throw err } + rootKey = crypto.randomBytes(16) + await fsPromises.writeFile(rootKeyFile, rootKey) } if (!rootKey || rootKey.length !== 16) { @@ -82,16 +78,20 @@ if (!rootKey || rootKey.length !== 16) { const fastify = createServer({ serverName: config.SERVER_NAME, serverBearerToken: config.SERVER_BEARER_TOKEN, + allowedProjects: config.ALLOWED_PROJECTS, rootKey, coreStorage, dbFolder, projectMigrationsFolder, clientMigrationsFolder, logger: true, + trustProxy: true, }) +fastify.get('/healthcheck', async () => {}) + try { - await fastify.listen({ port: config.PORT, host: '::' }) + await fastify.listen({ port: config.PORT, host: '0.0.0.0' }) } catch (err) { fastify.log.error(err) process.exit(1) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 5c8dbd6aa..b5a683285 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -140,16 +140,102 @@ test('trying to add second project fails', async (t) => { assert.deepEqual(response.json(), expectedResponseBody) }) - await t.test('attempt to add second project', async () => { + await t.test('attempt to add second project fails', async () => { const response = await server.inject({ method: 'POST', url: '/projects', body: randomProjectKeys(), }) assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/) }) }) +test('allowedProjects=3', async (t) => { + const server = createTestServer(t, { allowedProjects: 3 }) + + await t.test('add 3 projects', async () => { + for (let i = 0; i < 3; i++) { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 200) + } + }) + + await t.test('attempt to add 4th project fails', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/) + }) +}) + +test('trying to create the same project twice fails', async (t) => { + const server = createTestServer(t, { allowedProjects: 2 }) + + const projectKeys = randomProjectKeys() + + await t.test('add project first time succeeds', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 200) + }) + + await t.test('attempt to re-add same project fails', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 400) + assert.match(response.json().message, /already exists/) + }) +}) + +test('allowedProjects adding project in allow list', async (t) => { + const projectKeys = randomProjectKeys() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + const server = createTestServer(t, { + allowedProjects: [projectPublicId], + }) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + + assert.equal(response.statusCode, 200) +}) + +test('allowedProjects adding project not in allow list', async (t) => { + const allowedProjectId = projectKeyToPublicId( + Buffer.from(randomProjectKeys().projectKey, 'hex') + ) + const server = createTestServer(t, { + allowedProjects: [allowedProjectId], + }) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + + assert.equal(response.statusCode, 403) +}) + test('observations endpoint', async (t) => { const server = createTestServer(t) diff --git a/test-e2e/server.js b/test-e2e/server.js index 02b87e5a0..0ff1f9049 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -2,6 +2,7 @@ import { valueOf } from '@comapeo/schema' import { generate } from '@mapeo/mock-data' import assert from 'node:assert/strict' import test from 'node:test' +import { execa } from 'execa' import { MEMBER_ROLE_ID } from '../src/roles.js' import createServer from '../src/server/app.js' import { @@ -47,7 +48,7 @@ test('adding a server peer', async (t) => { ) assert.equal( serverPeer.selfHostedServerDetails?.baseUrl, - 'http://localhost:9876/', + serverBaseUrl, 'server peer stores base URL' ) }) @@ -132,11 +133,51 @@ test('data can be synced via a server', async (t) => { }) /** - * * @param {import('node:test').TestContext} t * @returns {Promise} server base URL */ async function createTestServer(t) { + if (process.env.REMOTE_TEST_SERVER) { + return createRemoteTestServer(t) + } else { + return createLocalTestServer(t) + } +} + +/** + * @param {import('node:test').TestContext} t + * @returns {Promise} server base URL + */ +async function createRemoteTestServer(t) { + const { stdout } = await execa( + 'fly', + ['apps', 'create', '--generate-name', '--org', 'digidem', '--json'], + { stderr: 'inherit' } + ) + const { ID: appName } = JSON.parse(stdout) + t.after(async () => { + await execa('fly', ['apps', 'destroy', appName, '-y'], { stdio: 'inherit' }) + }) + await execa( + 'fly', + ['secrets', 'set', 'SERVER_BEARER_TOKEN=ignored', '--app', appName], + { stdio: 'inherit' } + ) + await execa( + 'fly', + ['deploy', '--app', appName, '-e', 'SERVER_NAME=test server'], + { + stdio: 'inherit', + } + ) + return `https://${appName}.fly.dev/` +} + +/** + * @param {import('node:test').TestContext} t + * @returns {Promise} server base URL + */ +async function createLocalTestServer(t) { // TODO: Use a port that's guaranteed to be open const port = 9876 const server = createServer({ @@ -146,7 +187,7 @@ async function createTestServer(t) { }) await server.listen({ port }) t.after(() => server.close()) - return `http://localhost:${port}` + return `http://localhost:${port}/` } /** From 5d0a0aaabe553b67c5ff57bfff9c10998c58117c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 8 Oct 2024 20:34:39 +0000 Subject: [PATCH 041/118] Remove old environment variable --- src/server/server.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/server/server.js b/src/server/server.js index d6798faa6..1cbcd4b41 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -20,9 +20,6 @@ const schema = Type.Object({ description: 'Bearer token for accessing the server, can be any random string', }), - SERVER_PUBLIC_BASE_URL: Type.String({ - description: 'public base URL of the server', - }), STORAGE_DIR: Type.String({ description: 'path to directory where data is stored', default: DEFAULT_STORAGE, From 4a275d82272b5b7e6b6063dcc80c50b1433b73d6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 8 Oct 2024 21:05:24 +0000 Subject: [PATCH 042/118] Start sync when opening sync websocket --- src/server/routes.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/routes.js b/src/server/routes.js index e0cd1d082..f9aa185b2 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -84,6 +84,7 @@ export default async function routes( /** @type {any} */ (false) ) wsCoreReplicator(socket, replicationStream) + project.$sync.start() } ) From 350eb8a6e784dd31a01e0dc8ff91c86961eaafb7 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 8 Oct 2024 22:05:31 +0000 Subject: [PATCH 043/118] GET /projects to list projects --- src/server/routes.js | 30 +++++++++++++++++ test-e2e/server-integration.js | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/server/routes.js b/src/server/routes.js index f9aa185b2..9e31499bd 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -88,6 +88,36 @@ export default async function routes( } ) + fastify.get( + '/projects', + { + schema: { + response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + projectId: Type.String(), + }) + ), + }), + 403: { $ref: 'HttpError' }, + }, + }, + async preHandler(req) { + verifyBearerAuth(req) + }, + }, + async function (req, reply) { + const existingProjects = await this.comapeo.listProjects() + + reply.send({ + data: existingProjects.map(({ projectId }) => ({ projectId })), + }) + + return reply + } + ) + fastify.post( '/projects', { diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index b5a683285..656a395de 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -108,6 +108,65 @@ test('no project added, sync endpoint not available', async (t) => { assert.equal(response.json().error, 'Not Found') }) +test('listing projects', async (t) => { + const server = createTestServer(t, { allowedProjects: 999 }) + + await t.test('with invalid auth', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) + }) + + await t.test('with no projects', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { data: [] }) + }) + + await t.test('with projects', async () => { + const projectKeys1 = randomProjectKeys() + const projectKeys2 = randomProjectKeys() + + await Promise.all( + [projectKeys1, projectKeys2].map(async (projectKeys) => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 200) + }) + ) + + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = response.json() + assert(Array.isArray(data)) + assert.equal(data.length, 2, 'expected 2 projects') + for (const projectKeys of [projectKeys1, projectKeys2]) { + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + assert( + data.some((project) => project.projectId === projectPublicId), + `expected ${projectPublicId} to be found` + ) + } + }) +}) + test('invalid project public id', async (t) => { const server = createTestServer(t) From e47b7433e50e0525add58fde1a8dc8dc02d1e824 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 9 Oct 2024 00:26:40 +0100 Subject: [PATCH 044/118] chore: add CI workflow for server e2e tests (#895) --- .github/workflows/fly-cleanup.yml | 28 ++++++++++++++++++++++++++++ .github/workflows/server-test.yml | 26 ++++++++++++++++++++++++++ fly.toml | 2 +- test-e2e/server.js | 18 ++++++++++-------- 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/fly-cleanup.yml create mode 100644 .github/workflows/server-test.yml diff --git a/.github/workflows/fly-cleanup.yml b/.github/workflows/fly-cleanup.yml new file mode 100644 index 000000000..8b987276b --- /dev/null +++ b/.github/workflows/fly-cleanup.yml @@ -0,0 +1,28 @@ +# Cleans up orphaned test apps on Fly +# TODO: check app creation date - could destroy an app during a test run + +name: Fly Cleanup +on: + workflow_dispatch: + schedule: + - cron: '0 5 * * *' # Every day at 5am UTC +jobs: + cleanup: + name: Cleanup Orphaned Apps + runs-on: ubuntu-latest + concurrency: deploy-group # optional: ensure only one action runs at a time + steps: + - uses: actions/checkout@v4 + - uses: superfly/flyctl-actions/setup-flyctl@master + - env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + run: | + fly apps list -q -o digidem | while IFS= read -r name; do + # Trim leading and trailing whitespace from $name + name=$(echo "$name" | xargs) + # Check if the name starts with 'comapeo-cloud-test-' + if [[ $name == comapeo-cloud-test-* ]]; then + # Call the fly destroy command with the name + fly apps destroy -y "$name" + fi + done diff --git a/.github/workflows/server-test.yml b/.github/workflows/server-test.yml new file mode 100644 index 000000000..bde164447 --- /dev/null +++ b/.github/workflows/server-test.yml @@ -0,0 +1,26 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Server e2e cloud test + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - uses: superfly/flyctl-actions/setup-flyctl@master + - run: npm ci + - run: npm run build --if-present + - run: node --test ./test-e2e/server.js + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + REMOTE_TEST_SERVER: true diff --git a/fly.toml b/fly.toml index cc24a0f86..367ed43f8 100644 --- a/fly.toml +++ b/fly.toml @@ -4,7 +4,7 @@ # app = 'comapeo-cloud' -primary_region = 'ord' +primary_region = 'iad' [env] STORAGE_DIR = '/data' diff --git a/test-e2e/server.js b/test-e2e/server.js index 0ff1f9049..d17584a55 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -149,22 +149,24 @@ async function createTestServer(t) { * @returns {Promise} server base URL */ async function createRemoteTestServer(t) { - const { stdout } = await execa( - 'fly', - ['apps', 'create', '--generate-name', '--org', 'digidem', '--json'], - { stderr: 'inherit' } + const appName = 'comapeo-cloud-test-' + Math.random().toString(36).slice(8) + await execa( + 'flyctl', + ['apps', 'create', '--name', appName, '--org', 'digidem', '--json'], + { stdio: 'inherit' } ) - const { ID: appName } = JSON.parse(stdout) t.after(async () => { - await execa('fly', ['apps', 'destroy', appName, '-y'], { stdio: 'inherit' }) + await execa('flyctl', ['apps', 'destroy', appName, '-y'], { + stdio: 'inherit', + }) }) await execa( - 'fly', + 'flyctl', ['secrets', 'set', 'SERVER_BEARER_TOKEN=ignored', '--app', appName], { stdio: 'inherit' } ) await execa( - 'fly', + 'flyctl', ['deploy', '--app', appName, '-e', 'SERVER_NAME=test server'], { stdio: 'inherit', From d6dbf2b2b753a2fd854ecf706d4f02272490a1a5 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 9 Oct 2024 15:51:13 +0100 Subject: [PATCH 045/118] fix: avoid uncaught websocket errors (#897) --- src/member-api.js | 2 ++ src/sync/sync-api.js | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/member-api.js b/src/member-api.js index c36d1b3bb..6b4a21ed2 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -344,6 +344,7 @@ export class MemberApi extends TypedEmitter { ? 'ws:' : 'wss:' const websocket = new WebSocket(websocketUrl) + websocket.on('error', noop) const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) @@ -357,6 +358,7 @@ export class MemberApi extends TypedEmitter { websocket.close() await once(websocket, 'close') + websocket.off('error', noop) } /** diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 03661b86b..6a344d778 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -312,6 +312,8 @@ export class SyncApi extends TypedEmitter { } const websocket = new WebSocket(url) + // TODO: Handle websocket errors + websocket.on('error', noop) // TODO: Handle errors (maybe with the `unexpected-response` event?) @@ -320,6 +322,7 @@ export class SyncApi extends TypedEmitter { this.#serverWebsockets.set(url, websocket) websocket.once('close', () => { + websocket.off('error', noop) this.#serverWebsockets.delete(url) }) } From 4fd5039a9deaad5a32cf265aa6e9d557ec1a183c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 15:07:39 +0000 Subject: [PATCH 046/118] GET /projects should return project name --- src/server/routes.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server/routes.js b/src/server/routes.js index 9e31499bd..2a3e5e364 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -97,6 +97,7 @@ export default async function routes( data: Type.Array( Type.Object({ projectId: Type.String(), + name: Type.String(), }) ), }), @@ -111,7 +112,10 @@ export default async function routes( const existingProjects = await this.comapeo.listProjects() reply.send({ - data: existingProjects.map(({ projectId }) => ({ projectId })), + data: existingProjects.map((project) => ({ + projectId: project.projectId, + name: project.name, + })), }) return reply From 619894bae2cebad30616448d769b57d3cbad0a27 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 16:45:01 +0000 Subject: [PATCH 047/118] Remove an already-addressed TODO --- src/server/routes.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/routes.js b/src/server/routes.js index 2a3e5e364..d281cd85e 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -243,7 +243,6 @@ export default async function routes( lon: obs.lon, attachments: obs.attachments.map((attachment) => ({ url: new URL( - // TODO: Support other variants `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, req.baseUrl ), From df60da63f7ec5032f816333e3e76753545b266a5 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 16:48:51 +0000 Subject: [PATCH 048/118] Proxy all headers through with attachments endpoint --- src/server/routes.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index d281cd85e..952aff8ce 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -294,12 +294,9 @@ export default async function routes( const proxiedResponse = await fetch(blobUrl) reply.code(proxiedResponse.status) - // TODO: Are there other headers we want to pass through? - reply.header('Content-Type', proxiedResponse.headers.get('content-type')) - reply.header( - 'Content-Length', - proxiedResponse.headers.get('content-length') - ) + for (const [headerName, headerValue] of proxiedResponse.headers) { + reply.header(headerName, headerValue) + } return reply.send(proxiedResponse.body) } ) From ac18cef6b7dec64b17ddd4f3d53a82cd294c80ff Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 19:38:36 +0000 Subject: [PATCH 049/118] Add `deleted` field to GET /observations --- src/server/routes.js | 29 ++++++++++++++++------------- test-e2e/server-integration.js | 14 ++++++++++++-- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 952aff8ce..e0d0485d3 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -235,19 +235,22 @@ export default async function routes( const project = await this.comapeo.getProject(projectPublicId) reply.send({ - data: (await project.observation.getMany()).map((obs) => ({ - docId: obs.docId, - createdAt: obs.createdAt, - updatedAt: obs.updatedAt, - lat: obs.lat, - lon: obs.lon, - attachments: obs.attachments.map((attachment) => ({ - url: new URL( - `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, - req.baseUrl - ), - })), - })), + data: (await project.observation.getMany({ includeDeleted: true })).map( + (obs) => ({ + docId: obs.docId, + createdAt: obs.createdAt, + updatedAt: obs.updatedAt, + deleted: obs.deleted, + lat: obs.lat, + lon: obs.lon, + attachments: obs.attachments.map((attachment) => ({ + url: new URL( + `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, + req.baseUrl + ), + })), + }) + ), }) } ) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 656a395de..94e76b188 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -351,6 +351,12 @@ test('observations endpoint', async (t) => { } return project.observation.create(noAttachments) })(), + (async () => { + const { docId } = await project.observation.create( + valueOf(generate('observation')[0]) + ) + return project.observation.delete(docId) + })(), (async () => { const blob = await project.$blobs.create( { @@ -382,7 +388,7 @@ test('observations endpoint', async (t) => { const { data } = await response.json() - assert.equal(data.length, 2) + assert.equal(data.length, 3) await Promise.all( observations.map(async (observation) => { @@ -395,7 +401,11 @@ test('observations endpoint', async (t) => { assert.equal(observationFromApi.updatedAt, observation.updatedAt) assert.equal(observationFromApi.lat, observation.lat) assert.equal(observationFromApi.lon, observation.lon) - await assertAttachmentsCanBeFetched({ server, observationFromApi }) + assert.equal(observationFromApi.deleted, observation.deleted) + + if (!observationFromApi.deleted) { + await assertAttachmentsCanBeFetched({ server, observationFromApi }) + } }) ) } From be6dfe78774bfad33b993939f7bd9877b9d34f88 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 19:40:24 +0000 Subject: [PATCH 050/118] Add response schema for GET /observations --- src/server/routes.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/server/routes.js b/src/server/routes.js index e0d0485d3..a355ae14f 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -221,6 +221,23 @@ export default async function routes( projectPublicId: BASE32_STRING_32_BYTES, }), response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + docId: Type.String(), + createdAt: Type.String(), + updatedAt: Type.String(), + deleted: Type.Boolean(), + lat: Type.Optional(Type.Number()), + lon: Type.Optional(Type.Number()), + attachments: Type.Array( + Type.Object({ + url: Type.String(), + }) + ), + }) + ), + }), 403: { $ref: 'HttpError' }, 404: { $ref: 'HttpError' }, }, From 7f4f12980a8052455cea36db27ccb56cca04535d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 19:46:06 +0000 Subject: [PATCH 051/118] GET /observations should include tags --- src/server/routes.js | 18 ++++++++++++++++++ test-e2e/server-integration.js | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/server/routes.js b/src/server/routes.js index a355ae14f..d8995b416 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -235,6 +235,23 @@ export default async function routes( url: Type.String(), }) ), + tags: Type.Record( + Type.String(), + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + Type.Array( + Type.Union([ + Type.Boolean(), + Type.Number(), + Type.String(), + Type.Null(), + ]) + ), + ]) + ), }) ), }), @@ -266,6 +283,7 @@ export default async function routes( req.baseUrl ), })), + tags: obs.tags, }) ), }) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 94e76b188..79f25189e 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -402,10 +402,10 @@ test('observations endpoint', async (t) => { assert.equal(observationFromApi.lat, observation.lat) assert.equal(observationFromApi.lon, observation.lon) assert.equal(observationFromApi.deleted, observation.deleted) - if (!observationFromApi.deleted) { await assertAttachmentsCanBeFetched({ server, observationFromApi }) } + assert.deepEqual(observationFromApi.tags, observation.tags) }) ) } From 6d0a947fe9b2b1e193bb183e561093056f84645e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 19:52:44 +0000 Subject: [PATCH 052/118] Remove/move some TODOs --- src/mapeo-manager.js | 12 +++++------- src/server/comapeo-plugin.js | 7 +------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/mapeo-manager.js b/src/mapeo-manager.js index 78e31760f..bdddabc38 100644 --- a/src/mapeo-manager.js +++ b/src/mapeo-manager.js @@ -106,10 +106,10 @@ export class MapeoManager extends TypedEmitter { /** @type {string} */ #projectMigrationsFolder #deviceId - #localPeers // TODO(evanhahn) maybe should be null/undefined for servers - #invite // TODO(evanhahn) maybe should be null/undefined for servers - #fastify // TODO(evanhahn) maybe should be null/undefined for servers - #localDiscovery // TODO(evanhahn) maybe should be null/undefined for servers + #localPeers + #invite + #fastify + #localDiscovery #loggerBase #l #defaultConfigPath @@ -758,16 +758,14 @@ export class MapeoManager extends TypedEmitter { }) ) - // TODO(evanhahn) if (deviceInfo.deviceType !== 'selfHostedServer') { await Promise.all( this.#localPeers.peers .filter(({ status }) => status === 'connected') .map((peer) => - // TODO(evanhahn) TypeScript isn't smart enough to know that - // deviceInfo is okay here? this.#localPeers.sendDeviceInfo( peer.deviceId, + // TODO TypeScript isn't smart enough to know that deviceInfo is okay here? /** @type {any} */ (deviceInfo) ) ) diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index 9e352807e..b5f736308 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -7,12 +7,7 @@ import createFastifyPlugin from 'fastify-plugin' /** @type {import('fastify').FastifyPluginAsync} */ const comapeoPlugin = async function (fastify, opts) { - const comapeo = new MapeoManager({ - ...opts, - fastify, - // TODO(evanhahn) - // deviceType: 'selfHostedServer', - }) + const comapeo = new MapeoManager({ ...opts, fastify }) fastify.decorate('comapeo', comapeo) } From 7cc3a1f5dd2c4da3e2a4a7620cb7cbd306d08075 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 19:59:19 +0000 Subject: [PATCH 053/118] Clean up project public key in MapeoProject --- src/mapeo-project.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 93373031c..5706dc824 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -72,8 +72,7 @@ const EMPTY_PROJECT_SETTINGS = Object.freeze({}) * @extends {TypedEmitter<{ close: () => void }>} */ export class MapeoProject extends TypedEmitter { - #projectId - #projectPublicId + #projectKey #deviceId #identityKeypair #coreManager @@ -129,7 +128,7 @@ export class MapeoProject extends TypedEmitter { this.#l = Logger.create('project', logger) this.#deviceId = getDeviceId(keyManager) - this.#projectId = projectKeyToId(projectKey) + this.#projectKey = projectKey this.#loadingConfig = false const getReplicationStream = this[kProjectReplicate].bind( @@ -318,10 +317,6 @@ export class MapeoProject extends TypedEmitter { }, }) - const projectPublicId = projectKeyToPublicId(projectKey) - // TODO: clean this up - this.#projectPublicId = projectPublicId - this.#blobStore = new BlobStore({ coreManager: this.#coreManager, }) @@ -333,7 +328,7 @@ export class MapeoProject extends TypedEmitter { if (!base.endsWith('/')) { base += '/' } - return base + projectPublicId + return base + this.#projectPublicId }, }) @@ -345,7 +340,7 @@ export class MapeoProject extends TypedEmitter { if (!base.endsWith('/')) { base += '/' } - return base + projectPublicId + return base + this.#projectPublicId }, }) @@ -446,6 +441,14 @@ export class MapeoProject extends TypedEmitter { return this.#deviceId } + get #projectId() { + return projectKeyToId(this.#projectKey) + } + + get #projectPublicId() { + return projectKeyToPublicId(this.#projectKey) + } + /** * Resolves when hypercores have all loaded * From bb74456ee4f11ba45664309b53bd9bd8af2db34b Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 20:11:34 +0000 Subject: [PATCH 054/118] Split addServerPeer into smaller methods This change should have no functionality impact. --- src/member-api.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 6b4a21ed2..192fbd591 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -278,6 +278,22 @@ export class MemberApi extends TypedEmitter { 'Base URL is invalid' ) + const { deviceId } = await this.#addServerToProject(baseUrl) + + const roleId = MEMBER_ROLE_ID + await this.#roles.assignRole(deviceId, roleId) + + await this.#waitForInitialSyncWithServer( + baseUrl, + dangerouslyAllowInsecureConnections + ) + } + + /** + * @param {string} baseUrl Server base URL. Should already be validated. + * @returns {Promise<{ deviceId: string }>} + */ + async #addServerToProject(baseUrl) { const requestUrl = new URL('projects', baseUrl) const requestBody = { projectKey: encodeBufferForServer(this.#projectKey), @@ -314,7 +330,6 @@ export class MemberApi extends TypedEmitter { ) } - /** @type {string} */ let deviceId try { const responseBody = await response.json() assert( @@ -327,22 +342,31 @@ export class MemberApi extends TypedEmitter { typeof responseBody.data.deviceId === 'string', 'Response body is valid' ) - ;({ deviceId } = responseBody.data) + const { deviceId } = responseBody.data + return { deviceId } } catch (err) { throw new Error( "Failed to add server peer because we couldn't parse the response" ) } + } - const roleId = MEMBER_ROLE_ID - await this.#roles.assignRole(deviceId, roleId) - + /** + * @param {string} baseUrl + * @param {boolean} dangerouslyAllowInsecureConnections + * @returns {Promise} + */ + async #waitForInitialSyncWithServer( + baseUrl, + dangerouslyAllowInsecureConnections + ) { const projectPublicId = projectKeyToPublicId(this.#projectKey) const websocketUrl = new URL('sync/' + projectPublicId, baseUrl) websocketUrl.protocol = dangerouslyAllowInsecureConnections && websocketUrl.protocol === 'http:' ? 'ws:' : 'wss:' + const websocket = new WebSocket(websocketUrl) websocket.on('error', noop) const replicationStream = this.#getReplicationStream() From 7449eb517d48d00b5633def8ce27c86c0a71f811 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 20:37:30 +0000 Subject: [PATCH 055/118] Wait for initial sync with server, not all peers --- src/lib/get-own.js | 10 +++++++++ src/mapeo-project.js | 10 ++++++--- src/member-api.js | 45 +++++++++++++++++++---------------------- src/sync/sync-api.js | 48 ++++++++++++++++++++++++++++++++++++++++++++ test/lib/get-own.js | 23 +++++++++++++++++++++ 5 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 src/lib/get-own.js create mode 100644 test/lib/get-own.js diff --git a/src/lib/get-own.js b/src/lib/get-own.js new file mode 100644 index 000000000..02c07f04b --- /dev/null +++ b/src/lib/get-own.js @@ -0,0 +1,10 @@ +/** + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {K} key + * @returns {undefined | T[K]} + */ +export function getOwn(obj, key) { + return Object.hasOwn(obj, key) ? obj[key] : undefined +} diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 5706dc824..d2cb3030c 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -44,7 +44,11 @@ import { valueOf, } from './utils.js' import { MemberApi } from './member-api.js' -import { SyncApi, kHandleDiscoveryKey } from './sync/sync-api.js' +import { + SyncApi, + kHandleDiscoveryKey, + kWaitForInitialSyncWithPeer, +} from './sync/sync-api.js' import { Logger } from './logger.js' import { IconApi } from './icon-api.js' import { readConfig } from './config-import.js' @@ -309,8 +313,8 @@ export class MapeoProject extends TypedEmitter { projectKey, rpc: localPeers, getReplicationStream, - // TODO: This should be scoped to a single peer, not all peers - waitForInitialSync: () => this.$sync.waitForSync('initial'), + waitForInitialSyncWithPeer: (deviceId) => + this.$sync[kWaitForInitialSyncWithPeer](deviceId), dataTypes: { deviceInfo: this.#dataTypes.deviceInfo, project: this.#dataTypes.projectSettings, diff --git a/src/member-api.js b/src/member-api.js index 192fbd591..9e15e5649 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -54,7 +54,7 @@ export class MemberApi extends TypedEmitter { #projectKey #rpc #getReplicationStream - #waitForInitialSync + #waitForInitialSyncWithPeer #dataTypes /** @type {Map} */ @@ -69,7 +69,7 @@ export class MemberApi extends TypedEmitter { * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc * @param {() => ReplicationStream} opts.getReplicationStream - * @param {() => Promise} opts.waitForInitialSync + * @param {(deviceId: string) => Promise} opts.waitForInitialSyncWithPeer * @param {Object} opts.dataTypes * @param {Pick} opts.dataTypes.deviceInfo * @param {Pick} opts.dataTypes.project @@ -82,7 +82,7 @@ export class MemberApi extends TypedEmitter { projectKey, rpc, getReplicationStream, - waitForInitialSync, + waitForInitialSyncWithPeer, dataTypes, }) { super() @@ -93,7 +93,7 @@ export class MemberApi extends TypedEmitter { this.#projectKey = projectKey this.#rpc = rpc this.#getReplicationStream = getReplicationStream - this.#waitForInitialSync = waitForInitialSync + this.#waitForInitialSyncWithPeer = waitForInitialSyncWithPeer this.#dataTypes = dataTypes } @@ -278,20 +278,21 @@ export class MemberApi extends TypedEmitter { 'Base URL is invalid' ) - const { deviceId } = await this.#addServerToProject(baseUrl) + const { serverDeviceId } = await this.#addServerToProject(baseUrl) const roleId = MEMBER_ROLE_ID - await this.#roles.assignRole(deviceId, roleId) + await this.#roles.assignRole(serverDeviceId, roleId) - await this.#waitForInitialSyncWithServer( + await this.#waitForInitialSyncWithServer({ baseUrl, - dangerouslyAllowInsecureConnections - ) + serverDeviceId, + dangerouslyAllowInsecureConnections, + }) } /** * @param {string} baseUrl Server base URL. Should already be validated. - * @returns {Promise<{ deviceId: string }>} + * @returns {Promise<{ serverDeviceId: string }>} */ async #addServerToProject(baseUrl) { const requestUrl = new URL('projects', baseUrl) @@ -342,8 +343,7 @@ export class MemberApi extends TypedEmitter { typeof responseBody.data.deviceId === 'string', 'Response body is valid' ) - const { deviceId } = responseBody.data - return { deviceId } + return { serverDeviceId: responseBody.data.deviceId } } catch (err) { throw new Error( "Failed to add server peer because we couldn't parse the response" @@ -352,14 +352,17 @@ export class MemberApi extends TypedEmitter { } /** - * @param {string} baseUrl - * @param {boolean} dangerouslyAllowInsecureConnections + * @param {object} options + * @param {string} options.baseUrl + * @param {string} options.serverDeviceId + * @param {boolean} options.dangerouslyAllowInsecureConnections * @returns {Promise} */ - async #waitForInitialSyncWithServer( + async #waitForInitialSyncWithServer({ baseUrl, - dangerouslyAllowInsecureConnections - ) { + serverDeviceId, + dangerouslyAllowInsecureConnections, + }) { const projectPublicId = projectKeyToPublicId(this.#projectKey) const websocketUrl = new URL('sync/' + projectPublicId, baseUrl) websocketUrl.protocol = @@ -372,13 +375,7 @@ export class MemberApi extends TypedEmitter { const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) - // TODO: remove this - await new Promise((resolve) => { - setTimeout(resolve, 3000) - }) - - // TODO: This should be scoped to a single peer - await this.#waitForInitialSync() + await this.#waitForInitialSyncWithPeer(serverDeviceId) websocket.close() await once(websocket, 'close') diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 6a344d778..e79388cc8 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -9,6 +9,7 @@ import { PRESYNC_NAMESPACES, } from '../constants.js' import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js' +import { getOwn } from '../lib/get-own.js' import { NO_ROLE_ID } from '../roles.js' import { wsCoreReplicator } from '../server/ws-core-replicator.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ @@ -20,6 +21,9 @@ export const kHandleDiscoveryKey = Symbol('handle discovery key') export const kSyncState = Symbol('sync state') export const kRequestFullStop = Symbol('background') export const kRescindFullStopRequest = Symbol('foreground') +export const kWaitForInitialSyncWithPeer = Symbol( + 'wait for initial sync with peer' +) /** * @typedef {'initial' | 'full'} SyncType @@ -414,6 +418,25 @@ export class SyncApi extends TypedEmitter { }) } + /** + * @param {string} deviceId + * @returns {Promise} + */ + async [kWaitForInitialSyncWithPeer](deviceId) { + const state = this[kSyncState].getState() + if (isInitiallySyncedWithPeer(state, deviceId)) return + return new Promise((resolve) => { + /** @param {import('./sync-state.js').State} state */ + const onState = (state) => { + if (isInitiallySyncedWithPeer(state, deviceId)) { + this[kSyncState].off('state', onState) + resolve() + } + } + this[kSyncState].on('state', onState) + }) + } + #clearAutostopDataSyncTimeoutIfExists() { if (this.#autostopDataSyncTimeout) { clearTimeout(this.#autostopDataSyncTimeout) @@ -590,6 +613,31 @@ function isSynced(state, type, peerSyncControllers) { return true } +/** + * @param {import('./sync-state.js').State} state + * @param {string} peerId + */ +function isInitiallySyncedWithPeer(state, peerId) { + for (const ns of PRESYNC_NAMESPACES) { + const remoteDeviceSyncState = getOwn(state[ns].remoteStates, peerId) + if (!remoteDeviceSyncState) return false + + switch (remoteDeviceSyncState.status) { + case 'starting': + return false + case 'started': + case 'stopped': { + const { want, wanted } = remoteDeviceSyncState + if (want || wanted) return false + break + } + default: + throw new ExhaustivenessError(remoteDeviceSyncState.status) + } + } + return true +} + /** * @param {import('./sync-state.js').State} namespaceSyncState * @param {Iterable} peerSyncControllers diff --git a/test/lib/get-own.js b/test/lib/get-own.js new file mode 100644 index 000000000..40a83b900 --- /dev/null +++ b/test/lib/get-own.js @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { getOwn } from '../../src/lib/get-own.js' + +test('getOwn', () => { + class Foo { + ownProperty = 123 + inheritedProperty() { + return 789 + } + } + const foo = new Foo() + assert.equal(getOwn(foo, 'ownProperty'), 123) + assert.equal(getOwn(foo, 'inheritedProperty'), undefined) + assert.equal(getOwn(foo, /** @type {any} */ ('hasOwnProperty')), undefined) + assert.equal(getOwn(foo, /** @type {any} */ ('garbage')), undefined) + + const nullProto = Object.create(null) + nullProto.foo = 123 + assert.equal(getOwn(nullProto, 'foo'), 123) + assert.equal(getOwn(nullProto, 'garbage'), undefined) + assert.equal(getOwn(nullProto, 'hasOwnProperty'), undefined) +}) From 9aaeb37910821a6779ec8eef88af995e94448ba3 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 20:53:20 +0000 Subject: [PATCH 056/118] waitForSyncWithServer shouldn't just wait --- test-e2e/server.js | 51 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index d17584a55..84519849d 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,8 +1,9 @@ import { valueOf } from '@comapeo/schema' import { generate } from '@mapeo/mock-data' +import { execa } from 'execa' import assert from 'node:assert/strict' import test from 'node:test' -import { execa } from 'execa' +import { pEvent } from 'p-event' import { MEMBER_ROLE_ID } from '../src/roles.js' import createServer from '../src/server/app.js' import { @@ -15,6 +16,8 @@ import { waitForSync, } from './utils.js' /** @import { MapeoManager } from '../src/mapeo-manager.js' */ +/** @import { MapeoProject } from '../src/mapeo-project.js' */ +/** @import { State as SyncState } from '../src/sync/sync-api.js' */ // TODO: test invalid base URL // TODO: test bad requests @@ -87,6 +90,15 @@ test('data can be synced via a server', async (t) => { await managerAProject.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) + const serverDeviceIdPromise = managerAProject.$member + .getMany() + .then((members) => { + const serverMember = members.find( + (member) => member.deviceType === 'selfHostedServer' + ) + assert(serverMember, 'Manager A must have a server member') + return serverMember.deviceId + }) // Add Manager B to project const disconnectManagers = connectPeers(managers) @@ -114,7 +126,8 @@ test('data can be synced via a server', async (t) => { const observation = await managerAProject.observation.create( valueOf(generate('observation')[0]) ) - await waitForSyncWithServer() + const serverDeviceId = await serverDeviceIdPromise + await waitForSyncWithServer(managerAProject, serverDeviceId) managerAProject.$sync.disconnectServers() managerAProject.$sync.stop() await assert.rejects( @@ -125,7 +138,7 @@ test('data can be synced via a server', async (t) => { // Manager B sees observation after syncing managerBProject.$sync.connectServers() managerBProject.$sync.start() - await waitForSyncWithServer() + await waitForSyncWithServer(managerBProject, serverDeviceId) assert( await managerBProject.observation.getByDocId(observation.docId), 'manager B now sees data' @@ -212,9 +225,31 @@ function waitForNoPeersToBeConnected(manager) { }) } -function waitForSyncWithServer() { - // TODO: This is fake! - return new Promise((resolve) => { - setTimeout(resolve, 3000) - }) +/** + * @param {MapeoProject} project + * @param {string} serverDeviceId + * @returns {Promise} + */ +async function waitForSyncWithServer(project, serverDeviceId) { + const initialState = project.$sync.getState() + if (isSyncedWithServer(initialState, serverDeviceId)) return + await pEvent(project.$sync, 'sync-state', (state) => + isSyncedWithServer(state, serverDeviceId) + ) +} + +/** + * @param {SyncState} syncState + * @param {string} serverDeviceId + * @returns {boolean} + */ +function isSyncedWithServer(syncState, serverDeviceId) { + const serverSyncState = syncState.remoteDeviceSyncState[serverDeviceId] + return Boolean( + serverSyncState && + serverSyncState.initial.want === 0 && + serverSyncState.initial.wanted === 0 && + serverSyncState.data.want === 0 && + serverSyncState.data.wanted === 0 + ) } From 9135128ffdc1fe82cabcece756bc2bd12459cdd4 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 21:34:35 +0000 Subject: [PATCH 057/118] Improve validation of server base URLs --- src/lib/is-hostname-ip-address.js | 26 +++++++++++++++++ src/member-api.js | 14 ++++++++- test-e2e/server.js | 46 +++++++++++++++++++++++++++++- test/lib/is-hostname-ip-address.js | 29 +++++++++++++++++++ 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 src/lib/is-hostname-ip-address.js create mode 100644 test/lib/is-hostname-ip-address.js diff --git a/src/lib/is-hostname-ip-address.js b/src/lib/is-hostname-ip-address.js new file mode 100644 index 000000000..3e9acf86b --- /dev/null +++ b/src/lib/is-hostname-ip-address.js @@ -0,0 +1,26 @@ +import { isIPv4, isIPv6 } from 'node:net' + +/** + * Is this hostname an IP address? + * + * @param {string} hostname + * @returns {boolean} + * @example + * isHostnameIpAddress('100.64.0.42') + * // => false + * + * isHostnameIpAddress('[2001:0db8:85a3:0000:0000:8a2e:0370:7334]') + * // => true + * + * isHostnameIpAddress('example.com') + * // => false + */ +export function isHostnameIpAddress(hostname) { + if (isIPv4(hostname)) return true + + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return isIPv6(hostname.slice(1, -1)) + } + + return false +} diff --git a/src/member-api.js b/src/member-api.js index 9e15e5649..c3a3566d3 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -15,6 +15,7 @@ import { import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' +import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' import { once } from 'node:events' @@ -474,6 +475,8 @@ function isValidServerBaseUrl( baseUrl, { dangerouslyAllowInsecureConnections } ) { + if (baseUrl.length > 2000) return false + /** @type {URL} */ let url try { url = new URL(baseUrl) @@ -486,7 +489,16 @@ function isValidServerBaseUrl( (dangerouslyAllowInsecureConnections && url.protocol === 'http:') if (!isProtocolValid) return false - // TODO: Validate that username/password is missing? + if (url.username) return false + if (url.password) return false + if (url.search) return false + if (url.hash) return false + + if (!isHostnameIpAddress(url.hostname)) { + const parts = url.hostname.split('.') + const isDomainValid = parts.length >= 2 && parts.every(Boolean) + if (!isDomainValid) return false + } return true } diff --git a/test-e2e/server.js b/test-e2e/server.js index 84519849d..91c27ed79 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -19,10 +19,54 @@ import { /** @import { MapeoProject } from '../src/mapeo-project.js' */ /** @import { State as SyncState } from '../src/sync/sync-api.js' */ -// TODO: test invalid base URL // TODO: test bad requests // TODO: test other base URLs +test('invalid base URLs', async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + const invalidUrls = [ + '', + 'no-protocol.example', + 'ftp://invalid-protocol.example', + 'http://invalid-protocol.example', + 'https:', + 'https://', + 'https://.', + 'https://..', + 'https://https://', + 'https://https://double-protocol.example', + 'https://bare-domain', + 'https://bare-domain:1234', + 'https://empty-part.', + 'https://.empty-part', + 'https://spaces .in-part', + 'https://spaces.in part', + 'https://bad-port.example:-1', + 'https://username@has-auth.example', + 'https://username:password@has-auth.example', + 'https://has-query.example/?foo=bar', + 'https://has-hash.example/#hash', + `https://${'x'.repeat(2000)}.example`, + ] + await Promise.all( + invalidUrls.map((url) => + assert.rejects( + () => project.$member.addServerPeer(url), + /base url is invalid/i, + `${url} should be invalid` + ) + ) + ) + + const hasServerPeer = (await project.$member.getMany()).some( + (member) => member.deviceType === 'selfHostedServer' + ) + assert(!hasServerPeer, 'no server peers should be added') +}) + test('adding a server peer', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() diff --git a/test/lib/is-hostname-ip-address.js b/test/lib/is-hostname-ip-address.js new file mode 100644 index 000000000..9af96b075 --- /dev/null +++ b/test/lib/is-hostname-ip-address.js @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isHostnameIpAddress } from '../../src/lib/is-hostname-ip-address.js' + +test('IPv4', () => { + const ips = ['0.0.0.0', '127.0.0.1', '100.64.0.42'] + for (const ip of ips) { + assert(isHostnameIpAddress(ip)) + } +}) + +test('IPv6', () => { + const ips = [ + '::', + '2001:0db8:0000:0000:0000:0000:0000:0000', + '0:0:0:0:0:ffff:6440:002a', + ] + for (const ip of ips) { + assert(!isHostnameIpAddress(ip)) + assert(isHostnameIpAddress('[' + ip + ']')) + } +}) + +test('non-IP addresses', () => { + const hostnames = ['example', 'example.com', '123.example.com'] + for (const hostname of hostnames) { + assert(!isHostnameIpAddress(hostname)) + } +}) From 257b9917a3d9f02fab576719e62f430c8ae6d8a4 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 21:35:29 +0000 Subject: [PATCH 058/118] Move some members higher up in SyncApi --- src/sync/sync-api.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index e79388cc8..20d5c7ef3 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -84,6 +84,10 @@ export class SyncApi extends TypedEmitter { /** @type {Map>} */ #pendingDiscoveryKeys = new Map() #l + #getServerWebsocketUrls + #getReplicationStream + /** @type {Map} */ + #serverWebsockets = new Map() /** * @param {object} opts @@ -292,12 +296,6 @@ export class SyncApi extends TypedEmitter { this.emit('sync-state', this.#getState(namespaceSyncState)) } - // TODO: Move these higher up - #getServerWebsocketUrls - #getReplicationStream - /** @type {Map} */ - #serverWebsockets = new Map() - /** * @returns {void} */ From 4d3439201cde25850eaefc4299ee5d1f61b88c9d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 9 Oct 2024 22:09:27 +0000 Subject: [PATCH 059/118] Fix broken test --- src/member-api.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index c3a3566d3..cdb953b84 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -494,7 +494,10 @@ function isValidServerBaseUrl( if (url.search) return false if (url.hash) return false - if (!isHostnameIpAddress(url.hostname)) { + if ( + !isHostnameIpAddress(url.hostname) && + !dangerouslyAllowInsecureConnections + ) { const parts = url.hostname.split('.') const isDomainValid = parts.length >= 2 && parts.every(Boolean) if (!isDomainValid) return false From 1642109f37be231ec40cc3557fa2cecd2013876c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 16 Oct 2024 19:32:12 +0000 Subject: [PATCH 060/118] test: server integration tests should use dynamic port --- test-e2e/server-integration.js | 38 ++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/test-e2e/server-integration.js b/test-e2e/server-integration.js index 79f25189e..e957d8bf4 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server-integration.js @@ -13,9 +13,6 @@ import { map } from 'iterpal' /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ -// TODO: Dynamically choose a port that's open -const PORT = 9875 -const BASE_URL = `http://localhost:${PORT}/` const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') const FIXTURES_ROOT = new URL('../src/server/test/fixtures/', import.meta.url) const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname @@ -298,14 +295,15 @@ test('allowedProjects adding project not in allow list', async (t) => { test('observations endpoint', async (t) => { const server = createTestServer(t) - await server.listen({ port: PORT }) + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) t.after(() => server.close()) const manager = await createManager('client', t) const projectId = await manager.createProject() const project = await manager.getProject(projectId) - await project.$member.addServerPeer(BASE_URL, { + await project.$member.addServerPeer(serverAddress, { dangerouslyAllowInsecureConnections: true, }) @@ -378,7 +376,7 @@ test('observations endpoint', async (t) => { await project.$sync.waitForSync('full') const response = await server.inject({ - authority: `localhost:${PORT}`, + authority: serverUrl.host, method: 'GET', url: `/projects/${projectId}/observations`, headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, @@ -403,7 +401,11 @@ test('observations endpoint', async (t) => { assert.equal(observationFromApi.lon, observation.lon) assert.equal(observationFromApi.deleted, observation.deleted) if (!observationFromApi.deleted) { - await assertAttachmentsCanBeFetched({ server, observationFromApi }) + await assertAttachmentsCanBeFetched({ + server, + serverAddress, + observationFromApi, + }) } assert.deepEqual(observationFromApi.tags, observation.tags) }) @@ -479,10 +481,15 @@ function blobToAttachment(blob) { /** * @param {object} options * @param {FastifyInstance} options.server + * @param {string} options.serverAddress * @param {Record} options.observationFromApi * @returns {Promise} */ -async function assertAttachmentsCanBeFetched({ server, observationFromApi }) { +async function assertAttachmentsCanBeFetched({ + server, + serverAddress, + observationFromApi, +}) { assert(Array.isArray(observationFromApi.attachments)) await Promise.all( observationFromApi.attachments.map( @@ -490,7 +497,11 @@ async function assertAttachmentsCanBeFetched({ server, observationFromApi }) { async (attachment) => { assert(attachment && typeof attachment === 'object') assert('url' in attachment && typeof attachment.url === 'string') - await assertAttachmentAndVariantsCanBeFetched(server, attachment.url) + await assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + attachment.url + ) } ) ) @@ -498,11 +509,16 @@ async function assertAttachmentsCanBeFetched({ server, observationFromApi }) { /** * @param {FastifyInstance} server + * @param {string} serverAddress * @param {string} url * @returns {Promise} */ -async function assertAttachmentAndVariantsCanBeFetched(server, url) { - assert(url.startsWith(BASE_URL)) +async function assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + url +) { + assert(url.startsWith(serverAddress)) /** @type {Map} */ const variantsToCheck = new Map([ From 3b0a8e081b244ac860b86cc373e61f8cd57c8c72 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:09:53 +0000 Subject: [PATCH 061/118] Move server integration tests into a separate server test folder --- test-e2e/{ => server}/server-integration.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) rename test-e2e/{ => server}/server-integration.js (97%) diff --git a/test-e2e/server-integration.js b/test-e2e/server/server-integration.js similarity index 97% rename from test-e2e/server-integration.js rename to test-e2e/server/server-integration.js index e957d8bf4..42ee98926 100644 --- a/test-e2e/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -5,16 +5,19 @@ import assert from 'node:assert/strict' import crypto, { randomBytes } from 'node:crypto' import * as fs from 'node:fs/promises' import test from 'node:test' -import createServer from '../src/server/app.js' -import { projectKeyToPublicId } from '../src/utils.js' -import { blobMetadata } from '../test/helpers/blob-store.js' -import { createManager, getManagerOptions } from './utils.js' +import createServer from '../../src/server/app.js' +import { projectKeyToPublicId } from '../../src/utils.js' +import { blobMetadata } from '../../test/helpers/blob-store.js' +import { createManager, getManagerOptions } from '../utils.js' import { map } from 'iterpal' /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') -const FIXTURES_ROOT = new URL('../src/server/test/fixtures/', import.meta.url) +const FIXTURES_ROOT = new URL( + '../../src/server/test/fixtures/', + import.meta.url +) const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname @@ -438,7 +441,7 @@ const TEST_SERVER_DEFAULTS = { /** * @param {import('node:test').TestContext} t - * @param {Partial} [serverOptions] + * @param {Partial} [serverOptions] * @returns {ReturnType & { deviceId: string }} */ function createTestServer(t, serverOptions) { From ad681f4deb7544fa133c9c86666a4d56dd551a33 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:14:23 +0000 Subject: [PATCH 062/118] test: move GET /info test to separate file --- test-e2e/server/server-info-endpoint.js | 21 ++++++++++ test-e2e/server/server-integration.js | 54 ++----------------------- test-e2e/server/test-helpers.js | 37 +++++++++++++++++ 3 files changed, 61 insertions(+), 51 deletions(-) create mode 100644 test-e2e/server/server-info-endpoint.js create mode 100644 test-e2e/server/test-helpers.js diff --git a/test-e2e/server/server-info-endpoint.js b/test-e2e/server/server-info-endpoint.js new file mode 100644 index 000000000..cc7a2e93f --- /dev/null +++ b/test-e2e/server/server-info-endpoint.js @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createTestServer } from './test-helpers.js' + +test('server info endpoint', async (t) => { + const serverName = 'test server' + const server = createTestServer(t, { serverName }) + + const response = await server.inject({ + method: 'GET', + url: '/info', + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + data: { + deviceId: server.deviceId, + name: serverName, + }, + }) +}) diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/server-integration.js index 42ee98926..3272fc1e3 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -1,19 +1,17 @@ import { valueOf } from '@comapeo/schema' -import { KeyManager } from '@mapeo/crypto' import { generate } from '@mapeo/mock-data' +import { map } from 'iterpal' import assert from 'node:assert/strict' import crypto, { randomBytes } from 'node:crypto' import * as fs from 'node:fs/promises' import test from 'node:test' -import createServer from '../../src/server/app.js' import { projectKeyToPublicId } from '../../src/utils.js' import { blobMetadata } from '../../test/helpers/blob-store.js' -import { createManager, getManagerOptions } from '../utils.js' -import { map } from 'iterpal' +import { createManager } from '../utils.js' +import { BEARER_TOKEN, createTestServer } from './test-helpers.js' /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ -const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') const FIXTURES_ROOT = new URL( '../../src/server/test/fixtures/', import.meta.url @@ -22,22 +20,6 @@ const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname -test('server info endpoint', async (t) => { - const serverName = 'test server' - const server = createTestServer(t, { serverName }) - const expectedResponseBody = { - data: { - deviceId: server.deviceId, - name: serverName, - }, - } - const response = await server.inject({ - method: 'GET', - url: '/info', - }) - assert.deepEqual(response.json(), expectedResponseBody) -}) - test('allowedHosts valid', async (t) => { const allowedHost = 'www.example.com' const server = createTestServer(t, { @@ -434,36 +416,6 @@ function randomProjectKeys() { } } -const TEST_SERVER_DEFAULTS = { - serverName: 'test server', - serverBearerToken: BEARER_TOKEN, -} - -/** - * @param {import('node:test').TestContext} t - * @param {Partial} [serverOptions] - * @returns {ReturnType & { deviceId: string }} - */ -function createTestServer(t, serverOptions) { - const serverName = - serverOptions?.serverName || TEST_SERVER_DEFAULTS.serverName - const managerOptions = getManagerOptions(serverName) - const km = new KeyManager(managerOptions.rootKey) - const server = createServer({ - ...managerOptions, - ...TEST_SERVER_DEFAULTS, - ...serverOptions, - }) - t.after(() => server.close()) - Object.defineProperty(server, 'deviceId', { - get() { - return km.getIdentityKeypair().publicKey.toString('hex') - }, - }) - // @ts-expect-error - return server -} - /** * TODO: Use a better type for `blob.type` * @param {object} blob diff --git a/test-e2e/server/test-helpers.js b/test-e2e/server/test-helpers.js new file mode 100644 index 000000000..5b4565085 --- /dev/null +++ b/test-e2e/server/test-helpers.js @@ -0,0 +1,37 @@ +import { KeyManager } from '@mapeo/crypto' +import createServer from '../../src/server/app.js' +import { getManagerOptions } from '../utils.js' +/** @import { TestContext } from 'node:test' */ +/** @import { ServerOptions } from '../../src/server/app.js' */ + +export const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') + +const TEST_SERVER_DEFAULTS = { + serverName: 'test server', + serverBearerToken: BEARER_TOKEN, +} + +/** + * @param {TestContext} t + * @param {Partial} [serverOptions] + * @returns {ReturnType & { deviceId: string }} + */ +export function createTestServer(t, serverOptions) { + const serverName = + serverOptions?.serverName || TEST_SERVER_DEFAULTS.serverName + const managerOptions = getManagerOptions(serverName) + const km = new KeyManager(managerOptions.rootKey) + const server = createServer({ + ...managerOptions, + ...TEST_SERVER_DEFAULTS, + ...serverOptions, + }) + t.after(() => server.close()) + Object.defineProperty(server, 'deviceId', { + get() { + return km.getIdentityKeypair().publicKey.toString('hex') + }, + }) + // @ts-expect-error + return server +} From 10fb67b84b95d84609e000235c6c536a78a30369 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:17:08 +0000 Subject: [PATCH 063/118] test: move allowedhosts tests to separate file --- test-e2e/server/allowed-hosts.js | 28 +++++++++++++++++++++++++++ test-e2e/server/server-integration.js | 25 ------------------------ 2 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 test-e2e/server/allowed-hosts.js diff --git a/test-e2e/server/allowed-hosts.js b/test-e2e/server/allowed-hosts.js new file mode 100644 index 000000000..4cab3b40e --- /dev/null +++ b/test-e2e/server/allowed-hosts.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createTestServer } from './test-helpers.js' + +test('allowed host', async (t) => { + const allowedHost = 'www.example.com' + const server = createTestServer(t, { allowedHosts: [allowedHost] }) + + const response = await server.inject({ + authority: allowedHost, + method: 'GET', + url: '/info', + }) + + assert.equal(response.statusCode, 200) +}) + +test('disallowed host', async (t) => { + const server = createTestServer(t, { allowedHosts: ['www.example.com'] }) + + const response = await server.inject({ + authority: 'www.invalid-host.example', + method: 'GET', + url: '/info', + }) + + assert.equal(response.statusCode, 403) +}) diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/server-integration.js index 3272fc1e3..3e84dee47 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -20,31 +20,6 @@ const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname -test('allowedHosts valid', async (t) => { - const allowedHost = 'www.example.com' - const server = createTestServer(t, { - allowedHosts: [allowedHost], - }) - const response = await server.inject({ - authority: allowedHost, - method: 'GET', - url: '/info', - }) - assert.equal(response.statusCode, 200) -}) - -test('allowedHosts invalid', async (t) => { - const server = createTestServer(t, { - allowedHosts: ['www.example.com'], - }) - const response = await server.inject({ - authority: 'www.invalid-host.com', - method: 'GET', - url: '/info', - }) - assert.equal(response.statusCode, 403) -}) - test('add project, sync endpoint available', async (t) => { const server = createTestServer(t) const projectKeys = randomProjectKeys() From 7b443a88c1fb63e6869075ab7a702e4a25fbdc71 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:21:41 +0000 Subject: [PATCH 064/118] test: move GET /projects tests to separate file --- test-e2e/server/list-projects-endpoint.js | 67 ++++++++++++++++++ test-e2e/server/server-integration.js | 84 ++--------------------- test-e2e/server/test-helpers.js | 15 ++++ 3 files changed, 88 insertions(+), 78 deletions(-) create mode 100644 test-e2e/server/list-projects-endpoint.js diff --git a/test-e2e/server/list-projects-endpoint.js b/test-e2e/server/list-projects-endpoint.js new file mode 100644 index 000000000..39814536c --- /dev/null +++ b/test-e2e/server/list-projects-endpoint.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + BEARER_TOKEN, + createTestServer, + randomProjectKeys, +} from './test-helpers.js' +import { projectKeyToPublicId } from '../../src/utils.js' + +test('listing projects', async (t) => { + const server = createTestServer(t, { allowedProjects: 999 }) + + await t.test('with invalid auth', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) + }) + + await t.test('with no projects', async () => { + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { data: [] }) + }) + + await t.test('with projects', async () => { + const projectKeys1 = randomProjectKeys() + const projectKeys2 = randomProjectKeys() + + await Promise.all( + [projectKeys1, projectKeys2].map(async (projectKeys) => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 200) + }) + ) + + const response = await server.inject({ + method: 'GET', + url: '/projects', + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + + const { data } = response.json() + assert(Array.isArray(data)) + assert.equal(data.length, 2, 'expected 2 projects') + for (const projectKeys of [projectKeys1, projectKeys2]) { + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + assert( + data.some((project) => project.projectId === projectPublicId), + `expected ${projectPublicId} to be found` + ) + } + }) +}) diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/server-integration.js index 3e84dee47..41950c0ec 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -2,13 +2,17 @@ import { valueOf } from '@comapeo/schema' import { generate } from '@mapeo/mock-data' import { map } from 'iterpal' import assert from 'node:assert/strict' -import crypto, { randomBytes } from 'node:crypto' +import { randomBytes } from 'node:crypto' import * as fs from 'node:fs/promises' import test from 'node:test' import { projectKeyToPublicId } from '../../src/utils.js' import { blobMetadata } from '../../test/helpers/blob-store.js' import { createManager } from '../utils.js' -import { BEARER_TOKEN, createTestServer } from './test-helpers.js' +import { + BEARER_TOKEN, + createTestServer, + randomProjectKeys, +} from './test-helpers.js' /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ @@ -65,65 +69,6 @@ test('no project added, sync endpoint not available', async (t) => { assert.equal(response.json().error, 'Not Found') }) -test('listing projects', async (t) => { - const server = createTestServer(t, { allowedProjects: 999 }) - - await t.test('with invalid auth', async () => { - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer bad' }, - }) - assert.equal(response.statusCode, 403) - }) - - await t.test('with no projects', async () => { - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - assert.deepEqual(response.json(), { data: [] }) - }) - - await t.test('with projects', async () => { - const projectKeys1 = randomProjectKeys() - const projectKeys2 = randomProjectKeys() - - await Promise.all( - [projectKeys1, projectKeys2].map(async (projectKeys) => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.equal(response.statusCode, 200) - }) - ) - - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - - const { data } = response.json() - assert(Array.isArray(data)) - assert.equal(data.length, 2, 'expected 2 projects') - for (const projectKeys of [projectKeys1, projectKeys2]) { - const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') - ) - assert( - data.some((project) => project.projectId === projectPublicId), - `expected ${projectPublicId} to be found` - ) - } - }) -}) - test('invalid project public id', async (t) => { const server = createTestServer(t) @@ -374,23 +319,6 @@ test('observations endpoint', async (t) => { ) }) -function randomHexKey(length = 32) { - return Buffer.from(crypto.randomBytes(length)).toString('hex') -} - -function randomProjectKeys() { - return { - projectKey: randomHexKey(), - encryptionKeys: { - auth: randomHexKey(), - config: randomHexKey(), - data: randomHexKey(), - blobIndex: randomHexKey(), - blob: randomHexKey(), - }, - } -} - /** * TODO: Use a better type for `blob.type` * @param {object} blob diff --git a/test-e2e/server/test-helpers.js b/test-e2e/server/test-helpers.js index 5b4565085..7a8500991 100644 --- a/test-e2e/server/test-helpers.js +++ b/test-e2e/server/test-helpers.js @@ -1,6 +1,7 @@ import { KeyManager } from '@mapeo/crypto' import createServer from '../../src/server/app.js' import { getManagerOptions } from '../utils.js' +import { randomBytes } from 'node:crypto' /** @import { TestContext } from 'node:test' */ /** @import { ServerOptions } from '../../src/server/app.js' */ @@ -35,3 +36,17 @@ export function createTestServer(t, serverOptions) { // @ts-expect-error return server } + +const randomHexKey = (length = 32) => + Buffer.from(randomBytes(length)).toString('hex') + +export const randomProjectKeys = () => ({ + projectKey: randomHexKey(), + encryptionKeys: { + auth: randomHexKey(), + config: randomHexKey(), + data: randomHexKey(), + blobIndex: randomHexKey(), + blob: randomHexKey(), + }, +}) From 1b6e2ea153fd66f17ed690e6efc1e6ad2506b2ad Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:46:19 +0000 Subject: [PATCH 065/118] test: move POST /projects test to separate file --- test-e2e/server/add-project-endpoint.js | 121 ++++++++++++++++++++++ test-e2e/server/server-integration.js | 129 +----------------------- 2 files changed, 125 insertions(+), 125 deletions(-) create mode 100644 test-e2e/server/add-project-endpoint.js diff --git a/test-e2e/server/add-project-endpoint.js b/test-e2e/server/add-project-endpoint.js new file mode 100644 index 000000000..676de9532 --- /dev/null +++ b/test-e2e/server/add-project-endpoint.js @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createTestServer, randomProjectKeys } from './test-helpers.js' +import { projectKeyToPublicId } from '../../src/utils.js' + +test('adding a project', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + + assert.equal(response.statusCode, 200) + assert.deepEqual(response.json(), { + data: { deviceId: server.deviceId }, + }) +}) + +test('adding a second project fails by default', async (t) => { + const server = createTestServer(t) + + const firstAddResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(firstAddResponse.statusCode, 200) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/) +}) + +test('allowing a maximum number of projects', async (t) => { + const server = createTestServer(t, { allowedProjects: 3 }) + + await t.test('adding 3 projects', async () => { + for (let i = 0; i < 3; i++) { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 200) + } + }) + + await t.test('attempting to add 4th project fails', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 403) + assert.match(response.json().message, /maximum number of projects/) + }) +}) + +test( + 'allowing a specific list of projects', + { concurrency: true }, + async (t) => { + const projectKeys = randomProjectKeys() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + const server = createTestServer(t, { + allowedProjects: [projectPublicId], + }) + + await t.test('adding a project in the list', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 200) + }) + + await t.test('trying to add a project not in the list', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: randomProjectKeys(), + }) + assert.equal(response.statusCode, 403) + }) + } +) + +// TODO: This test is wrong. Adding the same project twice should be idempotent. +test('trying to create the same project twice fails', async (t) => { + const server = createTestServer(t, { allowedProjects: 2 }) + + const projectKeys = randomProjectKeys() + + await t.test('add project first time succeeds', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 200) + }) + + await t.test('attempt to re-add same project fails', async () => { + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(response.statusCode, 400) + assert.match(response.json().message, /already exists/) + }) +}) diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/server-integration.js index 41950c0ec..06005e05b 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -31,18 +31,10 @@ test('add project, sync endpoint available', async (t) => { Buffer.from(projectKeys.projectKey, 'hex') ) - await t.test('add project', async () => { - const expectedResponseBody = { - data: { - deviceId: server.deviceId, - }, - } - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.deepEqual(response.json(), expectedResponseBody) + await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, }) await t.test('sync endpoint available', async (t) => { @@ -84,119 +76,6 @@ test('invalid project public id', async (t) => { assert.equal(response.json().code, 'FST_ERR_VALIDATION') }) -test('trying to add second project fails', async (t) => { - const server = createTestServer(t) - - await t.test('add first project', async () => { - const expectedResponseBody = { - data: { - deviceId: server.deviceId, - }, - } - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: randomProjectKeys(), - }) - assert.deepEqual(response.json(), expectedResponseBody) - }) - - await t.test('attempt to add second project fails', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: randomProjectKeys(), - }) - assert.equal(response.statusCode, 403) - assert.match(response.json().message, /maximum number of projects/) - }) -}) - -test('allowedProjects=3', async (t) => { - const server = createTestServer(t, { allowedProjects: 3 }) - - await t.test('add 3 projects', async () => { - for (let i = 0; i < 3; i++) { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: randomProjectKeys(), - }) - assert.equal(response.statusCode, 200) - } - }) - - await t.test('attempt to add 4th project fails', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: randomProjectKeys(), - }) - assert.equal(response.statusCode, 403) - assert.match(response.json().message, /maximum number of projects/) - }) -}) - -test('trying to create the same project twice fails', async (t) => { - const server = createTestServer(t, { allowedProjects: 2 }) - - const projectKeys = randomProjectKeys() - - await t.test('add project first time succeeds', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.equal(response.statusCode, 200) - }) - - await t.test('attempt to re-add same project fails', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.equal(response.statusCode, 400) - assert.match(response.json().message, /already exists/) - }) -}) - -test('allowedProjects adding project in allow list', async (t) => { - const projectKeys = randomProjectKeys() - const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') - ) - const server = createTestServer(t, { - allowedProjects: [projectPublicId], - }) - - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - - assert.equal(response.statusCode, 200) -}) - -test('allowedProjects adding project not in allow list', async (t) => { - const allowedProjectId = projectKeyToPublicId( - Buffer.from(randomProjectKeys().projectKey, 'hex') - ) - const server = createTestServer(t, { - allowedProjects: [allowedProjectId], - }) - - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: randomProjectKeys(), - }) - - assert.equal(response.statusCode, 403) -}) - test('observations endpoint', async (t) => { const server = createTestServer(t) From d2624f30919842623610545dba3b89850e122092 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:53:56 +0000 Subject: [PATCH 066/118] test: Move GET /observations to its own test file --- test-e2e/server/observations-endpoint.js | 256 +++++++++++++++++++++++ test-e2e/server/server-integration.js | 238 +-------------------- 2 files changed, 257 insertions(+), 237 deletions(-) create mode 100644 test-e2e/server/observations-endpoint.js diff --git a/test-e2e/server/observations-endpoint.js b/test-e2e/server/observations-endpoint.js new file mode 100644 index 000000000..9efae8575 --- /dev/null +++ b/test-e2e/server/observations-endpoint.js @@ -0,0 +1,256 @@ +import { valueOf } from '@comapeo/schema' +import { generate } from '@mapeo/mock-data' +import { map } from 'iterpal' +import assert from 'node:assert/strict' +import * as fs from 'node:fs/promises' +import test from 'node:test' +import { projectKeyToPublicId } from '../../src/utils.js' +import { blobMetadata } from '../../test/helpers/blob-store.js' +import { createManager } from '../utils.js' +import { + BEARER_TOKEN, + createTestServer, + randomProjectKeys, +} from './test-helpers.js' +/** @import { ObservationValue } from '@comapeo/schema'*/ +/** @import { FastifyInstance } from 'fastify' */ + +const FIXTURES_ROOT = new URL( + '../../src/server/test/fixtures/', + import.meta.url +) +const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname +const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname +const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname + +test('returns a 403 if no auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/observations`, + }) + assert.equal(response.statusCode, 403) +}) + +test('returns a 403 if incorrect auth is provided', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${randomProjectPublicId()}/observations`, + headers: { Authorization: 'Bearer bad' }, + }) + assert.equal(response.statusCode, 403) +}) + +test('returning no observations', async (t) => { + const server = createTestServer(t) + const projectKeys = randomProjectKeys() + const projectPublicId = projectKeyToPublicId( + Buffer.from(projectKeys.projectKey, 'hex') + ) + + const addProjectResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(addProjectResponse.statusCode, 200) + + const response = await server.inject({ + method: 'GET', + url: `/projects/${projectPublicId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) + assert.deepEqual(await response.json(), { data: [] }) +}) + +test('returning observations with fetchable attachments', async (t) => { + const server = createTestServer(t) + + const serverAddress = await server.listen() + const serverUrl = new URL(serverAddress) + t.after(() => server.close()) + + const manager = await createManager('client', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + await project.$member.addServerPeer(serverAddress, { + dangerouslyAllowInsecureConnections: true, + }) + + project.$sync.start() + project.$sync.connectServers() + + const observations = await Promise.all([ + (() => { + /** @type {ObservationValue} */ + const noAttachments = { + ...valueOf(generate('observation')[0]), + attachments: [], + } + return project.observation.create(noAttachments) + })(), + (async () => { + const { docId } = await project.observation.create( + valueOf(generate('observation')[0]) + ) + return project.observation.delete(docId) + })(), + (async () => { + const blob = await project.$blobs.create( + { + original: FIXTURE_ORIGINAL_PATH, + preview: FIXTURE_PREVIEW_PATH, + thumbnail: FIXTURE_THUMBNAIL_PATH, + }, + blobMetadata({ mimeType: 'image/jpeg' }) + ) + /** @type {ObservationValue} */ + const withAttachment = { + ...valueOf(generate('observation')[0]), + attachments: [blobToAttachment(blob)], + } + return project.observation.create(withAttachment) + })(), + ]) + + await project.$sync.waitForSync('full') + + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + + assert.equal(response.statusCode, 200) + + const { data } = await response.json() + + assert.equal(data.length, 3) + + await Promise.all( + observations.map(async (observation) => { + const observationFromApi = data.find( + (/** @type {{ docId: string }} */ o) => o.docId === observation.docId + ) + assert(observationFromApi, 'observation found in API response') + assert.equal(observationFromApi.createdAt, observation.createdAt) + assert.equal(observationFromApi.updatedAt, observation.updatedAt) + assert.equal(observationFromApi.lat, observation.lat) + assert.equal(observationFromApi.lon, observation.lon) + assert.equal(observationFromApi.deleted, observation.deleted) + if (!observationFromApi.deleted) { + await assertAttachmentsCanBeFetched({ + server, + serverAddress, + observationFromApi, + }) + } + assert.deepEqual(observationFromApi.tags, observation.tags) + }) + ) +}) + +function randomProjectPublicId() { + return projectKeyToPublicId( + Buffer.from(randomProjectKeys().projectKey, 'hex') + ) +} + +/** + * @param {object} blob + * @param {string} blob.driveId + * @param {'photo' | 'audio' | 'video'} blob.type + * @param {string} blob.name + * @param {string} blob.hash + */ +function blobToAttachment(blob) { + return { + driveDiscoveryId: blob.driveId, + type: blob.type, + name: blob.name, + hash: blob.hash, + } +} + +/** + * @param {object} options + * @param {FastifyInstance} options.server + * @param {string} options.serverAddress + * @param {Record} options.observationFromApi + * @returns {Promise} + */ +async function assertAttachmentsCanBeFetched({ + server, + serverAddress, + observationFromApi, +}) { + assert(Array.isArray(observationFromApi.attachments)) + await Promise.all( + observationFromApi.attachments.map( + /** @param {unknown} attachment */ + async (attachment) => { + assert(attachment && typeof attachment === 'object') + assert('url' in attachment && typeof attachment.url === 'string') + await assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + attachment.url + ) + } + ) + ) +} + +/** + * @param {FastifyInstance} server + * @param {string} serverAddress + * @param {string} url + * @returns {Promise} + */ +async function assertAttachmentAndVariantsCanBeFetched( + server, + serverAddress, + url +) { + assert(url.startsWith(serverAddress)) + + /** @type {Map} */ + const variantsToCheck = new Map([ + [null, FIXTURE_ORIGINAL_PATH], + ['original', FIXTURE_ORIGINAL_PATH], + ['preview', FIXTURE_PREVIEW_PATH], + ['thumbnail', FIXTURE_THUMBNAIL_PATH], + ]) + + await Promise.all( + map(variantsToCheck, async ([variant, fixturePath]) => { + const expectedResponseBodyPromise = fs.readFile(fixturePath) + const attachmentResponse = await server.inject({ + method: 'GET', + url: url + (variant ? `?variant=${variant}` : ''), + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal( + attachmentResponse.statusCode, + 200, + `expected 200 when fetching ${variant} attachment` + ) + assert.equal( + attachmentResponse.headers['content-type'], + 'image/jpeg', + `expected ${variant} attachment to be a JPEG` + ) + assert.deepEqual( + attachmentResponse.rawPayload, + await expectedResponseBodyPromise, + `expected ${variant} attachment to match fixture` + ) + }) + ) +} diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/server-integration.js index 06005e05b..e9cd61665 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/server-integration.js @@ -1,28 +1,8 @@ -import { valueOf } from '@comapeo/schema' -import { generate } from '@mapeo/mock-data' -import { map } from 'iterpal' import assert from 'node:assert/strict' import { randomBytes } from 'node:crypto' -import * as fs from 'node:fs/promises' import test from 'node:test' import { projectKeyToPublicId } from '../../src/utils.js' -import { blobMetadata } from '../../test/helpers/blob-store.js' -import { createManager } from '../utils.js' -import { - BEARER_TOKEN, - createTestServer, - randomProjectKeys, -} from './test-helpers.js' -/** @import { ObservationValue } from '@comapeo/schema'*/ -/** @import { FastifyInstance } from 'fastify' */ - -const FIXTURES_ROOT = new URL( - '../../src/server/test/fixtures/', - import.meta.url -) -const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname -const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname -const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname +import { createTestServer, randomProjectKeys } from './test-helpers.js' test('add project, sync endpoint available', async (t) => { const server = createTestServer(t) @@ -75,219 +55,3 @@ test('invalid project public id', async (t) => { assert.equal(response.statusCode, 400) assert.equal(response.json().code, 'FST_ERR_VALIDATION') }) - -test('observations endpoint', async (t) => { - const server = createTestServer(t) - - const serverAddress = await server.listen() - const serverUrl = new URL(serverAddress) - t.after(() => server.close()) - - const manager = await createManager('client', t) - const projectId = await manager.createProject() - const project = await manager.getProject(projectId) - - await project.$member.addServerPeer(serverAddress, { - dangerouslyAllowInsecureConnections: true, - }) - - await t.test('returns a 403 if no auth is provided', async () => { - const response = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, - }) - assert.equal(response.statusCode, 403) - }) - - await t.test('returns a 403 if incorrect auth is provided', async () => { - const response = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer bad' }, - }) - assert.equal(response.statusCode, 403) - }) - - await t.test('no observations', async () => { - const response = await server.inject({ - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - assert.deepEqual(await response.json(), { data: [] }) - }) - - await t.test( - 'returning observations with fetchable attachments', - async () => { - project.$sync.start() - project.$sync.connectServers() - - const observations = await Promise.all([ - (() => { - /** @type {ObservationValue} */ - const noAttachments = { - ...valueOf(generate('observation')[0]), - attachments: [], - } - return project.observation.create(noAttachments) - })(), - (async () => { - const { docId } = await project.observation.create( - valueOf(generate('observation')[0]) - ) - return project.observation.delete(docId) - })(), - (async () => { - const blob = await project.$blobs.create( - { - original: FIXTURE_ORIGINAL_PATH, - preview: FIXTURE_PREVIEW_PATH, - thumbnail: FIXTURE_THUMBNAIL_PATH, - }, - blobMetadata({ mimeType: 'image/jpeg' }) - ) - /** @type {ObservationValue} */ - const withAttachment = { - ...valueOf(generate('observation')[0]), - attachments: [blobToAttachment(blob)], - } - return project.observation.create(withAttachment) - })(), - ]) - - await project.$sync.waitForSync('full') - - const response = await server.inject({ - authority: serverUrl.host, - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - - assert.equal(response.statusCode, 200) - - const { data } = await response.json() - - assert.equal(data.length, 3) - - await Promise.all( - observations.map(async (observation) => { - const observationFromApi = data.find( - (/** @type {{ docId: string }} */ o) => - o.docId === observation.docId - ) - assert(observationFromApi, 'observation found in API response') - assert.equal(observationFromApi.createdAt, observation.createdAt) - assert.equal(observationFromApi.updatedAt, observation.updatedAt) - assert.equal(observationFromApi.lat, observation.lat) - assert.equal(observationFromApi.lon, observation.lon) - assert.equal(observationFromApi.deleted, observation.deleted) - if (!observationFromApi.deleted) { - await assertAttachmentsCanBeFetched({ - server, - serverAddress, - observationFromApi, - }) - } - assert.deepEqual(observationFromApi.tags, observation.tags) - }) - ) - } - ) -}) - -/** - * TODO: Use a better type for `blob.type` - * @param {object} blob - * @param {string} blob.driveId - * @param {any} blob.type - * @param {string} blob.name - * @param {string} blob.hash - */ -function blobToAttachment(blob) { - return { - driveDiscoveryId: blob.driveId, - type: blob.type, - name: blob.name, - hash: blob.hash, - } -} - -/** - * @param {object} options - * @param {FastifyInstance} options.server - * @param {string} options.serverAddress - * @param {Record} options.observationFromApi - * @returns {Promise} - */ -async function assertAttachmentsCanBeFetched({ - server, - serverAddress, - observationFromApi, -}) { - assert(Array.isArray(observationFromApi.attachments)) - await Promise.all( - observationFromApi.attachments.map( - /** @param {unknown} attachment */ - async (attachment) => { - assert(attachment && typeof attachment === 'object') - assert('url' in attachment && typeof attachment.url === 'string') - await assertAttachmentAndVariantsCanBeFetched( - server, - serverAddress, - attachment.url - ) - } - ) - ) -} - -/** - * @param {FastifyInstance} server - * @param {string} serverAddress - * @param {string} url - * @returns {Promise} - */ -async function assertAttachmentAndVariantsCanBeFetched( - server, - serverAddress, - url -) { - assert(url.startsWith(serverAddress)) - - /** @type {Map} */ - const variantsToCheck = new Map([ - [null, FIXTURE_ORIGINAL_PATH], - ['original', FIXTURE_ORIGINAL_PATH], - ['preview', FIXTURE_PREVIEW_PATH], - ['thumbnail', FIXTURE_THUMBNAIL_PATH], - ]) - - await Promise.all( - map(variantsToCheck, async ([variant, fixturePath]) => { - const expectedResponseBodyPromise = fs.readFile(fixturePath) - const attachmentResponse = await server.inject({ - method: 'GET', - url: url + (variant ? `?variant=${variant}` : ''), - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal( - attachmentResponse.statusCode, - 200, - `expected 200 when fetching ${variant} attachment` - ) - assert.equal( - attachmentResponse.headers['content-type'], - 'image/jpeg', - `expected ${variant} attachment to be a JPEG` - ) - assert.deepEqual( - attachmentResponse.rawPayload, - await expectedResponseBodyPromise, - `expected ${variant} attachment to match fixture` - ) - }) - ) -} From d21ea62cc92c4bcd24856d0539fb73f492158210 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 20:56:06 +0000 Subject: [PATCH 067/118] test: rename remaining server integration tests --- test-e2e/server/{server-integration.js => sync-endpoint.js} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename test-e2e/server/{server-integration.js => sync-endpoint.js} (85%) diff --git a/test-e2e/server/server-integration.js b/test-e2e/server/sync-endpoint.js similarity index 85% rename from test-e2e/server/server-integration.js rename to test-e2e/server/sync-endpoint.js index e9cd61665..8ddecad52 100644 --- a/test-e2e/server/server-integration.js +++ b/test-e2e/server/sync-endpoint.js @@ -4,7 +4,7 @@ import test from 'node:test' import { projectKeyToPublicId } from '../../src/utils.js' import { createTestServer, randomProjectKeys } from './test-helpers.js' -test('add project, sync endpoint available', async (t) => { +test('sync endpoint is available after adding a project', async (t) => { const server = createTestServer(t) const projectKeys = randomProjectKeys() const projectPublicId = projectKeyToPublicId( @@ -24,7 +24,7 @@ test('add project, sync endpoint available', async (t) => { }) }) -test('no project added, sync endpoint not available', async (t) => { +test('sync endpoint is not available before adding a project', async (t) => { const server = createTestServer(t) const projectPublicId = projectKeyToPublicId(randomBytes(32)) @@ -41,7 +41,7 @@ test('no project added, sync endpoint not available', async (t) => { assert.equal(response.json().error, 'Not Found') }) -test('invalid project public id', async (t) => { +test('sync endpoint returns error with an invalid project public ID', async (t) => { const server = createTestServer(t) const response = await server.inject({ From 1920bce9a0efecbdacbbada360d6d958070030b8 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 21:01:01 +0000 Subject: [PATCH 068/118] Cleanups to sync endpoint tests --- test-e2e/server/sync-endpoint.js | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/test-e2e/server/sync-endpoint.js b/test-e2e/server/sync-endpoint.js index 8ddecad52..14288ad84 100644 --- a/test-e2e/server/sync-endpoint.js +++ b/test-e2e/server/sync-endpoint.js @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { randomBytes } from 'node:crypto' import test from 'node:test' import { projectKeyToPublicId } from '../../src/utils.js' import { createTestServer, randomProjectKeys } from './test-helpers.js' @@ -11,6 +10,19 @@ test('sync endpoint is available after adding a project', async (t) => { Buffer.from(projectKeys.projectKey, 'hex') ) + await t.test('sync endpoint not available yet', async () => { + const response = await server.inject({ + method: 'GET', + url: '/sync/' + projectPublicId, + headers: { + connection: 'upgrade', + upgrade: 'websocket', + }, + }) + assert.equal(response.statusCode, 404) + assert.equal(response.json().error, 'Not Found') + }) + await server.inject({ method: 'POST', url: '/projects', @@ -24,23 +36,6 @@ test('sync endpoint is available after adding a project', async (t) => { }) }) -test('sync endpoint is not available before adding a project', async (t) => { - const server = createTestServer(t) - - const projectPublicId = projectKeyToPublicId(randomBytes(32)) - - const response = await server.inject({ - method: 'GET', - url: '/sync/' + projectPublicId, - headers: { - connection: 'upgrade', - upgrade: 'websocket', - }, - }) - assert.equal(response.statusCode, 404) - assert.equal(response.json().error, 'Not Found') -}) - test('sync endpoint returns error with an invalid project public ID', async (t) => { const server = createTestServer(t) @@ -52,6 +47,7 @@ test('sync endpoint returns error with an invalid project public ID', async (t) upgrade: 'websocket', }, }) + assert.equal(response.statusCode, 400) assert.equal(response.json().code, 'FST_ERR_VALIDATION') }) From 32e403c8efe2d1659b7d7fed6364cdbce67321d4 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 21:09:00 +0000 Subject: [PATCH 069/118] WIP: additional tests for POST /proejcts --- test-e2e/server/add-project-endpoint.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test-e2e/server/add-project-endpoint.js b/test-e2e/server/add-project-endpoint.js index 676de9532..337e8efcc 100644 --- a/test-e2e/server/add-project-endpoint.js +++ b/test-e2e/server/add-project-endpoint.js @@ -3,6 +3,27 @@ import test from 'node:test' import { createTestServer, randomProjectKeys } from './test-helpers.js' import { projectKeyToPublicId } from '../../src/utils.js' +test('request missing project key', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + // TODO: omit the project key + body: randomProjectKeys(), + }) + + assert.equal(response.statusCode, 400) +}) + +test('request missing any encryption keys', async (t) => { + // TODO +}) + +test('request missing an encryption key', async (t) => { + // TODO +}) + test('adding a project', async (t) => { const server = createTestServer(t) From 74fd08aee5abdf4c8fab711d1698f61e57288750 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 22:24:15 +0000 Subject: [PATCH 070/118] Clean up some add project endpoint tests --- src/lib/omit.js | 28 +++++++++++++++++++++++ test-e2e/server/add-project-endpoint.js | 30 ++++++++++++++++++++----- 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/lib/omit.js diff --git a/src/lib/omit.js b/src/lib/omit.js new file mode 100644 index 000000000..d0d34caf9 --- /dev/null +++ b/src/lib/omit.js @@ -0,0 +1,28 @@ +/** + * Returns a new object with the own enumerable keys of `obj` that are not in `keys`. + * + * In other words, remove some keys from an object. + * + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {ReadonlyArray} keys + * @returns {Omit} + * @example + * const obj = { foo: 1, bar: 2, baz: 3 } + * omit(obj, ['foo', 'bar']) + * // => { baz: 3 } + */ +export function omit(obj, keys) { + /** @type {Partial} */ const result = {} + + /** @type {Set} */ const toOmit = new Set(keys) + + for (const key in obj) { + if (!Object.hasOwn(obj, key)) continue + if (toOmit.has(key)) continue + result[key] = obj[key] + } + + return /** @type {Omit} */ (result) +} diff --git a/test-e2e/server/add-project-endpoint.js b/test-e2e/server/add-project-endpoint.js index 337e8efcc..342bc37e2 100644 --- a/test-e2e/server/add-project-endpoint.js +++ b/test-e2e/server/add-project-endpoint.js @@ -1,7 +1,8 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { createTestServer, randomProjectKeys } from './test-helpers.js' +import { omit } from '../../src/lib/omit.js' import { projectKeyToPublicId } from '../../src/utils.js' +import { createTestServer, randomProjectKeys } from './test-helpers.js' test('request missing project key', async (t) => { const server = createTestServer(t) @@ -9,19 +10,38 @@ test('request missing project key', async (t) => { const response = await server.inject({ method: 'POST', url: '/projects', - // TODO: omit the project key - body: randomProjectKeys(), + body: omit(randomProjectKeys(), ['projectKey']), }) assert.equal(response.statusCode, 400) }) test('request missing any encryption keys', async (t) => { - // TODO + const server = createTestServer(t) + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: omit(randomProjectKeys(), ['encryptionKeys']), + }) + + assert.equal(response.statusCode, 400) }) test('request missing an encryption key', async (t) => { - // TODO + const server = createTestServer(t) + const projectKeys = randomProjectKeys() + + const response = await server.inject({ + method: 'POST', + url: '/projects', + body: { + ...projectKeys, + encryptionKeys: omit(projectKeys.encryptionKeys, ['config']), + }, + }) + + assert.equal(response.statusCode, 400) }) test('adding a project', async (t) => { From f6b65a338b1decbae2a3d96163fbfb7446ffdcf5 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Mon, 21 Oct 2024 10:41:30 +0100 Subject: [PATCH 071/118] fix: suspend rather than stop fly machines --- fly.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fly.toml b/fly.toml index 367ed43f8..a2e048974 100644 --- a/fly.toml +++ b/fly.toml @@ -14,7 +14,7 @@ primary_region = 'iad' [http_service] internal_port = 8080 force_https = true - auto_stop_machines = 'stop' + auto_stop_machines = 'suspend' auto_start_machines = true min_machines_running = 0 max_machines_running = 1 From 9aa74f3c28f2538fcd2734a6305743b773df7ebd Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 15:57:22 +0000 Subject: [PATCH 072/118] Require server base URL to be relative to root path --- src/member-api.js | 3 +++ test-e2e/server.js | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index cdb953b84..78f5ffdf5 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -494,6 +494,9 @@ function isValidServerBaseUrl( if (url.search) return false if (url.hash) return false + // We may want to support this someday. See . + if (url.pathname !== '/') return false + if ( !isHostnameIpAddress(url.hostname) && !dangerouslyAllowInsecureConnections diff --git a/test-e2e/server.js b/test-e2e/server.js index 91c27ed79..c0f803897 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -20,7 +20,6 @@ import { /** @import { State as SyncState } from '../src/sync/sync-api.js' */ // TODO: test bad requests -// TODO: test other base URLs test('invalid base URLs', async (t) => { const manager = createManager('device0', t) @@ -50,6 +49,8 @@ test('invalid base URLs', async (t) => { 'https://has-query.example/?foo=bar', 'https://has-hash.example/#hash', `https://${'x'.repeat(2000)}.example`, + // We may want to support this someday. See . + 'https://has-pathname.example/p', ] await Promise.all( invalidUrls.map((url) => From 44612cc09ba02cc1e1d7d69780e8227d00f90d42 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:09:39 +0000 Subject: [PATCH 073/118] Test if server returns a non-200 --- test-e2e/server.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test-e2e/server.js b/test-e2e/server.js index c0f803897..fd7b465af 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,6 +1,7 @@ import { valueOf } from '@comapeo/schema' import { generate } from '@mapeo/mock-data' import { execa } from 'execa' +import createFastify from 'fastify' import assert from 'node:assert/strict' import test from 'node:test' import { pEvent } from 'p-event' @@ -68,6 +69,35 @@ test('invalid base URLs', async (t) => { assert(!hasServerPeer, 'no server peers should be added') }) +test("fails if server doesn't return a 200", { concurrency: 1 }, async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + await Promise.all( + [204, 302, 400, 500].map((statusCode) => + t.test(`when returning a ${statusCode}`, async (t) => { + const fastify = createFastify() + fastify.post('/projects', (_req, reply) => { + reply.status(statusCode).send() + }) + const url = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(url, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: `Failed to add server peer due to HTTP status code ${statusCode}`, + } + ) + }) + ) + ) +}) + test('adding a server peer', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() From 4c41246c77695913ac32834293b19bf9d0da661f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:10:14 +0000 Subject: [PATCH 074/118] Remove a TODO --- src/member-api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index 78f5ffdf5..e996d8879 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -326,7 +326,6 @@ export class MemberApi extends TypedEmitter { } if (response.status !== 200) { - // TODO: Better error handling here throw new Error( `Failed to add server peer due to HTTP status code ${response.status}` ) From bb1d2363c625b94545c3db2f133cba1fc57f4194 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:10:36 +0000 Subject: [PATCH 075/118] Also allow server to return 201 --- src/member-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index e996d8879..c184b4f47 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -325,7 +325,7 @@ export class MemberApi extends TypedEmitter { ) } - if (response.status !== 200) { + if (response.status !== 200 && response.status !== 201) { throw new Error( `Failed to add server peer due to HTTP status code ${response.status}` ) From 5e8df034067da901b0891b85162bfed9e1d4ae9d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:15:55 +0000 Subject: [PATCH 076/118] Test server returning bad data --- test-e2e/server.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test-e2e/server.js b/test-e2e/server.js index fd7b465af..5baf1f484 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -98,6 +98,42 @@ test("fails if server doesn't return a 200", { concurrency: 1 }, async (t) => { ) }) +test("fails if server doesn't return data in the right format", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + await Promise.all( + [ + '', + '{bad_json', + JSON.stringify({ data: {} }), + JSON.stringify({ data: { deviceId: 123 } }), + JSON.stringify({ deviceId: 'not under "data"' }), + ].map((responseData) => + t.test(`when returning ${responseData}`, async (t) => { + const fastify = createFastify() + fastify.post('/projects', (_req, reply) => { + reply.header('Content-Type', 'application/json').send(responseData) + }) + const url = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(url, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: + "Failed to add server peer because we couldn't parse the response", + } + ) + }) + ) + ) +}) + test('adding a server peer', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() From 297983d1b37287cc89df0c4c7d82f8d190493cc3 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:16:47 +0000 Subject: [PATCH 077/118] Fix server test concurrency --- test-e2e/server.js | 122 ++++++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 57 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 5baf1f484..ecfe1e972 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -69,70 +69,78 @@ test('invalid base URLs', async (t) => { assert(!hasServerPeer, 'no server peers should be added') }) -test("fails if server doesn't return a 200", { concurrency: 1 }, async (t) => { - const manager = createManager('device0', t) - const projectId = await manager.createProject() - const project = await manager.getProject(projectId) +test( + "fails if server doesn't return a 200", + { concurrency: true }, + async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) - await Promise.all( - [204, 302, 400, 500].map((statusCode) => - t.test(`when returning a ${statusCode}`, async (t) => { - const fastify = createFastify() - fastify.post('/projects', (_req, reply) => { - reply.status(statusCode).send() - }) - const url = await fastify.listen() - t.after(() => fastify.close()) + await Promise.all( + [204, 302, 400, 500].map((statusCode) => + t.test(`when returning a ${statusCode}`, async (t) => { + const fastify = createFastify() + fastify.post('/projects', (_req, reply) => { + reply.status(statusCode).send() + }) + const url = await fastify.listen() + t.after(() => fastify.close()) - await assert.rejects( - () => - project.$member.addServerPeer(url, { - dangerouslyAllowInsecureConnections: true, - }), - { - message: `Failed to add server peer due to HTTP status code ${statusCode}`, - } - ) - }) + await assert.rejects( + () => + project.$member.addServerPeer(url, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: `Failed to add server peer due to HTTP status code ${statusCode}`, + } + ) + }) + ) ) - ) -}) + } +) -test("fails if server doesn't return data in the right format", async (t) => { - const manager = createManager('device0', t) - const projectId = await manager.createProject() - const project = await manager.getProject(projectId) +test( + "fails if server doesn't return data in the right format", + { concurrency: true }, + async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) - await Promise.all( - [ - '', - '{bad_json', - JSON.stringify({ data: {} }), - JSON.stringify({ data: { deviceId: 123 } }), - JSON.stringify({ deviceId: 'not under "data"' }), - ].map((responseData) => - t.test(`when returning ${responseData}`, async (t) => { - const fastify = createFastify() - fastify.post('/projects', (_req, reply) => { - reply.header('Content-Type', 'application/json').send(responseData) - }) - const url = await fastify.listen() - t.after(() => fastify.close()) + await Promise.all( + [ + '', + '{bad_json', + JSON.stringify({ data: {} }), + JSON.stringify({ data: { deviceId: 123 } }), + JSON.stringify({ deviceId: 'not under "data"' }), + ].map((responseData) => + t.test(`when returning ${responseData}`, async (t) => { + const fastify = createFastify() + fastify.post('/projects', (_req, reply) => { + reply.header('Content-Type', 'application/json').send(responseData) + }) + const url = await fastify.listen() + t.after(() => fastify.close()) - await assert.rejects( - () => - project.$member.addServerPeer(url, { - dangerouslyAllowInsecureConnections: true, - }), - { - message: - "Failed to add server peer because we couldn't parse the response", - } - ) - }) + await assert.rejects( + () => + project.$member.addServerPeer(url, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: + "Failed to add server peer because we couldn't parse the response", + } + ) + }) + ) ) - ) -}) + } +) test('adding a server peer', async (t) => { const manager = createManager('device0', t) From f106f077958ccdb81306884cafea1c7f614e6e24 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 16:20:41 +0000 Subject: [PATCH 078/118] Fail if we can't connect to the server --- test-e2e/server.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test-e2e/server.js b/test-e2e/server.js index ecfe1e972..54d5f797f 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -69,6 +69,23 @@ test('invalid base URLs', async (t) => { assert(!hasServerPeer, 'no server peers should be added') }) +test("fails if we can't connect to the server", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + const url = 'http://localhost:9999' + await assert.rejects( + () => + project.$member.addServerPeer(url, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: /Failed to add server peer due to network error/, + } + ) +}) + test( "fails if server doesn't return a 200", { concurrency: true }, From d5380b63552abb06d22245a141776bd9db88af9f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 17:12:52 +0000 Subject: [PATCH 079/118] Use a port guaranteed to be open --- test-e2e/server.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 54d5f797f..8932fee83 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -185,9 +185,9 @@ test('adding a server peer', async (t) => { MEMBER_ROLE_ID, 'server peers are added as regular members' ) - assert.equal( - serverPeer.selfHostedServerDetails?.baseUrl, - serverBaseUrl, + assert.deepEqual( + new URL(serverPeer.selfHostedServerDetails?.baseUrl || ''), + new URL(serverBaseUrl), 'server peer stores base URL' ) }) @@ -329,16 +329,14 @@ async function createRemoteTestServer(t) { * @returns {Promise} server base URL */ async function createLocalTestServer(t) { - // TODO: Use a port that's guaranteed to be open - const port = 9876 const server = createServer({ ...getManagerOptions('test server'), serverName: 'test server', serverBearerToken: 'ignored', }) - await server.listen({ port }) + const address = await server.listen() t.after(() => server.close()) - return `http://localhost:${port}/` + return address } /** From 654ceb2e19a7abd3dd9c76d0a0d58e5e6adec5bc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 17:14:22 +0000 Subject: [PATCH 080/118] Remove a TODO from server test --- test-e2e/server.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 8932fee83..426a5dc20 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -20,8 +20,6 @@ import { /** @import { MapeoProject } from '../src/mapeo-project.js' */ /** @import { State as SyncState } from '../src/sync/sync-api.js' */ -// TODO: test bad requests - test('invalid base URLs', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() From 33640d14c65019de5ad5aa1bb84f97b2af75ad06 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 20:06:32 +0000 Subject: [PATCH 081/118] Handle bad server /sync endpoint --- src/mapeo-project.js | 4 ++-- src/member-api.js | 29 +++++++++++++++++++------- src/server/ws-core-replicator.js | 1 + src/sync/sync-api.js | 21 ++++++++++++++++--- test-e2e/server.js | 35 ++++++++++++++++++++++++++------ 5 files changed, 72 insertions(+), 18 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index b7051c4b4..10e68c7cd 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -314,8 +314,8 @@ export class MapeoProject extends TypedEmitter { projectKey, rpc: localPeers, getReplicationStream, - waitForInitialSyncWithPeer: (deviceId) => - this.$sync[kWaitForInitialSyncWithPeer](deviceId), + waitForInitialSyncWithPeer: (deviceId, abortSignal) => + this.$sync[kWaitForInitialSyncWithPeer](deviceId, abortSignal), dataTypes: { deviceInfo: this.#dataTypes.deviceInfo, project: this.#dataTypes.projectSettings, diff --git a/src/member-api.js b/src/member-api.js index c184b4f47..09ffacbd6 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -18,7 +18,6 @@ import timingSafeEqual from './lib/timing-safe-equal.js' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' -import { once } from 'node:events' /** * @import { * DeviceInfo, @@ -70,7 +69,7 @@ export class MemberApi extends TypedEmitter { * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc * @param {() => ReplicationStream} opts.getReplicationStream - * @param {(deviceId: string) => Promise} opts.waitForInitialSyncWithPeer + * @param {(deviceId: string, abortSignal: AbortSignal) => Promise} opts.waitForInitialSyncWithPeer * @param {Object} opts.dataTypes * @param {Pick} opts.dataTypes.deviceInfo * @param {Pick} opts.dataTypes.project @@ -371,15 +370,31 @@ export class MemberApi extends TypedEmitter { : 'wss:' const websocket = new WebSocket(websocketUrl) - websocket.on('error', noop) + await pEvent(websocket, 'open') + + const onErrorPromise = pEvent(websocket, 'error') + const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) - await this.#waitForInitialSyncWithPeer(serverDeviceId) + const syncAbortController = new AbortController() + const syncPromise = this.#waitForInitialSyncWithPeer( + serverDeviceId, + syncAbortController.signal + ) - websocket.close() - await once(websocket, 'close') - websocket.off('error', noop) + const errorEvent = await Promise.race([onErrorPromise, syncPromise]) + + if (errorEvent) { + syncAbortController.abort() + websocket.close() + throw errorEvent.error + } else { + const onClosePromise = pEvent(websocket, 'close') + onErrorPromise.cancel() + websocket.close() + await onClosePromise + } } /** diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js index 55f45627f..eb79ba8bf 100644 --- a/src/server/ws-core-replicator.js +++ b/src/server/ws-core-replicator.js @@ -5,6 +5,7 @@ import { createWebSocketStream } from 'ws' /** * @param {import('ws').WebSocket} ws * @param {import('../types.js').ReplicationStream} replicationStream + * @returns {Promise} */ export function wsCoreReplicator(ws, replicationStream) { // This is purely to satisfy typescript at its worst. `pipeline` expects a diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 20d5c7ef3..8a5ac5d63 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -418,20 +418,35 @@ export class SyncApi extends TypedEmitter { /** * @param {string} deviceId + * @param {AbortSignal} abortSignal * @returns {Promise} */ - async [kWaitForInitialSyncWithPeer](deviceId) { + async [kWaitForInitialSyncWithPeer](deviceId, abortSignal) { + abortSignal.throwIfAborted() + const state = this[kSyncState].getState() if (isInitiallySyncedWithPeer(state, deviceId)) return - return new Promise((resolve) => { + + return new Promise((resolve, reject) => { /** @param {import('./sync-state.js').State} state */ const onState = (state) => { if (isInitiallySyncedWithPeer(state, deviceId)) { - this[kSyncState].off('state', onState) + cleanup() resolve() } } + const onAbort = () => { + cleanup() + reject(abortSignal.reason) + } + + const cleanup = () => { + this[kSyncState].off('state', onState) + abortSignal.removeEventListener('abort', onAbort) + } + this[kSyncState].on('state', onState) + abortSignal.addEventListener('abort', onAbort) }) } diff --git a/test-e2e/server.js b/test-e2e/server.js index 426a5dc20..9e610208e 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -72,10 +72,10 @@ test("fails if we can't connect to the server", async (t) => { const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const url = 'http://localhost:9999' + const serverBaseUrl = 'http://localhost:9999' await assert.rejects( () => - project.$member.addServerPeer(url, { + project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }), { @@ -99,12 +99,12 @@ test( fastify.post('/projects', (_req, reply) => { reply.status(statusCode).send() }) - const url = await fastify.listen() + const serverBaseUrl = await fastify.listen() t.after(() => fastify.close()) await assert.rejects( () => - project.$member.addServerPeer(url, { + project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }), { @@ -138,12 +138,12 @@ test( fastify.post('/projects', (_req, reply) => { reply.header('Content-Type', 'application/json').send(responseData) }) - const url = await fastify.listen() + const serverBaseUrl = await fastify.listen() t.after(() => fastify.close()) await assert.rejects( () => - project.$member.addServerPeer(url, { + project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }), { @@ -157,6 +157,29 @@ test( } ) +test("fails if first request succeeds but sync doesn't", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + const fastify = createFastify() + fastify.post('/projects', (_req, reply) => { + reply.send({ data: { deviceId: 'abc123' } }) + }) + const serverBaseUrl = await fastify.listen() + t.after(() => fastify.close()) + + await assert.rejects( + () => + project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }), + { + message: /404/, + } + ) +}) + test('adding a server peer', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() From 2661bea5e0a8a8675ec9ca71a7161b055c1a3576 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 21:04:10 +0000 Subject: [PATCH 082/118] Only include photo attachments in GET /observations endpoint Closes . --- src/server/routes.js | 14 ++++++----- src/server/test/fixtures/audio.mp3 | Bin 0 -> 17181 bytes test-e2e/server/observations-endpoint.js | 29 ++++++++++++++--------- 3 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 src/server/test/fixtures/audio.mp3 diff --git a/src/server/routes.js b/src/server/routes.js index d8995b416..d252772fa 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -277,12 +277,14 @@ export default async function routes( deleted: obs.deleted, lat: obs.lat, lon: obs.lon, - attachments: obs.attachments.map((attachment) => ({ - url: new URL( - `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, - req.baseUrl - ), - })), + attachments: obs.attachments + .filter((attachment) => attachment.type === 'photo') + .map((attachment) => ({ + url: new URL( + `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, + req.baseUrl + ), + })), tags: obs.tags, }) ), diff --git a/src/server/test/fixtures/audio.mp3 b/src/server/test/fixtures/audio.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..19531804b9d3454e99c0fcd44758b6e7fc8cd474 GIT binary patch literal 17181 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 literal 0 HcmV?d00001 diff --git a/test-e2e/server/observations-endpoint.js b/test-e2e/server/observations-endpoint.js index 9efae8575..1c9df5abe 100644 --- a/test-e2e/server/observations-endpoint.js +++ b/test-e2e/server/observations-endpoint.js @@ -22,6 +22,7 @@ const FIXTURES_ROOT = new URL( const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname +const FIXTURE_AUDIO_PATH = new URL('audio.mp3', FIXTURES_ROOT).pathname test('returns a 403 if no auth is provided', async (t) => { const server = createTestServer(t) @@ -101,18 +102,24 @@ test('returning observations with fetchable attachments', async (t) => { return project.observation.delete(docId) })(), (async () => { - const blob = await project.$blobs.create( - { - original: FIXTURE_ORIGINAL_PATH, - preview: FIXTURE_PREVIEW_PATH, - thumbnail: FIXTURE_THUMBNAIL_PATH, - }, - blobMetadata({ mimeType: 'image/jpeg' }) - ) + const [imageBlob, audioBlob] = await Promise.all([ + project.$blobs.create( + { + original: FIXTURE_ORIGINAL_PATH, + preview: FIXTURE_PREVIEW_PATH, + thumbnail: FIXTURE_THUMBNAIL_PATH, + }, + blobMetadata({ mimeType: 'image/jpeg' }) + ), + project.$blobs.create( + { original: FIXTURE_AUDIO_PATH }, + blobMetadata({ mimeType: 'audio/mpeg' }) + ), + ]) /** @type {ObservationValue} */ const withAttachment = { ...valueOf(generate('observation')[0]), - attachments: [blobToAttachment(blob)], + attachments: [blobToAttachment(imageBlob), blobToAttachment(audioBlob)], } return project.observation.create(withAttachment) })(), @@ -145,7 +152,7 @@ test('returning observations with fetchable attachments', async (t) => { assert.equal(observationFromApi.lon, observation.lon) assert.equal(observationFromApi.deleted, observation.deleted) if (!observationFromApi.deleted) { - await assertAttachmentsCanBeFetched({ + await assertAttachmentsCanBeFetchedAsJpeg({ server, serverAddress, observationFromApi, @@ -185,7 +192,7 @@ function blobToAttachment(blob) { * @param {Record} options.observationFromApi * @returns {Promise} */ -async function assertAttachmentsCanBeFetched({ +async function assertAttachmentsCanBeFetchedAsJpeg({ server, serverAddress, observationFromApi, From 719df6b70539df25f1a75c70b7fed0c0f501d474 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 21:06:34 +0000 Subject: [PATCH 083/118] Move server tests to src/server/test/ --- .../server/test}/add-project-endpoint.js | 4 ++-- {test-e2e/server => src/server/test}/allowed-hosts.js | 0 .../server/test}/list-projects-endpoint.js | 2 +- .../server/test}/observations-endpoint.js | 11 ++++------- .../server/test}/server-info-endpoint.js | 0 {test-e2e/server => src/server/test}/sync-endpoint.js | 2 +- {test-e2e/server => src/server/test}/test-helpers.js | 6 +++--- 7 files changed, 11 insertions(+), 14 deletions(-) rename {test-e2e/server => src/server/test}/add-project-endpoint.js (97%) rename {test-e2e/server => src/server/test}/allowed-hosts.js (100%) rename {test-e2e/server => src/server/test}/list-projects-endpoint.js (97%) rename {test-e2e/server => src/server/test}/observations-endpoint.js (96%) rename {test-e2e/server => src/server/test}/server-info-endpoint.js (100%) rename {test-e2e/server => src/server/test}/sync-endpoint.js (96%) rename {test-e2e/server => src/server/test}/test-helpers.js (89%) diff --git a/test-e2e/server/add-project-endpoint.js b/src/server/test/add-project-endpoint.js similarity index 97% rename from test-e2e/server/add-project-endpoint.js rename to src/server/test/add-project-endpoint.js index 342bc37e2..f27e401b6 100644 --- a/test-e2e/server/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { omit } from '../../src/lib/omit.js' -import { projectKeyToPublicId } from '../../src/utils.js' +import { omit } from '../../lib/omit.js' +import { projectKeyToPublicId } from '../../utils.js' import { createTestServer, randomProjectKeys } from './test-helpers.js' test('request missing project key', async (t) => { diff --git a/test-e2e/server/allowed-hosts.js b/src/server/test/allowed-hosts.js similarity index 100% rename from test-e2e/server/allowed-hosts.js rename to src/server/test/allowed-hosts.js diff --git a/test-e2e/server/list-projects-endpoint.js b/src/server/test/list-projects-endpoint.js similarity index 97% rename from test-e2e/server/list-projects-endpoint.js rename to src/server/test/list-projects-endpoint.js index 39814536c..3ed6cc545 100644 --- a/test-e2e/server/list-projects-endpoint.js +++ b/src/server/test/list-projects-endpoint.js @@ -5,7 +5,7 @@ import { createTestServer, randomProjectKeys, } from './test-helpers.js' -import { projectKeyToPublicId } from '../../src/utils.js' +import { projectKeyToPublicId } from '../../utils.js' test('listing projects', async (t) => { const server = createTestServer(t, { allowedProjects: 999 }) diff --git a/test-e2e/server/observations-endpoint.js b/src/server/test/observations-endpoint.js similarity index 96% rename from test-e2e/server/observations-endpoint.js rename to src/server/test/observations-endpoint.js index 1c9df5abe..0b4a3d965 100644 --- a/test-e2e/server/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -4,9 +4,9 @@ import { map } from 'iterpal' import assert from 'node:assert/strict' import * as fs from 'node:fs/promises' import test from 'node:test' -import { projectKeyToPublicId } from '../../src/utils.js' -import { blobMetadata } from '../../test/helpers/blob-store.js' -import { createManager } from '../utils.js' +import { projectKeyToPublicId } from '../../utils.js' +import { blobMetadata } from '../../../test/helpers/blob-store.js' +import { createManager } from '../../../test-e2e/utils.js' import { BEARER_TOKEN, createTestServer, @@ -15,10 +15,7 @@ import { /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ -const FIXTURES_ROOT = new URL( - '../../src/server/test/fixtures/', - import.meta.url -) +const FIXTURES_ROOT = new URL('./fixtures/', import.meta.url) const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname diff --git a/test-e2e/server/server-info-endpoint.js b/src/server/test/server-info-endpoint.js similarity index 100% rename from test-e2e/server/server-info-endpoint.js rename to src/server/test/server-info-endpoint.js diff --git a/test-e2e/server/sync-endpoint.js b/src/server/test/sync-endpoint.js similarity index 96% rename from test-e2e/server/sync-endpoint.js rename to src/server/test/sync-endpoint.js index 14288ad84..4c89e0146 100644 --- a/test-e2e/server/sync-endpoint.js +++ b/src/server/test/sync-endpoint.js @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { projectKeyToPublicId } from '../../src/utils.js' +import { projectKeyToPublicId } from '../../utils.js' import { createTestServer, randomProjectKeys } from './test-helpers.js' test('sync endpoint is available after adding a project', async (t) => { diff --git a/test-e2e/server/test-helpers.js b/src/server/test/test-helpers.js similarity index 89% rename from test-e2e/server/test-helpers.js rename to src/server/test/test-helpers.js index 7a8500991..c5c0df894 100644 --- a/test-e2e/server/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -1,9 +1,9 @@ import { KeyManager } from '@mapeo/crypto' -import createServer from '../../src/server/app.js' -import { getManagerOptions } from '../utils.js' +import createServer from '../app.js' +import { getManagerOptions } from '../../../test-e2e/utils.js' import { randomBytes } from 'node:crypto' /** @import { TestContext } from 'node:test' */ -/** @import { ServerOptions } from '../../src/server/app.js' */ +/** @import { ServerOptions } from '../app.js' */ export const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') From c511a60dade8472c89285cfc2265474ddbd2b99e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 22:21:12 +0000 Subject: [PATCH 084/118] Basic server root Closes . --- src/server/routes.js | 11 ++++++++- src/server/static/index.html | 43 ++++++++++++++++++++++++++++++++++++ src/server/test/root.js | 20 +++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/server/static/index.html create mode 100644 src/server/test/root.js diff --git a/src/server/routes.js b/src/server/routes.js index d252772fa..24157bab7 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,4 +1,5 @@ import { Type } from '@sinclair/typebox' +import * as fs from 'node:fs' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' import timingSafeEqual from '../lib/timing-safe-equal.js' @@ -12,6 +13,8 @@ const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) const BASE32_REGEX_32_BYTES = '^[0-9A-Za-z]{52}$' const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) +const INDEX_HTML_PATH = new URL('./static/index.html', import.meta.url) + /** * @typedef {object} RouteOptions * @prop {string} serverBearerToken @@ -37,6 +40,12 @@ export default async function routes( } } + fastify.get('/', (_req, reply) => { + const stream = fs.createReadStream(INDEX_HTML_PATH) + reply.header('Content-Type', 'text/html') + reply.send(stream) + }) + fastify.get( '/info', { @@ -283,7 +292,7 @@ export default async function routes( url: new URL( `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, req.baseUrl - ), + ).href, })), tags: obs.tags, }) diff --git a/src/server/static/index.html b/src/server/static/index.html new file mode 100644 index 000000000..d7b253b12 --- /dev/null +++ b/src/server/static/index.html @@ -0,0 +1,43 @@ + + + + + CoMapeo + + + + +

¡Hola desde CoMapeo!

+

Olá da CoMapeo!

+

Hello from CoMapeo!

+ + diff --git a/src/server/test/root.js b/src/server/test/root.js new file mode 100644 index 000000000..9d4422eeb --- /dev/null +++ b/src/server/test/root.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createTestServer } from './test-helpers.js' + +test('server root', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'GET', + url: '/', + }) + + assert.equal(response.statusCode, 200) + const contentType = response.headers['content-type'] + assert( + typeof contentType === 'string' && contentType.startsWith('text/html'), + 'response is HTML' + ) + assert(response.body.includes(' Date: Mon, 21 Oct 2024 22:48:33 +0000 Subject: [PATCH 085/118] Return some error codes when adding peers Partly addresses #925. Not finished. --- src/lib/error-with-code.js | 20 ++++++++++++++++++++ src/member-api.js | 19 ++++++++++++------- test-e2e/server.js | 10 +++++++++- test/lib/error-with-code.js | 10 ++++++++++ 4 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 src/lib/error-with-code.js create mode 100644 test/lib/error-with-code.js diff --git a/src/lib/error-with-code.js b/src/lib/error-with-code.js new file mode 100644 index 000000000..5cb26e1b6 --- /dev/null +++ b/src/lib/error-with-code.js @@ -0,0 +1,20 @@ +/** + * Create an `Error` with a `code` property. + * + * @example + * const err = new ErrorWithCode('INVALID_DATA', 'data was invalid') + * err.message + * // => 'data was invalid' + * err.code + * // => 'INVALID_DATA' + */ +export class ErrorWithCode extends Error { + /** + * @param {string} code + * @param {string} message + */ + constructor(code, message) { + super(message) + /** @readonly */ this.code = code + } +} diff --git a/src/member-api.js b/src/member-api.js index 09ffacbd6..3bdc2abc2 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -16,6 +16,7 @@ import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' +import { ErrorWithCode } from './lib/error-with-code.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' /** @@ -273,10 +274,11 @@ export class MemberApi extends TypedEmitter { baseUrl, { dangerouslyAllowInsecureConnections = false } = {} ) { - assert( - isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections }), - 'Base URL is invalid' - ) + if ( + !isValidServerBaseUrl(baseUrl, { dangerouslyAllowInsecureConnections }) + ) { + throw new ErrorWithCode('INVALID_URL', 'Server base URL is invalid') + } const { serverDeviceId } = await this.#addServerToProject(baseUrl) @@ -319,13 +321,15 @@ export class MemberApi extends TypedEmitter { err && typeof err === 'object' && 'message' in err ? err.message : String(err) - throw new Error( + throw new ErrorWithCode( + 'NETWORK_ERROR', `Failed to add server peer due to network error: ${message}` ) } if (response.status !== 200 && response.status !== 201) { - throw new Error( + throw new ErrorWithCode( + 'INVALID_SERVER_RESPONSE', `Failed to add server peer due to HTTP status code ${response.status}` ) } @@ -344,7 +348,8 @@ export class MemberApi extends TypedEmitter { ) return { serverDeviceId: responseBody.data.deviceId } } catch (err) { - throw new Error( + throw new ErrorWithCode( + 'INVALID_SERVER_RESPONSE', "Failed to add server peer because we couldn't parse the response" ) } diff --git a/test-e2e/server.js b/test-e2e/server.js index 9e610208e..974d38c83 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -55,7 +55,10 @@ test('invalid base URLs', async (t) => { invalidUrls.map((url) => assert.rejects( () => project.$member.addServerPeer(url), - /base url is invalid/i, + { + code: 'INVALID_URL', + message: /base url is invalid/i, + }, `${url} should be invalid` ) ) @@ -79,6 +82,7 @@ test("fails if we can't connect to the server", async (t) => { dangerouslyAllowInsecureConnections: true, }), { + code: 'NETWORK_ERROR', message: /Failed to add server peer due to network error/, } ) @@ -108,6 +112,7 @@ test( dangerouslyAllowInsecureConnections: true, }), { + code: 'INVALID_SERVER_RESPONSE', message: `Failed to add server peer due to HTTP status code ${statusCode}`, } ) @@ -147,6 +152,7 @@ test( dangerouslyAllowInsecureConnections: true, }), { + code: 'INVALID_SERVER_RESPONSE', message: "Failed to add server peer because we couldn't parse the response", } @@ -175,6 +181,8 @@ test("fails if first request succeeds but sync doesn't", async (t) => { dangerouslyAllowInsecureConnections: true, }), { + // TODO + // code: 'INVALID_SERVER_RESPONSE', message: /404/, } ) diff --git a/test/lib/error-with-code.js b/test/lib/error-with-code.js new file mode 100644 index 000000000..c1db531ff --- /dev/null +++ b/test/lib/error-with-code.js @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ErrorWithCode } from '../../src/lib/error-with-code.js' + +test('ErrorWithCode', () => { + const err = new ErrorWithCode('MY_CODE', 'my message') + assert.equal(err.code, 'MY_CODE') + assert.equal(err.message, 'my message') + assert(err instanceof Error) +}) From fc1ad5299a9dc1674d2e2a542ad2a73ac472d33e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 23:34:09 +0000 Subject: [PATCH 086/118] Add some failure codes when adding a server Partly addresses #925. --- src/lib/error-with-code.js | 20 ----------- src/lib/error.js | 47 ++++++++++++++++++++++++++ src/member-api.js | 31 +++++++++++++++-- test-e2e/server.js | 15 ++++++--- test/lib/error-with-code.js | 10 ------ test/lib/error.js | 66 +++++++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 36 deletions(-) delete mode 100644 src/lib/error-with-code.js create mode 100644 src/lib/error.js delete mode 100644 test/lib/error-with-code.js create mode 100644 test/lib/error.js diff --git a/src/lib/error-with-code.js b/src/lib/error-with-code.js deleted file mode 100644 index 5cb26e1b6..000000000 --- a/src/lib/error-with-code.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Create an `Error` with a `code` property. - * - * @example - * const err = new ErrorWithCode('INVALID_DATA', 'data was invalid') - * err.message - * // => 'data was invalid' - * err.code - * // => 'INVALID_DATA' - */ -export class ErrorWithCode extends Error { - /** - * @param {string} code - * @param {string} message - */ - constructor(code, message) { - super(message) - /** @readonly */ this.code = code - } -} diff --git a/src/lib/error.js b/src/lib/error.js new file mode 100644 index 000000000..41cbe5544 --- /dev/null +++ b/src/lib/error.js @@ -0,0 +1,47 @@ +/** + * Create an `Error` with a `code` property. + * + * @example + * const err = new ErrorWithCode('INVALID_DATA', 'data was invalid') + * err.message + * // => 'data was invalid' + * err.code + * // => 'INVALID_DATA' + */ +export class ErrorWithCode extends Error { + /** + * @param {string} code + * @param {string} message + * @param {object} [options] + * @param {unknown} [options.cause] + */ + constructor(code, message, options) { + super(message, options) + /** @readonly */ this.code = code + } +} + +/** + * Get the error message from an object if possible. + * Otherwise, stringify the argument. + * + * @param {unknown} maybeError + * @returns {string} + * @example + * try { + * // do something + * } catch (err) { + * console.error(getErrorMessage(err)) + * } + */ +export function getErrorMessage(maybeError) { + if (maybeError && typeof maybeError === 'object' && 'message' in maybeError) { + try { + const { message } = maybeError + if (typeof message === 'string') return message + } catch (_err) { + // Ignored + } + } + return 'unknown error' +} diff --git a/src/member-api.js b/src/member-api.js index 3bdc2abc2..670ca942d 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -16,7 +16,7 @@ import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from './lib/timing-safe-equal.js' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' -import { ErrorWithCode } from './lib/error-with-code.js' +import { ErrorWithCode } from './lib/error.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' import { wsCoreReplicator } from './server/ws-core-replicator.js' /** @@ -265,6 +265,17 @@ export class MemberApi extends TypedEmitter { /** * Add a server peer. * + * Can reject with any of the following error codes (accessed via `err.code`): + * + * - `INVALID_URL`: the base URL is invalid, likely due to user error. + * - `NETWORK_ERROR`: there was an issue connecting to the server. Is the + * device online? Is the server online? + * - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned + * an unexpected response. Is the server running a compatible version of + * CoMapeo Cloud? + * + * If `err.code` is not specified, that indicates a bug in this module. + * * @param {string} baseUrl * @param {object} [options] * @param {boolean} [options.dangerouslyAllowInsecureConnections] @@ -375,7 +386,23 @@ export class MemberApi extends TypedEmitter { : 'wss:' const websocket = new WebSocket(websocketUrl) - await pEvent(websocket, 'open') + + try { + await pEvent(websocket, 'open', { rejectionEvents: ['error'] }) + } catch (rejectionEvent) { + throw new ErrorWithCode( + // It's difficult for us to reliably disambiguate between "network error" + // and "invalid response from server" here, so we just say it was an + // invalid server response. + 'INVALID_SERVER_RESPONSE', + 'Failed to open the socket', + rejectionEvent && + typeof rejectionEvent === 'object' && + 'error' in rejectionEvent + ? { cause: rejectionEvent.error } + : { cause: rejectionEvent } + ) + } const onErrorPromise = pEvent(websocket, 'error') diff --git a/test-e2e/server.js b/test-e2e/server.js index 974d38c83..a625fdf03 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -180,10 +180,17 @@ test("fails if first request succeeds but sync doesn't", async (t) => { project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }), - { - // TODO - // code: 'INVALID_SERVER_RESPONSE', - message: /404/, + (err) => { + assert(err instanceof Error, 'receives an error') + assert('code' in err, 'gets an error code') + assert.equal( + err.code, + 'INVALID_SERVER_RESPONSE', + 'gets the correct error code' + ) + assert(err.cause instanceof Error, 'error has a cause') + assert(err.cause.message.includes('404'), 'error cause is an HTTP 404') + return true } ) }) diff --git a/test/lib/error-with-code.js b/test/lib/error-with-code.js deleted file mode 100644 index c1db531ff..000000000 --- a/test/lib/error-with-code.js +++ /dev/null @@ -1,10 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { ErrorWithCode } from '../../src/lib/error-with-code.js' - -test('ErrorWithCode', () => { - const err = new ErrorWithCode('MY_CODE', 'my message') - assert.equal(err.code, 'MY_CODE') - assert.equal(err.message, 'my message') - assert(err instanceof Error) -}) diff --git a/test/lib/error.js b/test/lib/error.js new file mode 100644 index 000000000..c7de6ea98 --- /dev/null +++ b/test/lib/error.js @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict' +import test, { describe } from 'node:test' +import { ErrorWithCode, getErrorMessage } from '../../src/lib/error.js' + +describe('ErrorWithCode', () => { + test('ErrorWithCode with two arguments', () => { + const err = new ErrorWithCode('MY_CODE', 'my message') + assert.equal(err.code, 'MY_CODE') + assert.equal(err.message, 'my message') + assert(err instanceof Error) + }) + + test('ErrorWithCode with three arguments', () => { + const otherError = new Error('hello') + const err = new ErrorWithCode('MY_CODE', 'my message', { + cause: otherError, + }) + assert.equal(err.code, 'MY_CODE') + assert.equal(err.message, 'my message') + assert.equal(err.cause, otherError) + assert(err instanceof Error) + }) +}) + +describe('getErrorMessage', () => { + test('from objects without a string message', () => { + const testCases = [ + undefined, + null, + ['ignored'], + { message: 123 }, + { + get message() { + throw new Error('this should not crash') + }, + }, + ] + + for (const testCase of testCases) { + assert.equal(getErrorMessage(testCase), 'unknown error') + } + }) + + test('from objects with a string message', () => { + class WithInheritedMessage { + get message() { + return 'foo' + } + } + + const testCases = [ + { message: 'foo' }, + new Error('foo'), + { + get message() { + return 'foo' + }, + }, + new WithInheritedMessage(), + ] + + for (const testCase of testCases) { + assert.equal(getErrorMessage(testCase), 'foo') + } + }) +}) From 1c025a1a987c4c2af81ba19d0d6bd52ed0033d2c Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 23:39:23 +0000 Subject: [PATCH 087/118] test: use helper for finding server peer --- test-e2e/server.js | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index a625fdf03..523f50f5e 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -18,6 +18,7 @@ import { } from './utils.js' /** @import { MapeoManager } from '../src/mapeo-manager.js' */ /** @import { MapeoProject } from '../src/mapeo-project.js' */ +/** @import { MemberInfo } from '../src/member-api.js' */ /** @import { State as SyncState } from '../src/sync/sync-api.js' */ test('invalid base URLs', async (t) => { @@ -64,10 +65,7 @@ test('invalid base URLs', async (t) => { ) ) - const hasServerPeer = (await project.$member.getMany()).some( - (member) => member.deviceType === 'selfHostedServer' - ) - assert(!hasServerPeer, 'no server peers should be added') + assert(!(await findServerPeer(project)), 'no server peers should be added') }) test("fails if we can't connect to the server", async (t) => { @@ -202,18 +200,13 @@ test('adding a server peer', async (t) => { const serverBaseUrl = await createTestServer(t) - const hasServerPeerBeforeAdding = (await project.$member.getMany()).some( - (member) => member.deviceType === 'selfHostedServer' - ) - assert(!hasServerPeerBeforeAdding, 'no server peers before adding') + assert(!(await findServerPeer(project)), 'no server peers before adding') await project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) - const serverPeer = (await project.$member.getMany()).find( - (member) => member.deviceType === 'selfHostedServer' - ) + const serverPeer = await findServerPeer(project) assert(serverPeer, 'expected a server peer to be found by the client') assert.equal(serverPeer.name, 'test server', 'server peers have name') assert.equal( @@ -262,15 +255,12 @@ test('data can be synced via a server', async (t) => { await managerAProject.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) - const serverDeviceIdPromise = managerAProject.$member - .getMany() - .then((members) => { - const serverMember = members.find( - (member) => member.deviceType === 'selfHostedServer' - ) + const serverDeviceIdPromise = findServerPeer(managerAProject).then( + (serverMember) => { assert(serverMember, 'Manager A must have a server member') return serverMember.deviceId - }) + } + ) // Add Manager B to project const disconnectManagers = connectPeers(managers) @@ -283,10 +273,7 @@ test('data can be synced via a server', async (t) => { // Sync managers to tell Manager B about the server const projects = [managerAProject, managerBProject] await waitForSync(projects, 'initial') - const managerBMembers = await managerBProject.$member.getMany() - const serverPeer = managerBMembers.find( - (member) => member.deviceType === 'selfHostedServer' - ) + const serverPeer = await findServerPeer(managerBProject) assert(serverPeer, 'expected a server peer to be found by the client') // Manager A adds data that Manager B doesn't know about @@ -375,6 +362,16 @@ async function createLocalTestServer(t) { return address } +/** + * @param {MapeoProject} project + * @returns {Promise} + */ +async function findServerPeer(project) { + return (await project.$member.getMany()).find( + (member) => member.deviceType === 'selfHostedServer' + ) +} + /** * @param {MapeoManager} manager * @returns {Promise} From b9dd1c06a8de98414a620658a61e6188385ef147 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 21 Oct 2024 23:48:29 +0000 Subject: [PATCH 088/118] Skeleton removal of server peer --- src/member-api.js | 9 +++++++++ test-e2e/server.js | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index 670ca942d..b3c49086e 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -366,6 +366,15 @@ export class MemberApi extends TypedEmitter { } } + /** + * @param {string} serverDeviceId + * @returns {Promise} + */ + async removeServerPeer(serverDeviceId) { + // TODO + console.log({ serverDeviceId }) + } + /** * @param {object} options * @param {string} options.baseUrl diff --git a/test-e2e/server.js b/test-e2e/server.js index 523f50f5e..3f09d3d9b 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -5,7 +5,7 @@ import createFastify from 'fastify' import assert from 'node:assert/strict' import test from 'node:test' import { pEvent } from 'p-event' -import { MEMBER_ROLE_ID } from '../src/roles.js' +import { LEFT_ROLE_ID, MEMBER_ROLE_ID } from '../src/roles.js' import createServer from '../src/server/app.js' import { connectPeers, @@ -221,6 +221,30 @@ test('adding a server peer', async (t) => { ) }) +test.skip('removing a server peer', async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject() + const project = await manager.getProject(projectId) + + const serverBaseUrl = await createTestServer(t) + + await project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }) + + const serverPeer = await findServerPeer(project) + assert(serverPeer, 'server peer should be added') + await project.$member.removeServerPeer(serverPeer.deviceId) + + assert.equal( + (await findServerPeer(project))?.role.roleId, + LEFT_ROLE_ID, + 'we should believe the server is gone' + ) + + // TODO: ensure no connections are made +}) + test("can't add a server to two different projects", async (t) => { const [managerA, managerB] = await createManagers(2, t, 'mobile') const projectIdA = await managerA.createProject() From 749c467f531b6a1007a1f622a34df3faaa9dc4bf Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 17 Oct 2024 22:38:16 +0000 Subject: [PATCH 089/118] WIP --- src/server/routes.js | 22 +++++----- src/server/test/add-project-endpoint.js | 54 ++++++++++++++++--------- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 24157bab7..484d4eb53 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -160,15 +160,6 @@ export default async function routes( const projectPublicId = projectKeyToPublicId(projectKey) const existingProjects = await this.comapeo.listProjects() - if ( - typeof allowedProjectsSetOrNumber === 'number' && - existingProjects.length >= allowedProjectsSetOrNumber - ) { - throw fastify.httpErrors.forbidden( - 'Server is already linked to the maximum number of projects' - ) - } - if ( allowedProjectsSetOrNumber instanceof Set && !allowedProjectsSetOrNumber.has(projectPublicId) @@ -176,8 +167,19 @@ export default async function routes( throw fastify.httpErrors.forbidden('Project not allowed') } - if (existingProjects.find((p) => p.projectId === projectPublicId)) { + const existingProject = existingProjects.find( + (p) => p.projectId === projectPublicId + ) + if (existingProject) { + // TODO: This should only throw if the encryption keys are different. throw fastify.httpErrors.badRequest('Project already exists') + } else if ( + typeof allowedProjectsSetOrNumber === 'number' && + existingProjects.length >= allowedProjectsSetOrNumber + ) { + throw fastify.httpErrors.forbidden( + 'Server is already linked to the maximum number of projects' + ) } const baseUrl = req.baseUrl.toString() diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index f27e401b6..e7d148b68 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -135,28 +135,44 @@ test( } ) -// TODO: This test is wrong. Adding the same project twice should be idempotent. -test('trying to create the same project twice fails', async (t) => { - const server = createTestServer(t, { allowedProjects: 2 }) - +test('adding the same project twice is idempotent', async (t) => { + const server = createTestServer(t, { allowedProjects: 1 }) const projectKeys = randomProjectKeys() - await t.test('add project first time succeeds', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.equal(response.statusCode, 200) + const firstResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, }) + assert.equal(firstResponse.statusCode, 200) - await t.test('attempt to re-add same project fails', async () => { - const response = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys, - }) - assert.equal(response.statusCode, 400) - assert.match(response.json().message, /already exists/) + const secondResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys, + }) + assert.equal(secondResponse.statusCode, 200) +}) + +test('adding a project ID with different encryption keys is an error', async (t) => { + const server = createTestServer(t, { allowedProjects: 1 }) + const projectKeys1 = randomProjectKeys() + const projectKeys2 = { + ...projectKeys1, + encryptionKeys: randomProjectKeys().encryptionKeys, + } + + const firstResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys1, + }) + assert.equal(firstResponse.statusCode, 200) + + const secondResponse = await server.inject({ + method: 'POST', + url: '/projects', + body: projectKeys2, }) + assert.equal(secondResponse.statusCode, 403) }) From 503b17eebcabc6b5d83ec2da7a14a1d3a91bccfb Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 22 Oct 2024 18:58:41 +0000 Subject: [PATCH 090/118] Use 409, not 403, for duplicates --- src/server/test/add-project-endpoint.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index e7d148b68..a973762de 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -174,5 +174,5 @@ test('adding a project ID with different encryption keys is an error', async (t) url: '/projects', body: projectKeys2, }) - assert.equal(secondResponse.statusCode, 403) + assert.equal(secondResponse.statusCode, 409) }) From 919240434ec85f98cd576c0898bda0f02273ea22 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 22 Oct 2024 21:13:12 +0000 Subject: [PATCH 091/118] Adding projects multiple times is idempotent --- src/server/routes.js | 75 ++++++++++++++----------- src/server/test/add-project-endpoint.js | 23 -------- 2 files changed, 41 insertions(+), 57 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 484d4eb53..0d06e3b6f 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -3,7 +3,7 @@ import * as fs from 'node:fs' import { kProjectReplicate } from '../mapeo-project.js' import { wsCoreReplicator } from './ws-core-replicator.js' import timingSafeEqual from '../lib/timing-safe-equal.js' -import { projectKeyToPublicId } from '../utils.js' +import { assert, projectKeyToPublicId } from '../utils.js' /** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ @@ -158,28 +158,28 @@ export default async function routes( async function (req, reply) { const projectKey = Buffer.from(req.body.projectKey, 'hex') const projectPublicId = projectKeyToPublicId(projectKey) + const existingProjects = await this.comapeo.listProjects() + const alreadyHasThisProject = existingProjects.some((p) => + timingSafeEqual(p.projectId, projectPublicId) + ) - if ( - allowedProjectsSetOrNumber instanceof Set && - !allowedProjectsSetOrNumber.has(projectPublicId) - ) { - throw fastify.httpErrors.forbidden('Project not allowed') - } + if (!alreadyHasThisProject) { + if ( + allowedProjectsSetOrNumber instanceof Set && + !allowedProjectsSetOrNumber.has(projectPublicId) + ) { + throw fastify.httpErrors.forbidden('Project not allowed') + } - const existingProject = existingProjects.find( - (p) => p.projectId === projectPublicId - ) - if (existingProject) { - // TODO: This should only throw if the encryption keys are different. - throw fastify.httpErrors.badRequest('Project already exists') - } else if ( - typeof allowedProjectsSetOrNumber === 'number' && - existingProjects.length >= allowedProjectsSetOrNumber - ) { - throw fastify.httpErrors.forbidden( - 'Server is already linked to the maximum number of projects' - ) + if ( + typeof allowedProjectsSetOrNumber === 'number' && + existingProjects.length >= allowedProjectsSetOrNumber + ) { + throw fastify.httpErrors.forbidden( + 'Server is already linked to the maximum number of projects' + ) + } } const baseUrl = req.baseUrl.toString() @@ -198,21 +198,28 @@ export default async function routes( }) } - const projectId = await this.comapeo.addProject( - { - projectKey, - projectName: 'TODO: Figure out if this should be named', - encryptionKeys: { - auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), - config: Buffer.from(req.body.encryptionKeys.config, 'hex'), - data: Buffer.from(req.body.encryptionKeys.data, 'hex'), - blobIndex: Buffer.from(req.body.encryptionKeys.blobIndex, 'hex'), - blob: Buffer.from(req.body.encryptionKeys.blob, 'hex'), + if (!alreadyHasThisProject) { + const projectId = await this.comapeo.addProject( + { + projectKey, + projectName: 'TODO: Figure out if this should be named', + encryptionKeys: { + auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), + config: Buffer.from(req.body.encryptionKeys.config, 'hex'), + data: Buffer.from(req.body.encryptionKeys.data, 'hex'), + blobIndex: Buffer.from(req.body.encryptionKeys.blobIndex, 'hex'), + blob: Buffer.from(req.body.encryptionKeys.blob, 'hex'), + }, }, - }, - { waitForSync: false } - ) - const project = await this.comapeo.getProject(projectId) + { waitForSync: false } + ) + assert( + projectId === projectPublicId, + 'adding a project should return the same ID as what was passed' + ) + } + + const project = await this.comapeo.getProject(projectPublicId) project.$sync.start() reply.send({ diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index a973762de..cc39dcbcb 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -153,26 +153,3 @@ test('adding the same project twice is idempotent', async (t) => { }) assert.equal(secondResponse.statusCode, 200) }) - -test('adding a project ID with different encryption keys is an error', async (t) => { - const server = createTestServer(t, { allowedProjects: 1 }) - const projectKeys1 = randomProjectKeys() - const projectKeys2 = { - ...projectKeys1, - encryptionKeys: randomProjectKeys().encryptionKeys, - } - - const firstResponse = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys1, - }) - assert.equal(firstResponse.statusCode, 200) - - const secondResponse = await server.inject({ - method: 'POST', - url: '/projects', - body: projectKeys2, - }) - assert.equal(secondResponse.statusCode, 409) -}) From 9625729bd24becefbe303256b2943f15887a2e78 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 22 Oct 2024 21:15:28 +0000 Subject: [PATCH 092/118] Add a comment --- src/server/routes.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/server/routes.js b/src/server/routes.js index 0d06e3b6f..2d1f16e51 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -160,6 +160,12 @@ export default async function routes( const projectPublicId = projectKeyToPublicId(projectKey) const existingProjects = await this.comapeo.listProjects() + + // This assumes that two projects with the same project key are equivalent, + // and that we don't need to add more. Theoretically, someone could add + // project with ID 1 and keys A, then add project with ID 1 and keys B. + // This would mean a malicious/buggy client, which could cause errors if + // trying to sync with this server--that seems acceptable. const alreadyHasThisProject = existingProjects.some((p) => timingSafeEqual(p.projectId, projectPublicId) ) From 94232858d26b65647136e8a98fc7c73e0a5b1482 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 22 Oct 2024 21:15:56 +0000 Subject: [PATCH 093/118] Add another comment --- src/server/routes.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/routes.js b/src/server/routes.js index 2d1f16e51..802407a42 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -167,6 +167,8 @@ export default async function routes( // This would mean a malicious/buggy client, which could cause errors if // trying to sync with this server--that seems acceptable. const alreadyHasThisProject = existingProjects.some((p) => + // We don't want people to be able to enumerate the project keys that + // this server has. timingSafeEqual(p.projectId, projectPublicId) ) From cb8ebd3f5042e6dd16b54a23803fa2e7e5793b29 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Wed, 23 Oct 2024 10:55:58 +0100 Subject: [PATCH 094/118] feat: convert server to fastify plugin (#919) Co-authored-by: Evan Hahn --- src/server/app.js | 35 ++++++++++++++++----------------- src/server/server.js | 11 +++++++---- src/server/test/test-helpers.js | 10 ++++++---- test-e2e/server.js | 5 +++-- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/server/app.js b/src/server/app.js index 6685793c4..a87b292c3 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -1,6 +1,6 @@ import fastifyWebsocket from '@fastify/websocket' -import createFastify from 'fastify' import fastifySensible from '@fastify/sensible' +import createFastifyPlugin from 'fastify-plugin' import routes from './routes.js' import comapeoPlugin from './comapeo-plugin.js' import baseUrlPlugin from './base-url-plugin.js' @@ -12,27 +12,22 @@ import allowedHostsPlugin from './allowed-hosts-plugin.js' /** * @internal * @typedef {object} OtherServerOptions - * @prop {FastifyServerOptions['logger']} [logger] - * @prop {FastifyServerOptions['trustProxy']} [trustProxy] * @prop {string[]} [allowedHosts] */ /** @typedef {ComapeoPluginOptions & OtherServerOptions & RouteOptions} ServerOptions */ -/** - * @param {ServerOptions} opts - * @returns - */ -export default function createServer({ - logger, - trustProxy, - serverBearerToken, - serverName, - allowedHosts, - allowedProjects = 1, - ...comapeoPluginOpts -}) { - const fastify = createFastify({ logger, trustProxy }) +/** @type {import('fastify').FastifyPluginAsync} */ +async function comapeoServer( + fastify, + { + serverBearerToken, + serverName, + allowedHosts, + allowedProjects = 1, + ...comapeoPluginOpts + } +) { fastify.register(fastifyWebsocket) fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) fastify.register(allowedHostsPlugin, { allowedHosts }) @@ -43,5 +38,9 @@ export default function createServer({ serverName, allowedProjects, }) - return fastify } + +export default createFastifyPlugin(comapeoServer, { + name: 'comapeoServer', + fastify: '4.x', +}) diff --git a/src/server/server.js b/src/server/server.js index 1cbcd4b41..e42a2c67d 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -1,5 +1,6 @@ -import createServer from './app.js' +import comapeoServer from './app.js' import envSchema from 'env-schema' +import createFastify from 'fastify' import { Type } from '@sinclair/typebox' import path from 'node:path' import fsPromises from 'node:fs/promises' @@ -72,7 +73,11 @@ if (!rootKey || rootKey.length !== 16) { throw new Error('Root key must be 16 bytes') } -const fastify = createServer({ +const fastify = createFastify({ + logger: true, + trustProxy: true, +}) +fastify.register(comapeoServer, { serverName: config.SERVER_NAME, serverBearerToken: config.SERVER_BEARER_TOKEN, allowedProjects: config.ALLOWED_PROJECTS, @@ -81,8 +86,6 @@ const fastify = createServer({ dbFolder, projectMigrationsFolder, clientMigrationsFolder, - logger: true, - trustProxy: true, }) fastify.get('/healthcheck', async () => {}) diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js index c5c0df894..be46b7770 100644 --- a/src/server/test/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -1,7 +1,8 @@ import { KeyManager } from '@mapeo/crypto' -import createServer from '../app.js' -import { getManagerOptions } from '../../../test-e2e/utils.js' +import createFastify from 'fastify' import { randomBytes } from 'node:crypto' +import { getManagerOptions } from '../../../test-e2e/utils.js' +import comapeoServer from '../app.js' /** @import { TestContext } from 'node:test' */ /** @import { ServerOptions } from '../app.js' */ @@ -15,14 +16,15 @@ const TEST_SERVER_DEFAULTS = { /** * @param {TestContext} t * @param {Partial} [serverOptions] - * @returns {ReturnType & { deviceId: string }} + * @returns {import('fastify').FastifyInstance & { deviceId: string }} */ export function createTestServer(t, serverOptions) { const serverName = serverOptions?.serverName || TEST_SERVER_DEFAULTS.serverName const managerOptions = getManagerOptions(serverName) const km = new KeyManager(managerOptions.rootKey) - const server = createServer({ + const server = createFastify() + server.register(comapeoServer, { ...managerOptions, ...TEST_SERVER_DEFAULTS, ...serverOptions, diff --git a/test-e2e/server.js b/test-e2e/server.js index 3f09d3d9b..a87d74f7d 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -6,7 +6,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { pEvent } from 'p-event' import { LEFT_ROLE_ID, MEMBER_ROLE_ID } from '../src/roles.js' -import createServer from '../src/server/app.js' +import comapeoServer from '../src/server/app.js' import { connectPeers, createManager, @@ -376,7 +376,8 @@ async function createRemoteTestServer(t) { * @returns {Promise} server base URL */ async function createLocalTestServer(t) { - const server = createServer({ + const server = createFastify() + server.register(comapeoServer, { ...getManagerOptions('test server'), serverName: 'test server', serverBearerToken: 'ignored', From d1db84c65a909ffb2fe35e94d425e3a119ab8114 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 15:41:37 +0000 Subject: [PATCH 095/118] More work on removing server peers --- src/sync/sync-api.js | 25 +++++++++++--- test-e2e/server.js | 80 +++++++++++++++++++++++++++++++++++--------- 2 files changed, 86 insertions(+), 19 deletions(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 8a5ac5d63..1ac5efac6 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -13,6 +13,7 @@ import { getOwn } from '../lib/get-own.js' import { NO_ROLE_ID } from '../roles.js' import { wsCoreReplicator } from '../server/ws-core-replicator.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ +/** @import * as http from 'node:http' */ /** @import { CoreOwnership } from '../core-ownership.js' */ /** @import { OpenedNoiseStream } from '../lib/noise-secret-stream-helpers.js' */ /** @import { ReplicationStream } from '../types.js' */ @@ -314,17 +315,33 @@ export class SyncApi extends TypedEmitter { } const websocket = new WebSocket(url) - // TODO: Handle websocket errors - websocket.on('error', noop) - // TODO: Handle errors (maybe with the `unexpected-response` event?) + /** @param {Error} err */ + const onWebsocketError = (err) => { + this.#l.log('Ignoring WebSocket error to %s: %o', url, err) + } + websocket.on('error', onWebsocketError) + + /** + * @param {unknown} _req + * @param {http.IncomingMessage} res + */ + const onWebsocketUnexpectedResponse = (_req, res) => { + this.#l.log( + 'Ignoring unexpected %d WebSocket response to %s', + res.statusCode, + url + ) + } + websocket.on('unexpected-response', onWebsocketUnexpectedResponse) const replicationStream = this.#getReplicationStream() wsCoreReplicator(websocket, replicationStream) this.#serverWebsockets.set(url, websocket) websocket.once('close', () => { - websocket.off('error', noop) + websocket.off('error', onWebsocketError) + websocket.off('unexpected-response', onWebsocketUnexpectedResponse) this.#serverWebsockets.delete(url) }) } diff --git a/test-e2e/server.js b/test-e2e/server.js index a87d74f7d..b4a3ac24a 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,9 +1,10 @@ import { valueOf } from '@comapeo/schema' +import { setTimeout as delay } from 'node:timers/promises' import { generate } from '@mapeo/mock-data' import { execa } from 'execa' import createFastify from 'fastify' import assert from 'node:assert/strict' -import test from 'node:test' +import test, { mock } from 'node:test' import { pEvent } from 'p-event' import { LEFT_ROLE_ID, MEMBER_ROLE_ID } from '../src/roles.js' import comapeoServer from '../src/server/app.js' @@ -16,11 +17,14 @@ import { waitForPeers, waitForSync, } from './utils.js' +/** @import { FastifyInstance } from 'fastify' */ /** @import { MapeoManager } from '../src/mapeo-manager.js' */ /** @import { MapeoProject } from '../src/mapeo-project.js' */ /** @import { MemberInfo } from '../src/member-api.js' */ /** @import { State as SyncState } from '../src/sync/sync-api.js' */ +const USE_REMOTE_SERVER = Boolean(process.env.REMOTE_TEST_SERVER) + test('invalid base URLs', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() @@ -198,7 +202,7 @@ test('adding a server peer', async (t) => { const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const serverBaseUrl = await createTestServer(t) + const { serverBaseUrl } = await createTestServer(t) assert(!(await findServerPeer(project)), 'no server peers before adding') @@ -226,7 +230,8 @@ test.skip('removing a server peer', async (t) => { const projectId = await manager.createProject() const project = await manager.getProject(projectId) - const serverBaseUrl = await createTestServer(t) + const testServer = await createTestServer(t) + const { serverBaseUrl } = testServer await project.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, @@ -234,6 +239,7 @@ test.skip('removing a server peer', async (t) => { const serverPeer = await findServerPeer(project) assert(serverPeer, 'server peer should be added') + await project.$member.removeServerPeer(serverPeer.deviceId) assert.equal( @@ -242,7 +248,38 @@ test.skip('removing a server peer', async (t) => { 'we should believe the server is gone' ) - // TODO: ensure no connections are made + // If we don't have access to the server (e.g., if it's remote), we can't run + // this part of the test. We could probably support this, but it's a lot more + // work for limited benefit. + if ('server' in testServer) { + await testServer.server.close() + + const bogusServer = createFastify() + const anyRequestHandler = mock.fn(() => 'should not happen') + bogusServer.all('*', anyRequestHandler) + + const { port } = new URL(serverBaseUrl) + const bogusServerAddress = await bogusServer.listen({ + // host, + port: Number(port), + }) + t.after(() => bogusServer.close()) + assert.equal( + bogusServerAddress, + serverBaseUrl, + 'Bogus server should have same address as "real" test server. Test is not set up correctly.' + ) + + project.$sync.connectServers() + + await delay(500) + + assert.strictEqual( + anyRequestHandler.mock.calls.length, + 0, + 'no connection was made to the server' + ) + } }) test("can't add a server to two different projects", async (t) => { @@ -252,21 +289,21 @@ test("can't add a server to two different projects", async (t) => { const projectA = await managerA.getProject(projectIdA) const projectB = await managerB.getProject(projectIdB) - const serverHost = await createTestServer(t) + const { serverBaseUrl } = await createTestServer(t) - await projectA.$member.addServerPeer(serverHost, { + await projectA.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) await assert.rejects(async () => { - await projectB.$member.addServerPeer(serverHost, { + await projectB.$member.addServerPeer(serverBaseUrl, { dangerouslyAllowInsecureConnections: true, }) }, Error) }) test('data can be synced via a server', async (t) => { - const [managers, serverBaseUrl] = await Promise.all([ + const [managers, { serverBaseUrl }] = await Promise.all([ createManagers(2, t, 'mobile'), createTestServer(t), ]) @@ -328,12 +365,25 @@ test('data can be synced via a server', async (t) => { ) }) +/** + * @typedef {object} LocalTestServer + * @prop {'local'} type + * @prop {string} serverBaseUrl + * @prop {FastifyInstance} server + */ + +/** + * @typedef {object} RemoteTestServer + * @prop {'remote'} type + * @prop {string} serverBaseUrl + */ + /** * @param {import('node:test').TestContext} t - * @returns {Promise} server base URL + * @returns {Promise} */ async function createTestServer(t) { - if (process.env.REMOTE_TEST_SERVER) { + if (USE_REMOTE_SERVER) { return createRemoteTestServer(t) } else { return createLocalTestServer(t) @@ -342,7 +392,7 @@ async function createTestServer(t) { /** * @param {import('node:test').TestContext} t - * @returns {Promise} server base URL + * @returns {Promise} */ async function createRemoteTestServer(t) { const appName = 'comapeo-cloud-test-' + Math.random().toString(36).slice(8) @@ -368,12 +418,12 @@ async function createRemoteTestServer(t) { stdio: 'inherit', } ) - return `https://${appName}.fly.dev/` + return { type: 'remote', serverBaseUrl: `https://${appName}.fly.dev/` } } /** * @param {import('node:test').TestContext} t - * @returns {Promise} server base URL + * @returns {Promise} */ async function createLocalTestServer(t) { const server = createFastify() @@ -382,9 +432,9 @@ async function createLocalTestServer(t) { serverName: 'test server', serverBearerToken: 'ignored', }) - const address = await server.listen() + const serverBaseUrl = await server.listen() t.after(() => server.close()) - return address + return { type: 'local', server, serverBaseUrl } } /** From 9b0d820eafe1b80ce9abeff56ac2f77d2972bf50 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 15:42:03 +0000 Subject: [PATCH 096/118] Minor: reorder two methods --- src/member-api.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index b3c49086e..0410510ba 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -366,15 +366,6 @@ export class MemberApi extends TypedEmitter { } } - /** - * @param {string} serverDeviceId - * @returns {Promise} - */ - async removeServerPeer(serverDeviceId) { - // TODO - console.log({ serverDeviceId }) - } - /** * @param {object} options * @param {string} options.baseUrl @@ -438,6 +429,15 @@ export class MemberApi extends TypedEmitter { } } + /** + * @param {string} serverDeviceId + * @returns {Promise} + */ + async removeServerPeer(serverDeviceId) { + // TODO + console.log({ serverDeviceId }) + } + /** * @param {string} deviceId * @returns {Promise} From 43f07ec5ad3b0ecf173cdd6061b2cc2e7c2e55e9 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 17:57:11 +0000 Subject: [PATCH 097/118] Remove unnecessary server close --- src/server/test/observations-endpoint.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js index 0b4a3d965..5cb4231db 100644 --- a/src/server/test/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -70,7 +70,6 @@ test('returning observations with fetchable attachments', async (t) => { const serverAddress = await server.listen() const serverUrl = new URL(serverAddress) - t.after(() => server.close()) const manager = await createManager('client', t) const projectId = await manager.createProject() From a2ca81ee7e970b34f88f2c713057015fcf0227e1 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 17:57:49 +0000 Subject: [PATCH 098/118] test: remove unnecessary `await` --- src/server/test/observations-endpoint.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js index 5cb4231db..d93fcf4d1 100644 --- a/src/server/test/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -71,7 +71,7 @@ test('returning observations with fetchable attachments', async (t) => { const serverAddress = await server.listen() const serverUrl = new URL(serverAddress) - const manager = await createManager('client', t) + const manager = createManager('client', t) const projectId = await manager.createProject() const project = await manager.getProject(projectId) From 23b796271914088b36bfd31cc5b73a37dab6f8ec Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 17:59:36 +0000 Subject: [PATCH 099/118] Remove unused test file --- test/server/ws-core-replicator.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test/server/ws-core-replicator.js diff --git a/test/server/ws-core-replicator.js b/test/server/ws-core-replicator.js deleted file mode 100644 index e69de29bb..000000000 From 883d14bf802859b91f7371365e5ca50298d98f3d Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 23 Oct 2024 18:43:38 +0000 Subject: [PATCH 100/118] Remove removeServerPeer --- src/member-api.js | 9 --------- test-e2e/server.js | 5 ++++- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 0410510ba..670ca942d 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -429,15 +429,6 @@ export class MemberApi extends TypedEmitter { } } - /** - * @param {string} serverDeviceId - * @returns {Promise} - */ - async removeServerPeer(serverDeviceId) { - // TODO - console.log({ serverDeviceId }) - } - /** * @param {string} deviceId * @returns {Promise} diff --git a/test-e2e/server.js b/test-e2e/server.js index b4a3ac24a..6a2ab0dc8 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -225,6 +225,8 @@ test('adding a server peer', async (t) => { ) }) +// TODO: Add support for removing a server peer. +// See . test.skip('removing a server peer', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() @@ -240,7 +242,8 @@ test.skip('removing a server peer', async (t) => { const serverPeer = await findServerPeer(project) assert(serverPeer, 'server peer should be added') - await project.$member.removeServerPeer(serverPeer.deviceId) + // TODO + // await project.$member.removeServerPeer(serverPeer.deviceId) assert.equal( (await findServerPeer(project))?.role.roleId, From 48271947a5453ab721f1d7b5bd57f959ee8c32bb Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 24 Oct 2024 11:13:24 -0500 Subject: [PATCH 101/118] Server: PUT /projects, not POST /projects (#938) Closes [#19]. [#19]: https://github.com/digidem/comapeo-cloud/issues/19 --- src/member-api.js | 2 +- src/server/routes.js | 2 +- src/server/test/add-project-endpoint.js | 24 +++++++++++------------ src/server/test/list-projects-endpoint.js | 2 +- src/server/test/observations-endpoint.js | 2 +- src/server/test/sync-endpoint.js | 2 +- test-e2e/server.js | 6 +++--- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 670ca942d..63a8d8399 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -323,7 +323,7 @@ export class MemberApi extends TypedEmitter { /** @type {Response} */ let response try { response = await fetch(requestUrl, { - method: 'POST', + method: 'PUT', body: JSON.stringify(requestBody), headers: { 'Content-Type': 'application/json' }, }) diff --git a/src/server/routes.js b/src/server/routes.js index 802407a42..16cf16963 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -131,7 +131,7 @@ export default async function routes( } ) - fastify.post( + fastify.put( '/projects', { schema: { diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index cc39dcbcb..46e2c299c 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -8,7 +8,7 @@ test('request missing project key', async (t) => { const server = createTestServer(t) const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: omit(randomProjectKeys(), ['projectKey']), }) @@ -20,7 +20,7 @@ test('request missing any encryption keys', async (t) => { const server = createTestServer(t) const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: omit(randomProjectKeys(), ['encryptionKeys']), }) @@ -33,7 +33,7 @@ test('request missing an encryption key', async (t) => { const projectKeys = randomProjectKeys() const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: { ...projectKeys, @@ -48,7 +48,7 @@ test('adding a project', async (t) => { const server = createTestServer(t) const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) @@ -63,14 +63,14 @@ test('adding a second project fails by default', async (t) => { const server = createTestServer(t) const firstAddResponse = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) assert.equal(firstAddResponse.statusCode, 200) const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) @@ -84,7 +84,7 @@ test('allowing a maximum number of projects', async (t) => { await t.test('adding 3 projects', async () => { for (let i = 0; i < 3; i++) { const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) @@ -94,7 +94,7 @@ test('allowing a maximum number of projects', async (t) => { await t.test('attempting to add 4th project fails', async () => { const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) @@ -117,7 +117,7 @@ test( await t.test('adding a project in the list', async () => { const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) @@ -126,7 +126,7 @@ test( await t.test('trying to add a project not in the list', async () => { const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: randomProjectKeys(), }) @@ -140,14 +140,14 @@ test('adding the same project twice is idempotent', async (t) => { const projectKeys = randomProjectKeys() const firstResponse = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) assert.equal(firstResponse.statusCode, 200) const secondResponse = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) diff --git a/src/server/test/list-projects-endpoint.js b/src/server/test/list-projects-endpoint.js index 3ed6cc545..f1b8760bc 100644 --- a/src/server/test/list-projects-endpoint.js +++ b/src/server/test/list-projects-endpoint.js @@ -36,7 +36,7 @@ test('listing projects', async (t) => { await Promise.all( [projectKeys1, projectKeys2].map(async (projectKeys) => { const response = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js index d93fcf4d1..4a7587c60 100644 --- a/src/server/test/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -50,7 +50,7 @@ test('returning no observations', async (t) => { ) const addProjectResponse = await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) diff --git a/src/server/test/sync-endpoint.js b/src/server/test/sync-endpoint.js index 4c89e0146..af34109d4 100644 --- a/src/server/test/sync-endpoint.js +++ b/src/server/test/sync-endpoint.js @@ -24,7 +24,7 @@ test('sync endpoint is available after adding a project', async (t) => { }) await server.inject({ - method: 'POST', + method: 'PUT', url: '/projects', body: projectKeys, }) diff --git a/test-e2e/server.js b/test-e2e/server.js index 6a2ab0dc8..9f0dcfb43 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -102,7 +102,7 @@ test( [204, 302, 400, 500].map((statusCode) => t.test(`when returning a ${statusCode}`, async (t) => { const fastify = createFastify() - fastify.post('/projects', (_req, reply) => { + fastify.put('/projects', (_req, reply) => { reply.status(statusCode).send() }) const serverBaseUrl = await fastify.listen() @@ -142,7 +142,7 @@ test( ].map((responseData) => t.test(`when returning ${responseData}`, async (t) => { const fastify = createFastify() - fastify.post('/projects', (_req, reply) => { + fastify.put('/projects', (_req, reply) => { reply.header('Content-Type', 'application/json').send(responseData) }) const serverBaseUrl = await fastify.listen() @@ -171,7 +171,7 @@ test("fails if first request succeeds but sync doesn't", async (t) => { const project = await manager.getProject(projectId) const fastify = createFastify() - fastify.post('/projects', (_req, reply) => { + fastify.put('/projects', (_req, reply) => { reply.send({ data: { deviceId: 'abc123' } }) }) const serverBaseUrl = await fastify.listen() From 4f47a29f4cdfb1a5cb38b56d9aebf4ce40c14ce2 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 24 Oct 2024 20:07:39 +0000 Subject: [PATCH 102/118] Use string-timing-safe-equal in server code --- package-lock.json | 9 +++++++++ package.json | 3 ++- src/server/routes.js | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1d0eb9124..735faf846 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "streamx": "^2.19.0", + "string-timing-safe-equal": "^0.1.0", "styled-map-package": "^2.0.0", "sub-encoder": "^2.1.1", "throttle-debounce": "^5.0.0", @@ -8966,6 +8967,14 @@ "node": ">=0.6.19" } }, + "node_modules/string-timing-safe-equal": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/string-timing-safe-equal/-/string-timing-safe-equal-0.1.0.tgz", + "integrity": "sha512-AMhfQVC+que87xh7nAW2ShSDK6E3CYFC3zGieewF7OHBW9vGPCatMuVPzZ9afoIOT5q6ldKPOKeuQf9hVZlvhw==", + "engines": { + "node": ">=18" + } + }, "node_modules/string-width": { "version": "4.2.3", "license": "MIT", diff --git a/package.json b/package.json index 608e6fae3..d648a351c 100644 --- a/package.json +++ b/package.json @@ -157,8 +157,8 @@ "yazl": "^2.5.1" }, "dependencies": { - "@comapeo/schema": "file:comapeo-schema-server.tgz", "@comapeo/fallback-smp": "^1.0.0", + "@comapeo/schema": "file:comapeo-schema-server.tgz", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", "@fastify/sensible": "^5.6.0", @@ -199,6 +199,7 @@ "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", "streamx": "^2.19.0", + "string-timing-safe-equal": "^0.1.0", "styled-map-package": "^2.0.0", "sub-encoder": "^2.1.1", "throttle-debounce": "^5.0.0", diff --git a/src/server/routes.js b/src/server/routes.js index 16cf16963..ff82ef731 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,9 +1,9 @@ import { Type } from '@sinclair/typebox' import * as fs from 'node:fs' +import timingSafeEqual from 'string-timing-safe-equal' import { kProjectReplicate } from '../mapeo-project.js' -import { wsCoreReplicator } from './ws-core-replicator.js' -import timingSafeEqual from '../lib/timing-safe-equal.js' import { assert, projectKeyToPublicId } from '../utils.js' +import { wsCoreReplicator } from './ws-core-replicator.js' /** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ From 47cce8746d7fabb9cd60d32366cd120bcf608ecc Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 24 Oct 2024 20:13:25 +0000 Subject: [PATCH 103/118] Install @comapeo/schema 1.2.0 --- comapeo-schema-server.tgz | Bin 98441 -> 0 bytes drizzle/project/0001_gifted_donald_blake.sql | 1 - drizzle/project/0001_medical_wendell_rand.sql | 22 +++ drizzle/project/meta/0001_snapshot.json | 125 +++++++++++++++++- drizzle/project/meta/_journal.json | 4 +- package-lock.json | 22 ++- package.json | 2 +- 7 files changed, 165 insertions(+), 11 deletions(-) delete mode 100644 comapeo-schema-server.tgz delete mode 100644 drizzle/project/0001_gifted_donald_blake.sql create mode 100644 drizzle/project/0001_medical_wendell_rand.sql diff --git a/comapeo-schema-server.tgz b/comapeo-schema-server.tgz deleted file mode 100644 index e85c8ba447bd0a71e72e7a19c7e6d1c2e9a5bbd0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98441 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

ao*q+^pLn= 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 statement-breakpoint +CREATE TABLE `remoteDetectionAlert` ( + `docId` text PRIMARY KEY NOT NULL, + `versionId` text NOT NULL, + `originalVersionId` text NOT NULL, + `schemaName` text NOT NULL, + `createdAt` text NOT NULL, + `updatedAt` text NOT NULL, + `links` text NOT NULL, + `deleted` integer NOT NULL, + `detectionDateStart` text NOT NULL, + `detectionDateEnd` text NOT NULL, + `sourceId` text NOT NULL, + `metadata` text NOT NULL, + `geometry` text NOT NULL, + `forks` text NOT NULL +); +--> statement-breakpoint +ALTER TABLE deviceInfo ADD `selfHostedServerDetails` text; \ No newline at end of file diff --git a/drizzle/project/meta/0001_snapshot.json b/drizzle/project/meta/0001_snapshot.json index 2f0b3daa1..ec502c944 100644 --- a/drizzle/project/meta/0001_snapshot.json +++ b/drizzle/project/meta/0001_snapshot.json @@ -1,7 +1,7 @@ { "version": "5", "dialect": "sqlite", - "id": "77087662-2e7c-4926-8cdd-7f50b4dd558c", + "id": "fd9449b2-3a8e-440e-ba57-62c2db1dc39f", "prevId": "597b1fb1-5fe9-4601-abab-9cd02b6a77fe", "tables": { "coreOwnership_backlink": { @@ -793,6 +793,129 @@ "compositePrimaryKeys": {}, "uniqueConstraints": {} }, + "remoteDetectionAlert_backlink": { + "name": "remoteDetectionAlert_backlink", + "columns": { + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "remoteDetectionAlert": { + "name": "remoteDetectionAlert", + "columns": { + "docId": { + "name": "docId", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "versionId": { + "name": "versionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "originalVersionId": { + "name": "originalVersionId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schemaName": { + "name": "schemaName", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "links": { + "name": "links", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted": { + "name": "deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detectionDateStart": { + "name": "detectionDateStart", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detectionDateEnd": { + "name": "detectionDateEnd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sourceId": { + "name": "sourceId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forks": { + "name": "forks", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, "role_backlink": { "name": "role_backlink", "columns": { diff --git a/drizzle/project/meta/_journal.json b/drizzle/project/meta/_journal.json index 470485334..20711e230 100644 --- a/drizzle/project/meta/_journal.json +++ b/drizzle/project/meta/_journal.json @@ -12,8 +12,8 @@ { "idx": 1, "version": "5", - "when": 1727880144696, - "tag": "0001_gifted_donald_blake", + "when": 1729783892753, + "tag": "0001_medical_wendell_rand", "breakpoints": true } ] diff --git a/package-lock.json b/package-lock.json index 735faf846..e577c395e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@comapeo/fallback-smp": "^1.0.0", - "@comapeo/schema": "file:comapeo-schema-server.tgz", + "@comapeo/schema": "1.2.0", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", "@fastify/sensible": "^5.6.0", @@ -308,6 +308,17 @@ "yauzl-promise": "^4.0.0" } }, + "node_modules/@comapeo/core2.0.1/node_modules/@comapeo/schema": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-1.0.0.tgz", + "integrity": "sha512-dK227I+0yg9D2y5/O5NGywx50tgeNYyUkl1uYnSmNAPlbv+r2KX9aaC9m4dEjIja2aR2VFnYn6z537ERZiahqQ==", + "dev": true, + "dependencies": { + "compact-encoding": "^2.12.0", + "protobufjs": "^7.2.5", + "type-fest": "^4.26.0" + } + }, "node_modules/@comapeo/fallback-smp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@comapeo/fallback-smp/-/fallback-smp-1.0.0.tgz", @@ -322,12 +333,11 @@ } }, "node_modules/@comapeo/schema": { - "version": "1.0.0", - "resolved": "file:comapeo-schema-server.tgz", - "integrity": "sha512-veKLYr+15cV3M/JgLu0Aer5k3ZSSwRH+3os+J7ApF9JtYjp93WvuilgkdyOiAIqO2e6RoGWOMqoeA7/41O41aw==", - "license": "MIT", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@comapeo/schema/-/schema-1.2.0.tgz", + "integrity": "sha512-LWrUSqtXmrEmE/B9V/zffKBbJmMo37AlvjXczvGx1+BbCAjOYCPDX6GCtnSKNsvtnNS2KQZDm9apg3mp92tFGA==", "dependencies": { - "@comapeo/geometry": "^1.0.1", + "@comapeo/geometry": "^1.0.2", "compact-encoding": "^2.12.0", "protobufjs": "^7.2.5", "type-fest": "^4.26.0" diff --git a/package.json b/package.json index d648a351c..87d0ad25d 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ }, "dependencies": { "@comapeo/fallback-smp": "^1.0.0", - "@comapeo/schema": "file:comapeo-schema-server.tgz", + "@comapeo/schema": "1.2.0", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", "@fastify/sensible": "^5.6.0", From f47d9f00adb22072e1baa5e4b237c64deb557f85 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 24 Oct 2024 20:14:07 +0000 Subject: [PATCH 104/118] Use string-timing-safe-equal in member API code --- src/member-api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/member-api.js b/src/member-api.js index 63a8d8399..668bb2a18 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -14,7 +14,7 @@ import { } from './utils.js' import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' -import timingSafeEqual from './lib/timing-safe-equal.js' +import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' import { ErrorWithCode } from './lib/error.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' From 4482f59350409de3de3421c72e49334088f928ee Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 24 Oct 2024 20:26:52 +0000 Subject: [PATCH 105/118] Fix CI: we now use the real schema package --- Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e73311182..9074b8b4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,6 @@ FROM node:${NODE_VERSION} AS build RUN apt-get update && apt-get install -y --no-install-recommends dumb-init WORKDIR /usr/src/app COPY package*.json /usr/src/app/ -# TODO: Remove this line when the package is published -COPY comapeo-schema-server.tgz /usr/src/app/ RUN npm ci --omit=dev # --------------> The production image__ From 50be333761ba0df55ca671843812d00d03586df6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 28 Oct 2024 23:35:05 +0000 Subject: [PATCH 106/118] Remove some `any`s from server code --- src/mapeo-project.js | 6 +----- src/server/routes.js | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index 89f33c9e6..c58cb3eaf 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -143,11 +143,7 @@ export class MapeoProject extends TypedEmitter { this.#loadingConfig = false this.#isArchiveDevice = isArchiveDevice - const getReplicationStream = this[kProjectReplicate].bind( - this, - // TODO: See if we can fix these - /** @type {any} */ (true) - ) + const getReplicationStream = this[kProjectReplicate].bind(this, true) ///////// 1. Setup database this.#sqlite = new Database(dbPath) diff --git a/src/server/routes.js b/src/server/routes.js index ff82ef731..a54b4660b 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -88,10 +88,7 @@ export default async function routes( async function (socket, req) { // The preValidation hook ensures that the project exists const project = await this.comapeo.getProject(req.params.projectPublicId) - const replicationStream = project[kProjectReplicate]( - // TODO: See if we can fix this type cast - /** @type {any} */ (false) - ) + const replicationStream = project[kProjectReplicate](false) wsCoreReplicator(socket, replicationStream) project.$sync.start() } From 1c4f1ed440392c608c8a0ccfc204d9f025e7673a Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Mon, 28 Oct 2024 23:36:19 +0000 Subject: [PATCH 107/118] add reference to issue 25 --- src/server/routes.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/server/routes.js b/src/server/routes.js index a54b4660b..e9fb24e31 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -301,6 +301,8 @@ export default async function routes( lat: obs.lat, lon: obs.lon, attachments: obs.attachments + // TODO: For now, only photos are supported. + // See . .filter((attachment) => attachment.type === 'photo') .map((attachment) => ({ url: new URL( @@ -323,6 +325,7 @@ export default async function routes( projectPublicId: BASE32_STRING_32_BYTES, driveDiscoveryId: Type.String(), // TODO: For now, only photos are supported. + // See . type: Type.Literal('photo'), name: Type.String(), }), From 50a52713f8c0a4a3748a51b9d5686209b22a3b02 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:01:30 +0000 Subject: [PATCH 108/118] Send project name to server when adding it Closes [#912]. [#912]: https://github.com/digidem/comapeo-core/issues/912 --- src/mapeo-project.js | 8 +++ src/member-api.js | 16 ++++++ src/server/routes.js | 4 +- src/server/test/add-project-endpoint.js | 60 ++++++++++++++++------- src/server/test/list-projects-endpoint.js | 26 ++++++---- src/server/test/observations-endpoint.js | 8 +-- src/server/test/sync-endpoint.js | 8 +-- src/server/test/test-helpers.js | 17 ++++--- test-e2e/server.js | 31 +++++++++--- 9 files changed, 126 insertions(+), 52 deletions(-) diff --git a/src/mapeo-project.js b/src/mapeo-project.js index c58cb3eaf..5afc70cb1 100644 --- a/src/mapeo-project.js +++ b/src/mapeo-project.js @@ -321,6 +321,7 @@ export class MapeoProject extends TypedEmitter { roles: this.#roles, coreOwnership: this.#coreOwnership, encryptionKeys, + getProjectName: this.#getProjectName.bind(this), projectKey, rpc: localPeers, getReplicationStream, @@ -609,6 +610,13 @@ export class MapeoProject extends TypedEmitter { } } + /** + * @returns {Promise} + */ + async #getProjectName() { + return (await this.$getProjectSettings()).name + } + async $getOwnRole() { return this.#roles.getRole(this.#deviceId) } diff --git a/src/member-api.js b/src/member-api.js index 668bb2a18..a4454b419 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -27,6 +27,7 @@ import { wsCoreReplicator } from './server/ws-core-replicator.js' * ProjectSettingsValue * } from '@comapeo/schema' */ +/** @import { Promisable } from 'type-fest' */ /** @import { Invite, InviteResponse } from './generated/rpc.js' */ /** @import { DataType } from './datatype/index.js' */ /** @import { DataStore } from './datastore/index.js' */ @@ -52,6 +53,7 @@ export class MemberApi extends TypedEmitter { #roles #coreOwnership #encryptionKeys + #getProjectName #projectKey #rpc #getReplicationStream @@ -67,6 +69,7 @@ export class MemberApi extends TypedEmitter { * @param {import('./roles.js').Roles} opts.roles * @param {import('./core-ownership.js').CoreOwnership} opts.coreOwnership * @param {import('./generated/keys.js').EncryptionKeys} opts.encryptionKeys + * @param {() => Promisable} opts.getProjectName * @param {Buffer} opts.projectKey * @param {import('./local-peers.js').LocalPeers} opts.rpc * @param {() => ReplicationStream} opts.getReplicationStream @@ -80,6 +83,7 @@ export class MemberApi extends TypedEmitter { roles, coreOwnership, encryptionKeys, + getProjectName, projectKey, rpc, getReplicationStream, @@ -91,6 +95,7 @@ export class MemberApi extends TypedEmitter { this.#roles = roles this.#coreOwnership = coreOwnership this.#encryptionKeys = encryptionKeys + this.#getProjectName = getProjectName this.#projectKey = projectKey this.#rpc = rpc this.#getReplicationStream = getReplicationStream @@ -268,6 +273,8 @@ export class MemberApi extends TypedEmitter { * Can reject with any of the following error codes (accessed via `err.code`): * * - `INVALID_URL`: the base URL is invalid, likely due to user error. + * - `MISSING_DATA`: some required data is missing in order to add the server + * peer. For example, the project must have a name. * - `NETWORK_ERROR`: there was an issue connecting to the server. Is the * device online? Is the server online? * - `INVALID_SERVER_RESPONSE`: we connected to the server but it returned @@ -308,8 +315,17 @@ export class MemberApi extends TypedEmitter { * @returns {Promise<{ serverDeviceId: string }>} */ async #addServerToProject(baseUrl) { + const projectName = await this.#getProjectName() + if (!projectName) { + throw new ErrorWithCode( + 'MISSING_DATA', + 'Project must have name to add server peer' + ) + } + const requestUrl = new URL('projects', baseUrl) const requestBody = { + projectName, projectKey: encodeBufferForServer(this.#projectKey), encryptionKeys: { auth: encodeBufferForServer(this.#encryptionKeys.auth), diff --git a/src/server/routes.js b/src/server/routes.js index e9fb24e31..66285217b 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -133,6 +133,7 @@ export default async function routes( { schema: { body: Type.Object({ + projectName: Type.String(), projectKey: HEX_STRING_32_BYTES, encryptionKeys: Type.Object({ auth: HEX_STRING_32_BYTES, @@ -153,6 +154,7 @@ export default async function routes( }, }, async function (req, reply) { + const { projectName } = req.body const projectKey = Buffer.from(req.body.projectKey, 'hex') const projectPublicId = projectKeyToPublicId(projectKey) @@ -207,7 +209,7 @@ export default async function routes( const projectId = await this.comapeo.addProject( { projectKey, - projectName: 'TODO: Figure out if this should be named', + projectName, encryptionKeys: { auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), config: Buffer.from(req.body.encryptionKeys.config, 'hex'), diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index 46e2c299c..2d06793b1 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -2,7 +2,31 @@ import assert from 'node:assert/strict' import test from 'node:test' import { omit } from '../../lib/omit.js' import { projectKeyToPublicId } from '../../utils.js' -import { createTestServer, randomProjectKeys } from './test-helpers.js' +import { createTestServer, randomAddProjectBody } from './test-helpers.js' + +test('request missing project name', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: omit(randomAddProjectBody(), ['projectName']), + }) + + assert.equal(response.statusCode, 400) +}) + +test('request with empty project name', async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { ...randomAddProjectBody(), projectName: '' }, + }) + + assert.equal(response.statusCode, 400) +}) test('request missing project key', async (t) => { const server = createTestServer(t) @@ -10,7 +34,7 @@ test('request missing project key', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: omit(randomProjectKeys(), ['projectKey']), + body: omit(randomAddProjectBody(), ['projectKey']), }) assert.equal(response.statusCode, 400) @@ -22,7 +46,7 @@ test('request missing any encryption keys', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: omit(randomProjectKeys(), ['encryptionKeys']), + body: omit(randomAddProjectBody(), ['encryptionKeys']), }) assert.equal(response.statusCode, 400) @@ -30,14 +54,14 @@ test('request missing any encryption keys', async (t) => { test('request missing an encryption key', async (t) => { const server = createTestServer(t) - const projectKeys = randomProjectKeys() + const body = randomAddProjectBody() const response = await server.inject({ method: 'PUT', url: '/projects', body: { - ...projectKeys, - encryptionKeys: omit(projectKeys.encryptionKeys, ['config']), + ...body, + encryptionKeys: omit(body.encryptionKeys, ['config']), }, }) @@ -50,7 +74,7 @@ test('adding a project', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(response.statusCode, 200) @@ -65,14 +89,14 @@ test('adding a second project fails by default', async (t) => { const firstAddResponse = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(firstAddResponse.statusCode, 200) const response = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(response.statusCode, 403) assert.match(response.json().message, /maximum number of projects/) @@ -86,7 +110,7 @@ test('allowing a maximum number of projects', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(response.statusCode, 200) } @@ -96,7 +120,7 @@ test('allowing a maximum number of projects', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(response.statusCode, 403) assert.match(response.json().message, /maximum number of projects/) @@ -107,9 +131,9 @@ test( 'allowing a specific list of projects', { concurrency: true }, async (t) => { - const projectKeys = randomProjectKeys() + const body = randomAddProjectBody() const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') + Buffer.from(body.projectKey, 'hex') ) const server = createTestServer(t, { allowedProjects: [projectPublicId], @@ -119,7 +143,7 @@ test( const response = await server.inject({ method: 'PUT', url: '/projects', - body: projectKeys, + body, }) assert.equal(response.statusCode, 200) }) @@ -128,7 +152,7 @@ test( const response = await server.inject({ method: 'PUT', url: '/projects', - body: randomProjectKeys(), + body: randomAddProjectBody(), }) assert.equal(response.statusCode, 403) }) @@ -137,19 +161,19 @@ test( test('adding the same project twice is idempotent', async (t) => { const server = createTestServer(t, { allowedProjects: 1 }) - const projectKeys = randomProjectKeys() + const body = randomAddProjectBody() const firstResponse = await server.inject({ method: 'PUT', url: '/projects', - body: projectKeys, + body, }) assert.equal(firstResponse.statusCode, 200) const secondResponse = await server.inject({ method: 'PUT', url: '/projects', - body: projectKeys, + body, }) assert.equal(secondResponse.statusCode, 200) }) diff --git a/src/server/test/list-projects-endpoint.js b/src/server/test/list-projects-endpoint.js index f1b8760bc..40e28377a 100644 --- a/src/server/test/list-projects-endpoint.js +++ b/src/server/test/list-projects-endpoint.js @@ -3,7 +3,7 @@ import test from 'node:test' import { BEARER_TOKEN, createTestServer, - randomProjectKeys, + randomAddProjectBody, } from './test-helpers.js' import { projectKeyToPublicId } from '../../utils.js' @@ -30,15 +30,15 @@ test('listing projects', async (t) => { }) await t.test('with projects', async () => { - const projectKeys1 = randomProjectKeys() - const projectKeys2 = randomProjectKeys() + const body1 = randomAddProjectBody() + const body2 = randomAddProjectBody() await Promise.all( - [projectKeys1, projectKeys2].map(async (projectKeys) => { + [body1, body2].map(async (body) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: projectKeys, + body, }) assert.equal(response.statusCode, 200) }) @@ -54,13 +54,19 @@ test('listing projects', async (t) => { const { data } = response.json() assert(Array.isArray(data)) assert.equal(data.length, 2, 'expected 2 projects') - for (const projectKeys of [projectKeys1, projectKeys2]) { + for (const body of [body1, body2]) { const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') + Buffer.from(body.projectKey, 'hex') ) - assert( - data.some((project) => project.projectId === projectPublicId), - `expected ${projectPublicId} to be found` + /** @type {Record} */ + const project = data.find( + (project) => project.projectId === projectPublicId + ) + assert(project, `expected ${projectPublicId} to be found`) + assert.equal( + project.name, + body.projectName, + 'expected project name to match' ) } }) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js index 4a7587c60..328ad7f58 100644 --- a/src/server/test/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -10,7 +10,7 @@ import { createManager } from '../../../test-e2e/utils.js' import { BEARER_TOKEN, createTestServer, - randomProjectKeys, + randomAddProjectBody, } from './test-helpers.js' /** @import { ObservationValue } from '@comapeo/schema'*/ /** @import { FastifyInstance } from 'fastify' */ @@ -44,7 +44,7 @@ test('returns a 403 if incorrect auth is provided', async (t) => { test('returning no observations', async (t) => { const server = createTestServer(t) - const projectKeys = randomProjectKeys() + const projectKeys = randomAddProjectBody() const projectPublicId = projectKeyToPublicId( Buffer.from(projectKeys.projectKey, 'hex') ) @@ -72,7 +72,7 @@ test('returning observations with fetchable attachments', async (t) => { const serverUrl = new URL(serverAddress) const manager = createManager('client', t) - const projectId = await manager.createProject() + const projectId = await manager.createProject({ name: 'CoMapeo project' }) const project = await manager.getProject(projectId) await project.$member.addServerPeer(serverAddress, { @@ -161,7 +161,7 @@ test('returning observations with fetchable attachments', async (t) => { function randomProjectPublicId() { return projectKeyToPublicId( - Buffer.from(randomProjectKeys().projectKey, 'hex') + Buffer.from(randomAddProjectBody().projectKey, 'hex') ) } diff --git a/src/server/test/sync-endpoint.js b/src/server/test/sync-endpoint.js index af34109d4..1ef34f04b 100644 --- a/src/server/test/sync-endpoint.js +++ b/src/server/test/sync-endpoint.js @@ -1,13 +1,13 @@ import assert from 'node:assert/strict' import test from 'node:test' import { projectKeyToPublicId } from '../../utils.js' -import { createTestServer, randomProjectKeys } from './test-helpers.js' +import { createTestServer, randomAddProjectBody } from './test-helpers.js' test('sync endpoint is available after adding a project', async (t) => { const server = createTestServer(t) - const projectKeys = randomProjectKeys() + const addProjectBody = randomAddProjectBody() const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') + Buffer.from(addProjectBody.projectKey, 'hex') ) await t.test('sync endpoint not available yet', async () => { @@ -26,7 +26,7 @@ test('sync endpoint is available after adding a project', async (t) => { await server.inject({ method: 'PUT', url: '/projects', - body: projectKeys, + body: addProjectBody, }) await t.test('sync endpoint available', async (t) => { diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js index be46b7770..f3875e18c 100644 --- a/src/server/test/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -39,16 +39,17 @@ export function createTestServer(t, serverOptions) { return server } -const randomHexKey = (length = 32) => +const randomHex = (length = 32) => Buffer.from(randomBytes(length)).toString('hex') -export const randomProjectKeys = () => ({ - projectKey: randomHexKey(), +export const randomAddProjectBody = () => ({ + projectName: randomHex(16), + projectKey: randomHex(), encryptionKeys: { - auth: randomHexKey(), - config: randomHexKey(), - data: randomHexKey(), - blobIndex: randomHexKey(), - blob: randomHexKey(), + auth: randomHex(), + config: randomHex(), + data: randomHex(), + blobIndex: randomHex(), + blob: randomHex(), }, }) diff --git a/test-e2e/server.js b/test-e2e/server.js index 9f0dcfb43..5ded90fc1 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -72,11 +72,28 @@ test('invalid base URLs', async (t) => { assert(!(await findServerPeer(project)), 'no server peers should be added') }) -test("fails if we can't connect to the server", async (t) => { +test('project with no name', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() const project = await manager.getProject(projectId) + await assert.rejects( + () => + project.$member.addServerPeer('http://localhost:9999', { + dangerouslyAllowInsecureConnections: true, + }), + { + code: 'MISSING_DATA', + message: /name/, + } + ) +}) + +test("fails if we can't connect to the server", async (t) => { + const manager = createManager('device0', t) + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + const serverBaseUrl = 'http://localhost:9999' await assert.rejects( () => @@ -95,7 +112,7 @@ test( { concurrency: true }, async (t) => { const manager = createManager('device0', t) - const projectId = await manager.createProject() + const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) await Promise.all( @@ -129,7 +146,7 @@ test( { concurrency: true }, async (t) => { const manager = createManager('device0', t) - const projectId = await manager.createProject() + const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) await Promise.all( @@ -167,7 +184,7 @@ test( test("fails if first request succeeds but sync doesn't", async (t) => { const manager = createManager('device0', t) - const projectId = await manager.createProject() + const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) const fastify = createFastify() @@ -199,7 +216,7 @@ test("fails if first request succeeds but sync doesn't", async (t) => { test('adding a server peer', async (t) => { const manager = createManager('device0', t) - const projectId = await manager.createProject() + const projectId = await manager.createProject({ name: 'foo' }) const project = await manager.getProject(projectId) const { serverBaseUrl } = await createTestServer(t) @@ -287,8 +304,8 @@ test.skip('removing a server peer', async (t) => { test("can't add a server to two different projects", async (t) => { const [managerA, managerB] = await createManagers(2, t, 'mobile') - const projectIdA = await managerA.createProject() - const projectIdB = await managerB.createProject() + const projectIdA = await managerA.createProject({ name: 'project A' }) + const projectIdB = await managerB.createProject({ name: 'project B' }) const projectA = await managerA.getProject(projectIdA) const projectB = await managerB.getProject(projectIdB) From b95e24c1880966317eaf03fa7d4bf81c7946187f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:06:56 +0000 Subject: [PATCH 109/118] More tests bad requests to `PUT /projects` Closes [#17]. [#17]: https://github.com/digidem/comapeo-cloud/issues/17 --- src/server/routes.js | 2 +- src/server/test/add-project-endpoint.js | 34 ++++++++++++++++++++++++- src/server/test/test-helpers.js | 2 +- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/server/routes.js b/src/server/routes.js index 66285217b..8ba1ccf90 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -133,7 +133,7 @@ export default async function routes( { schema: { body: Type.Object({ - projectName: Type.String(), + projectName: Type.String({ minLength: 1 }), projectKey: HEX_STRING_32_BYTES, encryptionKeys: Type.Object({ auth: HEX_STRING_32_BYTES, diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index 2d06793b1..7d26ca60b 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -2,7 +2,11 @@ import assert from 'node:assert/strict' import test from 'node:test' import { omit } from '../../lib/omit.js' import { projectKeyToPublicId } from '../../utils.js' -import { createTestServer, randomAddProjectBody } from './test-helpers.js' +import { + createTestServer, + randomAddProjectBody, + randomHex, +} from './test-helpers.js' test('request missing project name', async (t) => { const server = createTestServer(t) @@ -40,6 +44,18 @@ test('request missing project key', async (t) => { assert.equal(response.statusCode, 400) }) +test("request with a project key that's too short", async (t) => { + const server = createTestServer(t) + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { ...randomAddProjectBody(), projectKey: randomHex(31) }, + }) + + assert.equal(response.statusCode, 400) +}) + test('request missing any encryption keys', async (t) => { const server = createTestServer(t) @@ -68,6 +84,22 @@ test('request missing an encryption key', async (t) => { assert.equal(response.statusCode, 400) }) +test("request with an encryption key that's too short", async (t) => { + const server = createTestServer(t) + const body = randomAddProjectBody() + + const response = await server.inject({ + method: 'PUT', + url: '/projects', + body: { + ...body, + encryptionKeys: { ...body.encryptionKeys, config: randomHex(31) }, + }, + }) + + assert.equal(response.statusCode, 400) +}) + test('adding a project', async (t) => { const server = createTestServer(t) diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js index f3875e18c..5622176f2 100644 --- a/src/server/test/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -39,7 +39,7 @@ export function createTestServer(t, serverOptions) { return server } -const randomHex = (length = 32) => +export const randomHex = (length = 32) => Buffer.from(randomBytes(length)).toString('hex') export const randomAddProjectBody = () => ({ From 3f5d607787c6eb621257d10bf05b362adbb08a1e Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:28:08 +0000 Subject: [PATCH 110/118] Handle quick connect/disconnects to server Closes [#911]. [#911]: https://github.com/digidem/comapeo-core/issues/911 --- src/sync/sync-api.js | 9 ++++++- test-e2e/server.js | 57 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 1ac5efac6..32ed44007 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -73,6 +73,7 @@ export class SyncApi extends TypedEmitter { /** @type {Map} */ #pscByPeerId = new Map() #wantsToSyncData = false + #wantsToConnectToServers = false #hasRequestedFullStop = false /** @type {SyncEnabledState} */ #previousSyncEnabledState = 'none' @@ -301,9 +302,14 @@ export class SyncApi extends TypedEmitter { * @returns {void} */ connectServers() { - // TODO: decide how to handle this async stuff + this.#wantsToConnectToServers = true + this.#getServerWebsocketUrls() .then((urls) => { + const hasDisconnectedSinceWebsocketUrlsRequestFinished = + !this.#wantsToConnectToServers + if (hasDisconnectedSinceWebsocketUrlsRequestFinished) return + for (const url of urls) { const existingWebsocket = this.#serverWebsockets.get(url) if ( @@ -357,6 +363,7 @@ export class SyncApi extends TypedEmitter { websocket.close() } this.#serverWebsockets.clear() + this.#wantsToConnectToServers = false } /** diff --git a/test-e2e/server.js b/test-e2e/server.js index 5ded90fc1..2147f0865 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -17,6 +17,7 @@ import { waitForPeers, waitForSync, } from './utils.js' +import pDefer from 'p-defer' /** @import { FastifyInstance } from 'fastify' */ /** @import { MapeoManager } from '../src/mapeo-manager.js' */ /** @import { MapeoProject } from '../src/mapeo-project.js' */ @@ -385,6 +386,62 @@ test('data can be synced via a server', async (t) => { ) }) +test('connecting and then immediately disconnecting (and then immediately connecting again)', async (t) => { + const manager = createManager('seed', t) + await manager.setDeviceInfo({ name: 'manager', deviceType: 'mobile' }) + + // Because we need to stop the server, we can't use a remote server here. + const { server, serverBaseUrl } = await createLocalTestServer(t) + + const projectId = await manager.createProject({ name: 'foo' }) + const project = await manager.getProject(projectId) + await project.$member.addServerPeer(serverBaseUrl, { + dangerouslyAllowInsecureConnections: true, + }) + assert(await findServerPeer(project), 'test setup: server peer exists') + + await server.close() + + const bogusServer = createFastify() + const madeAnyRequestToServer = pDefer() + const anyRequestHandler = mock.fn(() => { + madeAnyRequestToServer.resolve() + return 'some request was made' + }) + bogusServer.all('*', anyRequestHandler) + const { port } = new URL(serverBaseUrl) + const bogusServerAddress = await bogusServer.listen({ port: Number(port) }) + t.after(() => bogusServer.close()) + assert.equal( + bogusServerAddress, + serverBaseUrl, + 'test setup: bogus server should have same address as "real" test server' + ) + + project.$sync.connectServers() + project.$sync.disconnectServers() + + await delay(500) + + assert.strictEqual( + anyRequestHandler.mock.calls.length, + 0, + 'no connection was made to the server' + ) + + project.$sync.connectServers() + project.$sync.disconnectServers() + project.$sync.connectServers() + + await madeAnyRequestToServer.promise + + assert.strictEqual( + anyRequestHandler.mock.calls.length, + 1, + 'a connection was made to the server' + ) +}) + /** * @typedef {object} LocalTestServer * @prop {'local'} type From 966da944efb4d0f694328aec5d27e7b3fdcab896 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:29:26 +0000 Subject: [PATCH 111/118] Remove removeServerPeer skeleton test --- test-e2e/server.js | 62 +--------------------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index 2147f0865..b57535fad 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -6,7 +6,7 @@ import createFastify from 'fastify' import assert from 'node:assert/strict' import test, { mock } from 'node:test' import { pEvent } from 'p-event' -import { LEFT_ROLE_ID, MEMBER_ROLE_ID } from '../src/roles.js' +import { MEMBER_ROLE_ID } from '../src/roles.js' import comapeoServer from '../src/server/app.js' import { connectPeers, @@ -243,66 +243,6 @@ test('adding a server peer', async (t) => { ) }) -// TODO: Add support for removing a server peer. -// See . -test.skip('removing a server peer', async (t) => { - const manager = createManager('device0', t) - const projectId = await manager.createProject() - const project = await manager.getProject(projectId) - - const testServer = await createTestServer(t) - const { serverBaseUrl } = testServer - - await project.$member.addServerPeer(serverBaseUrl, { - dangerouslyAllowInsecureConnections: true, - }) - - const serverPeer = await findServerPeer(project) - assert(serverPeer, 'server peer should be added') - - // TODO - // await project.$member.removeServerPeer(serverPeer.deviceId) - - assert.equal( - (await findServerPeer(project))?.role.roleId, - LEFT_ROLE_ID, - 'we should believe the server is gone' - ) - - // If we don't have access to the server (e.g., if it's remote), we can't run - // this part of the test. We could probably support this, but it's a lot more - // work for limited benefit. - if ('server' in testServer) { - await testServer.server.close() - - const bogusServer = createFastify() - const anyRequestHandler = mock.fn(() => 'should not happen') - bogusServer.all('*', anyRequestHandler) - - const { port } = new URL(serverBaseUrl) - const bogusServerAddress = await bogusServer.listen({ - // host, - port: Number(port), - }) - t.after(() => bogusServer.close()) - assert.equal( - bogusServerAddress, - serverBaseUrl, - 'Bogus server should have same address as "real" test server. Test is not set up correctly.' - ) - - project.$sync.connectServers() - - await delay(500) - - assert.strictEqual( - anyRequestHandler.mock.calls.length, - 0, - 'no connection was made to the server' - ) - } -}) - test("can't add a server to two different projects", async (t) => { const [managerA, managerB] = await createManagers(2, t, 'mobile') const projectIdA = await managerA.createProject({ name: 'project A' }) From 0223710cf5c3bbb5ea0bfc51458c4103086ce656 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:30:51 +0000 Subject: [PATCH 112/118] reference a TODO --- .github/workflows/fly-cleanup.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/fly-cleanup.yml b/.github/workflows/fly-cleanup.yml index 8b987276b..aeee93d13 100644 --- a/.github/workflows/fly-cleanup.yml +++ b/.github/workflows/fly-cleanup.yml @@ -1,5 +1,7 @@ # Cleans up orphaned test apps on Fly + # TODO: check app creation date - could destroy an app during a test run +# See . name: Fly Cleanup on: From 97e09024954b297fe1f0abb2a6b505d48034bb58 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:35:57 +0000 Subject: [PATCH 113/118] test: stop using getManagerOptions --- test-e2e/server.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test-e2e/server.js b/test-e2e/server.js index b57535fad..7bd731269 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -1,23 +1,24 @@ import { valueOf } from '@comapeo/schema' -import { setTimeout as delay } from 'node:timers/promises' import { generate } from '@mapeo/mock-data' import { execa } from 'execa' import createFastify from 'fastify' import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' import test, { mock } from 'node:test' +import { setTimeout as delay } from 'node:timers/promises' +import pDefer from 'p-defer' import { pEvent } from 'p-event' +import RAM from 'random-access-memory' import { MEMBER_ROLE_ID } from '../src/roles.js' import comapeoServer from '../src/server/app.js' import { connectPeers, createManager, createManagers, - getManagerOptions, invite, waitForPeers, waitForSync, } from './utils.js' -import pDefer from 'p-defer' /** @import { FastifyInstance } from 'fastify' */ /** @import { MapeoManager } from '../src/mapeo-manager.js' */ /** @import { MapeoProject } from '../src/mapeo-project.js' */ @@ -443,9 +444,19 @@ async function createRemoteTestServer(t) { * @returns {Promise} */ async function createLocalTestServer(t) { + const comapeoCoreUrl = new URL('..', import.meta.url) + const projectMigrationsFolder = new URL('./drizzle/project', comapeoCoreUrl) + .pathname + const clientMigrationsFolder = new URL('./drizzle/client', comapeoCoreUrl) + .pathname + const server = createFastify() server.register(comapeoServer, { - ...getManagerOptions('test server'), + rootKey: randomBytes(16), + projectMigrationsFolder, + clientMigrationsFolder, + dbFolder: ':memory:', + coreStorage: () => new RAM(), serverName: 'test server', serverBearerToken: 'ignored', }) From 354a0a81432d36995e8019e1ade1ba1700a53385 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Tue, 29 Oct 2024 00:38:45 +0000 Subject: [PATCH 114/118] Remove getManagerOptions --- src/server/test/test-helpers.js | 17 +++++++++++++---- test-e2e/utils.js | 18 ------------------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js index 5622176f2..bed3d0b37 100644 --- a/src/server/test/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -1,7 +1,7 @@ import { KeyManager } from '@mapeo/crypto' import createFastify from 'fastify' import { randomBytes } from 'node:crypto' -import { getManagerOptions } from '../../../test-e2e/utils.js' +import RAM from 'random-access-memory' import comapeoServer from '../app.js' /** @import { TestContext } from 'node:test' */ /** @import { ServerOptions } from '../app.js' */ @@ -19,9 +19,18 @@ const TEST_SERVER_DEFAULTS = { * @returns {import('fastify').FastifyInstance & { deviceId: string }} */ export function createTestServer(t, serverOptions) { - const serverName = - serverOptions?.serverName || TEST_SERVER_DEFAULTS.serverName - const managerOptions = getManagerOptions(serverName) + const comapeoCoreUrl = new URL('../../..', import.meta.url) + const projectMigrationsFolder = new URL('./drizzle/project', comapeoCoreUrl) + .pathname + const clientMigrationsFolder = new URL('./drizzle/client', comapeoCoreUrl) + .pathname + const managerOptions = { + rootKey: randomBytes(16), + projectMigrationsFolder, + clientMigrationsFolder, + dbFolder: ':memory:', + coreStorage: () => new RAM(), + } const km = new KeyManager(managerOptions.rootKey) const server = createFastify() server.register(comapeoServer, { diff --git a/test-e2e/utils.js b/test-e2e/utils.js index 44a0002e1..614b0d2ca 100644 --- a/test-e2e/utils.js +++ b/test-e2e/utils.js @@ -187,24 +187,6 @@ export async function createManagers( ) } -/** - * TODO: DRY this out with the below - * @param {string} seed - * @param {Partial[0]>} [overrides] - * @returns {ConstructorParameters[0]} - */ -export function getManagerOptions(seed, overrides = {}) { - return { - rootKey: getRootKey(seed), - projectMigrationsFolder, - clientMigrationsFolder, - dbFolder: ':memory:', - coreStorage: () => new RAM(), - fastify: Fastify(), - ...overrides, - } -} - /** * @param {string} seed * @param {import('node:test').TestContext} t From 6d18bf68df26c9e8744cfec14658ad1ac1da4d1a Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Oct 2024 23:51:33 +0000 Subject: [PATCH 115/118] src/server should use more realistic imports from core --- src/index.js | 10 +++++ src/server/comapeo-plugin.js | 2 +- src/server/routes.js | 12 ++--- src/server/server.js | 18 ++++---- src/server/test/add-project-endpoint.js | 24 +++++++--- src/server/test/list-projects-endpoint.js | 2 +- src/server/test/observations-endpoint.js | 55 ++++++++++++++++------- src/server/test/sync-endpoint.js | 2 +- src/server/test/test-helpers.js | 19 +++++--- src/server/types.ts | 2 +- src/server/ws-core-replicator.js | 17 ++++++- 11 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/index.js b/src/index.js index 795ab8471..5460e26f1 100644 --- a/src/index.js +++ b/src/index.js @@ -3,9 +3,19 @@ import { COORDINATOR_ROLE_ID, MEMBER_ROLE_ID, } from './roles.js' +import { kProjectReplicate } from './mapeo-project.js' export { plugin as CoMapeoMapsFastifyPlugin } from './fastify-plugins/maps.js' export { FastifyController } from './fastify-controller.js' export { MapeoManager } from './mapeo-manager.js' +/** @import { MapeoProject } from './mapeo-project.js' */ + +/** + * @param {MapeoProject} project + * @param {Parameters} args + * @returns {ReturnType} + */ +export const replicateProject = (project, ...args) => + project[kProjectReplicate](...args) export const roles = /** @type {const} */ ({ CREATOR_ROLE_ID, diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js index b5f736308..03daa0fc2 100644 --- a/src/server/comapeo-plugin.js +++ b/src/server/comapeo-plugin.js @@ -1,4 +1,4 @@ -import { MapeoManager } from '../mapeo-manager.js' +import { MapeoManager } from '../index.js' import createFastifyPlugin from 'fastify-plugin' /** diff --git a/src/server/routes.js b/src/server/routes.js index 8ba1ccf90..0e4c81d46 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -1,8 +1,9 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import { Type } from '@sinclair/typebox' +import assert from 'node:assert/strict' import * as fs from 'node:fs' import timingSafeEqual from 'string-timing-safe-equal' -import { kProjectReplicate } from '../mapeo-project.js' -import { assert, projectKeyToPublicId } from '../utils.js' +import { replicateProject } from '../index.js' import { wsCoreReplicator } from './ws-core-replicator.js' /** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ /** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ @@ -88,7 +89,7 @@ export default async function routes( async function (socket, req) { // The preValidation hook ensures that the project exists const project = await this.comapeo.getProject(req.params.projectPublicId) - const replicationStream = project[kProjectReplicate](false) + const replicationStream = replicateProject(project, false) wsCoreReplicator(socket, replicationStream) project.$sync.start() } @@ -220,8 +221,9 @@ export default async function routes( }, { waitForSync: false } ) - assert( - projectId === projectPublicId, + assert.equal( + projectId, + projectPublicId, 'adding a project should return the same ID as what was passed' ) } diff --git a/src/server/server.js b/src/server/server.js index e42a2c67d..b088b8234 100644 --- a/src/server/server.js +++ b/src/server/server.js @@ -1,10 +1,10 @@ -import comapeoServer from './app.js' +import { Type } from '@sinclair/typebox' import envSchema from 'env-schema' import createFastify from 'fastify' -import { Type } from '@sinclair/typebox' -import path from 'node:path' -import fsPromises from 'node:fs/promises' import crypto from 'node:crypto' +import fsPromises from 'node:fs/promises' +import path from 'node:path' +import comapeoServer from './app.js' const DEFAULT_STORAGE = path.join(process.cwd(), 'data') const CORE_DIR_NAME = 'core' @@ -40,12 +40,10 @@ const config = envSchema({ schema, dotenv: true }) const coreStorage = path.join(config.STORAGE_DIR, CORE_DIR_NAME) const dbFolder = path.join(config.STORAGE_DIR, DB_DIR_NAME) const rootKeyFile = path.join(config.STORAGE_DIR, ROOT_KEY_FILE_NAME) -const projectMigrationsFolder = new URL( - '../../drizzle/project', - import.meta.url -).pathname -const clientMigrationsFolder = new URL('../../drizzle/client', import.meta.url) - .pathname + +const migrationsFolder = new URL('../../drizzle/', import.meta.url).pathname +const projectMigrationsFolder = path.join(migrationsFolder, 'project') +const clientMigrationsFolder = path.join(migrationsFolder, 'client') await Promise.all([ fsPromises.mkdir(coreStorage, { recursive: true }), diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js index 7d26ca60b..595bdee91 100644 --- a/src/server/test/add-project-endpoint.js +++ b/src/server/test/add-project-endpoint.js @@ -1,7 +1,6 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import assert from 'node:assert/strict' import test from 'node:test' -import { omit } from '../../lib/omit.js' -import { projectKeyToPublicId } from '../../utils.js' import { createTestServer, randomAddProjectBody, @@ -14,7 +13,7 @@ test('request missing project name', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: omit(randomAddProjectBody(), ['projectName']), + body: omit(randomAddProjectBody(), 'projectName'), }) assert.equal(response.statusCode, 400) @@ -38,7 +37,7 @@ test('request missing project key', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: omit(randomAddProjectBody(), ['projectKey']), + body: omit(randomAddProjectBody(), 'projectKey'), }) assert.equal(response.statusCode, 400) @@ -62,7 +61,7 @@ test('request missing any encryption keys', async (t) => { const response = await server.inject({ method: 'PUT', url: '/projects', - body: omit(randomAddProjectBody(), ['encryptionKeys']), + body: omit(randomAddProjectBody(), 'encryptionKeys'), }) assert.equal(response.statusCode, 400) @@ -77,7 +76,7 @@ test('request missing an encryption key', async (t) => { url: '/projects', body: { ...body, - encryptionKeys: omit(body.encryptionKeys, ['config']), + encryptionKeys: omit(body.encryptionKeys, 'config'), }, }) @@ -209,3 +208,16 @@ test('adding the same project twice is idempotent', async (t) => { }) assert.equal(secondResponse.statusCode, 200) }) + +/** + * @template {object} T + * @template {keyof T} K + * @param {T} obj + * @param {K} key + * @returns {Omit} + */ +function omit(obj, key) { + const result = { ...obj } + delete result[key] + return result +} diff --git a/src/server/test/list-projects-endpoint.js b/src/server/test/list-projects-endpoint.js index 40e28377a..3d91d49e0 100644 --- a/src/server/test/list-projects-endpoint.js +++ b/src/server/test/list-projects-endpoint.js @@ -1,3 +1,4 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import assert from 'node:assert/strict' import test from 'node:test' import { @@ -5,7 +6,6 @@ import { createTestServer, randomAddProjectBody, } from './test-helpers.js' -import { projectKeyToPublicId } from '../../utils.js' test('listing projects', async (t) => { const server = createTestServer(t, { allowedProjects: 999 }) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js index 328ad7f58..6e717c300 100644 --- a/src/server/test/observations-endpoint.js +++ b/src/server/test/observations-endpoint.js @@ -1,15 +1,16 @@ import { valueOf } from '@comapeo/schema' +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import { generate } from '@mapeo/mock-data' import { map } from 'iterpal' import assert from 'node:assert/strict' import * as fs from 'node:fs/promises' import test from 'node:test' -import { projectKeyToPublicId } from '../../utils.js' -import { blobMetadata } from '../../../test/helpers/blob-store.js' -import { createManager } from '../../../test-e2e/utils.js' +import { setTimeout as delay } from 'node:timers/promises' +import { MapeoManager } from '../../index.js' import { BEARER_TOKEN, createTestServer, + getManagerOptions, randomAddProjectBody, } from './test-helpers.js' /** @import { ObservationValue } from '@comapeo/schema'*/ @@ -71,7 +72,7 @@ test('returning observations with fetchable attachments', async (t) => { const serverAddress = await server.listen() const serverUrl = new URL(serverAddress) - const manager = createManager('client', t) + const manager = new MapeoManager(getManagerOptions()) const projectId = await manager.createProject({ name: 'CoMapeo project' }) const project = await manager.getProject(projectId) @@ -105,11 +106,11 @@ test('returning observations with fetchable attachments', async (t) => { preview: FIXTURE_PREVIEW_PATH, thumbnail: FIXTURE_THUMBNAIL_PATH, }, - blobMetadata({ mimeType: 'image/jpeg' }) + { mimeType: 'image/jpeg', timestamp: Date.now() } ), project.$blobs.create( { original: FIXTURE_AUDIO_PATH }, - blobMetadata({ mimeType: 'audio/mpeg' }) + { mimeType: 'audio/mpeg', timestamp: Date.now() } ), ]) /** @type {ObservationValue} */ @@ -123,18 +124,21 @@ test('returning observations with fetchable attachments', async (t) => { await project.$sync.waitForSync('full') - const response = await server.inject({ - authority: serverUrl.host, - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - - assert.equal(response.statusCode, 200) - - const { data } = await response.json() + // It's possible that the client thinks it's synced but the server hasn't + // processed everything yet, so we try a few times. + const data = await runWithRetries(3, async () => { + const response = await server.inject({ + authority: serverUrl.host, + method: 'GET', + url: `/projects/${projectId}/observations`, + headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, + }) + assert.equal(response.statusCode, 200) - assert.equal(data.length, 3) + const { data } = await response.json() + assert.equal(data.length, 3) + return data + }) await Promise.all( observations.map(async (observation) => { @@ -181,6 +185,23 @@ function blobToAttachment(blob) { } } +/** + * @template T + * @param {number} retries + * @param {() => Promise} fn + * @returns {Promise} + */ +async function runWithRetries(retries, fn) { + for (let i = 0; i < retries - 1; i++) { + try { + return await fn() + } catch { + await delay(500) + } + } + return fn() +} + /** * @param {object} options * @param {FastifyInstance} options.server diff --git a/src/server/test/sync-endpoint.js b/src/server/test/sync-endpoint.js index 1ef34f04b..d2eaefaaf 100644 --- a/src/server/test/sync-endpoint.js +++ b/src/server/test/sync-endpoint.js @@ -1,6 +1,6 @@ +import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' import assert from 'node:assert/strict' import test from 'node:test' -import { projectKeyToPublicId } from '../../utils.js' import { createTestServer, randomAddProjectBody } from './test-helpers.js' test('sync endpoint is available after adding a project', async (t) => { diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js index bed3d0b37..26a9bd5ee 100644 --- a/src/server/test/test-helpers.js +++ b/src/server/test/test-helpers.js @@ -3,6 +3,7 @@ import createFastify from 'fastify' import { randomBytes } from 'node:crypto' import RAM from 'random-access-memory' import comapeoServer from '../app.js' +/** @import { MapeoManager } from '../../index.js' */ /** @import { TestContext } from 'node:test' */ /** @import { ServerOptions } from '../app.js' */ @@ -14,23 +15,31 @@ const TEST_SERVER_DEFAULTS = { } /** - * @param {TestContext} t - * @param {Partial} [serverOptions] - * @returns {import('fastify').FastifyInstance & { deviceId: string }} + * @returns {ConstructorParameters[0]} */ -export function createTestServer(t, serverOptions) { +export function getManagerOptions() { const comapeoCoreUrl = new URL('../../..', import.meta.url) const projectMigrationsFolder = new URL('./drizzle/project', comapeoCoreUrl) .pathname const clientMigrationsFolder = new URL('./drizzle/client', comapeoCoreUrl) .pathname - const managerOptions = { + return { rootKey: randomBytes(16), projectMigrationsFolder, clientMigrationsFolder, dbFolder: ':memory:', coreStorage: () => new RAM(), + fastify: createFastify(), } +} + +/** + * @param {TestContext} t + * @param {Partial} [serverOptions] + * @returns {import('fastify').FastifyInstance & { deviceId: string }} + */ +export function createTestServer(t, serverOptions) { + const managerOptions = getManagerOptions() const km = new KeyManager(managerOptions.rootKey) const server = createFastify() server.register(comapeoServer, { diff --git a/src/server/types.ts b/src/server/types.ts index 97844cb2e..923af9e99 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -3,7 +3,7 @@ // typescript support currently, so need to be careful about using this where it // is not in scope. -import { type MapeoManager } from '../mapeo-manager.js' +import { type MapeoManager } from '../index.js' declare module 'fastify' { interface FastifyInstance { diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js index eb79ba8bf..c2d2cf5a6 100644 --- a/src/server/ws-core-replicator.js +++ b/src/server/ws-core-replicator.js @@ -1,10 +1,23 @@ -import { pipeline } from 'node:stream/promises' import { Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' import { createWebSocketStream } from 'ws' +/** @import Protomux from 'protomux' */ +/** @import NoiseStream from '@hyperswarm/secret-stream' */ +/** @import { Duplex } from 'streamx' */ + +/** + * @internal + * @typedef {Omit & { userData: Protomux }} ProtocolStream + */ + +/** + * @internal + * @typedef {Duplex & { noiseStream: ProtocolStream }} ReplicationStream + */ /** * @param {import('ws').WebSocket} ws - * @param {import('../types.js').ReplicationStream} replicationStream + * @param {ReplicationStream} replicationStream * @returns {Promise} */ export function wsCoreReplicator(ws, replicationStream) { From 92c84c7f90632083e4a55658866d8bff6b371a13 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 30 Oct 2024 23:54:53 +0000 Subject: [PATCH 116/118] Use more realistic imports of server code from core --- src/lib/ws-core-replicator.js | 47 +++++++++++++++++++++++++++++++++++ src/member-api.js | 2 +- src/sync/sync-api.js | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 src/lib/ws-core-replicator.js diff --git a/src/lib/ws-core-replicator.js b/src/lib/ws-core-replicator.js new file mode 100644 index 000000000..2ca1f8909 --- /dev/null +++ b/src/lib/ws-core-replicator.js @@ -0,0 +1,47 @@ +import { pipeline } from 'node:stream/promises' +import { Transform } from 'node:stream' +import { createWebSocketStream } from 'ws' +/** @import { WebSocket } from 'ws' */ +/** @import { ReplicationStream } from '../types.js' */ + +/** + * @param {WebSocket} ws + * @param {ReplicationStream} replicationStream + * @returns {Promise} + */ +export function wsCoreReplicator(ws, replicationStream) { + // This is purely to satisfy typescript at its worst. `pipeline` expects a + // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex + // stream. The difference is that streamx does not implement the + // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline` + // function does not depend on any of these methods (I have read through the + // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely + // cast the stream to a NodeJS ReadWriteStream. + const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ ( + /** @type {unknown} */ (replicationStream) + ) + return pipeline( + _replicationStream, + wsSafetyTransform(ws), + createWebSocketStream(ws), + _replicationStream + ) +} + +/** + * Avoid writing data to a closing or closed websocket, which would result in an + * error. Instead we drop the data and wait for the stream close/end events to + * propagate and close the streams cleanly. + * + * @param {WebSocket} ws + */ +function wsSafetyTransform(ws) { + return new Transform({ + transform(chunk, encoding, callback) { + if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { + return callback() + } + callback(null, chunk) + }, + }) +} diff --git a/src/member-api.js b/src/member-api.js index a4454b419..9e6b5d921 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -17,8 +17,8 @@ import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' import { ErrorWithCode } from './lib/error.js' +import { wsCoreReplicator } from './lib/ws-core-replicator.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' -import { wsCoreReplicator } from './server/ws-core-replicator.js' /** * @import { * DeviceInfo, diff --git a/src/sync/sync-api.js b/src/sync/sync-api.js index 81be4dca2..ea5ed77c4 100644 --- a/src/sync/sync-api.js +++ b/src/sync/sync-api.js @@ -10,8 +10,8 @@ import { } from '../constants.js' import { ExhaustivenessError, assert, keyToId, noop } from '../utils.js' import { getOwn } from '../lib/get-own.js' +import { wsCoreReplicator } from '../lib/ws-core-replicator.js' import { NO_ROLE_ID } from '../roles.js' -import { wsCoreReplicator } from '../server/ws-core-replicator.js' /** @import { CoreOwnership as CoreOwnershipDoc } from '@comapeo/schema' */ /** @import * as http from 'node:http' */ /** @import { CoreOwnership } from '../core-ownership.js' */ From 5fa619253844555f5f4a11e8b0b87b72342e295f Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 31 Oct 2024 00:17:14 +0000 Subject: [PATCH 117/118] Clean up a few small things --- src/member-api.js | 15 ++++++--------- src/server/routes.js | 33 +++++++++++++++------------------ 2 files changed, 21 insertions(+), 27 deletions(-) diff --git a/src/member-api.js b/src/member-api.js index 9e6b5d921..92157ab5b 100644 --- a/src/member-api.js +++ b/src/member-api.js @@ -16,7 +16,7 @@ import { keyBy } from './lib/key-by.js' import { abortSignalAny } from './lib/ponyfills.js' import timingSafeEqual from 'string-timing-safe-equal' import { isHostnameIpAddress } from './lib/is-hostname-ip-address.js' -import { ErrorWithCode } from './lib/error.js' +import { ErrorWithCode, getErrorMessage } from './lib/error.js' import { wsCoreReplicator } from './lib/ws-core-replicator.js' import { MEMBER_ROLE_ID, ROLES, isRoleIdForNewInvite } from './roles.js' /** @@ -285,7 +285,7 @@ export class MemberApi extends TypedEmitter { * * @param {string} baseUrl * @param {object} [options] - * @param {boolean} [options.dangerouslyAllowInsecureConnections] + * @param {boolean} [options.dangerouslyAllowInsecureConnections] Allow insecure network connections. Should only be used in tests. * @returns {Promise} */ async addServerPeer( @@ -300,8 +300,7 @@ export class MemberApi extends TypedEmitter { const { serverDeviceId } = await this.#addServerToProject(baseUrl) - const roleId = MEMBER_ROLE_ID - await this.#roles.assignRole(serverDeviceId, roleId) + await this.#roles.assignRole(serverDeviceId, MEMBER_ROLE_ID) await this.#waitForInitialSyncWithServer({ baseUrl, @@ -344,13 +343,11 @@ export class MemberApi extends TypedEmitter { headers: { 'Content-Type': 'application/json' }, }) } catch (err) { - const message = - err && typeof err === 'object' && 'message' in err - ? err.message - : String(err) throw new ErrorWithCode( 'NETWORK_ERROR', - `Failed to add server peer due to network error: ${message}` + `Failed to add server peer due to network error: ${getErrorMessage( + err + )}` ) } diff --git a/src/server/routes.js b/src/server/routes.js index 0e4c81d46..2ccdfaab2 100644 --- a/src/server/routes.js +++ b/src/server/routes.js @@ -32,6 +32,7 @@ export default async function routes( const allowedProjectsSetOrNumber = Array.isArray(allowedProjects) ? new Set(allowedProjects) : allowedProjects + /** * @param {FastifyRequest} req */ @@ -62,11 +63,11 @@ export default async function routes( }, }, }, - async function (_req, reply) { + async function () { const { deviceId, name } = this.comapeo.getDeviceInfo() - reply.send({ + return { data: { deviceId, name: name || serverName }, - }) + } } ) @@ -115,17 +116,14 @@ export default async function routes( verifyBearerAuth(req) }, }, - async function (req, reply) { - const existingProjects = await this.comapeo.listProjects() - - reply.send({ - data: existingProjects.map((project) => ({ + async function () { + const projects = await this.comapeo.listProjects() + return { + data: projects.map((project) => ({ projectId: project.projectId, name: project.name, })), - }) - - return reply + } } ) @@ -154,7 +152,7 @@ export default async function routes( }, }, }, - async function (req, reply) { + async function (req) { const { projectName } = req.body const projectKey = Buffer.from(req.body.projectKey, 'hex') const projectPublicId = projectKeyToPublicId(projectKey) @@ -231,12 +229,11 @@ export default async function routes( const project = await this.comapeo.getProject(projectPublicId) project.$sync.start() - reply.send({ + return { data: { deviceId: this.comapeo.deviceId, }, - }) - return reply + } } ) @@ -291,11 +288,11 @@ export default async function routes( await ensureProjectExists(this, req) }, }, - async function (req, reply) { + async function (req) { const { projectPublicId } = req.params const project = await this.comapeo.getProject(projectPublicId) - reply.send({ + return { data: (await project.observation.getMany({ includeDeleted: true })).map( (obs) => ({ docId: obs.docId, @@ -317,7 +314,7 @@ export default async function routes( tags: obs.tags, }) ), - }) + } } ) From a0b310f0ac8bc8c864d31924ff73e89dace7ea19 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Thu, 31 Oct 2024 22:07:24 +0000 Subject: [PATCH 118/118] Use @comapeo/server --- .dockerignore | 16 - Dockerfile | 23 -- fly.toml | 36 -- package-lock.json | 298 ++++++++-------- package.json | 10 +- src/server/README.md | 38 -- src/server/allowed-hosts-plugin.js | 19 - src/server/app.js | 46 --- src/server/base-url-plugin.js | 11 - src/server/comapeo-plugin.js | 14 - src/server/routes.js | 405 ---------------------- src/server/server.js | 106 ------ src/server/static/index.html | 43 --- src/server/test/add-project-endpoint.js | 223 ------------ src/server/test/allowed-hosts.js | 28 -- src/server/test/fixtures/audio.mp3 | Bin 17181 -> 0 bytes src/server/test/fixtures/original.jpg | Bin 16308 -> 0 bytes src/server/test/fixtures/preview.jpg | Bin 8461 -> 0 bytes src/server/test/fixtures/thumbnail.jpg | Bin 1661 -> 0 bytes src/server/test/list-projects-endpoint.js | 73 ---- src/server/test/observations-endpoint.js | 280 --------------- src/server/test/root.js | 20 -- src/server/test/server-info-endpoint.js | 21 -- src/server/test/sync-endpoint.js | 53 --- src/server/test/test-helpers.js | 73 ---- src/server/types.ts | 15 - src/server/ws-core-replicator.js | 58 ---- test-e2e/server.js | 26 +- 28 files changed, 155 insertions(+), 1780 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile delete mode 100644 fly.toml delete mode 100644 src/server/README.md delete mode 100644 src/server/allowed-hosts-plugin.js delete mode 100644 src/server/app.js delete mode 100644 src/server/base-url-plugin.js delete mode 100644 src/server/comapeo-plugin.js delete mode 100644 src/server/routes.js delete mode 100644 src/server/server.js delete mode 100644 src/server/static/index.html delete mode 100644 src/server/test/add-project-endpoint.js delete mode 100644 src/server/test/allowed-hosts.js delete mode 100644 src/server/test/fixtures/audio.mp3 delete mode 100644 src/server/test/fixtures/original.jpg delete mode 100644 src/server/test/fixtures/preview.jpg delete mode 100644 src/server/test/fixtures/thumbnail.jpg delete mode 100644 src/server/test/list-projects-endpoint.js delete mode 100644 src/server/test/observations-endpoint.js delete mode 100644 src/server/test/root.js delete mode 100644 src/server/test/server-info-endpoint.js delete mode 100644 src/server/test/sync-endpoint.js delete mode 100644 src/server/test/test-helpers.js delete mode 100644 src/server/types.ts delete mode 100644 src/server/ws-core-replicator.js diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 5256b32a0..000000000 --- a/.dockerignore +++ /dev/null @@ -1,16 +0,0 @@ -# flyctl launch added from .gitignore -**/.DS_Store -**/node_modules -**/coverage -**/.tmp -**/tmp -**/proto/build -dist -!drizzle/**/*.sql -**/.eslintcache -**/docs/api/html/* -**/test/fixtures/config/*.zip - -# flyctl launch added from .husky/_/.gitignore -.husky/_/**/* -fly.toml diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9074b8b4c..000000000 --- a/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Best practices from https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/ - -ARG NODE_VERSION=20.17.0 - -# --------------> The build image__ -FROM node:${NODE_VERSION} AS build -RUN apt-get update && apt-get install -y --no-install-recommends dumb-init -WORKDIR /usr/src/app -COPY package*.json /usr/src/app/ -RUN npm ci --omit=dev - -# --------------> The production image__ -FROM node:${NODE_VERSION}-bullseye-slim - -ENV NODE_ENV production -ENV PORT 8080 -EXPOSE 8080 -COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init -USER node -WORKDIR /usr/src/app -COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules -COPY --chown=node:node . /usr/src/app -CMD ["dumb-init", "node", "src/server/server.js"] diff --git a/fly.toml b/fly.toml deleted file mode 100644 index a2e048974..000000000 --- a/fly.toml +++ /dev/null @@ -1,36 +0,0 @@ -# fly.toml app configuration file generated for comapeo-cloud on 2024-10-07T20:59:21+01:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = 'comapeo-cloud' -primary_region = 'iad' - -[env] - STORAGE_DIR = '/data' - -[build] - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = 'suspend' - auto_start_machines = true - min_machines_running = 0 - max_machines_running = 1 - processes = ['app'] - -[[http_service.checks]] - grace_period = "10s" - interval = "30s" - method = "GET" - timeout = "5s" - path = "/healthcheck" - -[[vm]] - size = 'shared-cpu-1x' - -[mounts] - source = "myapp_data" - destination = "/data" - snapshot_retention = 14 diff --git a/package-lock.json b/package-lock.json index 9f080c15f..d31534f58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,7 @@ "@comapeo/schema": "1.2.0", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", - "@fastify/sensible": "^5.6.0", "@fastify/type-provider-typebox": "^4.1.0", - "@fastify/websocket": "^10.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", @@ -30,7 +28,6 @@ "debug": "^4.3.4", "dot-prop": "^9.0.0", "drizzle-orm": "^0.30.8", - "env-schema": "^6.0.0", "fastify": "^4.0.0", "fastify-plugin": "^4.5.1", "hyperblobs": "2.3.0", @@ -64,9 +61,8 @@ }, "devDependencies": { "@bufbuild/buf": "^1.26.1", + "@comapeo/cloud": "^0.1.0", "@comapeo/core2.0.1": "npm:@comapeo/core@2.0.1", - "@fastify/ajv-compiler": "^4.0.1", - "@fastify/fast-json-stringify-compiler": "^5.0.1", "@mapeo/default-config": "5.0.0", "@mapeo/mock-data": "^2.1.1", "@sinonjs/fake-timers": "^10.0.2", @@ -91,7 +87,7 @@ "cpy-cli": "^5.0.0", "drizzle-kit": "^0.20.14", "eslint": "^8.57.0", - "execa": "^9.4.0", + "execa": "^9.5.1", "husky": "^8.0.0", "iterpal": "^0.4.0", "lint-staged": "^14.0.1", @@ -252,6 +248,87 @@ "@bufbuild/buf-win32-x64": "1.26.1" } }, + "node_modules/@comapeo/cloud": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@comapeo/cloud/-/cloud-0.1.0.tgz", + "integrity": "sha512-60UNyOoz2+rGKDOtalkAzGnlHnOVBZrHplppaJaLc81qsP3hL8tFhlOUYAroR+WORhc/Ju/3UYslL5MIocq29A==", + "dev": true, + "dependencies": { + "@comapeo/core": "^2.1.0", + "@fastify/sensible": "^5.6.0", + "@fastify/websocket": "^10.0.1", + "@mapeo/crypto": "^1.0.0-alpha.10", + "@sinclair/typebox": "^0.33.17", + "env-schema": "^6.0.0", + "fastify": "^4.28.1", + "string-timing-safe-equal": "^0.1.0", + "ws": "^8.18.0" + } + }, + "node_modules/@comapeo/cloud/node_modules/@sinclair/typebox": { + "version": "0.33.17", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.17.tgz", + "integrity": "sha512-75232GRx3wp3P7NP+yc4nRK3XUAnaQShxTAzapgmQrgs0QvSq0/mOJGoZXRpH15cFCKyys+4laCPbBselqJ5Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@comapeo/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-2.1.0.tgz", + "integrity": "sha512-Fvi/EO1RJIQfpmKFUs4QApM2TsV8JrKw3HbNZ3hmlXiPl1oVVvIce0KkfdPJOoYHbEznTc9dIN1A2vkaoi431A==", + "dev": true, + "dependencies": { + "@comapeo/fallback-smp": "^1.0.0", + "@comapeo/schema": "1.2.0", + "@digidem/types": "^2.3.0", + "@fastify/error": "^3.4.1", + "@fastify/type-provider-typebox": "^4.1.0", + "@hyperswarm/secret-stream": "^6.6.3", + "@mapeo/crypto": "1.0.0-alpha.10", + "@mapeo/sqlite-indexer": "1.0.0-alpha.9", + "@sinclair/typebox": "^0.29.6", + "b4a": "^1.6.3", + "bcp-47": "^2.1.0", + "better-sqlite3": "^8.7.0", + "big-sparse-array": "^1.0.3", + "bogon": "^1.1.0", + "compact-encoding": "^2.12.0", + "corestore": "6.8.4", + "debug": "^4.3.4", + "dot-prop": "^9.0.0", + "drizzle-orm": "^0.30.8", + "fastify": "^4.0.0", + "fastify-plugin": "^4.5.1", + "hyperblobs": "2.3.0", + "hypercore": "10.17.0", + "hypercore-crypto": "3.4.2", + "hyperdrive": "11.5.3", + "json-stable-stringify": "^1.1.1", + "magic-bytes.js": "^1.10.0", + "map-obj": "^5.0.2", + "mime": "^4.0.3", + "multi-core-indexer": "^1.0.0", + "p-defer": "^4.0.0", + "p-event": "^6.0.1", + "p-timeout": "^6.1.2", + "protobufjs": "^7.2.3", + "protomux": "^3.4.1", + "quickbit-universal": "^2.2.0", + "sodium-universal": "^4.0.0", + "start-stop-state-machine": "^1.2.0", + "streamx": "^2.19.0", + "string-timing-safe-equal": "^0.1.0", + "styled-map-package": "^2.0.0", + "sub-encoder": "^2.1.1", + "throttle-debounce": "^5.0.0", + "tiny-typed-emitter": "^2.1.0", + "type-fest": "^4.5.0", + "undici": "^6.13.0", + "varint": "^6.0.0", + "ws": "^8.18.0", + "yauzl-promise": "^4.0.0" + } + }, "node_modules/@comapeo/core2.0.1": { "name": "@comapeo/core", "version": "2.0.1", @@ -527,60 +604,6 @@ "node": ">=14" } }, - "node_modules/@fastify/ajv-compiler": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.1.tgz", - "integrity": "sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==", - "dev": true, - "dependencies": { - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-uri": "^3.0.0" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.12.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", - "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", - "dev": true - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, "node_modules/@fastify/deepmerge": { "version": "1.3.0", "license": "MIT" @@ -590,90 +613,6 @@ "resolved": "https://registry.npmjs.org/@fastify/error/-/error-3.4.1.tgz", "integrity": "sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==" }, - "node_modules/@fastify/fast-json-stringify-compiler": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.1.tgz", - "integrity": "sha512-f2d3JExJgFE3UbdFcpPwqNUEoHWmt8pAKf8f+9YuLESdefA0WgqxeT6DrGL4Yrf/9ihXNSKOqpjEmurV405meA==", - "dev": true, - "dependencies": { - "fast-json-stringify": "^6.0.0" - } - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/ajv/node_modules/fast-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", - "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==", - "dev": true - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-json-stringify": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.0.0.tgz", - "integrity": "sha512-FGMKZwniMTgZh7zQp9b6XnBVxUmKVahQLQeRQHqwYmPDqDhcEKZ3BaQsxelFFI5PY7nN71OEeiL47/zUWcYe1A==", - "dev": true, - "dependencies": { - "@fastify/merge-json-schemas": "^0.1.1", - "ajv": "^8.12.0", - "ajv-formats": "^3.0.1", - "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.3.0", - "json-schema-ref-resolver": "^1.0.1", - "rfdc": "^1.2.0" - } - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/fast-uri": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", - "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==", - "dev": true - }, - "node_modules/@fastify/fast-json-stringify-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true - }, - "node_modules/@fastify/merge-json-schemas": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.1.1.tgz", - "integrity": "sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - } - }, "node_modules/@fastify/send": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/send/-/send-2.1.0.tgz", @@ -701,6 +640,8 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-5.6.0.tgz", "integrity": "sha512-Vq6Z2ZQy10GDqON+hvLF52K99s9et5gVVxTul5n3SIAf0Kq5QjPRUKkAMT3zPAiiGvoHtS3APa/3uaxfDgCODQ==", + "dev": true, + "license": "MIT", "dependencies": { "@lukeed/ms": "^2.0.1", "fast-deep-equal": "^3.1.1", @@ -779,6 +720,8 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz", "integrity": "sha512-8/pQIxTPRD8U94aILTeJ+2O3el/r19+Ej5z1O1mXlqplsUH7KzCjAI0sgd5DM/NoPjAi5qLFNIjgM5+9/rGSNw==", + "dev": true, + "license": "MIT", "dependencies": { "duplexify": "^4.1.2", "fastify-plugin": "^4.0.0", @@ -1176,7 +1119,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@shikijs/core": { "version": "1.17.7", @@ -1239,6 +1183,7 @@ "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3168,6 +3113,8 @@ "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -3179,6 +3126,8 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=12" } @@ -3409,6 +3358,8 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -3459,6 +3410,8 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/env-schema/-/env-schema-6.0.0.tgz", "integrity": "sha512-/IHp1EmrfubUOfF1wfe8koDWM5/dxUDylHANPNrPyrsYWJ7KRiB8gXbjtqQBujmOhpSpXXOhhnaL+meb+MaGtA==", + "dev": true, + "license": "MIT", "dependencies": { "ajv": "^8.12.0", "dotenv": "^16.4.5", @@ -3469,6 +3422,8 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3481,14 +3436,18 @@ } }, "node_modules/env-schema/node_modules/fast-uri": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.2.tgz", - "integrity": "sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/env-schema/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" }, "node_modules/error-ex": { "version": "1.3.2", @@ -3891,10 +3850,11 @@ } }, "node_modules/execa": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.4.0.tgz", - "integrity": "sha512-yKHlle2YGxZE842MERVIplWwNH5VYmqqcPFgtnlU//K8gxuFFXu0pwd/CrfXTumFpeEiufsP7+opT/bPJa1yVw==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.1.tgz", + "integrity": "sha512-QY5PPtSonnGwhhHDNI7+3RvY285c7iuJFFB+lU+oEzMY/gEGJ808owqJsrr8Otd1E/x07po1LkUBmdAc5duPAg==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.3", @@ -3921,6 +3881,7 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -3933,6 +3894,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -3945,6 +3907,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, + "license": "ISC", "engines": { "node": ">=14" }, @@ -4203,6 +4166,7 @@ "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", "dev": true, + "license": "MIT", "dependencies": { "is-unicode-supported": "^2.0.0" }, @@ -4465,6 +4429,7 @@ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, + "license": "MIT", "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" @@ -4481,6 +4446,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -4843,6 +4809,7 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.0.tgz", "integrity": "sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18.18.0" } @@ -5573,15 +5540,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-schema-ref-resolver": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz", - "integrity": "sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - } - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6280,6 +6238,8 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6484,6 +6444,8 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -6492,6 +6454,8 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -6965,6 +6929,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" @@ -6981,6 +6946,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -8953,7 +8919,9 @@ "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "dev": true, + "license": "MIT" }, "node_modules/streamx": { "version": "2.19.0", @@ -9123,6 +9091,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -9577,6 +9546,8 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -9777,6 +9748,7 @@ "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" }, @@ -9913,6 +9885,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8" } diff --git a/package.json b/package.json index 8105f867b..ebc00f2d5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "db:generate:project": "drizzle-kit generate:sqlite --schema src/schema/project.js --out drizzle/project", "db:generate:client": "drizzle-kit generate:sqlite --schema src/schema/client.js --out drizzle/client", "prepack": "npm run build:types", - "prepare": "husky install || true" + "prepare": "husky install" }, "files": [ "src", @@ -108,9 +108,8 @@ "homepage": "https://github.com/digidem/comapeo-core#readme", "devDependencies": { "@bufbuild/buf": "^1.26.1", + "@comapeo/cloud": "^0.1.0", "@comapeo/core2.0.1": "npm:@comapeo/core@2.0.1", - "@fastify/ajv-compiler": "^4.0.1", - "@fastify/fast-json-stringify-compiler": "^5.0.1", "@mapeo/default-config": "5.0.0", "@mapeo/mock-data": "^2.1.1", "@sinonjs/fake-timers": "^10.0.2", @@ -135,7 +134,7 @@ "cpy-cli": "^5.0.0", "drizzle-kit": "^0.20.14", "eslint": "^8.57.0", - "execa": "^9.4.0", + "execa": "^9.5.1", "husky": "^8.0.0", "iterpal": "^0.4.0", "lint-staged": "^14.0.1", @@ -161,9 +160,7 @@ "@comapeo/schema": "1.2.0", "@digidem/types": "^2.3.0", "@fastify/error": "^3.4.1", - "@fastify/sensible": "^5.6.0", "@fastify/type-provider-typebox": "^4.1.0", - "@fastify/websocket": "^10.0.1", "@hyperswarm/secret-stream": "^6.6.3", "@mapeo/crypto": "1.0.0-alpha.10", "@mapeo/sqlite-indexer": "1.0.0-alpha.9", @@ -178,7 +175,6 @@ "debug": "^4.3.4", "dot-prop": "^9.0.0", "drizzle-orm": "^0.30.8", - "env-schema": "^6.0.0", "fastify": "^4.0.0", "fastify-plugin": "^4.5.1", "hyperblobs": "2.3.0", diff --git a/src/server/README.md b/src/server/README.md deleted file mode 100644 index 8ce9582c3..000000000 --- a/src/server/README.md +++ /dev/null @@ -1,38 +0,0 @@ -## Deploying CoMapeo Cloud - -CoMapeo Cloud comes with a [`Dockerfile`](../../Dockerfile) that can be used to build a Docker image. This image can be used to deploy CoMapeo Cloud on a server. - -Server configuration is done using environment variables. The following environment variables are available: - -| Environment Variable | Required | Description | Default Value | -| --------------------- | -------- | -------------------------------------------------------------------- | ---------------- | -| `SERVER_BEARER_TOKEN` | Yes | Token for authenticating API requests. Should be large random string | | -| `PORT` | No | Port on which the server runs | `8080` | -| `SERVER_NAME` | No | Friendly server name, seen by users when adding server | `CoMapeo Server` | -| `ALLOWED_PROJECTS` | No | Number of projects allowed to register with the server | `1` | -| `STORAGE_DIR` | No | Path for storing app & project data | `$CWD/data` | - -### Deploying with fly.io - -CoMapeo Cloud can be deployed on [fly.io](https://fly.io) using the following steps: - -1. Install the flyctl CLI tool by following the instructions [here](https://fly.io/docs/getting-started/installing-flyctl/). -2. Create a new app on fly.io by running `flyctl apps create`, take a note of the app name. -3. Set the SERVER_BEARER_TOKEN secret via: - ```sh - flyctl secrets set SERVER_BEARER_TOKEN= --app - ``` -4. Deploy the app by running (optionally setting the `ALLOWED_PROJECTS` environment variable): - ```sh - flyctl deploy --app -e ALLOWED_PROJECTS=10 - ``` -5. The app should now be running on fly.io. You can access it at `https://.fly.dev`. - -To destroy the app (delete all data and project invites), run: - -> [!WARNING] -> This action is irreversible and will permanently delete all data associated with the app, and projects that have already added the server will no longer be able to sync with it. - -```sh -flyctl destroy --app -``` diff --git a/src/server/allowed-hosts-plugin.js b/src/server/allowed-hosts-plugin.js deleted file mode 100644 index 732f05bea..000000000 --- a/src/server/allowed-hosts-plugin.js +++ /dev/null @@ -1,19 +0,0 @@ -import createFastifyPlugin from 'fastify-plugin' - -/** - * @typedef {object} AllowedHostsPluginOptions - * @property {string[]} [allowedHosts] - */ - -/** @type {import('fastify').FastifyPluginAsync} */ -const comapeoPlugin = async function (fastify, { allowedHosts }) { - if (!allowedHosts) { - return - } - const allowedHostsSet = new Set(allowedHosts) - fastify.addHook('onRequest', async function (req) { - this.assert(allowedHostsSet.has(req.hostname), 403, 'Forbidden') - }) -} - -export default createFastifyPlugin(comapeoPlugin, { name: 'allowedHosts' }) diff --git a/src/server/app.js b/src/server/app.js deleted file mode 100644 index a87b292c3..000000000 --- a/src/server/app.js +++ /dev/null @@ -1,46 +0,0 @@ -import fastifyWebsocket from '@fastify/websocket' -import fastifySensible from '@fastify/sensible' -import createFastifyPlugin from 'fastify-plugin' -import routes from './routes.js' -import comapeoPlugin from './comapeo-plugin.js' -import baseUrlPlugin from './base-url-plugin.js' -import allowedHostsPlugin from './allowed-hosts-plugin.js' -/** @import { FastifyServerOptions } from 'fastify' */ -/** @import { ComapeoPluginOptions } from './comapeo-plugin.js' */ -/** @import { RouteOptions } from './routes.js' */ - -/** - * @internal - * @typedef {object} OtherServerOptions - * @prop {string[]} [allowedHosts] - */ - -/** @typedef {ComapeoPluginOptions & OtherServerOptions & RouteOptions} ServerOptions */ - -/** @type {import('fastify').FastifyPluginAsync} */ -async function comapeoServer( - fastify, - { - serverBearerToken, - serverName, - allowedHosts, - allowedProjects = 1, - ...comapeoPluginOpts - } -) { - fastify.register(fastifyWebsocket) - fastify.register(fastifySensible, { sharedSchemaId: 'HttpError' }) - fastify.register(allowedHostsPlugin, { allowedHosts }) - fastify.register(baseUrlPlugin) - fastify.register(comapeoPlugin, comapeoPluginOpts) - fastify.register(routes, { - serverBearerToken, - serverName, - allowedProjects, - }) -} - -export default createFastifyPlugin(comapeoServer, { - name: 'comapeoServer', - fastify: '4.x', -}) diff --git a/src/server/base-url-plugin.js b/src/server/base-url-plugin.js deleted file mode 100644 index 819049785..000000000 --- a/src/server/base-url-plugin.js +++ /dev/null @@ -1,11 +0,0 @@ -import createFastifyPlugin from 'fastify-plugin' - -/** @type {import('fastify').FastifyPluginAsync} */ -const baseUrlPlugin = async function (fastify) { - fastify.decorateRequest('baseUrl', null) - fastify.addHook('onRequest', async function (req) { - req.baseUrl = new URL(this.prefix, `${req.protocol}://${req.hostname}`) - }) -} - -export default createFastifyPlugin(baseUrlPlugin, { name: 'baseUrl' }) diff --git a/src/server/comapeo-plugin.js b/src/server/comapeo-plugin.js deleted file mode 100644 index 03daa0fc2..000000000 --- a/src/server/comapeo-plugin.js +++ /dev/null @@ -1,14 +0,0 @@ -import { MapeoManager } from '../index.js' -import createFastifyPlugin from 'fastify-plugin' - -/** - * @typedef {Omit[0], 'fastify'>} ComapeoPluginOptions - */ - -/** @type {import('fastify').FastifyPluginAsync} */ -const comapeoPlugin = async function (fastify, opts) { - const comapeo = new MapeoManager({ ...opts, fastify }) - fastify.decorate('comapeo', comapeo) -} - -export default createFastifyPlugin(comapeoPlugin, { name: 'comapeo' }) diff --git a/src/server/routes.js b/src/server/routes.js deleted file mode 100644 index 2ccdfaab2..000000000 --- a/src/server/routes.js +++ /dev/null @@ -1,405 +0,0 @@ -import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import { Type } from '@sinclair/typebox' -import assert from 'node:assert/strict' -import * as fs from 'node:fs' -import timingSafeEqual from 'string-timing-safe-equal' -import { replicateProject } from '../index.js' -import { wsCoreReplicator } from './ws-core-replicator.js' -/** @import {FastifyInstance, FastifyPluginAsync, FastifyRequest, RawServerDefault} from 'fastify' */ -/** @import {TypeBoxTypeProvider} from '@fastify/type-provider-typebox' */ - -const BEARER_SPACE_LENGTH = 'Bearer '.length -const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$' -const HEX_STRING_32_BYTES = Type.String({ pattern: HEX_REGEX_32_BYTES }) -const BASE32_REGEX_32_BYTES = '^[0-9A-Za-z]{52}$' -const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES }) - -const INDEX_HTML_PATH = new URL('./static/index.html', import.meta.url) - -/** - * @typedef {object} RouteOptions - * @prop {string} serverBearerToken - * @prop {string} serverName - * @prop {string[] | number} [allowedProjects=1] - */ - -/** @type {FastifyPluginAsync} */ -export default async function routes( - fastify, - { serverBearerToken, serverName, allowedProjects = 1 } -) { - /** @type {Set | number} */ - const allowedProjectsSetOrNumber = Array.isArray(allowedProjects) - ? new Set(allowedProjects) - : allowedProjects - - /** - * @param {FastifyRequest} req - */ - const verifyBearerAuth = (req) => { - if (!isBearerTokenValid(req.headers.authorization, serverBearerToken)) { - throw fastify.httpErrors.forbidden('Invalid bearer token') - } - } - - fastify.get('/', (_req, reply) => { - const stream = fs.createReadStream(INDEX_HTML_PATH) - reply.header('Content-Type', 'text/html') - reply.send(stream) - }) - - fastify.get( - '/info', - { - schema: { - response: { - 200: Type.Object({ - data: Type.Object({ - deviceId: Type.String(), - name: Type.String(), - }), - }), - 500: { $ref: 'HttpError' }, - }, - }, - }, - async function () { - const { deviceId, name } = this.comapeo.getDeviceInfo() - return { - data: { deviceId, name: name || serverName }, - } - } - ) - - fastify.get( - '/sync/:projectPublicId', - { - schema: { - params: Type.Object({ - projectPublicId: BASE32_STRING_32_BYTES, - }), - response: { - 404: { $ref: 'HttpError' }, - }, - }, - async preHandler(req) { - await ensureProjectExists(this, req) - }, - websocket: true, - }, - async function (socket, req) { - // The preValidation hook ensures that the project exists - const project = await this.comapeo.getProject(req.params.projectPublicId) - const replicationStream = replicateProject(project, false) - wsCoreReplicator(socket, replicationStream) - project.$sync.start() - } - ) - - fastify.get( - '/projects', - { - schema: { - response: { - 200: Type.Object({ - data: Type.Array( - Type.Object({ - projectId: Type.String(), - name: Type.String(), - }) - ), - }), - 403: { $ref: 'HttpError' }, - }, - }, - async preHandler(req) { - verifyBearerAuth(req) - }, - }, - async function () { - const projects = await this.comapeo.listProjects() - return { - data: projects.map((project) => ({ - projectId: project.projectId, - name: project.name, - })), - } - } - ) - - fastify.put( - '/projects', - { - schema: { - body: Type.Object({ - projectName: Type.String({ minLength: 1 }), - projectKey: HEX_STRING_32_BYTES, - encryptionKeys: Type.Object({ - auth: HEX_STRING_32_BYTES, - config: HEX_STRING_32_BYTES, - data: HEX_STRING_32_BYTES, - blobIndex: HEX_STRING_32_BYTES, - blob: HEX_STRING_32_BYTES, - }), - }), - response: { - 200: Type.Object({ - data: Type.Object({ - deviceId: HEX_STRING_32_BYTES, - }), - }), - 400: { $ref: 'HttpError' }, - }, - }, - }, - async function (req) { - const { projectName } = req.body - const projectKey = Buffer.from(req.body.projectKey, 'hex') - const projectPublicId = projectKeyToPublicId(projectKey) - - const existingProjects = await this.comapeo.listProjects() - - // This assumes that two projects with the same project key are equivalent, - // and that we don't need to add more. Theoretically, someone could add - // project with ID 1 and keys A, then add project with ID 1 and keys B. - // This would mean a malicious/buggy client, which could cause errors if - // trying to sync with this server--that seems acceptable. - const alreadyHasThisProject = existingProjects.some((p) => - // We don't want people to be able to enumerate the project keys that - // this server has. - timingSafeEqual(p.projectId, projectPublicId) - ) - - if (!alreadyHasThisProject) { - if ( - allowedProjectsSetOrNumber instanceof Set && - !allowedProjectsSetOrNumber.has(projectPublicId) - ) { - throw fastify.httpErrors.forbidden('Project not allowed') - } - - if ( - typeof allowedProjectsSetOrNumber === 'number' && - existingProjects.length >= allowedProjectsSetOrNumber - ) { - throw fastify.httpErrors.forbidden( - 'Server is already linked to the maximum number of projects' - ) - } - } - - const baseUrl = req.baseUrl.toString() - - const existingDeviceInfo = this.comapeo.getDeviceInfo() - // We don't set device info until this point. We trust that `req.hostname` - // is the hostname we want clients to use to sync to the server. - if ( - existingDeviceInfo.deviceType === 'device_type_unspecified' || - existingDeviceInfo.selfHostedServerDetails?.baseUrl !== baseUrl - ) { - await this.comapeo.setDeviceInfo({ - deviceType: 'selfHostedServer', - name: serverName, - selfHostedServerDetails: { baseUrl }, - }) - } - - if (!alreadyHasThisProject) { - const projectId = await this.comapeo.addProject( - { - projectKey, - projectName, - encryptionKeys: { - auth: Buffer.from(req.body.encryptionKeys.auth, 'hex'), - config: Buffer.from(req.body.encryptionKeys.config, 'hex'), - data: Buffer.from(req.body.encryptionKeys.data, 'hex'), - blobIndex: Buffer.from(req.body.encryptionKeys.blobIndex, 'hex'), - blob: Buffer.from(req.body.encryptionKeys.blob, 'hex'), - }, - }, - { waitForSync: false } - ) - assert.equal( - projectId, - projectPublicId, - 'adding a project should return the same ID as what was passed' - ) - } - - const project = await this.comapeo.getProject(projectPublicId) - project.$sync.start() - - return { - data: { - deviceId: this.comapeo.deviceId, - }, - } - } - ) - - fastify.get( - '/projects/:projectPublicId/observations', - { - schema: { - params: Type.Object({ - projectPublicId: BASE32_STRING_32_BYTES, - }), - response: { - 200: Type.Object({ - data: Type.Array( - Type.Object({ - docId: Type.String(), - createdAt: Type.String(), - updatedAt: Type.String(), - deleted: Type.Boolean(), - lat: Type.Optional(Type.Number()), - lon: Type.Optional(Type.Number()), - attachments: Type.Array( - Type.Object({ - url: Type.String(), - }) - ), - tags: Type.Record( - Type.String(), - Type.Union([ - Type.Boolean(), - Type.Number(), - Type.String(), - Type.Null(), - Type.Array( - Type.Union([ - Type.Boolean(), - Type.Number(), - Type.String(), - Type.Null(), - ]) - ), - ]) - ), - }) - ), - }), - 403: { $ref: 'HttpError' }, - 404: { $ref: 'HttpError' }, - }, - }, - async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) - }, - }, - async function (req) { - const { projectPublicId } = req.params - const project = await this.comapeo.getProject(projectPublicId) - - return { - data: (await project.observation.getMany({ includeDeleted: true })).map( - (obs) => ({ - docId: obs.docId, - createdAt: obs.createdAt, - updatedAt: obs.updatedAt, - deleted: obs.deleted, - lat: obs.lat, - lon: obs.lon, - attachments: obs.attachments - // TODO: For now, only photos are supported. - // See . - .filter((attachment) => attachment.type === 'photo') - .map((attachment) => ({ - url: new URL( - `projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`, - req.baseUrl - ).href, - })), - tags: obs.tags, - }) - ), - } - } - ) - - fastify.get( - '/projects/:projectPublicId/attachments/:driveDiscoveryId/:type/:name', - { - schema: { - params: Type.Object({ - projectPublicId: BASE32_STRING_32_BYTES, - driveDiscoveryId: Type.String(), - // TODO: For now, only photos are supported. - // See . - type: Type.Literal('photo'), - name: Type.String(), - }), - querystring: Type.Object({ - variant: Type.Optional( - Type.Union([ - Type.Literal('original'), - Type.Literal('preview'), - Type.Literal('thumbnail'), - ]) - ), - }), - response: { - 403: { $ref: 'HttpError' }, - 404: { $ref: 'HttpError' }, - }, - }, - async preHandler(req) { - verifyBearerAuth(req) - await ensureProjectExists(this, req) - }, - }, - async function (req, reply) { - const project = await this.comapeo.getProject(req.params.projectPublicId) - - const blobUrl = await project.$blobs.getUrl({ - driveId: req.params.driveDiscoveryId, - name: req.params.name, - type: req.params.type, - variant: req.query.variant || 'original', - }) - - const proxiedResponse = await fetch(blobUrl) - reply.code(proxiedResponse.status) - for (const [headerName, headerValue] of proxiedResponse.headers) { - reply.header(headerName, headerValue) - } - return reply.send(proxiedResponse.body) - } - ) -} - -/** - * @param {FastifyInstance} fastify - * @param {object} req - * @param {object} req.params - * @param {string} req.params.projectPublicId - * @returns {Promise} - */ -async function ensureProjectExists(fastify, req) { - try { - await fastify.comapeo.getProject(req.params.projectPublicId) - } catch (e) { - if (e instanceof Error && e.message.startsWith('NotFound')) { - throw fastify.httpErrors.notFound('Project not found') - } - throw e - } -} - -/** - * @param {undefined | string} headerValue - * @param {string} expectedBearerToken - * @returns {boolean} - */ -function isBearerTokenValid(headerValue = '', expectedBearerToken) { - // This check is not strictly required for correctness, but helps protect - // against long values. - const expectedLength = BEARER_SPACE_LENGTH + expectedBearerToken.length - if (headerValue.length !== expectedLength) return false - - if (!headerValue.startsWith('Bearer ')) return false - const actualBearerToken = headerValue.slice(BEARER_SPACE_LENGTH) - - return timingSafeEqual(actualBearerToken, expectedBearerToken) -} diff --git a/src/server/server.js b/src/server/server.js deleted file mode 100644 index b088b8234..000000000 --- a/src/server/server.js +++ /dev/null @@ -1,106 +0,0 @@ -import { Type } from '@sinclair/typebox' -import envSchema from 'env-schema' -import createFastify from 'fastify' -import crypto from 'node:crypto' -import fsPromises from 'node:fs/promises' -import path from 'node:path' -import comapeoServer from './app.js' - -const DEFAULT_STORAGE = path.join(process.cwd(), 'data') -const CORE_DIR_NAME = 'core' -const DB_DIR_NAME = 'db' -const ROOT_KEY_FILE_NAME = 'root-key' - -const schema = Type.Object({ - PORT: Type.Number({ default: 8080 }), - SERVER_NAME: Type.String({ - description: 'name of the server', - default: 'CoMapeo Server', - }), - SERVER_BEARER_TOKEN: Type.String({ - description: - 'Bearer token for accessing the server, can be any random string', - }), - STORAGE_DIR: Type.String({ - description: 'path to directory where data is stored', - default: DEFAULT_STORAGE, - }), - ALLOWED_PROJECTS: Type.Optional( - Type.Integer({ - minimum: 1, - description: 'number of projects allowed to join the server', - }) - ), -}) - -/** @typedef {import('@sinclair/typebox').Static} Env */ -/** @type {ReturnType>} */ -const config = envSchema({ schema, dotenv: true }) - -const coreStorage = path.join(config.STORAGE_DIR, CORE_DIR_NAME) -const dbFolder = path.join(config.STORAGE_DIR, DB_DIR_NAME) -const rootKeyFile = path.join(config.STORAGE_DIR, ROOT_KEY_FILE_NAME) - -const migrationsFolder = new URL('../../drizzle/', import.meta.url).pathname -const projectMigrationsFolder = path.join(migrationsFolder, 'project') -const clientMigrationsFolder = path.join(migrationsFolder, 'client') - -await Promise.all([ - fsPromises.mkdir(coreStorage, { recursive: true }), - fsPromises.mkdir(dbFolder, { recursive: true }), -]) - -/** @type {Buffer} */ -let rootKey -try { - rootKey = await fsPromises.readFile(rootKeyFile) -} catch (err) { - if ( - typeof err === 'object' && - err && - 'code' in err && - err.code !== 'ENOENT' - ) { - throw err - } - rootKey = crypto.randomBytes(16) - await fsPromises.writeFile(rootKeyFile, rootKey) -} - -if (!rootKey || rootKey.length !== 16) { - throw new Error('Root key must be 16 bytes') -} - -const fastify = createFastify({ - logger: true, - trustProxy: true, -}) -fastify.register(comapeoServer, { - serverName: config.SERVER_NAME, - serverBearerToken: config.SERVER_BEARER_TOKEN, - allowedProjects: config.ALLOWED_PROJECTS, - rootKey, - coreStorage, - dbFolder, - projectMigrationsFolder, - clientMigrationsFolder, -}) - -fastify.get('/healthcheck', async () => {}) - -try { - await fastify.listen({ port: config.PORT, host: '0.0.0.0' }) -} catch (err) { - fastify.log.error(err) - process.exit(1) -} - -/** @param {NodeJS.Signals} signal*/ -async function closeGracefully(signal) { - console.log(`Received signal to terminate: ${signal}`) - await fastify.close() - console.log('Gracefully closed fastify') - process.kill(process.pid, signal) -} -process.once('SIGINT', closeGracefully) -process.once('SIGTERM', closeGracefully) diff --git a/src/server/static/index.html b/src/server/static/index.html deleted file mode 100644 index d7b253b12..000000000 --- a/src/server/static/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - CoMapeo - - - - -

¡Hola desde CoMapeo!

-

Olá da CoMapeo!

-

Hello from CoMapeo!

- - diff --git a/src/server/test/add-project-endpoint.js b/src/server/test/add-project-endpoint.js deleted file mode 100644 index 595bdee91..000000000 --- a/src/server/test/add-project-endpoint.js +++ /dev/null @@ -1,223 +0,0 @@ -import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import assert from 'node:assert/strict' -import test from 'node:test' -import { - createTestServer, - randomAddProjectBody, - randomHex, -} from './test-helpers.js' - -test('request missing project name', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: omit(randomAddProjectBody(), 'projectName'), - }) - - assert.equal(response.statusCode, 400) -}) - -test('request with empty project name', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: { ...randomAddProjectBody(), projectName: '' }, - }) - - assert.equal(response.statusCode, 400) -}) - -test('request missing project key', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: omit(randomAddProjectBody(), 'projectKey'), - }) - - assert.equal(response.statusCode, 400) -}) - -test("request with a project key that's too short", async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: { ...randomAddProjectBody(), projectKey: randomHex(31) }, - }) - - assert.equal(response.statusCode, 400) -}) - -test('request missing any encryption keys', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: omit(randomAddProjectBody(), 'encryptionKeys'), - }) - - assert.equal(response.statusCode, 400) -}) - -test('request missing an encryption key', async (t) => { - const server = createTestServer(t) - const body = randomAddProjectBody() - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: { - ...body, - encryptionKeys: omit(body.encryptionKeys, 'config'), - }, - }) - - assert.equal(response.statusCode, 400) -}) - -test("request with an encryption key that's too short", async (t) => { - const server = createTestServer(t) - const body = randomAddProjectBody() - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: { - ...body, - encryptionKeys: { ...body.encryptionKeys, config: randomHex(31) }, - }, - }) - - assert.equal(response.statusCode, 400) -}) - -test('adding a project', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - - assert.equal(response.statusCode, 200) - assert.deepEqual(response.json(), { - data: { deviceId: server.deviceId }, - }) -}) - -test('adding a second project fails by default', async (t) => { - const server = createTestServer(t) - - const firstAddResponse = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - assert.equal(firstAddResponse.statusCode, 200) - - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - assert.equal(response.statusCode, 403) - assert.match(response.json().message, /maximum number of projects/) -}) - -test('allowing a maximum number of projects', async (t) => { - const server = createTestServer(t, { allowedProjects: 3 }) - - await t.test('adding 3 projects', async () => { - for (let i = 0; i < 3; i++) { - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - assert.equal(response.statusCode, 200) - } - }) - - await t.test('attempting to add 4th project fails', async () => { - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - assert.equal(response.statusCode, 403) - assert.match(response.json().message, /maximum number of projects/) - }) -}) - -test( - 'allowing a specific list of projects', - { concurrency: true }, - async (t) => { - const body = randomAddProjectBody() - const projectPublicId = projectKeyToPublicId( - Buffer.from(body.projectKey, 'hex') - ) - const server = createTestServer(t, { - allowedProjects: [projectPublicId], - }) - - await t.test('adding a project in the list', async () => { - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body, - }) - assert.equal(response.statusCode, 200) - }) - - await t.test('trying to add a project not in the list', async () => { - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body: randomAddProjectBody(), - }) - assert.equal(response.statusCode, 403) - }) - } -) - -test('adding the same project twice is idempotent', async (t) => { - const server = createTestServer(t, { allowedProjects: 1 }) - const body = randomAddProjectBody() - - const firstResponse = await server.inject({ - method: 'PUT', - url: '/projects', - body, - }) - assert.equal(firstResponse.statusCode, 200) - - const secondResponse = await server.inject({ - method: 'PUT', - url: '/projects', - body, - }) - assert.equal(secondResponse.statusCode, 200) -}) - -/** - * @template {object} T - * @template {keyof T} K - * @param {T} obj - * @param {K} key - * @returns {Omit} - */ -function omit(obj, key) { - const result = { ...obj } - delete result[key] - return result -} diff --git a/src/server/test/allowed-hosts.js b/src/server/test/allowed-hosts.js deleted file mode 100644 index 4cab3b40e..000000000 --- a/src/server/test/allowed-hosts.js +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { createTestServer } from './test-helpers.js' - -test('allowed host', async (t) => { - const allowedHost = 'www.example.com' - const server = createTestServer(t, { allowedHosts: [allowedHost] }) - - const response = await server.inject({ - authority: allowedHost, - method: 'GET', - url: '/info', - }) - - assert.equal(response.statusCode, 200) -}) - -test('disallowed host', async (t) => { - const server = createTestServer(t, { allowedHosts: ['www.example.com'] }) - - const response = await server.inject({ - authority: 'www.invalid-host.example', - method: 'GET', - url: '/info', - }) - - assert.equal(response.statusCode, 403) -}) diff --git a/src/server/test/fixtures/audio.mp3 b/src/server/test/fixtures/audio.mp3 deleted file mode 100644 index 19531804b9d3454e99c0fcd44758b6e7fc8cd474..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17181 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 diff --git a/src/server/test/fixtures/original.jpg b/src/server/test/fixtures/original.jpg deleted file mode 100644 index 5a3b430be3d9d35377d3a29a63c15ece9a66949b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16308 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 diff --git a/src/server/test/fixtures/thumbnail.jpg b/src/server/test/fixtures/thumbnail.jpg deleted file mode 100644 index facee563c04409a1e9e9ab36f9bdce15621a4ae6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1661 zcmbW!doaFEEY43*n#?wX`o z2BYjMM96j;avMpK+_FiCvL`V~bg@(I**$0f+THJS-uIvPInQ}M=UpFJe+F!HvU9Wp zKo9^xNdfCm0BeAV!xQidM8YP5Dw(K8v)83*Yty`TSsB^~`i34m;tTsnCY5JK#$Jl| zhtIcVU%Gm&vAHoUT{PTPH&kBVSo38FNLE#)Y0}(vb=_-Dz$a?{bJm3bRvNGX{2)*b zKwv=#3$C{VDv~{=z^?)R42Xb`QX5dxXc<{aL&HV@0YVT02}wyIk&^C2$vc3=N-0qE zEH@~+`k~Y!aQf_=QfYOo+EzU4;TsJD|Hx#tjM64$!e&h^ZR%DXLnC7oQ!~1?jjf%% zgQJt1yN9ROK5w7H@R5MPprgUZqfSJ}oQ#c2Idk@0YFc_mZeD&t;RWu+B7RwUg`o1v z)vCH1^$j;0o0@O6-EHsa?CKWv3_KcqJTyEqIyU)yYI^3y?A*&&i%a6S%kNg+udaP@ zfdKT4CAq)B{^7z(TnHo*LZZI7Ktzn>Kv<*{MQ?+Gr7OxWLQzehEse9vDXncqs~fQ1 z;Qb>X$|z|VPHHZGp?#J8J6Q7nlKlny+cgGYAW-so5Ed{8x?(Ljw)L>_$(O5c+!Q(V zmK1E3rAu$if&66@q2kAILi)V$MM1m1J}%eYgrp3!6ws~69e%w0Us6ftJ~mbl*cOV| znXY=1#NPNg8IRQ-u-=@KPGZdY1_rz`PH8lpu?~$o&ACQDp5b77{f62$mK_8qjD}fW z(L{;%f7iydi^!Yz40CcOzlZbUz=H129?aRGtb5t!^h5N(fDFd{7W71e-g|t{; z2DV?uOPifCkd5UT^d-Ks@vr62&WKC}#^n=@=UbdbBqE{^y35Nh>? zVerxR>M_5R{=B{jW{F~^GMt)hbHy@z3+2L~hj;1H__dEoa^o@&FY>nH?;^UBV7WVP zkKR;yVfx(bW;vJ35Cs!1)Vx9juO;GKUqmM(=%}(TAY>#7rZ{hBwjMvozcJ_cO=gd_9!bMi^a@pRM27lV!=9a zfkV#k^vd$!2u4d2$R_q$aymONak&9XPnFr?Pm>wdq^59b5%KMm(J7|<Q;@(f;b0P5JG4tO z>T32Dxa-lHQQ|cZ*@ltHJxn}CfAyJ|wL7|})96qMF_#XnJ+`JyPv@RAE2X4)caPEI zU6_huH#Eu7+{w(yn#5kPH95M>PpaK1n(aN1&T@R&&k`xp7IK;y+-INNAJA=edPZoE zf{1Q0H)jICto`J5z-OCOlLqssk}ExBI5>GCwPN4xLf=VZOTOK%=PRP+<*J?ez13<7 zbX#5d3LIvA^ba?!;5+~T diff --git a/src/server/test/list-projects-endpoint.js b/src/server/test/list-projects-endpoint.js deleted file mode 100644 index 3d91d49e0..000000000 --- a/src/server/test/list-projects-endpoint.js +++ /dev/null @@ -1,73 +0,0 @@ -import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import assert from 'node:assert/strict' -import test from 'node:test' -import { - BEARER_TOKEN, - createTestServer, - randomAddProjectBody, -} from './test-helpers.js' - -test('listing projects', async (t) => { - const server = createTestServer(t, { allowedProjects: 999 }) - - await t.test('with invalid auth', async () => { - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer bad' }, - }) - assert.equal(response.statusCode, 403) - }) - - await t.test('with no projects', async () => { - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - assert.deepEqual(response.json(), { data: [] }) - }) - - await t.test('with projects', async () => { - const body1 = randomAddProjectBody() - const body2 = randomAddProjectBody() - - await Promise.all( - [body1, body2].map(async (body) => { - const response = await server.inject({ - method: 'PUT', - url: '/projects', - body, - }) - assert.equal(response.statusCode, 200) - }) - ) - - const response = await server.inject({ - method: 'GET', - url: '/projects', - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - - const { data } = response.json() - assert(Array.isArray(data)) - assert.equal(data.length, 2, 'expected 2 projects') - for (const body of [body1, body2]) { - const projectPublicId = projectKeyToPublicId( - Buffer.from(body.projectKey, 'hex') - ) - /** @type {Record} */ - const project = data.find( - (project) => project.projectId === projectPublicId - ) - assert(project, `expected ${projectPublicId} to be found`) - assert.equal( - project.name, - body.projectName, - 'expected project name to match' - ) - } - }) -}) diff --git a/src/server/test/observations-endpoint.js b/src/server/test/observations-endpoint.js deleted file mode 100644 index 6e717c300..000000000 --- a/src/server/test/observations-endpoint.js +++ /dev/null @@ -1,280 +0,0 @@ -import { valueOf } from '@comapeo/schema' -import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import { generate } from '@mapeo/mock-data' -import { map } from 'iterpal' -import assert from 'node:assert/strict' -import * as fs from 'node:fs/promises' -import test from 'node:test' -import { setTimeout as delay } from 'node:timers/promises' -import { MapeoManager } from '../../index.js' -import { - BEARER_TOKEN, - createTestServer, - getManagerOptions, - randomAddProjectBody, -} from './test-helpers.js' -/** @import { ObservationValue } from '@comapeo/schema'*/ -/** @import { FastifyInstance } from 'fastify' */ - -const FIXTURES_ROOT = new URL('./fixtures/', import.meta.url) -const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname -const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname -const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname -const FIXTURE_AUDIO_PATH = new URL('audio.mp3', FIXTURES_ROOT).pathname - -test('returns a 403 if no auth is provided', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'GET', - url: `/projects/${randomProjectPublicId()}/observations`, - }) - assert.equal(response.statusCode, 403) -}) - -test('returns a 403 if incorrect auth is provided', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'GET', - url: `/projects/${randomProjectPublicId()}/observations`, - headers: { Authorization: 'Bearer bad' }, - }) - assert.equal(response.statusCode, 403) -}) - -test('returning no observations', async (t) => { - const server = createTestServer(t) - const projectKeys = randomAddProjectBody() - const projectPublicId = projectKeyToPublicId( - Buffer.from(projectKeys.projectKey, 'hex') - ) - - const addProjectResponse = await server.inject({ - method: 'PUT', - url: '/projects', - body: projectKeys, - }) - assert.equal(addProjectResponse.statusCode, 200) - - const response = await server.inject({ - method: 'GET', - url: `/projects/${projectPublicId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - assert.deepEqual(await response.json(), { data: [] }) -}) - -test('returning observations with fetchable attachments', async (t) => { - const server = createTestServer(t) - - const serverAddress = await server.listen() - const serverUrl = new URL(serverAddress) - - const manager = new MapeoManager(getManagerOptions()) - const projectId = await manager.createProject({ name: 'CoMapeo project' }) - const project = await manager.getProject(projectId) - - await project.$member.addServerPeer(serverAddress, { - dangerouslyAllowInsecureConnections: true, - }) - - project.$sync.start() - project.$sync.connectServers() - - const observations = await Promise.all([ - (() => { - /** @type {ObservationValue} */ - const noAttachments = { - ...valueOf(generate('observation')[0]), - attachments: [], - } - return project.observation.create(noAttachments) - })(), - (async () => { - const { docId } = await project.observation.create( - valueOf(generate('observation')[0]) - ) - return project.observation.delete(docId) - })(), - (async () => { - const [imageBlob, audioBlob] = await Promise.all([ - project.$blobs.create( - { - original: FIXTURE_ORIGINAL_PATH, - preview: FIXTURE_PREVIEW_PATH, - thumbnail: FIXTURE_THUMBNAIL_PATH, - }, - { mimeType: 'image/jpeg', timestamp: Date.now() } - ), - project.$blobs.create( - { original: FIXTURE_AUDIO_PATH }, - { mimeType: 'audio/mpeg', timestamp: Date.now() } - ), - ]) - /** @type {ObservationValue} */ - const withAttachment = { - ...valueOf(generate('observation')[0]), - attachments: [blobToAttachment(imageBlob), blobToAttachment(audioBlob)], - } - return project.observation.create(withAttachment) - })(), - ]) - - await project.$sync.waitForSync('full') - - // It's possible that the client thinks it's synced but the server hasn't - // processed everything yet, so we try a few times. - const data = await runWithRetries(3, async () => { - const response = await server.inject({ - authority: serverUrl.host, - method: 'GET', - url: `/projects/${projectId}/observations`, - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal(response.statusCode, 200) - - const { data } = await response.json() - assert.equal(data.length, 3) - return data - }) - - await Promise.all( - observations.map(async (observation) => { - const observationFromApi = data.find( - (/** @type {{ docId: string }} */ o) => o.docId === observation.docId - ) - assert(observationFromApi, 'observation found in API response') - assert.equal(observationFromApi.createdAt, observation.createdAt) - assert.equal(observationFromApi.updatedAt, observation.updatedAt) - assert.equal(observationFromApi.lat, observation.lat) - assert.equal(observationFromApi.lon, observation.lon) - assert.equal(observationFromApi.deleted, observation.deleted) - if (!observationFromApi.deleted) { - await assertAttachmentsCanBeFetchedAsJpeg({ - server, - serverAddress, - observationFromApi, - }) - } - assert.deepEqual(observationFromApi.tags, observation.tags) - }) - ) -}) - -function randomProjectPublicId() { - return projectKeyToPublicId( - Buffer.from(randomAddProjectBody().projectKey, 'hex') - ) -} - -/** - * @param {object} blob - * @param {string} blob.driveId - * @param {'photo' | 'audio' | 'video'} blob.type - * @param {string} blob.name - * @param {string} blob.hash - */ -function blobToAttachment(blob) { - return { - driveDiscoveryId: blob.driveId, - type: blob.type, - name: blob.name, - hash: blob.hash, - } -} - -/** - * @template T - * @param {number} retries - * @param {() => Promise} fn - * @returns {Promise} - */ -async function runWithRetries(retries, fn) { - for (let i = 0; i < retries - 1; i++) { - try { - return await fn() - } catch { - await delay(500) - } - } - return fn() -} - -/** - * @param {object} options - * @param {FastifyInstance} options.server - * @param {string} options.serverAddress - * @param {Record} options.observationFromApi - * @returns {Promise} - */ -async function assertAttachmentsCanBeFetchedAsJpeg({ - server, - serverAddress, - observationFromApi, -}) { - assert(Array.isArray(observationFromApi.attachments)) - await Promise.all( - observationFromApi.attachments.map( - /** @param {unknown} attachment */ - async (attachment) => { - assert(attachment && typeof attachment === 'object') - assert('url' in attachment && typeof attachment.url === 'string') - await assertAttachmentAndVariantsCanBeFetched( - server, - serverAddress, - attachment.url - ) - } - ) - ) -} - -/** - * @param {FastifyInstance} server - * @param {string} serverAddress - * @param {string} url - * @returns {Promise} - */ -async function assertAttachmentAndVariantsCanBeFetched( - server, - serverAddress, - url -) { - assert(url.startsWith(serverAddress)) - - /** @type {Map} */ - const variantsToCheck = new Map([ - [null, FIXTURE_ORIGINAL_PATH], - ['original', FIXTURE_ORIGINAL_PATH], - ['preview', FIXTURE_PREVIEW_PATH], - ['thumbnail', FIXTURE_THUMBNAIL_PATH], - ]) - - await Promise.all( - map(variantsToCheck, async ([variant, fixturePath]) => { - const expectedResponseBodyPromise = fs.readFile(fixturePath) - const attachmentResponse = await server.inject({ - method: 'GET', - url: url + (variant ? `?variant=${variant}` : ''), - headers: { Authorization: 'Bearer ' + BEARER_TOKEN }, - }) - assert.equal( - attachmentResponse.statusCode, - 200, - `expected 200 when fetching ${variant} attachment` - ) - assert.equal( - attachmentResponse.headers['content-type'], - 'image/jpeg', - `expected ${variant} attachment to be a JPEG` - ) - assert.deepEqual( - attachmentResponse.rawPayload, - await expectedResponseBodyPromise, - `expected ${variant} attachment to match fixture` - ) - }) - ) -} diff --git a/src/server/test/root.js b/src/server/test/root.js deleted file mode 100644 index 9d4422eeb..000000000 --- a/src/server/test/root.js +++ /dev/null @@ -1,20 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { createTestServer } from './test-helpers.js' - -test('server root', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'GET', - url: '/', - }) - - assert.equal(response.statusCode, 200) - const contentType = response.headers['content-type'] - assert( - typeof contentType === 'string' && contentType.startsWith('text/html'), - 'response is HTML' - ) - assert(response.body.includes(' { - const serverName = 'test server' - const server = createTestServer(t, { serverName }) - - const response = await server.inject({ - method: 'GET', - url: '/info', - }) - - assert.equal(response.statusCode, 200) - assert.deepEqual(response.json(), { - data: { - deviceId: server.deviceId, - name: serverName, - }, - }) -}) diff --git a/src/server/test/sync-endpoint.js b/src/server/test/sync-endpoint.js deleted file mode 100644 index d2eaefaaf..000000000 --- a/src/server/test/sync-endpoint.js +++ /dev/null @@ -1,53 +0,0 @@ -import { keyToPublicId as projectKeyToPublicId } from '@mapeo/crypto' -import assert from 'node:assert/strict' -import test from 'node:test' -import { createTestServer, randomAddProjectBody } from './test-helpers.js' - -test('sync endpoint is available after adding a project', async (t) => { - const server = createTestServer(t) - const addProjectBody = randomAddProjectBody() - const projectPublicId = projectKeyToPublicId( - Buffer.from(addProjectBody.projectKey, 'hex') - ) - - await t.test('sync endpoint not available yet', async () => { - const response = await server.inject({ - method: 'GET', - url: '/sync/' + projectPublicId, - headers: { - connection: 'upgrade', - upgrade: 'websocket', - }, - }) - assert.equal(response.statusCode, 404) - assert.equal(response.json().error, 'Not Found') - }) - - await server.inject({ - method: 'PUT', - url: '/projects', - body: addProjectBody, - }) - - await t.test('sync endpoint available', async (t) => { - const ws = await server.injectWS('/sync/' + projectPublicId) - t.after(() => ws.terminate()) - assert.equal(ws.readyState, ws.OPEN, 'websocket is open') - }) -}) - -test('sync endpoint returns error with an invalid project public ID', async (t) => { - const server = createTestServer(t) - - const response = await server.inject({ - method: 'GET', - url: '/sync/foobidoobi', - headers: { - connection: 'upgrade', - upgrade: 'websocket', - }, - }) - - assert.equal(response.statusCode, 400) - assert.equal(response.json().code, 'FST_ERR_VALIDATION') -}) diff --git a/src/server/test/test-helpers.js b/src/server/test/test-helpers.js deleted file mode 100644 index 26a9bd5ee..000000000 --- a/src/server/test/test-helpers.js +++ /dev/null @@ -1,73 +0,0 @@ -import { KeyManager } from '@mapeo/crypto' -import createFastify from 'fastify' -import { randomBytes } from 'node:crypto' -import RAM from 'random-access-memory' -import comapeoServer from '../app.js' -/** @import { MapeoManager } from '../../index.js' */ -/** @import { TestContext } from 'node:test' */ -/** @import { ServerOptions } from '../app.js' */ - -export const BEARER_TOKEN = Buffer.from('swordfish').toString('base64') - -const TEST_SERVER_DEFAULTS = { - serverName: 'test server', - serverBearerToken: BEARER_TOKEN, -} - -/** - * @returns {ConstructorParameters[0]} - */ -export function getManagerOptions() { - const comapeoCoreUrl = new URL('../../..', import.meta.url) - const projectMigrationsFolder = new URL('./drizzle/project', comapeoCoreUrl) - .pathname - const clientMigrationsFolder = new URL('./drizzle/client', comapeoCoreUrl) - .pathname - return { - rootKey: randomBytes(16), - projectMigrationsFolder, - clientMigrationsFolder, - dbFolder: ':memory:', - coreStorage: () => new RAM(), - fastify: createFastify(), - } -} - -/** - * @param {TestContext} t - * @param {Partial} [serverOptions] - * @returns {import('fastify').FastifyInstance & { deviceId: string }} - */ -export function createTestServer(t, serverOptions) { - const managerOptions = getManagerOptions() - const km = new KeyManager(managerOptions.rootKey) - const server = createFastify() - server.register(comapeoServer, { - ...managerOptions, - ...TEST_SERVER_DEFAULTS, - ...serverOptions, - }) - t.after(() => server.close()) - Object.defineProperty(server, 'deviceId', { - get() { - return km.getIdentityKeypair().publicKey.toString('hex') - }, - }) - // @ts-expect-error - return server -} - -export const randomHex = (length = 32) => - Buffer.from(randomBytes(length)).toString('hex') - -export const randomAddProjectBody = () => ({ - projectName: randomHex(16), - projectKey: randomHex(), - encryptionKeys: { - auth: randomHex(), - config: randomHex(), - data: randomHex(), - blobIndex: randomHex(), - blob: randomHex(), - }, -}) diff --git a/src/server/types.ts b/src/server/types.ts deleted file mode 100644 index 923af9e99..000000000 --- a/src/server/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -// This file should be read by Typescript and augments the FastifyInstance -// Unfortunately it does this globally, which is a limitation of fastify -// typescript support currently, so need to be careful about using this where it -// is not in scope. - -import { type MapeoManager } from '../index.js' - -declare module 'fastify' { - interface FastifyInstance { - comapeo: MapeoManager - } - interface FastifyRequest { - baseUrl: URL - } -} diff --git a/src/server/ws-core-replicator.js b/src/server/ws-core-replicator.js deleted file mode 100644 index c2d2cf5a6..000000000 --- a/src/server/ws-core-replicator.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Transform } from 'node:stream' -import { pipeline } from 'node:stream/promises' -import { createWebSocketStream } from 'ws' -/** @import Protomux from 'protomux' */ -/** @import NoiseStream from '@hyperswarm/secret-stream' */ -/** @import { Duplex } from 'streamx' */ - -/** - * @internal - * @typedef {Omit & { userData: Protomux }} ProtocolStream - */ - -/** - * @internal - * @typedef {Duplex & { noiseStream: ProtocolStream }} ReplicationStream - */ - -/** - * @param {import('ws').WebSocket} ws - * @param {ReplicationStream} replicationStream - * @returns {Promise} - */ -export function wsCoreReplicator(ws, replicationStream) { - // This is purely to satisfy typescript at its worst. `pipeline` expects a - // NodeJS ReadWriteStream, but our replicationStream is a streamx Duplex - // stream. The difference is that streamx does not implement the - // `setEncoding`, `unpipe`, `wrap` or `isPaused` methods. The `pipeline` - // function does not depend on any of these methods (I have read through the - // NodeJS source code at cebf21d (v22.9.0) to confirm this), so we can safely - // cast the stream to a NodeJS ReadWriteStream. - const _replicationStream = /** @type {NodeJS.ReadWriteStream} */ ( - /** @type {unknown} */ (replicationStream) - ) - return pipeline( - _replicationStream, - wsSafetyTransform(ws), - createWebSocketStream(ws), - _replicationStream - ) -} - -/** - * Avoid writing data to a closing or closed websocket, which would result in an - * error. Instead we drop the data and wait for the stream close/end events to - * propagate and close the streams cleanly. - * - * @param {import('ws').WebSocket} ws - */ -function wsSafetyTransform(ws) { - return new Transform({ - transform(chunk, encoding, callback) { - if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) { - return callback() - } - callback(null, chunk) - }, - }) -} diff --git a/test-e2e/server.js b/test-e2e/server.js index 7bd731269..544dae12e 100644 --- a/test-e2e/server.js +++ b/test-e2e/server.js @@ -10,7 +10,7 @@ import pDefer from 'p-defer' import { pEvent } from 'p-event' import RAM from 'random-access-memory' import { MEMBER_ROLE_ID } from '../src/roles.js' -import comapeoServer from '../src/server/app.js' +import comapeoServer from '@comapeo/cloud' import { connectPeers, createManager, @@ -19,6 +19,7 @@ import { waitForPeers, waitForSync, } from './utils.js' +import { fileURLToPath } from 'node:url' /** @import { FastifyInstance } from 'fastify' */ /** @import { MapeoManager } from '../src/mapeo-manager.js' */ /** @import { MapeoProject } from '../src/mapeo-project.js' */ @@ -27,6 +28,8 @@ import { const USE_REMOTE_SERVER = Boolean(process.env.REMOTE_TEST_SERVER) +const comapeoCoreUrl = new URL('..', import.meta.url) + test('invalid base URLs', async (t) => { const manager = createManager('device0', t) const projectId = await manager.createProject() @@ -413,28 +416,32 @@ async function createTestServer(t) { * @returns {Promise} */ async function createRemoteTestServer(t) { + const comapeoCloudUrl = new URL( + './node_modules/@comapeo/cloud/', + comapeoCoreUrl + ) + const execaOptions = { + cwd: fileURLToPath(comapeoCloudUrl), + stdio: /** @type {const} */ ('inherit'), + } const appName = 'comapeo-cloud-test-' + Math.random().toString(36).slice(8) await execa( 'flyctl', ['apps', 'create', '--name', appName, '--org', 'digidem', '--json'], - { stdio: 'inherit' } + execaOptions ) t.after(async () => { - await execa('flyctl', ['apps', 'destroy', appName, '-y'], { - stdio: 'inherit', - }) + await execa('flyctl', ['apps', 'destroy', appName, '-y'], execaOptions) }) await execa( 'flyctl', ['secrets', 'set', 'SERVER_BEARER_TOKEN=ignored', '--app', appName], - { stdio: 'inherit' } + execaOptions ) await execa( 'flyctl', ['deploy', '--app', appName, '-e', 'SERVER_NAME=test server'], - { - stdio: 'inherit', - } + execaOptions ) return { type: 'remote', serverBaseUrl: `https://${appName}.fly.dev/` } } @@ -444,7 +451,6 @@ async function createRemoteTestServer(t) { * @returns {Promise} */ async function createLocalTestServer(t) { - const comapeoCoreUrl = new URL('..', import.meta.url) const projectMigrationsFolder = new URL('./drizzle/project', comapeoCoreUrl) .pathname const clientMigrationsFolder = new URL('./drizzle/client', comapeoCoreUrl)