Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
31 changes: 16 additions & 15 deletions apps/nestjs-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,21 +160,21 @@
"@nestjs/websockets": "10.3.5",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.5.0",
"@opentelemetry/exporter-logs-otlp-http": "0.201.1",
"@opentelemetry/exporter-metrics-otlp-http": "0.201.1",
"@opentelemetry/exporter-trace-otlp-http": "0.201.1",
"@opentelemetry/instrumentation-express": "0.50.0",
"@opentelemetry/instrumentation-http": "0.201.1",
"@opentelemetry/instrumentation-ioredis": "0.49.0",
"@opentelemetry/instrumentation-nestjs-core": "0.49.0",
"@opentelemetry/instrumentation-pg": "0.49.0",
"@opentelemetry/instrumentation-pino": "0.54.0",
"@opentelemetry/instrumentation-runtime-node": "0.24.0",
"@opentelemetry/resources": "2.0.1",
"@opentelemetry/sdk-node": "0.201.1",
"@opentelemetry/sdk-trace-base": "2.0.1",
"@opentelemetry/semantic-conventions": "1.34.0",
"@opentelemetry/context-async-hooks": "2.10.0",
"@opentelemetry/exporter-logs-otlp-http": "0.221.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.221.0",
"@opentelemetry/exporter-trace-otlp-http": "0.221.0",
"@opentelemetry/instrumentation-express": "0.69.0",
"@opentelemetry/instrumentation-http": "0.221.0",
"@opentelemetry/instrumentation-ioredis": "0.69.0",
"@opentelemetry/instrumentation-nestjs-core": "0.67.0",
"@opentelemetry/instrumentation-pg": "0.73.0",
"@opentelemetry/instrumentation-pino": "0.67.0",
"@opentelemetry/instrumentation-runtime-node": "0.34.0",
"@opentelemetry/resources": "2.10.0",
"@opentelemetry/sdk-node": "0.221.0",
"@opentelemetry/sdk-trace-base": "2.10.0",
"@opentelemetry/semantic-conventions": "1.43.0",
"@orpc/nest": "1.13.0",
"@prisma/client": "6.2.1",
"@prisma/instrumentation": "6.2.1",
Expand Down Expand Up @@ -265,6 +265,7 @@
"passport-openidconnect": "0.1.2",
"pause": "0.1.0",
"pdf-parse": "2.4.5",
"permessage-deflate": "0.1.7",
"pg": "8.11.5",
"pino-http": "10.5.0",
"pino-pretty": "11.0.0",
Expand Down
8 changes: 5 additions & 3 deletions apps/nestjs-backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { PluginModule } from './features/plugin/plugin.module';
import { PluginContextMenuModule } from './features/plugin-context-menu/plugin-context-menu.module';
import { PluginPanelModule } from './features/plugin-panel/plugin-panel.module';
import { RecordHistoryColdModule } from './features/record-history-cold/record-history-cold.module';
import { RecordRemovalColdModule } from './features/record-removal-cold/record-removal-cold.module';
import { SelectionModule } from './features/selection/selection.module';
import { AdminOpenApiModule } from './features/setting/open-api/admin-open-api.module';
import { SettingOpenApiModule } from './features/setting/open-api/setting-open-api.module';
Expand Down Expand Up @@ -102,10 +103,11 @@ export const appModules = {
AiModule,
PluginModule,
PluginPanelModule,
// the ONLY mount of the cold queue CONSUMER: feature modules import
// RecordHistoryColdCoreModule (services only), so auxiliary entrypoints
// composing them never become competing cold-queue workers
// the ONLY mount of the cold queue CONSUMERS: feature modules import
// the Core modules (services only), so auxiliary entrypoints composing
// them never become competing cold-queue workers
RecordHistoryColdModule,
RecordRemovalColdModule,
PluginContextMenuModule,
PluginChartModule,
ObservabilityModule,
Expand Down
11 changes: 8 additions & 3 deletions apps/nestjs-backend/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { isDomainError, toError } from '@teable/v2-core';
import { json, urlencoded } from 'express';
import helmet from 'helmet';
import isPortReachable from 'is-port-reachable';
Expand Down Expand Up @@ -84,9 +85,13 @@ export async function bootstrap() {
logger.log(`> System Time Zone: ${timeZone}`);
logger.log(`> Current System Time: ${now.toString()}`);

process.on('unhandledRejection', (reason: string, promise: Promise<unknown>) => {
logger.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);
throw reason;
process.on('unhandledRejection', (reason: unknown, promise: Promise<unknown>) => {
// DomainError is intentionally a POJO (Result-based, not thrown). If one
// still escapes as an unhandled rejection, wrap it so Sentry gets a real
// stack-bearing Error instead of collapsing into activeSpanWrapper.
const normalized = isDomainError(reason) ? toError(reason) : reason;
logger.error(`Unhandled Rejection at: ${promise}, reason: ${normalized}`);
throw normalized;
});

process.on('uncaughtException', (error) => {
Expand Down
24 changes: 24 additions & 0 deletions apps/nestjs-backend/src/cache/redis-native.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,30 @@ export class RedisNativeService {
});
}

/**
* Batch ZCOUNT via pipeline — single network roundtrip for multiple sorted-set keys.
* @param keys - Array of Redis sorted set keys
* @param min - Minimum score (number or '-inf')
* @param max - Maximum score (number or '+inf')
* @returns Array of counts (0 for missing keys)
* @throws on any per-command error — unknown must not read as zero
*/
async zcountMulti(keys: string[], min: number | string, max: number | string): Promise<number[]> {
if (keys.length === 0) return [];
const pipe = this.client.pipeline();
for (const key of keys) {
pipe.zcount(key, min, max);
}
const replies = await pipe.exec();
return keys.map((key, i) => {
const reply = replies?.[i];
if (!reply) throw new Error(`zcountMulti got no reply for ${key}`);
const [err, count] = reply;
if (err) throw err;
return (count as number) ?? 0;
});
}

/**
* Batch SCARD via pipeline — single network roundtrip for multiple set keys.
* @param keys - Array of Redis set keys
Expand Down
19 changes: 19 additions & 0 deletions apps/nestjs-backend/src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export interface ICacheStore {
[key: `auth:session-store:${string}`]: ISessionData;
[key: `auth:session-user:${string}`]: Record<string, number>;
[key: `auth:session-expire:${string}`]: boolean;
// Epoch seconds of the user's last clearByUserId, kept for the session ttl:
// distinguishes "revoked by sign-out-everywhere" from "lost the concurrent
// read-modify-write on the per-user session map".
[key: `auth:session-user-cleared:${string}`]: number;
[key: `oauth2:${string}`]: IOauth2State;
[key: `reset-password-email:${string}`]: IResetPasswordEmailCache;
[key: `workflow:running:${string}`]: string;
Expand Down Expand Up @@ -125,6 +129,7 @@ export enum OperationName {
UpdateView = 'updateView',
CreateRecords = 'createRecords',
DeleteRecords = 'deleteRecords',
ArchiveRecords = 'archiveRecords',
UpdateRecords = 'updateRecords',
UpdateRecordsOrder = 'updateRecordsOrder',
CreateFields = 'createFields',
Expand Down Expand Up @@ -191,6 +196,19 @@ export interface IDeleteRecordsOperation extends Omit<ICreateRecordsOperation, '
name: OperationName.DeleteRecords;
}

// The archived snapshots stay in record_trash (write-ahead), so the stack entry only
// carries ids: undo restores the rows matched by operationId, redo re-archives by id.
export interface IArchiveRecordsOperation extends IUndoRedoOperationBase {
name: OperationName.ArchiveRecords;
params: {
tableId: string;
};
result: {
recordIds: string[];
};
operationId: string;
}

export interface IConvertFieldOperation extends IUndoRedoOperationBase {
name: OperationName.ConvertField;
params: {
Expand Down Expand Up @@ -294,6 +312,7 @@ export type IUndoRedoOperation =
| IUpdateRecordsOperation
| ICreateRecordsOperation
| IDeleteRecordsOperation
| IArchiveRecordsOperation
| IUpdateRecordsOrderOperation
| ICreateFieldsOperation
| IDeleteFieldsOperation
Expand Down
24 changes: 16 additions & 8 deletions apps/nestjs-backend/src/configs/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
import { resolveSecret } from './secrets/resolve-secret';
import { SECRET_SPECS } from './secrets/secret-specs';

const getCookieSecure = (value: string | undefined) => {
if (!value) {
Expand All @@ -13,17 +15,23 @@ const getCookieSecure = (value: string | undefined) => {
return value === 'true';
};

// Secret resolution and policy live in ./secrets (secret-specs.ts is the
// single source of truth; secrets-policy.ts enforces production policy).
export const authConfig = registerAs('auth', () => ({
jwt: {
secret:
process.env.BACKEND_JWT_SECRET ?? process.env.SECRET_KEY ?? '533Cr3tK3yF0rH4sh1nGJ4W773k3n$',
secret: resolveSecret(SECRET_SPECS.jwtSecret),
// Verify-only fallback for PLANNED rotations of BACKEND_JWT_SECRET:
// TeableJwtService signs with `secret` and verifies against both. A leaked
// secret must be hard-cut (never listed here) — see features/auth/jwt.
oldSecret: process.env.BACKEND_JWT_SECRET_OLD,
expiresIn: process.env.BACKEND_JWT_EXPIRES_IN ?? '20d',
},
session: {
secret:
process.env.BACKEND_SESSION_SECRET ??
process.env.SECRET_KEY ??
'dafea6be69af1c1c3b8caf2b609342f6eb4540b554e19539f7643b75b480c932',
secret: resolveSecret(SECRET_SPECS.sessionSecret),
// Verify-only fallback accepted while rotating BACKEND_SESSION_SECRET.
// express-session validates a signed cookie against every secret in the
// array (first entry signs new cookies), so existing sessions survive.
oldSecret: process.env.BACKEND_SESSION_SECRET_OLD,
expiresIn: process.env.BACKEND_SESSION_EXPIRES_IN ?? '7d',
cookie: {
secure: getCookieSecure(process.env.BACKEND_SESSION_COOKIE_SECURE),
Expand All @@ -33,8 +41,8 @@ export const authConfig = registerAs('auth', () => ({
prefix: 'teable',
encryption: {
algorithm: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_ALGORITHM ?? 'aes-128-cbc',
key: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx9',
iv: process.env.BACKEND_ACCESS_TOKEN_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf4',
key: resolveSecret(SECRET_SPECS.accessTokenEncryptionKey),
iv: resolveSecret(SECRET_SPECS.accessTokenEncryptionIv),
},
},
resetPasswordEmailExpiresIn:
Expand Down
4 changes: 3 additions & 1 deletion apps/nestjs-backend/src/configs/base.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
import { resolveSecret } from './secrets/resolve-secret';
import { SECRET_SPECS } from './secrets/secret-specs';

export const baseConfig = registerAs('base', () => ({
isCloud: process.env.NEXT_BUILD_ENV_EDITION?.toUpperCase() === 'CLOUD',
publicOrigin: process.env.PUBLIC_ORIGIN,
storagePrefix: process.env.STORAGE_PREFIX ?? process.env.PUBLIC_ORIGIN,
secretKey: process.env.SECRET_KEY ?? 'defaultSecretKey',
secretKey: resolveSecret(SECRET_SPECS.secretKey),
publicDatabaseProxy: process.env.PUBLIC_DATABASE_PROXY,
defaultMaxBaseDBConnections: Number(process.env.DEFAULT_MAX_BASE_DB_CONNECTIONS ?? 20),
templateSpaceId: process.env.TEMPLATE_SPACE_ID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export const computedOutboxTriggerConfig = registerAs('computedOutboxTrigger', (
process.env.V2_COMPUTED_OUTBOX_MONITOR_INTERVAL_MS,
30_000
),
// Caps how many wakeups one redrive scan publishes per target. A resumed
// pause or long outage can leave weeks of backlog; draining it in a single
// sweep floods the claim path and the data-db pool. The remainder is
// picked up by the following reconcile cycles.
redriveMaxPublishPerTarget: readPositiveInteger(
process.env.V2_COMPUTED_OUTBOX_REDRIVE_MAX_PUBLISH_PER_TARGET,
1000
),
};
});

Expand Down
30 changes: 25 additions & 5 deletions apps/nestjs-backend/src/configs/config.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
import fs from 'fs';
import path from 'path';
import type { DynamicModule } from '@nestjs/common';
import { Logger, Module } from '@nestjs/common';
Expand All @@ -13,6 +14,7 @@ import { loggerConfig } from './logger.config';
import { mailConfig } from './mail.config';
import { oauthConfig } from './oauth.config';
import { riskControlConfig } from './risk-control.config';
import { enforceSecretsPolicy } from './secrets/secrets-policy';
import { storageConfig } from './storage';
import { thresholdConfig } from './threshold.config';
import { trashConfig } from './trash.config';
Expand All @@ -32,24 +34,42 @@ const configurations = [
riskControlConfig,
];

// The env files live in the nextjs-app package. NEXTJS_DIR is relative to the
// backend package dir, but the process may be started from the repo root (make,
// IDE run configs) — probe the known anchors instead of trusting cwd, since a
// silently unresolved path now means missing secrets instead of falling back
// to (removed) source-code defaults.
const resolveEnvFileDir = (): string => {
const nextJsDir = nextJsConfig().dir;
const candidates = [
path.join(process.cwd(), nextJsDir),
path.join(process.cwd(), 'community/apps/nextjs-app'),
Comment on lines +45 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe the actual workspace env directory

When the backend is started from the repository root with NEXTJS_DIR unset, the default ../nextjs-app resolves outside the repository and the fallback probes community/apps/nextjs-app, while this checkout actually stores the files under apps/nextjs-app. Consequently none of .env.development.local, .env.development, or .env is loaded, and the newly enforced secret policy aborts startup for the repo-root/IDE invocation that this resolver explicitly intends to support. Include apps/nextjs-app as a root-relative candidate or resolve the default relative to the backend package.

Useful? React with 👍 / 👎.

];
return candidates.find((dir) => fs.existsSync(dir)) ?? candidates[0];
};

@Module({})
export class ConfigModule {
static register(): DynamicModule {
return BaseConfigModule.forRoot({
const envDir = resolveEnvFileDir();
const dynamicModule = BaseConfigModule.forRoot({
isGlobal: true,
cache: true,
expandVariables: true,
load: configurations,
envFilePath: ['.env.development.local', '.env.development', '.env'].map((str) => {
const nextJsDir = nextJsConfig().dir;
const envDir = nextJsDir ? path.join(process.cwd(), nextJsDir, str) : str;
const envFile = path.join(envDir, str);

Logger.attachBuffer();
Logger.log(`[Env File Path]: ${envDir}`);
Logger.log(`[Env File Path]: ${envFile}`);
Logger.detachBuffer();
return envDir;
return envFile;
}),
validationSchema: envValidationSchema,
});
// forRoot has synchronously merged the env files into process.env; enforce
// the secrets policy now, before any config factory resolves a secret.
enforceSecretsPolicy();
return dynamicModule;
}
}
17 changes: 17 additions & 0 deletions apps/nestjs-backend/src/configs/env.validation.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ export const envValidationSchema = Joi.object({

PUBLIC_ORIGIN: Joi.string().uri().required(),

// secrets — shape only; production requirements and migration teaching are
// enforced by enforceSecretsPolicy (configs/secrets/secrets-policy.ts)
SECRET_KEY: Joi.string().optional(),
BACKEND_JWT_SECRET: Joi.string().optional(),
BACKEND_JWT_SECRET_OLD: Joi.string().optional(),
BACKEND_SESSION_SECRET: Joi.string().optional(),
BACKEND_SESSION_SECRET_OLD: Joi.string().optional(),
BACKEND_ACCESS_TOKEN_ENCRYPTION_KEY: Joi.string().optional(),
BACKEND_ACCESS_TOKEN_ENCRYPTION_IV: Joi.string().optional(),
BACKEND_DATA_DB_URL_ENCRYPTION_KEY: Joi.string().optional(),
BACKEND_DATA_DB_URL_ENCRYPTION_IV: Joi.string().optional(),
BACKEND_MAIL_ENCRYPTION_KEY: Joi.string().optional(),
BACKEND_MAIL_ENCRYPTION_IV: Joi.string().optional(),
BACKEND_STORAGE_ENCRYPTION_KEY: Joi.string().optional(),
BACKEND_STORAGE_ENCRYPTION_IV: Joi.string().optional(),

// Express `trust proxy`: 'true' | 'false' | hop count | IP/CIDR/preset list.
// Unset = trust private-network proxies (see parseTrustProxy in bootstrap.config).
BACKEND_TRUST_PROXY: Joi.string().optional(),
Expand All @@ -49,6 +65,7 @@ export const envValidationSchema = Joi.object({
V2_COMPUTED_OUTBOX_TRIGGER_PUBLISH_TIMEOUT_MS: Joi.number().integer().positive().default(1000),
V2_COMPUTED_OUTBOX_MONITOR_CONCURRENCY: Joi.number().integer().positive().default(4),
V2_COMPUTED_OUTBOX_MONITOR_INTERVAL_MS: Joi.number().integer().positive().default(30000),
// Computed stage budget overrides (0 disables that dimension; all 0 = no staging)

// per-space scheduling default concurrency limits
SPACE_AI_FIELD_GENERATION_DEFAULT_LIMIT: Joi.number().integer().positive().optional(),
Expand Down
6 changes: 4 additions & 2 deletions apps/nestjs-backend/src/configs/mail.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import { Inject } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import { registerAs } from '@nestjs/config';
import { resolveSecret } from './secrets/resolve-secret';
import { SECRET_SPECS } from './secrets/secret-specs';

export const mailConfig = registerAs('mail', () => {
const host = process.env.BACKEND_MAIL_HOST;
Expand Down Expand Up @@ -38,8 +40,8 @@ export const mailConfig = registerAs('mail', () => {
dnsTimeout: parseInt(process.env.BACKEND_MAIL_DNS_TIMEOUT ?? '5000', 10),
encryption: {
algorithm: 'aes-128-cbc',
key: process.env.BACKEND_MAIL_ENCRYPTION_KEY ?? 'ie21hOKjlXUiGDx1',
iv: process.env.BACKEND_MAIL_ENCRYPTION_IV ?? 'i0vKGXBWkzyAoGf1',
key: resolveSecret(SECRET_SPECS.mailEncryptionKey),
iv: resolveSecret(SECRET_SPECS.mailEncryptionIv),
encoding: 'base64' as BufferEncoding,
},
};
Expand Down
Loading
Loading