feat: add supabase jwt auth context

This commit is contained in:
Codex
2026-06-29 00:17:29 +08:00
parent fbcfa5127e
commit 9553836ac7
20 changed files with 452 additions and 85 deletions

View File

@@ -11,7 +11,7 @@
目前已经完成并在本地验证通过的内容:
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
@@ -22,7 +22,7 @@
还没有达到生产交付的部分:
- 正式 Supabase Auth/JWT 鉴权还没替换迁移期请求头
- Supabase Auth/JWT 已可联调;生产前还要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化
- 真实短信、微信登录、QQ 登录、微信支付、支付宝等 provider adapter 还没接完。
- OSS/COS/Supabase Storage 上传下载签名 provider 已接入上传后校验、PDF 预览、防盗链和视频水印还没完成。
- Excel/CSV 导入、分数线/视频批量导入和异步 worker 还没完成。
@@ -143,13 +143,13 @@ apps/api/src/features/
video/ 题目视频讲解
```
迁移期 API 上下文目前使用
API 身份上下文:
- `x-tenant-id`
- `x-user-id`
- `x-platform-admin-key`
- 推荐:`Authorization: Bearer <supabase_access_token>`,可配合 `x-tenant-id` 提供当前租户上下文。
- 本地/迁移期:`Authorization: Bearer <tk_session>`
- 兼容旧测试:`x-user-id``x-platform-admin-key` 仅允许在 `ALLOW_LEGACY_AUTH_HEADERS=true``ALLOW_PLATFORM_ADMIN_KEY=true` 的非生产环境使用。
生产环境必须替换为 Supabase Auth/JWT 或服务端 session
生产环境必须设置 `ALLOW_LEGACY_AUTH_HEADERS=false``ALLOW_PLATFORM_ADMIN_KEY=false`,前端不能再传 `x-user-id` 代表当前用户
## 重要安全约定
@@ -174,8 +174,8 @@ npm run check:refactor
优先继续补:
1. Supabase Auth/JWT 正式鉴权、生产配置 fail-fast、请求体大小限制和租户隔离回归测试
1. 真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限
2. Taro 前端 scaffold让 H5 和小程序共用同一套 API。
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
5. 微信/QQ 登录、短信、微信支付、支付宝支付 adapter
5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权和租户采纳

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"jose": "^6.2.3",
"pg": "^8.16.3"
},
"devDependencies": {

View File

@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import { config } from './config.js';
import { queryOne } from './db.js';
import { getHeader, type RequestContext } from './http.js';
@@ -11,9 +12,11 @@ export interface SessionIdentity {
avatarUrl: string | null;
primaryRole: string;
createdAt: string;
tenantId: string;
tenantId: string | null;
sessionId: string;
sessionExpiresAt: string;
authSource: 'app_session' | 'supabase_jwt';
authUserId: string | null;
}
interface RequestAuthState {
@@ -23,6 +26,7 @@ interface RequestAuthState {
}
const requestAuthState = new WeakMap<RequestContext, RequestAuthState>();
let remoteJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
@@ -30,6 +34,10 @@ export function bearerTokenFrom(ctx: RequestContext) {
return match?.[1]?.trim() || '';
}
function tenantContextFrom(ctx: RequestContext) {
return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId') || '';
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
@@ -40,7 +48,9 @@ export async function findUserBySessionToken(token: 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"
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt",
'app_session'::text as "authSource",
u.auth_user_id as "authUserId"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
@@ -52,12 +62,140 @@ export async function findUserBySessionToken(token: string) {
);
}
function expectedIssuer() {
return config.authJwtIssuer || undefined;
}
function expectedAudience() {
return config.authJwtAudience || undefined;
}
function symmetricJwtSecret() {
return new TextEncoder().encode(config.authJwtSecret);
}
function jwksKeySet() {
if (!config.authJwtJwksUrl) return null;
if (!remoteJwks) remoteJwks = createRemoteJWKSet(new URL(config.authJwtJwksUrl));
return remoteJwks;
}
async function verifySupabaseJwt(token: string) {
const verifyOptions = {
issuer: expectedIssuer(),
audience: expectedAudience(),
};
const jwks = jwksKeySet();
const verified = jwks
? await jwtVerify(token, jwks, verifyOptions)
: await jwtVerify(token, symmetricJwtSecret(), verifyOptions);
return verified.payload;
}
function objectClaim(payload: JWTPayload, key: string) {
const value = payload[key];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function tenantClaimFrom(payload: JWTPayload) {
const claim = payload.tenant_id || objectClaim(payload, 'app_metadata').tenant_id || objectClaim(payload, 'user_metadata').tenant_id;
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;
}
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>(
`
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",
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
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.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)],
);
if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) {
return platformUser;
}
if (!requestedTenantId) return null;
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",
tm.tenant_id as "tenantId",
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id
where u.auth_user_id = $1::uuid
and tm.status = 'active'
and tm.tenant_id = $2::uuid
order by case
when u.primary_role = 'platform_admin' then 0
when tm.role = 'tenant_owner' then 1
when tm.role = 'tenant_admin' then 2
else 9
end, tm.created_at asc
limit 1
`,
[authUserId, requestedTenantId, sessionExpiryFrom(payload)],
);
}
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;
let session: SessionIdentity | null = null;
if (bearerToken) {
session = bearerToken.startsWith('tk_')
? await findUserBySessionToken(bearerToken)
: await findUserBySupabaseJwt(bearerToken, tenantContextFrom(ctx));
}
const state = { bearerToken, session, sessionResolved: true };
requestAuthState.set(ctx, state);
return state;

View File

@@ -11,6 +11,10 @@ export interface ApiConfig {
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
authJwtIssuer: string;
authJwtAudience: string;
authJwtSecret: string;
authJwtJwksUrl: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
@@ -44,6 +48,7 @@ loadDotenv();
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
@@ -78,6 +83,9 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) {
failures.push('AUTH_SESSION_SECRET must be a strong production secret');
}
if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
}
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');
}
@@ -107,6 +115,10 @@ const loadedConfig: ApiConfig = {
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authJwtIssuer: envString('AUTH_JWT_ISSUER', ''),
authJwtAudience: envString('AUTH_JWT_AUDIENCE', 'authenticated'),
authJwtSecret: envString('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET),
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),

View File

@@ -23,6 +23,9 @@ export async function tenantIdFrom(ctx: RequestContext) {
if (legacyTenantId && legacyTenantId !== auth.session.tenantId) {
throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH');
}
if (!auth.session.tenantId) {
throw new HttpError(400, 'Tenant context is required for this authenticated request', 'TENANT_ID_REQUIRED');
}
return auth.session.tenantId;
}
@@ -148,7 +151,7 @@ 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');
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}
const provided = getHeader(ctx.req, 'x-platform-admin-key');

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js';
import { currentSessionFromContext, hydrateRequestAuth } 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';
@@ -352,12 +352,9 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
export async function meRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) {
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
}
await hydrateRequestAuth(ctx);
const session = await findUserBySessionToken(token);
const session = currentSessionFromContext(ctx);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
@@ -378,17 +375,16 @@ export async function meRoute(ctx: RequestContext) {
session: {
id: session.sessionId,
expiresAt: session.sessionExpiresAt,
source: session.authSource,
},
};
}
export async function logoutRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) return { ok: true };
const session = await findUserBySessionToken(token);
if (session && session.tenantId === tenantId) {
await hydrateRequestAuth(ctx);
const session = currentSessionFromContext(ctx);
if (session?.authSource === 'app_session' && session.tenantId === tenantId) {
await query(
`
update app_private.auth_sessions

