diff --git a/README.md b/README.md index ab71e790..b82cd7cb 100644 --- a/README.md +++ b/README.md @@ -248,10 +248,10 @@ npm run check:api npm run check:worker npm run check:importer npm run check:taro +npm run test:readiness npm run audit:runtime npm run pb:import:dry-run npm run pb:import:validate -npm run test:readiness npm run test:pb:dry-run npm run test:api npm run test:worker:crm diff --git a/apps/taro/src/services/api-auth.ts b/apps/taro/src/services/api-auth.ts new file mode 100644 index 00000000..5993ff21 --- /dev/null +++ b/apps/taro/src/services/api-auth.ts @@ -0,0 +1,61 @@ +export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy'; + +export type SupabaseAccessTokenProvider = () => Promise; + +async function defaultH5SupabaseAccessTokenProvider() { + if (process.env.TARO_ENV !== 'h5') return null; + const { getSupabaseAccessToken } = await import('./supabase'); + return getSupabaseAccessToken(); +} + +let supabaseAccessTokenProvider: SupabaseAccessTokenProvider = defaultH5SupabaseAccessTokenProvider; + +export function setSupabaseAccessTokenProviderForTest(provider: SupabaseAccessTokenProvider | null) { + supabaseAccessTokenProvider = provider || defaultH5SupabaseAccessTokenProvider; +} + +export async function resolveApiAuthorization(input: { + authMode?: ApiAuthMode; + hasTokenOverride: boolean; + explicitToken?: string | null; + legacyToken?: string | null; +}) { + const authMode = input.authMode || 'auto'; + if (authMode === 'none') return null; + + if (input.hasTokenOverride) { + return input.explicitToken || null; + } + + if (authMode === 'legacy') { + return input.legacyToken || null; + } + + const supabaseToken = await supabaseAccessTokenProvider(); + if (supabaseToken) return supabaseToken; + + if (authMode === 'supabase') return null; + return input.legacyToken || null; +} + +export function buildApiHeaders(input: { + tenantId?: string | null; + token?: string | null; + extraHeaders?: Record; +}) { + const headers: Record = { + 'content-type': 'application/json', + ...(input.tenantId ? { 'x-tenant-id': input.tenantId } : {}), + ...(input.token ? { authorization: `Bearer ${input.token}` } : {}), + }; + + for (const [key, value] of Object.entries(input.extraHeaders || {})) { + const normalizedKey = key.toLowerCase(); + if (normalizedKey === 'authorization' || normalizedKey === 'x-tenant-id') { + throw new Error(`Reserved API header cannot be overridden by page code: ${key}`); + } + headers[key] = value; + } + + return headers; +} diff --git a/apps/taro/src/services/api.ts b/apps/taro/src/services/api.ts index 25d551ce..4e63a4ad 100644 --- a/apps/taro/src/services/api.ts +++ b/apps/taro/src/services/api.ts @@ -1,6 +1,8 @@ import Taro from '@tarojs/taro'; import { appEnv, ensureRuntimeConfigLoaded } from '@/env'; import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types'; +import { buildApiHeaders, resolveApiAuthorization } from './api-auth'; +import type { ApiAuthMode } from './api-auth'; import { getStorage, removeStorage, setStorage } from './storage'; const TENANT_KEY = 'tiku:tenant'; @@ -44,6 +46,9 @@ export function clearSession() { removeStorage(SESSION_KEY); } +export type { ApiAuthMode, SupabaseAccessTokenProvider } from './api-auth'; +export { setSupabaseAccessTokenProviderForTest } from './api-auth'; + function normalizeBaseUrl(baseUrl: string) { return baseUrl.replace(/\/+$/, ''); } @@ -64,20 +69,24 @@ export async function apiRequest( body?: unknown; tenantId?: string | null; token?: string | null; + authMode?: ApiAuthMode; headers?: Record; } = {}, ): Promise { await ensureRuntimeConfigLoaded(); const tenant = getTenantContext(); const session = getSession(); - const token = options.token ?? session?.token ?? null; - const tenantId = options.tenantId ?? tenant?.tenantId ?? null; - const headers: Record = { - 'content-type': 'application/json', - ...(tenantId ? { 'x-tenant-id': tenantId } : {}), - ...(token ? { authorization: `Bearer ${token}` } : {}), - ...options.headers, - }; + const hasTenantOverride = Object.prototype.hasOwnProperty.call(options, 'tenantId'); + const hasTokenOverride = Object.prototype.hasOwnProperty.call(options, 'token'); + const tenantId = hasTenantOverride ? options.tenantId : tenant?.tenantId ?? null; + const authMode = options.authMode || 'auto'; + const token = await resolveApiAuthorization({ + authMode, + hasTokenOverride, + explicitToken: options.token, + legacyToken: session?.token, + }); + const headers = buildApiHeaders({ tenantId, token, extraHeaders: options.headers }); const response = await Taro.request({ url: buildUrl(path, options.query), @@ -116,6 +125,7 @@ export async function resolveTenant(input: { host?: string; tenantCode?: string tenantCode: input.tenantCode || appEnv.tenantCode, }, tenantId: null, + authMode: 'none', }); const tenantId = payload.item?.tenantId || payload.tenant?.tenantId || payload.tenant?.id; if (!tenantId) throw new ApiError({ status: 500, code: 'TENANT_RESOLVE_INVALID', message: '租户解析结果缺少 tenantId' }); diff --git a/apps/taro/src/services/auth.ts b/apps/taro/src/services/auth.ts index 2348fc36..6394ef63 100644 --- a/apps/taro/src/services/auth.ts +++ b/apps/taro/src/services/auth.ts @@ -6,6 +6,7 @@ export async function sendSmsCode(phone: string, purpose: 'login' | 'bind_phone' method: 'POST', body: { phone, purpose }, tenantId: null, + authMode: 'none', }); } @@ -13,6 +14,7 @@ export async function verifySmsCode(phone: string, code: string, purpose: 'login const payload = await apiRequest>('/api/auth/sms/verify', { method: 'POST', body: { phone, code, purpose }, + authMode: 'none', }); if (payload.session?.token) saveSession(payload.session); return payload; diff --git a/apps/taro/src/services/catalog.ts b/apps/taro/src/services/catalog.ts index 9614420d..03206094 100644 --- a/apps/taro/src/services/catalog.ts +++ b/apps/taro/src/services/catalog.ts @@ -20,9 +20,9 @@ export interface DashboardSnapshot { export async function loadStudentDashboard(regionId?: string): Promise { const [entries, banners, announcements, profile] = await Promise.all([ - apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId } }), - apiRequest<{ items?: unknown[] }>('/api/catalog/banners'), - apiRequest<{ items?: unknown[] }>('/api/catalog/announcements'), + apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId }, authMode: 'none' }), + apiRequest<{ items?: unknown[] }>('/api/catalog/banners', { authMode: 'none' }), + apiRequest<{ items?: unknown[] }>('/api/catalog/announcements', { authMode: 'none' }), apiRequest<{ item?: unknown }>('/api/profile/me').catch(() => ({ item: null })), ]); return { @@ -145,64 +145,69 @@ export interface SignedAssetLink { } export async function loadRegions() { - return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions'); + return apiRequest<{ items?: RegionItem[] }>('/api/catalog/regions', { authMode: 'none' }); } export async function loadContentEntries(regionId?: string, entryType?: string) { - return apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId, entryType } }); + return apiRequest<{ items?: ContentEntry[] }>('/api/catalog/content-entries', { query: { regionId, entryType }, authMode: 'none' }); } export async function loadContentNodes(entryId: string, parentId: string | null = 'root', mode: 'children' | 'flat' = 'children') { - return apiRequest<{ items?: ContentNode[] }>('/api/catalog/content-nodes', { query: { entryId, parentId, mode } }); + return apiRequest<{ items?: ContentNode[] }>('/api/catalog/content-nodes', { query: { entryId, parentId, mode }, authMode: 'none' }); } export async function loadQuestionCollections(input: { entryId?: string; nodeId?: string; limit?: number }) { return apiRequest<{ items?: QuestionCollection[] }>('/api/catalog/question-collections', { query: { entryId: input.entryId, nodeId: input.nodeId, limit: input.limit || 100 }, + authMode: 'none', }); } export async function loadCollectionQuestions(collectionId: string, limit = 200) { return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/question-collections/questions', { query: { collectionId, limit }, + authMode: 'none', }); } export async function loadQuestions(query: { entryId?: string; contentNodeId?: string; collectionId?: string; questionIds?: string[]; limit?: number }) { return apiRequest<{ items?: import('./learning').QuestionItem[] }>('/api/catalog/questions', { query: { ...query, questionIds: query.questionIds?.join(','), limit: query.limit || 200 }, + authMode: 'none', }); } export async function loadPracticeBlueprints(input: { entryId?: string; nodeId?: string; collectionId?: string; mode?: string }) { return apiRequest<{ items?: PracticeBlueprint[] }>('/api/catalog/practice-blueprints', { query: input, + authMode: 'none', }); } export async function loadVocabularyUnits(regionId?: string) { - return apiRequest<{ items?: VocabularyUnit[] }>('/api/catalog/vocabulary-units', { query: { regionId } }); + return apiRequest<{ items?: VocabularyUnit[] }>('/api/catalog/vocabulary-units', { query: { regionId }, authMode: 'none' }); } export async function loadVocabularyWords(unitId: string) { - return apiRequest<{ items?: import('./learning').VocabularyWord[] }>('/api/catalog/vocabulary-words', { query: { unitId } }); + return apiRequest<{ items?: import('./learning').VocabularyWord[] }>('/api/catalog/vocabulary-words', { query: { unitId }, authMode: 'none' }); } export async function loadHandbookSubjects(regionId?: string) { - return apiRequest<{ items?: HandbookSubject[] }>('/api/catalog/handbook-subjects', { query: { regionId } }); + return apiRequest<{ items?: HandbookSubject[] }>('/api/catalog/handbook-subjects', { query: { regionId }, authMode: 'none' }); } export async function loadHandbookChapters(subjectId: string) { - return apiRequest<{ items?: HandbookChapter[] }>('/api/catalog/handbook-chapters', { query: { subjectId } }); + return apiRequest<{ items?: HandbookChapter[] }>('/api/catalog/handbook-chapters', { query: { subjectId }, authMode: 'none' }); } export async function loadHandbookEntries(chapterId: string, includeContent = true) { - return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent } }); + return apiRequest<{ items?: HandbookEntry[] }>('/api/catalog/handbook-entries', { query: { chapterId, includeContent }, authMode: 'none' }); } export async function loadScorelineRecords(query: { regionId?: string; schoolId?: string; majorId?: string; year?: number; pageSize?: number } = {}) { return apiRequest<{ items?: ScorelineRecord[]; total?: number }>('/api/scoreline/records', { query: { ...query, pageSize: query.pageSize || 20 }, + authMode: 'none', }); } diff --git a/apps/taro/src/services/commerce.ts b/apps/taro/src/services/commerce.ts index c5a05221..27594ff6 100644 --- a/apps/taro/src/services/commerce.ts +++ b/apps/taro/src/services/commerce.ts @@ -89,7 +89,7 @@ export interface CouponClaimResult { } export async function loadSvipPlans(regionId?: string) { - return apiRequest<{ items?: SvipPlan[] }>('/api/catalog/svip-plans', { query: { regionId } }); + return apiRequest<{ items?: SvipPlan[] }>('/api/catalog/svip-plans', { query: { regionId }, authMode: 'none' }); } export async function createOrder(body: { @@ -146,7 +146,7 @@ export async function loadEntitlements() { export async function checkActivationCode(code: string, regionId?: string) { return apiRequest<{ valid?: boolean; item?: Record; reasonCode?: string; message?: string }>( '/api/commerce/activation-codes/check', - { method: 'POST', body: { code, regionId } }, + { method: 'POST', body: { code, regionId }, authMode: 'none' }, ); } diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 54e89641..432c8705 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -38,6 +38,7 @@ - `practice_blueprints` - 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。 - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 +- `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization`、`x-tenant-id` 或 `x-user-id`。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 - H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。 diff --git a/docs/refactor/supabase-frontend-access-strategy.md b/docs/refactor/supabase-frontend-access-strategy.md index ac211096..59b8ee6b 100644 --- a/docs/refactor/supabase-frontend-access-strategy.md +++ b/docs/refactor/supabase-frontend-access-strategy.md @@ -139,7 +139,7 @@ PLATFORM_ADMIN_API_KEY ## API 请求目标形态 -当前迁移期: +历史迁移期接口曾允许: ```text Authorization: Bearer @@ -147,13 +147,15 @@ x-tenant-id: x-user-id: ``` -生产目标: +新的 Taro client 已经禁止页面使用 `x-user-id` 表示当前用户,并默认采用 Supabase JWT 优先: ```text Authorization: Bearer x-tenant-id: # 可选租户上下文;不是身份来源,必须与 JWT tenant claim 或 membership 匹配 ``` +`apps/taro/src/services/api.ts` 的 `apiRequest` 默认 `authMode='auto'`:H5 优先发送 Supabase access token,没有 Supabase token 时才兜底迁移期 `tk_` session。公共接口必须显式使用 `authMode='none'`,迁移演练才允许使用 `authMode='legacy'`,云端正式回归可用 `authMode='supabase'` 强制暴露残留 legacy 依赖。`headers.Authorization` 和 `headers['x-tenant-id']` 是保留 header,页面代码不能覆盖。 + 当前 `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`,后端会校验该用户确实属于该租户。 生产时后端负责: @@ -183,6 +185,7 @@ ALLOW_PLATFORM_ADMIN_KEY=false - 不要把 Supabase-first 误解成前端直写所有表。 - 不要让 Taro 直接写订单、支付、权益、租户配置、CRM、导入相关表。 - 不要把 service role/secret key 放进 Taro。 +- 不要在页面里绕过 `apiRequest` 手写 `Taro.request`、`Authorization`、`x-tenant-id` 或 `x-user-id`。 - 不要为了少写接口而放宽 RLS。 - 新增前端直连 Supabase table/view/RPC 之前,必须先补: - 明确 RLS policy。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 6bb3eba7..82240192 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -53,7 +53,23 @@ F:\project\参考\旧题库项目\src - 私有 PDF、资料、视频、对象存储签名。 - 租户后台、平台后台、内容导入、CRM、销售/代理、数据看板。 -前端应封装一个统一 API client,所有页面禁止直接散写 `Taro.request`。 +前端应封装一个统一 API client,所有页面禁止直接散写 `Taro.request`。当前统一入口是: + +```text +apps/taro/src/services/api.ts +apps/taro/src/services/api-auth.ts +``` + +`apiRequest` 的默认鉴权模式是 `authMode='auto'`: + +| authMode | 行为 | 适用场景 | +| --- | --- | --- | +| `auto` | H5 先读取 Supabase Auth access token;没有 Supabase token 时才兜底迁移期 `tk_` session | 绝大多数登录后业务接口 | +| `supabase` | 只发送 Supabase access token,没有 token 也不回退 `tk_` | 云端 JWT/RLS 回归、需要提前发现迁移 token 依赖的页面 | +| `legacy` | 只发送迁移期 `tk_` session | 本地迁移、旧数据导入演练、临时内网联调 | +| `none` | 不发送 Authorization | 租户解析、短信发送/验证、公开目录、公开套餐等接口 | + +公共接口必须显式传 `authMode: 'none'`,例如 `tenant/resolve`、`catalog/regions`、`catalog/content-entries`、`catalog/svip-plans`、`auth/sms/send`。平台全局接口或租户解析如不应带租户上下文,必须显式传 `tenantId: null`;不能依赖当前本地缓存的租户。 本地迁移期仍可兼容旧请求头,但新的 Taro 请求封装必须按下面目标实现: @@ -71,20 +87,14 @@ x-tenant-id: # 作为租户上下文,不能作为身份依据 前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析:如果 Authorization 存在,用户态接口以 token 映射出的业务用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH` 或 `AUTH_SESSION_INVALID`。 -H5 使用 Supabase Auth 时,推荐请求流程: +H5 使用 Supabase Auth 时,不要在页面里手写 `Authorization`,推荐请求流程是由统一 client 完成: ```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, - }, -}); +await apiRequest('/api/profile/me'); ``` +`apps/taro/src/services/api-auth.ts` 会读取 Supabase session 并生成 `Authorization: Bearer `。页面层禁止通过 `headers.Authorization` 或 `headers['x-tenant-id']` 覆盖身份和租户上下文;如确实要切换租户上下文,必须使用 `tenantId` 显式参数。该规则由 `scripts/taro-api-auth-mode-test.js` 纳入 `npm run test:readiness`。 + 后端会通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射用户身份。`x-tenant-id` 只能帮助确定当前租户上下文,不能让用户访问自己没有 membership 的租户。 生产或云端测试建议设置: diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index 8f36c62b..fa87e672 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -146,10 +146,15 @@ ALLOW_LEGACY_AUTH_HEADERS=false ALLOW_PLATFORM_ADMIN_KEY=false ``` +H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务接口请求是否都发送 `Bearer `。`tk_` session 只作为迁移/本地兜底,不应成为线上长期依赖。 + ## 前端请求边界 - 所有页面统一通过 `apps/taro/src/services/api.ts` 调用后端。 +- 默认请求模式是 `authMode='auto'`:H5 先用 Supabase JWT,没有 JWT 才兜底迁移期 `tk_` session。 +- 公共接口、短信登录、租户解析必须显式 `authMode='none'`;平台全局或租户解析不应带租户上下文时必须显式 `tenantId: null`。 - H5 可以用 Supabase client 管理 Auth session/JWT,但业务数据默认走 `apps/api`。 +- 页面代码不能通过 `headers` 覆盖 `Authorization` 或 `x-tenant-id`,身份和租户上下文只能走统一 client 的 token provider、`authMode` 和 `tenantId` 参数。 - 订单、支付、权益、内容导入、后台配置、CRM、对象存储签名、视频播放签名必须走后端命令层。 - 登录后禁止传 `x-user-id` 或 body/query `userId` 表示当前用户。 - 私有 PDF、图片、视频不能由前端拼接 URL,必须使用 `content_assets` 和后端短签名。 diff --git a/package.json b/package.json index b4c8a0a5..f6dfadbb 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "test:worker:exports": "npm run db:smoke-seed && npm run build:worker && node scripts/export-worker-integration-test.js", "test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js", "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db", diff --git a/scripts/taro-api-auth-mode-test.js b/scripts/taro-api-auth-mode-test.js new file mode 100644 index 00000000..edfab9b9 --- /dev/null +++ b/scripts/taro-api-auth-mode-test.js @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = process.cwd(); +const authModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/services/api-auth.ts`).href); + +authModule.setSupabaseAccessTokenProviderForTest(async () => 'supabase_access_token'); + +assert.equal( + await authModule.resolveApiAuthorization({ + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + }), + 'supabase_access_token', +); + +assert.equal( + await authModule.resolveApiAuthorization({ + authMode: 'none', + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + }), + null, +); + +assert.equal( + await authModule.resolveApiAuthorization({ + authMode: 'legacy', + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + }), + 'tk_legacy_token', +); + +assert.equal( + await authModule.resolveApiAuthorization({ + hasTokenOverride: true, + explicitToken: null, + legacyToken: 'tk_legacy_token', + }), + null, +); + +authModule.setSupabaseAccessTokenProviderForTest(async () => null); + +assert.equal( + await authModule.resolveApiAuthorization({ + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + }), + 'tk_legacy_token', +); + +assert.equal( + await authModule.resolveApiAuthorization({ + authMode: 'supabase', + hasTokenOverride: false, + legacyToken: 'tk_legacy_token', + }), + null, +); + +const authenticatedHeaders = authModule.buildApiHeaders({ + tenantId: 'tenant-1', + token: 'supabase_access_token', + extraHeaders: { 'x-client-version': 'test' }, +}); +assert.equal(authenticatedHeaders.authorization, 'Bearer supabase_access_token'); +assert.equal(authenticatedHeaders['x-tenant-id'], 'tenant-1'); +assert.equal(authenticatedHeaders['x-client-version'], 'test'); + +const publicHeaders = authModule.buildApiHeaders({ tenantId: null, token: null }); +assert.equal(publicHeaders.authorization, undefined); +assert.equal(publicHeaders['x-tenant-id'], undefined); + +assert.throws( + () => authModule.buildApiHeaders({ extraHeaders: { Authorization: 'Bearer unsafe' } }), + /Reserved API header cannot be overridden/, +); +assert.throws( + () => authModule.buildApiHeaders({ tenantId: 'tenant-1', extraHeaders: { 'X-Tenant-Id': 'tenant-2' } }), + /Reserved API header cannot be overridden/, +); + +authModule.setSupabaseAccessTokenProviderForTest(null); + +console.log('[PASS] Taro API auth mode guardrails');