chore(deps): upgrade all dependencies, toolchains and images (#2825)

This commit is contained in:
Hampus
2026-09-17 21:08:56 +02:00
committed by GitHub
parent 56e04e7b53
commit 3b552e00ef
510 changed files with 23614 additions and 37473 deletions
+8 -12
View File
@@ -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
+8 -7
View File
@@ -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"
}
+2 -2
View File
@@ -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:"
}
}
+2 -2
View File
@@ -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"
}
}
+2 -2
View File
@@ -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"
}
}
+2 -3
View File
@@ -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({
+2 -2
View File
@@ -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"
}
}
+1 -1
View File
@@ -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);
+2 -3
View File
@@ -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(
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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:"
}
}
+2 -2
View File
@@ -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"
}
}
+4 -3
View File
@@ -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();
}
}
+2 -2
View File
@@ -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"
}
}
+2 -2
View File
@@ -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"
}
}
+2 -2
View File
@@ -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
View File
@@ -1,7 +1,6 @@
{
"extends": "../../../tsconfigs/package.json",
"compilerOptions": {
"types": ["node"],
"paths": {
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
"@pkgs/*": ["../*"]
+2 -2
View File
@@ -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"
}
}
+2 -2
View File
@@ -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,
+2
View File
@@ -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();
+5 -4
View File
@@ -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;
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
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 () => {
+1 -1
View File
@@ -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');
+1 -1
View File
@@ -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