View File

@@ -62,9 +62,9 @@ types.ts 仅本领域使用的类型
- 可预期错误用 `HttpError`,生产环境不向前端暴露内部异常。
- 写接口必须考虑幂等、审计和租户隔离;支付 webhook 必须先设计幂等键。
- 不要为了少写 API 而让前端直写复杂业务表。新增前端直连 table/view/RPC 必须先满足 RLS、最小 grant、跨租户测试、权限测试和索引要求。
- 迁移期接口可用 `x-user-id` 标识学生用户;接 Supabase Auth 后统一替换为 JWT 解析
- 登录类接口先使用 `Authorization: Bearer tk_*` 迁移期 sessionsession 明文只返回客户端,数据库只保存 hash。
- 平台运营接口使用 `x-platform-admin-key` 作为临时保护;正式上线前要迁到平台管理员 JWT 和审计日志
- 新接口优先使用 `Authorization: Bearer <supabase_access_token>`;后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份
- 迁移期仍支持 `Authorization: Bearer tk_*` sessionsession 明文只返回客户端,数据库只保存 hash。
- `x-user-id` `x-platform-admin-key` 只允许在非生产兼容模式使用;生产必须关闭 `ALLOW_LEGACY_AUTH_HEADERS``ALLOW_PLATFORM_ADMIN_KEY`
- `platform-admin` 管平台与合作商之间的 SaaS 账务,`tenant-admin` 管合作商自己的品牌、域名、公开配置、登录/商户配置、活动和兑换码,`tenant-content` 管合作商自己的题库和学习内容维护。
- `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault对前端只返回 `secretRef` 和掩码状态。
- `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。

