feat: establish production SaaS foundation

This commit is contained in:
Codex
2026-07-12 19:26:57 +08:00
parent 1c2ce38cea
commit 39f7332f33
219 changed files with 20647 additions and 2628 deletions

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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
View 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;
}

View File

@@ -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[]> {

View File

@@ -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}`;
}

View 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();
}

View File

@@ -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 {

View File

@@ -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(
`

View 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];
}

View File

@@ -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'

View File

@@ -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}

View File

@@ -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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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(
`

View File

@@ -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

View File

@@ -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,

View 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}`)}%`;
}

View File

@@ -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');
}

View File

@@ -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}

View File

@@ -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",

View File

@@ -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

View 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');
}

View File

@@ -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 {

View File

@@ -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;
});
});
}