mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
chore(deps): upgrade all dependencies, toolchains and images (#2825)
This commit is contained in:
+8
-12
@@ -1,11 +1,11 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
ARG BUILD_VERSION
|
||||
|
||||
FROM node:24-bookworm-slim AS base
|
||||
FROM node:26-trixie-slim AS base
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
RUN npm install -g [email protected]
|
||||
|
||||
FROM base AS deploy
|
||||
|
||||
@@ -23,9 +23,9 @@ COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter fluxer_api run build
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allowUnusedPatches=true /out
|
||||
RUN pnpm deploy --legacy --filter=fluxer_api --prod --config.allow-unused-patches=true /out
|
||||
|
||||
FROM node:24-bookworm-slim
|
||||
FROM node:26-trixie-slim
|
||||
|
||||
ARG BUILD_VERSION
|
||||
ARG SOURCE_SHA
|
||||
@@ -45,32 +45,28 @@ LABEL app.fluxer.build-version="${BUILD_VERSION}"
|
||||
|
||||
WORKDIR /usr/src/app/fluxer_api
|
||||
|
||||
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' > /etc/apt/sources.list.d/backports.list && \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
ffmpeg \
|
||||
libimage-exiftool-perl \
|
||||
libwebp7 \
|
||||
libwebpmux3 \
|
||||
libheif1 \
|
||||
libvips42 && \
|
||||
apt-get install -y --no-install-recommends -t bookworm-backports \
|
||||
libheif-plugin-libde265 \
|
||||
libheif-plugin-dav1d && \
|
||||
libheif-plugin-dav1d \
|
||||
libvips42t64 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN corepack enable && corepack prepare [email protected] --activate
|
||||
RUN npm install -g [email protected]
|
||||
|
||||
COPY --from=deploy /out ./
|
||||
COPY --from=deploy /usr/src/app/fluxer_api/dist ./dist
|
||||
COPY --from=deploy /usr/src/app/tsconfigs /usr/src/app/tsconfigs
|
||||
|
||||
RUN rm -rf pkgs && \
|
||||
mkdir -p /usr/src/app/.cache/corepack && \
|
||||
chown -R 65532:65532 /usr/src/app
|
||||
|
||||
ENV HOME=/usr/src/app
|
||||
ENV COREPACK_HOME=/usr/src/app/.cache/corepack
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_OPTIONS="--enable-source-maps"
|
||||
ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
@@ -5,19 +5,19 @@
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "tsx watch --clear-screen=false src/AppEntrypoint.ts",
|
||||
"start": "tsx src/AppEntrypoint.ts",
|
||||
"start:worker": "tsx src/WorkerEntrypoint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "catalog:",
|
||||
"@atproto/jwk-jose": "catalog:",
|
||||
"@atproto/oauth-client-node": "catalog:",
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
"@aws-sdk/lib-storage": "catalog:",
|
||||
"@aws-sdk/s3-request-presigner": "catalog:",
|
||||
"@bluesky-social/jwk-jose": "catalog:",
|
||||
"@bluesky-social/oauth-client-node": "catalog:",
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@bufbuild/protobuf": "^2.15.0",
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
"@fluxer/config": "workspace:*",
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -34,6 +34,8 @@
|
||||
"@hono/node-server": "catalog:",
|
||||
"@messageformat/core": "catalog:",
|
||||
"@messageformat/parser": "catalog:",
|
||||
"@nats-io/jetstream": "catalog:",
|
||||
"@nats-io/transport-node": "catalog:",
|
||||
"@pkgs/cache": "workspace:*",
|
||||
"@pkgs/captcha": "workspace:*",
|
||||
"@pkgs/cassandra": "workspace:*",
|
||||
@@ -71,7 +73,6 @@
|
||||
"lodash": "catalog:",
|
||||
"maxmind": "catalog:",
|
||||
"mime": "catalog:",
|
||||
"nats": "catalog:",
|
||||
"nodemailer": "catalog:",
|
||||
"pg": "catalog:",
|
||||
"pino": "catalog:",
|
||||
@@ -88,10 +89,10 @@
|
||||
"devDependencies": {
|
||||
"@types/archiver": "catalog:",
|
||||
"@types/lodash": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"esbuild": "catalog:",
|
||||
"msw": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
"packageManager": "pnpm@12.4.2"
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -9,14 +9,14 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pkgs/kv_client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
@@ -15,6 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"cassandra-driver": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@elastic/elasticsearch": "catalog:",
|
||||
@@ -15,6 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/i18n": "workspace:*",
|
||||
@@ -19,8 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/nodemailer": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {createLogger} from '@fluxer/logger/src/Logger';
|
||||
import type {EmailMessage, IEmailProvider} from '@pkgs/email/src/EmailProviderTypes';
|
||||
import nodemailer from 'nodemailer';
|
||||
import nodemailer, {type Transporter} from 'nodemailer';
|
||||
|
||||
const logger = createLogger('@pkgs/email/src/SmtpEmailProvider');
|
||||
|
||||
@@ -18,7 +18,7 @@ interface SmtpEmailConfig {
|
||||
}
|
||||
|
||||
export class SmtpEmailProvider implements IEmailProvider {
|
||||
private readonly transporter: nodemailer.Transporter;
|
||||
private readonly transporter: Transporter;
|
||||
|
||||
constructor(config: SmtpEmailConfig) {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "catalog:",
|
||||
@@ -21,6 +21,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ interface GeoipRuntimePathOptions {
|
||||
}
|
||||
|
||||
export function parseGeoipSourceConfig(rawValue: string | undefined): GeoipSourceConfig {
|
||||
if (!rawValue || !rawValue.startsWith('s3://')) {
|
||||
if (!rawValue?.startsWith('s3://')) {
|
||||
return createGeoipFilesystemSourceConfig(rawValue);
|
||||
}
|
||||
return parseGeoipS3SourceConfig(rawValue);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -17,8 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"undici-types": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@pkgs/http_client/src/HttpClientRequestInternals';
|
||||
import type {HttpClientMetrics, HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
|
||||
import type {
|
||||
FetchDispatcher,
|
||||
HttpClient,
|
||||
HttpClientFactoryOptions,
|
||||
HttpMethod,
|
||||
@@ -26,7 +27,6 @@ import type {
|
||||
StreamResponse,
|
||||
} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
const DEFAULT_SERVICE_NAME = 'unknown';
|
||||
|
||||
@@ -79,7 +79,7 @@ function createFetchInit(
|
||||
headers: Headers,
|
||||
body: string | undefined,
|
||||
signal: AbortSignal,
|
||||
dispatcher: Dispatcher | undefined,
|
||||
dispatcher: FetchDispatcher | undefined,
|
||||
): RequestInit {
|
||||
return {
|
||||
method,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {HttpClientTelemetry} from '@pkgs/http_client/src/HttpClientTelemetryTypes';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
|
||||
export type ResponseStream = ReadableStream<Uint8Array> | null;
|
||||
export type FetchDispatcher = NonNullable<RequestInit['dispatcher']>;
|
||||
export type HttpMethod = 'GET' | 'POST' | 'HEAD' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS';
|
||||
export type RequestUrlValidationPhase = 'initial' | 'redirect';
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface RequestUrlValidationContext {
|
||||
}
|
||||
|
||||
export interface RequestUrlPolicy {
|
||||
readonly dispatcher?: Dispatcher;
|
||||
readonly dispatcher?: FetchDispatcher;
|
||||
validate(url: URL, context: RequestUrlValidationContext): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import dns from 'node:dns';
|
||||
import type {LookupFunction} from 'node:net';
|
||||
import {BlockList, isIP} from 'node:net';
|
||||
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
|
||||
import type {RequestUrlPolicy, RequestUrlValidationContext} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import type {
|
||||
FetchDispatcher,
|
||||
RequestUrlPolicy,
|
||||
RequestUrlValidationContext,
|
||||
} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {HttpError} from '@pkgs/http_client/src/HttpError';
|
||||
import {Agent} from 'undici';
|
||||
import type {Dispatcher} from 'undici-types';
|
||||
import {Agent, Dispatcher1Wrapper} from 'undici';
|
||||
|
||||
const DEFAULT_DNS_CACHE_TTL_MS = 60000;
|
||||
const DNS_CACHE_MAX_ENTRIES = 10000;
|
||||
@@ -223,7 +226,7 @@ async function defaultLookupHost(hostname: string): Promise<Array<string>> {
|
||||
return addresses.map((addressEntry) => addressEntry.address);
|
||||
}
|
||||
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
|
||||
function createBlocklistDispatcher(allowPrivateAddresses: boolean): FetchDispatcher {
|
||||
const lookup: LookupFunction = (hostname, options, callback) => {
|
||||
dns.lookup(hostname, {...options, all: true, order: options.order ?? 'verbatim'}, (error, addresses) => {
|
||||
if (error) {
|
||||
@@ -246,15 +249,18 @@ function createBlocklistDispatcher(allowPrivateAddresses: boolean): Dispatcher {
|
||||
callback(null, primary.address, primary.family);
|
||||
});
|
||||
};
|
||||
return new Agent({
|
||||
connect: {
|
||||
lookup,
|
||||
},
|
||||
}) as unknown as Dispatcher;
|
||||
return new Dispatcher1Wrapper(
|
||||
new Agent({
|
||||
allowH2: false,
|
||||
connect: {
|
||||
lookup,
|
||||
},
|
||||
}),
|
||||
) as unknown as FetchDispatcher;
|
||||
}
|
||||
|
||||
interface PublicInternetRequestUrlPolicy extends RequestUrlPolicy {
|
||||
readonly dispatcher: Dispatcher;
|
||||
readonly dispatcher: FetchDispatcher;
|
||||
}
|
||||
|
||||
export function createPublicInternetRequestUrlPolicy(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,7 @@ export class KVClient implements IKVProvider {
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
protocol: 2,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
});
|
||||
}
|
||||
@@ -311,6 +312,7 @@ export class KVClient implements IKVProvider {
|
||||
connectTimeout: clusterConfig.timeoutMs,
|
||||
commandTimeout: clusterConfig.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
protocol: 2,
|
||||
},
|
||||
scaleReads: 'master',
|
||||
...(hasNatMap ? {natMap} : {}),
|
||||
|
||||
@@ -72,6 +72,7 @@ export class KVSubscription implements IKVSubscription {
|
||||
connectTimeout: this.timeoutMs,
|
||||
commandTimeout: this.timeoutMs,
|
||||
maxRetriesPerRequest: 1,
|
||||
protocol: 2,
|
||||
retryStrategy: createRetryStrategy(),
|
||||
};
|
||||
const connection = this.mode === 'cluster' ? resolveKVClusterConnection(this.url, this.clusterNodes) : null;
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"mime": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "catalog:"
|
||||
"@nats-io/jetstream": "catalog:",
|
||||
"@nats-io/transport-node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {NatsConnection} from 'nats';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
|
||||
export interface INatsConnectionManager {
|
||||
connect(): Promise<void>;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type JetStreamClient, type JetStreamManager, jetstream, jetstreamManager} from '@nats-io/jetstream';
|
||||
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
||||
import type {JetStreamClient, JetStreamManager} from 'nats';
|
||||
|
||||
export class JetStreamConnectionManager extends NatsConnectionManager {
|
||||
getJetStreamClient(): JetStreamClient {
|
||||
return this.getConnection().jetstream();
|
||||
return jetstream(this.getConnection());
|
||||
}
|
||||
|
||||
async getJetStreamManager(): Promise<JetStreamManager> {
|
||||
return this.getConnection().jetstreamManager();
|
||||
return jetstreamManager(this.getConnection());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {connect, DrainingConnectionError, type NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import type {NatsConnectionOptions} from '@pkgs/nats/src/NatsConnectionOptions';
|
||||
import {connect, ErrorCode, type NatsConnection, NatsError} from 'nats';
|
||||
|
||||
const DEFAULT_MAX_RECONNECT_ATTEMPTS = -1;
|
||||
const DEFAULT_RECONNECT_TIME_WAIT_MS = 500;
|
||||
@@ -46,7 +46,7 @@ export class NatsConnectionManager implements INatsConnectionManager {
|
||||
});
|
||||
await this.connectPromise;
|
||||
if (generation !== this.drainGeneration) {
|
||||
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
|
||||
throw new DrainingConnectionError();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export class NatsConnectionManager implements INatsConnectionManager {
|
||||
|
||||
private assertNotDraining(): void {
|
||||
if (this.drainPromise !== null) {
|
||||
throw NatsError.errorForCode(ErrorCode.ConnectionDraining);
|
||||
throw new DrainingConnectionError();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"pg": "catalog:"
|
||||
@@ -15,6 +15,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/pg": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pkgs/kv_client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:ts7",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('TwilioSmsProvider', () => {
|
||||
const fetchStub: typeof fetch = async (_input, init) => {
|
||||
capturedRequest = {
|
||||
url: String(_input),
|
||||
authHeader: (init?.headers as Record<string, string>).Authorization,
|
||||
authHeader: (init?.headers as Record<string, string>)?.Authorization,
|
||||
body: init?.body as string,
|
||||
};
|
||||
return new Response(JSON.stringify({success: true}), {status: 200});
|
||||
@@ -65,7 +65,7 @@ describe('TwilioSmsProvider', () => {
|
||||
const fetchStub: typeof fetch = async (_input, init) => {
|
||||
capturedRequest = {
|
||||
url: String(_input),
|
||||
authHeader: (init?.headers as Record<string, string>).Authorization,
|
||||
authHeader: (init?.headers as Record<string, string>)?.Authorization,
|
||||
body: init?.body as string,
|
||||
};
|
||||
return new Response(JSON.stringify({channel: 'auto'}), {status: 200});
|
||||
@@ -232,7 +232,7 @@ describe('TwilioSmsProvider', () => {
|
||||
capturedRequest = {
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
authHeader: (init?.headers as Record<string, string>).Authorization,
|
||||
authHeader: (init?.headers as Record<string, string>)?.Authorization,
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/logger": "workspace:*",
|
||||
@@ -15,6 +15,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
@@ -60,6 +60,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"typescript": "catalog:ts7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export class AdminAuditService {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
if (!auditLogSearchService?.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
@@ -129,7 +129,7 @@ export class AdminAuditService {
|
||||
}): Promise<AuditLogsListResponse> {
|
||||
const auditLogSearchService = getAuditLogSearchService();
|
||||
const targetIdBigInt = data.target_id ? BigInt(data.target_id) : undefined;
|
||||
if (!auditLogSearchService || !auditLogSearchService.isAvailable()) {
|
||||
if (!auditLogSearchService?.isAvailable()) {
|
||||
return this.listAuditLogsFromDatabase({
|
||||
adminUserId: data.admin_user_id,
|
||||
targetType: data.target_type,
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function generateWebAuthnRegistrationOptions(ctx: ApiContext, userI
|
||||
userName: user.username!,
|
||||
userDisplayName: user.username!,
|
||||
attestationType: 'none',
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
excludeCredentials: existingCredentials.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: cred.transports
|
||||
@@ -174,6 +175,7 @@ export async function verifyWebAuthnRegistration(
|
||||
expectedOrigin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false,
|
||||
supportedAlgorithmIDs: [-8, -7, -257],
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error({error, userId, expectedChallenge, rpID, expectedOrigin}, 'WebAuthn verification failed');
|
||||
|
||||
@@ -135,19 +135,14 @@ describe('reject reasons reaching the caller through the real gate', () => {
|
||||
REJECT_REASON_CODES.invalid_number,
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'landline',
|
||||
'tollFree',
|
||||
'premium',
|
||||
'sharedCost',
|
||||
'uan',
|
||||
'voicemail',
|
||||
'pager',
|
||||
] as const)('line_type_hard_rejected for %s says it is not a mobile', async (lineType) => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
|
||||
REJECT_REASON_CODES.line_type_hard_rejected,
|
||||
);
|
||||
});
|
||||
it.each(['landline', 'tollFree', 'premium', 'sharedCost', 'uan', 'voicemail', 'pager'] as const)(
|
||||
'line_type_hard_rejected for %s says it is not a mobile',
|
||||
async (lineType) => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({lineType})})).toBe(
|
||||
REJECT_REASON_CODES.line_type_hard_rejected,
|
||||
);
|
||||
},
|
||||
);
|
||||
it('sms_pumping_risk_high routes to human review', async () => {
|
||||
expect(await codeFromVerify(MOBILE_US, {lookupResult: lookup({smsPumpingRiskScore: 100})})).toBe(
|
||||
REJECT_REASON_CODES.sms_pumping_risk_high,
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
import type {BlueskyOAuthConfig, BlueskyOAuthKeyConfig} from '@app/api/config/APIConfig';
|
||||
import {ConnectionCredentialRepository} from '@app/api/connection/ConnectionCredentialRepository';
|
||||
import {Agent} from '@atproto/api';
|
||||
import {JoseKey} from '@bluesky-social/jwk-jose';
|
||||
import {JoseKey} from '@atproto/jwk-jose';
|
||||
import {
|
||||
FetchError,
|
||||
NodeOAuthClient,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type OAuthClientMetadataInput,
|
||||
OAuthResponseError,
|
||||
requestLocalLock,
|
||||
} from '@bluesky-social/oauth-client-node';
|
||||
} from '@atproto/oauth-client-node';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
|
||||
interface BlueskyClientConfiguration {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type NodeSavedSessionStore,
|
||||
type NodeSavedState,
|
||||
type NodeSavedStateStore,
|
||||
} from '@bluesky-social/oauth-client-node';
|
||||
} from '@atproto/oauth-client-node';
|
||||
import {SnowflakeType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {z} from 'zod';
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MessageInteractionRepository extends IMessageInteractionRepository
|
||||
|
||||
async removeChannelPin(channelId: ChannelID, messageId: MessageID): Promise<void> {
|
||||
const message = await this.messageRepository.getMessage(channelId, messageId);
|
||||
if (!message || !message.pinnedTimestamp) {
|
||||
if (!message?.pinnedTimestamp) {
|
||||
return;
|
||||
}
|
||||
await deleteOneOrMany(
|
||||
|
||||
@@ -617,7 +617,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason: string | null;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
@@ -690,7 +690,7 @@ export class ChannelOperationsService {
|
||||
auditLogReason: string | null;
|
||||
}): Promise<void> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(params.channelId);
|
||||
if (!channel || !channel.guildId) throw new UnknownChannelError();
|
||||
if (!channel?.guildId) throw new UnknownChannelError();
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
|
||||
@@ -115,7 +115,7 @@ export class MessageDeleteService {
|
||||
}): Promise<void> {
|
||||
const channelId = webhook.channelId!;
|
||||
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel || !channel.guildId) {
|
||||
if (!channel?.guildId) {
|
||||
throw new CannotExecuteOnDmError();
|
||||
}
|
||||
const message = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
|
||||
@@ -312,7 +312,7 @@ function collectEmbedReferencedAttachmentCdnKeys(message: Message, ownKeys: Read
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
if (!url || !url.startsWith(mediaPrefix)) {
|
||||
if (!url?.startsWith(mediaPrefix)) {
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
|
||||
@@ -94,7 +94,7 @@ export class MessageMentionService {
|
||||
referencedMessage?.authorId &&
|
||||
referencedMessage.authorId !== message.authorId &&
|
||||
!isDMChannel &&
|
||||
(!allowedMentions || allowedMentions.replied_user !== false);
|
||||
allowedMentions?.replied_user !== false;
|
||||
if (shouldAddReferencedUser) {
|
||||
userMentions.add(referencedMessage!.authorId!);
|
||||
}
|
||||
|
||||
@@ -569,7 +569,7 @@ export class MessagePersistenceService {
|
||||
}
|
||||
const updatedSnapshots = message.messageSnapshots.map((snapshot, index) => {
|
||||
const edit = snapshotEdits[index];
|
||||
if (!edit || !edit.attachments || edit.attachments.length === 0) {
|
||||
if (!edit?.attachments || edit.attachments.length === 0) {
|
||||
return snapshot.toMessageSnapshot();
|
||||
}
|
||||
const snapshotRow = snapshot.toMessageSnapshot();
|
||||
|
||||
@@ -80,7 +80,7 @@ export class MessageProcessingService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<void> {
|
||||
if (channel.guildId || channel.type !== ChannelTypes.DM) return;
|
||||
if (!channel.recipientIds || channel.recipientIds.size !== 2) return;
|
||||
if (channel.recipientIds?.size !== 2) return;
|
||||
const recipientIds = Array.from(channel.recipientIds);
|
||||
const openStates = await this.batchCheckDmChannelOpen(recipientIds, channelId);
|
||||
const closedRecipients = openStates.filter((state) => !state.isOpen);
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {Message} from '@app/api/models/Message';
|
||||
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import type {NatsConnection} from 'nats';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -11,7 +11,9 @@ import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const MESSAGE_RESPONSE_SERVICE_SUBJECT = 'svc.messages';
|
||||
const MESSAGE_RESPONSE_SERVICE_TIMEOUT_MS = 6000;
|
||||
@@ -79,8 +81,6 @@ function isMessageServiceResponse(value: unknown): value is MessageServiceRespon
|
||||
}
|
||||
|
||||
export class MessageResponseDataService {
|
||||
private readonly codec = StringCodec();
|
||||
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async listMessages(params: {
|
||||
@@ -307,10 +307,10 @@ export class MessageResponseDataService {
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const response = await connection.request(
|
||||
MESSAGE_RESPONSE_SERVICE_SUBJECT,
|
||||
this.codec.encode(JSON.stringify(payload)),
|
||||
textEncoder.encode(JSON.stringify(payload)),
|
||||
{timeout: MESSAGE_RESPONSE_SERVICE_TIMEOUT_MS},
|
||||
);
|
||||
const decoded = this.codec.decode(response.data);
|
||||
const decoded = textDecoder.decode(response.data);
|
||||
const parsed = parseJsonWithGuard(decoded, isMessageServiceResponse);
|
||||
if (!parsed) {
|
||||
throwForSvcErrorReply('message-response-service', parseJsonRecord(decoded));
|
||||
|
||||
@@ -1059,7 +1059,7 @@ export class MessageSendService {
|
||||
}): Promise<Message> {
|
||||
const channelId = webhook.channelId!;
|
||||
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel || !channel.guildId) {
|
||||
if (!channel?.guildId) {
|
||||
throw new CannotExecuteOnDmError();
|
||||
}
|
||||
const guild = await this.deps.gatewayService.getGuildData({
|
||||
@@ -1245,7 +1245,7 @@ export class MessageSendService {
|
||||
}): Promise<Message> {
|
||||
const channelId = webhook.channelId!;
|
||||
const channel = await this.deps.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel || !channel.guildId) {
|
||||
if (!channel?.guildId) {
|
||||
throw new CannotExecuteOnDmError();
|
||||
}
|
||||
const existingMessage = await this.deps.channelRepository.messages.getMessage(channelId, messageId);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {executeConditional, fetchMany} from '@app/api/database/CassandraQueryExe
|
||||
import {type ConditionalWriteEntry, Db, validateTtlSeconds} from '@app/api/database/CassandraTypes';
|
||||
import {USER_CONNECTION_CREDENTIAL_TYPE, type UserConnectionStorageRow} from '@app/api/database/types/ConnectionTypes';
|
||||
import {UserConnections} from '@app/api/Tables';
|
||||
import {isAtprotoDid} from '@bluesky-social/oauth-client-node';
|
||||
import {isAtprotoDid} from '@atproto/oauth-client-node';
|
||||
import {z} from 'zod';
|
||||
|
||||
type CredentialWrite = ConditionalWriteEntry<UserConnectionStorageRow, 'user_id' | 'connection_type' | 'connection_id'>;
|
||||
|
||||
@@ -377,7 +377,7 @@ export class NcmecSubmissionService {
|
||||
|
||||
async finalizeAttachmentReport(attachmentId: AttachmentID, requeueCount = 0): Promise<void> {
|
||||
const submission = await this.deps.ncmecRepository.getAttachmentSubmission(attachmentId);
|
||||
if (!submission || submission.status !== 'submitted' || submission.content_deleted_at) {
|
||||
if (submission?.status !== 'submitted' || submission.content_deleted_at) {
|
||||
return;
|
||||
}
|
||||
if (submission.user_id === null) {
|
||||
@@ -439,7 +439,7 @@ export class NcmecSubmissionService {
|
||||
submissionUserId,
|
||||
refreshedWorkflow.archive_id,
|
||||
);
|
||||
if (!archive || !archive.completed_at) {
|
||||
if (!archive?.completed_at) {
|
||||
await this.requeueFinalizer(attachmentId, requeueCount + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -113,8 +113,7 @@ export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDes
|
||||
for (const rawAsset of value.assets) {
|
||||
const asset = parseDesktopReleaseAsset(rawAsset);
|
||||
if (
|
||||
!asset ||
|
||||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
|
||||
!asset?.storage_key.startsWith(expectedStoragePrefix) ||
|
||||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
|
||||
storageKeys.has(asset.storage_key)
|
||||
) {
|
||||
|
||||
@@ -228,19 +228,22 @@ describe('desktop release readiness', () => {
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
])(
|
||||
'offers the manifest version when reading its release %s fails with a storage error',
|
||||
async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
},
|
||||
);
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from '@fluxer/schema/src/domains/gif/GifSchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const GIF_SERVICE_SUBJECT = process.env.FLUXER_GIF_SERVICE_SUBJECT || 'svc.gifs';
|
||||
const DEFAULT_GIF_SERVICE_TIMEOUT_MS = 12_000;
|
||||
@@ -137,7 +139,6 @@ function readResolved(value: unknown): GifResponse | null {
|
||||
|
||||
class NatsGifProvider implements IGifProvider {
|
||||
readonly meta = GIF_PROVIDER_META;
|
||||
private readonly codec = StringCodec();
|
||||
|
||||
constructor(
|
||||
private readonly connectionManager: INatsConnectionManager,
|
||||
@@ -256,8 +257,8 @@ class NatsGifProvider implements IGifProvider {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const response = await connection.request(this.subject, this.codec.encode(JSON.stringify(payload)), {timeout});
|
||||
const decoded = this.codec.decode(response.data);
|
||||
const response = await connection.request(this.subject, textEncoder.encode(JSON.stringify(payload)), {timeout});
|
||||
const decoded = textDecoder.decode(response.data);
|
||||
const parsed = parseJsonUnknown(decoded);
|
||||
const failedMessage = readFailedMessage(parsed);
|
||||
if (failedMessage) {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function GuildMemberSearchController(app: HonoApp) {
|
||||
return ctx.json(createEmptySearchResponse(guildIdString, false));
|
||||
}
|
||||
const searchService = getGuildMemberSearchService();
|
||||
if (!searchService || !searchService.isAvailable()) {
|
||||
if (!searchService?.isAvailable()) {
|
||||
return ctx.json(createEmptySearchResponse(guildIdString, false));
|
||||
}
|
||||
const needsIndexing = guildMembersNeedReindexing(guild.membersIndexedAt);
|
||||
|
||||
@@ -157,7 +157,7 @@ export class EmojiService {
|
||||
const sourceEmoji = await this.guildRepository.getEmojiById(sourceEmojiId);
|
||||
if (!sourceEmoji) throw new UnknownGuildEmojiError();
|
||||
const sourceGuild = await this.guildRepository.findUnique(sourceEmoji.guildId);
|
||||
if (!sourceGuild || !sourceGuild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED)) {
|
||||
if (!sourceGuild?.features.has(GuildFeatures.CLONE_EMOJI_ENABLED)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
|
||||
@@ -171,7 +171,7 @@ export class StickerService {
|
||||
const sourceSticker = await this.guildRepository.getStickerById(sourceStickerId);
|
||||
if (!sourceSticker) throw new UnknownGuildStickerError();
|
||||
const sourceGuild = await this.guildRepository.findUnique(sourceSticker.guildId);
|
||||
if (!sourceGuild || !sourceGuild.features.has(GuildFeatures.CLONE_STICKER_ENABLED)) {
|
||||
if (!sourceGuild?.features.has(GuildFeatures.CLONE_STICKER_ENABLED)) {
|
||||
throw new MissingAccessError();
|
||||
}
|
||||
const guildData = await this.contentHelpers.getGuildData({userId: user.id, guildId});
|
||||
|
||||
@@ -343,21 +343,20 @@ describe('collectGuildAuditLogUserIds', () => {
|
||||
expect(userIdStrings(log)).toEqual([ACTOR_ID]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
AuditLogActionType.WEBHOOK_DELETE,
|
||||
AuditLogActionType.EMOJI_DELETE,
|
||||
AuditLogActionType.STICKER_DELETE,
|
||||
])('includes the creator of deleted content for action %i', (actionType) => {
|
||||
const log = makeLog({
|
||||
actionType,
|
||||
targetId: TARGET_ID,
|
||||
changes: [
|
||||
{key: 'name', old_value: 'blob'},
|
||||
{key: 'creator_id', old_value: OTHER_ID},
|
||||
],
|
||||
});
|
||||
expect(userIdStrings(log)).toEqual([ACTOR_ID, OTHER_ID]);
|
||||
});
|
||||
it.each([AuditLogActionType.WEBHOOK_DELETE, AuditLogActionType.EMOJI_DELETE, AuditLogActionType.STICKER_DELETE])(
|
||||
'includes the creator of deleted content for action %i',
|
||||
(actionType) => {
|
||||
const log = makeLog({
|
||||
actionType,
|
||||
targetId: TARGET_ID,
|
||||
changes: [
|
||||
{key: 'name', old_value: 'blob'},
|
||||
{key: 'creator_id', old_value: OTHER_ID},
|
||||
],
|
||||
});
|
||||
expect(userIdStrings(log)).toEqual([ACTOR_ID, OTHER_ID]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(OVERWRITE_ACTIONS)('includes member overwrite targets for action %i', (actionType) => {
|
||||
const memberLog = makeLog({actionType, targetId: TARGET_ID, options: {type: '1', channel_id: OTHER_ID}});
|
||||
|
||||
@@ -99,7 +99,7 @@ function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: st
|
||||
const httpUrl = toHttpUrl(endpoint);
|
||||
const parsed = new URL(httpUrl);
|
||||
const pathPrefix = parsed.pathname.replace(/\/+$/, '');
|
||||
const client = new RoomServiceClient(parsed.origin, apiKey, apiSecret);
|
||||
const client = new RoomServiceClient(parsed.origin, apiKey, apiSecret, {requestTimeout: 60});
|
||||
if (pathPrefix) {
|
||||
const rpc = Reflect.get(client, 'rpc');
|
||||
if (rpc != null && typeof rpc === 'object' && 'prefix' in rpc) {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import {GatewayRpcMethodError, GatewayRpcMethodErrorCodes} from '@app/api/infrastructure/GatewayRpcError';
|
||||
import type {IGatewayRpcTransport} from '@app/api/infrastructure/IGatewayRpcTransport';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {type Msg, RequestError, TimeoutError} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {type Msg, StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const NATS_REQUEST_TIMEOUT_MS = 5000;
|
||||
const NATS_SUBJECT_PREFIX = 'rpc.gateway.';
|
||||
@@ -15,26 +18,11 @@ interface NatsRpcResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const NATS_NO_RESPONDERS_CODE = '503';
|
||||
const NATS_TIMEOUT_CODE = 'TIMEOUT';
|
||||
|
||||
function getErrorCode(error: Error): string | null {
|
||||
if (!('code' in error)) {
|
||||
return null;
|
||||
}
|
||||
const code = error.code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
|
||||
function mapNatsRpcTransportError(error: unknown): GatewayRpcMethodError | null {
|
||||
if (!(error instanceof Error)) {
|
||||
return null;
|
||||
}
|
||||
const code = getErrorCode(error);
|
||||
if (code === NATS_NO_RESPONDERS_CODE || error.message === 'NO_RESPONDERS' || error.name === 'NoRespondersError') {
|
||||
if (error instanceof RequestError && error.isNoResponders()) {
|
||||
return new GatewayRpcMethodError(GatewayRpcMethodErrorCodes.NO_RESPONDERS);
|
||||
}
|
||||
if (code === NATS_TIMEOUT_CODE || error.message === 'TIMEOUT' || error.name === 'TimeoutError') {
|
||||
if (error instanceof TimeoutError) {
|
||||
return new GatewayRpcMethodError(GatewayRpcMethodErrorCodes.TIMEOUT);
|
||||
}
|
||||
return null;
|
||||
@@ -64,7 +52,6 @@ function decodeNatsRpcResponse(responseText: string): NatsRpcResponse {
|
||||
|
||||
export class NatsGatewayRpcTransport implements IGatewayRpcTransport {
|
||||
private readonly connectionManager: INatsConnectionManager;
|
||||
private readonly codec = StringCodec();
|
||||
|
||||
constructor(connectionManager: INatsConnectionManager) {
|
||||
this.connectionManager = connectionManager;
|
||||
@@ -72,7 +59,7 @@ export class NatsGatewayRpcTransport implements IGatewayRpcTransport {
|
||||
|
||||
async call(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const subject = `${NATS_SUBJECT_PREFIX}${method}`;
|
||||
const payload = this.codec.encode(JSON.stringify(params));
|
||||
const payload = textEncoder.encode(JSON.stringify(params));
|
||||
let responseMsg: Msg;
|
||||
try {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
@@ -90,7 +77,7 @@ export class NatsGatewayRpcTransport implements IGatewayRpcTransport {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const responseText = this.codec.decode(responseMsg.data);
|
||||
const responseText = textDecoder.decode(responseMsg.data);
|
||||
const response = decodeNatsRpcResponse(responseText);
|
||||
if (!response.ok) {
|
||||
throw new GatewayRpcMethodError(response.error ?? GatewayRpcMethodErrorCodes.INTERNAL_ERROR);
|
||||
|
||||
@@ -4,8 +4,8 @@ import {NatsUnfurlerService} from '@app/api/infrastructure/NatsUnfurlerService';
|
||||
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
|
||||
import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeoutError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {type NatsConnection, NoRespondersError, RequestError, TimeoutError} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {type NatsConnection, StringCodec} from 'nats';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface FakeRequest {
|
||||
@@ -16,12 +16,7 @@ interface FakeRequest {
|
||||
|
||||
const RESOLVED_REPLY = JSON.stringify({Resolved: {embeds: [], cache_ttl_seconds: null}});
|
||||
|
||||
function natsErrorWithCode(code: string): Error {
|
||||
return Object.assign(new Error('nats request failed'), {code});
|
||||
}
|
||||
|
||||
class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
private readonly codec = StringCodec();
|
||||
private closed = true;
|
||||
readonly requests: Array<FakeRequest> = [];
|
||||
connectCalls = 0;
|
||||
@@ -44,14 +39,14 @@ class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
request: async (subject: string, data: Uint8Array, options?: {timeout?: number}) => {
|
||||
this.requests.push({
|
||||
subject,
|
||||
body: JSON.parse(this.codec.decode(data)) as Record<string, unknown>,
|
||||
body: JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
if (this.requestError) {
|
||||
throw this.requestError;
|
||||
}
|
||||
return {
|
||||
data: this.codec.encode(this.replyText),
|
||||
data: new TextEncoder().encode(this.replyText),
|
||||
};
|
||||
},
|
||||
} as unknown as NatsConnection;
|
||||
@@ -112,14 +107,17 @@ describe('NatsUnfurlerService', () => {
|
||||
});
|
||||
|
||||
it('rejects with a gateway timeout error when the request times out', async () => {
|
||||
const manager = new FakeNatsConnectionManager(RESOLVED_REPLY, natsErrorWithCode('TIMEOUT'));
|
||||
const manager = new FakeNatsConnectionManager(RESOLVED_REPLY, new TimeoutError());
|
||||
const service = new NatsUnfurlerService(manager);
|
||||
|
||||
await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(GatewayTimeoutError);
|
||||
});
|
||||
|
||||
it('rejects with a service unavailable error when no responders answer', async () => {
|
||||
const manager = new FakeNatsConnectionManager(RESOLVED_REPLY, natsErrorWithCode('503'));
|
||||
const manager = new FakeNatsConnectionManager(
|
||||
RESOLVED_REPLY,
|
||||
new RequestError("no responders: 'svc.unfurl'", {cause: new NoRespondersError('svc.unfurl')}),
|
||||
);
|
||||
const service = new NatsUnfurlerService(manager);
|
||||
|
||||
await expect(service.unfurlWithCachePolicy('https://example.com')).rejects.toBeInstanceOf(ServiceUnavailableError);
|
||||
|
||||
@@ -10,14 +10,15 @@ import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeou
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import {FluxerError} from '@fluxer/errors/src/FluxerError';
|
||||
import type {MessageEmbedResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import {RequestError, TimeoutError} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const NATS_UNFURL_SUBJECT = 'svc.unfurl';
|
||||
const NATS_UNFURL_TIMEOUT_MS = 12000;
|
||||
const NATS_UNFURL_CACHE_ONLY_TIMEOUT_MS = 1000;
|
||||
const NATS_NO_RESPONDERS_CODE = '503';
|
||||
const NATS_TIMEOUT_CODE = 'TIMEOUT';
|
||||
|
||||
interface NatsUnfurlRequest {
|
||||
op: 'Unfurl';
|
||||
@@ -64,14 +65,10 @@ function mapUnfurlTransportError(error: unknown): Error {
|
||||
if (error instanceof FluxerError) {
|
||||
return error;
|
||||
}
|
||||
if (!(error instanceof Error)) {
|
||||
return new BadGatewayError({message: '[nats-unfurl] request failed'});
|
||||
}
|
||||
const code = 'code' in error && typeof error.code === 'string' ? error.code : null;
|
||||
if (code === NATS_NO_RESPONDERS_CODE || error.message === 'NO_RESPONDERS' || error.name === 'NoRespondersError') {
|
||||
if (error instanceof RequestError && error.isNoResponders()) {
|
||||
return new ServiceUnavailableError({message: '[nats-unfurl] no unfurl service is answering'});
|
||||
}
|
||||
if (code === NATS_TIMEOUT_CODE || error.message === 'TIMEOUT' || error.name === 'TimeoutError') {
|
||||
if (error instanceof TimeoutError) {
|
||||
return new GatewayTimeoutError({message: '[nats-unfurl] unfurl service did not answer in time'});
|
||||
}
|
||||
return new BadGatewayError({message: '[nats-unfurl] request failed'});
|
||||
@@ -79,7 +76,6 @@ function mapUnfurlTransportError(error: unknown): Error {
|
||||
|
||||
export class NatsUnfurlerService extends IUnfurlerService {
|
||||
private readonly connectionManager: INatsConnectionManager;
|
||||
private readonly codec = StringCodec();
|
||||
|
||||
constructor(
|
||||
connectionManager: INatsConnectionManager,
|
||||
@@ -109,11 +105,11 @@ export class NatsUnfurlerService extends IUnfurlerService {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const payload = this.codec.encode(JSON.stringify(request));
|
||||
const payload = textEncoder.encode(JSON.stringify(request));
|
||||
const responseMsg = await connection.request(NATS_UNFURL_SUBJECT, payload, {
|
||||
timeout: options.cacheOnly === true ? NATS_UNFURL_CACHE_ONLY_TIMEOUT_MS : NATS_UNFURL_TIMEOUT_MS,
|
||||
});
|
||||
const responseText = this.codec.decode(responseMsg.data);
|
||||
const responseText = textDecoder.decode(responseMsg.data);
|
||||
const response = parseJsonWithGuard(responseText, isNatsUnfurlResponse);
|
||||
if (!response) {
|
||||
throwForSvcErrorReply('nats-unfurl', parseJsonRecord(responseText));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {SnowflakeService} from '@app/api/infrastructure/SnowflakeService';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {type NatsConnection, StringCodec} from 'nats';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
interface FakeRequest {
|
||||
@@ -18,7 +18,6 @@ interface FakeRequest {
|
||||
type FakeBatch = Array<string> | {error: string};
|
||||
|
||||
class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
private readonly codec = StringCodec();
|
||||
private closed = true;
|
||||
private readonly batches: Array<FakeBatch>;
|
||||
readonly requests: Array<FakeRequest> = [];
|
||||
@@ -38,12 +37,12 @@ class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
}
|
||||
return {
|
||||
request: async (subject: string, data: Uint8Array, options?: {timeout?: number}) => {
|
||||
const body = JSON.parse(this.codec.decode(data)) as FakeRequest['body'];
|
||||
const body = JSON.parse(new TextDecoder().decode(data)) as FakeRequest['body'];
|
||||
this.requests.push({subject, body, timeout: options?.timeout});
|
||||
const batch = this.batches.shift() ?? [];
|
||||
const response = Array.isArray(batch) ? {ids: batch} : batch;
|
||||
return {
|
||||
data: this.codec.encode(JSON.stringify(response)),
|
||||
data: new TextEncoder().encode(JSON.stringify(response)),
|
||||
};
|
||||
},
|
||||
} as unknown as NatsConnection;
|
||||
|
||||
@@ -7,7 +7,9 @@ import {requireIntegerInRange} from '@app/api/utils/IntegerOptions';
|
||||
import {isJsonRecord, parseJsonWithGuard} from '@app/api/utils/JsonBoundaryUtils';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const DEFAULT_REMOTE_SUBJECT = 'svc.snowflakes';
|
||||
const DEFAULT_REMOTE_BATCH_SIZE = 128;
|
||||
@@ -72,7 +74,6 @@ export class SnowflakeService implements ISnowflakeService {
|
||||
private readonly lowWatermark: number;
|
||||
private readonly requestTimeoutMs: number;
|
||||
private readonly maxBufferAgeMs: number;
|
||||
private readonly codec = StringCodec();
|
||||
private phase: 'idle' | 'starting' | 'ready' | 'stopping' | 'stopped' = 'idle';
|
||||
private buffer: Array<bigint> = [];
|
||||
private bufferOffset = 0;
|
||||
@@ -272,14 +273,14 @@ export class SnowflakeService implements ISnowflakeService {
|
||||
if (routingKey) {
|
||||
request.routing_key = routingKey;
|
||||
}
|
||||
const responseMessage = await connection.request(this.subject, this.codec.encode(JSON.stringify(request)), {
|
||||
const responseMessage = await connection.request(this.subject, textEncoder.encode(JSON.stringify(request)), {
|
||||
timeout: this.requestTimeoutMs,
|
||||
});
|
||||
this.assertActive();
|
||||
if (responseMessage.data.byteLength > MAX_REMOTE_RESPONSE_BYTES) {
|
||||
throw new Error('Snowflake service response exceeds the byte limit');
|
||||
}
|
||||
const response = parseJsonWithGuard(this.codec.decode(responseMessage.data), isRemoteSnowflakeResponse);
|
||||
const response = parseJsonWithGuard(textDecoder.decode(responseMessage.data), isRemoteSnowflakeResponse);
|
||||
if (!response) {
|
||||
throw new Error('Snowflake service returned an invalid response');
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {DiscardPolicy, JetStreamApiError, RetentionPolicy, StorageType} from '@nats-io/jetstream';
|
||||
import {nanos} from '@nats-io/transport-node';
|
||||
import {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import {DiscardPolicy, NatsError, nanos, RetentionPolicy, StorageType} from 'nats';
|
||||
|
||||
export type StorageChangeOp = 'put' | 'delete';
|
||||
|
||||
@@ -68,10 +69,7 @@ function subjectToken(bucket: string): string {
|
||||
}
|
||||
|
||||
function jsErrorCode(error: unknown): number | null {
|
||||
if (!(error instanceof NatsError)) {
|
||||
return null;
|
||||
}
|
||||
return error.jsError()?.err_code ?? null;
|
||||
return error instanceof JetStreamApiError ? error.code : null;
|
||||
}
|
||||
|
||||
export class StorageChangeFeed {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {NatsUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {type NatsConnection, StringCodec} from 'nats';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface FakeRequest {
|
||||
@@ -14,7 +14,6 @@ interface FakeRequest {
|
||||
}
|
||||
|
||||
class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
private readonly codec = StringCodec();
|
||||
private closed = true;
|
||||
private readonly responses: Array<unknown>;
|
||||
readonly requests: Array<FakeRequest> = [];
|
||||
@@ -38,7 +37,7 @@ class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
request: async (subject: string, data: Uint8Array, options?: {timeout?: number}) => {
|
||||
this.requests.push({
|
||||
subject,
|
||||
body: JSON.parse(this.codec.decode(data)) as Record<string, unknown>,
|
||||
body: JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>,
|
||||
timeout: options?.timeout,
|
||||
});
|
||||
const response = this.responses.shift();
|
||||
@@ -46,7 +45,7 @@ class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
throw response;
|
||||
}
|
||||
return {
|
||||
data: this.codec.encode(JSON.stringify(response)),
|
||||
data: new TextEncoder().encode(JSON.stringify(response)),
|
||||
};
|
||||
},
|
||||
} as unknown as NatsConnection;
|
||||
|
||||
@@ -10,7 +10,9 @@ import {isJsonRecord, parseJsonRecord, parseJsonWithGuard} from '@app/api/utils/
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {NatsConnectionManager} from '@pkgs/nats/src/NatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const USERS_SERVICE_SUBJECT = process.env.FLUXER_USERS_SERVICE_SUBJECT || 'svc.users';
|
||||
const DEFAULT_USERS_SERVICE_TIMEOUT_MS = 6000;
|
||||
@@ -49,7 +51,6 @@ function isUserPartialsResponse(value: unknown): value is UserPartialsResponse {
|
||||
}
|
||||
|
||||
export class NatsUsersServiceClient implements IUsersServiceClient {
|
||||
private readonly codec = StringCodec();
|
||||
private readonly inflightPartials = new Map<UserID, PendingUserPartials>();
|
||||
|
||||
constructor(
|
||||
@@ -160,10 +161,10 @@ export class NatsUsersServiceClient implements IUsersServiceClient {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const response = await connection.request(this.subject, this.codec.encode(JSON.stringify(payload)), {
|
||||
const response = await connection.request(this.subject, textEncoder.encode(JSON.stringify(payload)), {
|
||||
timeout: this.requestTimeoutMs,
|
||||
});
|
||||
const decoded = this.codec.decode(response.data);
|
||||
const decoded = textDecoder.decode(response.data);
|
||||
const parsed = parseJsonWithGuard(decoded, responseGuard);
|
||||
if (parsed === null) {
|
||||
throwForSvcErrorReply('users-service', parseJsonRecord(decoded));
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
GatewayRolloutConfigPublisher,
|
||||
} from '@app/api/instance/GatewayRolloutConfigPublisher';
|
||||
import type {GatewayRolloutConfig} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {type NatsConnection, StringCodec} from 'nats';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface FakePublish {
|
||||
@@ -15,7 +15,6 @@ interface FakePublish {
|
||||
}
|
||||
|
||||
class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
private readonly codec = StringCodec();
|
||||
private closed = true;
|
||||
readonly publishes: Array<FakePublish> = [];
|
||||
connectCalls = 0;
|
||||
@@ -34,7 +33,7 @@ class FakeNatsConnectionManager implements INatsConnectionManager {
|
||||
publish: (subject: string, data: Uint8Array) => {
|
||||
this.publishes.push({
|
||||
subject,
|
||||
body: JSON.parse(this.codec.decode(data)) as Record<string, unknown>,
|
||||
body: JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>,
|
||||
});
|
||||
},
|
||||
flush: async () => {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import type {GatewayRolloutConfig} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import {StringCodec} from 'nats';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const GATEWAY_ROLLOUT_CONFIG_NATS_SUBJECT = 'config.gateway.rollout';
|
||||
|
||||
@@ -12,8 +13,6 @@ interface GatewayRolloutConfigNatsMessage {
|
||||
}
|
||||
|
||||
export class GatewayRolloutConfigPublisher {
|
||||
private readonly codec = StringCodec();
|
||||
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: GatewayRolloutConfig): Promise<void> {
|
||||
@@ -25,7 +24,7 @@ export class GatewayRolloutConfigPublisher {
|
||||
type: 'gateway_rollout_config',
|
||||
config,
|
||||
};
|
||||
connection.publish(GATEWAY_ROLLOUT_CONFIG_NATS_SUBJECT, this.codec.encode(JSON.stringify(message)));
|
||||
connection.publish(GATEWAY_ROLLOUT_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
|
||||
await connection.flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function ensureBearerScope(ctx: Context<HonoEnv>, scope: OAuth2Scope, mode: OAut
|
||||
return false;
|
||||
}
|
||||
const oauthScopes = ctx.get('oauthBearerScopes');
|
||||
if (!oauthScopes || !oauthScopes.has(scope)) {
|
||||
if (!oauthScopes?.has(scope)) {
|
||||
throw new MissingOAuthScopeError(scope);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -52,46 +52,44 @@ describe('ResponseTypeMiddleware', () => {
|
||||
expect(await response.json()).toEqual({id: '123456789012345678'});
|
||||
});
|
||||
|
||||
test.each([
|
||||
undefined,
|
||||
'application/json',
|
||||
'application/json; charset=utf-8',
|
||||
'Application/JSON; charset=utf-8',
|
||||
])('rejects mismatching responses while validation is enabled (content type: %s)', async (responseContentType) => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
const middleware =
|
||||
responseContentType === undefined
|
||||
? ResponseType(SnowflakeResponse)
|
||||
: OpenAPI({
|
||||
operationId: 'get_invalid_snowflake_test',
|
||||
summary: 'Get invalid snowflake',
|
||||
description: 'Returns an invalid snowflake to verify JSON response validation.',
|
||||
responseSchema: SnowflakeResponse,
|
||||
responseContentType,
|
||||
tags: ['Tests'],
|
||||
});
|
||||
app.get('/snowflake', middleware, (ctx) => ctx.json({id: 'not-a-snowflake'}));
|
||||
const errorLoggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
test.each([undefined, 'application/json', 'application/json; charset=utf-8', 'Application/JSON; charset=utf-8'])(
|
||||
'rejects mismatching responses while validation is enabled (content type: %s)',
|
||||
async (responseContentType) => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
const middleware =
|
||||
responseContentType === undefined
|
||||
? ResponseType(SnowflakeResponse)
|
||||
: OpenAPI({
|
||||
operationId: 'get_invalid_snowflake_test',
|
||||
summary: 'Get invalid snowflake',
|
||||
description: 'Returns an invalid snowflake to verify JSON response validation.',
|
||||
responseSchema: SnowflakeResponse,
|
||||
responseContentType,
|
||||
tags: ['Tests'],
|
||||
});
|
||||
app.get('/snowflake', middleware, (ctx) => ctx.json({id: 'not-a-snowflake'}));
|
||||
const errorLoggerSpy = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const response = await app.request('/snowflake');
|
||||
try {
|
||||
const response = await app.request('/snowflake');
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(errorLoggerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorLoggerSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {id: 'not-a-snowflake'},
|
||||
method: 'GET',
|
||||
path: '/snowflake',
|
||||
status: 200,
|
||||
validationErrors: [{message: 'INVALID_SNOWFLAKE_FORMAT', path: 'id'}],
|
||||
},
|
||||
'Response validation failed',
|
||||
);
|
||||
} finally {
|
||||
errorLoggerSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
expect(errorLoggerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(errorLoggerSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
body: {id: 'not-a-snowflake'},
|
||||
method: 'GET',
|
||||
path: '/snowflake',
|
||||
status: 200,
|
||||
validationErrors: [{message: 'INVALID_SNOWFLAKE_FORMAT', path: 'id'}],
|
||||
},
|
||||
'Response validation failed',
|
||||
);
|
||||
} finally {
|
||||
errorLoggerSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('passes the response through untouched while validation is disabled', async () => {
|
||||
Config.dev.validateResponses = false;
|
||||
|
||||
@@ -35,7 +35,7 @@ export class BotAuthService {
|
||||
}
|
||||
const {applicationId, secret} = parsed;
|
||||
const application = await this.applicationRepository.getApplication(applicationId);
|
||||
if (!application || !application.hasBotUser() || !application.botTokenHash) {
|
||||
if (!application?.hasBotUser() || !application.botTokenHash) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -246,7 +246,7 @@ export class OAuth2RequestService {
|
||||
try {
|
||||
const applicationId = createApplicationID(BigInt(params.body.client_id));
|
||||
const application = await this.applicationRepository.getApplication(applicationId);
|
||||
if (!application || !application.botUserId) {
|
||||
if (!application?.botUserId) {
|
||||
throw new NotABotApplicationError();
|
||||
}
|
||||
const botUserId = application.botUserId;
|
||||
@@ -454,7 +454,7 @@ export class OAuth2RequestService {
|
||||
createApplicationID(params.applicationId),
|
||||
);
|
||||
const application = await this.applicationRepository.getApplication(createApplicationID(params.applicationId));
|
||||
if (!application || !application.botUserId) {
|
||||
if (!application?.botUserId) {
|
||||
throw new BotUserNotFoundError();
|
||||
}
|
||||
const botUser = await this.apiContext.services.users.findUnique(application.botUserId);
|
||||
|
||||
@@ -333,7 +333,7 @@ export class OAuth2Service {
|
||||
|
||||
async userInfo(accessToken: string) {
|
||||
const token = await this.tokens.getAccessToken(accessToken);
|
||||
if (!token || !token.userId) {
|
||||
if (!token?.userId) {
|
||||
throw new InvalidTokenError();
|
||||
}
|
||||
const application = await this.applications.getApplication(token.applicationId);
|
||||
|
||||
+556
-1020
File diff suppressed because it is too large
Load Diff
@@ -519,7 +519,7 @@ function scoreUserAgent(signals: RiskSignals): Array<ScoreContribution> {
|
||||
|
||||
function scoreGeoLocale(signals: RiskSignals): Array<ScoreContribution> {
|
||||
const geo = signals.localeGeoMatch;
|
||||
if (!geo || !geo.mismatchDetected) return [];
|
||||
if (!geo?.mismatchDetected) return [];
|
||||
return [
|
||||
{
|
||||
rule: RULE.geoMismatch,
|
||||
@@ -606,7 +606,7 @@ function applySharedConnectionDampener(
|
||||
|
||||
function scoreTiming(signals: RiskSignals): Array<ScoreContribution> {
|
||||
const timing = signals.registrationTiming;
|
||||
if (!timing || !timing.isSuspiciousHour) return [];
|
||||
if (!timing?.isSuspiciousHour) return [];
|
||||
return [
|
||||
{
|
||||
rule: RULE.suspiciousHour,
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('IpInfoService caching', () => {
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(60);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureOutcome).toBe('request_failed');
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('request_failed');
|
||||
});
|
||||
|
||||
it('negative-caches a schema mismatch', async () => {
|
||||
@@ -113,8 +113,8 @@ describe('IpInfoService caching', () => {
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(600);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureOutcome).toBe('schema_mismatch');
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureHttpStatus).toBe(200);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureOutcome).toBe('schema_mismatch');
|
||||
expect((sets[0]?.value as CachedIpInfoFailure)?.failureHttpStatus).toBe(200);
|
||||
});
|
||||
|
||||
it('negative-caches a quota rejection for longer', async () => {
|
||||
|
||||
@@ -958,7 +958,7 @@ export class RpcService {
|
||||
const loadUserDataStartedAtNs = startRpcTiming();
|
||||
const userData = await this.getUserData({userId, includePrivateChannels: true, timingSteps: loadUserDataSteps});
|
||||
timings.record('load_user_data', loadUserDataStartedAtNs, loadUserDataSteps);
|
||||
if (!userData || !userData.user) {
|
||||
if (!userData?.user) {
|
||||
Logger.warn(
|
||||
{
|
||||
tokenType,
|
||||
|
||||
@@ -659,35 +659,33 @@ describe('Message Search Permissions', () => {
|
||||
expect(user12Messages.length).toBeGreaterThan(0);
|
||||
expect(user34Messages.length).toBe(0);
|
||||
});
|
||||
test.each([
|
||||
'all_dms',
|
||||
'open_dms',
|
||||
'all',
|
||||
'open_dms_and_all_guilds',
|
||||
] as const)('scope: %s does not trust foreign DM context_channel_id', async (scope) => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
await createFriendship(harness, user1, user2);
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const canary = `foreign-dm-context-canary-${Date.now()}-${scope}`;
|
||||
await sendChannelMessage(harness, user1.token, dmChannel.id, canary);
|
||||
await markChannelAsIndexed(harness, dmChannel.id);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, attacker.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: canary,
|
||||
scope,
|
||||
context_channel_id: dmChannel.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
if (!isSearchResult(result)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(result.messages.some((message) => message.channel_id === dmChannel.id)).toBe(false);
|
||||
expect(result.messages.some((message) => message.content === canary)).toBe(false);
|
||||
});
|
||||
test.each(['all_dms', 'open_dms', 'all', 'open_dms_and_all_guilds'] as const)(
|
||||
'scope: %s does not trust foreign DM context_channel_id',
|
||||
async (scope) => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
await createFriendship(harness, user1, user2);
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
const canary = `foreign-dm-context-canary-${Date.now()}-${scope}`;
|
||||
await sendChannelMessage(harness, user1.token, dmChannel.id, canary);
|
||||
await markChannelAsIndexed(harness, dmChannel.id);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, attacker.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: canary,
|
||||
scope,
|
||||
context_channel_id: dmChannel.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
if (!isSearchResult(result)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(result.messages.some((message) => message.channel_id === dmChannel.id)).toBe(false);
|
||||
expect(result.messages.some((message) => message.content === canary)).toBe(false);
|
||||
},
|
||||
);
|
||||
test('scope: all_dms does not trust a guild context_channel_id', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const attacker = await createTestAccount(harness);
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('Message Search Referenced Message', () => {
|
||||
try {
|
||||
await createBuilder(harness, token).post('/search/messages').body(body).expect(HTTP_STATUS.OK).execute();
|
||||
const [invocation] = spy.mock.results;
|
||||
if (!invocation || invocation.type !== 'return') {
|
||||
if (invocation?.type !== 'return') {
|
||||
throw new Error('SearchService.searchMessages did not return a result');
|
||||
}
|
||||
return await invocation.value;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const STRIPE_API_VERSION = '2026-03-25.dahlia';
|
||||
export const STRIPE_API_VERSION = '2026-08-26.dahlia';
|
||||
|
||||
@@ -1618,7 +1618,7 @@ export function TestHarnessController(app: HonoApp) {
|
||||
'[test/worker/process-pending-deletions] Processing deletion',
|
||||
);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user || !user.pendingDeletionAt) {
|
||||
if (!user?.pendingDeletionAt) {
|
||||
Logger.info(
|
||||
{
|
||||
userId: userId.toString(),
|
||||
|
||||
@@ -41,8 +41,8 @@ import type {
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {NatsConnection} from '@nats-io/transport-node';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
import type {NatsConnection} from 'nats';
|
||||
|
||||
class NoopNatsConnectionManager implements INatsConnectionManager {
|
||||
async connect(): Promise<void> {}
|
||||
|
||||
@@ -449,7 +449,7 @@ export class UserEmailOwnershipRepository {
|
||||
private async releaseClaimRow(emailLower: string, expectedOwnerId?: UserID): Promise<void> {
|
||||
const ownerRow = await this.fetchOwnerRow(emailLower);
|
||||
const currentOwnerId = parseOptionalUserId(ownerRow?.user_id);
|
||||
if (!ownerRow || ownerRow.claimed !== true || currentOwnerId === null) {
|
||||
if (ownerRow?.claimed !== true || currentOwnerId === null) {
|
||||
return;
|
||||
}
|
||||
if (expectedOwnerId !== undefined && currentOwnerId !== expectedOwnerId) {
|
||||
@@ -484,7 +484,7 @@ export class UserEmailOwnershipRepository {
|
||||
|
||||
private async findValidClaimedOwnerId(emailLower: string): Promise<UserID | null> {
|
||||
const ownerRow = await this.fetchOwnerRow(emailLower);
|
||||
if (!ownerRow || ownerRow.claimed !== true) {
|
||||
if (ownerRow?.claimed !== true) {
|
||||
return null;
|
||||
}
|
||||
const ownerId = parseOptionalUserId(ownerRow.user_id);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
export class UserIndexRepository {
|
||||
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
|
||||
if (data.username && data.discriminator != null && data.discriminator !== undefined) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.upsertAll({
|
||||
username: data.username.toLowerCase(),
|
||||
|
||||
@@ -570,7 +570,7 @@ export class UserContentService {
|
||||
}
|
||||
const harvestRepository = new UserHarvestRepository();
|
||||
const harvest = await harvestRepository.findByUserAndHarvestId(userId, params.harvestId);
|
||||
if (!harvest || !harvest.completedAt || !harvest.storageKey || harvest.failedAt) {
|
||||
if (!harvest?.completedAt || !harvest.storageKey || harvest.failedAt) {
|
||||
return null;
|
||||
}
|
||||
if (harvest.downloadUrlExpiresAt && harvest.downloadUrlExpiresAt < new Date()) {
|
||||
|
||||
@@ -32,13 +32,14 @@ describe('users table write guard for synthetic accounts', () => {
|
||||
await expect(repository.updateLastActiveAt({userId, lastActiveAt: new Date(0)})).rejects.toThrow();
|
||||
expect(await repository.listUsers([userId])).toEqual([]);
|
||||
});
|
||||
test.each(
|
||||
SYNTHETIC_USER_IDS,
|
||||
)('findUnique still synthesises user %s after a refused write', async (_label, userId) => {
|
||||
const repository = new UserRepository();
|
||||
await expect(repository.patchUpsert(userId, {bio: 'written by a test'})).rejects.toThrow();
|
||||
const user = await repository.findUnique(userId);
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.id.toString()).toBe(userId.toString());
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)(
|
||||
'findUnique still synthesises user %s after a refused write',
|
||||
async (_label, userId) => {
|
||||
const repository = new UserRepository();
|
||||
await expect(repository.patchUpsert(userId, {bio: 'written by a test'})).rejects.toThrow();
|
||||
const user = await repository.findUnique(userId);
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.id.toString()).toBe(userId.toString());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,15 +23,11 @@ describe('chunkArray', () => {
|
||||
expect(chunkArray([], 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects invalid chunk size %j even for empty input', (size) => {
|
||||
expect(() => chunkArray([1], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
expect(() => chunkArray([], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
});
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid chunk size %j even for empty input',
|
||||
(size) => {
|
||||
expect(() => chunkArray([1], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
expect(() => chunkArray([], size)).toThrow('Chunk size must be a positive safe integer');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -69,27 +69,23 @@ describe('mapWithConcurrency', () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
0,
|
||||
-1,
|
||||
1.5,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
])('rejects invalid concurrency %j before scheduling work', async (concurrency) => {
|
||||
let calls = 0;
|
||||
const mapper = async () => {
|
||||
calls++;
|
||||
return 1;
|
||||
};
|
||||
await expect(mapWithConcurrency([1], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
await expect(mapWithConcurrency([], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid concurrency %j before scheduling work',
|
||||
async (concurrency) => {
|
||||
let calls = 0;
|
||||
const mapper = async () => {
|
||||
calls++;
|
||||
return 1;
|
||||
};
|
||||
await expect(mapWithConcurrency([1], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
await expect(mapWithConcurrency([], concurrency, mapper)).rejects.toThrow(
|
||||
'Concurrency must be a positive safe integer',
|
||||
);
|
||||
expect(calls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('waits for in-flight work before propagating a mapper rejection', async () => {
|
||||
const first = Promise.withResolvers<number>();
|
||||
|
||||
@@ -28,14 +28,12 @@ describe('parseString', () => {
|
||||
});
|
||||
|
||||
describe('hasVisibleContent', () => {
|
||||
it.each([
|
||||
'',
|
||||
' \t\n',
|
||||
'\u200e \u200b\ufeff',
|
||||
'\u2800\u3164\u{e0100}',
|
||||
])('rejects whitespace or invisible-only input %j', (input) => {
|
||||
expect(hasVisibleContent(input)).toBe(false);
|
||||
});
|
||||
it.each(['', ' \t\n', '\u200e \u200b\ufeff', '\u2800\u3164\u{e0100}'])(
|
||||
'rejects whitespace or invisible-only input %j',
|
||||
(input) => {
|
||||
expect(hasVisibleContent(input)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['hello', '\u200e hello', '🙂', '` `'])('accepts visible input %j', (input) => {
|
||||
expect(hasVisibleContent(input)).toBe(true);
|
||||
|
||||
@@ -4,22 +4,22 @@ import {randomUUID} from 'node:crypto';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {WorkerLaneDefinition} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import {
|
||||
AckPolicy,
|
||||
type ConsumerInfo,
|
||||
type ConsumerUpdateConfig,
|
||||
DeliverPolicy,
|
||||
DiscardPolicy,
|
||||
JetStreamApiError,
|
||||
type JetStreamManager,
|
||||
NatsError,
|
||||
nanos,
|
||||
ReplayPolicy,
|
||||
RetentionPolicy,
|
||||
StorageType,
|
||||
type StreamConfig,
|
||||
} from 'nats';
|
||||
} from '@nats-io/jetstream';
|
||||
import {nanos} from '@nats-io/transport-node';
|
||||
import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
|
||||
const STREAM_NAME = 'JOBS';
|
||||
const SUBJECT_PREFIX = 'jobs.';
|
||||
@@ -55,12 +55,6 @@ interface WorkerStreamDefinition {
|
||||
discardNewPerSubject: boolean;
|
||||
}
|
||||
|
||||
interface WorkerStreamConfiguration extends StreamConfig {
|
||||
persist_mode?: string;
|
||||
allow_msg_ttl?: boolean;
|
||||
allow_msg_counter?: boolean;
|
||||
}
|
||||
|
||||
const JOBS_STREAM: WorkerStreamDefinition = {
|
||||
name: STREAM_NAME,
|
||||
subject: `${SUBJECT_PREFIX}>`,
|
||||
@@ -95,21 +89,14 @@ export interface WorkerDeadLetterMetadata {
|
||||
}
|
||||
|
||||
function jsErrorCode(error: unknown): number | null {
|
||||
if (!(error instanceof NatsError)) {
|
||||
return null;
|
||||
}
|
||||
return error.jsError()?.err_code ?? null;
|
||||
return error instanceof JetStreamApiError ? error.code : null;
|
||||
}
|
||||
|
||||
function describeStreamRejection(error: unknown): string | null {
|
||||
if (!(error instanceof NatsError)) {
|
||||
if (!(error instanceof JetStreamApiError) || !STREAM_FULL_ERR_CODES.has(error.code)) {
|
||||
return null;
|
||||
}
|
||||
const apiError = error.jsError();
|
||||
if (apiError === null || !STREAM_FULL_ERR_CODES.has(apiError.err_code ?? 0)) {
|
||||
return null;
|
||||
}
|
||||
return apiError.description ?? 'stream rejected the publish';
|
||||
return error.apiError().description || 'stream rejected the publish';
|
||||
}
|
||||
|
||||
export class JetStreamWorkerQueue {
|
||||
@@ -155,7 +142,7 @@ export class JetStreamWorkerQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private async applyStreamLimits(jsm: JetStreamManager, existing: WorkerStreamConfiguration): Promise<void> {
|
||||
private async applyStreamLimits(jsm: JetStreamManager, existing: StreamConfig): Promise<void> {
|
||||
const unmanaged = this.unmanagedStreamSettings(existing, JOBS_STREAM);
|
||||
if (unmanaged.length > 0) {
|
||||
Logger.warn(
|
||||
@@ -205,7 +192,7 @@ export class JetStreamWorkerQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private assertStreamIdentity(config: WorkerStreamConfiguration, stream: WorkerStreamDefinition): void {
|
||||
private assertStreamIdentity(config: StreamConfig, stream: WorkerStreamDefinition): void {
|
||||
const incompatible: Array<string> = [];
|
||||
if (config.name !== stream.name) incompatible.push('name');
|
||||
if (config.subjects?.length !== 1 || config.subjects[0] !== stream.subject) incompatible.push('subjects');
|
||||
@@ -222,7 +209,7 @@ export class JetStreamWorkerQueue {
|
||||
}
|
||||
}
|
||||
|
||||
private diffStreamLimits(config: WorkerStreamConfiguration, stream: WorkerStreamDefinition): Array<string> {
|
||||
private diffStreamLimits(config: StreamConfig, stream: WorkerStreamDefinition): Array<string> {
|
||||
const drifted: Array<string> = [];
|
||||
if (!Number.isSafeInteger(config.max_bytes) || config.max_bytes < stream.minBytes) drifted.push('max_bytes');
|
||||
if (config.max_msgs !== stream.maxMessages) drifted.push('max_msgs');
|
||||
@@ -234,7 +221,7 @@ export class JetStreamWorkerQueue {
|
||||
return drifted;
|
||||
}
|
||||
|
||||
private unmanagedStreamSettings(config: WorkerStreamConfiguration, stream: WorkerStreamDefinition): Array<string> {
|
||||
private unmanagedStreamSettings(config: StreamConfig, stream: WorkerStreamDefinition): Array<string> {
|
||||
const unmanaged: Array<string> = [];
|
||||
if (config.max_age !== nanos(stream.maxAgeMs)) unmanaged.push('max_age');
|
||||
if (config.duplicate_window !== nanos(DUPLICATE_WINDOW_MS)) unmanaged.push('duplicate_window');
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
type WorkerHeartbeat,
|
||||
type WorkerHeartbeatSignal,
|
||||
} from '@app/api/worker/WorkerHeartbeat';
|
||||
import type {ConsumerMessages, FetchOptions, JsMsg} from '@nats-io/jetstream';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {JobCancelledError, type WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {ConsumerMessages, FetchOptions, JsMsg} from 'nats';
|
||||
|
||||
const MAX_DLQ_PUBLISH_ATTEMPTS = 3;
|
||||
const DLQ_RETRY_DELAY_MS = 250;
|
||||
|
||||
@@ -428,7 +428,7 @@ const extractEmbeds: WorkerTaskHandler = async (payload, helpers) => {
|
||||
messageId,
|
||||
validated.expectedContentHash,
|
||||
);
|
||||
if (!message || !message.content) {
|
||||
if (!message?.content) {
|
||||
Logger.info({messageId: messageId.toString()}, 'Skipping extractEmbeds: message not found or no content');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ const userProcessPendingDeletions: WorkerTaskHandler = async (_payload, helpers)
|
||||
try {
|
||||
const userId = createUserID(deletion.userId);
|
||||
const user = await userRepository.findUnique(userId);
|
||||
if (!user || !user.pendingDeletionAt) {
|
||||
if (!user?.pendingDeletionAt) {
|
||||
Logger.warn({userId}, 'User not found or not pending deletion in Cassandra, removing from KV');
|
||||
await deletionQueueService.removeFromQueue(userId);
|
||||
continue;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import {JetStreamWorkerQueue} from '@app/api/worker/JetStreamWorkerQueue';
|
||||
import {WORKER_LANES} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {WorkerQueueOverflowError} from '@app/api/worker/WorkerQueueOverflowError';
|
||||
import {DiscardPolicy, JetStreamApiError, RetentionPolicy, StorageType, type StreamConfig} from '@nats-io/jetstream';
|
||||
import type {JetStreamConnectionManager} from '@pkgs/nats/src/JetStreamConnectionManager';
|
||||
import {DiscardPolicy, NatsError, RetentionPolicy, StorageType, type StreamConfig} from 'nats';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const GIB = 1024 * 1024 * 1024;
|
||||
@@ -51,29 +51,24 @@ function withStreamDefaults(config: Partial<StreamConfig>): Partial<StreamConfig
|
||||
};
|
||||
}
|
||||
|
||||
function missingResourceError(resource: 'stream' | 'consumer'): NatsError {
|
||||
const description = `${resource} not found`;
|
||||
const error = new NatsError(description, '404');
|
||||
error.api_error = {code: 404, err_code: resource === 'stream' ? 10059 : 10014, description};
|
||||
return error;
|
||||
function missingResourceError(resource: 'stream' | 'consumer'): JetStreamApiError {
|
||||
return new JetStreamApiError({
|
||||
code: 404,
|
||||
err_code: resource === 'stream' ? 10059 : 10014,
|
||||
description: `${resource} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
function streamLimitError(description: string): NatsError {
|
||||
const error = new NatsError('503', '503');
|
||||
error.api_error = {code: 503, err_code: 10077, description};
|
||||
return error;
|
||||
function streamLimitError(description: string): JetStreamApiError {
|
||||
return new JetStreamApiError({code: 503, err_code: 10077, description});
|
||||
}
|
||||
|
||||
function serverResourceError(): NatsError {
|
||||
const error = new NatsError('503', '503');
|
||||
error.api_error = {code: 503, err_code: 10023, description: 'insufficient resources'};
|
||||
return error;
|
||||
function serverResourceError(): JetStreamApiError {
|
||||
return new JetStreamApiError({code: 503, err_code: 10023, description: 'insufficient resources'});
|
||||
}
|
||||
|
||||
function noStorageError(): NatsError {
|
||||
const error = new NatsError('503', '503');
|
||||
error.api_error = {code: 503, err_code: 10047, description: 'insufficient storage resources available'};
|
||||
return error;
|
||||
function noStorageError(): JetStreamApiError {
|
||||
return new JetStreamApiError({code: 503, err_code: 10047, description: 'insufficient storage resources available'});
|
||||
}
|
||||
|
||||
function storageBudget(budget: number): (config: Partial<StreamConfig>) => Error | null {
|
||||
@@ -205,7 +200,7 @@ describe('jobs stream limits', () => {
|
||||
|
||||
it('fails the boot when even the smallest jobs stream does not fit', async () => {
|
||||
const {queue, added} = createQueue({existing: null, reject: storageBudget(0)});
|
||||
await expect(queue.ensureStream()).rejects.toBeInstanceOf(NatsError);
|
||||
await expect(queue.ensureStream()).rejects.toBeInstanceOf(JetStreamApiError);
|
||||
expect(added).toHaveLength(8);
|
||||
expect(added[7]?.max_bytes).toBe(64 * MIB);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {sendSystemDm} from '@app/api/worker/tasks/SendSystemDm';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {JsMsg} from 'nats';
|
||||
import type {JsMsg} from '@nats-io/jetstream';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const TASK_TYPE = 'sendSystemDm';
|
||||
|
||||
@@ -4,8 +4,8 @@ import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {JsMsg} from '@nats-io/jetstream';
|
||||
import {JobCancelledError} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {JsMsg} from 'nats';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const LANE_ACK_WAIT_MS = 120000;
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {WorkerService} from '@app/api/worker/WorkerService';
|
||||
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
|
||||
import type {ConsumerMessages, JsMsg} from '@nats-io/jetstream';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import type {ConsumerMessages, JsMsg} from 'nats';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const HEARTBEAT_PATH = '/tmp/fluxer-worker-heartbeat-test';
|
||||
|
||||
@@ -66,17 +66,17 @@ describe('Worker process error handler', () => {
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['uncaughtException' as WorkerProcessErrorSource],
|
||||
['unhandledRejection' as WorkerProcessErrorSource],
|
||||
])('survives a transient error arriving as %s', async (source) => {
|
||||
const {exit, shutdown, handle} = createHarness();
|
||||
it.each([['uncaughtException' as WorkerProcessErrorSource], ['unhandledRejection' as WorkerProcessErrorSource]])(
|
||||
'survives a transient error arriving as %s',
|
||||
async (source) => {
|
||||
const {exit, shutdown, handle} = createHarness();
|
||||
|
||||
await handle(source, adminShutdownError());
|
||||
await handle(source, adminShutdownError());
|
||||
|
||||
expect(shutdown).not.toHaveBeenCalled();
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(shutdown).not.toHaveBeenCalled();
|
||||
expect(exit).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('survives a socket error raised by a pooled Postgres client', async () => {
|
||||
const {exit, shutdown, handle} = createHarness();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {JsMsg} from 'nats';
|
||||
import type {JsMsg} from '@nats-io/jetstream';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const RETIRED_TASK_TYPE = 'sendScheduledMessage';
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {ConsumerMessages, JsMsg} from 'nats';
|
||||
import type {ConsumerMessages, JsMsg} from '@nats-io/jetstream';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const TASK_TYPE = 'processInactivityDeletions';
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {IJobLedgerRepository} from '@app/api/jobs/IJobLedgerRepository';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {WorkerRunner} from '@app/api/worker/WorkerRunner';
|
||||
import type {ConsumerMessages, JsMsg} from 'nats';
|
||||
import type {ConsumerMessages, JsMsg} from '@nats-io/jetstream';
|
||||
import {beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const TASK_TYPE = 'processInactivityDeletions';
|
||||
|
||||
@@ -3,7 +3,7 @@ import {lstat, opendir, readlink} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {Readable} from 'node:stream';
|
||||
import {finished, pipeline} from 'node:stream/promises';
|
||||
import archiver, {type Archiver, type EntryData} from 'archiver';
|
||||
import {type Archiver, type EntryData, ZipArchive} from 'archiver';
|
||||
|
||||
export interface ArchiveEntryWriter {
|
||||
append(source: Readable | Buffer | string, data: EntryData): Promise<void>;
|
||||
@@ -27,19 +27,18 @@ export async function writeZipArchive(
|
||||
filePath: string,
|
||||
produce: (archive: ArchiveFileWriter) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const archive: Archiver = archiver('zip', {zlib: {level: 6}});
|
||||
const archive: Archiver = new ZipArchive({zlib: {level: 6}});
|
||||
const output = createWriteStream(filePath);
|
||||
const outputClosed = new Promise<void>((resolve) => output.once('close', resolve));
|
||||
const archiveClosed = new Promise<void>((resolve) => archive.once('close', resolve));
|
||||
const inputs = new Map<Readable, Promise<void>>();
|
||||
const entries = new Map<EntryData, PendingArchiveEntry>();
|
||||
const entries: Array<PendingArchiveEntry> = [];
|
||||
let failure: {error: unknown} | undefined;
|
||||
|
||||
function fail(error: unknown): void {
|
||||
if (failure) return;
|
||||
failure = {error};
|
||||
for (const entry of entries.values()) entry.reject(error);
|
||||
entries.clear();
|
||||
for (const entry of entries.splice(0)) entry.reject(error);
|
||||
const streamError = error instanceof Error ? error : new Error('Archive creation failed', {cause: error});
|
||||
for (const source of inputs.keys()) source.destroy(streamError);
|
||||
archive.abort();
|
||||
@@ -76,7 +75,7 @@ export async function writeZipArchive(
|
||||
}
|
||||
requireWritable();
|
||||
const entry = {...data};
|
||||
const processed = new Promise<void>((resolve, reject) => entries.set(entry, {resolve, reject}));
|
||||
const processed = new Promise<void>((resolve, reject) => entries.push({resolve, reject}));
|
||||
const completed = Promise.all([processed, completion]);
|
||||
try {
|
||||
archive.append(source, entry);
|
||||
@@ -117,14 +116,13 @@ export async function writeZipArchive(
|
||||
|
||||
archive.on('warning', fail);
|
||||
archive.on('error', fail);
|
||||
archive.on('entry', (entry) => {
|
||||
archive.on('entry', () => {
|
||||
if (failure) return;
|
||||
const pending = entries.get(entry);
|
||||
const pending = entries.shift();
|
||||
if (!pending) {
|
||||
fail(new Error('Archive completed an unknown entry'));
|
||||
return;
|
||||
}
|
||||
entries.delete(entry);
|
||||
pending.resolve();
|
||||
});
|
||||
output.on('error', fail);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user