View File

@@ -15,7 +15,7 @@
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| Supabase/PostgreSQL schema | 可联调 | `supabase/migrations` 已包含多租户、题库、学习、订单、内容、CRM、平台账务等表 |
| RLS/租户隔离 | 迁移期 | 表层普遍有 `tenant_id` 和 RLS 策略API 已接入 session 优先身份上下文;生产前继续补 Supabase JWT/RLS 回归 |
| RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 JWT/RLS 回归 |
| API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` |
| Docker API | 可联调 | `docker-compose.api.yml``apps/api/Dockerfile` 可用 |
| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、seed、API 集成测试 |
@@ -37,9 +37,10 @@
| --- | --- | --- |
| 短信验证码登录 | 可联调 | 已有验证码、冷却、hash、登录事件支持 mock、阿里云短信、腾讯云短信 provider生产仍需真实账号联调 |
| 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId |
| Supabase Auth JWT | 可联调 | API 已用 Bearer JWT 验签并通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份;支持 HS256 JWT secret 或 JWKS测试覆盖学生、租户管理员、平台管理员、错租户、坏签名 |
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder需要 code 换 token、回调域名、账号合并和审计 |
| 平台管理员鉴权 | 迁移期 | `x-platform-admin-key` 已可通过配置禁用;生产前必须换平台管理员 JWT/服务端会话 |
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 |
| 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API |

View File

@@ -20,7 +20,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- |
| 多租户底座 | 基础完成 | 租户、域名、品牌、设置、RLS 基础、审计 | Supabase Auth/JWT 替换迁移期请求头,生产 RLS 回归 |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 |
| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 |
| 租户后台 | 基础完成 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限 | 自定义角色模板、菜单/模块可见性 UI、字段级权限 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏 | 完整模考交卷报告、专项策略、公题库采纳/授权、Excel 导入 |
@@ -75,7 +75,7 @@
### P0上云测试和前端主链路前必须处理
- 生产鉴权: Supabase Auth/JWT 或服务端 session 替换 `x-tenant-id``x-user-id``x-platform-admin-key`
- 生产鉴权:API 已支持 Supabase Auth JWT;继续做真实云端 Auth/JWKS 回归、RLS 深测,并在生产关闭 `x-user-id``x-platform-admin-key` 兼容入口
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage继续完成上传后对象校验、PDF 预览、视频播放签名、防盗链和水印。
- 真实数据 dry-run导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,跑迁移和校验报告。
- 生产环境配置:补 `.env` 模板、数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。

View File

@@ -17,6 +17,7 @@
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
- `tenant`:域名/租户解析。
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
- `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
@@ -165,10 +166,11 @@ GET /api/tenant-admin/audit-logs
## 迁移期约定
- 当前写接口用 `x-tenant-id``x-user-id` 做迁移期上下文
- `auth` 当前签发迁移期 `tk_` sessiontoken hash 存在 `app_private.auth_sessions`;后续接 Supabase Auth 后,`x-user-id` 要替换为 JWT 用户身份解析
- 当前 API 已支持 Supabase Auth JWT 和迁移期 `tk_` session新前端应优先使用 `Authorization: Bearer <supabase_access_token>`
- `x-tenant-id` 只作为租户上下文,后端会校验 JWT/session 用户确实属于该租户;`x-user-id` 只允许在非生产兼容测试中使用
- `auth` 当前仍可签发迁移期 `tk_` sessiontoken hash 存在 `app_private.auth_sessions`,用于旧数据迁移和本地联调。
- 短信验证码只保存 HMAC hash不保存明文本地 `mock` provider 才会返回 `debugCode`
- `platform-admin` 当前用 `x-platform-admin-key` 做迁移期保护,生产后必须替换为平台管理员 JWT/服务端会话
- `platform-admin` 已支持平台管理员 Supabase JWT`x-platform-admin-key` 只作为非生产兼容保护
- B 端合作商年费/服务费使用 `tenant_invoices``tenant_invoice_items``tenant_invoice_payments`,不与 C 端学生订单混表。
- 订单金额以后端套餐价格为准,不信任前端传价。
- 激活码兑换和支付成功都走同一套 `grantSvipEntitlement` 权益开通逻辑。

