forked from wangziqi/gongxue-base
feat: establish production SaaS foundation
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
FROM node:20-alpine AS deps
|
||||
ARG NODE_IMAGE=node:20.20.2-alpine3.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
|
||||
|
||||
FROM ${NODE_IMAGE} AS deps
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -9,7 +11,11 @@ COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
RUN npm ci --workspaces --include-workspace-root
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
FROM deps AS production-deps
|
||||
RUN npm prune --omit=dev --workspaces --include-workspace-root \
|
||||
&& npm cache clean --force
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -17,18 +23,13 @@ COPY apps/api ./apps/api
|
||||
COPY packages ./packages
|
||||
RUN npm run build:api
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
FROM ${NODE_IMAGE} AS runner
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/api/dist ./apps/api/dist
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/config/package.json packages/config/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/domain/package.json packages/domain/package.json
|
||||
COPY scripts/import-pocketbase/package.json scripts/import-pocketbase/package.json
|
||||
COPY --from=production-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /app/apps/api/dist ./apps/api/dist
|
||||
|
||||
EXPOSE 8787
|
||||
CMD ["npm", "--workspace", "@tiku-saas/api", "run", "start"]
|
||||
USER node
|
||||
CMD ["node", "apps/api/dist/apps/api/src/server.js"]
|
||||
|
||||
@@ -43,9 +43,11 @@ export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
type QueryOne = <T = unknown>(sql: string, params?: unknown[]) => Promise<T | null>;
|
||||
|
||||
export async function findUserBySessionToken(token: string, queryUser: QueryOne = queryOne) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<SessionIdentity>(
|
||||
return queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
@@ -55,10 +57,24 @@ export async function findUserBySessionToken(token: string) {
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
join public.tenants t on t.id = s.tenant_id
|
||||
where s.token_hash = $1
|
||||
and u.status = 'active'
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
and (
|
||||
u.primary_role = 'platform_admin'
|
||||
or (
|
||||
t.status = 'active'
|
||||
and exists (
|
||||
select 1
|
||||
from public.tenant_memberships tm
|
||||
where tm.tenant_id = s.tenant_id
|
||||
and tm.user_id = s.user_id
|
||||
and tm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
@@ -105,66 +121,48 @@ function tenantClaimFrom(payload: JWTPayload) {
|
||||
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
|
||||
}
|
||||
|
||||
function appRoleClaimFrom(payload: JWTPayload) {
|
||||
const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role;
|
||||
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
|
||||
}
|
||||
|
||||
function sessionExpiryFrom(payload: JWTPayload) {
|
||||
return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString();
|
||||
}
|
||||
|
||||
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
payload = await verifySupabaseJwt(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findUserByVerifiedSupabasePayload(
|
||||
payload: JWTPayload,
|
||||
requestedTenantContext = '',
|
||||
queryUser: QueryOne = queryOne,
|
||||
) {
|
||||
const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : '';
|
||||
if (!authUserId) return null;
|
||||
|
||||
const tenantClaim = tenantClaimFrom(payload);
|
||||
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
|
||||
const requestedTenantId = tenantClaim || requestedTenantContext;
|
||||
const appRole = appRoleClaimFrom(payload);
|
||||
|
||||
const platformUser = await queryOne<SessionIdentity>(
|
||||
const platformUser = await queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
coalesce($2::uuid, tm.tenant_id) as "tenantId",
|
||||
null::uuid as "tenantId",
|
||||
$1::text as "sessionId",
|
||||
$3::timestamptz as "sessionExpiresAt",
|
||||
$2::timestamptz as "sessionExpiresAt",
|
||||
'supabase_jwt'::text as "authSource",
|
||||
u.auth_user_id as "authUserId",
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and u.primary_role = 'platform_admin'
|
||||
and ($2::uuid is null or exists (
|
||||
select 1
|
||||
from public.tenant_memberships scoped_tm
|
||||
where scoped_tm.user_id = u.id
|
||||
and scoped_tm.tenant_id = $2::uuid
|
||||
and scoped_tm.status = 'active'
|
||||
))
|
||||
order by tm.created_at asc nulls last
|
||||
limit 1
|
||||
`,
|
||||
[authUserId, requestedTenantId || null, sessionExpiryFrom(payload)],
|
||||
[authUserId, sessionExpiryFrom(payload)],
|
||||
);
|
||||
|
||||
if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) {
|
||||
return platformUser;
|
||||
}
|
||||
// Supabase's top-level role is normally "authenticated". Platform authority
|
||||
// comes exclusively from the active database user, never from JWT role claims.
|
||||
if (platformUser) return platformUser;
|
||||
|
||||
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
|
||||
if (!requestedTenantId) return null;
|
||||
|
||||
return queryOne<SessionIdentity>(
|
||||
return queryUser<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
@@ -176,8 +174,10 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
u.platform_permissions as "platformPermissions"
|
||||
from public.platform_users u
|
||||
join public.tenant_memberships tm on tm.user_id = u.id
|
||||
join public.tenants t on t.id = tm.tenant_id
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and t.status = 'active'
|
||||
and tm.status = 'active'
|
||||
and tm.tenant_id = $2::uuid
|
||||
order by case
|
||||
@@ -192,6 +192,16 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
);
|
||||
}
|
||||
|
||||
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
|
||||
let payload: JWTPayload;
|
||||
try {
|
||||
payload = await verifySupabaseJwt(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return findUserByVerifiedSupabasePayload(payload, requestedTenantContext);
|
||||
}
|
||||
|
||||
export async function hydrateRequestAuth(ctx: RequestContext) {
|
||||
const cached = requestAuthState.get(ctx);
|
||||
if (cached?.sessionResolved) return cached;
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface ApiConfig {
|
||||
databaseUrl: string;
|
||||
defaultTenantSlug: string;
|
||||
corsOrigins: string[];
|
||||
corsTenantDomainsEnabled: boolean;
|
||||
corsTenantDomainCacheTtlMs: number;
|
||||
corsTenantDomainNegativeCacheTtlMs: number;
|
||||
corsTenantDomainCacheMaxEntries: number;
|
||||
maxJsonBodyBytes: number;
|
||||
maxImportJsonBodyBytes: number;
|
||||
authCodePepper: string;
|
||||
@@ -17,7 +21,16 @@ export interface ApiConfig {
|
||||
authJwtJwksUrl: string;
|
||||
authCodeTtlSeconds: number;
|
||||
authSmsCooldownSeconds: number;
|
||||
authSmsTenantDailyLimit: number;
|
||||
authSmsPhoneDailyLimit: number;
|
||||
authSmsIpHourlyLimit: number;
|
||||
authSmsDeviceHourlyLimit: number;
|
||||
authSessionTtlSeconds: number;
|
||||
apiHeadersTimeoutMs: number;
|
||||
apiRequestTimeoutMs: number;
|
||||
apiKeepAliveTimeoutMs: number;
|
||||
apiShutdownGracePeriodMs: number;
|
||||
apiMaxRequestsPerSocket: number;
|
||||
allowLegacyAuthHeaders: boolean;
|
||||
allowPlatformAdminKey: boolean;
|
||||
platformAdminApiKey: string;
|
||||
@@ -66,6 +79,12 @@ function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BOD
|
||||
return Math.min(Math.trunc(value), hardMax);
|
||||
}
|
||||
|
||||
function boundedPositiveNumber(key: string, fallback: number, min: number, max: number) {
|
||||
const value = envNumber(key, fallback);
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.max(min, Math.min(max, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function isUnsafeSecret(value: string, defaultValue: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return (
|
||||
@@ -100,11 +119,35 @@ function isAllowedHost(value: string, allowedHosts: string[]) {
|
||||
return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`));
|
||||
}
|
||||
|
||||
function isProductionCorsOrigin(value: string) {
|
||||
try {
|
||||
const parsed = new URL(value.trim());
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const localHost = isLocalHost(host) || host.endsWith('.localhost') || /^127\./.test(host);
|
||||
return (
|
||||
parsed.protocol === 'https:'
|
||||
&& !parsed.username
|
||||
&& !parsed.password
|
||||
&& parsed.pathname === '/'
|
||||
&& !parsed.search
|
||||
&& !parsed.hash
|
||||
&& Boolean(host)
|
||||
&& !localHost
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateProductionConfig(nextConfig: ApiConfig) {
|
||||
if (!nextConfig.isProduction) return;
|
||||
|
||||
const failures: string[] = [];
|
||||
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
|
||||
if (nextConfig.corsOrigins.length === 0 || nextConfig.corsOrigins.some(origin => !isProductionCorsOrigin(origin))) {
|
||||
failures.push('CORS_ORIGIN must contain only production HTTPS origins without paths, query strings or credentials');
|
||||
}
|
||||
if (!nextConfig.corsTenantDomainsEnabled) failures.push('CORS_TENANT_DOMAINS_ENABLED must be true in production');
|
||||
if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase().replace(/[_\s]/g, '-'))) {
|
||||
failures.push('AUTH_SMS_PROVIDER must be aliyun-pnvs in production');
|
||||
}
|
||||
@@ -185,6 +228,10 @@ const loadedConfig: ApiConfig = {
|
||||
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
|
||||
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
|
||||
corsOrigins: envList('CORS_ORIGIN', '*'),
|
||||
corsTenantDomainsEnabled: envBoolean('CORS_TENANT_DOMAINS_ENABLED', false),
|
||||
corsTenantDomainCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_TTL_MS', 60_000, 1_000, 600_000),
|
||||
corsTenantDomainNegativeCacheTtlMs: boundedPositiveNumber('CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', 10_000, 1_000, 300_000),
|
||||
corsTenantDomainCacheMaxEntries: boundedPositiveNumber('CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', 10_000, 100, 100_000),
|
||||
maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES),
|
||||
maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES),
|
||||
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
|
||||
@@ -196,7 +243,16 @@ const loadedConfig: ApiConfig = {
|
||||
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
|
||||
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||
authSmsTenantDailyLimit: boundedPositiveNumber('AUTH_SMS_TENANT_DAILY_LIMIT', 20_000, 1, 10_000_000),
|
||||
authSmsPhoneDailyLimit: boundedPositiveNumber('AUTH_SMS_PHONE_DAILY_LIMIT', 10, 1, 10_000),
|
||||
authSmsIpHourlyLimit: boundedPositiveNumber('AUTH_SMS_IP_HOURLY_LIMIT', 120, 1, 1_000_000),
|
||||
authSmsDeviceHourlyLimit: boundedPositiveNumber('AUTH_SMS_DEVICE_HOURLY_LIMIT', 10, 1, 100_000),
|
||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||
apiHeadersTimeoutMs: boundedPositiveNumber('API_HEADERS_TIMEOUT_MS', 15_000, 1_000, 120_000),
|
||||
apiRequestTimeoutMs: boundedPositiveNumber('API_REQUEST_TIMEOUT_MS', 120_000, 5_000, 600_000),
|
||||
apiKeepAliveTimeoutMs: boundedPositiveNumber('API_KEEP_ALIVE_TIMEOUT_MS', 5_000, 1_000, 120_000),
|
||||
apiShutdownGracePeriodMs: boundedPositiveNumber('API_SHUTDOWN_GRACE_PERIOD_MS', 30_000, 1_000, 300_000),
|
||||
apiMaxRequestsPerSocket: boundedPositiveNumber('API_MAX_REQUESTS_PER_SOCKET', 1_000, 1, 100_000),
|
||||
allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction),
|
||||
allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction),
|
||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),
|
||||
|
||||
225
apps/api/src/core/cors.ts
Normal file
225
apps/api/src/core/cors.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { queryOne } from './db.js';
|
||||
import { isLocalTenantHost } from '../features/tenant/locator.js';
|
||||
|
||||
export type CorsDecisionReason =
|
||||
| 'no-origin'
|
||||
| 'wildcard'
|
||||
| 'static-origin'
|
||||
| 'tenant-domain'
|
||||
| 'invalid-origin'
|
||||
| 'tenant-domain-disabled'
|
||||
| 'tenant-domain-lookup-failed';
|
||||
|
||||
export interface CorsDecision {
|
||||
allowed: boolean;
|
||||
allowOrigin: string | null;
|
||||
reason: CorsDecisionReason;
|
||||
}
|
||||
|
||||
export interface CorsPolicyOptions {
|
||||
staticOrigins: string[];
|
||||
tenantDomainsEnabled: boolean;
|
||||
positiveCacheTtlMs: number;
|
||||
negativeCacheTtlMs: number;
|
||||
maxCacheEntries: number;
|
||||
lookupTenantDomain?: (host: string) => Promise<boolean>;
|
||||
now?: () => number;
|
||||
onLookupError?: (error: unknown, host: string) => void;
|
||||
}
|
||||
|
||||
interface NormalizedOrigin {
|
||||
origin: string;
|
||||
hostname: string;
|
||||
protocol: 'http:' | 'https:';
|
||||
hasNonDefaultPort: boolean;
|
||||
}
|
||||
|
||||
interface CachedTenantDomain {
|
||||
allowed: boolean;
|
||||
expiresAt: number;
|
||||
lookupFailed: boolean;
|
||||
}
|
||||
|
||||
const CORS_ALLOW_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
||||
const CORS_ALLOW_HEADERS = 'content-type,authorization,x-request-id,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key';
|
||||
|
||||
function firstHeader(req: IncomingMessage, name: string) {
|
||||
if (name.toLowerCase() === 'origin') {
|
||||
const originHeaders = req.rawHeaders.filter((value, index) => index % 2 === 0 && value.toLowerCase() === 'origin');
|
||||
if (originHeaders.length > 1) return '__multiple_origin_headers__';
|
||||
}
|
||||
const value = req.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0] || '';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function addVaryHeader(res: ServerResponse, value: string) {
|
||||
const current = res.getHeader('vary');
|
||||
const values = (Array.isArray(current) ? current : String(current || '').split(','))
|
||||
.map(item => String(item).trim())
|
||||
.filter(Boolean);
|
||||
if (!values.some(item => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
||||
res.setHeader('vary', values.join(', '));
|
||||
}
|
||||
|
||||
export function normalizeCorsOrigin(value: string): NormalizedOrigin | null {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw === 'null' || raw.includes(',') || /\s/.test(raw)) return null;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
||||
if (parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
|
||||
if (!parsed.hostname || parsed.hostname.endsWith('.')) return null;
|
||||
|
||||
const protocol = parsed.protocol as 'http:' | 'https:';
|
||||
const defaultPort = protocol === 'https:' ? '443' : '80';
|
||||
return {
|
||||
origin: parsed.origin.toLowerCase(),
|
||||
hostname: parsed.hostname.toLowerCase(),
|
||||
protocol,
|
||||
hasNonDefaultPort: Boolean(parsed.port && parsed.port !== defaultPort),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupActiveTenantDomain(host: string) {
|
||||
const row = await queryOne<{ allowed: boolean }>(
|
||||
`
|
||||
select exists (
|
||||
select 1
|
||||
from public.tenant_domains d
|
||||
join public.tenants t on t.id = d.tenant_id
|
||||
where d.host = $1
|
||||
and d.status = 'active'
|
||||
and t.status = 'active'
|
||||
) as allowed
|
||||
`,
|
||||
[host],
|
||||
);
|
||||
return row?.allowed === true;
|
||||
}
|
||||
|
||||
export class CorsPolicy {
|
||||
private readonly staticOrigins: Set<string>;
|
||||
private readonly allowAll: boolean;
|
||||
private readonly tenantDomainsEnabled: boolean;
|
||||
private readonly positiveCacheTtlMs: number;
|
||||
private readonly negativeCacheTtlMs: number;
|
||||
private readonly maxCacheEntries: number;
|
||||
private readonly lookupTenantDomain: (host: string) => Promise<boolean>;
|
||||
private readonly now: () => number;
|
||||
private readonly onLookupError?: (error: unknown, host: string) => void;
|
||||
private readonly cache = new Map<string, CachedTenantDomain>();
|
||||
private readonly pendingLookups = new Map<string, Promise<{ allowed: boolean; lookupFailed: boolean }>>();
|
||||
|
||||
constructor(options: CorsPolicyOptions) {
|
||||
this.allowAll = options.staticOrigins.includes('*');
|
||||
this.staticOrigins = new Set(
|
||||
options.staticOrigins
|
||||
.filter(origin => origin !== '*')
|
||||
.map(origin => normalizeCorsOrigin(origin)?.origin || '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
this.tenantDomainsEnabled = options.tenantDomainsEnabled;
|
||||
this.positiveCacheTtlMs = options.positiveCacheTtlMs;
|
||||
this.negativeCacheTtlMs = options.negativeCacheTtlMs;
|
||||
this.maxCacheEntries = options.maxCacheEntries;
|
||||
this.lookupTenantDomain = options.lookupTenantDomain || lookupActiveTenantDomain;
|
||||
this.now = options.now || Date.now;
|
||||
this.onLookupError = options.onLookupError;
|
||||
}
|
||||
|
||||
async evaluate(rawOrigin: string): Promise<CorsDecision> {
|
||||
if (!rawOrigin.trim()) return { allowed: true, allowOrigin: null, reason: 'no-origin' };
|
||||
|
||||
const normalized = normalizeCorsOrigin(rawOrigin);
|
||||
if (!normalized) return { allowed: false, allowOrigin: null, reason: 'invalid-origin' };
|
||||
if (this.allowAll) return { allowed: true, allowOrigin: '*', reason: 'wildcard' };
|
||||
if (this.staticOrigins.has(normalized.origin)) {
|
||||
return { allowed: true, allowOrigin: normalized.origin, reason: 'static-origin' };
|
||||
}
|
||||
|
||||
// Tenant browser domains are production HTTPS hosts. Development ports belong
|
||||
// in the explicit static allowlist and must never be inferred from Host headers.
|
||||
if (
|
||||
!this.tenantDomainsEnabled
|
||||
|| normalized.protocol !== 'https:'
|
||||
|| normalized.hasNonDefaultPort
|
||||
|| isLocalTenantHost(normalized.hostname)
|
||||
) {
|
||||
return { allowed: false, allowOrigin: null, reason: 'tenant-domain-disabled' };
|
||||
}
|
||||
|
||||
const lookup = await this.lookupWithCache(normalized.hostname);
|
||||
if (!lookup.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
allowOrigin: null,
|
||||
reason: lookup.lookupFailed ? 'tenant-domain-lookup-failed' : 'tenant-domain-disabled',
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true, allowOrigin: normalized.origin, reason: 'tenant-domain' };
|
||||
}
|
||||
|
||||
private async lookupWithCache(host: string) {
|
||||
const now = this.now();
|
||||
const cached = this.cache.get(host);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
this.cache.delete(host);
|
||||
this.cache.set(host, cached);
|
||||
return { allowed: cached.allowed, lookupFailed: cached.lookupFailed };
|
||||
}
|
||||
if (cached) this.cache.delete(host);
|
||||
|
||||
const pending = this.pendingLookups.get(host);
|
||||
if (pending) return pending;
|
||||
|
||||
const lookup = this.performLookup(host);
|
||||
this.pendingLookups.set(host, lookup);
|
||||
try {
|
||||
return await lookup;
|
||||
} finally {
|
||||
this.pendingLookups.delete(host);
|
||||
}
|
||||
}
|
||||
|
||||
private async performLookup(host: string) {
|
||||
let allowed = false;
|
||||
let lookupFailed = false;
|
||||
try {
|
||||
allowed = await this.lookupTenantDomain(host);
|
||||
} catch (error) {
|
||||
lookupFailed = true;
|
||||
this.onLookupError?.(error, host);
|
||||
}
|
||||
|
||||
const ttlMs = allowed ? this.positiveCacheTtlMs : this.negativeCacheTtlMs;
|
||||
this.storeCache(host, { allowed, expiresAt: this.now() + ttlMs, lookupFailed });
|
||||
return { allowed, lookupFailed };
|
||||
}
|
||||
|
||||
private storeCache(host: string, entry: CachedTenantDomain) {
|
||||
this.cache.delete(host);
|
||||
while (this.cache.size >= this.maxCacheEntries) {
|
||||
const oldest = this.cache.keys().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
this.cache.set(host, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeCorsRequest(req: IncomingMessage, res: ServerResponse, policy: CorsPolicy) {
|
||||
const decision = await policy.evaluate(firstHeader(req, 'origin'));
|
||||
res.setHeader('access-control-allow-methods', CORS_ALLOW_METHODS);
|
||||
res.setHeader('access-control-allow-headers', CORS_ALLOW_HEADERS);
|
||||
res.setHeader('access-control-expose-headers', 'x-request-id');
|
||||
if (decision.allowOrigin) res.setHeader('access-control-allow-origin', decision.allowOrigin);
|
||||
if (decision.reason !== 'wildcard') addVaryHeader(res, 'Origin');
|
||||
return decision;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { DEFAULT_DATABASE_URL } from '../../../../packages/config/src/index.js';
|
||||
|
||||
export const pool = createPool({
|
||||
connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
max: 10,
|
||||
applicationName: 'tiku-api',
|
||||
});
|
||||
|
||||
export async function query<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
|
||||
@@ -9,10 +9,29 @@ export interface RequestContext {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
url: URL;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export type Handler = (ctx: RequestContext) => Promise<unknown>;
|
||||
|
||||
export interface ApiResponseMeta {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function withResponseMeta(body: unknown, requestId: string) {
|
||||
if (body && typeof body === 'object' && !Array.isArray(body)) {
|
||||
const record = body as Record<string, unknown>;
|
||||
const existingMeta = record.meta && typeof record.meta === 'object' && !Array.isArray(record.meta)
|
||||
? record.meta as Record<string, unknown>
|
||||
: {};
|
||||
return {
|
||||
...record,
|
||||
meta: { ...existingMeta, requestId },
|
||||
};
|
||||
}
|
||||
return { data: body, meta: { requestId } };
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||
@@ -25,19 +44,6 @@ export function getHeader(req: IncomingMessage, name: string): string {
|
||||
return value || '';
|
||||
}
|
||||
|
||||
export function applyCors(req: IncomingMessage, res: ServerResponse) {
|
||||
const origin = getHeader(req, 'origin');
|
||||
const allowAll = config.corsOrigins.includes('*');
|
||||
if (allowAll) {
|
||||
res.setHeader('access-control-allow-origin', '*');
|
||||
} else if (origin && config.corsOrigins.includes(origin)) {
|
||||
res.setHeader('access-control-allow-origin', origin);
|
||||
res.setHeader('vary', 'origin');
|
||||
}
|
||||
res.setHeader('access-control-allow-methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
res.setHeader('access-control-allow-headers', 'content-type,authorization,x-tenant-id,x-tenant-code,x-user-id,x-platform-admin-key');
|
||||
}
|
||||
|
||||
export function routeKey(method: string | undefined, pathname: string) {
|
||||
return `${method || 'GET'} ${pathname}`;
|
||||
}
|
||||
|
||||
10
apps/api/src/core/request-id.ts
Normal file
10
apps/api/src/core/request-id.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { getHeader } from './http.js';
|
||||
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export function requestIdFrom(req: IncomingMessage) {
|
||||
const provided = getHeader(req, 'x-request-id').trim();
|
||||
return REQUEST_ID_PATTERN.test(provided) ? provided : crypto.randomUUID();
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
writeLoginEvent,
|
||||
type PlatformUserSummary,
|
||||
} from './service.js';
|
||||
import { hashSmsDeviceId, normalizeSmsDeviceId, reserveSmsSend } from './sms-limits.js';
|
||||
|
||||
interface SmsCodeRow {
|
||||
id: string;
|
||||
@@ -37,10 +38,6 @@ interface SmsCodeRow {
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface CooldownRow {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type SmsVerifyResult =
|
||||
| {
|
||||
ok: false;
|
||||
@@ -265,29 +262,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const metadata = jsonObject(body.metadata);
|
||||
|
||||
const cooldownRows = await query<CooldownRow>(
|
||||
`
|
||||
select created_at as "createdAt"
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
and created_at > now() - ($4::text || ' seconds')::interval
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, phone, purpose, config.authSmsCooldownSeconds],
|
||||
);
|
||||
|
||||
const cooldownRow = cooldownRows[0];
|
||||
if (cooldownRow) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - new Date(cooldownRow.createdAt).getTime()) / 1000);
|
||||
const cooldown = Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
|
||||
throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN');
|
||||
}
|
||||
const deviceId = normalizeSmsDeviceId(optionalString(body, 'deviceId'));
|
||||
const deviceHash = hashSmsDeviceId(deviceId);
|
||||
|
||||
const providerChoice = await activeSmsProvider(tenantId);
|
||||
const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig);
|
||||
@@ -297,50 +273,89 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
|
||||
const code = generateSmsCode();
|
||||
const outId = crypto.randomUUID();
|
||||
const providerResult = await provider.send({
|
||||
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
const reservedExpiresAt = new Date(Date.now() + config.authCodeTtlSeconds * 1000).toISOString();
|
||||
const reservation = await transaction(client => reserveSmsSend(client, {
|
||||
tenantId,
|
||||
phone,
|
||||
code,
|
||||
purpose,
|
||||
codeHash,
|
||||
provider: provider.name,
|
||||
expiresAt: reservedExpiresAt,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
deviceHash,
|
||||
outId,
|
||||
ttlSeconds: config.authCodeTtlSeconds,
|
||||
cooldownSeconds: config.authSmsCooldownSeconds,
|
||||
metadata,
|
||||
});
|
||||
}));
|
||||
|
||||
let providerResult;
|
||||
try {
|
||||
providerResult = await provider.send({
|
||||
tenantId,
|
||||
phone,
|
||||
code,
|
||||
purpose,
|
||||
outId,
|
||||
ttlSeconds: config.authCodeTtlSeconds,
|
||||
cooldownSeconds: config.authSmsCooldownSeconds,
|
||||
metadata,
|
||||
});
|
||||
} catch (error) {
|
||||
await transaction(async client => {
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set metadata = metadata || $3::jsonb
|
||||
where tenant_id = $1 and id = $2 and status = 'pending'
|
||||
`,
|
||||
[tenantId, reservation.id, JSON.stringify({ providerSendFailedAt: new Date().toISOString() })],
|
||||
);
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
provider: `sms:${provider.name}`,
|
||||
identifier: phone,
|
||||
result: 'failed',
|
||||
failureCode: 'SMS_PROVIDER_SEND_FAILED',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: { purpose, reservationId: reservation.id },
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const ttlSeconds = Number.isFinite(providerResult.ttlSeconds) && Number(providerResult.ttlSeconds) > 0
|
||||
? Math.trunc(Number(providerResult.ttlSeconds))
|
||||
: config.authCodeTtlSeconds;
|
||||
const codeHash = hashSmsCode(tenantId, phone, purpose, code);
|
||||
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const insertResult = await client.query(
|
||||
const updateResult = await client.query(
|
||||
`
|
||||
insert into public.sms_verification_codes (
|
||||
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'sent', $6::timestamptz, $7, $8, $9::jsonb)
|
||||
update public.sms_verification_codes
|
||||
set provider = $3,
|
||||
status = 'sent',
|
||||
expires_at = $4::timestamptz,
|
||||
metadata = metadata || $5::jsonb
|
||||
where tenant_id = $1 and id = $2 and status = 'pending'
|
||||
returning id, phone, purpose, provider, status, expires_at as "expiresAt", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
phone,
|
||||
purpose,
|
||||
codeHash,
|
||||
reservation.id,
|
||||
providerResult.provider,
|
||||
expiresAt,
|
||||
ipAddress || null,
|
||||
userAgent || null,
|
||||
JSON.stringify({
|
||||
...metadata,
|
||||
outId,
|
||||
verification: providerResult.verification || 'local',
|
||||
providerStatus: providerResult.status,
|
||||
providerMessageId: providerResult.providerMessageId || null,
|
||||
reservation: false,
|
||||
}),
|
||||
],
|
||||
);
|
||||
if (!updateResult.rows[0]) {
|
||||
throw new HttpError(409, 'SMS send reservation is no longer active', 'SMS_RESERVATION_LOST');
|
||||
}
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
@@ -352,7 +367,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
metadata: { purpose },
|
||||
});
|
||||
|
||||
return insertResult.rows[0];
|
||||
return updateResult.rows[0];
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,12 @@ export interface LoginSessionSummary {
|
||||
|
||||
export function clientIpFrom(ctx: RequestContext) {
|
||||
const forwarded = getHeader(ctx.req, 'x-forwarded-for');
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || ctx.req.socket.remoteAddress || '').trim();
|
||||
const remoteAddress = (ctx.req.socket.remoteAddress || '').trim();
|
||||
const trustedProxy = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(remoteAddress);
|
||||
if (trustedProxy) {
|
||||
return (forwarded.split(',')[0] || getHeader(ctx.req, 'x-real-ip') || remoteAddress).trim();
|
||||
}
|
||||
return remoteAddress;
|
||||
}
|
||||
|
||||
export function userAgentFrom(ctx: RequestContext) {
|
||||
@@ -101,6 +106,44 @@ export async function createLoginSession(
|
||||
metadata?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<LoginSessionSummary> {
|
||||
const authority = await client.query<{ tenantStatus: string; userStatus: string; primaryRole: string }>(
|
||||
`
|
||||
select t.status as "tenantStatus",
|
||||
u.status as "userStatus",
|
||||
u.primary_role as "primaryRole"
|
||||
from public.tenants t
|
||||
join public.platform_users u on u.id = $2::uuid
|
||||
where t.id = $1::uuid
|
||||
for update of t, u
|
||||
`,
|
||||
[input.tenantId, input.userId],
|
||||
);
|
||||
const authorityRow = authority.rows[0];
|
||||
if (!authorityRow || authorityRow.userStatus !== 'active') {
|
||||
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
|
||||
}
|
||||
if (authorityRow.primaryRole !== 'platform_admin') {
|
||||
if (authorityRow.tenantStatus !== 'active') {
|
||||
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
|
||||
}
|
||||
const membership = await client.query<{ status: string }>(
|
||||
`
|
||||
select status
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1::uuid
|
||||
and user_id = $2::uuid
|
||||
and status = 'active'
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[input.tenantId, input.userId],
|
||||
);
|
||||
if (!membership.rows[0]) {
|
||||
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
|
||||
}
|
||||
}
|
||||
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + config.authSessionTtlSeconds * 1000).toISOString();
|
||||
@@ -393,15 +436,51 @@ export async function upsertOAuthUser(
|
||||
}
|
||||
|
||||
export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) {
|
||||
await client.query(
|
||||
const authority = await client.query<{ tenantStatus: string; userStatus: string }>(
|
||||
`
|
||||
select t.status as "tenantStatus", u.status as "userStatus"
|
||||
from public.tenants t
|
||||
join public.platform_users u on u.id = $2::uuid
|
||||
where t.id = $1::uuid
|
||||
for update of t, u
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const authorityRow = authority.rows[0];
|
||||
if (!authorityRow || authorityRow.userStatus !== 'active') {
|
||||
throw new HttpError(403, 'Account is disabled', 'AUTH_USER_INACTIVE');
|
||||
}
|
||||
if (authorityRow.tenantStatus !== 'active') {
|
||||
throw new HttpError(403, 'Tenant is not active', 'AUTH_TENANT_INACTIVE');
|
||||
}
|
||||
|
||||
const insertedMembership = await client.query<{ status: string }>(
|
||||
`
|
||||
insert into public.tenant_memberships (tenant_id, user_id, role, status)
|
||||
values ($1, $2, 'student', 'active')
|
||||
on conflict (tenant_id, user_id, role)
|
||||
do update set status = 'active', updated_at = now()
|
||||
do nothing
|
||||
returning status
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const membership = insertedMembership.rows[0]
|
||||
? insertedMembership
|
||||
: await client.query<{ status: string }>(
|
||||
`
|
||||
select status
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1::uuid
|
||||
and user_id = $2::uuid
|
||||
and role = 'student'
|
||||
limit 1
|
||||
for update
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
if (membership.rows[0]?.status !== 'active') {
|
||||
throw new HttpError(403, 'Tenant membership is not active', 'AUTH_MEMBERSHIP_INACTIVE');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
|
||||
216
apps/api/src/features/auth/sms-limits.ts
Normal file
216
apps/api/src/features/auth/sms-limits.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { config } from '../../core/config.js';
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
interface SmsSendReservation {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function retryAfterSeconds(createdAt: string) {
|
||||
const elapsedSeconds = Math.floor((Date.now() - new Date(createdAt).getTime()) / 1000);
|
||||
return Math.max(1, config.authSmsCooldownSeconds - elapsedSeconds);
|
||||
}
|
||||
|
||||
function quotaError(code: string, message: string) {
|
||||
return new HttpError(429, message, code);
|
||||
}
|
||||
|
||||
function quotaScopeHash(dimension: string, value: string) {
|
||||
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-quota:${dimension}:${value}`).digest('hex');
|
||||
}
|
||||
|
||||
async function consumeQuota(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
dimension: 'tenant' | 'phone' | 'ip' | 'device';
|
||||
scopeValue: string;
|
||||
bucket: 'hour' | 'day';
|
||||
limit: number;
|
||||
code: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
if (!input.scopeValue) return;
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into app_private.sms_send_rate_limits (
|
||||
tenant_id, dimension, scope_hash, bucket_start, request_count
|
||||
)
|
||||
values (
|
||||
$1, $2, $3,
|
||||
case
|
||||
when $4 = 'day' then date_trunc('day', now() at time zone 'Asia/Shanghai') at time zone 'Asia/Shanghai'
|
||||
else date_trunc('hour', now())
|
||||
end,
|
||||
1
|
||||
)
|
||||
on conflict (tenant_id, dimension, scope_hash, bucket_start)
|
||||
do update set request_count = app_private.sms_send_rate_limits.request_count + 1,
|
||||
updated_at = now()
|
||||
where app_private.sms_send_rate_limits.request_count < $5
|
||||
returning request_count
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.dimension,
|
||||
quotaScopeHash(input.dimension, input.scopeValue),
|
||||
input.bucket,
|
||||
input.limit,
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw quotaError(input.code, input.message);
|
||||
}
|
||||
|
||||
export function normalizeSmsDeviceId(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) return '';
|
||||
if (normalized.length > 200 || !/^[A-Za-z0-9._:-]+$/.test(normalized)) {
|
||||
throw new HttpError(400, 'Invalid device identifier', 'INVALID_DEVICE_ID');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function hashSmsDeviceId(value: string) {
|
||||
const normalized = normalizeSmsDeviceId(value);
|
||||
if (!normalized) return '';
|
||||
return crypto.createHmac('sha256', config.authCodePepper).update(`sms-device:${normalized}`).digest('hex');
|
||||
}
|
||||
|
||||
export async function reserveSmsSend(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
phone: string;
|
||||
purpose: string;
|
||||
codeHash: string;
|
||||
provider: string;
|
||||
expiresAt: string;
|
||||
ipAddress: string;
|
||||
userAgent: string;
|
||||
deviceHash: string;
|
||||
outId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SmsSendReservation> {
|
||||
await client.query(
|
||||
`select pg_advisory_xact_lock(hashtextextended($1, 0))`,
|
||||
[`sms-send:${input.tenantId}:${input.phone}:${input.purpose}`],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
with stale as (
|
||||
select ctid
|
||||
from app_private.sms_send_rate_limits
|
||||
where updated_at < now() - interval '3 days'
|
||||
order by updated_at asc
|
||||
limit 32
|
||||
for update skip locked
|
||||
)
|
||||
delete from app_private.sms_send_rate_limits limits
|
||||
using stale
|
||||
where limits.ctid = stale.ctid
|
||||
`,
|
||||
);
|
||||
|
||||
const existing = await client.query<{ createdAt: string }>(
|
||||
`
|
||||
select created_at as "createdAt"
|
||||
from public.sms_verification_codes
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
and created_at > now() - ($4::text || ' seconds')::interval
|
||||
order by created_at desc
|
||||
limit 1
|
||||
`,
|
||||
[input.tenantId, input.phone, input.purpose, config.authSmsCooldownSeconds],
|
||||
);
|
||||
if (existing.rows[0]) {
|
||||
const cooldown = retryAfterSeconds(existing.rows[0].createdAt);
|
||||
throw quotaError('SMS_COOLDOWN', `SMS code was sent too frequently. Retry after ${cooldown} seconds.`);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.sms_verification_codes
|
||||
set status = 'expired'
|
||||
where tenant_id = $1
|
||||
and phone = $2
|
||||
and purpose = $3
|
||||
and consumed_at is null
|
||||
and status in ('pending', 'sent')
|
||||
`,
|
||||
[input.tenantId, input.phone, input.purpose],
|
||||
);
|
||||
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'tenant',
|
||||
scopeValue: input.tenantId,
|
||||
bucket: 'day',
|
||||
limit: config.authSmsTenantDailyLimit,
|
||||
code: 'SMS_TENANT_DAILY_LIMIT',
|
||||
message: 'Tenant SMS daily quota exceeded',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'phone',
|
||||
scopeValue: input.phone,
|
||||
bucket: 'day',
|
||||
limit: config.authSmsPhoneDailyLimit,
|
||||
code: 'SMS_PHONE_DAILY_LIMIT',
|
||||
message: 'SMS daily quota exceeded for this phone number',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'ip',
|
||||
scopeValue: input.ipAddress,
|
||||
bucket: 'hour',
|
||||
limit: config.authSmsIpHourlyLimit,
|
||||
code: 'SMS_IP_HOURLY_LIMIT',
|
||||
message: 'SMS hourly quota exceeded for this network',
|
||||
});
|
||||
await consumeQuota(client, {
|
||||
tenantId: input.tenantId,
|
||||
dimension: 'device',
|
||||
scopeValue: input.deviceHash,
|
||||
bucket: 'hour',
|
||||
limit: config.authSmsDeviceHourlyLimit,
|
||||
code: 'SMS_DEVICE_HOURLY_LIMIT',
|
||||
message: 'SMS hourly quota exceeded for this device',
|
||||
});
|
||||
|
||||
const result = await client.query<SmsSendReservation>(
|
||||
`
|
||||
insert into public.sms_verification_codes (
|
||||
tenant_id, phone, purpose, code_hash, provider, status, expires_at,
|
||||
ip_address, user_agent, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'pending', $6::timestamptz, $7, $8, $9::jsonb)
|
||||
returning id, created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.phone,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.provider,
|
||||
input.expiresAt,
|
||||
input.ipAddress || null,
|
||||
input.userAgent || null,
|
||||
JSON.stringify({
|
||||
...input.metadata,
|
||||
outId: input.outId,
|
||||
deviceHash: input.deviceHash || undefined,
|
||||
reservation: true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -233,7 +233,10 @@ export async function collectionQuestionsRoute(ctx: RequestContext) {
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.question_collection_items ci
|
||||
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where ci.tenant_id = $1
|
||||
and ci.collection_id = $2
|
||||
and q.status = 'published'
|
||||
|
||||
@@ -314,7 +314,10 @@ export async function questionsRoute(ctx: RequestContext) {
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where ${filters.join(' and ')}
|
||||
order by q.created_at desc
|
||||
limit $${params.length}
|
||||
|
||||
@@ -1195,7 +1195,10 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
q.type, v.correct_option_index, v.correct_option_indices, v.answer_text,
|
||||
v.sub_questions
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2 and q.status = 'published'
|
||||
limit 1
|
||||
`,
|
||||
@@ -1431,7 +1434,10 @@ async function buildPracticeSessionReport(
|
||||
v.correct_option_indices as "correctOptionIndices",
|
||||
v.answer_text as "answerText", v.sub_questions as "subQuestions"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
left join public.question_collection_items ci
|
||||
on ci.tenant_id = q.tenant_id
|
||||
and ci.question_id = q.id
|
||||
@@ -1772,7 +1778,10 @@ export async function practiceSessionDetailRoute(ctx: RequestContext) {
|
||||
v.code_lang as "codeLang", v.code_template as "codeTemplate",
|
||||
q.created_at as "createdAt", q.updated_at as "updatedAt"
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = any($2::uuid[])
|
||||
`,
|
||||
[tenantId, questionIds],
|
||||
@@ -2171,7 +2180,10 @@ export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
|
||||
) as "suggestedReviewAt"
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||
@@ -2222,7 +2234,10 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.favorite_questions fq
|
||||
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where fq.tenant_id = $1 and fq.user_id = $2
|
||||
order by fq.created_at desc
|
||||
limit $3
|
||||
@@ -2276,7 +2291,10 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
q.type, q.type_label as "typeLabel", v.content
|
||||
from public.wrong_questions wq
|
||||
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where wq.tenant_id = $1 and wq.user_id = $2
|
||||
and ($3::boolean = false or wq.resolved_at is null)
|
||||
order by wq.last_wrong_at desc
|
||||
|
||||
@@ -57,6 +57,7 @@ function truncate(value: unknown, max = 1900) {
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TENANT_STATUSES = new Set(['draft', 'active', 'suspended', 'archived']);
|
||||
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
|
||||
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
|
||||
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
|
||||
@@ -701,16 +702,13 @@ export async function upsertPlatformStaffRoute(ctx: RequestContext) {
|
||||
const item = await transaction(async client => {
|
||||
let targetStaffId = staffId || null;
|
||||
|
||||
const authUser = await client.query(
|
||||
const authUser = await client.query<{ exists: boolean }>(
|
||||
`
|
||||
select id
|
||||
from auth.users
|
||||
where id = $1::uuid
|
||||
limit 1
|
||||
select app.auth_user_exists($1::uuid) as exists
|
||||
`,
|
||||
[authUserId],
|
||||
);
|
||||
if (authUser.rowCount === 0) {
|
||||
if (!authUser.rows[0]?.exists) {
|
||||
throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
@@ -2241,6 +2239,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
const status = optionalString(body, 'status');
|
||||
const billingStatus = optionalString(body, 'billingStatus');
|
||||
if (!status && !billingStatus) throw new HttpError(400, 'status or billingStatus is required', 'REQUIRED_FIELD');
|
||||
if (status && !TENANT_STATUSES.has(status)) throw new HttpError(400, 'Unsupported tenant status', 'INVALID_STATUS');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
@@ -2258,6 +2257,25 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
);
|
||||
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Tenant not found', 'TENANT_NOT_FOUND');
|
||||
if (status && status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $2::jsonb
|
||||
where tenant_id = $1
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'platform.tenant.status_updated',
|
||||
tenantStatus: status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
await recordPlatformAudit(client, ctx, 'platform.tenant.status_updated', 'tenant', tenantId, {
|
||||
status: status || null,
|
||||
billingStatus: billingStatus || null,
|
||||
|
||||
@@ -35,6 +35,11 @@ export interface TenantAdminAuth {
|
||||
dataScope: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TenantPermissionContext = Pick<
|
||||
TenantAdminAuth,
|
||||
'role' | 'permissions' | 'templatePermissions'
|
||||
>;
|
||||
|
||||
function permissionKeys(permission: string) {
|
||||
const parts = permission.split(':').filter(Boolean);
|
||||
const keys = [permission];
|
||||
@@ -53,15 +58,23 @@ function explicitPermission(permissions: Record<string, unknown>, permission: st
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
export function hasResolvedTenantPermission(
|
||||
auth: TenantPermissionContext,
|
||||
permission: string,
|
||||
roleDefaultAllowed: boolean,
|
||||
) {
|
||||
const explicit = explicitPermission(auth.permissions, permission);
|
||||
if (explicit !== null) return explicit;
|
||||
|
||||
const templateExplicit = explicitPermission(auth.templatePermissions, permission);
|
||||
if (templateExplicit !== null) return templateExplicit;
|
||||
|
||||
return roleDefaultAllowed;
|
||||
}
|
||||
|
||||
export function hasTenantPermission(auth: TenantPermissionContext, permission: string) {
|
||||
const defaults = ROLE_PERMISSION_DEFAULTS[auth.role] || [];
|
||||
return defaults.some(defaultPermission => {
|
||||
const roleDefaultAllowed = defaults.some(defaultPermission => {
|
||||
if (defaultPermission === '*') return true;
|
||||
if (defaultPermission === permission) return true;
|
||||
if (defaultPermission.endsWith(':*')) {
|
||||
@@ -69,6 +82,7 @@ export function hasTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return hasResolvedTenantPermission(auth, permission, roleDefaultAllowed);
|
||||
}
|
||||
|
||||
export function requireTenantPermission(auth: TenantAdminAuth, permission: string) {
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
scopedSupervisionClassIds,
|
||||
supervisionBatchKey,
|
||||
} from './supervision.js';
|
||||
import {
|
||||
containsSearchPattern,
|
||||
decodeTenantStudentsCursor,
|
||||
encodeTenantStudentsCursor,
|
||||
} from './student-cursor.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
@@ -938,6 +943,7 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
const status = stringParam(ctx, 'status') || 'active';
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const classId = stringParam(ctx, 'classId');
|
||||
const cursor = decodeTenantStudentsCursor(stringParam(ctx, 'cursor'));
|
||||
const scopedIds = await scopedClassIds(auth);
|
||||
if (classId) await ensureReadableClass(auth, classId);
|
||||
if (!STUDENT_MEMBER_STATUSES.includes(status)) {
|
||||
@@ -952,8 +958,13 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
}
|
||||
const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2'];
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`);
|
||||
filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`);
|
||||
params.push(containsSearchPattern(keyword));
|
||||
filters.push(`(
|
||||
coalesce(u.username, '') || ' ' ||
|
||||
coalesce(u.name, '') || ' ' ||
|
||||
coalesce(u.phone, '') || ' ' ||
|
||||
coalesce(u.email::text, '')
|
||||
) ilike $${params.length} escape '\\'`);
|
||||
}
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
@@ -981,11 +992,24 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
and scoped_cm.status = 'active'
|
||||
)`);
|
||||
}
|
||||
params.push(limit);
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.membershipId);
|
||||
filters.push(`(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
|
||||
}
|
||||
params.push(limit + 1);
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
const rows = await query<Record<string, unknown>>(
|
||||
`
|
||||
with class_agg as (
|
||||
with student_page as materialized (
|
||||
select tm.id, tm.tenant_id, tm.user_id, tm.status, tm.created_at, tm.updated_at
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||
where ${filters.join(' and ')}
|
||||
order by tm.created_at desc, tm.id desc
|
||||
limit $${params.length}
|
||||
),
|
||||
class_agg as (
|
||||
select tcm.tenant_id, tcm.user_id,
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
@@ -998,13 +1022,15 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
order by tc.sort_order asc, tc.created_at desc
|
||||
) filter (where tcm.status = 'active') as classes
|
||||
from public.tenant_class_members tcm
|
||||
join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id
|
||||
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
|
||||
where tcm.tenant_id = $1 and tcm.member_type = 'student'
|
||||
${classAggScopeSql}
|
||||
group by tcm.tenant_id, tcm.user_id
|
||||
)
|
||||
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
|
||||
tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt",
|
||||
tm.created_at as "memberCreatedAt", tm.created_at::text as "cursorCreatedAt",
|
||||
tm.updated_at as "memberUpdatedAt",
|
||||
u.username, u.email::text as email, u.phone, u.name,
|
||||
null::text as "avatarUrl", u.primary_role as "primaryRole",
|
||||
u.last_seen_at as "lastSeenAt",
|
||||
@@ -1016,21 +1042,37 @@ export async function tenantStudentsRoute(ctx: RequestContext) {
|
||||
sp.last_check_in_date as "lastCheckInDate",
|
||||
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
|
||||
coalesce(ca.classes, '[]'::jsonb) as classes
|
||||
from public.tenant_memberships tm
|
||||
from student_page tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
|
||||
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
|
||||
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
|
||||
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
|
||||
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
|
||||
where ${filters.join(' and ')}
|
||||
order by tm.created_at desc
|
||||
limit $${params.length}
|
||||
order by tm.created_at desc, tm.id desc
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null };
|
||||
const hasMore = rows.length > limit;
|
||||
const pageItems = rows.slice(0, limit);
|
||||
const lastItem = pageItems.at(-1);
|
||||
const nextCursor = hasMore && lastItem
|
||||
? encodeTenantStudentsCursor({
|
||||
createdAt: String(lastItem.cursorCreatedAt || ''),
|
||||
membershipId: String(lastItem.membershipId || ''),
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
items: pageItems.map(item => {
|
||||
const { cursorCreatedAt: _cursorCreatedAt, ...publicItem } = item;
|
||||
return maskStudentFields(auth, publicItem);
|
||||
}),
|
||||
scoped: scopedIds !== null,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentRoute(ctx: RequestContext) {
|
||||
@@ -1049,6 +1091,16 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) {
|
||||
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
|
||||
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and revoked_at is null
|
||||
`,
|
||||
[auth.tenantId, userId],
|
||||
);
|
||||
}
|
||||
const profile = await client.query(
|
||||
`
|
||||
insert into public.student_profiles (
|
||||
@@ -1115,6 +1167,27 @@ export async function updateTenantStudentStatusRoute(ctx: RequestContext) {
|
||||
[auth.tenantId, userId, status],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND');
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $3::jsonb
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'tenant.student.status_updated',
|
||||
membershipStatus: status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, {
|
||||
userId,
|
||||
status,
|
||||
@@ -1148,6 +1221,16 @@ export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) {
|
||||
await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(), updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2 and revoked_at is null
|
||||
`,
|
||||
[auth.tenantId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
const profile = await client.query(
|
||||
`
|
||||
|
||||
@@ -509,7 +509,10 @@ export async function tenantFeedbackReportRoute(ctx: RequestContext) {
|
||||
max(r.created_at) as "latestAt"
|
||||
from public.reports r
|
||||
join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where r.tenant_id = $1
|
||||
and r.question_id is not null
|
||||
and r.created_at >= $2::timestamptz
|
||||
|
||||
@@ -2686,6 +2686,28 @@ export async function upsertTenantMemberRoute(ctx: RequestContext) {
|
||||
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Tenant member not found', 'TENANT_MEMBER_NOT_FOUND');
|
||||
|
||||
if (status !== 'active') {
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $3::jsonb
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
userId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'tenant.member.upserted',
|
||||
membershipStatus: status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.member.upserted', 'tenant_memberships', result.rows[0].id, {
|
||||
userId,
|
||||
role,
|
||||
@@ -2735,6 +2757,26 @@ export async function disableTenantMemberRoute(ctx: RequestContext) {
|
||||
[auth.tenantId, membershipId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update app_private.auth_sessions
|
||||
set revoked_at = now(),
|
||||
updated_at = now(),
|
||||
metadata = metadata || $3::jsonb
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and revoked_at is null
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
result.rows[0].userId,
|
||||
JSON.stringify({
|
||||
revokedBy: 'tenant.member.disabled',
|
||||
membershipStatus: 'disabled',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
await recordAudit(client, auth, 'tenant.member.disabled', 'tenant_memberships', membershipId, {
|
||||
userId: result.rows[0].userId,
|
||||
role: result.rows[0].role,
|
||||
|
||||
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal file
39
apps/api/src/features/tenant-admin/student-cursor.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { HttpError } from '../../core/http.js';
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const CURSOR_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export interface TenantStudentsCursor {
|
||||
createdAt: string;
|
||||
membershipId: string;
|
||||
}
|
||||
|
||||
export function encodeTenantStudentsCursor(cursor: TenantStudentsCursor) {
|
||||
return Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: cursor.createdAt,
|
||||
membershipId: cursor.membershipId,
|
||||
}), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeTenantStudentsCursor(value: string): TenantStudentsCursor | null {
|
||||
if (!value) return null;
|
||||
if (value.length > 512 || !CURSOR_PATTERN.test(value)) {
|
||||
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : '';
|
||||
const membershipId = typeof parsed.membershipId === 'string' ? parsed.membershipId : '';
|
||||
if (parsed.version !== 1 || !createdAt || !Number.isFinite(Date.parse(createdAt)) || !UUID_PATTERN.test(membershipId)) {
|
||||
throw new Error('invalid cursor payload');
|
||||
}
|
||||
return { createdAt, membershipId };
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid student list cursor', 'INVALID_STUDENT_CURSOR');
|
||||
}
|
||||
}
|
||||
|
||||
export function containsSearchPattern(value: string) {
|
||||
return `%${value.replace(/[\\%_]/g, match => `\\${match}`)}%`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { hasResolvedTenantPermission } from '../tenant-admin/auth.js';
|
||||
|
||||
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
|
||||
|
||||
@@ -12,14 +13,6 @@ export interface TenantContentAuth {
|
||||
templatePermissions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasContentPermission(permissions: Record<string, unknown>) {
|
||||
return permissions['*'] === true || permissions['content:*'] === true;
|
||||
}
|
||||
|
||||
function hasPermission(permissions: Record<string, unknown>, permissionKey: string) {
|
||||
return permissions['*'] === true || permissions['content:*'] === true || permissions[permissionKey] === true;
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -36,13 +29,6 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2
|
||||
and tm.status = 'active'
|
||||
and (
|
||||
tm.role = any($3::text[])
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
|
||||
or tm.permissions ? 'content:*'
|
||||
or tm.permissions ? '*'
|
||||
)
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
@@ -52,17 +38,23 @@ export async function requireTenantContentEditor(ctx: RequestContext): Promise<T
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, Array.from(CONTENT_ROLES)],
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
if (!CONTENT_ROLES.has(membership.role) && !hasContentPermission(membership.permissions || {}) && !hasContentPermission(membership.templatePermissions || {})) {
|
||||
const permissions = membership.permissions || {};
|
||||
const templatePermissions = membership.templatePermissions || {};
|
||||
if (!hasResolvedTenantPermission(
|
||||
{ role: membership.role, permissions, templatePermissions },
|
||||
'content:write',
|
||||
CONTENT_ROLES.has(membership.role),
|
||||
)) {
|
||||
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
|
||||
}
|
||||
|
||||
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {}, templatePermissions: membership.templatePermissions || {} };
|
||||
return { tenantId, userId, role: membership.role, permissions, templatePermissions };
|
||||
}
|
||||
|
||||
export async function requireTenantContentPermission(
|
||||
@@ -85,15 +77,6 @@ export async function requireTenantContentPermission(
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2
|
||||
and tm.status = 'active'
|
||||
and (
|
||||
tm.role = any($3::text[])
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? $4
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? 'content:*'
|
||||
or coalesce(rt.permissions, '{}'::jsonb) ? '*'
|
||||
or tm.permissions ? $4
|
||||
or tm.permissions ? 'content:*'
|
||||
or tm.permissions ? '*'
|
||||
)
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
@@ -103,7 +86,7 @@ export async function requireTenantContentPermission(
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, defaultRoles, permissionKey],
|
||||
[tenantId, userId],
|
||||
);
|
||||
|
||||
if (!membership) {
|
||||
@@ -111,7 +94,11 @@ export async function requireTenantContentPermission(
|
||||
}
|
||||
const permissions = membership.permissions || {};
|
||||
const templatePermissions = membership.templatePermissions || {};
|
||||
if (!defaultRoles.includes(membership.role) && !hasPermission(permissions, permissionKey) && !hasPermission(templatePermissions, permissionKey)) {
|
||||
if (!hasResolvedTenantPermission(
|
||||
{ role: membership.role, permissions, templatePermissions },
|
||||
permissionKey,
|
||||
defaultRoles.includes(membership.role),
|
||||
)) {
|
||||
throw new HttpError(403, 'Tenant content permission is required', 'TENANT_CONTENT_PERMISSION_REQUIRED');
|
||||
}
|
||||
|
||||
|
||||
@@ -589,7 +589,10 @@ async function loadQuestions(client: pg.PoolClient, tenantId: string, scopeType:
|
||||
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1
|
||||
and q.status = 'published'
|
||||
and ${scopeFilter}
|
||||
|
||||
@@ -1830,7 +1830,12 @@ async function createGenericPreviewJob<T>(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
|
||||
async function loadPreviewJob(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantContentAuth,
|
||||
jobId: string,
|
||||
leaseToken?: string,
|
||||
) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -1846,16 +1851,18 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
|
||||
target_entry_id: string | null;
|
||||
target_content_node_id: string | null;
|
||||
target_collection_id: string | null;
|
||||
lease_token: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_subject_id, target_category_id,
|
||||
target_node_id, target_question_bank_id,
|
||||
target_entry_id, target_content_node_id, target_collection_id
|
||||
target_entry_id, target_content_node_id, target_collection_id,
|
||||
lease_token
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = 'questions'
|
||||
limit 1
|
||||
for update
|
||||
${leaseToken ? '' : 'for update'}
|
||||
`,
|
||||
[auth.tenantId, jobId],
|
||||
);
|
||||
@@ -1864,7 +1871,24 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
|
||||
if (!job) {
|
||||
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
}
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
if (leaseToken) {
|
||||
const lease = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1
|
||||
and id = $2
|
||||
and status = 'importing'
|
||||
and lease_token = $3::uuid
|
||||
and lease_expires_at > now()
|
||||
`,
|
||||
[auth.tenantId, jobId, leaseToken],
|
||||
);
|
||||
if (lease.rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
@@ -1875,6 +1899,7 @@ async function loadGenericPreviewJob(
|
||||
auth: TenantContentAuth,
|
||||
jobId: string,
|
||||
importType: ContentImportType,
|
||||
leaseToken?: string,
|
||||
) {
|
||||
const result = await client.query<{
|
||||
id: string;
|
||||
@@ -1886,21 +1911,39 @@ async function loadGenericPreviewJob(
|
||||
target_region_id: string | null;
|
||||
target_entry_id: string | null;
|
||||
target_content_node_id: string | null;
|
||||
lease_token: string | null;
|
||||
}>(
|
||||
`
|
||||
select id, status, total_count, valid_count, error_count, warning_count,
|
||||
target_region_id, target_entry_id, target_content_node_id
|
||||
target_region_id, target_entry_id, target_content_node_id, lease_token
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1 and id = $2 and import_type = $3
|
||||
limit 1
|
||||
for update
|
||||
${leaseToken ? '' : 'for update'}
|
||||
`,
|
||||
[auth.tenantId, jobId, importType],
|
||||
);
|
||||
|
||||
const job = result.rows[0];
|
||||
if (!job) throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
|
||||
if (['importing', 'failed'].includes(job.status)) {
|
||||
if (leaseToken) {
|
||||
const lease = await client.query(
|
||||
`
|
||||
select 1
|
||||
from public.content_import_jobs
|
||||
where tenant_id = $1
|
||||
and id = $2
|
||||
and status = 'importing'
|
||||
and lease_token = $3::uuid
|
||||
and lease_expires_at > now()
|
||||
`,
|
||||
[auth.tenantId, jobId, leaseToken],
|
||||
);
|
||||
if (lease.rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
if (!leaseToken && ['importing', 'failed'].includes(job.status)) {
|
||||
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
|
||||
}
|
||||
return job;
|
||||
@@ -2118,7 +2161,10 @@ async function currentVersionHash(client: pg.PoolClient, questionId: string) {
|
||||
`
|
||||
select v.source_hash
|
||||
from public.questions q
|
||||
join public.question_versions v on v.id = q.current_version_id
|
||||
join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.id = $1
|
||||
limit 1
|
||||
`,
|
||||
@@ -3099,6 +3145,7 @@ interface ContentImportExecutionOptions {
|
||||
importType: ExecutableContentImportType;
|
||||
allowPartial?: boolean;
|
||||
allowQueuedJob?: boolean;
|
||||
leaseToken?: string;
|
||||
}
|
||||
|
||||
interface ContentImportExecutionResult {
|
||||
@@ -3112,15 +3159,40 @@ interface ContentImportExecutionResult {
|
||||
warningCount?: number;
|
||||
}
|
||||
|
||||
type ContentImportLeaseOptions = Pick<ContentImportExecutionOptions, 'allowQueuedJob' | 'leaseToken'>;
|
||||
|
||||
function asyncLeaseWhereClause(input: ContentImportLeaseOptions, firstParam: number) {
|
||||
return input.allowQueuedJob === true
|
||||
? ` and status = 'importing' and lease_token = $${firstParam}::uuid and lease_expires_at > now()`
|
||||
: '';
|
||||
}
|
||||
|
||||
function asyncLeaseParams(input: ContentImportLeaseOptions) {
|
||||
if (input.allowQueuedJob !== true) return [];
|
||||
if (!input.leaseToken) {
|
||||
throw new HttpError(409, 'Import worker lease is required', 'IMPORT_WORKER_LEASE_REQUIRED');
|
||||
}
|
||||
return [input.leaseToken];
|
||||
}
|
||||
|
||||
function assertImportLeaseUpdate(rowCount: number | null, input: ContentImportLeaseOptions) {
|
||||
if (input.allowQueuedJob === true && rowCount !== 1) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuestionsImportJob(
|
||||
auth: TenantContentAuth,
|
||||
input: Omit<ContentImportExecutionOptions, 'importType'>,
|
||||
) {
|
||||
const allowPartial = input.allowPartial === true;
|
||||
return transaction(async client => {
|
||||
const job = await loadPreviewJob(client, auth, input.jobId);
|
||||
const job = await loadPreviewJob(client, auth, input.jobId, input.leaseToken);
|
||||
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
if (input.allowQueuedJob === true) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
@@ -3142,6 +3214,9 @@ async function executeQuestionsImportJob(
|
||||
error_message = 'Preview contains validation errors',
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
@@ -3150,14 +3225,16 @@ async function executeQuestionsImportJob(
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
if (input.allowQueuedJob !== true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
}
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
@@ -3185,7 +3262,7 @@ async function executeQuestionsImportJob(
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
const completed = await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
@@ -3196,8 +3273,13 @@ async function executeQuestionsImportJob(
|
||||
finished_at = now(),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
${asyncLeaseWhereClause(input, 8)}
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -3207,8 +3289,10 @@ async function executeQuestionsImportJob(
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
...asyncLeaseParams(input),
|
||||
],
|
||||
);
|
||||
assertImportLeaseUpdate(completed.rowCount, input);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -3251,8 +3335,11 @@ async function executeGenericImportJob<T>(
|
||||
) {
|
||||
const allowPartial = input.allowPartial === true;
|
||||
return transaction(async client => {
|
||||
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType);
|
||||
const job = await loadGenericPreviewJob(client, auth, input.jobId, input.importType, input.leaseToken);
|
||||
if (job.status === 'completed' || job.status === 'completed_with_errors') {
|
||||
if (input.allowQueuedJob === true) {
|
||||
throw new HttpError(409, 'Import worker lease was lost', 'IMPORT_WORKER_LEASE_LOST');
|
||||
}
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
@@ -3274,6 +3361,9 @@ async function executeGenericImportJob<T>(
|
||||
error_message = 'Preview contains validation errors',
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
@@ -3282,14 +3372,16 @@ async function executeGenericImportJob<T>(
|
||||
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
if (input.allowQueuedJob !== true) {
|
||||
await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[auth.tenantId, job.id],
|
||||
);
|
||||
}
|
||||
|
||||
const itemResult = await client.query<{
|
||||
id: string;
|
||||
@@ -3329,7 +3421,7 @@ async function executeGenericImportJob<T>(
|
||||
}
|
||||
|
||||
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
|
||||
await client.query(
|
||||
const completed = await client.query(
|
||||
`
|
||||
update public.content_import_jobs
|
||||
set status = $3,
|
||||
@@ -3340,8 +3432,13 @@ async function executeGenericImportJob<T>(
|
||||
finished_at = now(),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
${asyncLeaseWhereClause(input, 8)}
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
@@ -3351,8 +3448,10 @@ async function executeGenericImportJob<T>(
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
|
||||
...asyncLeaseParams(input),
|
||||
],
|
||||
);
|
||||
assertImportLeaseUpdate(completed.rowCount, input);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
@@ -3477,6 +3576,9 @@ async function queueContentImportJob(
|
||||
queued_at = coalesce(queued_at, now()),
|
||||
locked_at = null,
|
||||
locked_by = null,
|
||||
lease_token = null,
|
||||
lease_expires_at = null,
|
||||
last_heartbeat_at = null,
|
||||
next_attempt_at = now(),
|
||||
summary = coalesce(summary, '{}'::jsonb) || $3::jsonb,
|
||||
updated_at = now()
|
||||
@@ -3676,6 +3778,7 @@ const importJobSelect = `
|
||||
updated_count as "updatedCount", skipped_count as "skippedCount",
|
||||
execution_mode as "executionMode", queued_at as "queuedAt",
|
||||
locked_at as "lockedAt", locked_by as "lockedBy",
|
||||
lease_expires_at as "leaseExpiresAt", last_heartbeat_at as "lastHeartbeatAt",
|
||||
attempt_count as "attemptCount", max_attempts as "maxAttempts",
|
||||
next_attempt_at as "nextAttemptAt", parser_metadata as "parserMetadata",
|
||||
summary, error_message as "errorMessage",
|
||||
|
||||
@@ -395,7 +395,10 @@ async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
|
||||
v.answer_text, v.explanation, v.sub_questions, v.code_lang,
|
||||
v.code_template, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1
|
||||
and q.question_bank_id = $2
|
||||
and q.status = 'published'
|
||||
@@ -446,7 +449,10 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.legacy_id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
@@ -1440,7 +1446,10 @@ export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
@@ -1795,7 +1804,10 @@ async function resolvePublicQuestionBankConflictsBatch(auth: TenantContentAuth,
|
||||
`
|
||||
select q.id, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
left join public.question_versions v
|
||||
on v.tenant_id = q.tenant_id
|
||||
and v.question_id = q.id
|
||||
and v.id = q.current_version_id
|
||||
where q.tenant_id = $1 and q.id = $2
|
||||
limit 1
|
||||
for update of q
|
||||
|
||||
153
apps/api/src/features/tenant/locator.ts
Normal file
153
apps/api/src/features/tenant/locator.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export interface TenantLocatorInput {
|
||||
origin?: string;
|
||||
referer?: string;
|
||||
requestedHost?: string;
|
||||
requestHost?: string;
|
||||
tenantCode?: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
export interface TenantLocatorError {
|
||||
ok: false;
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TenantHostLocator {
|
||||
kind: 'host';
|
||||
host: string;
|
||||
expectedTenantCode: string | null;
|
||||
source: 'browser' | 'local-development';
|
||||
}
|
||||
|
||||
export interface TenantCodeLocator {
|
||||
kind: 'tenantCode';
|
||||
tenantCode: string;
|
||||
source: 'headless-client' | 'local-development';
|
||||
}
|
||||
|
||||
export type TenantLocatorDecision = TenantLocatorError | {
|
||||
ok: true;
|
||||
locator: TenantHostLocator | TenantCodeLocator;
|
||||
};
|
||||
|
||||
function normalizedHostname(value: string) {
|
||||
return value.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
export function normalizeTenantHost(value: string) {
|
||||
const raw = value.trim();
|
||||
if (!raw || /[\s,]/.test(raw)) return '';
|
||||
|
||||
const directIp = normalizedHostname(raw);
|
||||
if (isIP(directIp)) return directIp;
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw.includes('://') ? raw : `http://${raw}`);
|
||||
if (parsed.username || parsed.password) return '';
|
||||
const hostname = normalizedHostname(parsed.hostname);
|
||||
if (!hostname) return '';
|
||||
if (isIP(hostname)) return hostname;
|
||||
if (!/^[a-z0-9.-]+$/.test(hostname)) return '';
|
||||
if (hostname.startsWith('.') || hostname.includes('..')) return '';
|
||||
return hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalTenantHost(value: string) {
|
||||
const hostname = normalizeTenantHost(value);
|
||||
if (!hostname) return false;
|
||||
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
|
||||
if (hostname === '::1' || hostname === '0.0.0.0') return true;
|
||||
return isIP(hostname) === 4 && hostname.startsWith('127.');
|
||||
}
|
||||
|
||||
function hostFromBrowserUrl(value: string) {
|
||||
const raw = value.trim();
|
||||
if (!raw || raw === 'null' || raw.includes(',')) return '';
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return '';
|
||||
return normalizeTenantHost(parsed.host);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTenantCode(value: string) {
|
||||
const normalized = value.trim();
|
||||
return /^[A-Za-z0-9._-]{2,64}$/.test(normalized) ? normalized : '';
|
||||
}
|
||||
|
||||
function fail(statusCode: number, code: string, message: string): TenantLocatorError {
|
||||
return { ok: false, statusCode, code, message };
|
||||
}
|
||||
|
||||
export function selectTenantLocator(input: TenantLocatorInput): TenantLocatorDecision {
|
||||
const rawTenantCode = input.tenantCode?.trim() || '';
|
||||
const tenantCode = normalizeTenantCode(rawTenantCode);
|
||||
if (rawTenantCode && !tenantCode) return fail(400, 'TENANT_CODE_INVALID', 'Invalid tenant code');
|
||||
|
||||
const rawRequestedHost = input.requestedHost?.trim() || '';
|
||||
const requestedHost = normalizeTenantHost(rawRequestedHost);
|
||||
if (rawRequestedHost && !requestedHost) return fail(400, 'TENANT_HOST_INVALID', 'Invalid tenant host');
|
||||
|
||||
const rawOrigin = input.origin?.trim() || '';
|
||||
const rawReferer = input.referer?.trim() || '';
|
||||
const browserHost = rawOrigin && rawOrigin !== 'null'
|
||||
? hostFromBrowserUrl(rawOrigin)
|
||||
: hostFromBrowserUrl(rawReferer);
|
||||
if (rawOrigin && rawOrigin !== 'null' && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser origin');
|
||||
if (!rawOrigin && rawReferer && !browserHost) return fail(400, 'TENANT_ORIGIN_INVALID', 'Invalid browser referer');
|
||||
|
||||
if (browserHost) {
|
||||
if (requestedHost && requestedHost !== browserHost) {
|
||||
return fail(409, 'TENANT_HOST_CONFLICT', 'Requested host does not match browser origin');
|
||||
}
|
||||
if (isLocalTenantHost(browserHost)) {
|
||||
if (input.isProduction) return fail(400, 'TENANT_LOCAL_HOST_FORBIDDEN', 'Local tenant host is not allowed in production');
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: browserHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
locator: {
|
||||
kind: 'host',
|
||||
host: browserHost,
|
||||
expectedTenantCode: tenantCode || null,
|
||||
source: 'browser',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedHost) {
|
||||
if (input.isProduction || !isLocalTenantHost(requestedHost)) {
|
||||
return fail(400, 'TENANT_HOST_UNTRUSTED', 'Tenant host requires a matching browser origin');
|
||||
}
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: requestedHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
|
||||
const requestHost = normalizeTenantHost(input.requestHost || '');
|
||||
if (!input.isProduction && isLocalTenantHost(requestHost)) {
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'local-development' } };
|
||||
}
|
||||
return { ok: true, locator: { kind: 'host', host: requestHost, expectedTenantCode: null, source: 'local-development' } };
|
||||
}
|
||||
|
||||
if (tenantCode) {
|
||||
return { ok: true, locator: { kind: 'tenantCode', tenantCode, source: 'headless-client' } };
|
||||
}
|
||||
|
||||
return fail(400, 'TENANT_LOCATOR_REQUIRED', 'Tenant host or tenant code is required');
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { config } from '../../core/config.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
import { HttpError } from '../../core/errors.js';
|
||||
import { getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { selectTenantLocator } from './locator.js';
|
||||
|
||||
interface TenantResolveRow {
|
||||
id: string;
|
||||
@@ -23,24 +24,33 @@ interface TenantResolveRow {
|
||||
public_config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeHost(host: string) {
|
||||
return host.split(':')[0]?.trim().toLowerCase() || '';
|
||||
}
|
||||
type TenantLookup = (sql: string, params: unknown[]) => Promise<TenantResolveRow | null>;
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
const defaultTenantLookup: TenantLookup = (sql, params) => queryOne<TenantResolveRow>(sql, params);
|
||||
|
||||
export async function resolveTenantRoute(ctx: RequestContext, lookup: TenantLookup = defaultTenantLookup) {
|
||||
const hostParam = ctx.url.searchParams.get('host') || '';
|
||||
const tenantCode = ctx.url.searchParams.get('tenantCode') || getHeader(ctx.req, 'x-tenant-code');
|
||||
const requestHost = normalizeHost(hostParam || getHeader(ctx.req, 'x-forwarded-host') || getHeader(ctx.req, 'host'));
|
||||
const decision = selectTenantLocator({
|
||||
origin: getHeader(ctx.req, 'origin'),
|
||||
referer: getHeader(ctx.req, 'referer'),
|
||||
requestedHost: hostParam,
|
||||
requestHost: getHeader(ctx.req, 'host'),
|
||||
tenantCode,
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
if (!decision.ok) throw new HttpError(decision.statusCode, decision.message, decision.code);
|
||||
const locator = decision.locator;
|
||||
|
||||
const row = tenantCode
|
||||
? await queryOne<TenantResolveRow>(
|
||||
const row = locator.kind === 'tenantCode'
|
||||
? await lookup(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
null::text as host,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name,
|
||||
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
@@ -51,16 +61,16 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
where t.slug = $1 and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantCode],
|
||||
[locator.tenantCode],
|
||||
)
|
||||
: await queryOne<TenantResolveRow>(
|
||||
: await lookup(
|
||||
`
|
||||
select t.id, t.slug, t.name, t.status, t.mode,
|
||||
d.host::text,
|
||||
b.brand_name, b.short_name, b.slogan, b.logo_url, b.favicon_url,
|
||||
b.service_wechat, b.service_account_name,
|
||||
coalesce(tc.active_theme, b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(b.public_assets, tc.active_public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(nullif(tc.active_theme, '{}'::jsonb), b.theme, '{}'::jsonb) as theme,
|
||||
coalesce(nullif(tc.active_public_assets, '{}'::jsonb), b.public_assets, '{}'::jsonb) as public_assets,
|
||||
coalesce(s.feature_flags, '{}'::jsonb) as feature_flags,
|
||||
coalesce(s.admin_feature_flags, '{}'::jsonb) as admin_feature_flags,
|
||||
coalesce(s.public_config, '{}'::jsonb) as public_config
|
||||
@@ -72,20 +82,20 @@ export async function resolveTenantRoute(ctx: RequestContext) {
|
||||
where d.host = $1 and d.status = 'active' and t.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[requestHost || 'localhost'],
|
||||
[locator.host],
|
||||
);
|
||||
|
||||
if (!row && requestHost !== 'localhost') {
|
||||
ctx.url.searchParams.set('tenantCode', config.defaultTenantSlug);
|
||||
return resolveTenantRoute(ctx);
|
||||
if (!row) {
|
||||
if (locator.kind === 'host') throw new HttpError(404, 'Tenant domain is not bound', 'TENANT_DOMAIN_NOT_BOUND');
|
||||
throw new HttpError(404, 'Tenant code was not found', 'TENANT_CODE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
found: false,
|
||||
message: 'Tenant not found',
|
||||
lookup: { host: requestHost, tenantCode: tenantCode || null },
|
||||
};
|
||||
if (
|
||||
locator.kind === 'host'
|
||||
&& locator.expectedTenantCode
|
||||
&& row.slug.toLowerCase() !== locator.expectedTenantCode.toLowerCase()
|
||||
) {
|
||||
throw new HttpError(409, 'Tenant code does not match the resolved domain', 'TENANT_LOCATOR_CONFLICT');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import http from 'node:http';
|
||||
import { config } from './core/config.js';
|
||||
import { applyCors, publicErrorBody, routeKey, sendJson } from './core/http.js';
|
||||
import { authorizeCorsRequest, CorsPolicy } from './core/cors.js';
|
||||
import { closePool } from './core/db.js';
|
||||
import { publicErrorBody, routeKey, sendJson, withResponseMeta } from './core/http.js';
|
||||
import { requestIdFrom } from './core/request-id.js';
|
||||
import { createRouter } from './core/router.js';
|
||||
|
||||
const routes = createRouter();
|
||||
@@ -18,8 +21,58 @@ function resolveHandler(method: string | undefined, url: URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
applyCors(req, res);
|
||||
function writeLog(event: Record<string, unknown>, error = false) {
|
||||
const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event });
|
||||
if (error) console.error(line);
|
||||
else console.log(line);
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
const corsPolicy = new CorsPolicy({
|
||||
staticOrigins: config.corsOrigins,
|
||||
tenantDomainsEnabled: config.corsTenantDomainsEnabled,
|
||||
positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs,
|
||||
negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs,
|
||||
maxCacheEntries: config.corsTenantDomainCacheMaxEntries,
|
||||
onLookupError(error, host) {
|
||||
writeLog({
|
||||
event: 'cors_tenant_domain_lookup_failed',
|
||||
host,
|
||||
error: error instanceof Error ? error.message : 'unknown',
|
||||
}, true);
|
||||
},
|
||||
});
|
||||
|
||||
export const server = http.createServer(async (req, res) => {
|
||||
const requestId = requestIdFrom(req);
|
||||
const startedAt = process.hrtime.bigint();
|
||||
res.setHeader('x-request-id', requestId);
|
||||
res.once('finish', () => {
|
||||
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
|
||||
writeLog({
|
||||
event: 'http_request',
|
||||
requestId,
|
||||
method: req.method || 'GET',
|
||||
path: (() => {
|
||||
try { return new URL(req.url || '/', 'http://localhost').pathname; } catch { return '/'; }
|
||||
})(),
|
||||
status: res.statusCode,
|
||||
durationMs: Number(durationMs.toFixed(2)),
|
||||
}, res.statusCode >= 500);
|
||||
});
|
||||
|
||||
if (shuttingDown) {
|
||||
res.setHeader('connection', 'close');
|
||||
sendJson(res, 503, withResponseMeta({ error: 'Service is shutting down', code: 'SERVICE_UNAVAILABLE', requestId }, requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
const corsDecision = await authorizeCorsRequest(req, res, corsPolicy);
|
||||
if (!corsDecision.allowed) {
|
||||
sendJson(res, 403, withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
@@ -30,19 +83,66 @@ const server = http.createServer(async (req, res) => {
|
||||
const handler = resolveHandler(req.method, url);
|
||||
|
||||
if (!handler) {
|
||||
sendJson(res, 404, { error: 'Not found', path: url.pathname });
|
||||
sendJson(res, 404, withResponseMeta({ error: 'Not found', code: 'NOT_FOUND', path: url.pathname, requestId }, requestId));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler({ req, res, url });
|
||||
sendJson(res, 200, result);
|
||||
const result = await handler({ req, res, url, requestId });
|
||||
sendJson(res, 200, withResponseMeta(result, requestId));
|
||||
} catch (error) {
|
||||
const { statusCode, body } = publicErrorBody(error);
|
||||
sendJson(res, statusCode, body);
|
||||
sendJson(res, statusCode, withResponseMeta({ ...body, requestId }, requestId));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`[api] listening on http://127.0.0.1:${config.port}`);
|
||||
server.headersTimeout = config.apiHeadersTimeoutMs;
|
||||
server.requestTimeout = config.apiRequestTimeoutMs;
|
||||
server.keepAliveTimeout = config.apiKeepAliveTimeoutMs;
|
||||
server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket;
|
||||
|
||||
server.on('clientError', (error, socket) => {
|
||||
writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true);
|
||||
if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
||||
});
|
||||
|
||||
let shutdownPromise: Promise<void> | null = null;
|
||||
|
||||
export function shutdown(signal: string) {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
shuttingDown = true;
|
||||
shutdownPromise = new Promise(resolve => {
|
||||
writeLog({ event: 'shutdown_started', signal });
|
||||
const forceTimer = setTimeout(() => {
|
||||
writeLog({ event: 'shutdown_deadline_reached', signal }, true);
|
||||
server.closeAllConnections();
|
||||
}, config.apiShutdownGracePeriodMs);
|
||||
forceTimer.unref();
|
||||
server.close(() => {
|
||||
clearTimeout(forceTimer);
|
||||
closePool()
|
||||
.catch(error => writeLog({ event: 'database_pool_close_failed', error: error instanceof Error ? error.message : 'unknown' }, true))
|
||||
.finally(() => {
|
||||
writeLog({ event: 'shutdown_complete', signal });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
server.closeIdleConnections();
|
||||
});
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
server.listen(config.port, () => {
|
||||
writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port });
|
||||
});
|
||||
|
||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||
process.once(signal, () => {
|
||||
shutdown(signal)
|
||||
.then(() => { process.exitCode = 0; })
|
||||
.catch(error => {
|
||||
writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user