UNPKG

@signalapp/mock-server

Version:
244 lines (243 loc) 10.9 kB
"use strict"; // Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.createHandler = void 0; const assert_1 = __importDefault(require("assert")); const buffer_1 = require("buffer"); const debug_1 = __importDefault(require("debug")); const uuid_1 = require("uuid"); const micro_1 = require("micro"); const microrouter_1 = require("microrouter"); const libsignal_client_1 = require("@signalapp/libsignal-client"); const SealedSenderMultiRecipientMessage_1 = __importDefault(require("@signalapp/libsignal-client/dist/SealedSenderMultiRecipientMessage")); const compiled_1 = require("../../protos/compiled"); const common_1 = require("./common"); const debug = (0, debug_1.default)('mock:grpc'); const ALL_METHODS = [microrouter_1.get, microrouter_1.post, microrouter_1.put, microrouter_1.patch, microrouter_1.del, microrouter_1.head, microrouter_1.options]; function toServiceIdentifier(string) { const object = libsignal_client_1.ServiceId.parseFromServiceIdString(string); if (object instanceof libsignal_client_1.Pni) { return { identityType: compiled_1.org.signal.chat.common.IdentityType.IDENTITY_TYPE_PNI, uuid: object.getRawUuidBytes(), }; } if (object instanceof libsignal_client_1.Aci) { return { identityType: compiled_1.org.signal.chat.common.IdentityType.IDENTITY_TYPE_ACI, uuid: object.getRawUuidBytes(), }; } throw new Error(`Invalid service id: ${string}`); } // gRPC status codes used by the mock. const GRPC_STATUS_OK = 0; const GRPC_STATUS_UNKNOWN = 2; // A gRPC response over HTTP/2 always uses HTTP status 200; the actual gRPC // status code is carried in the trailing HEADERS frame (`grpc-status`). `micro` // has no notion of trailers, so we emit them via the HTTP/2 compat API. (Driving // the raw stream directly conflicts with the Http2ServerResponse that `micro` // holds and throws ERR_HTTP2_TRAILERS_ALREADY_SENT.) function sendGrpcResponse(res, body, status, message) { const trailers = { 'grpc-status': String(status), }; if (message !== undefined) { // Per the gRPC spec, `grpc-message` is percent-encoded. trailers['grpc-message'] = encodeURIComponent(message); } res.writeHead(200, { 'content-type': 'application/grpc' }); res.addTrailers(trailers); res.end(body); } function grpcRoute(endpoint, handler) { const definition = compiled_1.$services[endpoint]; // TODO(indutny): enforce on type level if (definition.isRequestStream || definition.isResponseStream) { throw new Error(`Request/response stream is not supported`); } return (0, microrouter_1.post)(`/${endpoint}`, async (req, res) => { try { const raw = await (0, micro_1.buffer)(req); (0, assert_1.default)(buffer_1.Buffer.isBuffer(raw)); (0, assert_1.default)(raw.buffer instanceof ArrayBuffer); if (raw.length < 5) { throw new Error('gRPC request is too short'); } if (raw[0] !== 0) { throw new Error('Unsupported request compression'); } const len = raw.readUint32BE(1); if (raw.length !== 5 + len) { throw new Error('Invalid gRPC request size'); } const request = definition.Request.decode(raw.subarray(5, 5 + len)); const response = await handler(request); const data = definition.Response.encode(response); const header = buffer_1.Buffer.alloc(5); header.writeUint32BE(data.length, 1); sendGrpcResponse(res, buffer_1.Buffer.concat([header, data]), GRPC_STATUS_OK); } catch (error) { debug('gRPC handler error for %s', endpoint, error); sendGrpcResponse(res, buffer_1.Buffer.alloc(0), GRPC_STATUS_UNKNOWN, error instanceof Error ? error.message : String(error)); } }); } const createHandler = (server) => { // gRPC async function onMultiRecipientMessage(request) { const { message: givenMessage, // TODO(indutny): check it at all? // groupSendToken, } = request; if (givenMessage == null) { throw new Error('Missing message'); } const { timestamp, payload } = givenMessage; const message = new SealedSenderMultiRecipientMessage_1.default(buffer_1.Buffer.from(payload)); const listByServiceId = new Map(); const recipients = message.recipientsByServiceIdString(); for (const [serviceId, recipient] of Object.entries(recipients)) { let list = listByServiceId.get(serviceId); if (!list) { list = []; listByServiceId.set(serviceId, list); } for (const [i, deviceId] of recipient.deviceIds.entries()) { const registrationId = recipient.registrationIds.at(i); list.push({ type: compiled_1.signalservice.Envelope.Type.UNIDENTIFIED_SENDER, destinationDeviceId: deviceId, destinationRegistrationId: registrationId, content: buffer_1.Buffer.from(message.messageForRecipient(recipient)).toString('base64'), }); } } const results = await Promise.all(Array.from(listByServiceId.entries()).map(async ([serviceId, messages]) => { return { uuid: serviceId, prepared: await server.prepareMultiDeviceMessage(undefined, serviceId, messages, timestamp), }; })); const mismatchedDevices = results.filter(({ prepared }) => { return prepared.status === 'incomplete' || prepared.status === 'stale'; }); if (mismatchedDevices.length > 0) { return { response: { mismatchedDevices: { mismatchedDevices: mismatchedDevices.map(({ uuid, prepared }) => { if (prepared.status === 'incomplete') { return { serviceIdentifier: toServiceIdentifier(uuid), missingDevices: prepared.missingDevices.slice(), extraDevices: prepared.extraDevices.slice(), staleDevices: null, }; } assert_1.default.ok(prepared.status === 'stale'); return { serviceIdentifier: toServiceIdentifier(uuid), missingDevices: null, extraDevices: null, staleDevices: prepared.staleDevices.slice(), }; }), }, }, }; } const uuids404 = results .filter(({ prepared }) => prepared.status === 'unknown') .map(({ uuid }) => uuid); const ok = results.filter(({ prepared }) => prepared.status === 'ok'); await Promise.all(ok.map(({ prepared }) => { assert_1.default.ok(prepared.status === 'ok'); return server.handlePreparedMultiDeviceMessage(undefined, prepared.targetServiceId, prepared.result); })); return { response: { success: { unresolvedRecipients: uuids404.map(toServiceIdentifier), }, }, }; } const onSendMultiRecipientMessage = grpcRoute('org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientMessage', onMultiRecipientMessage); const onSendMultiRecipientStory = grpcRoute('org.signal.chat.messages.MessagesAnonymous/SendMultiRecipientStory', onMultiRecipientMessage); const onLookupUsernameHash = grpcRoute('org.signal.chat.account.AccountsAnonymous/LookupUsernameHash', async ({ usernameHash }) => { const uuid = await server.lookupByUsernameHash(buffer_1.Buffer.from(usernameHash)); if (!uuid) { return { response: { notFound: {}, }, }; } return { response: { serviceIdentifier: toServiceIdentifier(uuid), }, }; }); const onLookupUsernameLink = grpcRoute('org.signal.chat.account.AccountsAnonymous/LookupUsernameLink', async ({ usernameLinkHandle }) => { const usernameCiphertext = await server.lookupByUsernameLink((0, uuid_1.stringify)(usernameLinkHandle)); if (!usernameCiphertext) { return { response: { notFound: {}, }, }; } return { response: { usernameCiphertext, }, }; }); const onGetUploadForm = grpcRoute('org.signal.chat.attachments.Attachments/GetUploadForm', async () => { const { cdn, key, headers, signedUploadLocation } = await server.getAttachmentUploadForm('attachments', (0, uuid_1.v4)()); return { outcome: { uploadForm: { cdn, key, headers: new Map(Object.entries(headers)), signedUploadLocation, }, }, }; }); const notFoundAfterAuth = async (req, res) => { const device = await (0, common_1.auth)(server, req, res); if (!device) { return; } debug('Unsupported request %s %s', req.method, req.url); return (0, micro_1.send)(res, 404, { error: 'Not supported yet' }); }; const routes = (0, microrouter_1.router)( // gRPC onSendMultiRecipientMessage, onSendMultiRecipientStory, onLookupUsernameHash, onLookupUsernameLink, onGetUploadForm, ...ALL_METHODS.map((method) => method('/*', notFoundAfterAuth))); return (req, res) => { debug('got request %s %s', req.method, req.url); try { res.once('finish', () => { debug('response %s %s', req.method, req.url, res.statusCode); }); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return routes(req, res); } catch (error) { (0, assert_1.default)(error instanceof Error); debug('request failure %s %s', req.method, req.url, error.stack); return (0, micro_1.send)(res, 500, error.message); } }; }; exports.createHandler = createHandler;