View File

@@ -33,12 +33,13 @@
- `question_collections`
- `practice_blueprints`
- 可以接入迁移期短信登录和 `tk_` session用于本地/内网联调。
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- 可以接入租户品牌、主题、功能开关和域名/小程序参数解析。
## 不能误认为已商用完成的部分
- 生产鉴权尚未完成:当前很多接口仍用 `x-tenant-id``x-user-id``x-platform-admin-key` 作为迁移期上下文
- 生产鉴权已具备 Supabase JWT API 入口,但仍要做真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化;前端不要继续使用 `x-user-id`
- 不要把“Supabase 支持前端 Data API”误解为“本项目所有业务表都由 Taro 直写”订单、支付、权益、租户后台、导入、CRM、私有资源必须走 RPC、`apps/api`、Edge Function 或 worker 这类后端命令层。
- 真实短信、微信登录、QQ 登录、微信支付、支付宝支付 provider 还未正式接完。
- 对象存储已完成签名 provider但 PDF 预览、防盗链、视频水印、上传后校验还要补。

View File

@@ -37,8 +37,8 @@
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用真实微信小程序码、分佣结算单、CRM worker 推送待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录已实现 | 仅烟测 | 平台收费链路骨架可用,正式鉴权/审计/自动计费未完成 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、OAuth 占位已实现 | 仅烟测 | 本地可测,真实短信/微信/QQ 登录未完成 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试 | 平台收费链路骨架可用,平台审计报表/自动计费待补 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/微信网页/QQ 登录生产联调待补 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
@@ -238,7 +238,7 @@ platform-admin:
上线前至少还需要完成:
1. 正式鉴权:迁移期 `x-tenant-id``x-user-id``x-platform-admin-key` 要替换为 Supabase Auth/JWT/服务端 session并逐表验证 RLS
1. 正式鉴权:API 已支持 Supabase Auth JWT生产前继续做真实云端 Auth/JWKS 回归、RLS 深测,并关闭 `x-user-id``x-platform-admin-key` 兼容入口
2. 国内能力接入短信、微信登录、微信小程序登录、QQ 登录、微信支付、支付宝支付的租户级配置入口已具备,但真实 provider adapter、回调验签和 webhook 幂等仍需实现。
3. 核心缺口 API学生端个人中心、分数线、题目视频详情、背单词进度/收藏已补基础 API下一步重点是后台维护、权限、统计和真实业务验收。
4. 后台能力:题库录入、题目/单词/知识手册 JSON 批量导入、资源台账、视频绑定、知识手册维护、分数线维护、品牌/商户/登录/活动/兑换码配置、销售客资、CRM 队列、成员权限、审计查询已补 APIExcel 导入、分数线/视频导入、真实对象存储签名和前端操作台待补。

