feat: enforce trusted session identity

This commit is contained in:
Codex
2026-06-28 21:56:11 +08:00
parent 1d873b2e50
commit 523b63c53b
23 changed files with 502 additions and 201 deletions

View File

@@ -4,3 +4,10 @@ DEFAULT_TENANT_SLUG=master
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
MAX_JSON_BODY_BYTES=1048576
MAX_IMPORT_JSON_BODY_BYTES=10485760
AUTH_SMS_PROVIDER=mock
AUTH_CODE_PEPPER=replace_with_a_long_random_secret
AUTH_SESSION_SECRET=replace_with_another_long_random_secret
# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key

View File

@@ -0,0 +1,73 @@
import crypto from 'node:crypto';
import { config } from './config.js';
import { queryOne } from './db.js';
import { getHeader, type RequestContext } from './http.js';
export interface SessionIdentity {
id: string;
username: string | null;
phone: string | null;
name: string | null;
avatarUrl: string | null;
primaryRole: string;
createdAt: string;
tenantId: string;
sessionId: string;
sessionExpiresAt: string;
}
interface RequestAuthState {
bearerToken: string;
session: SessionIdentity | null;
sessionResolved: boolean;
}
const requestAuthState = new WeakMap<RequestContext, RequestAuthState>();
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
export async function findUserBySessionToken(token: string) {
const tokenHash = hashSessionToken(token);
return queryOne<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",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
and s.revoked_at is null
and s.expires_at > now()
limit 1
`,
[tokenHash],
);
}
export async function hydrateRequestAuth(ctx: RequestContext) {
const cached = requestAuthState.get(ctx);
if (cached?.sessionResolved) return cached;
const bearerToken = bearerTokenFrom(ctx);
const session = bearerToken ? await findUserBySessionToken(bearerToken) : null;
const state = { bearerToken, session, sessionResolved: true };
requestAuthState.set(ctx, state);
return state;
}
export function currentSessionFromContext(ctx: RequestContext) {
return requestAuthState.get(ctx)?.session || null;
}
export function hasInvalidBearerSession(ctx: RequestContext) {
const state = requestAuthState.get(ctx);
return Boolean(state?.bearerToken && !state.session);
}

View File

@@ -14,6 +14,8 @@ export interface ApiConfig {
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
allowLegacyAuthHeaders: boolean;
allowPlatformAdminKey: boolean;
platformAdminApiKey: string;
storageDefaultProvider: string;
storageDefaultBucket: string;
@@ -79,6 +81,12 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
}
if (nextConfig.allowLegacyAuthHeaders) {
failures.push('ALLOW_LEGACY_AUTH_HEADERS=true is not allowed in production');
}
if (nextConfig.allowPlatformAdminKey) {
failures.push('ALLOW_PLATFORM_ADMIN_KEY=true is not allowed in production');
}
if (failures.length > 0) {
throw new Error(`Invalid production API configuration: ${failures.join('; ')}`);
@@ -102,6 +110,8 @@ const loadedConfig: ApiConfig = {
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
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),
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),

View File

@@ -1,28 +1,76 @@
import { config } from './config.js';
import { hydrateRequestAuth } from './auth-context.js';
import { getHeader, HttpError, type RequestContext } from './http.js';
export type JsonObject = Record<string, unknown>;
export function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
function legacyTenantIdFrom(ctx: RequestContext) {
return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
}
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
const userId =
function legacyUserIdFrom(ctx: RequestContext, body?: JsonObject) {
return (
getHeader(ctx.req, 'x-user-id') ||
ctx.url.searchParams.get('userId') ||
(typeof body?.userId === 'string' ? body.userId : '');
(typeof body?.userId === 'string' ? body.userId : '')
);
}
export async function tenantIdFrom(ctx: RequestContext) {
const auth = await hydrateRequestAuth(ctx);
const legacyTenantId = legacyTenantIdFrom(ctx);
if (auth.session) {
if (legacyTenantId && legacyTenantId !== auth.session.tenantId) {
throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH');
}
return auth.session.tenantId;
}
if (!legacyTenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return legacyTenantId;
}
export async function userIdFrom(ctx: RequestContext, body?: JsonObject) {
const auth = await hydrateRequestAuth(ctx);
if (auth.session) {
const legacyUserId = legacyUserIdFrom(ctx, body);
if (legacyUserId && legacyUserId !== auth.session.id) {
throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH');
}
return auth.session.id;
}
if (auth.bearerToken) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
const userId = legacyUserIdFrom(ctx, body);
if (!userId) {
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
}
if (!config.allowLegacyAuthHeaders) {
throw new HttpError(401, 'User identity must be resolved from a trusted session in this environment', 'TRUSTED_USER_REQUIRED');
}
return userId;
}
export async function optionalUserIdFrom(ctx: RequestContext, body?: JsonObject) {
const auth = await hydrateRequestAuth(ctx);
if (auth.session) {
const legacyUserId = legacyUserIdFrom(ctx, body);
if (legacyUserId && legacyUserId !== auth.session.id) {
throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH');
}
return auth.session.id;
}
if (auth.bearerToken) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
if (!config.allowLegacyAuthHeaders) return '';
return legacyUserIdFrom(ctx, body);
}
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
@@ -96,8 +144,17 @@ export function optionalStringArray(body: JsonObject, key: string): string[] {
return value.map(item => String(item)).filter(Boolean);
}
export function requirePlatformAdmin(ctx: RequestContext) {
export async function requirePlatformAdmin(ctx: RequestContext) {
const auth = await hydrateRequestAuth(ctx);
if (auth.session?.primaryRole === 'platform_admin') return;
if (auth.bearerToken) {
throw new HttpError(401, 'Invalid or expired platform admin session', 'AUTH_SESSION_INVALID');
}
const provided = getHeader(ctx.req, 'x-platform-admin-key');
if (provided && !config.allowPlatformAdminKey) {
throw new HttpError(401, 'Platform admin API key is disabled in this environment', 'PLATFORM_ADMIN_KEY_DISABLED');
}
if (!provided || provided !== config.platformAdminApiKey) {
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}

View File

@@ -1,15 +1,14 @@
import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js';
import { query, transaction } from '../../core/db.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
import { createSmsProvider } from './providers.js';
import {
assertChinaPhone,
bearerTokenFrom,
clientIpFrom,
createLoginSession,
findUserBySessionToken,
generateSmsCode,
hashSmsCode,
normalizePurpose,
@@ -77,7 +76,7 @@ async function activeSmsProviderName(tenantId: string) {
}
export async function sendSmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
@@ -168,7 +167,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
}
export async function verifySmsCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const phone = assertChinaPhone(requiredString(body, 'phone'));
const code = requiredString(body, 'code');
@@ -316,7 +315,7 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
}
export async function meRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) {
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
@@ -348,7 +347,7 @@ export async function meRoute(ctx: RequestContext) {
}
export async function logoutRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) return { ok: true };

View File

@@ -1,8 +1,8 @@
import crypto from 'node:crypto';
import type pg from 'pg';
import { hashSessionToken } from '../../core/auth-context.js';
import { config } from '../../core/config.js';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
export interface PlatformUserSummary {
id: string;
@@ -60,40 +60,6 @@ export function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
export async function findUserBySessionToken(token: string) {
const tokenHash = hashSessionToken(token);
return queryOne<
PlatformUserSummary & {
tenantId: string;
sessionId: string;
sessionExpiresAt: string;
}
>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
and s.revoked_at is null
and s.expires_at > now()
limit 1
`,
[tokenHash],
);
}
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
const match = authorization.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || '';
}
export async function createLoginSession(
client: pg.PoolClient,
input: {

View File

@@ -1,5 +1,5 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
@@ -18,14 +18,6 @@ interface CatalogAssetRow {
subjectId: string | null;
}
function optionalUserId(ctx: RequestContext) {
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
}
function hasUserContext(ctx: RequestContext) {
return !!optionalUserId(ctx);
}
async function hasActiveMembership(tenantId: string, userId: string) {
const row = await queryOne<{ id: string }>(
`
@@ -64,13 +56,15 @@ async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAss
}
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
if (asset.visibility === 'public' || asset.visibility === 'tenant') {
return { userId: await optionalUserIdFrom(ctx), svip: false };
}
if (asset.visibility === 'private') {
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
}
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const member = await hasActiveMembership(tenantId, userId);
if (!member) {
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
@@ -86,7 +80,7 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
}
export async function assetsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const regionId = stringParam(ctx, 'regionId');
@@ -95,7 +89,7 @@ export async function assetsRoute(ctx: RequestContext) {
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
const userPresent = hasUserContext(ctx);
const userPresent = Boolean(await optionalUserIdFrom(ctx));
const params: unknown[] = [tenantId];
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
@@ -151,7 +145,7 @@ export async function assetsRoute(ctx: RequestContext) {
}
export async function assetDownloadRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
if (!assetId) {
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');

View File

@@ -8,7 +8,7 @@ function optionalUuidParam(ctx: RequestContext, name: string) {
}
export async function contentEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryType = stringParam(ctx, 'entryType');
const includeHidden = stringParam(ctx, 'includeHidden') === 'true';
@@ -44,7 +44,7 @@ export async function contentEntriesRoute(ctx: RequestContext) {
}
export async function contentNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const entryId = stringParam(ctx, 'entryId');
if (!entryId) {
throw new HttpError(400, 'entryId is required', 'REQUIRED_FIELD');
@@ -97,7 +97,7 @@ export async function contentNodesRoute(ctx: RequestContext) {
}
export async function questionCollectionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
@@ -146,7 +146,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) {
}
export async function practiceBlueprintsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
const collectionId = optionalUuidParam(ctx, 'collectionId');
@@ -195,7 +195,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) {
}
export async function collectionQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const collectionId = stringParam(ctx, 'collectionId');
if (!collectionId) {
throw new HttpError(400, 'collectionId is required', 'REQUIRED_FIELD');

View File

@@ -1,22 +1,9 @@
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
import type { RequestContext } from '../../core/http.js';
import { query } from '../../core/db.js';
function tenantIdFrom(ctx: RequestContext) {
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
if (!tenantId) {
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
}
return tenantId;
}
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
const value = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), max);
}
import { intParam, tenantIdFrom } from '../../core/request.js';
export async function regionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const items = await query(
`
@@ -34,7 +21,7 @@ export async function regionsRoute(ctx: RequestContext) {
}
export async function regionModulesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
@@ -61,7 +48,7 @@ export async function regionModulesRoute(ctx: RequestContext) {
}
export async function moduleNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const moduleId = ctx.url.searchParams.get('moduleId');
const parentId = ctx.url.searchParams.get('parentId');
@@ -98,7 +85,7 @@ export async function moduleNodesRoute(ctx: RequestContext) {
}
export async function schoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1'];
@@ -124,7 +111,7 @@ export async function schoolsRoute(ctx: RequestContext) {
}
export async function majorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
@@ -155,7 +142,7 @@ export async function majorsRoute(ctx: RequestContext) {
}
export async function subjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const majorId = ctx.url.searchParams.get('majorId');
@@ -203,7 +190,7 @@ export async function subjectsRoute(ctx: RequestContext) {
}
export async function categoriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const nodeId = ctx.url.searchParams.get('nodeId');
const params: unknown[] = [tenantId];
@@ -235,7 +222,7 @@ export async function categoriesRoute(ctx: RequestContext) {
}
export async function questionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const categoryId = ctx.url.searchParams.get('categoryId');
const nodeId = ctx.url.searchParams.get('nodeId');
@@ -309,7 +296,7 @@ export async function questionsRoute(ctx: RequestContext) {
}
export async function vocabularyUnitsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
@@ -336,7 +323,7 @@ export async function vocabularyUnitsRoute(ctx: RequestContext) {
}
export async function vocabularyWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const unitId = ctx.url.searchParams.get('unitId');
const limit = intParam(ctx, 'limit', 1000, 2000);
const params: unknown[] = [tenantId];
@@ -368,7 +355,7 @@ export async function vocabularyWordsRoute(ctx: RequestContext) {
}
export async function handbookSubjectsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
@@ -395,7 +382,7 @@ export async function handbookSubjectsRoute(ctx: RequestContext) {
}
export async function handbookChaptersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const subjectId = ctx.url.searchParams.get('subjectId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
@@ -422,7 +409,7 @@ export async function handbookChaptersRoute(ctx: RequestContext) {
}
export async function handbookEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const chapterId = ctx.url.searchParams.get('chapterId');
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
const params: unknown[] = [tenantId];
@@ -450,7 +437,7 @@ export async function handbookEntriesRoute(ctx: RequestContext) {
}
export async function bannersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
@@ -478,7 +465,7 @@ export async function bannersRoute(ctx: RequestContext) {
}
export async function faqsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
@@ -503,7 +490,7 @@ export async function faqsRoute(ctx: RequestContext) {
}
export async function announcementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const items = await query(
`
select id, legacy_id as "legacyId", content, link,
@@ -521,7 +508,7 @@ export async function announcementsRoute(ctx: RequestContext) {
}
export async function productsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
@@ -548,7 +535,7 @@ export async function productsRoute(ctx: RequestContext) {
}
export async function timelinesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const schoolId = ctx.url.searchParams.get('schoolId');
const params: unknown[] = [tenantId];
@@ -580,7 +567,7 @@ export async function timelinesRoute(ctx: RequestContext) {
}
export async function svipPlansRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId');
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];

View File

@@ -45,8 +45,8 @@ interface EntitlementRow {
export async function createOrderRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const planId = requiredString(body, 'planId');
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
const payMethod = optionalString(body, 'payMethod') || 'manual';
@@ -133,8 +133,8 @@ export async function createOrderRoute(ctx: RequestContext) {
}
export async function ordersRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const items = await query<EntitlementRow>(
@@ -157,8 +157,8 @@ export async function ordersRoute(ctx: RequestContext) {
}
export async function entitlementsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const now = new Date().toISOString();
const items: EntitlementRow[] = await query<EntitlementRow>(
@@ -217,8 +217,8 @@ export async function entitlementsRoute(ctx: RequestContext) {
}
export async function entitlementCheckRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const regionId = ctx.url.searchParams.get('regionId') || '';
const now = new Date().toISOString();
@@ -255,7 +255,7 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
export async function confirmManualPaymentRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const orderNo = requiredString(body, 'orderNo');
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
const amountCents = optionalInteger(body, 'amountCents', -1);
@@ -324,8 +324,8 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
export async function redeemActivationCodeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const code = requiredString(body, 'code');
const regionId = optionalString(body, 'regionId') || null;

View File

@@ -342,8 +342,8 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly)
export async function createPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const assembly = await buildPracticeAssembly(tenantId, body);
const questionIds = await assembleQuestionIds(tenantId, assembly);
@@ -404,8 +404,8 @@ export async function createPracticeSessionRoute(ctx: RequestContext) {
export async function submitAnswerRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const selectedOptions = optionalStringArray(body, 'selectedOptions');
const answerText = optionalString(body, 'answerText');
@@ -489,8 +489,8 @@ export async function submitAnswerRoute(ctx: RequestContext) {
}
export async function favoriteQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const items = await query(
@@ -513,8 +513,8 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
const favorite = body.favorite !== false;
@@ -541,8 +541,8 @@ export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
}
export async function wrongQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
const limit = intParam(ctx, 'limit', 100, 500);
@@ -568,8 +568,8 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const questionId = requiredString(body, 'questionId');
await query(
@@ -590,8 +590,8 @@ function normalizeWordStatus(value: string) {
}
export async function wordProgressRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const status = stringParam(ctx, 'status');
const limit = intParam(ctx, 'limit', 500, 2000);
@@ -619,8 +619,8 @@ export async function wordProgressRoute(ctx: RequestContext) {
export async function updateWordProgressRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
@@ -680,8 +680,8 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
}
export async function favoriteWordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const limit = intParam(ctx, 'limit', 500, 2000);
@@ -707,8 +707,8 @@ export async function favoriteWordsRoute(ctx: RequestContext) {
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const wordId = requiredString(body, 'wordId');
const favorite = body.favorite !== false;
const note = optionalString(body, 'note') || null;
@@ -739,8 +739,8 @@ export async function toggleFavoriteWordRoute(ctx: RequestContext) {
}
export async function wordStatsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const unitId = stringParam(ctx, 'unitId');
const item = await queryOne(

View File

@@ -33,7 +33,7 @@ function toDateText(value: unknown) {
}
export async function platformOverviewRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
queryOne<{
@@ -110,7 +110,7 @@ export async function platformOverviewRoute(ctx: RequestContext) {
}
export async function platformPlansRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
const items = await query(
@@ -130,7 +130,7 @@ export async function platformPlansRoute(ctx: RequestContext) {
}
export async function tenantsRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const status = listQuery(ctx, 'status');
const billingStatus = listQuery(ctx, 'billingStatus');
@@ -178,7 +178,7 @@ export async function tenantsRoute(ctx: RequestContext) {
}
export async function tenantDetailRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
@@ -259,7 +259,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
}
export async function createTenantRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const slug = normalizeSlug(requiredString(body, 'slug'));
@@ -388,7 +388,7 @@ export async function createTenantRoute(ctx: RequestContext) {
}
export async function updateTenantStatusRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -415,7 +415,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
}
export async function upsertBillingProfileRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -467,7 +467,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
}
export async function createSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -530,7 +530,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) {
}
export async function tenantInvoicesRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const status = listQuery(ctx, 'status');
@@ -650,7 +650,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
}
export async function createInvoiceRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -676,7 +676,7 @@ export async function createInvoiceRoute(ctx: RequestContext) {
}
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -761,7 +761,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
}
export async function recordUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');
@@ -788,7 +788,7 @@ export async function recordUsageRoute(ctx: RequestContext) {
}
export async function tenantUsageRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const tenantId = ctx.url.searchParams.get('tenantId') || '';
const limit = intParam(ctx, 'limit', 100, 500);
@@ -812,7 +812,7 @@ export async function tenantUsageRoute(ctx: RequestContext) {
}
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
requirePlatformAdmin(ctx);
await requirePlatformAdmin(ctx);
const body = await readJsonBody(ctx);
const tenantId = requiredString(body, 'tenantId');

View File

@@ -40,8 +40,8 @@ function jsonArrayBodyValue(value: unknown) {
}
export async function profileMeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const limit = intParam(ctx, 'recentLimit', 8, 50);
const profile = await queryOne<ProfileRow>(
@@ -198,8 +198,8 @@ export async function profileMeRoute(ctx: RequestContext) {
export async function updateProfileMeRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx, body);
const name = optionalString(body, 'name') || null;
const avatarUrl = optionalString(body, 'avatarUrl') || null;

View File

@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto';
import type pg from 'pg';
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, optionalUserIdFrom, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
@@ -334,8 +334,8 @@ function crmPermission(auth: TenantAdminAuth) {
}
export async function referralInviteCodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const item = await transaction(async client => {
const activeMember = await userHasTenantMembership(client, tenantId, userId);
@@ -350,7 +350,7 @@ export async function referralInviteCodeRoute(ctx: RequestContext) {
}
export async function referralResolveRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const code = optionalString(body, 'code');
if (!code) return { valid: false };
@@ -368,12 +368,14 @@ export async function referralResolveRoute(ctx: RequestContext) {
}
export async function referralTrackEventRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
const refCode = normalizeCode(requiredString(body, 'refCode'));
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
const targetUserId = await optionalUserIdFrom(ctx, {
userId: nullableString(body.targetUserId) || nullableString(body.userId) || undefined,
}) || null;
const referrer = await resolveReferralCode(tenantId, refCode);
const result = await transaction(async client => {
@@ -431,9 +433,9 @@ export async function referralTrackEventRoute(ctx: RequestContext) {
}
export async function referralBindRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const body = await readJsonBody(ctx);
const studentUserId = userIdFrom(ctx, body);
const studentUserId = await userIdFrom(ctx, body);
const refCode = normalizeCode(requiredString(body, 'refCode'));
const referrer = await resolveReferralCode(tenantId, refCode);
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
@@ -772,8 +774,8 @@ export async function upsertReferralTeamRoute(ctx: RequestContext) {
}
export async function referralQrcodeRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const body = await readJsonBody(ctx);
const page = optionalString(body, 'page') || 'pages/index/index';
const provider = optionalString(body, 'provider') || 'wechat-miniapp';

View File

@@ -3,7 +3,7 @@ import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
export async function scorelineFieldsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query(
@@ -26,7 +26,7 @@ export async function scorelineFieldsRoute(ctx: RequestContext) {
}
export async function scorelineSchoolsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const q = stringParam(ctx, 'q');
const limit = intParam(ctx, 'limit', 200, 1000);
@@ -51,7 +51,7 @@ export async function scorelineSchoolsRoute(ctx: RequestContext) {
}
export async function scorelineMajorsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const limit = intParam(ctx, 'limit', 500, 2000);
@@ -76,7 +76,7 @@ export async function scorelineMajorsRoute(ctx: RequestContext) {
}
export async function scorelineRecordsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
@@ -121,7 +121,7 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
}
export async function scorelineTrendRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const schoolId = stringParam(ctx, 'schoolId');
const majorId = stringParam(ctx, 'majorId');
@@ -147,7 +147,7 @@ export async function scorelineTrendRoute(ctx: RequestContext) {
}
export async function scorelineYearsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const regionId = stringParam(ctx, 'regionId');
const items = await query<{ year: number }>(

View File

@@ -101,8 +101,8 @@ export function tenantPermissionCatalog() {
}
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`

View File

@@ -12,8 +12,8 @@ export interface TenantContentAuth {
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const userId = await userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`

View File

@@ -34,7 +34,7 @@ function videoSelectSql() {
}
export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId');
if (!questionId) {
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
@@ -54,7 +54,7 @@ export async function questionVideosRoute(ctx: RequestContext) {
export async function questionVideosBatchRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
if (!questionIds.length) {
@@ -80,7 +80,7 @@ export async function questionVideosBatchRoute(ctx: RequestContext) {
}
export async function videoSearchRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const tenantId = await tenantIdFrom(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const tags = stringParam(ctx, 'tags')
.split(',')