View File

@@ -88,7 +88,7 @@
### P0前端联调到云端前
1. 正式鉴权Supabase Auth/JWT 或服务端 session替换迁移期请求头
1. 正式鉴权:API 已支持 Supabase Auth JWT;继续补真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化
2. 生产配置 fail-fast默认密钥、`CORS=*`、mock SMS、平台默认 key 必须禁止。
3. JSON body size limit导入接口可配置更大限制但必须有上限。
4. 真实租户隔离测试:跨租户读写、角色越权、资源下载越权。

View File

@@ -20,6 +20,9 @@
当前后端已经进入“session 优先、迁移头受控兼容”的状态:
- `Authorization: Bearer <tk_session>` 会优先解析 `app_private.auth_sessions`,并作为用户身份来源。
- `Authorization: Bearer <supabase_access_token>` 已支持服务端验签,后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务用户和租户成员。
- Supabase JWT 支持 `AUTH_JWT_SECRET``AUTH_JWT_JWKS_URL`;生产推荐优先配置 Supabase Auth JWKS或在自托管兼容模式下配置强随机 JWT secret。
- JWT 可以在 `app_metadata.tenant_id` 或请求租户上下文中确定当前租户;如果两者冲突,后端拒绝,不允许前端覆盖 token 中的租户声明。
- 登录后如果请求中的 `x-user-id`、query/body `userId` 与 session 用户不一致,后端返回 `AUTH_USER_MISMATCH`
- 登录后如果请求中的 `x-tenant-id` 与 session 租户不一致,后端返回 `AUTH_TENANT_MISMATCH`
- 带了无效 bearer token 的用户态接口不会回退到 `x-user-id`
@@ -37,10 +40,10 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
## P0正式云端测试前必须完成
1. 正式用户鉴权
- 已支持服务端 session 解析可信 userId。
- 生产前继续接 Supabase Auth/JWT或将现有 server session 明确作为正式方案
- 已支持服务端 session 和 Supabase Auth JWT 解析可信 userId。
- 生产前必须用真实 Supabase Auth 项目或自托管 Auth 实例跑一轮云端 JWT 回归
- 前端禁止通过 query/body/header 指定 userId。
- `GET /api/auth/me` 后续要补租户成员、角色、权限返回。
- `GET /api/auth/me` 已支持 Supabase JWT后续要补租户成员、角色、权限返回。
2. 正式租户上下文
- H5 可由域名解析租户。
@@ -50,12 +53,13 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
3. 平台管理员鉴权
- `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。
- 生产前仍需替换为平台管理员 JWT/session 和审计日志
- 平台管理员也要有 JWT/session、角色、审计日志
- 已支持平台管理员 Supabase JWT且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim
- 生产前继续补平台后台关键操作审计报表和更细权限点
4. 生产配置 fail-fast
- `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`
- 禁止默认 `AUTH_SESSION_SECRET`
- 禁止默认 `AUTH_JWT_SECRET`,除非配置了 `AUTH_JWT_JWKS_URL`
- 禁止默认 `PLATFORM_ADMIN_API_KEY`
- 禁止 `CORS_ORIGIN=*`
- 禁止 `AUTH_SMS_PROVIDER=mock`

View File

@@ -28,10 +28,10 @@
### P0 上云测试前必须补齐
1. 生产鉴权
- Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id``x-user-id``x-platform-admin-key`
- 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界
- 做一轮真实 JWT + RLS 回归测试
- 已补生产配置 fail-fast 和 JSON body size limit后续继续补正式身份上下文
- 已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试
- 继续补真实云端 Auth/JWKS 回归、RLS 深测、自定义角色模板和菜单/模块/字段级权限
- 前端联调时禁止继续使用 `x-user-id``x-tenant-id` 只作为租户上下文,不能作为身份依据
2. 对象存储
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。

View File

@@ -147,20 +147,34 @@ x-user-id: <userId>
生产目标:
```text
Authorization: Bearer <supabase_access_token_or_server_session>
Authorization: Bearer <supabase_access_token>
x-tenant-id: <tenantId> # 可选租户上下文;不是身份来源,必须与 JWT tenant claim 或 membership 匹配
```
当前 `apps/api` 已支持 Supabase Auth JWT 验签,并通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射到业务身份。H5/Taro 登录后可以直接把 Supabase access token 放到 `Authorization`。如果 JWT 内没有 `tenant_id` claim前端仍要根据域名/小程序码解析后的租户传 `x-tenant-id`,后端会校验该用户确实属于该租户。
生产时后端负责:
- 验证 JWT。
- 从 JWT/session 获取 userId。
- 根据 host/tenantCode/JWT claims 解析 tenant。
- 从 JWT 映射业务 userId。
- 根据 host/tenantCode/JWT claims/请求上下文解析 tenant。
- 校验用户属于该租户。
- 校验角色和权限。
- 执行业务逻辑。
前端不再传 `x-user-id`,也不能靠传 `tenantId` 获得跨租户数据。
生产 API 环境变量至少要配置:
```text
AUTH_JWT_JWKS_URL=https://<supabase-auth-host>/auth/v1/.well-known/jwks.json
# 或自托管/兼容模式下使用强随机 secret
AUTH_JWT_SECRET=<strong-jwt-secret>
AUTH_JWT_AUDIENCE=authenticated
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
```
## 对后续 AI/开发者的硬性约束
- 不要把 Supabase-first 误解成前端直写所有表。

View File

@@ -65,10 +65,27 @@ x-tenant-id: <tenantId> # 仅作为登录前/公开目录租户上下文;登
生产目标:
```text
Authorization: Bearer <supabase_access_token_or_server_session>
Authorization: Bearer <supabase_access_token>
x-tenant-id: <tenantId> # 作为租户上下文,不能作为身份依据
```
前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 session 优先解析:如果 Authorization 存在,用户态接口以 session 用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH`
前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析:如果 Authorization 存在,用户态接口以 token 映射出的业务用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH``AUTH_SESSION_INVALID`
H5 使用 Supabase Auth 时,推荐请求流程:
```ts
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token;
await api.request('/api/profile/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
'x-tenant-id': tenantStore.tenantId,
},
});
```
后端会通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射用户身份。`x-tenant-id` 只能帮助确定当前租户上下文,不能让用户访问自己没有 membership 的租户。
生产或云端测试建议设置:

10
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"dependencies": {
"@supabase/storage-js": "^2.108.2",
"ali-oss": "^6.23.0",
"jose": "^6.2.3",
"pg": "^8.16.3"
},
"devDependencies": {
@@ -730,6 +731,15 @@
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT"
},
"node_modules/jose": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-base64": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz",

View File

@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import http from 'node:http';
import net from 'node:net';
import { SignJWT } from 'jose';
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
@@ -12,6 +13,10 @@ const TENANT_ADMIN_USER_ID = process.env.TENANT_ADMIN_USER_ID || '00000000-0000-
const TENANT_OPERATOR_USER_ID = '00000000-0000-0000-0000-000000000103';
const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104';
const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105';
const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
const AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const START_SERVER = process.argv.includes('--start-server');
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
@@ -346,6 +351,30 @@ function signAlipayParams(params) {
return crypto.createSign('RSA-SHA256').update(canonical).sign(paymentFixture.alipayPlatformPrivateKey, 'base64');
}
async function createSupabaseJwt(authUserId, options = {}) {
const secret = new TextEncoder().encode(options.secret || AUTH_JWT_SECRET);
const now = Math.floor(Date.now() / 1000);
const claims = {
sub: authUserId,
aud: 'authenticated',
role: 'authenticated',
phone: options.phone || undefined,
app_metadata: {
provider: 'phone',
providers: ['phone'],
...(options.appRole ? { app_role: options.appRole } : {}),
...(options.tenantId === false ? {} : { tenant_id: options.tenantId || MAIN_TENANT_ID }),
},
user_metadata: options.userMetadata || {},
};
return new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt(now)
.setExpirationTime(now + 60 * 60)
.sign(secret);
}
async function waitForProcessExit(child, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
@@ -482,6 +511,88 @@ async function testTrustedSessionIdentity() {
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
}
async function testSupabaseJwtIdentity() {
const studentJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
const studentHeaders = { authorization: `Bearer ${studentJwt}` };
const me = await request('/api/auth/me', {
userId: false,
headers: studentHeaders,
});
assert.equal(me.user?.id, USER_ID, 'auth/me should map Supabase auth user to platform user');
assert.equal(me.session?.source, 'supabase_jwt', 'auth/me should expose Supabase JWT session source');
const profile = await request('/api/profile/me', {
userId: false,
headers: studentHeaders,
});
assert.equal(profile.item?.userId, USER_ID, 'profile should accept Supabase JWT identity');
const tenantHeaderJwt = await createSupabaseJwt(AUTH_USER_ID, { tenantId: false });
const tenantHeaderProfile = await request('/api/profile/me', {
userId: false,
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
});
assert.equal(tenantHeaderProfile.item?.userId, USER_ID, 'tenant header should scope Supabase JWT when token has no tenant claim');
const missingTenant = await request('/api/profile/me', {
tenantId: false,
userId: false,
headers: { authorization: `Bearer ${tenantHeaderJwt}` },
expectStatus: 400,
});
assert.equal(missingTenant.code, 'TENANT_ID_REQUIRED', 'Supabase JWT without tenant context must not guess a tenant');
const mismatchedTenant = await request('/api/profile/me', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: studentHeaders,
expectStatus: 401,
});
assert.equal(mismatchedTenant.code, 'AUTH_SESSION_INVALID', 'Supabase JWT tenant claim must not be overwritten by request tenant');
const badSignatureJwt = await createSupabaseJwt(AUTH_USER_ID, { secret: 'wrong-development-jwt-secret-change-me' });
const badSignature = await request('/api/profile/me', {
userId: false,
headers: { authorization: `Bearer ${badSignatureJwt}` },
expectStatus: 401,
});
assert.equal(badSignature.code, 'AUTH_SESSION_INVALID', 'bad Supabase JWT signatures must be rejected');
const tenantAdminJwt = await createSupabaseJwt(AUTH_TENANT_ADMIN_USER_ID, { phone: '13800000001' });
const tenantOverview = await request('/api/tenant-admin/overview', {
userId: false,
headers: { authorization: `Bearer ${tenantAdminJwt}` },
});
assert.equal(tenantOverview.item?.id, MAIN_TENANT_ID, 'tenant admin should access own tenant through Supabase JWT');
const studentTenantAdminDenied = await request('/api/tenant-admin/overview', {
userId: false,
headers: studentHeaders,
expectStatus: 403,
});
assert.equal(studentTenantAdminDenied.code, 'TENANT_ADMIN_REQUIRED', 'student Supabase JWT must not access tenant admin APIs');
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
phone: '13999999999',
appRole: 'platform_admin',
});
const platformOverview = await request('/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: { authorization: `Bearer ${platformAdminJwt}` },
});
assert.ok(platformOverview.item?.tenants?.total >= 1, 'platform admin Supabase JWT should access platform overview');
const studentPlatformDenied = await request('/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: studentHeaders,
expectStatus: 403,
});
assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs');
}
async function testLegacyAuthHeadersDisabled() {
const login = await loginBySms('13800000006');
const baseUrl = await startLegacyDisabledServer();
@@ -2554,6 +2665,7 @@ async function main() {
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
await check('trusted session identity', testTrustedSessionIdentity);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('catalog and learning', testCatalogAndLearning);
await check('profile', testProfile);

View File

@@ -6,6 +6,10 @@ const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const ids = {
authUser: '00000000-0000-0000-0000-00000000a101',
authTenantAdminUser: '00000000-0000-0000-0000-00000000a102',
authTenantOperatorUser: '00000000-0000-0000-0000-00000000a103',
authPlatformAdminUser: '00000000-0000-0000-0000-00000000a999',
user: '00000000-0000-0000-0000-000000000101',
tenantAdminUser: '00000000-0000-0000-0000-000000000102',
tenantOperatorUser: '00000000-0000-0000-0000-000000000103',
@@ -51,6 +55,7 @@ const ids = {
partnerInvoice: '00000000-0000-0000-0000-000000000903',
partnerInvoiceItem: '00000000-0000-0000-0000-000000000904',
partnerInvoicePayment: '00000000-0000-0000-0000-000000000905',
platformAdminUser: '00000000-0000-0000-0000-000000000999',
};
const pool = new Pool({ connectionString: databaseUrl });
@@ -212,46 +217,83 @@ async function main() {
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values ($1, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
updated_at = now()
`,
[ids.user],
);
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
values ($1, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantAdminUser],
);
await client.query(
`
insert into public.platform_users (id, username, phone, name, primary_role, raw_profile)
insert into auth.users (
id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data,
created_at, updated_at
)
values
($1, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
($2, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
($3, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
($1, 'authenticated', 'authenticated', '13800000000', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
($2, 'authenticated', 'authenticated', '13800000001', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
($3, 'authenticated', 'authenticated', '13800000003', now(), '{"provider":"phone","providers":["phone"]}'::jsonb, '{}'::jsonb, now(), now()),
($4, 'authenticated', 'authenticated', '13999999999', now(), '{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb, '{}'::jsonb, now(), now())
on conflict (id)
do update set phone = excluded.phone,
raw_app_meta_data = excluded.raw_app_meta_data,
updated_at = now()
`,
[ids.authUser, ids.authTenantAdminUser, ids.authTenantOperatorUser, ids.authPlatformAdminUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values ($1, $2, 'smoke_student', '13800000000', 'Smoke Student', 'student', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
phone = excluded.phone,
name = excluded.name,
updated_at = now()
`,
[ids.user, ids.authUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values ($1, $2, 'smoke_tenant_admin', '13800000001', 'Smoke Tenant Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
[ids.tenantAdminUser, ids.authTenantAdminUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values
($1, $2, 'smoke_tenant_operator', '13800000003', 'Smoke Tenant Operator', 'tenant_operator', '{"source":"smoke-seed"}'::jsonb),
($3, null, 'smoke_tenant_sales', '13800000004', 'Smoke Tenant Sales', 'sales', '{"source":"smoke-seed"}'::jsonb),
($4, null, 'smoke_tenant_agent', '13800000005', 'Smoke Tenant Agent', 'agent', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.tenantOperatorUser, ids.authTenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
);
await client.query(
`
insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile)
values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"source":"smoke-seed"}'::jsonb)
on conflict (id)
do update set username = excluded.username,
auth_user_id = excluded.auth_user_id,
phone = excluded.phone,
name = excluded.name,
primary_role = excluded.primary_role,
updated_at = now()
`,
[ids.platformAdminUser, ids.authPlatformAdminUser],
);
await client.query(
@@ -288,6 +330,20 @@ async function main() {
[tenantId, ids.tenantAdminUser],
);
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions)
values
($1, $2, 'tenant_operator', 'active', '{"marketing:read":true}'::jsonb),
($1, $3, 'platform_admin', 'active', '{"*":true}'::jsonb)
on conflict (tenant_id, user_id, role)
do update set status = 'active',
permissions = excluded.permissions,
updated_at = now()
`,
[tenantId, ids.tenantOperatorUser, ids.platformAdminUser],
);
await client.query(
`
insert into public.student_profiles (tenant_id, user_id, stats, progress)