feat: establish production SaaS foundation

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

View File

@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import { CorsPolicy, normalizeCorsOrigin } from '../apps/api/src/core/cors.ts';
assert.deepEqual(normalizeCorsOrigin('HTTPS://Campus-A.Example.com:443'), {
origin: 'https://campus-a.example.com',
hostname: 'campus-a.example.com',
protocol: 'https:',
hasNonDefaultPort: false,
});
assert.equal(normalizeCorsOrigin('https://campus-a.example.com/path'), null);
assert.equal(normalizeCorsOrigin('https://campus-a.example.com,https://evil.example'), null);
assert.equal(normalizeCorsOrigin('null'), null);
let now = 1_000;
let lookupCalls = 0;
const domainStates = new Map([
['active.example.test', true],
['inactive.example.test', false],
]);
const policy = new CorsPolicy({
staticOrigins: ['https://platform.example.test', 'http://localhost:5173'],
tenantDomainsEnabled: true,
positiveCacheTtlMs: 1_000,
negativeCacheTtlMs: 200,
maxCacheEntries: 2,
now: () => now,
lookupTenantDomain: async host => {
lookupCalls += 1;
return domainStates.get(host) === true;
},
});
assert.deepEqual(await policy.evaluate(''), { allowed: true, allowOrigin: null, reason: 'no-origin' });
assert.equal(lookupCalls, 0, 'non-browser requests without Origin must not query tenant domains');
assert.deepEqual(await policy.evaluate('HTTPS://PLATFORM.EXAMPLE.TEST:443'), {
allowed: true,
allowOrigin: 'https://platform.example.test',
reason: 'static-origin',
});
assert.equal(lookupCalls, 0, 'static platform origins must not query tenant domains');
assert.equal((await policy.evaluate('http://localhost:5173')).allowed, true);
assert.equal((await policy.evaluate('http://active.example.test')).allowed, false, 'dynamic tenant origins must use HTTPS');
assert.equal((await policy.evaluate('https://active.example.test:8443')).allowed, false, 'dynamic tenant origins must not use custom ports');
assert.equal((await policy.evaluate('https://127.0.0.1')).allowed, false, 'dynamic tenant origins must not allow loopback hosts');
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true);
assert.equal(lookupCalls, 1);
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true);
assert.equal(lookupCalls, 1, 'active tenant domains must use the positive cache');
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false);
assert.equal(lookupCalls, 2);
domainStates.set('inactive.example.test', true);
now += 199;
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, false);
assert.equal(lookupCalls, 2, 'inactive tenant domains must use the negative cache before expiry');
now += 1;
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true);
assert.equal(lookupCalls, 3, 'negative cache expiry must refresh the database decision');
domainStates.set('active.example.test', false);
now += 799;
assert.equal((await policy.evaluate('https://active.example.test')).allowed, true, 'positive cache must remain stable before expiry');
now += 1;
assert.equal((await policy.evaluate('https://active.example.test')).allowed, false, 'positive cache expiry must observe a disabled tenant domain');
assert.equal(lookupCalls, 4);
assert.equal((await policy.evaluate('https://unknown.example.test')).allowed, false, 'unknown tenant domains must be rejected');
assert.equal(lookupCalls, 5);
assert.equal((await policy.evaluate('https://inactive.example.test')).allowed, true);
assert.equal(lookupCalls, 6, 'bounded cache must evict the least-recently-used domain after reaching its maximum');
let concurrentCalls = 0;
let releaseLookup;
const concurrentPolicy = new CorsPolicy({
staticOrigins: [],
tenantDomainsEnabled: true,
positiveCacheTtlMs: 1_000,
negativeCacheTtlMs: 200,
maxCacheEntries: 100,
lookupTenantDomain: async () => {
concurrentCalls += 1;
await new Promise(resolve => { releaseLookup = resolve; });
return true;
},
});
const pendingA = concurrentPolicy.evaluate('https://concurrent.example.test');
const pendingB = concurrentPolicy.evaluate('https://concurrent.example.test');
await new Promise(resolve => setImmediate(resolve));
assert.equal(concurrentCalls, 1, 'same-host cache misses must share one database lookup');
releaseLookup();
assert.equal((await pendingA).allowed, true);
assert.equal((await pendingB).allowed, true);
let failureNow = 10_000;
let failureCalls = 0;
let databaseHealthy = false;
const lookupErrors = [];
const failurePolicy = new CorsPolicy({
staticOrigins: [],
tenantDomainsEnabled: true,
positiveCacheTtlMs: 1_000,
negativeCacheTtlMs: 200,
maxCacheEntries: 100,
now: () => failureNow,
onLookupError: error => lookupErrors.push(error),
lookupTenantDomain: async () => {
failureCalls += 1;
if (!databaseHealthy) throw new Error('database unavailable');
return true;
},
});
const failedLookup = await failurePolicy.evaluate('https://recover.example.test');
assert.equal(failedLookup.allowed, false, 'tenant domain lookup failures must fail closed');
assert.equal(failedLookup.reason, 'tenant-domain-lookup-failed');
assert.equal(lookupErrors.length, 1);
databaseHealthy = true;
failureNow += 199;
const cachedFailure = await failurePolicy.evaluate('https://recover.example.test');
assert.equal(cachedFailure.allowed, false);
assert.equal(cachedFailure.reason, 'tenant-domain-lookup-failed', 'cached lookup failures must retain their failure reason');
assert.equal(failureCalls, 1, 'lookup failures must use a short negative cache to avoid database stampedes');
failureNow += 1;
assert.equal((await failurePolicy.evaluate('https://recover.example.test')).allowed, true);
assert.equal(failureCalls, 2, 'a recovered database must be consulted after negative cache expiry');
const disabledPolicy = new CorsPolicy({
staticOrigins: ['https://platform.example.test'],
tenantDomainsEnabled: false,
positiveCacheTtlMs: 1_000,
negativeCacheTtlMs: 200,
maxCacheEntries: 100,
lookupTenantDomain: async () => true,
});
assert.equal((await disabledPolicy.evaluate('https://tenant.example.test')).allowed, false);
assert.equal((await disabledPolicy.evaluate('https://platform.example.test')).allowed, true);
console.log('[PASS] dynamic tenant CORS policy, bounded cache and fail-closed contract');

View File

@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const dockerfile = fs.readFileSync(path.join(repoRoot, 'apps', 'api', 'Dockerfile'), 'utf8');
assert.match(
dockerfile,
/ARG NODE_IMAGE=node:20\.20\.2-alpine3\.23@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293/,
'API image must pin the reviewed multi-architecture Node/Alpine manifest',
);
assert.match(dockerfile, /FROM deps AS production-deps/);
assert.match(dockerfile, /npm prune --omit=dev --workspaces --include-workspace-root/);
assert.match(dockerfile, /COPY --from=production-deps --chown=node:node \/app\/node_modules \.\/node_modules/);
assert.match(dockerfile, /COPY --from=build --chown=node:node \/app\/apps\/api\/dist \.\/apps\/api\/dist/);
assert.match(dockerfile, /\nUSER node\n/);
assert.match(dockerfile, /CMD \["node", "apps\/api\/dist\/apps\/api\/src\/server\.js"\]/);
assert.doesNotMatch(
dockerfile.slice(dockerfile.lastIndexOf(`FROM \${NODE_IMAGE} AS runner`)),
/package-lock\.json|package\.json|scripts\/import-pocketbase|COPY --from=deps \/app\/node_modules/,
'runtime stage must contain only production dependencies and compiled API files',
);
console.log('[PASS] API Docker runtime least-privilege contract');

View File

@@ -6,8 +6,14 @@ import net from 'node:net';
import pg from 'pg';
import { SignJWT, exportJWK } from 'jose';
import writeXlsxFile from 'write-excel-file/node';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
const MAIN_TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const PARTNER_TENANT_ID = process.env.PARTNER_TENANT_ID || '00000000-0000-0000-0000-000000000901';
const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101';
@@ -108,6 +114,8 @@ let jwksServer = null;
let fakeWechatServer = null;
let fakeQqServer = null;
let fakeWechatPayServer = null;
let tenantPresentationSnapshot = null;
const providerBillJobIds = new Set();
function buildUrl(path, query = {}) {
return buildUrlAt(apiBase, path, query);
@@ -250,6 +258,162 @@ async function setTenantFeatureFlag(tenantId, flag, enabled) {
}
}
async function insertIntegrationAuthSession(tenantId, userId, source) {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
const result = await pool.query(
`
insert into app_private.auth_sessions (
tenant_id, user_id, token_hash, provider, expires_at, metadata
)
values ($1, $2, $3, 'integration-test', now() + interval '1 hour', $4::jsonb)
returning id
`,
[
tenantId,
userId,
crypto.createHash('sha256').update(`${source}:${crypto.randomUUID()}`).digest('hex'),
JSON.stringify({ source: 'api-integration-test', scenario: source }),
],
);
return result.rows[0].id;
} finally {
await pool.end();
}
}
async function assertIntegrationAuthSessionRevoked(sessionId, message) {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
const result = await pool.query(
`select revoked_at as "revokedAt" from app_private.auth_sessions where id = $1`,
[sessionId],
);
assert.ok(result.rows[0]?.revokedAt, message);
} finally {
await pool.end();
}
}
async function cleanupIntegrationAuthSessions() {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
await pool.query(
`delete from app_private.auth_sessions where metadata->>'source' = 'api-integration-test'`,
);
} finally {
await pool.end();
}
}
async function cleanupSmsIntegrationRateLimits() {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
await pool.query('delete from app_private.sms_send_rate_limits');
} finally {
await pool.end();
}
}
async function assertLocalIntegrationTarget() {
if (!START_SERVER) {
throw new Error(
'API integration tests are destructive and must use --start-server. Use the dedicated remote smoke commands for deployed environments.',
);
}
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
const client = await pool.connect();
try {
await assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation: destructiveTestConfirmation,
operation: 'API integration test',
});
} finally {
client.release();
}
} finally {
await pool.end();
}
}
async function captureTenantPresentationSnapshot() {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
const result = await pool.query(
`
select
(select row_to_json(b) from public.tenant_branding b where b.tenant_id = $1) as branding,
(select row_to_json(c) from public.tenant_theme_configs c where c.tenant_id = $1) as theme
`,
[MAIN_TENANT_ID],
);
return result.rows[0] || { branding: null, theme: null };
} finally {
await pool.end();
}
}
async function restoreTenantPresentationSnapshot(snapshot) {
if (!snapshot) return;
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
const client = await pool.connect();
try {
await client.query('begin');
await client.query('delete from public.tenant_theme_configs where tenant_id = $1', [MAIN_TENANT_ID]);
await client.query('delete from public.tenant_branding where tenant_id = $1', [MAIN_TENANT_ID]);
if (snapshot.branding) {
await client.query(
`insert into public.tenant_branding select * from json_populate_record(null::public.tenant_branding, $1::json)`,
[JSON.stringify(snapshot.branding)],
);
}
if (snapshot.theme) {
await client.query(
`insert into public.tenant_theme_configs select * from json_populate_record(null::public.tenant_theme_configs, $1::json)`,
[JSON.stringify(snapshot.theme)],
);
}
await client.query('commit');
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
} finally {
client.release();
await pool.end();
}
}
async function cleanupProviderBillJobs(billDate = '') {
const idsToDelete = [...providerBillJobIds];
if (!billDate && idsToDelete.length === 0) return;
const pool = new pg.Pool({ connectionString: databaseUrl, max: 1 });
try {
await pool.query(
`
delete from public.commerce_bill_download_jobs
where tenant_id = $1
and (
id = any($2::uuid[])
or (
nullif($3::text, '')::date is not null
and provider = 'wechat_pay'
and bill_date = nullif($3::text, '')::date
and bill_type = 'payment'
and metadata->>'source' = 'api-integration-test'
)
)
`,
[MAIN_TENANT_ID, idsToDelete, billDate],
);
idsToDelete.forEach(id => providerBillJobIds.delete(id));
} finally {
await pool.end();
}
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -313,6 +477,11 @@ async function startServerIfNeeded() {
...process.env,
PORT: String(port),
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
AUTH_SMS_PROVIDER: 'mock',
AUTH_SMS_TENANT_DAILY_LIMIT: '100000',
AUTH_SMS_PHONE_DAILY_LIMIT: '1000',
AUTH_SMS_IP_HOURLY_LIMIT: '100000',
AUTH_SMS_DEVICE_HOURLY_LIMIT: '1000',
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
},
@@ -340,6 +509,7 @@ async function startLegacyDisabledServer() {
...process.env,
PORT: String(port),
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
AUTH_SMS_PROVIDER: 'mock',
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
ALLOW_LEGACY_AUTH_HEADERS: 'false',
@@ -370,6 +540,7 @@ async function startJwksAuthServer(jwksUrl) {
...process.env,
PORT: String(port),
DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
AUTH_SMS_PROVIDER: 'mock',
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192',
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536',
AUTH_JWT_JWKS_URL: jwksUrl,
@@ -873,9 +1044,10 @@ async function testProductionConfigFailFast() {
assert.match(logs, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'production fail-fast should reject local_dev storage');
}
async function loginBySms(phone = '13800000000') {
async function loginBySms(phone = '13800000000', options = {}) {
const sent = await request('/api/auth/sms/send', {
userId: false,
tenantId: options.tenantId,
method: 'POST',
body: { phone, purpose: 'login' },
});
@@ -883,6 +1055,7 @@ async function loginBySms(phone = '13800000000') {
const verified = await request('/api/auth/sms/verify', {
userId: false,
tenantId: options.tenantId,
method: 'POST',
body: { phone, code: sent.debugCode, purpose: 'login' },
});
@@ -900,6 +1073,86 @@ async function sendMockSmsCode(phone, purpose) {
return sent.debugCode;
}
async function testConcurrentSmsSendReservation() {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
const tenantId = crypto.randomUUID();
const tenantSlug = `sms-concurrency-${Date.now().toString(36)}`;
const phone = `139${String(Date.now()).slice(-8)}`;
const deviceId = `api-integration-sms-${crypto.randomUUID()}`;
try {
await pool.query(
`insert into public.tenants (id, slug, name, status) values ($1, $2, 'SMS Concurrency Tenant', 'active')`,
[tenantId, tenantSlug],
);
const responses = await Promise.all(
Array.from({ length: 8 }, () => fetch(buildUrl('/api/auth/sms/send'), {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-tenant-id': tenantId,
'x-forwarded-for': '198.51.100.27',
},
body: JSON.stringify({ phone, purpose: 'login', deviceId }),
})),
);
const results = await Promise.all(
responses.map(async response => ({
status: response.status,
payload: await response.json().catch(() => ({})),
})),
);
const accepted = results.filter(result => result.status === 200);
const rejected = results.filter(result => result.status === 429);
assert.equal(accepted.length, 1, 'concurrent SMS sends must incur provider cost exactly once');
assert.equal(rejected.length, 7, 'all duplicate concurrent SMS sends must be rate limited');
assert.ok(accepted[0]?.payload?.debugCode, 'the accepted mock SMS send should expose a debug code');
assert.ok(
rejected.every(result => result.payload?.code === 'SMS_COOLDOWN'),
'duplicate concurrent SMS sends must fail through the cooldown reservation',
);
const activeReservations = await pool.query(
`
select count(*)::integer as count
from public.sms_verification_codes
where tenant_id = $1
and phone = $2
and purpose = 'login'
and consumed_at is null
and status in ('pending', 'sent')
`,
[tenantId, phone],
);
assert.equal(activeReservations.rows[0]?.count, 1, 'database must retain one active SMS reservation');
const quotaBuckets = await pool.query(
`
select dimension, request_count as "requestCount"
from app_private.sms_send_rate_limits
where tenant_id = $1
order by dimension
`,
[tenantId],
);
assert.deepEqual(
quotaBuckets.rows,
[
{ dimension: 'device', requestCount: 1 },
{ dimension: 'ip', requestCount: 1 },
{ dimension: 'phone', requestCount: 1 },
{ dimension: 'tenant', requestCount: 1 },
],
'rolled-back duplicate sends must not consume extra SMS quota',
);
} finally {
await pool.query('delete from public.tenants where id = $1', [tenantId]).catch(() => undefined);
await pool.end();
}
}
async function testTrustedSessionIdentity() {
const login = await loginBySms();
assert.equal(login.user?.id, USER_ID, 'smoke phone should log in as smoke user');
@@ -969,6 +1222,136 @@ async function testTrustedSessionIdentity() {
assert.equal(invalidSession.code, 'AUTH_SESSION_INVALID', 'invalid bearer token must not fall back to legacy user headers');
}
async function testAuthStatusEnforcement() {
const pool = new pg.Pool({ connectionString: databaseUrl });
const original = await pool.query(
`
select t.status as "tenantStatus", u.status as "userStatus", tm.status as "membershipStatus"
from public.tenants t
join public.platform_users u on u.id = $2
join public.tenant_memberships tm
on tm.tenant_id = t.id and tm.user_id = u.id and tm.role = 'student'
where t.id = $1
`,
[MAIN_TENANT_ID, USER_ID],
);
const snapshot = original.rows[0];
assert.ok(snapshot, 'auth status test requires the smoke student membership');
try {
const membershipLogin = await loginBySms();
await pool.query(
`update public.tenant_memberships set status = 'disabled', updated_at = now()
where tenant_id = $1 and user_id = $2 and role = 'student'`,
[MAIN_TENANT_ID, USER_ID],
);
const disabledMembershipSession = await request('/api/auth/me', {
userId: false,
headers: { authorization: `Bearer ${membershipLogin.session.token}` },
expectStatus: 401,
});
assert.equal(disabledMembershipSession.code, 'AUTH_SESSION_INVALID', 'disabled membership must invalidate existing app sessions');
const sentForDisabledMembership = await request('/api/auth/sms/send', {
userId: false,
method: 'POST',
body: { phone: '13800000000', purpose: 'login' },
});
const disabledMembershipLogin = await request('/api/auth/sms/verify', {
userId: false,
method: 'POST',
body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' },
expectStatus: 403,
});
assert.equal(disabledMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not reactivate a disabled membership');
const membershipAfterLogin = await pool.query(
`select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`,
[MAIN_TENANT_ID, USER_ID],
);
assert.equal(membershipAfterLogin.rows[0]?.status, 'disabled', 'failed login must preserve disabled membership status');
await pool.query(
`update public.tenant_memberships set status = 'invited', updated_at = now()
where tenant_id = $1 and user_id = $2 and role = 'student'`,
[MAIN_TENANT_ID, USER_ID],
);
const invitedMembershipLogin = await request('/api/auth/sms/verify', {
userId: false,
method: 'POST',
body: { phone: '13800000000', code: sentForDisabledMembership.debugCode, purpose: 'login' },
expectStatus: 403,
});
assert.equal(invitedMembershipLogin.code, 'AUTH_MEMBERSHIP_INACTIVE', 'login must not activate an invited membership');
await pool.query(
`
delete from public.sms_verification_codes
where tenant_id = $1
and phone = '13800000000'
and consumed_at is null
`,
[MAIN_TENANT_ID],
);
await pool.query(
`update public.tenant_memberships set status = 'active', updated_at = now()
where tenant_id = $1 and user_id = $2 and role = 'student'`,
[MAIN_TENANT_ID, USER_ID],
);
await pool.query(`update public.platform_users set status = 'disabled', updated_at = now() where id = $1`, [USER_ID]);
const disabledUserJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
const disabledUserDenied = await request('/api/auth/me', {
userId: false,
headers: { authorization: `Bearer ${disabledUserJwt}` },
expectStatus: 401,
});
assert.equal(disabledUserDenied.code, 'AUTH_SESSION_INVALID', 'disabled platform user must not authenticate with Supabase JWT');
await pool.query(`update public.platform_users set status = 'active', updated_at = now() where id = $1`, [USER_ID]);
await pool.query(`update public.tenants set status = 'suspended', updated_at = now() where id = $1`, [MAIN_TENANT_ID]);
const suspendedTenantJwt = await createSupabaseJwt(AUTH_USER_ID, { phone: '13800000000' });
const suspendedTenantDenied = await request('/api/auth/me', {
userId: false,
headers: { authorization: `Bearer ${suspendedTenantJwt}` },
expectStatus: 401,
});
assert.equal(suspendedTenantDenied.code, 'AUTH_SESSION_INVALID', 'suspended tenant must reject non-platform JWT identity');
const newTenantId = crypto.randomUUID();
const newTenantSlug = `auth-status-${Date.now().toString(36)}`;
const newPhone = `137${String(Date.now()).slice(-8)}`;
await pool.query(
`insert into public.tenants (id, slug, name, status) values ($1, $2, 'Auth Status Tenant', 'active')`,
[newTenantId, newTenantSlug],
);
const firstLogin = await loginBySms(newPhone, { tenantId: newTenantId });
assert.equal(firstLogin.isNewUser, true, 'first tenant login should create an active student membership');
const firstMembership = await pool.query(
`select status from public.tenant_memberships where tenant_id = $1 and user_id = $2 and role = 'student'`,
[newTenantId, firstLogin.user.id],
);
assert.equal(firstMembership.rows[0]?.status, 'active', 'new login membership must start active');
await pool.query('delete from public.tenants where id = $1', [newTenantId]);
await pool.query('delete from public.platform_users where id = $1', [firstLogin.user.id]);
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { phone: '13999999999' });
const platformOverview = await request('/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: { authorization: `Bearer ${platformAdminJwt}` },
});
assert.ok(platformOverview.item?.tenants?.total >= 1, 'global platform admin must remain available while a tenant is suspended');
} finally {
await pool.query(`update public.tenants set status = $2, updated_at = now() where id = $1`, [MAIN_TENANT_ID, snapshot.tenantStatus]);
await pool.query(`update public.platform_users set status = $2, updated_at = now() where id = $1`, [USER_ID, snapshot.userStatus]);
await pool.query(
`update public.tenant_memberships set status = $3, updated_at = now()
where tenant_id = $1 and user_id = $2 and role = 'student'`,
[MAIN_TENANT_ID, USER_ID, snapshot.membershipStatus],
);
await pool.end();
}
}
async function testPhoneBinding() {
const phoneSuffix = String(Date.now()).slice(-6);
const oldPhone = `13920${phoneSuffix}`;
@@ -1082,14 +1465,42 @@ async function testSupabaseJwtIdentity() {
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');
assert.ok(
platformOverview.item?.tenants?.total >= 1,
'database platform admin should accept a standard Supabase role=authenticated JWT without app_role',
);
const globallyScopedPlatformOverview = await request('/api/platform-admin/overview', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${platformAdminJwt}` },
});
assert.ok(
globallyScopedPlatformOverview.item?.tenants?.total >= 1,
'global platform admin should not require membership in a requested tenant context',
);
const elevatedStudentJwt = await createSupabaseJwt(AUTH_USER_ID, {
phone: '13800000000',
appRole: 'platform_admin',
});
const elevatedStudentDenied = await request('/api/platform-admin/overview', {
tenantId: false,
userId: false,
headers: { authorization: `Bearer ${elevatedStudentJwt}` },
expectStatus: 403,
});
assert.equal(
elevatedStudentDenied.code,
'PLATFORM_ADMIN_REQUIRED',
'JWT app_role must not elevate a non-platform database user',
);
const studentPlatformDenied = await request('/api/platform-admin/overview', {
tenantId: false,
@@ -1570,6 +1981,7 @@ async function testPlatformTenantOperationsAndAudit() {
assert.ok(created.item?.id, 'platform admin should create a tenant');
const tenantId = created.item.id;
const tenantSessionId = await insertIntegrationAuthSession(tenantId, USER_ID, 'platform-tenant-suspend');
const detail = await request('/api/platform-admin/tenants/detail', {
tenantId: false,
userId: false,
@@ -1600,6 +2012,24 @@ async function testPlatformTenantOperationsAndAudit() {
});
assert.equal(billing.item?.billingName, '集成测试更新主体', 'platform admin should update tenant billing profile');
const suspended = await request('/api/platform-admin/tenants/status', {
tenantId: false,
userId: false,
headers: adminHeaders,
method: 'PATCH',
body: {
tenantId,
status: 'suspended',
billingStatus: 'active',
reason: 'integration audit coverage',
},
});
assert.equal(suspended.item?.status, 'suspended', 'platform admin should suspend a tenant');
await assertIntegrationAuthSessionRevoked(
tenantSessionId,
'suspending a tenant must revoke all tenant sessions in the same operation',
);
const status = await request('/api/platform-admin/tenants/status', {
tenantId: false,
userId: false,
@@ -1608,8 +2038,7 @@ async function testPlatformTenantOperationsAndAudit() {
body: {
tenantId,
status: 'active',
billingStatus: 'active',
reason: 'integration audit coverage',
reason: 'restore after session revocation coverage',
},
});
assert.equal(status.item?.billingStatus, 'active', 'platform admin should update tenant billing status');
@@ -4186,6 +4615,7 @@ async function testCommerce() {
});
const billDate = shanghaiDateKey();
await cleanupProviderBillJobs(billDate);
const reconciliationRows = [
{
transactionType: 'payment',
@@ -4441,6 +4871,7 @@ async function testCommerce() {
},
});
assert.ok(providerBillJob.item?.id, 'tenant admin should request official provider bill download job');
providerBillJobIds.add(providerBillJob.item.id);
assert.equal(providerBillJob.item?.status, 'queued', 'new provider bill job should be queued');
assert.equal(providerBillJob.item?.provider, 'wechat_pay', 'provider bill job should keep provider');
assert.ok(!JSON.stringify(providerBillJob).includes(paymentFixture.wechatApiV3Key), 'provider bill job response must not leak payment secrets');
@@ -4476,6 +4907,8 @@ async function testCommerce() {
});
assert.equal(crossTenantProviderBillJobsDenied.code, 'TENANT_ADMIN_REQUIRED', 'provider bill jobs must be tenant isolated');
await cleanupProviderBillJobs(billDate);
const operationReconItems = await request('/api/commerce/reconciliation/items', {
userId: TENANT_ADMIN_USER_ID,
query: { batchId: reconciliationImport.item.id, matchStatus: 'amount_mismatch' },
@@ -7945,12 +8378,48 @@ async function testTenantAdminOps() {
assert.equal(tenantThemeAfterPublish.item?.draftTemplateCode, null, 'theme publish should clear draft template');
assert.equal(tenantThemeAfterPublish.item?.activePublicAssets?.iconSet, 'focus', 'theme publish should expose active public assets');
await request('/api/tenant-admin/branding', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
brandName: '集成测试品牌',
shortName: '集测题库',
theme: { primaryColor: '#0f766e' },
publicAssets: { iconSet: 'classic' },
},
});
const resolvedTenantTheme = await request('/api/tenant/resolve', {
userId: false,
tenantId: false,
query: { tenantCode: 'master' },
});
assert.equal(resolvedTenantTheme.branding?.theme?.primaryColor, '#123abc', 'tenant resolve should return published theme tokens to frontend');
assert.equal(resolvedTenantTheme.branding?.publicAssets?.iconSet, 'focus', 'tenant resolve should prefer published theme assets over branding fallback assets');
const themePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await themePool.query(
`
update public.tenant_theme_configs
set active_theme = '{}'::jsonb,
active_public_assets = '{}'::jsonb,
status = 'published',
published_at = now()
where tenant_id = $1
`,
[MAIN_TENANT_ID],
);
} finally {
await themePool.end();
}
const resolvedTenantBrandingFallback = await request('/api/tenant/resolve', {
userId: false,
tenantId: false,
query: { tenantCode: 'master' },
});
assert.equal(resolvedTenantBrandingFallback.branding?.theme?.primaryColor, '#0f766e', 'empty published theme should fall back to tenant branding tokens');
assert.equal(resolvedTenantBrandingFallback.branding?.publicAssets?.iconSet, 'classic', 'empty published assets should fall back to tenant branding assets');
const themeAuditLogs = await request('/api/tenant-admin/audit-logs', {
userId: TENANT_ADMIN_USER_ID,
@@ -9139,6 +9608,8 @@ async function testTenantMemberPermissionsAndAudit() {
});
assert.ok(salesBatch.item?.id, 'sales role should create code batch');
const salesSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, TENANT_SALES_USER_ID, 'tenant-member-disable');
const salesBrandingDenied = await request('/api/tenant-admin/branding', {
userId: TENANT_SALES_USER_ID,
method: 'PUT',
@@ -9155,6 +9626,10 @@ async function testTenantMemberPermissionsAndAudit() {
body: { membershipId: sales.item.id },
});
assert.equal(disableSales.item?.status, 'disabled', 'tenant admin should disable sales membership');
await assertIntegrationAuthSessionRevoked(
salesSessionId,
'disabling a tenant member must revoke that user\'s tenant sessions in the same operation',
);
const disabledSalesDenied = await request('/api/tenant-admin/code-batches', {
userId: TENANT_SALES_USER_ID,
@@ -9274,7 +9749,7 @@ async function testTenantClassStudentScopes() {
userId: USER_ID,
username: 'smoke_student',
phone: '13800000000',
name: 'Smoke Student',
name: 'Cursor Cohort Smoke Student',
regionId: ids.region,
status: 'active',
},
@@ -9288,13 +9763,40 @@ async function testTenantClassStudentScopes() {
userId: SECOND_STUDENT_USER_ID,
username: 'integration_second_student',
phone: '13800000016',
name: 'Integration Second Student',
name: 'Cursor Cohort Integration Student',
regionId: ids.region,
status: 'active',
},
});
assert.equal(secondStudent.item?.userId, SECOND_STUDENT_USER_ID, 'tenant admin should upsert another student');
const studentPageOne = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { keyword: 'Cursor Cohort', limit: 1 },
});
assert.equal(studentPageOne.items?.length, 1, 'student cursor page should honor its limit');
assert.equal(studentPageOne.hasMore, true, 'student cursor page should report more matching rows');
assert.ok(studentPageOne.nextCursor, 'student cursor page should return an opaque next cursor');
const studentPageTwo = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { keyword: 'Cursor Cohort', limit: 1, cursor: studentPageOne.nextCursor },
});
assert.equal(studentPageTwo.items?.length, 1, 'second student cursor page should contain the next row');
assert.notEqual(
studentPageTwo.items?.[0]?.membershipId,
studentPageOne.items?.[0]?.membershipId,
'student cursor pages must not repeat the boundary membership',
);
assert.equal(studentPageTwo.hasMore, false, 'second student cursor page should reach the end of the fixture');
const invalidStudentCursor = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
query: { cursor: 'not-a-valid-cursor' },
expectStatus: 400,
});
assert.equal(invalidStudentCursor.code, 'INVALID_STUDENT_CURSOR', 'student list should reject malformed cursors');
const teacherAssignment = await request('/api/tenant-admin/classes/members', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -9509,6 +10011,8 @@ async function testTenantStudentOperations() {
});
assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list');
const studentSessionId = await insertIntegrationAuthSession(MAIN_TENANT_ID, bulkStudentUserId, 'tenant-student-disable');
const disabled = await request('/api/tenant-admin/students/status', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
@@ -9519,6 +10023,10 @@ async function testTenantStudentOperations() {
},
});
assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership');
await assertIntegrationAuthSessionRevoked(
studentSessionId,
'disabling a student must revoke that student\'s tenant sessions in the same operation',
);
const disabledStudents = await request('/api/tenant-admin/students', {
userId: TENANT_ADMIN_USER_ID,
@@ -10620,13 +11128,22 @@ async function testReferralAndCrmGrowth() {
}
async function main() {
let primaryError = null;
let destructiveTargetApproved = false;
try {
await assertLocalIntegrationTarget();
destructiveTargetApproved = true;
tenantPresentationSnapshot = await captureTenantPresentationSnapshot();
await cleanupIntegrationAuthSessions();
await cleanupSmsIntegrationRateLimits();
await check('production config fail-fast', testProductionConfigFailFast);
await startServerIfNeeded();
console.log(`[INFO] API integration target: ${apiBase}`);
await check('health', () => request('/health', { userId: false }).then(payload => assert.equal(payload.ok, true)));
await check('concurrent SMS send reservation', testConcurrentSmsSendReservation);
await check('trusted session identity', testTrustedSessionIdentity);
await check('auth status enforcement', testAuthStatusEnforcement);
await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('platform admin permissions', testPlatformAdminPermissions);
@@ -10656,9 +11173,27 @@ async function main() {
await check('referral and CRM growth', testReferralAndCrmGrowth);
console.log('API integration tests complete.');
} catch (error) {
primaryError = error;
} finally {
stopServer();
const cleanupResults = destructiveTargetApproved
? await Promise.allSettled([
cleanupProviderBillJobs(shanghaiDateKey()),
cleanupIntegrationAuthSessions(),
cleanupSmsIntegrationRateLimits(),
restoreTenantPresentationSnapshot(tenantPresentationSnapshot),
])
: [];
const cleanupFailures = cleanupResults.filter(result => result.status === 'rejected');
if (cleanupFailures.length > 0) {
primaryError = new AggregateError(
[primaryError, ...cleanupFailures.map(result => result.reason)].filter(Boolean),
'API integration cleanup failed',
);
}
}
if (primaryError) throw primaryError;
}
main().catch(error => {

View File

@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { spawn } from 'node:child_process';
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
server.close(error => error ? reject(error) : resolve(port));
});
});
}
function waitFor(predicate, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const timer = setInterval(() => {
const value = predicate();
if (value) {
clearInterval(timer);
resolve(value);
} else if (Date.now() - startedAt > timeoutMs) {
clearInterval(timer);
reject(new Error('timed out waiting for API server operation'));
}
}, 25);
});
}
function rawRequest(port, { method = 'GET', path = '/', headers = {} } = {}) {
return new Promise((resolve, reject) => {
const request = http.request({ hostname: '127.0.0.1', port, method, path, headers }, response => {
let body = '';
response.setEncoding('utf8');
response.on('data', chunk => { body += chunk; });
response.on('end', () => resolve({ statusCode: response.statusCode, headers: response.headers, body }));
});
request.once('error', reject);
request.end();
});
}
const port = await freePort();
const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: 'development',
PORT: String(port),
API_SHUTDOWN_GRACE_PERIOD_MS: '2000',
CORS_ORIGIN: 'https://platform.example.test',
CORS_TENANT_DOMAINS_ENABLED: 'false',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
child.stdout.on('data', chunk => { output += chunk.toString(); });
child.stderr.on('data', chunk => { output += chunk.toString(); });
try {
await waitFor(() => output.includes('"event":"server_listening"'));
const providedRequestId = 'operations-contract-123';
const response = await fetch(`http://127.0.0.1:${port}/not-found?secret=query-value`, {
headers: { 'x-request-id': providedRequestId },
});
const body = await response.json();
assert.equal(response.status, 404);
assert.equal(response.headers.get('x-request-id'), providedRequestId);
assert.equal(body.requestId, providedRequestId);
assert.equal(body.meta.requestId, providedRequestId);
await waitFor(() => output.includes('"event":"http_request"'));
assert.match(output, /"requestId":"operations-contract-123"/);
assert.match(output, /"path":"\/not-found"/);
assert.ok(!output.includes('query-value'), 'structured access logs must not record query strings');
const allowedPreflight = await rawRequest(port, {
method: 'OPTIONS',
path: '/api/platform-admin/tenants',
headers: { origin: 'https://platform.example.test', 'access-control-request-method': 'GET' },
});
assert.equal(allowedPreflight.statusCode, 204);
assert.equal(allowedPreflight.headers['access-control-allow-origin'], 'https://platform.example.test');
assert.match(String(allowedPreflight.headers.vary || ''), /origin/i);
const deniedPreflight = await rawRequest(port, {
method: 'OPTIONS',
path: '/health',
headers: {
origin: 'https://unknown.example.test',
host: 'platform.example.test',
'x-forwarded-host': 'platform.example.test',
'x-tenant-code': 'master',
'access-control-request-method': 'GET',
},
});
assert.equal(deniedPreflight.statusCode, 403, 'unknown Origin preflight must be explicitly rejected');
assert.equal(deniedPreflight.headers['access-control-allow-origin'], undefined);
assert.equal(JSON.parse(deniedPreflight.body).code, 'CORS_ORIGIN_DENIED');
const deniedRequest = await rawRequest(port, {
path: '/not-found',
headers: {
origin: 'https://unknown.example.test',
host: 'platform.example.test',
'x-forwarded-host': 'platform.example.test',
},
});
assert.equal(deniedRequest.statusCode, 403, 'spoofed Host headers must not bypass Origin validation');
assert.equal(JSON.parse(deniedRequest.body).code, 'CORS_ORIGIN_DENIED');
const duplicateOriginRequest = await rawRequest(port, {
path: '/not-found',
headers: { origin: ['https://platform.example.test', 'https://unknown.example.test'] },
});
assert.equal(duplicateOriginRequest.statusCode, 403, 'duplicate Origin headers must be rejected');
assert.equal(JSON.parse(duplicateOriginRequest.body).code, 'CORS_ORIGIN_DENIED');
const originlessHealthPreflight = await rawRequest(port, { method: 'OPTIONS', path: '/health' });
assert.equal(originlessHealthPreflight.statusCode, 204, 'originless health checks must not be blocked by CORS');
assert.equal(originlessHealthPreflight.headers['access-control-allow-origin'], undefined);
child.kill('SIGTERM');
const exit = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('API server did not exit after SIGTERM')), 7_000);
child.once('exit', (code, signal) => {
clearTimeout(timer);
resolve({ code, signal });
});
});
assert.equal(exit.code, 0, `API server should gracefully exit: ${output}`);
assert.match(output, /"event":"shutdown_started"/);
assert.match(output, /"event":"shutdown_complete"/);
} finally {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
}
console.log('[PASS] API operations, fail-closed CORS and graceful shutdown contract');

View File

@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const migration = fs.readFileSync(
'supabase/migrations/202607120014_audit_log_capacity_indexes.sql',
'utf8',
);
const tenantRoutes = fs.readFileSync('apps/api/src/features/tenant-admin/routes.ts', 'utf8');
const platformRoutes = fs.readFileSync('apps/api/src/features/platform-admin/routes.ts', 'utf8');
const alertWorker = fs.readFileSync('apps/worker/src/jobs/platform-audit-alerts.ts', 'utf8');
for (const indexName of [
'idx_audit_logs_created',
'idx_audit_logs_tenant_created',
'idx_audit_logs_tenant_actor_created',
'idx_audit_logs_tenant_target_created',
'idx_audit_logs_platform_created',
]) {
assert.match(migration, new RegExp(`create index if not exists ${indexName}\\b`, 'i'));
}
assert.match(migration, /\(tenant_id, created_at desc, id desc\)/i);
assert.match(migration, /\(tenant_id, actor_user_id, created_at desc, id desc\)/i);
assert.match(migration, /\(tenant_id, target_type, created_at desc, id desc\)/i);
assert.match(migration, /where action like 'platform\.%'/i);
assert.match(tenantRoutes, /const filters = \['al\.tenant_id = \$1'\]/i);
assert.match(tenantRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i);
assert.match(platformRoutes, /from public\.audit_logs al[\s\S]+order by al\.created_at desc/i);
assert.match(alertWorker, /from public\.audit_logs al[\s\S]+al\.action like 'platform\.%'[\s\S]+al\.created_at >=/i);
console.log('[PASS] audit log capacity index contract');

View File

@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
process.env.NODE_ENV = 'development';
const { findUserBySessionToken, findUserByVerifiedSupabasePayload } = await import('../apps/api/src/core/auth-context.ts');
const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111';
const TENANT_ID = '22222222-2222-4222-8222-222222222222';
const OTHER_TENANT_ID = '33333333-3333-4333-8333-333333333333';
const platformSession = {
id: '44444444-4444-4444-8444-444444444444',
username: 'platform-admin',
phone: null,
name: 'Platform Admin',
avatarUrl: null,
primaryRole: 'platform_admin',
createdAt: new Date(0).toISOString(),
tenantId: null,
sessionId: AUTH_USER_ID,
sessionExpiresAt: new Date(Date.now() + 60_000).toISOString(),
authSource: 'supabase_jwt',
authUserId: AUTH_USER_ID,
platformPermissions: { '*': true },
};
{
const calls = [];
const tenantSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, authSource: 'app_session' };
const session = await findUserBySessionToken('tk_test_session', async (sql, params) => {
calls.push({ sql, params });
return tenantSession;
});
assert.equal(session, tenantSession);
assert.match(calls[0].sql, /join public\.tenants t on t\.id = s\.tenant_id/);
assert.match(calls[0].sql, /t\.status = 'active'/);
assert.match(calls[0].sql, /tm\.status = 'active'/);
assert.match(calls[0].sql, /u\.status = 'active'/);
}
{
const calls = [];
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'authenticated',
app_metadata: { provider: 'phone' },
}, '', async (sql, params) => {
calls.push({ sql, params });
return platformSession;
});
assert.equal(session, platformSession, 'standard Supabase role=authenticated must not downgrade a database platform admin');
assert.equal(calls.length, 1);
assert.equal(calls[0].sql.includes('tenant_memberships'), false, 'global platform lookup must not require tenant membership');
}
{
const calls = [];
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'authenticated',
}, OTHER_TENANT_ID, async (sql, params) => {
calls.push({ sql, params });
return platformSession;
});
assert.equal(session?.primaryRole, 'platform_admin');
assert.equal(session?.tenantId, null, 'global platform identity must not acquire tenant membership from request context');
assert.equal(calls.length, 1, 'platform admin should resolve before tenant membership lookup');
}
{
const calls = [];
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'service_role',
app_role: 'platform_admin',
app_metadata: { app_role: 'platform_admin' },
}, '', async (sql, params) => {
calls.push({ sql, params });
return null;
});
assert.equal(session, null, 'JWT role claims alone must never create platform authority');
assert.equal(calls.length, 1, 'JWT-only elevation must stop after authoritative platform lookup fails');
}
{
const calls = [];
const studentSession = { ...platformSession, primaryRole: 'student', tenantId: TENANT_ID, platformPermissions: {} };
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'authenticated',
app_metadata: { app_role: 'platform_admin' },
}, TENANT_ID, async (sql, params) => {
calls.push({ sql, params });
return calls.length === 1 ? null : studentSession;
});
assert.equal(session?.primaryRole, 'student', 'a malicious app_role claim must not elevate a tenant member');
assert.equal(calls.length, 2);
assert.match(calls[1].sql, /join public\.tenants t on t\.id = tm\.tenant_id/);
assert.match(calls[1].sql, /t\.status = 'active'/);
assert.match(calls[1].sql, /tm\.status = 'active'/);
assert.match(calls[1].sql, /u\.status = 'active'/);
}
{
const calls = [];
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'authenticated',
app_metadata: { tenant_id: TENANT_ID },
}, OTHER_TENANT_ID, async (sql, params) => {
calls.push({ sql, params });
return platformSession;
});
assert.equal(session?.primaryRole, 'platform_admin', 'database platform admin may select a tenant beyond its JWT default claim');
assert.equal(session?.tenantId, null, 'JWT tenant defaults must not become implicit platform membership');
assert.equal(calls.length, 1);
}
{
let queryCount = 0;
const session = await findUserByVerifiedSupabasePayload({
sub: AUTH_USER_ID,
role: 'authenticated',
app_metadata: { tenant_id: TENANT_ID },
}, OTHER_TENANT_ID, async () => {
queryCount += 1;
return null;
});
assert.equal(session, null, 'signed tenant claim must not be overwritten by request context');
assert.equal(queryCount, 1, 'tenant mismatch may perform only the authoritative global platform lookup');
}
console.log('[PASS] Supabase JWT platform authority contract');

View File

@@ -1,11 +1,16 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { autoGrantBadges } from '../apps/api/src/features/profile/badges.ts';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101';
const BADGE_ID = '00000000-0000-0000-0000-00000000b881';
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 8 });
@@ -76,6 +81,12 @@ async function grantOnce(index) {
async function main() {
try {
await assertDestructiveTestDatabase({
client: pool,
databaseUrl: DATABASE_URL,
confirmation: destructiveTestConfirmation,
operation: 'auto badge concurrency test',
});
await resetFixture();
const results = await Promise.all(Array.from({ length: 20 }, (_, index) => grantOnce(index)));
const grantedRows = results.flat().filter(item => item.badgeId === BADGE_ID);

View File

@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const migration = fs.readFileSync(
path.join(repoRoot, 'supabase', 'migrations', '202607120013_backend_runtime_roles.sql'),
'utf8',
);
const authBoundaryMigration = fs.readFileSync(
path.join(repoRoot, 'supabase', 'migrations', '202607120018_auth_user_reference_boundary.sql'),
'utf8',
);
const migrationHistoryBoundaryMigration = fs.readFileSync(
path.join(repoRoot, 'supabase', 'migrations', '202607120019_production_migration_history_boundary.sql'),
'utf8',
);
const safetyMigration = fs.readFileSync(
path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'),
'utf8',
);
const readiness = fs.readFileSync(
path.join(repoRoot, 'scripts', 'production-readiness-check.js'),
'utf8',
);
const apiEnv = fs.readFileSync(
path.join(repoRoot, 'scripts', 'deploy', 'env', 'api.env.example'),
'utf8',
);
const workerEnv = fs.readFileSync(
path.join(repoRoot, 'scripts', 'deploy', 'env', 'worker.env.example'),
'utf8',
);
const destructiveGuard = fs.readFileSync(
path.join(repoRoot, 'scripts', 'lib', 'destructive-test-database-guard.js'),
'utf8',
);
const platformAdminRoutes = fs.readFileSync(
path.join(repoRoot, 'apps', 'api', 'src', 'features', 'platform-admin', 'routes.ts'),
'utf8',
);
const platformAdminBootstrap = fs.readFileSync(
path.join(repoRoot, 'scripts', 'bootstrap-platform-admin.js'),
'utf8',
);
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
for (const role of ['tiku_api', 'tiku_worker']) {
assert.match(destructiveGuard, new RegExp(`['"]${role}['"]`));
}
assert.doesNotMatch(
migration,
/create role tiku_(?:api|worker)|alter role tiku_(?:api|worker)/i,
'normal Supabase migrations must not require superuser-only cluster role changes',
);
assert.match(migration, /not role_state\.rolbypassrls/i);
assert.match(migration, /role_state\.has_parent_roles/i);
assert.match(migration, /search_path=pg_catalog, public, extensions/i);
assert.match(migration, /revoke create on schema public, app, app_private, extensions from public/i);
assert.match(migration, /revoke all privileges on schema public, app, app_private, extensions from tiku_api, tiku_worker/i);
assert.match(migration, /grant usage on schema public, app_private, extensions to tiku_api, tiku_worker/i);
assert.match(migration, /grant usage on schema app to tiku_api/i);
assert.doesNotMatch(migration, /grant usage on schema app to tiku_worker/i);
assert.match(
migration,
/grant select, insert, update, delete[\s\S]*on all tables in schema public[\s\S]*to tiku_api, tiku_worker/i,
);
assert.match(
migration,
/grant select[\s\S]*on all tables in schema app_private[\s\S]*to tiku_api, tiku_worker/i,
);
assert.match(
migration,
/grant insert, update[\s\S]*on app_private\.auth_sessions,[\s\S]*app_private\.tenant_secrets,[\s\S]*app_private\.platform_secrets[\s\S]*to tiku_api/i,
);
assert.match(
migration,
/grant insert, update, delete[\s\S]*on app_private\.sms_send_rate_limits[\s\S]*to tiku_api/i,
);
assert.doesNotMatch(migration, /grant[^;]*truncate[^;]*to tiku_api|grant[^;]*truncate[^;]*to tiku_worker/i);
assert.match(migration, /revoke execute on all functions in schema app from public/i);
assert.match(
migration,
/alter table %I\.%I alter column %I set default pg_catalog\.gen_random_uuid\(\)/i,
'UUID defaults must be independent of the pgcrypto extension schema',
);
assert.doesNotMatch(
migration,
/grant execute on function (?:public|extensions)\.gen_random_uuid\(\)/i,
'runtime roles must rely on the PostgreSQL core UUID function instead of an extension wrapper',
);
assert.match(migration, /grant execute on function app\.public_question_bank_grant_allows[\s\S]*to tiku_api/i);
assert.doesNotMatch(
migration,
/grant execute[\s\S]*on all functions in schema (?:public|app|app_private)[\s\S]*to tiku_api/i,
);
assert.match(
migration,
/pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i,
'default ACL loops must skip Supabase-owned roles the migration user cannot SET ROLE into',
);
assert.match(migration, /from pg_auth_members membership/i);
assert.match(migration, /tiku_api\/tiku_worker must not own database objects/i);
assert.doesNotMatch(migration, /password\s+['"]/i, 'role passwords must be provisioned outside migrations');
assert.match(authBoundaryMigration, /create or replace function app\.auth_user_exists\(target_user_id uuid\)/i);
assert.match(authBoundaryMigration, /security definer[\s\S]*set search_path = ''/i);
assert.match(authBoundaryMigration, /from auth\.users auth_user[\s\S]*auth_user\.id = target_user_id/i);
assert.match(
authBoundaryMigration,
/revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
);
assert.match(authBoundaryMigration, /grant execute on function app\.auth_user_exists\(uuid\) to tiku_api/i);
assert.doesNotMatch(authBoundaryMigration, /grant[^;]*to tiku_worker/i);
assert.match(
migrationHistoryBoundaryMigration,
/create or replace function app\.production_migration_history\(expected_version text\)/i,
);
assert.match(
migrationHistoryBoundaryMigration,
/security definer[\s\S]*set search_path = ''/i,
);
assert.match(
migrationHistoryBoundaryMigration,
/from supabase_migrations\.schema_migrations/i,
);
assert.match(
migrationHistoryBoundaryMigration,
/revoke all on function app\.production_migration_history\(text\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
);
assert.match(
migrationHistoryBoundaryMigration,
/grant execute on function app\.production_migration_history\(text\) to tiku_api/i,
);
assert.doesNotMatch(migrationHistoryBoundaryMigration, /grant[^;]*to tiku_worker/i);
for (const [label, source] of [
['platform staff API', platformAdminRoutes],
['platform admin bootstrap CLI', platformAdminBootstrap],
]) {
assert.match(source, /app\.auth_user_exists\(\$1::uuid\)/, `${label} must use the boolean Auth boundary`);
assert.doesNotMatch(source, /\bfrom\s+auth\.users\b/i, `${label} must not read auth.users directly`);
assert.doesNotMatch(source, /\bjoin\s+auth\.users\b/i, `${label} must not join auth.users directly`);
}
const retiredSharedRole = ['tiku', 'app'].join('_');
assert.equal(
safetyMigration.includes(retiredSharedRole),
false,
'safety migration must not retain the retired shared runtime role',
);
assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/tiku_api:/);
assert.match(apiEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_api$/m);
assert.match(workerEnv, /DATABASE_URL=postgresql:\/\/tiku_worker:/);
assert.match(workerEnv, /^DB_EXPECTED_RUNTIME_ROLE=tiku_worker$/m);
for (const gateId of [
'db.runtime_role.identity',
'db.runtime_role.attributes',
'db.runtime_role.schema_acl',
'db.runtime_role.table_acl',
'db.runtime_role.function_acl',
'db.runtime_role.auth_acl',
'db.extensions.isolation',
'db.runtime_role.ownership',
'db.runtime_role.ddl_denied',
'db.migrations.current',
]) {
assert.ok(readiness.includes(gateId), `readiness must enforce ${gateId}`);
}
assert.ok(
packageJson.scripts?.['test:readiness']?.includes('backend-runtime-role-contract-test.js'),
'the production readiness contract suite must run the backend runtime role test',
);
console.log('[PASS] backend runtime role least-privilege contract');

View File

@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
parseBackendRuntimeRoleBootstrapOptions,
} from './bootstrap-backend-runtime-roles.js';
const sql = fs.readFileSync('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql', 'utf8');
const migration = fs.readFileSync('supabase/migrations/202607120013_backend_runtime_roles.sql', 'utf8');
assert.deepEqual(
parseBackendRuntimeRoleBootstrapOptions([], {}),
{ apply: false, adminUrl: '', confirmation: '', json: false },
);
assert.throws(
() => parseBackendRuntimeRoleBootstrapOptions(['--apply'], {}),
/DATABASE_ADMIN_URL is required/,
);
assert.throws(
() => parseBackendRuntimeRoleBootstrapOptions(['--apply'], {
DATABASE_ADMIN_URL: ['postgresql:', '//admin:secret@db.test/postgres'].join(''),
}),
/BOOTSTRAP_BACKEND_RUNTIME_ROLES/,
);
assert.match(sql, /alter role tiku_api[\s\S]*bypassrls/i);
assert.match(sql, /alter role tiku_worker[\s\S]*bypassrls/i);
for (const extensionName of ['pgcrypto', 'citext', 'ltree', 'pg_trgm']) {
assert.ok(sql.includes(`'${extensionName}'::name`), `${extensionName} must be managed by the privileged bootstrap`);
}
assert.match(
sql,
/if not found then[\s\S]*create extension %I with schema extensions/i,
'privileged bootstrap must install extensions before normal migrations can create them with unsafe defaults',
);
assert.match(sql, /alter extension %I set schema extensions/i);
assert.match(sql, /grant usage on schema extensions to tiku_api, tiku_worker/i);
assert.match(sql, /search_path = pg_catalog, public, extensions/i);
assert.match(sql, /pg_auth_members[\s\S]*revoke %I from %I/i);
assert.match(
sql,
/revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
);
assert.match(
sql,
/revoke execute on all functions in schema extensions[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
);
for (const trustedRole of [
'postgres',
'service_role',
'dashboard_user',
'supabase_auth_admin',
'supabase_storage_admin',
'supabase_realtime_admin',
'supabase_functions_admin',
]) {
assert.ok(sql.includes(`'${trustedRole}'::name`), `${trustedRole} must retain extension execution when present`);
}
assert.match(
sql,
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i,
);
assert.match(sql, /extension\.extname in \('citext', 'ltree', 'pg_trgm'\)[\s\S]*grant execute on function %s to tiku_api, tiku_worker/i);
assert.doesNotMatch(sql, /password\s+['"]/i, 'bootstrap must preserve externally managed passwords');
assert.doesNotMatch(migration, /create role tiku_api|alter role tiku_api/i);
assert.match(migration, /Runtime role % is missing[\s\S]*bootstrap-backend-runtime-roles\.js/i);
assert.match(migration, /not role_state\.rolbypassrls/i);
console.log('[PASS] privileged backend runtime role bootstrap contract');

View File

@@ -0,0 +1,251 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pg from 'pg';
import { describeDatabaseTarget } from './lib/destructive-test-database-guard.js';
const { Client } = pg;
const CONFIRMATION = 'BOOTSTRAP_BACKEND_RUNTIME_ROLES';
const sqlPath = fileURLToPath(new URL('./deploy/sql/bootstrap-backend-runtime-roles.sql', import.meta.url));
function argumentValue(argv, name) {
const index = argv.indexOf(name);
if (index >= 0) return String(argv[index + 1] || '').trim();
const prefix = `${name}=`;
const item = argv.find(value => value.startsWith(prefix));
return item ? item.slice(prefix.length).trim() : '';
}
export function parseBackendRuntimeRoleBootstrapOptions(
argv = process.argv.slice(2),
env = process.env,
) {
const apply = argv.includes('--apply');
const adminUrl = String(env.DATABASE_ADMIN_URL || '').trim();
const confirmation = argumentValue(argv, '--confirm');
if (apply && !adminUrl) throw new Error('DATABASE_ADMIN_URL is required with --apply');
if (apply && confirmation !== CONFIRMATION) {
throw new Error(`--confirm=${CONFIRMATION} is required with --apply`);
}
return { apply, adminUrl, confirmation, json: argv.includes('--json') };
}
function roleIsSafe(row) {
const config = Array.isArray(row?.rolconfig) ? row.rolconfig.map(String) : [];
return row
&& row.rolcanlogin === true
&& row.rolsuper === false
&& row.rolinherit === false
&& row.rolcreatedb === false
&& row.rolcreaterole === false
&& row.rolreplication === false
&& row.rolbypassrls === true
&& row.hasParentRoles === false
&& config.includes('search_path=pg_catalog, public, extensions');
}
async function loadRoleState(client) {
const result = await client.query(`
select role_row.rolname,
role_row.rolcanlogin,
role_row.rolsuper,
role_row.rolinherit,
role_row.rolcreatedb,
role_row.rolcreaterole,
role_row.rolreplication,
role_row.rolbypassrls,
role_row.rolconfig,
exists (
select 1 from pg_auth_members membership
where membership.member = role_row.oid
) as "hasParentRoles"
from pg_roles role_row
where role_row.rolname = any(array['tiku_api', 'tiku_worker']::name[])
order by role_row.rolname
`);
return result.rows;
}
async function loadPublicFunctionExecutionState(client) {
const result = await client.query(`
select requested_role.role_name,
role_row.oid is not null as role_exists,
coalesce((
select count(*)::integer
from pg_proc function_row
join pg_namespace namespace on namespace.oid = function_row.pronamespace
where namespace.nspname = 'public'
and role_row.oid is not null
and has_function_privilege(role_row.oid, function_row.oid, 'EXECUTE')
), 0)::integer as executable_function_count
from unnest(array['anon', 'authenticated', 'tiku_api', 'tiku_worker']::name[])
as requested_role(role_name)
left join pg_roles role_row on role_row.rolname = requested_role.role_name
order by requested_role.role_name
`);
return result.rows;
}
async function loadExtensionState(client) {
const result = await client.query(`
select extension.extname,
namespace.nspname as schema_name,
count(procedure_row.oid)::integer as function_count,
count(procedure_row.oid) filter (
where has_function_privilege('anon', procedure_row.oid, 'EXECUTE')
)::integer as anon_execute_count,
count(procedure_row.oid) filter (
where has_function_privilege('authenticated', procedure_row.oid, 'EXECUTE')
)::integer as authenticated_execute_count,
count(procedure_row.oid) filter (
where has_function_privilege('tiku_api', procedure_row.oid, 'EXECUTE')
)::integer as api_execute_count,
count(procedure_row.oid) filter (
where has_function_privilege('tiku_worker', procedure_row.oid, 'EXECUTE')
)::integer as worker_execute_count
from pg_extension extension
join pg_namespace namespace on namespace.oid = extension.extnamespace
left join pg_depend dependency
on dependency.refclassid = 'pg_extension'::regclass
and dependency.refobjid = extension.oid
and dependency.classid = 'pg_proc'::regclass
and dependency.deptype = 'e'
left join pg_proc procedure_row on procedure_row.oid = dependency.objid
where extension.extname = any(array['pgcrypto', 'citext', 'ltree', 'pg_trgm']::name[])
group by extension.extname, namespace.nspname
order by extension.extname
`);
return result.rows;
}
export async function bootstrapBackendRuntimeRoles(options) {
if (!options.apply) {
return {
status: 'plan',
apply: false,
confirmation: CONFIRMATION,
sqlPath: path.relative(process.cwd(), sqlPath),
changes: [
'Create tiku_api and tiku_worker if missing without assigning passwords',
'Enforce LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS',
'Move required extensions out of public and set search_path=pg_catalog,public,extensions',
'Close client extension RPC execution while preserving backend citext/ltree operations',
],
};
}
const target = describeDatabaseTarget(options.adminUrl);
const client = new Client({
connectionString: options.adminUrl,
application_name: 'tiku-runtime-role-bootstrap',
});
await client.connect();
try {
const identityResult = await client.query(`
select current_user,
current_setting('server_version_num')::integer as server_version_num,
rolsuper
from pg_roles
where rolname = current_user
`);
const identity = identityResult.rows[0];
if (!identity?.rolsuper) {
throw new Error(`DATABASE_ADMIN_URL must connect as a PostgreSQL superuser; ${identity?.current_user || 'current role'} is not superuser`);
}
if (Number(identity.server_version_num) < 130000) {
throw new Error('PostgreSQL 13 or newer is required');
}
const sql = await fs.readFile(sqlPath, 'utf8');
await client.query('begin');
try {
await client.query(sql);
await client.query('commit');
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
}
const roles = await loadRoleState(client);
if (roles.length !== 2 || roles.some(row => !roleIsSafe(row))) {
throw new Error('Runtime role bootstrap verification failed');
}
const publicFunctionExecution = await loadPublicFunctionExecutionState(client);
if (
publicFunctionExecution.length !== 4
|| publicFunctionExecution.some(row => !row.role_exists || Number(row.executable_function_count) !== 0)
) {
throw new Error('Public extension function execution bootstrap verification failed');
}
const extensions = await loadExtensionState(client);
const expectedExtensions = new Set(['pgcrypto', 'citext', 'ltree', 'pg_trgm']);
if (
extensions.length !== expectedExtensions.size
|| extensions.some(row => !expectedExtensions.has(row.extname) || row.schema_name !== 'extensions')
) {
throw new Error('Required extension schema bootstrap verification failed');
}
for (const row of extensions) {
const functionCount = Number(row.function_count);
const backendExecuteCount = row.extname === 'pgcrypto' ? 0 : functionCount;
if (
functionCount <= 0
|| Number(row.anon_execute_count) !== 0
|| Number(row.authenticated_execute_count) !== 0
|| Number(row.api_execute_count) !== backendExecuteCount
|| Number(row.worker_execute_count) !== backendExecuteCount
) {
throw new Error(`Extension function ACL bootstrap verification failed for ${row.extname}`);
}
}
return {
status: 'pass',
apply: true,
target,
administrator: identity.current_user,
roles: roles.map(row => ({
name: row.rolname,
login: row.rolcanlogin,
bypassRls: row.rolbypassrls,
noInherit: row.rolinherit === false,
hasParentRoles: row.hasParentRoles,
searchPath: row.rolconfig,
})),
publicFunctionExecution: publicFunctionExecution.map(row => ({
role: row.role_name,
executableFunctionCount: Number(row.executable_function_count),
})),
extensions: extensions.map(row => ({
name: row.extname,
schema: row.schema_name,
functionCount: Number(row.function_count),
})),
};
} finally {
await client.end();
}
}
async function main() {
let options;
try {
options = parseBackendRuntimeRoleBootstrapOptions();
const result = await bootstrapBackendRuntimeRoles(options);
if (options.json) console.log(JSON.stringify(result, null, 2));
else if (result.status === 'plan') {
console.log('Backend runtime role bootstrap plan');
result.changes.forEach(item => console.log(`- ${item}`));
console.log(`Apply with --apply --confirm=${CONFIRMATION} and DATABASE_ADMIN_URL.`);
} else {
console.log(`Backend runtime role bootstrap complete for ${result.target.host}:${result.target.port}/${result.target.database}`);
}
} catch (error) {
const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) };
if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2));
else console.error(failure.error);
process.exitCode = 1;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();

243
scripts/bootstrap-platform-admin-test.js vendored Normal file
View File

@@ -0,0 +1,243 @@
import assert from 'node:assert/strict';
import {
APPLY_CONFIRMATION,
BOOTSTRAP_LOCK_KEY,
bootstrapPlatformAdmin,
buildConfig,
helpText,
publicResult,
sanitizeErrorMessage,
} from './bootstrap-platform-admin.js';
const AUTH_USER_ID = '11111111-1111-4111-8111-111111111111';
const PLATFORM_USER_ID = '22222222-2222-4222-8222-222222222222';
const LEGACY_USER_ID = '33333333-3333-4333-8333-333333333333';
function result(rows = []) {
return { rows, rowCount: rows.length };
}
function mockPool(responses) {
const queries = [];
const client = {
async query(sql, params = []) {
const normalized = String(sql).replace(/\s+/g, ' ').trim();
queries.push({ sql: normalized, params });
if (normalized === 'begin' || normalized === 'commit' || normalized === 'rollback') return result();
const response = responses.shift();
if (response instanceof Error) throw response;
if (!response) throw new Error(`Unexpected query: ${normalized}`);
return response;
},
release() {},
};
return {
queries,
pool: { async connect() { return client; } },
assertExhausted() { assert.equal(responses.length, 0, 'all expected database queries should run'); },
};
}
function config(overrides = {}) {
return {
databaseUrl: 'test-database-url',
authUserId: AUTH_USER_ID,
apply: false,
confirmation: APPLY_CONFIRMATION,
username: 'owner.admin',
email: 'owner@example.test',
phone: '13800001234',
name: 'Owner Admin',
...overrides,
};
}
const parsed = buildConfig({
DATABASE_URL: 'test-database-url',
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
BOOTSTRAP_PLATFORM_ADMIN_USERNAME: 'owner.admin',
BOOTSTRAP_PLATFORM_ADMIN_NAME: 'Owner Admin',
}, []);
assert.equal(parsed.apply, false, 'bootstrap should default to dry-run');
assert.throws(
() => buildConfig({
DATABASE_URL: 'test-database-url',
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
}, ['--apply']),
new RegExp(APPLY_CONFIRMATION),
'apply must require the exact confirmation phrase',
);
assert.equal(
buildConfig({
DATABASE_URL: 'test-database-url',
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID: AUTH_USER_ID,
}, ['--apply', '--confirm', APPLY_CONFIRMATION]).apply,
true,
);
await assert.rejects(
() => bootstrapPlatformAdmin(config({ apply: true, confirmation: '' }), { pool: mockPool([]).pool }),
new RegExp(APPLY_CONFIRMATION),
'direct callers must not bypass the apply confirmation gate',
);
assert.match(helpText(), /active, Auth-bound platform admin exists/);
assert.match(helpText(), /exactly one unbound legacy platform_admin/);
const sensitiveDatabaseUrl = ['postgresql:', '', 'bootstrap_user:bootstrap_password@db.internal:5432/tiku'].join('/');
const safeError = sanitizeErrorMessage(
new Error(`connection failed for ${sensitiveDatabaseUrl}`),
sensitiveDatabaseUrl,
);
assert.equal(safeError.includes(sensitiveDatabaseUrl), false, 'failure output must redact the configured database URL');
assert.equal(safeError.includes('bootstrap_password'), false, 'failure output must not expose database credentials');
{
const mock = mockPool([
result(),
result([{ exists: true }]),
result(),
result(),
result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]),
]);
const dryRun = await bootstrapPlatformAdmin(config(), { pool: mock.pool });
mock.assertExhausted();
assert.equal(dryRun.dryRun, true);
assert.equal(dryRun.action, 'bind_legacy');
assert.equal(dryRun.platformUserId, LEGACY_USER_ID);
assert.equal(mock.queries[0].sql, 'begin');
assert.match(mock.queries[1].sql, /pg_advisory_xact_lock/);
assert.deepEqual(mock.queries[1].params, [BOOTSTRAP_LOCK_KEY]);
assert.equal(mock.queries.at(-1).sql, 'commit', 'dry-run should retain its transaction lock through normal commit');
assert.equal(mock.queries.some(query => query.sql === 'rollback'), false, 'successful dry-run should not break the transaction abstraction');
assert.equal(
mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)),
false,
'dry-run must execute no write statement',
);
assert.match(mock.queries[2].sql, /app\.auth_user_exists/);
assert.doesNotMatch(mock.queries[2].sql, /auth\.users/);
}
{
const saved = {
id: PLATFORM_USER_ID,
authUserId: AUTH_USER_ID,
username: 'owner.admin',
email: 'owner@example.test',
phone: '13800001234',
};
const mock = mockPool([
result(),
result([{ exists: true }]),
result(),
result(),
result(),
result([saved]),
result(),
]);
const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool });
mock.assertExhausted();
assert.equal(applied.action, 'create');
assert.equal(applied.auditAction, 'platform.admin.bootstrapped');
assert.equal(mock.queries.at(-1).sql, 'commit');
const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users'));
assert.ok(userWrite, 'apply should create the platform user inside the transaction');
assert.match(userWrite.sql, /platform_permissions/);
assert.match(userWrite.sql, /'\{"\*":true\}'::jsonb/);
const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs'));
assert.ok(auditWrite, 'apply should write an audit event in the same transaction');
assert.deepEqual(auditWrite.params, [PLATFORM_USER_ID, 'platform.admin.bootstrapped', 'create']);
assert.match(auditWrite.sql, /values \( null, null, \$2, 'platform_user', \$1::text/);
assert.match(auditWrite.sql, /'invokedBy', 'system_cli'/);
const auditPayload = JSON.stringify(auditWrite);
assert.equal(auditPayload.includes('owner@example.test'), false, 'audit must not contain email');
assert.equal(auditPayload.includes('13800001234'), false, 'audit must not contain phone');
assert.equal(auditPayload.includes(AUTH_USER_ID), false, 'audit must not contain the Supabase Auth user ID');
const output = JSON.stringify(publicResult(applied));
assert.equal(output.includes(AUTH_USER_ID), false, 'CLI output must mask Auth user ID');
assert.equal(output.includes(PLATFORM_USER_ID), false, 'CLI output must mask platform user ID');
assert.equal(output.includes('owner@example.test'), false, 'CLI output must mask email');
assert.equal(output.includes('13800001234'), false, 'CLI output must mask phone');
assert.equal(output.includes('owner.admin'), false, 'CLI output must mask username');
const emailUsernameOutput = JSON.stringify(publicResult({ ...applied, username: 'owner@example.test' }));
assert.equal(emailUsernameOutput.includes('owner@example.test'), false, 'email-shaped username must be masked');
const phoneUsernameOutput = JSON.stringify(publicResult({ ...applied, username: '13800001234' }));
assert.equal(phoneUsernameOutput.includes('13800001234'), false, 'phone-shaped username must be masked');
}
{
const mock = mockPool([
result(),
result([{ exists: true }]),
result([{ id: PLATFORM_USER_ID }]),
]);
await assert.rejects(
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
/active, Auth-bound platform admin already exists/,
);
mock.assertExhausted();
assert.equal(mock.queries.at(-1).sql, 'rollback');
assert.equal(mock.queries.some(query => query.sql.startsWith('insert into')), false);
}
{
const mock = mockPool([
result(),
result([{ exists: true }]),
result(),
result(),
result([
{ id: LEGACY_USER_ID, username: 'legacy.one' },
{ id: PLATFORM_USER_ID, username: 'legacy.two' },
]),
]);
await assert.rejects(
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
/Multiple unbound legacy platform admins/,
);
mock.assertExhausted();
assert.equal(mock.queries.at(-1).sql, 'rollback');
}
{
const mock = mockPool([
result(),
result([{ exists: true }]),
result(),
result(),
result([{ id: LEGACY_USER_ID, username: 'legacy.admin', email: null, phone: null, name: 'Legacy Admin' }]),
result([{
id: LEGACY_USER_ID,
authUserId: AUTH_USER_ID,
username: 'legacy.admin',
email: null,
phone: null,
}]),
result(),
]);
const applied = await bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool });
mock.assertExhausted();
assert.equal(applied.action, 'bind_legacy');
const userWrite = mock.queries.find(query => query.sql.includes('insert into public.platform_users'));
assert.equal(userWrite.params[0], LEGACY_USER_ID, 'unique legacy row should be bound instead of creating a duplicate');
const auditWrite = mock.queries.find(query => query.sql.includes('insert into public.audit_logs'));
assert.deepEqual(auditWrite.params, [LEGACY_USER_ID, 'platform.admin.bootstrapped', 'bind_legacy']);
}
{
const mock = mockPool([
result(),
result([{ exists: false }]),
]);
await assert.rejects(
() => bootstrapPlatformAdmin(config({ apply: true }), { pool: mock.pool }),
/Supabase Auth user not found/,
);
mock.assertExhausted();
assert.equal(mock.queries.at(-1).sql, 'rollback');
assert.equal(mock.queries.some(query => /^(insert|update|delete)\b/i.test(query.sql)), false);
}
console.log('[PASS] first platform admin bootstrap safety contract');

351
scripts/bootstrap-platform-admin.js vendored Normal file
View File

@@ -0,0 +1,351 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import pg from 'pg';
const APPLY_CONFIRMATION = 'BOOTSTRAP_FIRST_PLATFORM_ADMIN';
const BOOTSTRAP_LOCK_KEY = 'tiku-saas:first-platform-admin:v1';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function envString(env, key, fallback = '') {
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
}
function argumentValue(argv, name) {
const directIndex = argv.indexOf(name);
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
const prefix = `${name}=`;
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
}
function normalizeOptionalEmail(value) {
if (!value) return null;
const email = value.toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_EMAIL must be a valid email address');
}
return email;
}
function normalizeOptionalPhone(value) {
if (!value) return null;
const phone = value.replace(/\s+/g, '');
if (!/^\+?[0-9-]{6,32}$/.test(phone)) {
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_PHONE must be a valid phone number');
}
return phone;
}
function normalizeUsername(value, fallback) {
const username = (value || fallback).trim();
if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) {
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_USERNAME must contain 3-80 safe characters');
}
return username;
}
function buildConfig(env = process.env, argv = process.argv.slice(2)) {
const databaseUrl = envString(env, 'DATABASE_URL');
const authUserId = argumentValue(argv, '--auth-user-id') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID');
const apply = argv.includes('--apply');
const confirmation = argumentValue(argv, '--confirm') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_CONFIRM');
const email = normalizeOptionalEmail(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_EMAIL'));
const phone = normalizeOptionalPhone(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_PHONE'));
const username = normalizeUsername(
envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_USERNAME'),
`platform_${authUserId.slice(0, 8)}`,
);
const name = envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_NAME', 'Platform Administrator');
if (!databaseUrl) throw new Error('Missing required env: DATABASE_URL');
if (!UUID_RE.test(authUserId)) {
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID or --auth-user-id must be a valid UUID');
}
if (!name || name.length > 120) throw new Error('BOOTSTRAP_PLATFORM_ADMIN_NAME must contain 1-120 characters');
if (apply && confirmation !== APPLY_CONFIRMATION) {
throw new Error(`--apply requires --confirm ${APPLY_CONFIRMATION}`);
}
return { databaseUrl, authUserId, apply, confirmation, username, email, phone, name };
}
function maskEmail(value) {
if (!value) return null;
const [local = '', domain = ''] = String(value).split('@');
return `${local.slice(0, 2)}***@${domain}`;
}
function maskPhone(value) {
if (!value) return null;
const phone = String(value);
return phone.length > 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '***';
}
function maskUuid(value) {
if (!value) return null;
const id = String(value);
return `${id.slice(0, 8)}...${id.slice(-4)}`;
}
function maskUsername(value) {
if (!value) return null;
const username = String(value);
if (username.includes('@')) return maskEmail(username);
if (/^\+?[0-9-]{6,32}$/.test(username)) return maskPhone(username);
if (username.length <= 3) return '***';
return `${username.slice(0, 2)}***${username.slice(-1)}`;
}
function publicResult(result) {
return {
dryRun: result.dryRun,
action: result.action,
platformUserId: maskUuid(result.platformUserId),
authUserId: maskUuid(result.authUserId),
username: maskUsername(result.username),
email: maskEmail(result.email),
phone: maskPhone(result.phone),
primaryRole: 'platform_admin',
status: 'active',
permissions: ['*'],
auditAction: result.auditAction || null,
};
}
function sanitizeErrorMessage(error, databaseUrl = '') {
let message = error instanceof Error ? error.message : String(error);
if (databaseUrl) message = message.split(databaseUrl).join('[DATABASE_URL_REDACTED]');
return message
.replace(/postgres(?:ql)?:\/\/[^\s'"<>]+/gi, '[DATABASE_URL_REDACTED]')
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@');
}
async function withTransaction(client, callback) {
await client.query('begin');
try {
await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [BOOTSTRAP_LOCK_KEY]);
const result = await callback();
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
}
}
async function bootstrapPlatformAdmin(inputConfig, options = {}) {
const config = inputConfig?.databaseUrl
? inputConfig
: buildConfig(options.env || process.env, options.argv || process.argv.slice(2));
if (config.apply && config.confirmation !== APPLY_CONFIRMATION) {
throw new Error(`Apply requires confirmation ${APPLY_CONFIRMATION}`);
}
const pool = options.pool || new pg.Pool({ connectionString: config.databaseUrl, max: 1 });
const closePool = !options.pool;
try {
const client = await pool.connect();
try {
return await withTransaction(client, async () => {
const authUserResult = await client.query(
`
select app.auth_user_exists($1::uuid) as exists
`,
[config.authUserId],
);
if (!authUserResult.rows[0]?.exists) throw new Error('Supabase Auth user not found');
const activeBoundResult = await client.query(
`
select id
from public.platform_users
where primary_role = 'platform_admin'
and status = 'active'
and auth_user_id is not null
order by created_at asc
limit 2
for update
`,
);
if (activeBoundResult.rowCount > 0) {
throw new Error('An active, Auth-bound platform admin already exists; bootstrap is permanently refused');
}
const boundUserResult = await client.query(
`
select id, primary_role as "primaryRole"
from public.platform_users
where auth_user_id = $1::uuid
limit 1
for update
`,
[config.authUserId],
);
if (boundUserResult.rows[0]?.primaryRole !== undefined) {
throw new Error('Supabase Auth user is already bound to a platform user');
}
const legacyResult = await client.query(
`
select id, username, email::text, phone, name
from public.platform_users
where primary_role = 'platform_admin'
and auth_user_id is null
order by created_at asc
limit 2
for update
`,
);
if (legacyResult.rowCount > 1) {
throw new Error('Multiple unbound legacy platform admins exist; resolve the ambiguity manually');
}
const legacy = legacyResult.rows[0] || null;
const action = legacy ? 'bind_legacy' : 'create';
const platformUserId = legacy?.id || null;
const resolvedEmail = legacy?.email || config.email || null;
const resolvedPhone = legacy?.phone || config.phone || null;
const resolvedUsername = legacy?.username || config.username;
const resolvedName = legacy?.name || config.name;
if (!config.apply) {
return {
dryRun: true,
action,
platformUserId,
authUserId: config.authUserId,
username: resolvedUsername,
email: resolvedEmail,
phone: resolvedPhone,
auditAction: null,
};
}
const savedResult = await client.query(
`
insert into public.platform_users (
id, auth_user_id, username, email, phone, name,
primary_role, status, platform_permissions, raw_profile
)
values (
coalesce($1::uuid, pg_catalog.gen_random_uuid()), $2::uuid, $3, $4::extensions.citext, $5, $6,
'platform_admin', 'active', '{"*":true}'::jsonb,
jsonb_build_object('source', 'server-bootstrap', 'bootstrapVersion', 1)
)
on conflict (id)
do update set auth_user_id = excluded.auth_user_id,
username = coalesce(public.platform_users.username, excluded.username),
email = coalesce(public.platform_users.email, excluded.email),
phone = coalesce(public.platform_users.phone, excluded.phone),
name = coalesce(public.platform_users.name, excluded.name),
primary_role = 'platform_admin',
status = 'active',
platform_permissions = '{"*":true}'::jsonb,
raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile),
updated_at = now()
returning id, auth_user_id as "authUserId", username, email::text, phone
`,
[platformUserId, config.authUserId, resolvedUsername, resolvedEmail, resolvedPhone, resolvedName],
);
const saved = savedResult.rows[0];
const auditAction = 'platform.admin.bootstrapped';
await client.query(
`
insert into public.audit_logs (
tenant_id, actor_user_id, action, target_type, target_id, details
)
values (
null, null, $2, 'platform_user', $1::text,
jsonb_build_object(
'source', 'server-bootstrap',
'invokedBy', 'system_cli',
'bootstrapVersion', 1,
'mode', $3::text,
'authUserBound', true,
'permissions', jsonb_build_array('*')
)
)
`,
[saved.id, auditAction, action],
);
return {
dryRun: false,
action,
platformUserId: saved.id,
authUserId: saved.authUserId,
username: saved.username,
email: saved.email,
phone: saved.phone,
auditAction,
};
});
} finally {
client.release();
}
} finally {
if (closePool) await pool.end();
}
}
function helpText() {
return `
Bootstrap the first production platform administrator from an existing Supabase Auth user.
Safety contract:
- Runs as a local server CLI and talks directly to DATABASE_URL.
- Defaults to dry-run. Writing requires --apply and the exact confirmation phrase.
- Refuses once any active, Auth-bound platform admin exists.
- May bind exactly one unbound legacy platform_admin row; multiple candidates are refused.
- Uses a boolean Auth existence boundary and never reads auth.users profile fields.
- Provide optional email/phone explicitly when a new business profile needs them.
- Grants {"*":true} and writes a redacted system-CLI audit event in the same locked transaction.
Usage:
DATABASE_URL=<server-database-url> \\
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID=<existing-auth.users-id> \\
BOOTSTRAP_PLATFORM_ADMIN_USERNAME=<username> \\
BOOTSTRAP_PLATFORM_ADMIN_NAME=<display-name> \\
npm run bootstrap:platform-admin
npm run bootstrap:platform-admin -- --apply --confirm ${APPLY_CONFIRMATION}
Optional identity fields:
BOOTSTRAP_PLATFORM_ADMIN_EMAIL
BOOTSTRAP_PLATFORM_ADMIN_PHONE
`;
}
async function main() {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(helpText().trim());
return;
}
let config;
try {
config = buildConfig();
const result = await bootstrapPlatformAdmin(config);
console.log(JSON.stringify({ ok: true, ...publicResult(result) }, null, 2));
if (result.dryRun) {
console.error(`Dry-run only. Re-run with --apply --confirm ${APPLY_CONFIRMATION} after reviewing the target.`);
}
} catch (error) {
console.error(sanitizeErrorMessage(error, config?.databaseUrl || envString(process.env, 'DATABASE_URL')));
console.error(helpText());
process.exitCode = 1;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
await main();
}
export {
APPLY_CONFIRMATION,
BOOTSTRAP_LOCK_KEY,
bootstrapPlatformAdmin,
buildConfig,
helpText,
publicResult,
sanitizeErrorMessage,
};

View File

@@ -0,0 +1,173 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
const taroRoot = path.join(repoRoot, 'apps', 'taro');
const distProjectConfigPath = path.join(taroRoot, 'dist', 'weapp-student', 'project.config.json');
const placeholderAppIds = new Set([
'wx0000000000000000',
'wx0123456789abcdef',
'wx1234567890abcdef',
'wxabcdef0123456789',
]);
const placeholderTenantCodes = new Set([
'changeme',
'demo',
'example',
'placeholder',
'production-tenant',
'replace-with-tenant-code',
'smoke',
'tenant-production',
'test',
]);
function stringValue(value) {
return String(value || '').trim();
}
function normalizedHostname(value) {
return stringValue(value).toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
}
export function isLoopbackHostname(value) {
const hostname = normalizedHostname(value);
return hostname === 'localhost'
|| hostname.endsWith('.localhost')
|| hostname === '::1'
|| hostname === '0.0.0.0'
|| /^127(?:\.\d{1,3}){3}$/.test(hostname);
}
export function isPlaceholderHostname(value) {
const hostname = normalizedHostname(value);
return ['example', 'test', 'local'].some(suffix => hostname === suffix || hostname.endsWith(`.${suffix}`));
}
export function validateProductionApiBaseUrl(value) {
const apiBaseUrl = stringValue(value);
if (!apiBaseUrl) throw new Error('TARO_APP_API_BASE_URL is required for a production WeApp build');
let parsed;
try {
parsed = new URL(apiBaseUrl);
} catch {
throw new Error('TARO_APP_API_BASE_URL must be an absolute HTTPS URL for a production WeApp build');
}
if (parsed.protocol !== 'https:' || !parsed.hostname) {
throw new Error('TARO_APP_API_BASE_URL must use HTTPS for a production WeApp build');
}
if (isLoopbackHostname(parsed.hostname)) {
throw new Error('TARO_APP_API_BASE_URL must not use localhost or a loopback address for a production WeApp build');
}
if (isPlaceholderHostname(parsed.hostname)) {
throw new Error('TARO_APP_API_BASE_URL must not use a .example, .test, or .local host for a production WeApp build');
}
return apiBaseUrl.replace(/\/+$/, '');
}
export function validateProductionWechatAppId(value) {
const appId = stringValue(value);
if (!appId) throw new Error('WECHAT_MINIAPP_APP_ID is required for a production WeApp build');
if (!/^wx[0-9a-f]{16}$/i.test(appId)) {
throw new Error('WECHAT_MINIAPP_APP_ID must be a real 18-character WeChat AppID');
}
const normalized = appId.toLowerCase();
if (placeholderAppIds.has(normalized) || /^wx([0-9a-f])\1{15}$/i.test(normalized)) {
throw new Error('WECHAT_MINIAPP_APP_ID must not use a placeholder AppID');
}
return appId;
}
export function validateTenantCodeFormat(value) {
const tenantCode = stringValue(value);
if (!tenantCode) throw new Error('TARO_APP_TENANT_CODE is required when TARO_APP_WEAPP_TENANT_MODE=fixed');
if (!/^[A-Za-z0-9._-]{2,64}$/.test(tenantCode)) throw new Error('TARO_APP_TENANT_CODE has an invalid format');
return tenantCode;
}
export function validateProductionTenantCode(value) {
const tenantCode = validateTenantCodeFormat(value);
const normalized = tenantCode.toLowerCase();
if (placeholderTenantCodes.has(normalized)
|| /^(?:tenant[-_])?(?:example|test|demo|smoke|production|placeholder)(?:[-_]tenant)?$/.test(normalized)) {
throw new Error('TARO_APP_TENANT_CODE must not use a placeholder tenant code');
}
return tenantCode;
}
export function resolveWeappTenantMode(env = {}, production = false) {
const configuredMode = stringValue(env.TARO_APP_WEAPP_TENANT_MODE).toLowerCase();
if (configuredMode && configuredMode !== 'fixed' && configuredMode !== 'launch') {
throw new Error('TARO_APP_WEAPP_TENANT_MODE must be fixed or launch');
}
if (configuredMode) return configuredMode;
return production || stringValue(env.TARO_APP_TENANT_CODE) ? 'fixed' : 'launch';
}
export function resolveWeappBuildConfig(env = {}, { production = false } = {}) {
const tenantMode = resolveWeappTenantMode(env, production);
const configuredTenantCode = stringValue(env.TARO_APP_TENANT_CODE);
let tenantCode = '';
if (tenantMode === 'fixed') {
tenantCode = production
? validateProductionTenantCode(configuredTenantCode)
: validateTenantCodeFormat(configuredTenantCode);
} else if (production && configuredTenantCode) {
throw new Error('TARO_APP_TENANT_CODE must be empty when TARO_APP_WEAPP_TENANT_MODE=launch');
}
return {
production,
tenantMode,
tenantCode,
apiBaseUrl: production
? validateProductionApiBaseUrl(env.TARO_APP_API_BASE_URL)
: stringValue(env.TARO_APP_API_BASE_URL),
appId: production ? validateProductionWechatAppId(env.WECHAT_MINIAPP_APP_ID) : '',
};
}
export function runWeappBuild({ argv = process.argv.slice(2), env = process.env } = {}) {
const production = argv.includes('--production');
const buildConfig = resolveWeappBuildConfig(env, { production });
const result = spawnSync(process.execPath, [path.join(repoRoot, 'node_modules', '@tarojs', 'cli', 'bin', 'taro'), 'build', '--type', 'weapp'], {
cwd: taroRoot,
env: {
...env,
TARO_ENV: 'weapp',
TARO_APP_PORTAL: 'student',
TARO_APP_RELEASE_MODE: production ? 'production' : 'preview',
TARO_APP_API_BASE_URL: buildConfig.apiBaseUrl,
TARO_APP_WEAPP_TENANT_MODE: buildConfig.tenantMode,
TARO_APP_TENANT_CODE: buildConfig.tenantCode,
},
stdio: 'inherit',
});
if (result.error) throw result.error;
if (result.status !== 0) return result.status || 1;
if (production) {
const projectConfig = JSON.parse(fs.readFileSync(distProjectConfigPath, 'utf8'));
projectConfig.appid = buildConfig.appId;
projectConfig.setting = { ...(projectConfig.setting || {}), urlCheck: true };
fs.writeFileSync(distProjectConfigPath, `${JSON.stringify(projectConfig, null, 2)}\n`, 'utf8');
}
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
try {
process.exitCode = runWeappBuild();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@@ -165,7 +165,7 @@ async function main() {
console.error(error.message);
console.error(`
Required example:
DATABASE_URL=postgresql://tiku_app:***@127.0.0.1:54322/postgres
DATABASE_URL=postgresql://tiku_api:***@127.0.0.1:54322/postgres
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001
ALIYUN_ACCESS_KEY_ID=<real-access-key-id>
ALIYUN_ACCESS_KEY_SECRET=<real-access-key-secret>

View File

@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const migrationPath = path.join(
repoRoot,
'supabase',
'migrations',
'202607110001_data_api_acl_rls_hardening.sql',
);
const authBoundaryMigrationPath = path.join(
repoRoot,
'supabase',
'migrations',
'202607120018_auth_user_reference_boundary.sql',
);
const readinessPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js');
const privilegedBootstrapPath = path.join(
repoRoot,
'scripts',
'deploy',
'sql',
'bootstrap-backend-runtime-roles.sql',
);
const packagePath = path.join(repoRoot, 'package.json');
const taroSourceRoot = path.join(repoRoot, 'apps', 'taro', 'src');
function read(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const filePath = path.join(dir, entry.name);
return entry.isDirectory() ? walk(filePath) : [filePath];
});
}
const migration = read(migrationPath);
const authBoundaryMigration = read(authBoundaryMigrationPath);
const privilegedBootstrap = read(privilegedBootstrapPath);
assert.match(
privilegedBootstrap,
/revoke execute on all functions in schema public[\s\S]*from public, anon, authenticated, tiku_api, tiku_worker/i,
'the privileged bootstrap must close Supabase base-image extension RPC execution',
);
assert.match(
privilegedBootstrap,
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker/i,
'the privileged bootstrap must keep future extension-owner functions closed',
);
assert.match(
migration,
/revoke all privileges on all tables in schema public from public, anon, authenticated/i,
'existing public tables must not be exposed to client Data API roles',
);
assert.match(
authBoundaryMigration,
/revoke all on function app\.auth_user_exists\(uuid\)[\s\S]*from public, anon, authenticated, service_role, tiku_api, tiku_worker/i,
'the Auth existence boundary must be denied to every Data API role before the API-only grant',
);
assert.doesNotMatch(
authBoundaryMigration,
/grant execute on function app\.auth_user_exists\(uuid\) to (?:anon|authenticated|service_role)/i,
'the Auth existence boundary must never be exposed through PostgREST roles',
);
assert.match(
migration,
/revoke all privileges on all sequences in schema public from public, anon, authenticated/i,
'existing public sequences must not be exposed to client Data API roles',
);
assert.match(
migration,
/revoke all privileges on all functions in schema public from public, anon, authenticated/i,
'existing public RPC functions must not be exposed to client Data API roles',
);
assert.match(
migration,
/public functions remain executable by anon\/authenticated; run the privileged backend runtime role bootstrap before migrations/i,
'normal migrations must fail closed when Supabase-owned extension functions remain exposed',
);
assert.match(
migration,
/alter default privileges for role %I in schema public revoke all privileges on tables from public, anon, authenticated/i,
'future public tables must default to no client Data API grant',
);
assert.match(
migration,
/alter default privileges for role %I in schema public revoke all privileges on sequences from public, anon, authenticated/i,
'future public sequences must default to no client Data API grant',
);
assert.match(
migration,
/revoke create on schema public from public, anon, authenticated/i,
'client roles must not create objects in the exposed public schema',
);
assert.match(
migration,
/alter default privileges for role %I revoke execute on functions from public, anon, authenticated/i,
'future public RPC functions must require an explicit execute grant',
);
assert.match(
migration,
/select distinct owner_role\.rolname[\s\S]*pg_has_role\(current_user, owner_role\.oid, 'MEMBER'\)/i,
'default privileges must cover every public owner the migration role is authorized to manage',
);
assert.match(migration, /drop policy if exists platform_admin_platform_users/i);
assert.match(
migration,
/create policy platform_users_self_read[\s\S]*for select[\s\S]*to authenticated[\s\S]*auth_user_id\s*=\s*\(select auth\.uid\(\)\)/i,
'platform_users may expose only an explicit self-read policy to authenticated users',
);
assert.doesNotMatch(
migration,
/create policy [^;]+ on public\.platform_users[\s\S]*?for\s+(?:all|insert|update|delete)/i,
'platform_users must not have a client write policy',
);
assert.match(
migration,
/create or replace function app\.is_platform_admin\(\)[\s\S]*security definer[\s\S]*set search_path = ''[\s\S]*from public\.platform_users[\s\S]*status = 'active'/i,
'RLS platform authority must come from an active database identity',
);
assert.doesNotMatch(
migration,
/select\s+app\.current_role\(\)\s+in\s*\([^)]*platform_admin/i,
'a platform_admin JWT role claim must not be sufficient for RLS authority',
);
const readiness = read(readinessPath);
for (const gateId of [
'db.data_api.public_table_acl',
'db.data_api.public_sequence_acl',
'db.data_api.public_function_acl',
'db.data_api.public_default_acl',
'db.data_api.platform_users_write_policy',
'db.rls.platform_admin_authority',
]) {
assert.ok(readiness.includes(gateId), `production database readiness must include ${gateId}`);
}
const sdkImportViolations = [];
const dataApiCallViolations = [];
for (const filePath of walk(taroSourceRoot).filter(file => /\.(?:ts|tsx)$/.test(file))) {
const source = read(filePath);
const relative = path.relative(repoRoot, filePath).replace(/\\/g, '/');
if (source.includes('@supabase/supabase-js') && relative !== 'apps/taro/src/services/supabase.ts') {
sdkImportViolations.push(relative);
}
if (!source.includes('ensureSupabaseClient') && !source.includes('getSupabaseClient') && !source.includes('@supabase/supabase-js')) {
continue;
}
for (const match of source.matchAll(/\b([A-Za-z_$][\w$]*)\.(from|rpc)\s*\(/g)) {
if (match[1] !== 'Array' && match[1] !== 'Buffer') {
dataApiCallViolations.push(`${relative}:${match[0]}`);
}
}
}
assert.deepEqual(sdkImportViolations, [], 'Supabase SDK ownership must stay centralized in services/supabase.ts');
assert.deepEqual(dataApiCallViolations, [], 'Taro must not access business tables or RPCs through the Data API');
const rootPackage = JSON.parse(read(packagePath));
assert.ok(rootPackage.scripts?.['test:data-api:security'], 'root package must expose the Data API security contract test');
assert.ok(
rootPackage.scripts?.['test:readiness']?.includes('data-api-security-contract-test.js'),
'the production readiness contract suite must run the Data API security test',
);
console.log('[PASS] Supabase Data API deny-by-default security contract');

View File

@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n');
}
function section(source, startMarker, endMarker) {
const start = source.indexOf(startMarker);
assert.notEqual(start, -1, `missing section start: ${startMarker}`);
const end = source.indexOf(endMarker, start + startMarker.length);
assert.notEqual(end, -1, `missing section end: ${endMarker}`);
return source.slice(start, end);
}
function ordered(source, markers, label) {
let cursor = 0;
for (const marker of markers) {
const index = source.indexOf(marker, cursor);
assert.notEqual(index, -1, `${label}: missing or out-of-order marker: ${marker}`);
cursor = index + marker.length;
}
}
const rootDeploy = read('deploy.sh');
const compatDeploy = read('scripts/deploy/bin/deploy.sh');
const rootEnv = read('deploy.env.example');
const compatEnv = read('scripts/deploy/env/deploy.env.example');
const packageJson = read('package.json');
const taroPackageJson = read('apps/taro/package.json');
const apiService = read('scripts/deploy/systemd/tiku-api.service');
const workerService = read('scripts/deploy/systemd/tiku-worker@.service');
const workerTarget = read('scripts/deploy/systemd/tiku-workers.target');
const apiEnv = read('scripts/deploy/env/api.env.example');
const workerEnv = read('scripts/deploy/env/worker.env.example');
const runtimeRoleBootstrap = read('scripts/deploy/sql/bootstrap-backend-runtime-roles.sql');
const deployReadme = read('scripts/deploy/README.md');
assert.match(rootDeploy, /: "\$\{WWW_ROOT:=\/srv\/tiku-saas\/www\}"/);
assert.match(rootDeploy, /: "\$\{SERVICE_REPO_DIR:=\/opt\/tiku-saas\/repo\}"/);
assert.match(rootDeploy, /: "\$\{SERVICE_MODE:=systemd\}"/);
assert.match(rootDeploy, /: "\$\{HEALTHCHECK_URL:=http:\/\/127\.0\.0\.1:8787\/health\}"/);
assert.match(rootDeploy, /Production deployment requires a service restart strategy/);
assert.match(rootDeploy, /Production deployment requires HEALTHCHECK_URL/);
assert.match(rootDeploy, /Production systemd deployment requires SYNC_SERVICE_REPO=true/);
assert.match(rootDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/);
assert.match(compatDeploy, /Production deployment requires RUN_DB_READINESS=true before launch gate/);
assert.match(rootDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/);
assert.match(compatDeploy, /Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true/);
assert.match(rootDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/);
assert.match(compatDeploy, /Production database migrations require a separate DATABASE_MIGRATION_URL/);
assert.match(rootDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/);
assert.match(compatDeploy, /supabase db push --db-url \\"\\\$DATABASE_MIGRATION_URL\\"/);
assert.match(compatDeploy, /source "\$API_ENV_FILE"/);
assert.ok(rootDeploy.includes('LOCK_DIR="$DEPLOY_ROOT/.deploy.lock"'));
assert.ok(compatDeploy.includes('LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"'));
assert.match(rootDeploy, /LOCK_ACQUIRED=false/);
assert.match(compatDeploy, /LOCK_ACQUIRED=false/);
assert.match(compatDeploy, /export GIT_TERMINAL_PROMPT=0/);
assert.match(rootDeploy, /NPM_INSTALL_COMMAND:=npm ci --workspaces --include-workspace-root --include=dev/);
assert.match(rootDeploy, /NPM_INSTALL_COMMAND must allow the reviewed Taro workspace postinstall patches/);
assert.doesNotMatch(rootEnv, /--ignore-scripts/);
assert.match(rootEnv, /^NPM_INSTALL_COMMAND="npm ci --workspaces --include-workspace-root --include=dev"$/m);
assert.match(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--workspaces[\s\S]*--include-workspace-root[\s\S]*--include=dev/);
assert.doesNotMatch(compatDeploy, /npm --prefix "\$SOURCE_REPO_DIR" ci[\s\S]*--ignore-scripts/);
assert.match(taroPackageJson, /"postinstall": "node \.\.\/\.\.\/scripts\/taro-components-h5-runtime-patch\.js --apply"/);
assert.match(apiService, /WorkingDirectory=\/opt\/tiku-saas\/repo/);
assert.match(workerService, /WorkingDirectory=\/opt\/tiku-saas\/repo/);
assert.match(workerService, /ExecStart=.*--loop --job %i/);
assert.match(workerTarget, /Requires=tiku-worker@crm\.service/);
assert.match(workerTarget, /Wants=tiku-worker-monthly-usage\.timer/);
for (const [dist, target] of [
['h5-student', 'student'],
['h5-tenant-admin', 'tenant-admin'],
['h5-platform-admin', 'platform-admin'],
]) {
assert.ok(
rootDeploy.includes(`$release/apps/taro/dist/${dist}/\" \"$staging/${target}/`),
`root deploy must stage ${dist} as ${target}`,
);
assert.ok(
compatDeploy.includes(`$CANDIDATE_RELEASE/apps/taro/dist/${dist}/\" \"$staging/${target}/`),
`compat deploy must stage ${dist} as ${target}`,
);
}
const rootMain = section(rootDeploy, 'main() {', '\n}\n\nmain "$@"');
ordered(rootMain, [
'run_build_and_checks "$NEW_RELEASE"',
'stage_h5_release "$NEW_RELEASE"',
'ROLLBACK_ARMED=true',
'switch_current "$NEW_RELEASE"',
'sync_service_repo "$NEW_RELEASE"',
'restart_services',
'healthcheck',
'switch_www_release',
'verify_live_h5_release "$NEW_RELEASE"',
], 'root activation order');
assert.ok(rootDeploy.includes('DEPLOY_RELEASE_ROOT="$release"'), 'root launch gate must bind the candidate release root');
assert.ok(rootDeploy.includes('--verify-live-h5'), 'root deploy must verify the activated production H5 release');
const candidateChecks = section(compatDeploy, 'build_and_validate_candidate() {', '\n}\n\nstage_www_candidate() {');
ordered(candidateChecks, [
'npm run audit:taro:supply-chain',
'npm run build:taro:h5:student',
'npm run build:taro:h5:platform',
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json"',
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json"',
'install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json"',
'node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config',
'npm run manifest:taro:h5 -- --require-dist --require-runtime-config',
'npm run smoke:taro:h5',
'npm run audit:runtime',
'load_runtime_env',
'npm run readiness:production',
'run_shell "$DB_MIGRATION_COMMAND"',
'npm run readiness:production:db',
'npm run launch:gate',
], 'compat candidate validation order');
assert.ok(compatDeploy.includes('DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE"'), 'compat launch gate must bind the candidate release root');
const rootChecks = section(rootDeploy, 'run_build_and_checks() {', '\n}\n\nverify_live_h5_release() {');
ordered(rootChecks, [
'npm run audit:taro:supply-chain',
'npm run build:taro:h5:student',
'npm run audit:runtime',
'link_shared_env "$release"',
'load_runtime_env',
'npm run readiness:production',
'run_shell "$DB_MIGRATION_COMMAND"',
'npm run readiness:production:db',
'npm run launch:gate',
], 'root database gate order');
const compatMain = section(compatDeploy, 'main() {', '\n}\n\nmain "$@"');
assert.doesNotMatch(
compatMain,
/load_runtime_env/,
'production secrets must not be loaded before dependency installation and candidate builds',
);
ordered(compatMain, [
'stage_candidate "$release_name"',
'build_and_validate_candidate',
'stage_www_candidate "$release_name"',
'ROLLBACK_ARMED=true',
'atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK"',
'sync_service_repo "$CANDIDATE_RELEASE"',
'restart_services',
'healthcheck',
'switch_www_release "$candidate_www"',
'verify_live_h5_release',
], 'compat activation order');
assert.ok(compatDeploy.includes('--verify-live-h5'), 'compat deploy must verify the activated production H5 release');
assert.ok(!compatDeploy.includes('"$WWW_ROOT/student/"'), 'compat deploy must not overwrite the live student directory');
assert.match(compatDeploy, /WWW_RELEASES_DIR:\=\$\{WWW_ROOT%\/\}-releases/);
assert.match(compatDeploy, /\.tmp-\$release_name/);
const compatRollback = section(compatDeploy, 'rollback() {', '\n}\n\ncleanup() {');
assert.match(compatRollback, /atomic_symlink "\$PREVIOUS_WWW_RELEASE" "\$WWW_ROOT"/);
assert.match(compatRollback, /sync_service_repo "\$PREVIOUS_APP_RELEASE"/);
assert.match(compatRollback, /restart_services/);
assert.match(rootEnv, /^WWW_ROOT=\/srv\/tiku-saas\/www$/m);
assert.match(rootEnv, /^SERVICE_REPO_DIR=\/opt\/tiku-saas\/repo$/m);
assert.match(rootEnv, /^SERVICE_MODE=systemd$/m);
assert.match(rootEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m);
assert.match(rootEnv, /^HEALTHCHECK_URL=http:\/\/127\.0\.0\.1:8787\/health$/m);
assert.match(compatEnv, /^SOURCE_REPO_DIR=\/opt\/tiku-saas\/source$/m);
assert.match(compatEnv, /^REPO_DIR=\/opt\/tiku-saas\/repo$/m);
assert.match(rootEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m);
assert.match(compatEnv, /^NPM_AUDIT_REGISTRY=https:\/\/registry\.npmjs\.org\/$/m);
assert.match(rootEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m);
assert.match(compatEnv, /^RUN_TARO_SUPPLY_CHAIN_AUDIT=true$/m);
assert.match(compatEnv, /^SYSTEMD_UNITS="tiku-api\.service tiku-workers\.target"$/m);
assert.match(workerEnv, /^STORAGE_DEFAULT_BUCKET=/m);
assert.match(workerEnv, /^WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=/m);
assert.match(workerEnv, /^WORKER_CRM_POLL_INTERVAL_MS=/m);
assert.match(apiEnv, /chown root:deploy, and chmod 640/);
assert.doesNotMatch(workerEnv, /^ALIYUN_OSS_BUCKET=/m);
assert.doesNotMatch(workerEnv, /^ASSET_SECURITY_SCAN_ENDPOINT=/m);
assert.doesNotMatch(workerEnv, /^WORKER_POLL_INTERVAL_MS=/m);
assert.match(runtimeRoleBootstrap, /alter role tiku_api[\s\S]*bypassrls/i);
assert.match(runtimeRoleBootstrap, /alter role tiku_worker[\s\S]*bypassrls/i);
assert.doesNotMatch(runtimeRoleBootstrap, /password\s+['"]/i);
assert.match(deployReadme, /bootstrap:db-runtime-roles/);
assert.match(deployReadme, /BOOTSTRAP_BACKEND_RUNTIME_ROLES/);
assert.ok(
packageJson.includes('npm audit --registry=${NPM_AUDIT_REGISTRY:-https://registry.npmjs.org/}'),
'npm audit scripts must default to the official audit registry while remaining configurable',
);
console.log('deploy contract: ok');

View File

@@ -1,385 +1,470 @@
# tiku-supabase 云服务器部署说明
# SaaS 题库服务器部署手册
文档用于把当前仓库部署到云服务器,并和已经解析好的域名打通。仓库内只保存安全模板,真实密钥、数据库密码、支付密钥、短信密钥、对象存储密钥和 Gitea 部署凭证必须放在服务器 `/etc/tiku-saas/` 下,不能提交到 Git。
手册覆盖两类场景:
## 域名规划
1. 全新测试/预生产服务器的隔离 bootstrap。
2. 现有云服务器从旧原地覆盖脚本升级到版本化原子发布。
建议先按下面 6 个域名落地:
生产发布器默认 fail closed。缺少真实数据库 readiness、三端 runtime config、生产 evidence、浏览器烟测环境或服务重启权限时发布必须失败不能通过关闭门禁或伪造 evidence 绕过。
| 域名 | 用途 | 服务器转发 |
| --- | --- | --- |
| `api.tjszsb.com` | 自研业务 APITaro/H5/小程序统一调用 | `127.0.0.1:8787` |
| `app.tjszsb.com` | 学生 H5 题库端 | `/srv/tiku-saas/www/student` |
| `admin.tjszsb.com` | 租户后台 H5 | `/srv/tiku-saas/www/tenant-admin` |
| `console.tjszsb.com` | SaaS 平台后台 H5 | `/srv/tiku-saas/www/platform-admin` |
| `supabase.tjszsb.com` | Supabase API gateway/Auth/Storage/PostgREST | Supabase gateway通常是 `127.0.0.1:8000` |
| `studio.tjszsb.com` | Supabase Studio 运维后台 | 仅允许固定 IP/VPN/内网访问 |
## 先选择入口
`studio.tjszsb.com` 不建议裸露给公网。若必须临时开放,至少要加 Nginx IP 白名单、强密码、服务器防火墙和访问日志审计。
| 场景 | 入口 | 配置模型 | 状态 |
| --- | --- | --- | --- |
| 全新生产服务器 | 仓库根目录 `deploy.sh` | `/opt/tiku-saas/shared/` | 推荐 |
| 现有云服务器 | `scripts/deploy/bin/deploy.sh` 安装到 `/opt/tiku-saas/bin/deploy.sh` | `/etc/tiku-saas/` | 兼容升级入口 |
| 全新测试/预生产服务器 | 本手册的 staging bootstrap | 完全独立目录、数据库、域名和凭据 | 当前无一键 staging profile |
## 服务器目录
两套生产脚本的变量名不同,不能混用 env 模板:
推荐使用固定目录,方便后续脚本和 AI 协作不漂移:
- 根脚本:`REPO_URL``BRANCH``DEPLOY_ROOT`,模板为根目录 `deploy.env.example`
- 兼容脚本:`GIT_REPO``GIT_BRANCH``APP_ROOT`,模板为 `scripts/deploy/env/deploy.env.example`
```text
/opt/tiku-saas/repo Git 工作副本
/opt/tiku-saas/bin 服务器本地执行脚本
/srv/tiku-saas/www/student 学生端 H5 静态文件
/srv/tiku-saas/www/tenant-admin 租户后台 H5 静态文件
/srv/tiku-saas/www/platform-admin 平台后台 H5 静态文件
/srv/tiku-saas/data 运行期数据
/srv/tiku-saas/backups 数据库和对象存储备份
/etc/tiku-saas/deploy.env 部署脚本配置,含 Gitea 只读部署凭证
/etc/tiku-saas/api.env API 生产环境变量
/etc/tiku-saas/worker.env worker 生产环境变量
/etc/tiku-saas/runtime-config/ 三套 H5 公开运行时配置
```
建议创建独立低权限用户:
```bash
sudo useradd --system --create-home --shell /bin/bash deploy
sudo mkdir -p /opt/tiku-saas/bin /srv/tiku-saas/www/student /srv/tiku-saas/www/tenant-admin /srv/tiku-saas/www/platform-admin /srv/tiku-saas/data /srv/tiku-saas/backups /etc/tiku-saas/runtime-config
sudo chown -R deploy:deploy /opt/tiku-saas /srv/tiku-saas
sudo chmod 750 /etc/tiku-saas
```
## 宝塔服务器实际落地记录
2026-07-01 首次上云使用的是 Alibaba Cloud Linux 3 + 宝塔面板环境。该服务器的 80/443 已由宝塔 Nginx 接管,主配置不在 `/etc/nginx`,而在:
```text
/www/server/nginx/conf/nginx.conf
/www/server/panel/vhost/nginx/*.conf
```
因此在这类服务器上不要执行 `systemctl start nginx`、不要写 `/etc/nginx/sites-available`,也不要覆盖宝塔生成的站点配置。宝塔 Nginx 的测试和重载命令是:
```bash
/www/server/nginx/sbin/nginx -t -c /www/server/nginx/conf/nginx.conf
/www/server/nginx/sbin/nginx -s reload
```
本次保留企业目录隔离方案:
```text
/opt/tiku-saas/repo Gitea 工作副本
/opt/tiku-saas/bin 服务器部署脚本
/srv/tiku-saas/www H5 发布产物
/srv/tiku-saas/data 运行数据
/srv/tiku-saas/backups 备份
/etc/tiku-saas 真实 env、Gitea token、运行时配置
```
宝塔新增站点时会拦截 `/srv` 作为网站根目录。不要因此把密钥、仓库或运行数据搬进 `/www`。只为 H5 静态站点创建 `/www/wwwroot` 下的软链接:
```bash
mkdir -p /www/wwwroot/tiku-saas
ln -sfn /srv/tiku-saas/www/student /www/wwwroot/tiku-saas/student
ln -sfn /srv/tiku-saas/www/tenant-admin /www/wwwroot/tiku-saas/tenant-admin
ln -sfn /srv/tiku-saas/www/platform-admin /www/wwwroot/tiku-saas/platform-admin
chown -h deploy:deploy /www/wwwroot/tiku-saas/student
chown -h deploy:deploy /www/wwwroot/tiku-saas/tenant-admin
chown -h deploy:deploy /www/wwwroot/tiku-saas/platform-admin
```
宝塔面板中新增三个纯静态站点:
| 域名 | 宝塔根目录 |
| --- | --- |
| `app.tjszsb.com` | `/www/wwwroot/tiku-saas/student` |
| `admin.tjszsb.com` | `/www/wwwroot/tiku-saas/tenant-admin` |
| `console.tjszsb.com` | `/www/wwwroot/tiku-saas/platform-admin` |
每个站点需要保留 H5 history fallback并禁止缓存公开运行时配置
```nginx
location / {
try_files $uri $uri/ /index.html;
}
location = /runtime-config.json {
add_header Cache-Control "no-store" always;
try_files $uri =404;
}
```
当前服务器已经验证过的基础环境:
```text
Node.js: v20.20.2,系统级安装在 /usr/bin/nodedeploy 用户可用
npm: 10.8.2deploy 用户可用
Docker: 26.1.3
Docker Compose: v2.27.0
Nginx: 宝塔 /www/server/nginx/sbin/nginx1.30.1
```
不要使用 root 的 nvm Node 路径作为生产运行时。若 `deploy` 用户看不到 Node/NPM应安装系统级 NodeSource Node.js 20
```bash
curl -fsSL https://rpm.nodesource.com/setup_20.x | bash -
dnf install -y nodejs
sudo -u deploy bash -lc 'command -v node; command -v npm; node -v; npm -v'
```
大陆服务器 `npm ci` 可能访问 npm 官方源超时。本次部署在 `/etc/tiku-saas/deploy.env` 中使用可配置 npm registry 和重试参数:
```bash
NPM_REGISTRY=https://registry.npmmirror.com
NPM_FETCH_RETRIES=5
NPM_FETCH_RETRY_MINTIMEOUT=20000
NPM_FETCH_RETRY_MAXTIMEOUT=120000
NPM_FETCH_TIMEOUT=300000
```
截至 2026-07-01 21:39`sudo -u deploy /opt/tiku-saas/bin/deploy.sh` 已完成:
- Gitea `main` 拉取到 `/opt/tiku-saas/repo`
- `npm ci` 安装依赖。
- `npm run security:repo`,结果 0 finding。
- `node scripts/production-launch-gate-test.js`,通过。
- API 和 worker 构建通过。
- 学生端、租户后台、平台后台三套 Taro H5 构建通过。
- H5 发布到 `/srv/tiku-saas/www/student``/srv/tiku-saas/www/tenant-admin``/srv/tiku-saas/www/platform-admin`
- 三个 `runtime-config.json` 已安装到各自 H5 根目录。
Taro H5 构建存在 webpack asset size warning这是前端包体优化事项不影响当前部署继续进行。后续可做拆包、按需加载和 KaTeX 字体裁剪。
## 服务器接管和故障恢复
2026-07-03 最新接管状态:
- Gitea `main` 已包含 PNVS 短信认证、后台登录修复、PNVS provider 配置/诊断脚本和旧题库视觉对齐版本,最新提交应至少是 `4fb4125`
- 服务器仓库仍在 `/opt/tiku-saas/repo`,归属用户应为 `deploy:deploy`
- 生产 API 已能启动,`https://api.tjszsb.com/api/tenant/resolve?host=app.tjszsb.com` 已返回 `master` 租户。
- Supabase self-hosted 运行在 `/opt/tiku-saas/supabase-project`Kong 通过 Nginx 暴露到 `https://supabase.tjszsb.com`
- 线上 H5 公开配置文件在 `/srv/tiku-saas/www/*/runtime-config.json`,密钥只允许放 `supabasePublishableKey` 这类公开 key。
- 短信验证码登录生产必须使用 `AUTH_SMS_PROVIDER=aliyun-pnvs`。阿里云 AccessKey/Secret 只写入 `app_private.tenant_secrets(secret_scope='sms', secret_key='aliyun-pnvs')`,不要写进 `/etc/tiku-saas/api.env` 或 H5 `runtime-config.json`
配置 PNVS provider 推荐用仓库脚本写入数据库,避免手写 SQL 时把密钥打进命令历史。生产环境建议临时关闭 shell history再用 `read -s` 输入 AccessKeySecret
```bash
cd /opt/tiku-saas/repo
set -a
source /etc/tiku-saas/api.env
set +a
set +o history
read -r -p 'Aliyun AccessKeyId: ' ALIYUN_ACCESS_KEY_ID
read -r -s -p 'Aliyun AccessKeySecret: ' ALIYUN_ACCESS_KEY_SECRET; echo
read -r -p 'PNVS SignName: ' ALIYUN_PNVS_SIGN_NAME
read -r -p 'PNVS TemplateCode: ' ALIYUN_PNVS_TEMPLATE_CODE
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 \
ALIYUN_ACCESS_KEY_ID="$ALIYUN_ACCESS_KEY_ID" \
ALIYUN_ACCESS_KEY_SECRET="$ALIYUN_ACCESS_KEY_SECRET" \
ALIYUN_PNVS_SIGN_NAME="$ALIYUN_PNVS_SIGN_NAME" \
ALIYUN_PNVS_TEMPLATE_CODE="$ALIYUN_PNVS_TEMPLATE_CODE" \
npm run configure:aliyun-pnvs
unset ALIYUN_ACCESS_KEY_ID ALIYUN_ACCESS_KEY_SECRET ALIYUN_PNVS_SIGN_NAME ALIYUN_PNVS_TEMPLATE_CODE
set -o history
```
配置后先跑只读诊断,确认 env、`tenant_auth_providers``tenant_secrets` 对齐;输出只包含 AccessKey 长度和脱敏前后缀,不会打印密钥明文:
```bash
cd /opt/tiku-saas/repo
set -a
source /etc/tiku-saas/api.env
set +a
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run diagnose:aliyun-pnvs
```
如果 `readiness:production:db``legacy_sms_provider`,先 dry-run 查看仍处于 `active/testing` 的传统短信 provider再确认停用。这个脚本只会处理 `aliyun``aliyun-sms``tencent``tencent-sms` 等旧短信 auth provider不会改 PNVS 行:
```bash
cd /opt/tiku-saas/repo
set -a
source /etc/tiku-saas/api.env
set +a
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers
PNVS_TENANT_ID=00000000-0000-0000-0000-000000000001 npm run disable:legacy-sms-providers -- --apply
```
接管服务器时先做只读检查:
```bash
cd /opt/tiku-saas/repo
sudo -u deploy git status --short
sudo -u deploy git log -3 --oneline
sudo -u deploy git remote -v
systemctl status tiku-api --no-pager -l
systemctl status tiku-worker --no-pager -l
docker compose -f /opt/tiku-saas/supabase-project/docker-compose.yml ps
```
Gitea SSH 使用 `2222` 端口,推荐服务器 `deploy` 用户的 remote 使用完整 SSH URL
```bash
sudo -u deploy git -C /opt/tiku-saas/repo remote set-url origin ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git
sudo -u deploy ssh -o BatchMode=yes -T -p 2222 git@git.gongxue100.com
sudo -u deploy git -C /opt/tiku-saas/repo pull origin main
```
也可以写 `/home/deploy/.ssh/config`,但必须包含 `Port 2222`
```sshconfig
Host git.gongxue100.com
HostName git.gongxue100.com
User git
Port 2222
IdentityFile /home/deploy/.ssh/tiku_saas_deploy
IdentitiesOnly yes
```
如果 Taro 构建长时间没有输出,先判断它是真在编译还是已经卡死。构建中的 Taro/webpack 可能会有一段时间安静,但如果 `dist` 目录大小和最近修改时间 30 秒以上都不变,就按卡住处理:
```bash
ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm|node|taro|webpack' | grep -v grep
du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student
sleep 30
du -sh /opt/tiku-saas/repo/apps/taro/dist/h5-student
find /opt/tiku-saas/repo/apps/taro/dist/h5-student -type f -mmin -5 | head -20
```
确认卡住后,先在原终端 `Ctrl+C`。如果仍有残留 Taro 构建进程,再只结束这条构建链路,不要杀生产 API、Supabase 或其它 Node 服务:
```bash
ps -eo pid,ppid,user,stat,etime,%cpu,%mem,cmd | grep -E 'npm run build:taro|taro build --type h5|webpack' | grep -v grep
kill <pid>
sleep 3
kill -9 <pid>
```
然后清理单端产物并带 CI/内存参数重跑。先单独跑学生端,成功后再跑另外两端:
```bash
cd /opt/tiku-saas/repo
rm -rf apps/taro/dist/h5-student
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:student
rm -rf apps/taro/dist/h5-tenant-admin apps/taro/dist/h5-platform-admin
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:tenant
sudo -u deploy env CI=1 NODE_OPTIONS="--max-old-space-size=4096" npm run build:taro:h5:platform
```
三端构建成功后再发布:
服务器长期更新命令可以继续保持:
```bash
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
```
如果只是想接管代码开发,不要从服务器 `apps/taro/dist``/srv/tiku-saas/www` 拷贝产物;它们只是发布结果,源码以 Gitea `main` 为准
但首次发布当前基线前,必须先升级 `/opt/tiku-saas/bin/deploy.sh`、部署配置、API/Worker env 和 systemd units。发布脚本不会自动更新自己也不会自动安装 Nginx 或 systemd 模板
## 首次安装
## 发布能力与边界
1. 安装基础组件Docker、Docker Compose、Node.js 20+、Nginx、Certbot、Git、rsync、flock。
2. 按 Supabase 官方 self-hosting Docker 文档部署 Supabase。生产必须启用 HTTPS 反向代理Supabase 官方也要求生产自托管部署使用 HTTPS。
3. 把本目录模板复制到服务器:
新版生产部署器会:
```bash
sudo mkdir -p /opt/tiku-saas/bin /etc/tiku-saas/runtime-config
sudo cp scripts/deploy/bin/deploy.sh /opt/tiku-saas/bin/deploy.sh
sudo cp scripts/deploy/env/deploy.env.example /etc/tiku-saas/deploy.env
sudo cp scripts/deploy/env/api.env.example /etc/tiku-saas/api.env
sudo cp scripts/deploy/env/worker.env.example /etc/tiku-saas/worker.env
sudo cp scripts/deploy/runtime-config/h5-student.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-student.runtime-config.json
sudo cp scripts/deploy/runtime-config/h5-tenant-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json
sudo cp scripts/deploy/runtime-config/h5-platform-admin.runtime-config.example.json /etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json
sudo chmod 700 /opt/tiku-saas/bin/deploy.sh
sudo chmod 600 /etc/tiku-saas/*.env /etc/tiku-saas/runtime-config/*.json
1. 获取固定分支的最新 commit并在独立候选 release 安装锁定依赖。
2. 运行 TypeScript、仓库安全扫描、runtime audit 和 Taro 供应链审计。
3. 构建 API、Worker、学生 H5、租户后台 H5、平台后台 H5。
4. 注入三端公开 runtime config运行严格 guard、manifest、25 项静态 smoke 和 33 项真实浏览器交互 smoke。
5. 运行生产 env readiness可选执行 migration随后运行数据库 readiness。
6. 使用与 commit、artifact 和 SHA-256 绑定的真实 production launch evidence。
7. 原子切换应用与 Web release重启服务验证 API `/health` 和线上 H5 hash。
8. 失败时恢复上一份代码和 Web release。
脚本不会:
- 创建数据库或对象存储备份。
- 回滚已经提交的 migration。
- 自动更新 `/etc/systemd/system`、宝塔 Nginx 或 `/etc/tiku-saas/*.env`
- 逐一确认所有 Worker backlog、Provider、外部告警和业务数据正确性。
- 自动创建首个平台超管。
因此 migration 必须先备份并演练向后兼容。代码/Web 回滚不能被当作数据库回滚。
## 域名与目录
参考域名:
```text
api.tjszsb.com Node.js API
app.tjszsb.com 学生 H5
admin.tjszsb.com 租户后台 H5
console.tjszsb.com 平台后台 H5
supabase.tjszsb.com Supabase Gateway/Auth/Data API
studio.tjszsb.com Supabase Studio必须限制来源
```
4. 编辑 `/etc/tiku-saas/*.env``/etc/tiku-saas/runtime-config/*.json`,填入真实生产配置。
5. 安装 systemd 服务:
现有生产兼容布局:
```bash
sudo cp scripts/deploy/systemd/tiku-api.service /etc/systemd/system/tiku-api.service
sudo cp scripts/deploy/systemd/tiku-worker.service /etc/systemd/system/tiku-worker.service
sudo systemctl daemon-reload
sudo systemctl enable tiku-api tiku-worker
```text
/opt/tiku-saas/source Gitea 源码副本,只用于 fetch/build
/opt/tiku-saas/releases 版本化候选应用
/opt/tiku-saas/current 当前应用 release 软链接
/opt/tiku-saas/repo systemd 当前运行副本
/opt/tiku-saas/bin/deploy.sh 服务器外置兼容部署器
/srv/tiku-saas/www 当前三端 Web release 软链接
/srv/tiku-saas/www-releases 版本化 Web release
/etc/tiku-saas/deploy.env 部署配置和只读 Gitea 凭据
/etc/tiku-saas/api.env API 生产 env
/etc/tiku-saas/worker.env Worker 生产 env
/etc/tiku-saas/runtime-config/ 三端公开 runtime config
/etc/tiku-saas/production-launch-evidence.json
/etc/tiku-saas/launch-artifacts/ 与 evidence 配套的证据文件
```
6. 安装 Nginx 配置:
根部署器使用 `/opt/tiku-saas/shared/` 保存 deploy env、readiness env、runtime config 和 evidence。systemd 模板目前仍从 `/etc/tiku-saas/api.env``/etc/tiku-saas/worker.env` 读取运行配置,因此使用根脚本时也必须维护这两个文件;`shared/.env` 只用于候选 release 的 readiness关键 API 配置必须与 `/etc/tiku-saas/api.env` 保持一致,避免双配置漂移。现有服务器优先使用兼容入口,减少这项差异。
```bash
sudo cp scripts/deploy/nginx/tjszsb.com.conf.example /etc/nginx/sites-available/tiku-saas.conf
sudo ln -s /etc/nginx/sites-available/tiku-saas.conf /etc/nginx/sites-enabled/tiku-saas.conf
sudo nginx -t
sudo systemctl reload nginx
## 凭据
- 已经出现在聊天、工单、截图或日志里的 token 必须吊销。
- 服务器优先使用只读 SSH deploy keyGitea SSH 端口为 `2222`
- 使用 HTTPS 时token 只放权限为 `600/640` 的服务器 env由临时 `GIT_ASKPASS` 注入。
- token 不得写入 Git remote、脚本、README 或命令历史。
- `DATABASE_ADMIN_URL``DATABASE_MIGRATION_URL` 只从密码管理器临时注入,不长期写入 deploy/API/Worker env。
SSH remote
```text
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git
```
7. 申请 HTTPS 证书:
## 新测试服务器
```bash
sudo certbot --nginx -d api.tjszsb.com -d app.tjszsb.com -d admin.tjszsb.com -d console.tjszsb.com -d supabase.tjszsb.com -d studio.tjszsb.com
测试服务器必须与生产完全隔离:
```text
/opt/tiku-saas-staging
/srv/tiku-saas-staging
/etc/tiku-saas-staging
staging-api.example.com
staging-app.example.com
staging-admin.example.com
staging-console.example.com
独立 PostgreSQL/Supabase、Auth、bucket 和 Provider 测试账号
```
## Gitea 凭证
数据库安全标记必须是:
优先推荐 SSH deploy key。若暂时使用 Gitea token必须新建一个只读部署 token并写入 `/etc/tiku-saas/deploy.env`,不要把 token 写进脚本、Git remote、命令历史或 README。
已经在聊天、工单、截图里出现过的 token 都应当视为暴露,正式上云前请立即吊销并重新生成。
`deploy.sh` 会通过临时 `GIT_ASKPASS``git clone/fetch` 提供账号和 token避免 token 出现在 `git remote -v` 里。
## 更新发布
服务器上执行:
```bash
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
```text
environment=staging
allow_destructive_tests=false
```
脚本会执行:
API 与 Worker 应继续使用 `NODE_ENV=production`,这样能验证生产配置 fail-fast。staging 不能使用 production 数据库、bucket、支付/短信密钥或真实用户流量,也不能运行 `supabase db reset``db:smoke-seed:test``test:api``test:rls` 和会写入集成夹具的 Worker 测试。
1. 获取 `main` 最新代码
2. `npm ci` 安装锁定依赖。
3. 运行仓库安全扫描和生产上线门禁测试。
4. 构建 API、worker、学生 H5、租户后台 H5、平台后台 H5。
5.`rsync --delete` 发布静态产物。
6. 复制服务器本地 `runtime-config.json` 到对应 Web 根目录。
7. 重启 `tiku-api``tiku-worker`
8. 输出当前发布的 Git commit。
当前仓库没有经过验证的一键 staging profile。两套 deploy 脚本在 production 模式下都会强制真实 launch gatelaunch evidence 也不能用本地 mock 伪造。首次测试服务器采用下面的分阶段 bootstrap在 staging profile 被单独实现和验证前,不要直接套用生产 `/opt/tiku-saas``/srv/tiku-saas``/etc/tiku-saas` 路径
## 上线前检查
每次正式放量前至少执行:
### 1. 固定候选 commit
```bash
npm run security:repo
node scripts/production-launch-gate-test.js
node scripts/launch-persona-smoke-test.js
sudo install -d -o deploy -g deploy /opt/tiku-saas-staging/source
sudo -u deploy git clone \
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \
/opt/tiku-saas-staging/source
sudo -u deploy git -C /opt/tiku-saas-staging/source checkout <reviewed-commit-sha>
```
不要在验证期间继续移动候选分支。
### 2. 安装与构建预检
```bash
cd /opt/tiku-saas-staging/source
sudo -u deploy npm ci --workspaces --include-workspace-root --include=dev
sudo -u deploy npm run check:api
sudo -u deploy npm run check:worker
sudo -u deploy npm run check:taro
sudo -u deploy npm run security:repo
sudo -u deploy npm run audit:runtime
sudo -u deploy npm run audit:taro:supply-chain
sudo -u deploy npm run build:api
sudo -u deploy npm run build:worker
sudo -u deploy npm run build:taro:h5:student
sudo -u deploy npm run build:taro:h5:tenant
sudo -u deploy npm run build:taro:h5:platform
```
服务器必须安装 Chrome/Chromium或设置 `TARO_H5_SMOKE_BROWSER` 指向受支持浏览器。随后运行:
```bash
sudo -u deploy npm run smoke:taro:h5
sudo -u deploy env TARO_H5_INTERACTION_OUTPUT_DIR=/tmp/tiku-h5-staging \
npm run smoke:taro:h5:interaction
```
这一步只证明候选产物和本地 mock 旅程,不是生产 evidence。
### 3. 数据库 bootstrap 与 migration
先创建数据库和对象存储快照,再 dry-run runtime role 计划:
```bash
cd /opt/tiku-saas-staging/source
npm run bootstrap:db-runtime-roles
```
由真正 PostgreSQL superuser 应用一次:
```bash
DATABASE_ADMIN_URL='<secret-managed-superuser-url>' \
npm run bootstrap:db-runtime-roles -- \
--apply --confirm=BOOTSTRAP_BACKEND_RUNTIME_ROLES
```
bootstrap 不设置角色密码。用密码管理器为 `tiku_api``tiku_worker` 设置独立强密码,再把相应连接分别写入 staging API/Worker env。
全新数据库在 migrations 后没有业务租户数据,生产 readiness 会因没有 active tenant 而阻断。使用受控管理流程创建 staging 的 master tenant、active domain、branding/settings 和必要 Provider 配置;不要执行 `supabase/seed.sql`,它是本地开发 seed会把数据库标为 `local` 并写入 mock 数据。
由标准 migration role 应用迁移:
```bash
DATABASE_MIGRATION_URL='<secret-managed-migration-url>' \
supabase db push --db-url "$DATABASE_MIGRATION_URL"
```
然后使用 API 运行角色验证:
```bash
set -a
source /etc/tiku-saas-staging/api.env
set +a
npm run readiness:production
```
接入真实生产配置后,还要在服务器上补跑:
```bash
npm run readiness:production:db
npm run smoke:auth:remote
SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-real-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json
npm run perf:api:local
```
如果要同时验证手机号绑定也走 PNVS provider verification准备一个未绑定测试手机号后执行
migration 是前向操作。失败时按备份恢复方案处理,不依赖 deploy symlink 回滚。
### 4. runtime config 与受控激活
`scripts/deploy/runtime-config/*.example.json` 创建 staging 文件。浏览器 H5 使用 Origin 解析租户时,`tenantCode` 保持空字符串;只允许公开 HTTPS URL 和 Supabase publishable key。
在尚未具备 staging 原子发布 profile 时,先由运维在隔离路径安装 systemd/Nginx明确每一个 WorkingDirectory、EnvironmentFile、端口、域名和 Web root 都指向 `*-staging`。不得直接复制当前生产 unit 后仍保留 `/opt/tiku-saas/repo``/etc/tiku-saas`
完成真实 staging Auth/CORS/Provider/三类 persona、Worker 和线上 H5 检查后,将报告保存到 staging 自己的 evidence bundle。production launch evidence 仍只能由最终生产候选生成,不能从 staging 复制冒充。
## 现有服务器一次性升级
### 1. 冻结与备份
1. 将完整基线合并到 Gitea记录待部署 SHA。
2. 备份 PostgreSQL、对象存储、`/etc/tiku-saas`、旧部署脚本、旧运行目录和宝塔 Nginx 配置。
3. 验证备份可读,并记录数据库恢复与代码/Web 回滚步骤。
4. 首次 migration 推荐独立执行“备份 -> migration -> DB readiness -> evidence”正式应用发布时恢复 `RUN_DB_MIGRATIONS=false`
### 2. 准备 source checkout
从一个不依赖旧部署脚本的临时目录获取新代码:
```bash
SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=replace-with-login-phone SMS_SMOKE_BIND_PHONE=replace-with-bind-phone SMS_SMOKE_ORIGIN=https://admin.tjszsb.com npm run smoke:sms-login:remote -- --write docs/refactor/launch-artifacts/sms-pnvs-remote-smoke.json
sudo install -d -o deploy -g deploy /opt/tiku-saas/source
sudo -u deploy git clone \
ssh://git@git.gongxue100.com:2222/chenhaogxjy/tiku-supabase.git \
/opt/tiku-saas/source
sudo -u deploy git -C /opt/tiku-saas/source checkout <reviewed-commit-sha>
```
压测必须在目标云服务器、目标数据库参数、目标对象存储和目标 Nginx 配置下重新计算,本地 Windows 压测数据只能作为开发参考。
若目录已存在,只允许 clean checkout
## 关键安全要求
```bash
sudo -u deploy git -C /opt/tiku-saas/source status --short
sudo -u deploy git -C /opt/tiku-saas/source fetch origin main --prune
sudo -u deploy git -C /opt/tiku-saas/source checkout main
sudo -u deploy git -C /opt/tiku-saas/source merge --ff-only origin/main
```
- 前端只保存 `supabasePublishableKey`,严禁出现 service role、数据库密码、短信密钥、支付私钥。
- 自研业务 API 默认只接受 Supabase JWT 或迁移期受控 app session不允许前端携带平台管理密钥。
- API、worker、Supabase、Nginx 日志要开启轮转,避免磁盘被日志打满。
- 数据库至少每日备份,正式放量前要完成一次恢复演练。
- 支付回调、短信回调、对象存储回调必须使用 HTTPS 域名,并在 API 层校验签名和租户归属。
- Supabase Studio 必须限制访问来源。
### 3. 升级外置部署脚本
先备份,再安装兼容入口:
```bash
sudo cp -a /opt/tiku-saas/bin/deploy.sh \
/opt/tiku-saas/bin/deploy.sh.backup-$(date +%Y%m%d%H%M%S)
sudo install -o root -g deploy -m 0750 \
/opt/tiku-saas/source/scripts/deploy/bin/deploy.sh \
/opt/tiku-saas/bin/deploy.sh
```
不要期待仓库 pull 自动更新 `/opt/tiku-saas/bin/deploy.sh`
### 4. 更新配置
备份 `/etc/tiku-saas/deploy.env``api.env``worker.env`,再逐项 diff 模板,不要覆盖真实 secrets
```bash
diff -u /etc/tiku-saas/deploy.env \
/opt/tiku-saas/source/scripts/deploy/env/deploy.env.example || true
diff -u /etc/tiku-saas/api.env \
/opt/tiku-saas/source/scripts/deploy/env/api.env.example || true
diff -u /etc/tiku-saas/worker.env \
/opt/tiku-saas/source/scripts/deploy/env/worker.env.example || true
```
必须确认:
```text
SOURCE_REPO_DIR=/opt/tiku-saas/source
REPO_DIR=/opt/tiku-saas/repo
RELEASES_DIR=/opt/tiku-saas/releases
WWW_ROOT=/srv/tiku-saas/www
WWW_RELEASES_DIR=/srv/tiku-saas/www-releases
RUN_TARO_SUPPLY_CHAIN_AUDIT=true
RUN_DB_READINESS=true
RUN_LAUNCH_GATE=true
SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
HEALTHCHECK_URL=http://127.0.0.1:8787/health
```
API `DATABASE_URL` 必须使用 `tiku_api`Worker 必须使用 `tiku_worker`。真实 env 的权限推荐为 `root:deploy 0640`
Provider 密钥不要仅凭 env 模板判断“已经配置完成”。PNVS、OAuth、支付和租户级密钥以 `app_private.tenant_secrets` 及对应 Provider 配置为真实来源,必须通过仓库配置/诊断脚本和 production readiness 验证。对象存储及 Worker scanner 等进程级配置才由 API/Worker env 提供。
连接池要按进程总量预算:默认 9 个常驻 Worker 若每个 `DB_POOL_MAX=5`,仅 Worker 上限约 45 个连接;再加 API、timer/oneshot、Supabase 内部服务和运维连接。正式启用前应结合 PostgreSQL `max_connections` 和 PgBouncer 配额调整,而不是逐个进程孤立设置。
### 5. 升级 Worker 调度
不要使用通配复制后遗留旧 unit。显式安装当前清单
```bash
sudo systemctl stop tiku-worker.service 2>/dev/null || true
sudo systemctl disable tiku-worker.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/tiku-worker.service
for unit in \
tiku-api.service \
tiku-worker@.service \
tiku-worker-job@.service \
tiku-worker-monthly-usage.service \
tiku-worker-monthly-usage.timer \
tiku-worker-platform-audit-alerts.timer \
tiku-worker-platform-billing.timer \
tiku-worker-platform-dunning.timer \
tiku-worker-platform-usage.timer \
tiku-worker-student-supervision.timer \
tiku-workers.target
do
sudo install -o root -g root -m 0644 \
"/opt/tiku-saas/source/scripts/deploy/systemd/$unit" \
"/etc/systemd/system/$unit"
done
sudo systemctl daemon-reload
sudo systemctl enable tiku-api.service tiku-workers.target
```
不要在新的应用 release 尚未构建并同步到 `/opt/tiku-saas/repo` 前启动 `tiku-workers.target`
连续 Worker 为 CRM、commerce、provider bills、催缴通知、审计通知、assets、imports、public banks 和 exports定时任务负责计费、用量、月结超额、催缴、审计告警和学习督导。
### 6. systemd 权限
部署器以 `deploy` 用户运行,但需要重启 `tiku-api.service``tiku-workers.target`。首次升级前检查:
```bash
sudo -u deploy systemctl is-active tiku-api.service
sudo -u deploy systemctl restart tiku-api.service
```
第二条如果要求交互认证,部署会在切换阶段失败。不要给 `deploy` `NOPASSWD: ALL`。选择其一:
- 用受审 root wrapper 只允许 restart/is-active 这两个顶层 unit并把 `RESTART_COMMAND` 指向 wrapper。
- 配置精确的 PolicyKit 规则,只允许 deploy 管理本项目 unit。
- 由 root 执行受控部署器,同时确保 clone/npm/release 文件所有权仍为 deploy并重新验证脚本权限模型。
完成最小权限方案后,必须在非交互会话中验证。
### 7. 数据库、runtime config 与 evidence
按“新测试服务器”中的数据库步骤完成:
1. superuser runtime role/extension bootstrap。
2.`tiku_api/tiku_worker` 设置独立密码。
3. 标准 migration role 应用 migration。
4. `readiness:production``readiness:production:db`
三端 runtime config 路径:
```text
/etc/tiku-saas/runtime-config/h5-student.runtime-config.json
/etc/tiku-saas/runtime-config/h5-tenant-admin.runtime-config.json
/etc/tiku-saas/runtime-config/h5-platform-admin.runtime-config.json
```
`tenantCode` 默认留空,由三个生产 Origin 解析租户;只有明确的固定租户预览/小程序模式才填写。
production evidence 必须绑定待发布 commit。兼容脚本默认读取
```text
/etc/tiku-saas/production-launch-evidence.json
```
相对 artifact 路径从 evidence 所在目录解析,因此完整 bundle 应为:
```text
/etc/tiku-saas/production-launch-evidence.json
/etc/tiku-saas/launch-artifacts/*
```
只复制 evidence JSON 而不复制 artifacts 会 fail closed。staging、本地 mock 或旧 commit 的 evidence 不能复用。
### 8. 宝塔/Nginx
宝塔服务器继续保留它管理的站点文件,不要直接套用普通 `/etc/nginx/sites-available` 命令。人工对照 `scripts/deploy/nginx/tjszsb.com.conf.example` 同步:
- `/srv/tiku-saas/www/{student,tenant-admin,platform-admin}` 三端路径。
- SPA history fallback。
- `runtime-config.json` `no-store`
- 带 hash 静态资源长期缓存。
- API forwarded headers、body limit、超时和必要限流。
- Supabase Gateway/Studio HTTPS 与 Studio 来源限制。
若宝塔站点根目录必须位于 `/www/wwwroot`,用受控软链接指向 `/srv/tiku-saas/www/*`,不要复制第二份静态文件形成漂移。
### 9. 正式执行
服务器需有 Node.js 20、npm、Git、rsync、curl、Supabase CLI以及 Chrome/Chromium。浏览器不在标准路径时设置 `TARO_H5_SMOKE_BROWSER`
先确认 source clean、evidence commit 和待发布 SHA 一致,再运行:
```bash
sudo -u deploy /opt/tiku-saas/bin/deploy.sh
```
如果本次数据库已独立迁移完成,保持:
```text
RUN_DB_MIGRATIONS=false
```
部署器在任何激活前完成候选构建和门禁;应用/Web 切换、服务重启、API health 或线上 H5 hash 失败会触发代码/Web回滚。
## 发布后验收
脚本只检查顶层 target active 和 API `/health`,发布后还必须人工运行:
```bash
systemctl --failed --no-pager
systemctl is-active tiku-api.service tiku-workers.target
systemctl list-dependencies tiku-workers.target --no-pager
systemctl list-timers 'tiku-worker-*' --all --no-pager
systemctl status 'tiku-worker@*.service' --no-pager
journalctl -u tiku-api.service -n 100 --no-pager
journalctl -u 'tiku-worker@*.service' -n 200 --no-pager
curl -fsS http://127.0.0.1:8787/health
```
随后验证:
- 三个生产域名的 `index.html`、runtime config、登录和主入口。
- `/api/tenant/resolve` 的 Origin/CORS。
- 真实 Auth/JWKS、PNVS 短信、支付/退款回调、对象存储签名与扫描。
- Worker backlog、失败重试、timer 下次执行时间和外部告警渠道。
- 错误率、P95/P99、数据库连接池、慢 SQL、锁等待、磁盘和日志轮转。
## 回滚
自动回滚只覆盖应用代码和三端 Web
```text
/opt/tiku-saas/current
/opt/tiku-saas/repo
/srv/tiku-saas/www
```
数据库 migration、对象存储写入、外部 Provider 状态和已产生业务事件不会自动回滚。数据库恢复必须使用发布前已验证的快照/备份,并由负责人单独决策。
手工代码/Web 回滚前,先记录失败 release 和日志,再将软链接切回上一 release、同步运行目录、重启服务并复核 API/H5。不要使用 `git reset --hard` 处理服务器运行目录。
## 安全检查清单
- [ ] 暴露 token 已吊销,服务器只读凭据已轮换。
- [ ] 待发布 commit 已冻结source checkout clean。
- [ ] PostgreSQL、对象存储和 `/etc/tiku-saas` 已备份并验证可读。
- [ ] `tiku_api/tiku_worker` bootstrap、独立密码和 migration role 已完成。
- [ ] 三端 runtime config 只有公开字段,`tenantCode` 策略正确。
- [ ]`tiku-worker.service` 已停止、禁用并删除。
- [ ] 新 Worker units/timers/target 已安装deploy 重启权限最小化。
- [ ] Chrome/Chromium 可用于 33 项 H5 交互 smoke。
- [ ] evidence commit、artifacts、hash 和人工 attestation 完整。
- [ ] 宝塔/Nginx history fallback、缓存、CORS、CSP 和 forwarded headers 已复核。
- [ ] 首个平台超管通过 Auth-bound bootstrap 创建并留有审计。
- [ ] 发布后逐 Worker、timer、Provider、日志和监控验收完成。
## 参考
- Supabase self-hosting Docker: https://supabase.com/docs/guides/self-hosting/docker
- Supabase reverse proxy and HTTPS: https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https
- Supabase Auth self-hosting config: https://supabase.com/docs/guides/self-hosting/auth/config
- Supabase self-hosted S3 storage: https://supabase.com/docs/guides/self-hosting/self-hosted-s3
- [根 README](../../README.md)
- [生产地基基线](../../docs/refactor/production-foundation-baseline-20260712.md)
- [Taro H5 部署](../../docs/refactor/taro-h5-deployment.md)
- [上线 evidence 模板](../../docs/refactor/production-launch-evidence.template.json)
- [Supabase self-hosting](https://supabase.com/docs/guides/self-hosting/docker)
- [Supabase HTTPS reverse proxy](https://supabase.com/docs/guides/self-hosting/self-hosted-proxy-https)

View File

@@ -2,6 +2,7 @@
set -Eeuo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export GIT_TERMINAL_PROMPT=0
CONFIG_FILE="${CONFIG_FILE:-/etc/tiku-saas/deploy.env}"
@@ -16,35 +17,180 @@ source "$CONFIG_FILE"
: "${GIT_REPO:?GIT_REPO is required}"
: "${GIT_BRANCH:=main}"
: "${APP_ROOT:=/opt/tiku-saas}"
: "${SOURCE_REPO_DIR:=$APP_ROOT/source}"
: "${REPO_DIR:=$APP_ROOT/repo}"
: "${RELEASES_DIR:=$APP_ROOT/releases}"
: "${CURRENT_LINK:=$APP_ROOT/current}"
: "${WWW_ROOT:=/srv/tiku-saas/www}"
: "${WWW_RELEASES_DIR:=${WWW_ROOT%/}-releases}"
: "${RUNTIME_CONFIG_DIR:=/etc/tiku-saas/runtime-config}"
: "${KEEP_RELEASES:=5}"
: "${RUN_SECURITY_CHECKS:=true}"
: "${RUN_RUNTIME_AUDIT:=true}"
: "${RUN_TARO_SUPPLY_CHAIN_AUDIT:=true}"
: "${RUN_LAUNCH_GATE:=true}"
: "${RUN_DB_MIGRATIONS:=false}"
: "${RUN_DB_READINESS:=true}"
: "${DATABASE_MIGRATION_URL:=}"
: "${DB_MIGRATION_COMMAND:=supabase db push --db-url \"\$DATABASE_MIGRATION_URL\"}"
: "${API_ENV_FILE:=/etc/tiku-saas/api.env}"
: "${RESTART_SERVICES:=true}"
: "${SYSTEMD_UNITS:=tiku-api.service tiku-workers.target}"
: "${HEALTHCHECK_URL:=http://127.0.0.1:8787/health}"
: "${HEALTHCHECK_TIMEOUT_SECONDS:=60}"
: "${HEALTHCHECK_INTERVAL_SECONDS:=2}"
: "${NPM_REGISTRY:=https://registry.npmjs.org/}"
: "${NPM_AUDIT_REGISTRY:=https://registry.npmjs.org/}"
: "${NPM_FETCH_RETRIES:=5}"
: "${NPM_FETCH_RETRY_MINTIMEOUT:=20000}"
: "${NPM_FETCH_RETRY_MAXTIMEOUT:=120000}"
: "${NPM_FETCH_TIMEOUT:=300000}"
LOCK_FILE="${LOCK_FILE:-/tmp/tiku-saas-deploy.lock}"
mkdir -p "$APP_ROOT" "$WWW_ROOT/student" "$WWW_ROOT/tenant-admin" "$WWW_ROOT/platform-admin"
export DATABASE_MIGRATION_URL
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
echo "Another deployment is already running." >&2
exit 1
fi
LOCK_DIR="${LOCK_DIR:-$APP_ROOT/.deploy.lock}"
LOCK_ACQUIRED=false
ASKPASS_FILE=""
CANDIDATE_RELEASE=""
PREVIOUS_APP_RELEASE=""
PREVIOUS_WWW_RELEASE=""
BOOTSTRAP_SERVICE_BACKUP=""
ROLLBACK_ARMED=false
APP_SWITCHED=false
SERVICE_SYNC_STARTED=false
WWW_SWITCH_STARTED=false
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
die() {
printf '[deploy][error] %s\n' "$*" >&2
exit 1
}
truthy() {
case "${1:-}" in
1|true|TRUE|yes|YES|y|Y|on|ON) return 0 ;;
*) return 1 ;;
esac
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
run_shell() {
log "+ $*"
bash -lc "$*"
}
load_runtime_env() {
[[ -r "$API_ENV_FILE" ]] || die "Missing API runtime config: $API_ENV_FILE"
set -a
# shellcheck disable=SC1090
source "$API_ENV_FILE"
set +a
}
atomic_symlink() {
local target="$1"
local link="$2"
local next_link="${link}.next.$$"
rm -f "$next_link"
ln -s "$target" "$next_link"
if [[ -L "$link" || ! -e "$link" ]]; then
mv -Tf "$next_link" "$link"
return 0
fi
rm -f "$next_link"
return 1
}
restart_services() {
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
[[ -n "$SYSTEMD_UNITS" ]] || die "SYSTEMD_UNITS is required"
local unit
for unit in $SYSTEMD_UNITS; do
systemctl restart "$unit" || return 1
done
for unit in $SYSTEMD_UNITS; do
systemctl is-active --quiet "$unit" || return 1
done
}
healthcheck() {
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
local deadline=$((SECONDS + HEALTHCHECK_TIMEOUT_SECONDS))
log "Waiting for API healthcheck: $HEALTHCHECK_URL"
while (( SECONDS < deadline )); do
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
log "API healthcheck passed."
return 0
fi
sleep "$HEALTHCHECK_INTERVAL_SECONDS"
done
return 1
}
sync_service_repo() {
local release="$1"
[[ -d "$release" ]] || return 1
if [[ -L "$REPO_DIR" ]]; then
[[ "$(readlink -f "$REPO_DIR")" == "$(readlink -f "$release")" ]] \
|| die "REPO_DIR symlink must resolve to the selected current release"
return 0
fi
mkdir -p "$REPO_DIR"
rsync -a --delete --exclude .git "$release/" "$REPO_DIR/"
}
rollback() {
local failed=false
ROLLBACK_ARMED=false
if [[ "$WWW_SWITCH_STARTED" == "true" && -n "$PREVIOUS_WWW_RELEASE" ]]; then
log "Rolling Web root back to $PREVIOUS_WWW_RELEASE"
atomic_symlink "$PREVIOUS_WWW_RELEASE" "$WWW_ROOT" || failed=true
elif [[ "$WWW_SWITCH_STARTED" == "true" ]]; then
rm -f "$WWW_ROOT" || failed=true
fi
if [[ "$APP_SWITCHED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
log "Rolling application current link back to $PREVIOUS_APP_RELEASE"
atomic_symlink "$PREVIOUS_APP_RELEASE" "$CURRENT_LINK" || failed=true
elif [[ "$APP_SWITCHED" == "true" ]]; then
rm -f "$CURRENT_LINK" || failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$PREVIOUS_APP_RELEASE" ]]; then
sync_service_repo "$PREVIOUS_APP_RELEASE" || failed=true
elif [[ "$SERVICE_SYNC_STARTED" == "true" && -n "$BOOTSTRAP_SERVICE_BACKUP" ]]; then
log "Restoring pre-release service runtime"
sync_service_repo "$BOOTSTRAP_SERVICE_BACKUP" || failed=true
fi
if [[ "$SERVICE_SYNC_STARTED" == "true" ]]; then
restart_services || failed=true
healthcheck || failed=true
fi
[[ "$failed" == "false" ]]
}
cleanup() {
if [[ -n "${ASKPASS_FILE:-}" && -f "$ASKPASS_FILE" ]]; then
local status=$?
if [[ "$status" -ne 0 && "$ROLLBACK_ARMED" == "true" ]]; then
rollback || true
fi
if [[ -n "$ASKPASS_FILE" && -f "$ASKPASS_FILE" ]]; then
rm -f "$ASKPASS_FILE"
fi
if [[ "$LOCK_ACQUIRED" == "true" && -d "$LOCK_DIR" ]]; then
rmdir "$LOCK_DIR" 2>/dev/null || true
fi
}
trap cleanup EXIT
@@ -53,14 +199,11 @@ prepare_git_auth() {
export GIT_SSH_COMMAND
return
fi
if [[ -z "${GIT_USERNAME:-}" || -z "${GITEA_TOKEN:-}" ]]; then
return
fi
export GIT_USERNAME
export GITEA_TOKEN
export GIT_USERNAME GITEA_TOKEN
ASKPASS_FILE="$(mktemp)"
chmod 700 "$ASKPASS_FILE"
cat > "$ASKPASS_FILE" <<'ASKPASS'
@@ -75,81 +218,225 @@ ASKPASS
export GIT_TERMINAL_PROMPT=0
}
prepare_git_auth
if [[ ! -d "$REPO_DIR/.git" ]]; then
log "Cloning repository..."
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$REPO_DIR"
fi
cd "$REPO_DIR"
log "Fetching $GIT_BRANCH..."
git fetch origin "$GIT_BRANCH" --prune
git checkout "$GIT_BRANCH"
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Repository has local changes. Refusing to deploy until the server checkout is clean." >&2
exit 1
fi
git merge --ff-only "origin/$GIT_BRANCH"
CURRENT_SHA="$(git rev-parse --short=12 HEAD)"
log "Deploying commit $CURRENT_SHA"
log "Installing dependencies with npm ci..."
npm ci \
--registry="$NPM_REGISTRY" \
--fetch-retries="$NPM_FETCH_RETRIES" \
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
--fetch-timeout="$NPM_FETCH_TIMEOUT"
if [[ "$RUN_SECURITY_CHECKS" == "true" ]]; then
log "Running repository security scan..."
npm run security:repo
fi
if [[ "$RUN_LAUNCH_GATE" == "true" ]]; then
log "Running production launch gate test..."
node scripts/production-launch-gate-test.js
fi
log "Building API and worker..."
npm run build:api
npm run build:worker
log "Building H5 portals..."
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
log "Publishing H5 static assets..."
rsync -a --delete apps/taro/dist/h5-student/ "$WWW_ROOT/student/"
rsync -a --delete apps/taro/dist/h5-tenant-admin/ "$WWW_ROOT/tenant-admin/"
rsync -a --delete apps/taro/dist/h5-platform-admin/ "$WWW_ROOT/platform-admin/"
install_runtime_config() {
local source_file="$1"
local target_dir="$2"
if [[ ! -r "$source_file" ]]; then
echo "Missing runtime config: $source_file" >&2
exit 1
fi
[[ -r "$source_file" ]] || die "Missing runtime config: $source_file"
install -m 0644 "$source_file" "$target_dir/runtime-config.json"
}
log "Installing H5 runtime config files..."
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "$WWW_ROOT/student"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "$WWW_ROOT/tenant-admin"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "$WWW_ROOT/platform-admin"
stage_candidate() {
local release_name="$1"
local staging="$RELEASES_DIR/.tmp-$release_name"
if [[ "$RESTART_SERVICES" == "true" ]]; then
log "Restarting systemd services..."
systemctl restart tiku-api.service
systemctl restart tiku-worker.service
systemctl --no-pager --full status tiku-api.service tiku-worker.service >/dev/null
fi
rm -rf "$staging"
mkdir -p "$staging"
rsync -a --delete --exclude .git --exclude node_modules "$SOURCE_REPO_DIR/" "$staging/"
if ! cp -al "$SOURCE_REPO_DIR/node_modules" "$staging/node_modules"; then
rm -rf "$staging/node_modules"
rsync -a "$SOURCE_REPO_DIR/node_modules/" "$staging/node_modules/"
fi
CANDIDATE_RELEASE="$RELEASES_DIR/$release_name"
rm -rf "$CANDIDATE_RELEASE"
mv "$staging" "$CANDIDATE_RELEASE"
}
log "Deployment finished: $CURRENT_SHA"
build_and_validate_candidate() {
cd "$CANDIDATE_RELEASE"
if truthy "$RUN_SECURITY_CHECKS"; then
log "Running repository security scan against the candidate..."
GIT_DIR="$SOURCE_REPO_DIR/.git" GIT_WORK_TREE="$CANDIDATE_RELEASE" npm run security:repo
fi
log "Building API, worker and H5 portals..."
npm run build:api
npm run build:worker
npm run check:taro
if truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT"; then
npm run audit:taro:supply-chain
fi
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
log "Installing candidate H5 runtime configs..."
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-student.runtime-config.json" "apps/taro/dist/h5-student"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-tenant-admin.runtime-config.json" "apps/taro/dist/h5-tenant-admin"
install_runtime_config "$RUNTIME_CONFIG_DIR/h5-platform-admin.runtime-config.json" "apps/taro/dist/h5-platform-admin"
log "Validating candidate H5 artifacts before touching $WWW_ROOT..."
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
npm run manifest:taro:h5 -- --require-dist --require-runtime-config
npm run smoke:taro:h5
TARO_H5_INTERACTION_OUTPUT_DIR="${TARO_H5_INTERACTION_OUTPUT_DIR:-/tmp/tiku-h5-smoke}" npm run smoke:taro:h5:interaction
if truthy "$RUN_RUNTIME_AUDIT"; then
NPM_AUDIT_REGISTRY="$NPM_AUDIT_REGISTRY" npm run audit:runtime
fi
load_runtime_env
log "Running production environment readiness before the database migration step..."
npm run readiness:production
if truthy "$RUN_DB_MIGRATIONS"; then
log "Applying Supabase database migrations..."
run_shell "$DB_MIGRATION_COMMAND"
fi
if truthy "$RUN_DB_READINESS"; then
log "Running production database readiness after the optional migration step..."
npm run readiness:production:db
fi
if truthy "$RUN_LAUNCH_GATE"; then
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
log "Running production launch gate against the candidate..."
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE"
fi
}
verify_live_h5_release() {
truthy "$RUN_LAUNCH_GATE" || return 0
: "${PRODUCTION_LAUNCH_EVIDENCE:?PRODUCTION_LAUNCH_EVIDENCE is required when RUN_LAUNCH_GATE=true}"
log "Verifying the activated H5 release against production URLs..."
(
cd "$CANDIDATE_RELEASE"
DEPLOY_COMMIT_SHA="$(git -C "$SOURCE_REPO_DIR" rev-parse HEAD)" \
DEPLOY_RELEASE_ROOT="$CANDIDATE_RELEASE" \
npm run launch:gate -- --evidence "$PRODUCTION_LAUNCH_EVIDENCE" --verify-live-h5
)
}
stage_www_candidate() {
local release_name="$1"
local staging="$WWW_RELEASES_DIR/.tmp-$release_name"
local final="$WWW_RELEASES_DIR/$release_name"
rm -rf "$staging"
mkdir -p "$staging/student" "$staging/tenant-admin" "$staging/platform-admin"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-student/" "$staging/student/"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-tenant-admin/" "$staging/tenant-admin/"
rsync -a --delete --copy-links "$CANDIDATE_RELEASE/apps/taro/dist/h5-platform-admin/" "$staging/platform-admin/"
rm -rf "$final"
mv "$staging" "$final"
printf '%s\n' "$final"
}
switch_www_release() {
local candidate_www="$1"
WWW_SWITCH_STARTED=true
if [[ -L "$WWW_ROOT" ]]; then
PREVIOUS_WWW_RELEASE="$(readlink -f "$WWW_ROOT")"
elif [[ -d "$WWW_ROOT" ]]; then
PREVIOUS_WWW_RELEASE="$WWW_RELEASES_DIR/bootstrap-www-$(date +%Y%m%d%H%M%S)"
mv "$WWW_ROOT" "$PREVIOUS_WWW_RELEASE"
elif [[ -e "$WWW_ROOT" ]]; then
die "WWW_ROOT exists but is not a directory or symlink: $WWW_ROOT"
fi
atomic_symlink "$candidate_www" "$WWW_ROOT"
}
prune_releases() {
local directory="$1"
[[ "$KEEP_RELEASES" =~ ^[0-9]+$ ]] || return 0
(( KEEP_RELEASES > 0 )) || return 0
find "$directory" -mindepth 1 -maxdepth 1 -type d ! -name '.*' ! -name 'bootstrap-*' -print \
| sort -r \
| tail -n +"$((KEEP_RELEASES + 1))" \
| while IFS= read -r old_release; do
[[ "$old_release" == "$CANDIDATE_RELEASE" || "$old_release" == "$PREVIOUS_APP_RELEASE" || "$old_release" == "$PREVIOUS_WWW_RELEASE" ]] && continue
rm -rf "$old_release"
done
}
main() {
require_command git
require_command npm
require_command rsync
require_command curl
[[ "$WWW_RELEASES_DIR" != "$WWW_ROOT" ]] || die "WWW_RELEASES_DIR must be outside WWW_ROOT"
case "${WWW_RELEASES_DIR%/}/" in
"${WWW_ROOT%/}/"*) die "WWW_RELEASES_DIR must not be nested under WWW_ROOT" ;;
esac
[[ "$SOURCE_REPO_DIR" != "$REPO_DIR" ]] || die "SOURCE_REPO_DIR must be separate from the systemd REPO_DIR"
truthy "$RESTART_SERVICES" || die "Production deployment requires RESTART_SERVICES=true"
[[ -n "$HEALTHCHECK_URL" ]] || die "Production deployment requires HEALTHCHECK_URL"
truthy "$RUN_LAUNCH_GATE" || die "Production deployment requires RUN_LAUNCH_GATE=true"
truthy "$RUN_TARO_SUPPLY_CHAIN_AUDIT" \
|| die "Production deployment requires RUN_TARO_SUPPLY_CHAIN_AUDIT=true"
truthy "$RUN_DB_READINESS" \
|| die "Production deployment requires RUN_DB_READINESS=true before launch gate"
if truthy "$RUN_DB_MIGRATIONS"; then
[[ -n "$DATABASE_MIGRATION_URL" ]] \
|| die "Production database migrations require a separate DATABASE_MIGRATION_URL"
fi
mkdir -p "$APP_ROOT" "$RELEASES_DIR" "$WWW_RELEASES_DIR"
mkdir "$LOCK_DIR" 2>/dev/null || die "Another deployment is already running: $LOCK_DIR"
LOCK_ACQUIRED=true
prepare_git_auth
if [[ ! -d "$SOURCE_REPO_DIR/.git" ]]; then
log "Cloning source repository..."
git clone --branch "$GIT_BRANCH" "$GIT_REPO" "$SOURCE_REPO_DIR"
fi
log "Fetching $GIT_BRANCH..."
if [[ -n "$(git -C "$SOURCE_REPO_DIR" status --porcelain --untracked-files=normal)" ]]; then
die "Source repository has local changes; refusing to deploy."
fi
git -C "$SOURCE_REPO_DIR" fetch origin "$GIT_BRANCH" --prune
git -C "$SOURCE_REPO_DIR" checkout "$GIT_BRANCH"
git -C "$SOURCE_REPO_DIR" merge --ff-only "origin/$GIT_BRANCH"
local commit release_name
commit="$(git -C "$SOURCE_REPO_DIR" rev-parse --short=12 HEAD)"
release_name="$(date +%Y%m%d%H%M%S)-$commit"
log "Preparing candidate commit $commit"
log "Installing locked dependencies in the source checkout..."
npm --prefix "$SOURCE_REPO_DIR" ci \
--workspaces \
--include-workspace-root \
--include=dev \
--registry="$NPM_REGISTRY" \
--fetch-retries="$NPM_FETCH_RETRIES" \
--fetch-retry-mintimeout="$NPM_FETCH_RETRY_MINTIMEOUT" \
--fetch-retry-maxtimeout="$NPM_FETCH_RETRY_MAXTIMEOUT" \
--fetch-timeout="$NPM_FETCH_TIMEOUT"
stage_candidate "$release_name"
build_and_validate_candidate
local candidate_www
candidate_www="$(stage_www_candidate "$release_name")"
if [[ -L "$CURRENT_LINK" ]]; then
PREVIOUS_APP_RELEASE="$(readlink -f "$CURRENT_LINK")"
elif [[ -d "$REPO_DIR" ]]; then
BOOTSTRAP_SERVICE_BACKUP="$RELEASES_DIR/bootstrap-service-$(date +%Y%m%d%H%M%S)"
mkdir -p "$BOOTSTRAP_SERVICE_BACKUP"
rsync -a --delete --exclude .git "$REPO_DIR/" "$BOOTSTRAP_SERVICE_BACKUP/"
fi
ROLLBACK_ARMED=true
atomic_symlink "$CANDIDATE_RELEASE" "$CURRENT_LINK" || die "Failed to switch application current release"
APP_SWITCHED=true
SERVICE_SYNC_STARTED=true
sync_service_repo "$CANDIDATE_RELEASE"
restart_services
healthcheck
switch_www_release "$candidate_www" || die "Failed to switch Web release"
verify_live_h5_release
ROLLBACK_ARMED=false
prune_releases "$RELEASES_DIR"
prune_releases "$WWW_RELEASES_DIR"
log "Deployment finished: $commit"
}
main "$@"

View File

@@ -1,12 +1,32 @@
# Copy to /etc/tiku-saas/api.env and chmod 600.
# Copy to /etc/tiku-saas/api.env, chown root:deploy, and chmod 640.
# This file is read by systemd. Do not commit the real file.
NODE_ENV=production
PORT=8787
API_HEADERS_TIMEOUT_MS=15000
API_REQUEST_TIMEOUT_MS=120000
API_KEEP_ALIVE_TIMEOUT_MS=5000
API_SHUTDOWN_GRACE_PERIOD_MS=30000
API_MAX_REQUESTS_PER_SOCKET=1000
DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres
DATABASE_URL=postgresql://tiku_api:replace-with-password@127.0.0.1:5432/postgres
DB_EXPECTED_RUNTIME_ROLE=tiku_api
DB_POOL_MAX=10
DB_CONNECTION_TIMEOUT_MS=5000
DB_QUERY_TIMEOUT_MS=35000
DB_STATEMENT_TIMEOUT_MS=30000
DB_LOCK_TIMEOUT_MS=5000
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
DB_IDLE_TIMEOUT_MS=30000
DB_POOL_MAX_USES=7500
DB_POOL_MAX_LIFETIME_SECONDS=1800
DB_APPLICATION_NAME=tiku-api
DEFAULT_TENANT_SLUG=master
CORS_ORIGIN=https://app.tjszsb.com,https://admin.tjszsb.com,https://console.tjszsb.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
@@ -21,6 +41,11 @@ PLATFORM_ADMIN_API_KEY=replace-with-strong-random-platform-admin-key
# Production SMS verification uses aliyun-pnvs. Traditional aliyun/tencent adapters are compatibility paths only.
# Tenant-level SMS AccessKey/SecretKey live in app_private.tenant_secrets, not in this env file.
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_SMS_COOLDOWN_SECONDS=60
AUTH_SMS_TENANT_DAILY_LIMIT=20000
AUTH_SMS_PHONE_DAILY_LIMIT=10
AUTH_SMS_IP_HOURLY_LIMIT=120
AUTH_SMS_DEVICE_HOURLY_LIMIT=10
WECHAT_MINIAPP_APP_ID=replace-with-miniapp-app-id
WECHAT_MINIAPP_APP_SECRET=replace-with-miniapp-app-secret
@@ -43,14 +68,16 @@ ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key
ALIPAY_NOTIFY_URL=https://api.tjszsb.com/api/commerce/webhooks/alipay
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=replace-with-bucket
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=oss-cn-beijing
ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
ALIYUN_OSS_BUCKET=replace-with-bucket
ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id
ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret
ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan
ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000

View File

@@ -9,16 +9,42 @@ GIT_USERNAME=replace-with-readonly-deploy-user
GITEA_TOKEN=replace-with-rotated-readonly-token
APP_ROOT=/opt/tiku-saas
# The source checkout is isolated from the systemd runtime directory. This keeps
# candidate builds and npm ci from mutating the currently running release.
SOURCE_REPO_DIR=/opt/tiku-saas/source
REPO_DIR=/opt/tiku-saas/repo
RELEASES_DIR=/opt/tiku-saas/releases
WWW_ROOT=/srv/tiku-saas/www
WWW_RELEASES_DIR=/srv/tiku-saas/www-releases
RUNTIME_CONFIG_DIR=/etc/tiku-saas/runtime-config
RUN_SECURITY_CHECKS=true
RUN_RUNTIME_AUDIT=true
RUN_TARO_SUPPLY_CHAIN_AUDIT=true
RUN_LAUNCH_GATE=true
RESTART_SERVICES=true
PRODUCTION_LAUNCH_EVIDENCE=/etc/tiku-saas/production-launch-evidence.json
# Use https://registry.npmmirror.com on mainland China servers if npmjs times out.
# Database migrations are opt-in and run only after the environment/build gates.
# The deploy script always reruns DB readiness after this step and before launch:gate.
RUN_DB_MIGRATIONS=false
RUN_DB_READINESS=true
# Do not assign this in the file; inject DATABASE_MIGRATION_URL for the standard
# migration role from a secret manager immediately before deployment.
DB_MIGRATION_COMMAND='supabase db push --db-url "$DATABASE_MIGRATION_URL"'
API_ENV_FILE=/etc/tiku-saas/api.env
RESTART_SERVICES=true
SYSTEMD_UNITS="tiku-api.service tiku-workers.target"
HEALTHCHECK_URL=http://127.0.0.1:8787/health
HEALTHCHECK_TIMEOUT_SECONDS=60
HEALTHCHECK_INTERVAL_SECONDS=2
KEEP_RELEASES=5
# Package downloads may use a mirror when npmjs times out.
# The deploy script always installs workspace dev dependencies and runs lifecycle
# scripts because the locked Taro toolchain and reviewed Input patch require both.
NPM_REGISTRY=https://registry.npmjs.org/
# npm audit must use a registry with the audit API; npmmirror does not provide it.
NPM_AUDIT_REGISTRY=https://registry.npmjs.org/
NPM_FETCH_RETRIES=5
NPM_FETCH_RETRY_MINTIMEOUT=20000
NPM_FETCH_RETRY_MAXTIMEOUT=120000

View File

@@ -1,32 +1,124 @@
# Copy to /etc/tiku-saas/worker.env and chmod 600.
# This file is read by systemd. Do not commit the real file.
# This file is read by every worker systemd unit. Do not commit the real file.
NODE_ENV=production
DATABASE_URL=postgresql://tiku_app:replace-with-password@127.0.0.1:5432/postgres
SUPABASE_URL=https://supabase.tjszsb.com
DATABASE_URL=postgresql://tiku_worker:replace-with-password@127.0.0.1:5432/postgres
DB_EXPECTED_RUNTIME_ROLE=tiku_worker
# There are nine continuous worker processes; budget total PostgreSQL connections accordingly.
DB_POOL_MAX=5
DB_CONNECTION_TIMEOUT_MS=5000
DB_QUERY_TIMEOUT_MS=35000
DB_STATEMENT_TIMEOUT_MS=30000
DB_LOCK_TIMEOUT_MS=5000
DB_IDLE_IN_TRANSACTION_TIMEOUT_MS=30000
DB_IDLE_TIMEOUT_MS=30000
DB_POOL_MAX_USES=7500
DB_POOL_MAX_LIFETIME_SECONDS=1800
DB_APPLICATION_NAME=tiku-worker
# Object storage settings read by apps/worker/src/config.ts.
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=replace-with-bucket
STORAGE_PUBLIC_BASE_URL=https://replace-with-public-assets.example.com
STORAGE_MAX_UPLOAD_BYTES=524288000
STORAGE_ALLOWED_MIME_PREFIXES=image/,video/,audio/
STORAGE_ALLOWED_MIME_TYPES=application/pdf,application/json,application/zip,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/plain,text/markdown,text/csv
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=oss-cn-beijing
ALIYUN_OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com
ALIYUN_OSS_BUCKET=replace-with-bucket
ALIYUN_OSS_ACCESS_KEY_ID=replace-with-access-key-id
ALIYUN_OSS_ACCESS_KEY_SECRET=replace-with-access-key-secret
ASSET_SIGNING_SECRET=replace-with-strong-random-asset-secret
ALIYUN_OSS_STS_TOKEN=
ALIYUN_OSS_INTERNAL=false
# Production asset scans are fail-closed and use the WORKER_* names below.
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://replace-with-security-scanner.example.com/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=replace-with-strong-scanner-token
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
ASSET_SECURITY_SCAN_ENDPOINT=https://replace-with-security-scanner.example.com/scan
ASSET_SECURITY_SCAN_TOKEN=replace-with-scanner-token
WECHAT_PAY_MCH_ID=replace-with-merchant-id
WECHAT_PAY_APP_ID=replace-with-pay-app-id
WECHAT_PAY_API_V3_KEY=replace-with-api-v3-key
WECHAT_PAY_PRIVATE_KEY=replace-with-private-key-path-or-kms-id
# Continuous queue consumers managed by tiku-worker@.service.
WORKER_CRM_BATCH_SIZE=20
WORKER_CRM_POLL_INTERVAL_MS=10000
WORKER_CRM_MAX_ATTEMPTS=5
WORKER_CRM_BACKOFF_SECONDS=5,30,120,600,1800
WORKER_CRM_REQUEST_TIMEOUT_MS=10000
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
ALIPAY_APP_ID=replace-with-alipay-app-id
ALIPAY_APP_PRIVATE_KEY=replace-with-private-key-path-or-kms-id
ALIPAY_PUBLIC_KEY=replace-with-alipay-public-key
WORKER_COMMERCE_BATCH_SIZE=20
WORKER_COMMERCE_POLL_INTERVAL_MS=30000
WORKER_COMMERCE_MIN_AGE_SECONDS=300
WORKER_COMMERCE_REQUEST_TIMEOUT_MS=10000
CRM_WEBHOOK_TIMEOUT_MS=5000
WORKER_POLL_INTERVAL_MS=5000
WORKER_PROVIDER_BILL_BATCH_SIZE=5
WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000
WORKER_PROVIDER_BILL_ID=provider-bills-prod-1
WORKER_PROVIDER_BILL_CLAIM_STALE_SECONDS=900
WORKER_PLATFORM_DUNNING_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000
WORKER_PLATFORM_DUNNING_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_DUNNING_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_DUNNING_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE=50
WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000
WORKER_PLATFORM_AUDIT_NOTIFICATION_MAX_ATTEMPTS=5
WORKER_PLATFORM_AUDIT_NOTIFICATION_BACKOFF_SECONDS=10,60,300,900,1800
WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS=10000
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_ASSET_BATCH_SIZE=50
WORKER_ASSET_POLL_INTERVAL_MS=30000
WORKER_ASSET_MIN_AGE_SECONDS=300
WORKER_ASSET_RECHECK_INTERVAL_SECONDS=86400
WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
WORKER_IMPORT_BATCH_SIZE=5
WORKER_IMPORT_POLL_INTERVAL_MS=10000
WORKER_IMPORT_ID=imports-prod-1
WORKER_IMPORT_LEASE_SECONDS=120
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
WORKER_IMPORT_BACKOFF_SECONDS=30,120,600,1800
WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5
WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000
WORKER_PUBLIC_BANK_SYNC_COPY_LIMIT=1000
WORKER_PUBLIC_BANK_SYNC_ID=public-banks-prod-1
WORKER_PUBLIC_BANK_SYNC_CLAIM_STALE_SECONDS=900
WORKER_EXPORT_BATCH_SIZE=5
WORKER_EXPORT_POLL_INTERVAL_MS=10000
WORKER_EXPORT_ID=exports-prod-1
WORKER_EXPORT_BACKOFF_SECONDS=30,120,600,1800
EXPORT_LOCAL_STORAGE_ROOT=/srv/tiku-saas/data/exports
EXPORT_PDF_FONT_PATH=
# Periodic jobs managed by tiku-worker-job@.service and timer units.
WORKER_PLATFORM_BILLING_BATCH_SIZE=50
WORKER_PLATFORM_BILLING_DAYS_AHEAD=45
WORKER_PLATFORM_BILLING_DUE_DAYS=15
WORKER_PLATFORM_BILLING_ID=platform-billing-prod-1
# Leave month overrides empty for normal timers. Use CLI --month for backfills.
WORKER_PLATFORM_USAGE_BATCH_SIZE=100
WORKER_PLATFORM_USAGE_ID=platform-usage-prod-1
WORKER_PLATFORM_USAGE_MONTH=
WORKER_PLATFORM_USAGE_OVERAGE_BATCH_SIZE=100
WORKER_PLATFORM_USAGE_OVERAGE_ID=platform-usage-overage-prod-1
WORKER_PLATFORM_USAGE_OVERAGE_MONTH=
WORKER_PLATFORM_USAGE_OVERAGE_DUE_DAYS=15
WORKER_PLATFORM_DUNNING_BATCH_SIZE=100
WORKER_PLATFORM_DUNNING_ID=platform-dunning-prod-1
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE=200
WORKER_PLATFORM_AUDIT_ALERT_ID=platform-audit-alerts-prod-1
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS=14
WORKER_STUDENT_SUPERVISION_BATCH_SIZE=20
WORKER_STUDENT_SUPERVISION_ID=student-supervision-prod-1
WORKER_STUDENT_SUPERVISION_CLAIM_STALE_SECONDS=900

View File

@@ -6,6 +6,9 @@ map $http_upgrade $connection_upgrade {
'' close;
}
limit_req_zone $binary_remote_addr zone=tiku_auth:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=tiku_sms_send:10m rate=10r/s;
server {
listen 80;
server_name app.tjszsb.com;
@@ -90,12 +93,45 @@ server {
client_max_body_size 50m;
location = /api/auth/sms/send {
limit_req zone=tiku_sms_send burst=30 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Real-IP $remote_addr;
# Overwrite, rather than append to, any client-supplied forwarding chain.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location ^~ /api/auth/ {
limit_req zone=tiku_auth burst=100 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
# H5 tenant resolution trusts the browser Origin header. Do not accept a client-supplied forwarded host.
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

View File

@@ -3,5 +3,5 @@
"apiBaseUrl": "https://api.tjszsb.com",
"supabaseUrl": "https://supabase.tjszsb.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": "master"
"tenantCode": ""
}

View File

@@ -3,5 +3,5 @@
"apiBaseUrl": "https://api.tjszsb.com",
"supabaseUrl": "https://supabase.tjszsb.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": "master"
"tenantCode": ""
}

View File

@@ -3,5 +3,5 @@
"apiBaseUrl": "https://api.tjszsb.com",
"supabaseUrl": "https://supabase.tjszsb.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": "master"
"tenantCode": ""
}

View File

@@ -0,0 +1,188 @@
do $$
begin
if not exists (select 1 from pg_roles where rolname = 'tiku_api') then
create role tiku_api login;
end if;
if not exists (select 1 from pg_roles where rolname = 'tiku_worker') then
create role tiku_worker login;
end if;
end
$$;
alter role tiku_api
login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls;
alter role tiku_worker
login nosuperuser noinherit nocreatedb nocreaterole noreplication bypassrls;
create schema if not exists extensions;
revoke create on schema extensions from public, tiku_api, tiku_worker;
grant usage on schema extensions to tiku_api, tiku_worker;
-- Install or relocate required extensions before normal migrations. Otherwise
-- citext/ltree can be created later by supabase_admin with PostgreSQL's default
-- PUBLIC EXECUTE and the migration role cannot close that RPC surface.
do $$
declare
extension_name name;
extension_schema name;
extension_relocatable boolean;
begin
foreach extension_name in array array[
'pgcrypto'::name,
'citext'::name,
'ltree'::name,
'pg_trgm'::name
]
loop
select namespace.nspname, extension.extrelocatable
into extension_schema, extension_relocatable
from pg_extension extension
join pg_namespace namespace on namespace.oid = extension.extnamespace
where extension.extname = extension_name;
if not found then
execute format('create extension %I with schema extensions', extension_name);
elsif extension_schema <> 'extensions' then
if not extension_relocatable then
raise exception 'Required extension % cannot be relocated from schema %', extension_name, extension_schema;
end if;
execute format('alter extension %I set schema extensions', extension_name);
end if;
end loop;
end
$$;
alter role tiku_api set search_path = pg_catalog, public, extensions;
alter role tiku_worker set search_path = pg_catalog, public, extensions;
-- Official Supabase images can preinstall relocatable extensions such as
-- citext and ltree in public. Their functions are owned by supabase_admin, so
-- the normal migration role cannot remove PostgreSQL's default PUBLIC EXECUTE.
-- Seal that inherited RPC surface while a real superuser is available.
do $$
declare
required_role name;
begin
foreach required_role in array array['anon'::name, 'authenticated'::name]
loop
if not exists (select 1 from pg_roles where rolname = required_role) then
raise exception 'Required Supabase Data API role % is missing', required_role;
end if;
end loop;
end
$$;
revoke execute on all functions in schema public
from public, anon, authenticated, tiku_api, tiku_worker;
revoke execute on all functions in schema extensions
from public, anon, authenticated, tiku_api, tiku_worker;
-- Supabase's internal services use dedicated trusted database roles. Preserve
-- their extension execution when those roles exist, without reopening the
-- surface to the client-facing anon/authenticated roles.
do $$
declare
trusted_role name;
begin
foreach trusted_role in array array[
'postgres'::name,
'service_role'::name,
'dashboard_user'::name,
'supabase_auth_admin'::name,
'supabase_storage_admin'::name,
'supabase_realtime_admin'::name,
'supabase_functions_admin'::name
]
loop
if exists (select 1 from pg_roles where rolname = trusted_role) then
execute format('grant execute on all functions in schema extensions to %I', trusted_role);
end if;
end loop;
end
$$;
-- citext and ltree operators, casts and tree helpers call extension-owned
-- functions. pg_trgm index support is similarly needed by backend search.
-- Backend roles receive only those pure type/search helpers; pgcrypto stays
-- unavailable and Data API client roles receive no extension execution.
do $$
declare
extension_function record;
begin
for extension_function in
select procedure_row.oid::regprocedure::text as signature
from pg_proc procedure_row
join pg_depend dependency
on dependency.classid = 'pg_proc'::regclass
and dependency.objid = procedure_row.oid
and dependency.refclassid = 'pg_extension'::regclass
and dependency.deptype = 'e'
join pg_extension extension on extension.oid = dependency.refobjid
where extension.extname in ('citext', 'ltree', 'pg_trgm')
loop
execute format(
'revoke execute on function %s from public, anon, authenticated, tiku_api, tiku_worker',
extension_function.signature
);
execute format(
'grant execute on function %s to tiku_api, tiku_worker',
extension_function.signature
);
end loop;
end
$$;
-- Extension upgrades may add more public functions under the extension owner.
-- PostgreSQL's function default ACL is global, so this must not use IN SCHEMA.
do $$
declare
owner_name name;
begin
for owner_name in
select bootstrap_owner.owner_name
from (
select current_user::name as owner_name
union
select distinct owner_role.rolname
from pg_proc function_row
join pg_namespace namespace on namespace.oid = function_row.pronamespace
join pg_roles owner_role on owner_role.oid = function_row.proowner
where namespace.nspname in ('public', 'extensions')
) bootstrap_owner
loop
execute format(
'alter default privileges for role %I revoke execute on functions from public, anon, authenticated, tiku_api, tiku_worker',
owner_name
);
end loop;
end
$$;
-- Remove all inherited or SET ROLE paths. Runtime roles receive object ACLs
-- directly from the normal migration and never inherit another database role.
do $$
declare
runtime_role name;
parent_role name;
begin
foreach runtime_role in array array['tiku_api'::name, 'tiku_worker'::name]
loop
for parent_role in
select parent.rolname
from pg_auth_members membership
join pg_roles member on member.oid = membership.member
join pg_roles parent on parent.oid = membership.roleid
where member.rolname = runtime_role
loop
execute format('revoke %I from %I', parent_role, runtime_role);
end loop;
end loop;
end
$$;
comment on role tiku_api is
'Trusted API-only runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.';
comment on role tiku_worker is
'Trusted background-worker runtime. Cluster attributes are provisioned by the database administrator; object ACLs are managed by migration 202607120013.';

View File

@@ -13,7 +13,7 @@ ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/api/dist/apps/api/src/server.js
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
TimeoutStopSec=40
NoNewPrivileges=true
PrivateTmp=true

View File

@@ -0,0 +1,22 @@
[Unit]
Description=tiku-supabase periodic worker job (%i)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=deploy
Group=deploy
WorkingDirectory=/opt/tiku-saas/repo
EnvironmentFile=/etc/tiku-saas/worker.env
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job %i
TimeoutStartSec=1h
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Collect and invoice previous-month SaaS usage
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
User=deploy
Group=deploy
WorkingDirectory=/opt/tiku-saas/repo
EnvironmentFile=/etc/tiku-saas/worker.env
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage --month previous
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --once --job platform-usage-overage --month previous
TimeoutStartSec=3h
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/srv/tiku-saas /opt/tiku-saas/repo
CapabilityBoundingSet=
AmbientCapabilities=
LockPersonality=true

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Collect and invoice previous-month SaaS usage monthly
[Timer]
OnCalendar=*-*-01 04:00:00 Asia/Shanghai
Persistent=true
AccuracySec=1m
RandomizedDelaySec=5m
Unit=tiku-worker-monthly-usage.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Convert high-risk platform audit events into alerts
[Timer]
OnBootSec=2m
OnUnitInactiveSec=5m
AccuracySec=30s
RandomizedDelaySec=30s
Unit=tiku-worker-job@platform-audit-alerts.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Generate upcoming SaaS subscription invoices daily
[Timer]
OnCalendar=*-*-* 01:30:00 Asia/Shanghai
Persistent=true
RandomizedDelaySec=15m
Unit=tiku-worker-job@platform-billing.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Process overdue SaaS invoices daily
[Timer]
OnCalendar=*-*-* 03:00:00 Asia/Shanghai
Persistent=true
RandomizedDelaySec=15m
Unit=tiku-worker-job@platform-dunning.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Collect current-month SaaS usage snapshots
[Timer]
OnCalendar=*-*-* 02:00:00 Asia/Shanghai
Persistent=true
RandomizedDelaySec=15m
Unit=tiku-worker-job@platform-usage.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Generate due student supervision follow-ups
[Timer]
OnBootSec=5m
OnUnitInactiveSec=15m
AccuracySec=1m
RandomizedDelaySec=1m
Unit=tiku-worker-job@student-supervision.service
[Install]
WantedBy=tiku-workers.target

View File

@@ -1,7 +1,8 @@
[Unit]
Description=tiku-supabase worker
After=network-online.target
Description=tiku-supabase continuous worker (%i)
After=network-online.target postgresql.service
Wants=network-online.target
PartOf=tiku-workers.target
[Service]
Type=simple
@@ -9,7 +10,7 @@ User=deploy
Group=deploy
WorkingDirectory=/opt/tiku-saas/repo
EnvironmentFile=/etc/tiku-saas/worker.env
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop
ExecStart=/usr/bin/node /opt/tiku-saas/repo/apps/worker/dist/apps/worker/src/index.js --loop --job %i
Restart=always
RestartSec=5
KillSignal=SIGTERM
@@ -25,4 +26,4 @@ AmbientCapabilities=
LockPersonality=true
[Install]
WantedBy=multi-user.target
WantedBy=tiku-workers.target

View File

@@ -0,0 +1,20 @@
[Unit]
Description=tiku-supabase production worker scheduler
Requires=tiku-worker@crm.service
Requires=tiku-worker@commerce.service
Requires=tiku-worker@provider-bills.service
Requires=tiku-worker@platform-dunning-notifications.service
Requires=tiku-worker@platform-audit-notifications.service
Requires=tiku-worker@assets.service
Requires=tiku-worker@imports.service
Requires=tiku-worker@public-banks.service
Requires=tiku-worker@exports.service
Wants=tiku-worker-platform-billing.timer
Wants=tiku-worker-platform-usage.timer
Wants=tiku-worker-platform-dunning.timer
Wants=tiku-worker-platform-audit-alerts.timer
Wants=tiku-worker-student-supervision.timer
Wants=tiku-worker-monthly-usage.timer
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
DESTRUCTIVE_TEST_CONFIRMATION,
assertDestructiveTestDatabase,
describeDatabaseTarget,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const LOCAL_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const CONFIRMATION = DESTRUCTIVE_TEST_CONFIRMATION;
function clientWithMarker(environment, allowDestructiveTests) {
const queries = [];
return {
queries,
client: {
async query(sql) {
queries.push(String(sql).trim().replace(/\s+/g, ' '));
return { rows: [{ environment, allowDestructiveTests }], rowCount: 1 };
},
},
};
}
async function rejects(options, pattern) {
await assert.rejects(() => assertDestructiveTestDatabase(options), pattern);
}
{
const mock = clientWithMarker('local', true);
const result = await assertDestructiveTestDatabase({
client: mock.client,
databaseUrl: LOCAL_URL,
confirmation: CONFIRMATION,
operation: 'smoke seed',
});
assert.equal(result.environment, 'local');
assert.equal(mock.queries.length, 1);
assert.match(mock.queries[0], /^select environment,/i);
}
{
const mock = clientWithMarker('ci', true);
const result = await assertDestructiveTestDatabase({
client: mock.client,
databaseUrl: 'postgresql://ci_runner:secret@postgres-ci.internal:6432/tiku_ci',
confirmation: CONFIRMATION,
});
assert.equal(result.environment, 'ci');
}
for (const runtimeUser of ['tiku_api', 'tiku_worker']) {
const mock = clientWithMarker('local', true);
await rejects(
{
client: mock.client,
databaseUrl: `postgresql://${runtimeUser}:prod-secret@127.0.0.1:5432/postgres`,
confirmation: CONFIRMATION,
},
/reserved for production runtime/,
);
assert.equal(mock.queries.length, 0, 'known production targets must be rejected before SQL');
}
{
const missingMarkerClient = { query: async () => ({ rows: [], rowCount: 0 }) };
await rejects(
{
client: missingMarkerClient,
databaseUrl: 'postgresql://postgres:secret@127.0.0.1:15432/postgres',
confirmation: CONFIRMATION,
},
/marker row is missing/,
);
}
for (const environment of ['production', 'staging']) {
const mock = clientWithMarker(environment, true);
await rejects(
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION },
new RegExp(`environment ${environment} is not approved`),
);
}
{
const mock = clientWithMarker('local', false);
await rejects(
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation: CONFIRMATION },
/does not allow destructive tests/,
);
}
for (const confirmation of ['', 'wrong-confirmation']) {
const mock = clientWithMarker('local', true);
await rejects(
{ client: mock.client, databaseUrl: LOCAL_URL, confirmation },
/explicit confirmation/,
);
assert.equal(mock.queries.length, 0, 'confirmation must be checked before SQL');
}
{
const queryErrorClient = { query: async () => { throw new Error('password=do-not-print'); } };
let error;
try {
await assertDestructiveTestDatabase({
client: queryErrorClient,
databaseUrl: LOCAL_URL,
confirmation: CONFIRMATION,
});
} catch (caught) {
error = caught;
}
assert.match(error?.message || '', /marker is unavailable or unreadable/);
assert.equal(error.message.includes('do-not-print'), false);
}
for (const malformed of ['', 'not-a-url', 'https://example.com/database', 'postgresql://localhost']) {
await rejects(
{ client: clientWithMarker('local', true).client, databaseUrl: malformed, confirmation: CONFIRMATION },
/Refusing destructive database test/,
);
}
{
const target = describeDatabaseTarget('postgresql://user:super-secret@db.example.test:5439/tiku_test');
assert.deepEqual(target, {
host: 'db.example.test',
port: '5439',
database: 'tiku_test',
user: 'user',
});
assert.equal(JSON.stringify(target).includes('super-secret'), false);
assert.equal(resolveDestructiveTestConfirmation({}, ['--confirm', CONFIRMATION]), CONFIRMATION);
assert.equal(resolveDestructiveTestConfirmation({ SMOKE_SEED_CONFIRM: CONFIRMATION }, []), CONFIRMATION);
}
const repoRoot = process.cwd();
const smokeSeed = fs.readFileSync(path.join(repoRoot, 'scripts', 'smoke-seed.js'), 'utf8');
const apiIntegration = fs.readFileSync(path.join(repoRoot, 'scripts', 'api-integration-test.js'), 'utf8');
const rlsTest = fs.readFileSync(path.join(repoRoot, 'scripts', 'rls-tenant-isolation-test.js'), 'utf8');
const autoBadgeConcurrency = fs.readFileSync(
path.join(repoRoot, 'scripts', 'auto-badge-concurrency-test.js'),
'utf8',
);
const migration = fs.readFileSync(
path.join(repoRoot, 'supabase', 'migrations', '202607120001_destructive_test_environment_safety.sql'),
'utf8',
);
const seed = fs.readFileSync(path.join(repoRoot, 'supabase', 'seed.sql'), 'utf8');
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
for (const [name, source] of [
['smoke seed', smokeSeed],
['API integration test', apiIntegration],
['RLS isolation test', rlsTest],
['auto badge concurrency test', autoBadgeConcurrency],
]) {
assert.match(source, /assertDestructiveTestDatabase\s*\(/, `${name} must invoke the shared database guard`);
}
const guardCall = smokeSeed.indexOf('assertDestructiveTestDatabase(');
const firstBegin = smokeSeed.search(/client\.query\(['"]begin['"]\)/i);
const firstWrite = smokeSeed.search(/\b(?:update|delete from|insert into)\s+public\./i);
assert.ok(guardCall >= 0, 'smoke seed must call the shared guard');
assert.ok(firstBegin > guardCall, 'smoke seed guard must run before BEGIN');
assert.ok(firstWrite > guardCall, 'smoke seed guard must run before persistent SQL writes');
assert.match(migration, /create table if not exists app_private\.environment_safety/i);
assert.match(migration, /environment in \('local', 'test', 'ci', 'staging', 'production'\)/i);
assert.match(migration, /allow_destructive_tests boolean not null default false/i);
assert.doesNotMatch(
migration,
/insert into app_private\.environment_safety/i,
'migrations must not automatically authorize destructive tests',
);
assert.match(
seed,
/insert into app_private\.environment_safety[\s\S]*values \(true, 'local', true\)/i,
'local Supabase seed must provision the local-only marker',
);
assert.equal(packageJson.scripts?.['test:api:remote'], undefined, 'full API integration must not expose a remote mode');
for (const scriptName of ['db:smoke-seed:test', 'test:api', 'test:rls']) {
assert.match(
packageJson.scripts?.[scriptName] || '',
new RegExp(DESTRUCTIVE_TEST_CONFIRMATION),
`${scriptName} must carry the exact destructive-test confirmation`,
);
}
assert.match(
packageJson.scripts?.['test:readiness'] || '',
/auto-badge-concurrency-test\.js --confirm=SMOKE_SEED_LOCAL_OR_CI_ONLY/,
'readiness must explicitly confirm its destructive concurrency test',
);
assert.ok(
packageJson.scripts?.['test:readiness']?.includes('destructive-test-database-guard-test.js'),
'readiness contracts must include the destructive database guard test',
);
console.log('[PASS] destructive test database fail-closed guard');

View File

@@ -6,6 +6,7 @@ import {
} from './diagnose-aliyun-pnvs-provider.js';
const tenantId = '00000000-0000-0000-0000-000000000001';
const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
function queryFixture({ providerRows = [], secretRows = [] }) {
return async (sql, params) => {
@@ -57,7 +58,7 @@ const goodSecretRows = [
];
const ok = await diagnoseAliyunPnvsProvider(
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' },
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' },
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) },
);
@@ -71,14 +72,14 @@ assert.ok(!JSON.stringify(ok).includes(goodSecretRows[0].accessKeyId), 'diagnost
assert.match(ok.secret.accessKeyIdMasked, /^LTAI\.\.\./);
const missingSecret = await diagnoseAliyunPnvsProvider(
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun-pnvs' },
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun-pnvs' },
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: [] }) },
);
assert.equal(missingSecret.ok, false);
assert.ok(missingSecret.checks.some(item => item.id === 'db.tenant_secret' && item.status === 'blocker'));
const wrongEnvProvider = await diagnoseAliyunPnvsProvider(
{ databaseUrl: 'postgresql://example', tenantId, authSmsProvider: 'aliyun' },
{ databaseUrl: localDatabaseUrl, tenantId, authSmsProvider: 'aliyun' },
{ query: queryFixture({ providerRows: goodProviderRows, secretRows: goodSecretRows }) },
);
assert.equal(wrongEnvProvider.ok, false);

View File

@@ -6,6 +6,7 @@ import {
} from './disable-legacy-sms-providers.js';
const tenantId = '00000000-0000-0000-0000-000000000001';
const localDatabaseUrl = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const rows = [
{ id: 'legacy-aliyun', tenantId, provider: 'aliyun', status: 'active', displayName: '旧阿里云短信' },
{ id: 'legacy-tencent', tenantId, provider: 'tencent-sms', status: 'testing', displayName: '旧腾讯云短信' },
@@ -29,14 +30,14 @@ function queryFixture() {
}
const cfg = buildConfig(
{ DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId },
{ DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId },
[],
);
assert.equal(cfg.apply, false);
assert.equal(cfg.tenantId, tenantId);
const applyCfg = buildConfig(
{ DATABASE_URL: 'postgresql://example', PNVS_TENANT_ID: tenantId },
{ DATABASE_URL: localDatabaseUrl, PNVS_TENANT_ID: tenantId },
['--apply'],
);
assert.equal(applyCfg.apply, true);
@@ -45,7 +46,7 @@ const found = await findLegacySmsProviderRows(queryFixture(), tenantId);
assert.deepEqual(found.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']);
const dryRun = await disableLegacySmsProviders(
{ databaseUrl: 'postgresql://example', tenantId, apply: false },
{ databaseUrl: localDatabaseUrl, tenantId, apply: false },
{ query: queryFixture() },
);
assert.equal(dryRun.dryRun, true);
@@ -53,7 +54,7 @@ assert.equal(dryRun.changed, 0);
assert.deepEqual(dryRun.rows.map(row => row.id), ['legacy-aliyun', 'legacy-tencent']);
const applyRun = await disableLegacySmsProviders(
{ databaseUrl: 'postgresql://example', tenantId, apply: true },
{ databaseUrl: localDatabaseUrl, tenantId, apply: true },
{ query: queryFixture() },
);
assert.equal(applyRun.dryRun, false);

View File

@@ -64,6 +64,12 @@ def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
def write_json_file(file_path: pathlib.Path, payload: Any) -> None:
with file_path.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Export PocketBase SQLite collections to JSON.")
parser.add_argument("--data-db", required=True, help="Path to PocketBase data.db")
@@ -371,19 +377,11 @@ def main() -> int:
"generatedAt": utc_now_iso(),
"collections": collections,
}
(export_dir / "pb_schema.sqlite.json").write_text(
json.dumps(schema_payload, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
write_json_file(export_dir / "pb_schema.sqlite.json", schema_payload)
storage_manifest = build_storage_manifest(storage_dir, collections) if storage_dir else None
if storage_manifest:
(export_dir / "storage-manifest.json").write_text(
json.dumps(storage_manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
write_json_file(export_dir / "storage-manifest.json", storage_manifest)
aux = auxiliary_summary(aux_db, args.include_aux_logs) if aux_db else None
@@ -416,11 +414,7 @@ def main() -> int:
"auxiliary": aux,
}
(export_dir / "sqlite-export-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
newline="\n",
)
write_json_file(export_dir / "sqlite-export-manifest.json", manifest)
print(json.dumps(manifest, ensure_ascii=False))
return 0

View File

@@ -1,64 +1,70 @@
import assert from 'node:assert/strict';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pg from 'pg';
import { spawn } from 'node:child_process';
import { setTimeout as delay } from 'node:timers/promises';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const tenantId = '00000000-0000-0000-0000-000000000001';
const adminUserId = '00000000-0000-0000-0000-000000000102';
const confirmation = resolveDestructiveTestConfirmation();
const ids = {
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
contentEntry: '00000000-0000-0000-0000-000000000611',
contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614',
questionCollection: '00000000-0000-0000-0000-000000000615',
};
function runWorkerOnce() {
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'imports'], {
cwd: process.cwd(),
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_IMPORT_BATCH_SIZE: '5',
WORKER_IMPORT_ID: 'imports-integration-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let output = '';
child.stdout.on('data', chunk => {
output += chunk.toString();
});
child.stderr.on('data', chunk => {
output += chunk.toString();
});
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', code => {
try {
assert.equal(code, 0, `worker should exit 0\n${output}`);
assert.match(output, /imports batch processed=\d+/, 'worker output should include imports summary');
resolve(output);
} catch (error) {
reject(error);
}
});
});
process.env.DATABASE_URL = databaseUrl;
process.env.WORKER_IMPORT_BATCH_SIZE = '5';
process.env.WORKER_IMPORT_ID = 'imports-integration-default';
process.env.WORKER_IMPORT_LEASE_SECONDS = '120';
process.env.WORKER_IMPORT_HEARTBEAT_INTERVAL_MS = '30000';
const workerModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/worker/src/jobs/imports.js', import.meta.url))).href;
const apiModuleUrl = pathToFileURL(fileURLToPath(new URL('../apps/worker/dist/apps/api/src/features/tenant-content/imports.js', import.meta.url))).href;
const worker = await import(workerModuleUrl);
const { executeContentImportJob } = await import(apiModuleUrl);
function auth() {
return {
tenantId,
userId: adminUserId,
role: 'system_worker',
permissions: { 'content:*': true },
templatePermissions: {},
};
}
async function cleanup(pool) {
await pool.query(
`
update public.content_import_jobs
set status = 'pending',
locked_at = null,
locked_by = null,
lease_token = null,
lease_expires_at = null,
last_heartbeat_at = null,
next_attempt_at = now(),
updated_at = now()
where tenant_id = $1
and source_name like 'worker-lease-%'
and status = 'importing'
`,
[tenantId],
);
await pool.query(
`
delete from public.question_collection_items
where tenant_id = $1
and question_id in (
select id from public.questions
where tenant_id = $1
and (
legacy_id like 'worker-import-question-%'
or legacy_id like 'integration-import-async-choice-%'
)
where tenant_id = $1 and legacy_id like 'worker-lease-question-%'
)
`,
[tenantId],
@@ -69,24 +75,13 @@ async function cleanup(pool) {
where tenant_id = $1
and question_id in (
select id from public.questions
where tenant_id = $1
and (
legacy_id like 'worker-import-question-%'
or legacy_id like 'integration-import-async-choice-%'
)
where tenant_id = $1 and legacy_id like 'worker-lease-question-%'
)
`,
[tenantId],
);
await pool.query(
`
delete from public.questions
where tenant_id = $1
and (
legacy_id like 'worker-import-question-%'
or legacy_id like 'integration-import-async-choice-%'
)
`,
`delete from public.questions where tenant_id = $1 and legacy_id like 'worker-lease-question-%'`,
[tenantId],
);
await pool.query(
@@ -94,24 +89,21 @@ async function cleanup(pool) {
delete from public.audit_logs
where tenant_id = $1
and target_type = 'content_import_job'
and details::text like '%worker-import%'
`,
[tenantId],
);
await pool.query(
`
delete from public.content_import_jobs
where tenant_id = $1
and (
source_name like 'worker-import-%'
or source_name = 'async-question-import.json'
and target_id in (
select id::text from public.content_import_jobs
where tenant_id = $1 and source_name like 'worker-lease-%'
)
`,
[tenantId],
);
await pool.query(
`delete from public.content_import_jobs where tenant_id = $1 and source_name like 'worker-lease-%'`,
[tenantId],
);
}
async function createQueuedQuestionImport(pool) {
async function createQueuedQuestionImport(pool, suffix, maxAttempts = 3) {
const legacyId = `worker-lease-question-${suffix}`;
const preview = await pool.query(
`
insert into public.content_import_jobs (
@@ -120,20 +112,21 @@ async function createQueuedQuestionImport(pool) {
target_category_id, target_content_node_id, target_collection_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload, execution_mode, queued_at,
next_attempt_at, parser_metadata
next_attempt_at, max_attempts, parser_metadata
)
values (
$1, $2, 'questions', 'json', 'pending',
'worker-import-questions.json', 'worker-import-source-hash',
$3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid,
$3, $4, $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9::uuid,
false, 1, 1, 0, 0,
$8::jsonb, $9::jsonb, $10::jsonb, 'async', now(), now(), '{}'::jsonb
$10::jsonb, $11::jsonb, $12::jsonb, 'async', now(), now(), $13, '{}'::jsonb
)
returning id
`,
[
tenantId,
adminUserId,
`worker-lease-${suffix}.json`,
`worker-lease-source-${suffix}`,
ids.region,
ids.subject,
ids.category,
@@ -148,41 +141,29 @@ async function createQueuedQuestionImport(pool) {
collectionId: ids.questionCollection,
},
importOptions: { allowPartial: false },
source: 'worker-import-integration',
source: 'worker-import-lease-integration',
}),
JSON.stringify([
{
legacyId: 'worker-import-question-001',
type: 'choice',
content: '异步导入题worker 应该复用哪套导入规则?',
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
correctOptionIndices: [1],
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
difficulty: 2,
tags: ['worker-import'],
},
]),
JSON.stringify([
{
legacyId: 'worker-import-question-001',
type: 'choice',
typeLabel: null,
content: '异步导入题worker 应该复用哪套导入规则?',
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: null,
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
difficulty: 2,
tags: ['worker-import'],
mediaUrl: null,
subQuestions: [],
codeLang: null,
codeTemplate: null,
examMarkers: {},
sourceHash: 'worker-import-question-hash-001',
},
]),
JSON.stringify([{ legacyId, type: 'choice', content: `lease test ${suffix}` }]),
JSON.stringify([{
legacyId,
type: 'choice',
typeLabel: null,
content: `lease test ${suffix}`,
options: ['A', 'B'],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: null,
explanation: 'persistent lease integration fixture',
difficulty: 2,
tags: ['worker-lease'],
mediaUrl: null,
subQuestions: [],
codeLang: null,
codeTemplate: null,
examMarkers: {},
sourceHash: `worker-lease-hash-${suffix}`,
}]),
maxAttempts,
],
);
const jobId = preview.rows[0].id;
@@ -192,108 +173,236 @@ async function createQueuedQuestionImport(pool) {
tenant_id, job_id, row_no, external_id, status, target_type,
source_payload, normalized_payload, content_hash, issues_count
)
values ($1, $2, 1, 'worker-import-question-001', 'valid', 'question', $3::jsonb, $4::jsonb, 'worker-import-question-hash-001', 0)
values ($1, $2, 1, $3, 'valid', 'question', $4::jsonb, $5::jsonb, $6, 0)
`,
[
tenantId,
jobId,
legacyId,
JSON.stringify({ legacyId, content: `lease test ${suffix}` }),
JSON.stringify({
legacyId: 'worker-import-question-001',
content: '异步导入题worker 应该复用哪套导入规则?',
}),
JSON.stringify({
legacyId: 'worker-import-question-001',
legacyId,
type: 'choice',
typeLabel: null,
content: '异步导入题worker 应该复用哪套导入规则?',
options: ['自己重写', '复用后端导入 executor', '前端直写数据库', '跳过校验'],
content: `lease test ${suffix}`,
options: ['A', 'B'],
correctOptionIndex: 1,
correctOptionIndices: [1],
answerText: null,
explanation: 'worker 和 API 必须复用同一套后端导入规则。',
explanation: 'persistent lease integration fixture',
difficulty: 2,
tags: ['worker-import'],
tags: ['worker-lease'],
mediaUrl: null,
subQuestions: [],
codeLang: null,
codeTemplate: null,
examMarkers: {},
sourceHash: 'worker-import-question-hash-001',
sourceHash: `worker-lease-hash-${suffix}`,
}),
`worker-lease-hash-${suffix}`,
],
);
return jobId;
return { jobId, legacyId };
}
async function readJob(pool, jobId) {
const result = await pool.query(
`
select status, attempt_count as "attemptCount", locked_by as "lockedBy",
lease_token as "leaseToken", lease_expires_at as "leaseExpiresAt",
last_heartbeat_at as "lastHeartbeatAt", next_attempt_at as "nextAttemptAt",
inserted_count as "insertedCount", error_message as "errorMessage"
from public.content_import_jobs
where tenant_id = $1 and id = $2
`,
[tenantId, jobId],
);
return result.rows[0];
}
async function testAtomicClaim(pool) {
const jobs = await Promise.all([
createQueuedQuestionImport(pool, 'atomic-a'),
createQueuedQuestionImport(pool, 'atomic-b'),
createQueuedQuestionImport(pool, 'atomic-c'),
]);
const [left, right] = await Promise.all([
worker.claimImportJobs({ workerId: 'lease-worker-a', batchSize: 2, leaseSeconds: 30 }),
worker.claimImportJobs({ workerId: 'lease-worker-b', batchSize: 2, leaseSeconds: 30 }),
]);
const claimed = [...left, ...right];
assert.equal(claimed.length, 3, 'concurrent workers should claim all three jobs');
assert.equal(new Set(claimed.map(job => job.id)).size, 3, 'SKIP LOCKED claim must not duplicate a job');
assert.deepEqual(
new Set(claimed.map(job => job.id)),
new Set(jobs.map(job => job.jobId)),
'claims must stay within the ready fixture set',
);
for (const job of claimed) {
assert.equal(job.attemptCount, 1, 'claim should atomically increment attempt_count once');
assert.ok(job.leaseToken, 'claim should persist a fencing token');
}
}
async function testHeartbeat(pool) {
const fixture = await createQueuedQuestionImport(pool, 'heartbeat');
const [claimed] = await worker.claimImportJobs({
workerId: 'lease-heartbeat-worker',
batchSize: 1,
leaseSeconds: 3,
});
assert.equal(claimed.id, fixture.jobId);
const before = await readJob(pool, fixture.jobId);
const heartbeat = worker.startImportLeaseHeartbeat(claimed, {
leaseSeconds: 3,
heartbeatIntervalMs: 250,
});
await delay(700);
await heartbeat.stop();
const after = await readJob(pool, fixture.jobId);
assert.ok(after.lastHeartbeatAt > before.lastHeartbeatAt, 'heartbeat should advance last_heartbeat_at');
assert.ok(after.leaseExpiresAt > before.leaseExpiresAt, 'heartbeat should extend lease expiry');
}
async function testExpiredTakeoverAndFencing(pool) {
const fixture = await createQueuedQuestionImport(pool, 'takeover');
const [first] = await worker.claimImportJobs({ workerId: 'lease-old-worker', batchSize: 1, leaseSeconds: 30 });
await pool.query(
`
update public.content_import_jobs
set locked_at = now() - interval '10 seconds',
lease_expires_at = now() - interval '1 second',
last_heartbeat_at = now() - interval '10 seconds'
where tenant_id = $1 and id = $2
`,
[tenantId, fixture.jobId],
);
const [second] = await worker.claimImportJobs({ workerId: 'lease-new-worker', batchSize: 1, leaseSeconds: 30 });
assert.equal(second.id, fixture.jobId, 'expired importing job should be reclaimed');
assert.notEqual(second.leaseToken, first.leaseToken, 'takeover must rotate fencing token');
assert.equal(second.attemptCount, 2, 'takeover should consume exactly one additional attempt');
await assert.rejects(
executeContentImportJob(auth(), {
jobId: fixture.jobId,
importType: 'questions',
allowPartial: false,
allowQueuedJob: true,
leaseToken: first.leaseToken,
}),
error => error?.code === 'IMPORT_WORKER_LEASE_LOST',
'old worker must be fenced before it can write imported content',
);
assert.equal(
(await pool.query(`select count(*)::int as count from public.questions where tenant_id = $1 and legacy_id = $2`, [tenantId, fixture.legacyId])).rows[0].count,
0,
'fenced old worker must leave no business writes',
);
const staleFailure = await worker.markImportFailed(first, new Error('stale worker failure'));
assert.equal(staleFailure, 'lease_lost', 'old worker must not schedule retry or failure after takeover');
const afterStaleFailure = await readJob(pool, fixture.jobId);
assert.equal(afterStaleFailure.leaseToken, second.leaseToken, 'stale failure must not overwrite current lease');
const execution = await executeContentImportJob(auth(), {
jobId: fixture.jobId,
importType: 'questions',
allowPartial: false,
allowQueuedJob: true,
leaseToken: second.leaseToken,
});
assert.equal(execution.status, 'completed', 'current lease owner should complete import');
const completed = await readJob(pool, fixture.jobId);
assert.equal(completed.status, 'completed');
assert.equal(completed.attemptCount, 2, 'successful takeover must preserve exact attempt count');
assert.equal(completed.leaseToken, null, 'terminal transition should release fencing token');
assert.equal(Number(completed.insertedCount), 1);
await assert.rejects(
executeContentImportJob(auth(), {
jobId: fixture.jobId,
importType: 'questions',
allowPartial: false,
allowQueuedJob: true,
leaseToken: first.leaseToken,
}),
error => error?.code === 'IMPORT_WORKER_LEASE_LOST',
'old worker must not turn a completed takeover into an idempotent success',
);
}
async function testRetryState(pool) {
const fixture = await createQueuedQuestionImport(pool, 'retry');
const [first] = await worker.claimImportJobs({ workerId: 'lease-retry-worker', batchSize: 1, leaseSeconds: 30 });
const state = await worker.markImportFailed(first, Object.assign(new Error('retry fixture'), { code: 'RETRY_FIXTURE' }));
assert.equal(state, 'retrying');
const pending = await readJob(pool, fixture.jobId);
assert.equal(pending.status, 'pending');
assert.equal(pending.attemptCount, 1, 'retry scheduling must not increment attempt count');
assert.equal(pending.leaseToken, null, 'retry scheduling must release lease');
assert.ok(pending.nextAttemptAt, 'retry scheduling should persist next_attempt_at');
await pool.query(
`update public.content_import_jobs set next_attempt_at = now() - interval '1 second' where tenant_id = $1 and id = $2`,
[tenantId, fixture.jobId],
);
const [second] = await worker.claimImportJobs({ workerId: 'lease-retry-worker-2', batchSize: 1, leaseSeconds: 30 });
assert.equal(second.id, fixture.jobId);
assert.equal(second.attemptCount, 2, 'retry claim should increment attempt exactly once');
assert.notEqual(second.leaseToken, first.leaseToken, 'each attempt must receive a new fencing token');
}
async function testExpiredFinalAttempt(pool) {
const fixture = await createQueuedQuestionImport(pool, 'exhausted', 1);
await worker.claimImportJobs({ workerId: 'lease-crashed-final-worker', batchSize: 1, leaseSeconds: 30 });
await pool.query(
`
update public.content_import_jobs
set locked_at = now() - interval '10 seconds',
lease_expires_at = now() - interval '1 second',
last_heartbeat_at = now() - interval '10 seconds'
where tenant_id = $1 and id = $2
`,
[tenantId, fixture.jobId],
);
const claimed = await worker.claimImportJobs({ workerId: 'lease-reaper-worker', batchSize: 1, leaseSeconds: 30 });
assert.equal(claimed.length, 0, 'expired final attempt must not be executed again');
const failed = await readJob(pool, fixture.jobId);
assert.equal(failed.status, 'failed', 'expired final attempt should become terminal failed');
assert.equal(failed.attemptCount, 1, 'reaping final attempt must not inflate attempts');
assert.equal(failed.leaseToken, null, 'terminal reaper should release lease');
assert.match(failed.errorMessage, /lease expired/i);
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl });
const pool = new pg.Pool({ connectionString: databaseUrl, max: 12 });
try {
await assertDestructiveTestDatabase({
client: pool,
databaseUrl,
confirmation,
operation: 'import worker lease integration test',
});
await cleanup(pool);
const jobId = await createQueuedQuestionImport(pool);
const output = await runWorkerOnce();
assert.match(output, /completed=1/, 'worker should complete exactly the queued import job after cleanup');
const job = await pool.query(
`
select status, execution_mode, inserted_count, updated_count, skipped_count,
locked_at, locked_by, attempt_count, error_message
from public.content_import_jobs
where tenant_id = $1 and id = $2
`,
[tenantId, jobId],
);
assert.equal(job.rows[0]?.status, 'completed', 'queued import job should be completed');
assert.equal(job.rows[0]?.execution_mode, 'async', 'job should keep async execution mode');
assert.equal(Number(job.rows[0]?.inserted_count), 1, 'worker should insert one question');
assert.equal(job.rows[0]?.locked_at, null, 'completed job should release lock');
assert.equal(job.rows[0]?.locked_by, null, 'completed job should clear lock owner');
assert.equal(Number(job.rows[0]?.attempt_count), 1, 'worker should record one attempt');
assert.equal(job.rows[0]?.error_message, null, 'completed job should not retain error message');
const question = await pool.query(
`
select q.id, v.content
from public.questions q
join public.question_versions v on v.id = q.current_version_id
where q.tenant_id = $1 and q.legacy_id = 'worker-import-question-001'
limit 1
`,
[tenantId],
);
assert.equal(question.rows[0]?.content, '异步导入题worker 应该复用哪套导入规则?', 'worker should import question content');
const collectionItem = await pool.query(
`
select 1
from public.question_collection_items
where tenant_id = $1 and collection_id = $2 and question_id = $3
limit 1
`,
[tenantId, ids.questionCollection, question.rows[0]?.id],
);
assert.equal(collectionItem.rowCount, 1, 'worker should bind imported question to collection');
const audit = await pool.query(
`
select action
from public.audit_logs
where tenant_id = $1 and target_type = 'content_import_job' and target_id = $2
order by created_at desc
limit 1
`,
[tenantId, jobId],
);
assert.equal(audit.rows[0]?.action, 'content.import.questions.completed', 'worker import should write completion audit');
console.log('Import worker integration test complete.');
await testAtomicClaim(pool);
await cleanup(pool);
await testHeartbeat(pool);
await cleanup(pool);
await testExpiredTakeoverAndFencing(pool);
await cleanup(pool);
await testRetryState(pool);
await cleanup(pool);
await testExpiredFinalAttempt(pool);
console.log('Import worker lease integration test complete.');
} finally {
await cleanup(pool).catch(() => {});
await cleanup(pool).catch(() => undefined);
await worker.closeImportExecutorPool().catch(() => undefined);
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exit(1);
process.exitCode = 1;
});

View File

@@ -0,0 +1,148 @@
export const DESTRUCTIVE_TEST_CONFIRMATION = 'SMOKE_SEED_LOCAL_OR_CI_ONLY';
const ALLOWED_ENVIRONMENTS = new Set(['local', 'test', 'ci']);
const KNOWN_PRODUCTION_DATABASE_USERS = new Set(['tiku_api', 'tiku_worker']);
function argumentValue(argv, name) {
const directIndex = argv.indexOf(name);
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
const prefix = `${name}=`;
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
}
export function resolveDestructiveTestConfirmation(
env = process.env,
argv = process.argv.slice(2),
) {
return argumentValue(argv, '--confirm') || String(env.SMOKE_SEED_CONFIRM || '').trim();
}
export function describeDatabaseTarget(databaseUrl) {
if (!databaseUrl || typeof databaseUrl !== 'string') {
throw new Error('DATABASE_URL is required');
}
let parsed;
try {
parsed = new URL(databaseUrl);
} catch {
throw new Error('DATABASE_URL must be a valid PostgreSQL URL');
}
if (!['postgres:', 'postgresql:'].includes(parsed.protocol)) {
throw new Error('DATABASE_URL must use the postgres or postgresql protocol');
}
if (!parsed.hostname || !parsed.pathname || parsed.pathname === '/') {
throw new Error('DATABASE_URL must include a host and database name');
}
return {
host: parsed.hostname,
port: parsed.port || '5432',
database: decodeURIComponent(parsed.pathname.slice(1)),
user: decodeURIComponent(parsed.username || ''),
};
}
function targetText(target, environment = 'unavailable') {
const safe = value => String(value || '[missing]')
.replace(/[\u0000-\u001f\u007f\s]+/g, '_')
.slice(0, 160);
return [
`host=${safe(target.host)}`,
`port=${safe(target.port)}`,
`database=${safe(target.database)}`,
`user=${safe(target.user)}`,
`databaseEnvironment=${safe(environment)}`,
].join(' ');
}
function refusal(operation, reason, target, environment) {
return new Error(
`Refusing ${operation}: ${reason}. Target: ${targetText(target, environment)}`,
);
}
function knownProductionReason(target) {
const user = target.user.toLowerCase();
const host = target.host.toLowerCase();
if (KNOWN_PRODUCTION_DATABASE_USERS.has(user)) {
return 'database user is reserved for production runtime';
}
if (host === 'tjszsb.com' || host.endsWith('.tjszsb.com')) {
return 'database host belongs to the production domain';
}
return '';
}
export async function assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation = resolveDestructiveTestConfirmation(),
operation = 'destructive database test',
} = {}) {
let target;
try {
target = describeDatabaseTarget(databaseUrl);
} catch (error) {
throw new Error(`Refusing ${operation}: ${error.message}`);
}
if (confirmation !== DESTRUCTIVE_TEST_CONFIRMATION) {
throw refusal(
operation,
`explicit confirmation ${DESTRUCTIVE_TEST_CONFIRMATION} is required`,
target,
);
}
const productionReason = knownProductionReason(target);
if (productionReason) {
throw refusal(operation, productionReason, target);
}
if (!client || typeof client.query !== 'function') {
throw refusal(operation, 'a connected PostgreSQL client is required', target);
}
let result;
try {
result = await client.query(
`
select environment,
allow_destructive_tests as "allowDestructiveTests"
from app_private.environment_safety
where id = true
limit 1
`,
);
} catch {
throw refusal(
operation,
'database safety marker is unavailable or unreadable',
target,
);
}
const marker = result?.rows?.[0];
const environment = String(marker?.environment || 'missing').toLowerCase();
if (!marker) {
throw refusal(operation, 'database safety marker row is missing', target, environment);
}
if (!ALLOWED_ENVIRONMENTS.has(environment)) {
throw refusal(
operation,
`database environment ${environment} is not approved for destructive tests`,
target,
environment,
);
}
if (marker.allowDestructiveTests !== true) {
throw refusal(
operation,
'database marker does not allow destructive tests',
target,
environment,
);
}
return { target, environment, allowDestructiveTests: true };
}

View File

@@ -0,0 +1,249 @@
import crypto from 'node:crypto';
export const TENANT_FOREIGN_KEY_AUDIT_KIND = 'tenant-foreign-key-audit';
export const EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT = 189;
export const EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256 = '884a5a59c101299551c27bde83f82b9738074a8729da9284775f75537f615868';
const TENANT_FOREIGN_KEY_EXCEPTIONS = new Map([
[
'platform_audit_alerts.platform_audit_alerts_rule_id_fkey',
{
mode: 'global-or-same-tenant-parent',
reason: 'Platform audit rules may be global (tenant_id is null) or scoped to the alert tenant.',
childColumn: 'rule_id',
parentTable: 'platform_audit_alert_rules',
parentColumn: 'id',
},
],
[
'tenant_question_bank_adoptions.tenant_question_bank_adoptions_source_question_bank_id_fkey',
{
mode: 'platform-source-or-same-tenant-parent',
reason: 'A tenant adoption may reference a platform-owned public question bank.',
childColumn: 'source_question_bank_id',
parentTable: 'question_banks',
parentColumn: 'id',
},
],
[
'tenant_content_notifications.tenant_content_notifications_source_question_bank_id_fkey',
{
mode: 'platform-source-or-same-tenant-parent',
reason: 'A tenant notification may identify the platform-owned public question bank that triggered it.',
childColumn: 'source_question_bank_id',
parentTable: 'question_banks',
parentColumn: 'id',
},
],
]);
const RELATION_QUERY = `
with tenant_tables as (
select cls.oid, cls.relname
from pg_class cls
join pg_namespace ns on ns.oid = cls.relnamespace
where ns.nspname = 'public'
and cls.relkind in ('r', 'p')
and exists (
select 1
from pg_attribute attribute
where attribute.attrelid = cls.oid
and attribute.attname = 'tenant_id'
and not attribute.attisdropped
)
)
select child.relname as "childTable",
constraint_row.conname as "constraintName",
parent.relname as "parentTable",
array(
select attribute.attname
from unnest(constraint_row.conkey) with ordinality key_column(attnum, ordinal)
join pg_attribute attribute
on attribute.attrelid = constraint_row.conrelid
and attribute.attnum = key_column.attnum
order by key_column.ordinal
) as "childColumns",
array(
select attribute.attname
from unnest(constraint_row.confkey) with ordinality key_column(attnum, ordinal)
join pg_attribute attribute
on attribute.attrelid = constraint_row.confrelid
and attribute.attnum = key_column.attnum
order by key_column.ordinal
) as "parentColumns",
constraint_row.convalidated as validated,
constraint_row.confupdtype as "updateAction",
constraint_row.confdeltype as "deleteAction"
from pg_constraint constraint_row
join tenant_tables child on child.oid = constraint_row.conrelid
join tenant_tables parent on parent.oid = constraint_row.confrelid
where constraint_row.contype = 'f'
and not exists (
select 1
from unnest(constraint_row.conkey) key_column(attnum)
join pg_attribute attribute
on attribute.attrelid = constraint_row.conrelid
and attribute.attnum = key_column.attnum
where attribute.attname = 'tenant_id'
)
order by child.relname, constraint_row.conname
`;
function textArray(value) {
if (Array.isArray(value)) return value.map(item => String(item));
if (typeof value !== 'string' || value.length < 2) return [];
return value.slice(1, -1).split(',').filter(Boolean).map(item => item.replace(/^"|"$/g, ''));
}
export function normalizeTenantForeignKeyRelation(row) {
return {
childTable: String(row.childTable || row.child_table || ''),
constraintName: String(row.constraintName || row.constraint_name || ''),
childColumns: textArray(row.childColumns || row.child_columns),
parentTable: String(row.parentTable || row.parent_table || ''),
parentColumns: textArray(row.parentColumns || row.parent_columns),
validated: row.validated === true,
updateAction: String(row.updateAction || row.update_action || ''),
deleteAction: String(row.deleteAction || row.delete_action || ''),
};
}
export function tenantForeignKeyRelationKey(relation) {
return `${relation.childTable}.${relation.constraintName}`;
}
export function tenantForeignKeyRelationCanonical(relation) {
return [
relation.childTable,
relation.constraintName,
relation.childColumns.join(','),
relation.parentTable,
relation.parentColumns.join(','),
relation.validated ? 'validated' : 'not-valid',
`update:${relation.updateAction}`,
`delete:${relation.deleteAction}`,
].join('|');
}
export function tenantForeignKeySchemaSha256(relations) {
const canonical = relations
.map(normalizeTenantForeignKeyRelation)
.sort((left, right) => tenantForeignKeyRelationKey(left).localeCompare(tenantForeignKeyRelationKey(right)))
.map(tenantForeignKeyRelationCanonical)
.join('\n');
return crypto.createHash('sha256').update(canonical).digest('hex');
}
function exceptionFor(relation) {
const key = tenantForeignKeyRelationKey(relation);
const exception = TENANT_FOREIGN_KEY_EXCEPTIONS.get(key);
if (!exception) return null;
if (
relation.childColumns.length !== 1
|| relation.parentColumns.length !== 1
|| relation.childColumns[0] !== exception.childColumn
|| relation.parentTable !== exception.parentTable
|| relation.parentColumns[0] !== exception.parentColumn
) {
throw new Error(`Tenant foreign key exception definition no longer matches ${key}`);
}
return exception;
}
export function summarizeTenantForeignKeySchema(inputRelations) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
const keys = new Set(relations.map(tenantForeignKeyRelationKey));
const missingExceptions = [...TENANT_FOREIGN_KEY_EXCEPTIONS.keys()].filter(key => !keys.has(key));
const exceptionRelations = relations.filter(relation => exceptionFor(relation));
const unvalidatedRelations = relations
.filter(relation => !relation.validated)
.map(tenantForeignKeyRelationKey);
const schemaSha256 = tenantForeignKeySchemaSha256(relations);
const schemaMatches = relations.length === EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT
&& schemaSha256 === EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256
&& missingExceptions.length === 0
&& unvalidatedRelations.length === 0;
return {
relationCount: relations.length,
expectedRelationCount: EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT,
schemaSha256,
expectedSchemaSha256: EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256,
schemaMatches,
exceptionCount: exceptionRelations.length,
expectedExceptionCount: TENANT_FOREIGN_KEY_EXCEPTIONS.size,
missingExceptions,
unvalidatedRelations,
};
}
export async function loadTenantForeignKeyRelations(queryable) {
const result = await queryable.query(RELATION_QUERY);
return result.rows.map(normalizeTenantForeignKeyRelation);
}
function quoteIdentifier(value) {
return `"${String(value).replaceAll('"', '""')}"`;
}
function quoteLiteral(value) {
return `'${String(value).replaceAll("'", "''")}'`;
}
function violationPredicate(relation) {
const exception = exceptionFor(relation);
if (!exception) return 'child.tenant_id is distinct from parent.tenant_id';
if (exception.mode === 'global-or-same-tenant-parent') {
return 'parent.tenant_id is not null and child.tenant_id is distinct from parent.tenant_id';
}
if (exception.mode === 'platform-source-or-same-tenant-parent') {
return "child.tenant_id is distinct from parent.tenant_id and parent.source_scope is distinct from 'platform'";
}
throw new Error(`Unsupported tenant foreign key exception mode: ${exception.mode}`);
}
export function buildTenantForeignKeyViolationQuery(inputRelations) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
if (relations.length === 0) {
return `select null::text as "relationKey", null::text as "childTenantId", null::text as "parentTenantId" where false`;
}
return relations.map(relation => {
if (relation.childColumns.length !== relation.parentColumns.length || relation.childColumns.length === 0) {
throw new Error(`Invalid tenant foreign key shape: ${tenantForeignKeyRelationKey(relation)}`);
}
const join = relation.childColumns.map((childColumn, index) => (
`parent.${quoteIdentifier(relation.parentColumns[index])} = child.${quoteIdentifier(childColumn)}`
)).join(' and ');
return `(
select ${quoteLiteral(tenantForeignKeyRelationKey(relation))}::text as "relationKey",
child.tenant_id::text as "childTenantId",
parent.tenant_id::text as "parentTenantId"
from public.${quoteIdentifier(relation.childTable)} child
join public.${quoteIdentifier(relation.parentTable)} parent on ${join}
where ${violationPredicate(relation)}
limit 1
)`;
}).join('\nunion all\n');
}
export async function auditTenantForeignKeyData(client, inputRelations, statementTimeoutMs = 120_000) {
const relations = inputRelations.map(normalizeTenantForeignKeyRelation);
const timeout = Math.max(1_000, Math.min(900_000, Number(statementTimeoutMs) || 120_000));
await client.query('begin read only');
try {
await client.query(`set local statement_timeout = '${timeout}ms'`);
await client.query(`set local lock_timeout = '5s'`);
const result = await client.query(buildTenantForeignKeyViolationQuery(relations));
await client.query('commit');
return result.rows;
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
}
}
export function tenantForeignKeyExceptions() {
return [...TENANT_FOREIGN_KEY_EXCEPTIONS.entries()].map(([relationKey, definition]) => ({
relationKey,
...definition,
}));
}

View File

@@ -86,7 +86,7 @@ function runAuditAlertWorkerOnce() {
env: {
...process.env,
DATABASE_URL: databaseUrl,
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '20',
WORKER_PLATFORM_AUDIT_ALERT_BATCH_SIZE: '1000',
WORKER_PLATFORM_AUDIT_ALERT_LOOKBACK_DAYS: '30',
WORKER_PLATFORM_AUDIT_ALERT_ID: 'platform-usage-overage-alert-test',
},

View File

@@ -58,9 +58,9 @@ for (const item of lines) {
if (
/smoke:taro:h5:interaction/.test(text) &&
hasAny(text, [/26 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/])
hasAny(text, [/26 项/, /32 项/, /租户后台六个主模块/, /平台后台四个主模块/, /租户题库内容\/财务、平台租户\/账务中心/])
) {
failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 32 项和后台真实写操作,不能回退到旧描述:${text}`);
failures.push(`${item.file}:${item.line} H5 交互烟测已经覆盖 33、运行时竞态探针和后台真实写操作,不能回退到旧描述:${text}`);
}
}

View File

@@ -27,6 +27,7 @@ const safeBaseEnv = {
const safeApiEnv = {
...safeBaseEnv,
CORS_ORIGIN: 'https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com',
CORS_TENANT_DOMAINS_ENABLED: 'true',
AUTH_SMS_PROVIDER: 'aliyun-pnvs',
AUTH_CODE_PEPPER: 's3cure-prod-code-pepper-2026-06-30-abcdef',
AUTH_SESSION_SECRET: 's3cure-prod-session-secret-2026-06-30-ghijkl',
@@ -73,6 +74,28 @@ assert.match(
'API config should require PNVS for production SMS',
);
const unsafeApiTenantCorsDisabled = runImport(apiConfigUrl, {
...safeApiEnv,
CORS_TENANT_DOMAINS_ENABLED: 'false',
});
assert.notEqual(unsafeApiTenantCorsDisabled.status, 0, 'production API config should require dynamic tenant CORS');
assert.match(
unsafeApiTenantCorsDisabled.output,
/CORS_TENANT_DOMAINS_ENABLED must be true in production/,
'API config should fail closed when dynamic tenant CORS is disabled',
);
const unsafeApiCorsPath = runImport(apiConfigUrl, {
...safeApiEnv,
CORS_ORIGIN: 'https://platform-admin.gongxue100.com/app',
});
assert.notEqual(unsafeApiCorsPath.status, 0, 'production API config should reject non-Origin CORS URLs');
assert.match(
unsafeApiCorsPath.output,
/CORS_ORIGIN must contain only production HTTPS origins without paths/,
'API config should reject CORS entries with URL paths',
);
const unsafeApiTraditionalSmsProvider = runImport(apiConfigUrl, {
...safeApiEnv,
AUTH_SMS_PROVIDER: 'aliyun',

View File

@@ -3,7 +3,16 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { gateChecks, requiredAttestations } from './production-launch-gate.js';
import crypto from 'node:crypto';
import {
gateChecks,
parseArgs,
productionUrlFailure,
requiredAttestations,
validateEvidence,
weappAttestations,
weappGateChecks,
} from './production-launch-gate.js';
const repoRoot = process.cwd();
const scriptPath = path.join(repoRoot, 'scripts', 'production-launch-gate.js');
@@ -38,23 +47,189 @@ function sampleSummary(summarySpec) {
return result;
}
function nestedSummaryPayload(summary) {
return { summary };
}
function pocketBaseDryRunPayload(summary) {
return {
migrationProfile: summary.migrationProfile,
summary: {
blockers: summary.blocker,
warnings: summary.warning,
},
migrationReadiness: {
requiredCollections: Array.from({ length: 3 }, (_, index) => ({ collection: `required-${index}`, present: true })),
criticalFieldCoverage: Array.from({ length: 3 }, (_, index) => ({ collection: `coverage-${index}`, present: true })),
},
};
}
function productionEvidencePayload(spec, summary) {
if (spec.id === 'db.migration-history') return summary;
if (spec.id === 'auth.platform-admin-bootstrap') {
const { identityMatches: _identityMatches, ...artifact } = summary;
return artifact;
}
if (spec.id === 'backup.restore-drill') return summary;
if (spec.id === 'taro.supply-chain') {
const reviewedEdges = [
{ parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' },
{ parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' },
{ parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' },
{ parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' },
];
return {
schemaVersion: summary.schemaVersion,
status: summary.status,
securedBundleDependencies: summary.securedBundleDependencies,
npmLs: {
edges: reviewedEdges,
},
audit: { counts: summary.audit.counts },
riskBoundary: {
appliesTo: 'Taro 4.2.0 CLI and build toolchain',
controls: Array.from({ length: summary.riskControlCount }, (_, index) => `control-${index}`),
},
};
}
return null;
}
function jsonArtifactPayload(spec, summary) {
const productionPayload = productionEvidencePayload(spec, summary);
if (productionPayload) return productionPayload;
if (spec.id === 'readiness.production.env' || spec.id === 'readiness.production.db') return nestedSummaryPayload(summary);
if (spec.id === 'postgres.tuning-evidence' || spec.id === 'auth.sms-pnvs-diagnostics' || spec.id === 'auth.sms-pnvs-remote-smoke') return summary;
if (spec.id === 'migration.pb-production-dry-run') return pocketBaseDryRunPayload(summary);
if (spec.id === 'api.launch-persona-smoke') return summary;
if (spec.id === 'api.dynamic-tenant-cors-smoke' || spec.id === 'db.tenant-foreign-key-audit') return summary;
if (spec.id === 'taro.h5-static-smoke' || spec.id === 'taro.h5-release-guardrails' || spec.id === 'security.repo-scan') return nestedSummaryPayload(summary);
if (spec.id === 'taro.h5-interaction-smoke') return { summary: { fail: summary.fail, pass: summary.pass }, mockApi: summary.mockApi };
if (spec.id === 'taro.h5-release-manifest') return { ...nestedSummaryPayload(summary), portals: [] };
if (spec.id === 'taro.weapp-release-guardrails') return nestedSummaryPayload(summary);
return null;
}
function writeCheckArtifact(spec, summary, artifactPath) {
const jsonPayload = jsonArtifactPayload(spec, summary);
if (jsonPayload) {
fs.writeFileSync(artifactPath, `${JSON.stringify(jsonPayload, null, 2)}\n`, 'utf8');
return;
}
fs.writeFileSync(artifactPath, `TIKU_LAUNCH_GATE_SUCCESS:${spec.id}\n`, 'utf8');
}
function rewriteArtifact(tempDir, item, payload) {
const artifactPath = path.join(tempDir, item.artifact);
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
}
function createEvidence(tempDir, overrides = {}) {
const artifactDir = path.join(tempDir, 'launch-artifacts');
fs.mkdirSync(artifactDir, { recursive: true });
const checks = gateChecks.map(spec => {
const artifact = `launch-artifacts/${spec.id}.log`;
fs.writeFileSync(path.join(tempDir, artifact), `[PASS] ${spec.id}\n`, 'utf8');
const artifactPath = path.join(tempDir, artifact);
const summary = sampleSummary(spec.summary);
if (spec.id === 'db.migration-history') {
const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations'))
.filter(file => file.endsWith('.sql'))
.sort();
const latestMigration = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1];
summary.latestRepositoryMigration = latestMigration;
summary.latestAppliedMigration = latestMigration;
}
if (spec.id === 'auth.platform-admin-bootstrap') {
const identityHash = crypto.createHash('sha256').update('platform-admin-auth-user').digest('hex');
summary.adminUserIdSha256 = identityHash;
summary.authSmokeExpectedUserIdSha256 = identityHash;
summary.dryRunArtifactSha256 = crypto.createHash('sha256').update('platform-admin-dry-run-artifact').digest('hex');
summary.applyArtifactSha256 = crypto.createHash('sha256').update('platform-admin-apply-artifact').digest('hex');
summary.authSmokeArtifactSha256 = crypto.createHash('sha256').update('platform-admin-auth-smoke-artifact').digest('hex');
summary.auditArtifactSha256 = crypto.createHash('sha256').update('platform-admin-audit-artifact').digest('hex');
}
if (spec.id === 'backup.restore-drill') {
summary.verificationArtifactSha256 = crypto.createHash('sha256').update('backup-restore-verification-artifact').digest('hex');
}
if (spec.id === 'taro.h5-release-manifest') {
const releaseRoot = path.join(tempDir, 'candidate');
const portalDirs = [
['student', 'apps/taro/dist/h5-student'],
['tenant-admin', 'apps/taro/dist/h5-tenant-admin'],
['platform-admin', 'apps/taro/dist/h5-platform-admin'],
];
const portals = portalDirs.map(([portal, relativeDir]) => {
const dir = path.join(releaseRoot, relativeDir);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.html'), `<div id="app">${portal}</div>\n`, 'utf8');
const content = fs.readFileSync(path.join(dir, 'index.html'));
const treeHash = crypto.createHash('sha256')
.update('index.html\0')
.update(String(content.length))
.update('\0')
.update(content)
.update('\0')
.digest('hex');
return { portal, dist: { treeSha256: treeHash } };
});
fs.writeFileSync(artifactPath, `${JSON.stringify({ summary, portals }, null, 2)}\n`, 'utf8');
} else if (spec.id === 'performance.tenant-students-100k') {
const caseItem = (id, p95, indexes) => ({
id,
latencyMs: { p95 },
explain: { summary: { indexes } },
});
fs.writeFileSync(artifactPath, `${JSON.stringify({
schemaVersion: 1,
kind: 'tenant-student-capacity',
safety: { databaseEnvironment: summary.databaseEnvironment },
fixture: summary.fixture,
config: { deepCursorApproximateOffset: summary.deepCursorApproximateOffset },
cases: [
caseItem('first-page', summary.firstPageP95Ms, ['idx_memberships_student_keyset_page']),
caseItem('deep-cursor', summary.deepCursorP95Ms, ['idx_memberships_student_keyset_page']),
caseItem('name-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
caseItem('phone-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
caseItem('email-substring', summary.searchP95MaxMs, ['idx_platform_users_identity_search_trgm']),
],
cleanup: {
cleanupVerified: summary.cleanupVerified,
remaining: { tenants: 0, platformUsers: 0, memberships: 0, profiles: 0 },
},
}, null, 2)}\n`, 'utf8');
} else {
writeCheckArtifact(spec, summary, artifactPath);
}
return {
id: spec.id,
status: 'pass',
command: `npm run ${spec.commandIncludes} -- recorded-for-launch-gate`,
completedAt: isoNow(),
artifact,
summary: sampleSummary(spec.summary),
artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'),
summary,
};
});
const readinessDbCheck = checks.find(item => item.id === 'readiness.production.db');
const migrationHistoryCheck = checks.find(item => item.id === 'db.migration-history');
const readinessDbArtifactPath = path.join(tempDir, readinessDbCheck.artifact);
migrationHistoryCheck.summary.readinessArtifactSha256 = crypto.createHash('sha256')
.update(fs.readFileSync(readinessDbArtifactPath))
.digest('hex');
const migrationHistoryArtifactPath = path.join(tempDir, migrationHistoryCheck.artifact);
writeCheckArtifact(
gateChecks.find(spec => spec.id === migrationHistoryCheck.id),
migrationHistoryCheck.summary,
migrationHistoryArtifactPath,
);
migrationHistoryCheck.artifactSha256 = crypto.createHash('sha256')
.update(fs.readFileSync(migrationHistoryArtifactPath))
.digest('hex');
const attestations = requiredAttestations.map(spec => ({
id: spec.id,
status: 'approved',
@@ -66,6 +241,7 @@ function createEvidence(tempDir, overrides = {}) {
return {
schemaVersion: 1,
environment: 'production',
releaseTargets: ['h5'],
commit: '52cef9fabcd1234567890abcdef1234567890abc',
target: {
apiBaseUrl: 'https://api.gongxue100.com',
@@ -83,6 +259,9 @@ function runGate(evidence, options = {}) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-'));
const evidencePath = path.join(tempDir, 'evidence.json');
const finalEvidence = typeof evidence === 'function' ? evidence(tempDir) : evidence;
const deployReleaseRoot = options.deployReleaseRoot === '__TEMP_CANDIDATE__'
? path.join(tempDir, 'candidate')
: (options.deployReleaseRoot || '');
fs.writeFileSync(evidencePath, JSON.stringify(finalEvidence, null, 2), 'utf8');
const result = spawnSync(process.execPath, [scriptPath, '--evidence', evidencePath, '--json', ...(options.args || [])], {
@@ -95,6 +274,8 @@ function runGate(evidence, options = {}) {
ComSpec: process.env.ComSpec || '',
TEMP: process.env.TEMP || os.tmpdir(),
TMP: process.env.TMP || os.tmpdir(),
DEPLOY_COMMIT_SHA: options.deployCommitSha || '',
DEPLOY_RELEASE_ROOT: deployReleaseRoot,
},
});
@@ -103,10 +284,472 @@ function runGate(evidence, options = {}) {
return { ...result, payload };
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function liveH5Fixture(tempDir) {
const portalContents = {
student: {
targetKey: 'studentH5Url',
baseUrl: 'https://student.gongxue100.com',
appPath: '/js/app.student.js',
app: 'console.log("student");\n',
},
'tenant-admin': {
targetKey: 'tenantAdminH5Url',
baseUrl: 'https://admin.gongxue100.com',
appPath: '/js/app.tenant.js',
app: 'console.log("tenant-admin");\n',
},
'platform-admin': {
targetKey: 'platformAdminH5Url',
baseUrl: 'https://console.gongxue100.com',
appPath: '/js/app.platform.js',
app: 'console.log("platform-admin");\n',
},
};
const evidence = createEvidence(tempDir);
evidence.liveH5 = { portals: [] };
const responses = new Map();
for (const [portal, fixture] of Object.entries(portalContents)) {
const index = `<!doctype html><html><head><script defer src="${fixture.appPath}"></script></head><body><div id="app"></div></body></html>`;
const runtime = `${JSON.stringify({ portal, apiBaseUrl: evidence.target.apiBaseUrl })}\n`;
evidence.target[fixture.targetKey] = fixture.baseUrl;
evidence.liveH5.portals.push({
portal,
indexSha256: sha256(index),
appPath: fixture.appPath,
appSha256: sha256(fixture.app),
});
responses.set(`${fixture.baseUrl}/index.html`, { body: index, contentType: 'text/html; charset=utf-8' });
responses.set(`${fixture.baseUrl}/runtime-config.json`, { body: runtime, contentType: 'application/json' });
responses.set(`${fixture.baseUrl}${fixture.appPath}`, { body: fixture.app, contentType: 'application/javascript' });
}
return { evidence, responses };
}
function liveH5ManifestFixture(tempDir) {
const fixture = liveH5Fixture(tempDir);
const candidateRoot = path.join(tempDir, 'candidate');
const manifest = {
schemaVersion: 1,
portals: [],
};
for (const portalItem of fixture.evidence.liveH5.portals) {
const portalDir = path.join(
candidateRoot,
'apps',
'taro',
'dist',
portalItem.portal === 'student' ? 'h5-student' : `h5-${portalItem.portal}`,
);
const baseUrl = portalItem.portal === 'student'
? 'https://student.gongxue100.com'
: portalItem.portal === 'tenant-admin'
? 'https://admin.gongxue100.com'
: 'https://console.gongxue100.com';
const index = fixture.responses.get(`${baseUrl}/index.html`).body;
const app = fixture.responses.get(`${baseUrl}${portalItem.appPath}`).body;
const appFile = path.join(portalDir, `.${portalItem.appPath}`);
fs.mkdirSync(path.dirname(appFile), { recursive: true });
fs.writeFileSync(path.join(portalDir, 'index.html'), index, 'utf8');
fs.writeFileSync(appFile, app, 'utf8');
manifest.portals.push({
portal: portalItem.portal,
dist: {
dir: '../../must-not-be-used',
indexSha256: sha256(index),
},
});
}
const manifestPath = path.join(tempDir, 'launch-artifacts', 'taro-h5-release-manifest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
fixture.evidence.liveH5 = {
releaseManifestArtifact: path.relative(tempDir, manifestPath),
releaseManifestSha256: sha256(fs.readFileSync(manifestPath)),
};
return fixture;
}
function fixtureFetch(responses) {
return async url => {
const key = String(url);
const item = responses.get(key);
if (!item) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
return new Response(item.body, {
status: 200,
headers: { 'content-type': item.contentType },
});
};
}
function removeTempDir(tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
const safe = runGate(tempDir => createEvidence(tempDir));
assert.equal(safe.status, 0, `complete launch evidence should pass: ${safe.stdout} ${safe.stderr}`);
assert.equal(safe.payload.summary?.blocker, 0, 'complete launch evidence should have no blockers');
const evidenceTemplate = JSON.parse(fs.readFileSync(path.join(repoRoot, 'docs', 'refactor', 'production-launch-evidence.template.json'), 'utf8'));
const templateCheckIds = evidenceTemplate.checks.map(item => item.id);
const coreGateCheckIds = gateChecks.map(item => item.id);
assert.deepEqual(templateCheckIds, coreGateCheckIds, 'template core check IDs must exactly match gateChecks in order');
assert.equal(new Set(templateCheckIds).size, templateCheckIds.length, 'template core check IDs must be unique');
assert.ok(coreGateCheckIds.includes('worker.platform-billing'), 'platform billing worker must be a core launch check');
assert.ok(coreGateCheckIds.includes('worker.platform-dunning'), 'platform dunning worker must be a core launch check');
for (const id of ['db.migration-history', 'auth.platform-admin-bootstrap', 'backup.restore-drill', 'taro.supply-chain']) {
assert.ok(coreGateCheckIds.includes(id), `${id} must be a core launch check`);
}
for (const item of evidenceTemplate.checks) {
const spec = gateChecks.find(candidate => candidate.id === item.id);
if (typeof spec?.artifactSummary === 'function') continue;
assert.ok(
String(item.command || '').includes(`TIKU_LAUNCH_GATE_SUCCESS:${item.id}`),
`log-backed template check ${item.id} must append its explicit success sentinel`,
);
}
assert.equal(
evidenceTemplate.checks.filter(item => item.artifact && !item.artifactSha256).length,
0,
'every template check with an artifact must include artifactSha256',
);
assert.ok(evidenceTemplate.liveH5?.releaseManifestSha256, 'template must document the strict live H5 release manifest hash');
for (const rejectedUrl of [
'http://api.gongxue100.com',
'https://localhost',
'https://127.0.0.1',
'https://portal.test',
'https://portal.example',
'https://portal.example.com',
'https://replace-with-domain.invalid',
'https://replace-with-real-domain.com',
]) {
assert.ok(productionUrlFailure(rejectedUrl), `production URL validation must reject ${rejectedUrl}`);
}
assert.equal(productionUrlFailure('https://student.gongxue100.com'), '', 'real HTTPS production URL should be accepted');
assert.equal(parseArgs(['node', 'gate', '--verify-live-h5']).verifyLiveH5, true, 'CLI should explicitly enable strict live H5 validation');
assert.equal(
parseArgs(['node', 'gate', '--verify-live-h5', '--no-verify-live-h5']).verifyLiveH5,
false,
'CLI should allow deployment wrappers to explicitly disable inherited live validation',
);
const placeholderTarget = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.target.studentH5Url = 'https://student.example.com';
return evidence;
});
assert.notEqual(placeholderTarget.status, 0, 'placeholder target URL should fail launch gate');
assert.ok(placeholderTarget.payload.checks?.some(item => item.id === 'target.studentH5Url' && item.status === 'blocker'));
{
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
try {
const fixture = liveH5Fixture(tempDir);
const checks = await validateEvidence(fixture.evidence, {
evidencePath: path.join(tempDir, 'evidence.json'),
allowStale: false,
maxAgeDays: 14,
verifyLiveH5: true,
liveTimeoutMs: 5_000,
fetchImpl: fixtureFetch(fixture.responses),
deployReleaseRoot: path.join(tempDir, 'candidate'),
});
assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2));
assert.equal(checks.filter(item => /^live_h5\..+\.app_hash$/.test(item.id) && item.status === 'pass').length, 3);
} finally {
removeTempDir(tempDir);
}
}
{
const tempDir = fs.mkdtempSync(path.join(repoRoot, '.tmp-launch-gate-manifest-'));
try {
const fixture = liveH5ManifestFixture(tempDir);
const checks = await validateEvidence(fixture.evidence, {
evidencePath: path.join(tempDir, 'evidence.json'),
allowStale: false,
maxAgeDays: 14,
verifyLiveH5: true,
liveTimeoutMs: 5_000,
fetchImpl: fixtureFetch(fixture.responses),
deployReleaseRoot: path.join(tempDir, 'candidate'),
});
assert.equal(checks.some(item => item.status === 'blocker'), false, JSON.stringify(checks.filter(item => item.status === 'blocker'), null, 2));
assert.equal(checks.filter(item => /^live_h5\..+\.candidate_files$/.test(item.id) && item.status === 'pass').length, 3);
} finally {
removeTempDir(tempDir);
}
}
{
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
try {
const fixture = liveH5Fixture(tempDir);
const studentApp = fixture.evidence.liveH5.portals.find(item => item.portal === 'student');
fixture.responses.set('https://student.gongxue100.com/js/app.student.js', {
body: 'console.log("stale-production-bundle");\n',
contentType: 'application/javascript',
});
const checks = await validateEvidence(fixture.evidence, {
evidencePath: path.join(tempDir, 'evidence.json'),
allowStale: false,
maxAgeDays: 14,
verifyLiveH5: true,
liveTimeoutMs: 5_000,
fetchImpl: fixtureFetch(fixture.responses),
});
assert.ok(studentApp.appSha256);
assert.ok(checks.some(item => item.id === 'live_h5.student.app_hash' && item.status === 'blocker'));
} finally {
removeTempDir(tempDir);
}
}
{
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-launch-gate-live-'));
try {
const fixture = liveH5Fixture(tempDir);
fixture.responses.set('https://admin.gongxue100.com/runtime-config.json', {
body: JSON.stringify({ portal: 'student', apiBaseUrl: 'https://api.other-domain.com' }),
contentType: 'application/json',
});
const checks = await validateEvidence(fixture.evidence, {
evidencePath: path.join(tempDir, 'evidence.json'),
allowStale: false,
maxAgeDays: 14,
verifyLiveH5: true,
liveTimeoutMs: 5_000,
fetchImpl: fixtureFetch(fixture.responses),
});
assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_portal' && item.status === 'blocker'));
assert.ok(checks.some(item => item.id === 'live_h5.tenant-admin.runtime_api' && item.status === 'blocker'));
} finally {
removeTempDir(tempDir);
}
}
const wrongCommit = runGate(tempDir => createEvidence(tempDir), {
deployCommitSha: 'aaaaaaaaaaaa',
deployReleaseRoot: path.join(os.tmpdir(), 'missing-release'),
});
assert.notEqual(wrongCommit.status, 0, 'evidence for another commit should fail launch gate');
assert.ok(wrongCommit.payload.checks?.some(item => item.id === 'evidence.commit_match' && item.status === 'blocker'));
const matchingCandidate = runGate(tempDir => createEvidence(tempDir), {
deployCommitSha: '52cef9fabcd1',
deployReleaseRoot: '',
});
assert.notEqual(matchingCandidate.status, 0, 'deployment gate without a candidate release root should fail');
assert.ok(matchingCandidate.payload.checks?.some(item => item.id === 'evidence.release_root' && item.status === 'blocker'));
const candidateTreeMatch = runGate(tempDir => createEvidence(tempDir), {
deployCommitSha: '52cef9fabcd1',
deployReleaseRoot: '__TEMP_CANDIDATE__',
});
assert.equal(candidateTreeMatch.status, 0, `matching candidate release should pass: ${candidateTreeMatch.stdout}`);
const candidateTreeMismatch = runGate(tempDir => {
const evidence = createEvidence(tempDir);
fs.appendFileSync(path.join(tempDir, 'candidate/apps/taro/dist/h5-student/index.html'), 'tampered\n', 'utf8');
return evidence;
}, {
deployCommitSha: '52cef9fabcd1',
deployReleaseRoot: '__TEMP_CANDIDATE__',
});
assert.notEqual(candidateTreeMismatch.status, 0, 'candidate release that differs from the manifest should fail');
assert.ok(candidateTreeMismatch.payload.checks?.some(item => item.id === 'check.taro.h5-release-manifest.release_tree' && item.status === 'blocker'));
const tamperedArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
fs.appendFileSync(path.join(tempDir, item.artifact), 'tampered\n', 'utf8');
return evidence;
});
assert.notEqual(tamperedArtifact.status, 0, 'tampered artifact should fail launch gate');
assert.ok(tamperedArtifact.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker'));
const missingArtifactHash = runGate(tempDir => {
const evidence = createEvidence(tempDir);
delete evidence.checks.find(check => check.id === 'auth.remote-smoke').artifactSha256;
return evidence;
});
assert.notEqual(missingArtifactHash.status, 0, 'missing artifact hash should fail launch gate');
assert.ok(missingArtifactHash.payload.checks?.some(item => item.id === 'check.auth.remote-smoke.artifact_hash' && item.status === 'blocker'));
const forgedPassLog = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'worker.platform-billing');
const artifactPath = path.join(tempDir, item.artifact);
fs.writeFileSync(artifactPath, '[PASS] worker.platform-billing\n', 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(forgedPassLog.status, 0, 'a forged generic PASS log must not satisfy a log-backed check');
assert.ok(
forgedPassLog.payload.checks?.some(item => item.id === 'check.worker.platform-billing.artifact_success' && item.status === 'blocker'),
'missing check-specific log success sentinel must be reported as a blocker',
);
const forgedJsonSummary = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'readiness.production.env');
const artifactPath = path.join(tempDir, item.artifact);
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
payload.summary.blocker = 1;
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(forgedJsonSummary.status, 0, 'evidence summary must not override a failing structured artifact');
assert.ok(
forgedJsonSummary.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'),
'structured artifact mismatch must be reported as a blocker',
);
const invalidJsonArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'readiness.production.db');
const artifactPath = path.join(tempDir, item.artifact);
fs.writeFileSync(artifactPath, '{not-json}\n', 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(invalidJsonArtifact.status, 0, 'invalid JSON artifact must not satisfy a structured check');
assert.ok(
invalidJsonArtifact.payload.checks?.some(item => item.id === 'check.readiness.production.db.artifact_json' && item.status === 'blocker'),
'invalid structured artifact must be reported as a JSON blocker',
);
const unverifiedJsonSummaryField = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'readiness.production.env');
item.summary.operatorAssertion = 'pass';
return evidence;
});
assert.notEqual(unverifiedJsonSummaryField.status, 0, 'structured checks must reject summary fields that are not derived from the artifact gate contract');
assert.ok(
unverifiedJsonSummaryField.payload.checks?.some(item => item.id === 'check.readiness.production.env.artifact_summary' && item.status === 'blocker'),
'unverified structured summary fields must be reported as artifact summary blockers',
);
const staleMigrationArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'db.migration-history');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.latestAppliedMigration = '202607120018';
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(staleMigrationArtifact.status, 0, 'migration evidence older than the repository latest migration must fail');
assert.ok(staleMigrationArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
const forgedMigrationReadinessHash = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'db.migration-history');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.readinessArtifactSha256 = 'not-a-sha256';
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(forgedMigrationReadinessHash.status, 0, 'migration evidence must bind the readiness artifact by SHA-256');
assert.ok(forgedMigrationReadinessHash.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
const mismatchedMigrationReadinessArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'readiness.production.db');
const artifactPath = path.join(tempDir, item.artifact);
fs.appendFileSync(artifactPath, '\nchanged-after-migration-summary\n', 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(mismatchedMigrationReadinessArtifact.status, 0, 'migration summary must fail when the bound readiness artifact changes');
assert.ok(mismatchedMigrationReadinessArtifact.payload.checks?.some(item => item.id === 'check.db.migration-history.artifact_summary' && item.status === 'blocker'));
const mismatchedPlatformAdminIdentity = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'auth.platform-admin-bootstrap');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.authSmokeExpectedUserIdSha256 = crypto.createHash('sha256').update('different-auth-user').digest('hex');
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(mismatchedPlatformAdminIdentity.status, 0, 'platform admin bootstrap and Auth smoke identities must match');
assert.ok(mismatchedPlatformAdminIdentity.payload.checks?.some(item => item.id === 'check.auth.platform-admin-bootstrap.artifact_summary' && item.status === 'blocker'));
const unverifiedRestoreDrill = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'backup.restore-drill');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.integrityVerified = false;
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(unverifiedRestoreDrill.status, 0, 'restore drill without verified integrity must fail');
assert.ok(unverifiedRestoreDrill.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker'));
const forgedRestoreVerificationHash = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'backup.restore-drill');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.verificationArtifactSha256 = 'not-a-sha256';
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(forgedRestoreVerificationHash.status, 0, 'restore drill must bind its verification output by SHA-256');
assert.ok(forgedRestoreVerificationHash.payload.checks?.some(item => item.id === 'check.backup.restore-drill.artifact_summary' && item.status === 'blocker'));
const regressedTaroSupplyChain = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'taro.supply-chain');
const payload = jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary);
payload.securedBundleDependencies.swiper = '11.1.15';
payload.audit.counts.critical = 4;
rewriteArtifact(tempDir, item, payload);
return evidence;
});
assert.notEqual(regressedTaroSupplyChain.status, 0, 'Taro supply-chain bundle or vulnerability regression must fail');
assert.ok(regressedTaroSupplyChain.payload.checks?.some(item => item.id === 'check.taro.supply-chain.artifact_summary' && item.status === 'blocker'));
const missingWeappEvidence = runGate(tempDir => createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] }));
assert.notEqual(missingWeappEvidence.status, 0, 'WeApp release target without production evidence should fail launch gate');
assert.ok(missingWeappEvidence.payload.checks?.some(item => item.id === 'check.taro.build.weapp-student' && item.status === 'blocker'));
const completeWeappEvidence = runGate(tempDir => {
const evidence = createEvidence(tempDir, { releaseTargets: ['h5', 'weapp'] });
for (const spec of weappGateChecks) {
const artifact = `launch-artifacts/${spec.id}.log`;
const artifactPath = path.join(tempDir, artifact);
const summary = sampleSummary(spec.summary);
writeCheckArtifact(spec, summary, artifactPath);
evidence.checks.push({
id: spec.id,
status: 'pass',
command: `npm run ${spec.commandIncludes}`,
completedAt: isoNow(),
artifact,
artifactSha256: crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex'),
summary,
});
}
evidence.attestations.push(...weappAttestations.map(spec => ({
id: spec.id,
status: 'approved',
approver: 'test-owner',
approvedAt: isoNow(),
notes: spec.label,
})));
return evidence;
});
assert.equal(completeWeappEvidence.status, 0, `complete WeApp evidence should pass: ${completeWeappEvidence.stdout}`);
const missingArtifact = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'auth.remote-smoke');
@@ -146,6 +789,7 @@ const wrongSmsPnvsDiagnostics = runGate(tempDir => {
const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-diagnostics');
item.summary.env.providerMatchesPnvs = false;
item.summary.provider.provider = 'aliyun';
rewriteArtifact(tempDir, item, item.summary);
return evidence;
});
assert.notEqual(wrongSmsPnvsDiagnostics.status, 0, 'non-PNVS diagnostics should fail launch gate');
@@ -158,6 +802,7 @@ const wrongSmsProviderSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'auth.sms-pnvs-remote-smoke');
item.summary.provider = 'aliyun';
rewriteArtifact(tempDir, item, item.summary);
return evidence;
});
assert.notEqual(wrongSmsProviderSmoke.status, 0, 'non-PNVS SMS smoke should fail launch gate');
@@ -170,6 +815,7 @@ const wrongMigrationProfile = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'migration.pb-production-dry-run');
item.summary.migrationProfile = 'development';
rewriteArtifact(tempDir, item, pocketBaseDryRunPayload(item.summary));
return evidence;
});
assert.notEqual(wrongMigrationProfile.status, 0, 'development dry-run evidence should fail launch gate');
@@ -224,6 +870,54 @@ assert.ok(
'slow mixed benchmark should be reported as a blocker',
);
const capacityCleanupMismatch = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'performance.tenant-students-100k');
const artifactPath = path.join(tempDir, item.artifact);
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
payload.cleanup.remaining.platformUsers = 1;
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(capacityCleanupMismatch.status, 0, 'capacity artifact with managed rows after cleanup should fail');
assert.ok(
capacityCleanupMismatch.payload.checks?.some(item => item.id === 'check.performance.tenant-students-100k.artifact_summary' && item.status === 'blocker'),
'capacity cleanup mismatch should be reported from the artifact',
);
const tenantForeignKeyArtifactMismatch = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'db.tenant-foreign-key-audit');
const artifactPath = path.join(tempDir, item.artifact);
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
payload.data.invalidRelations = 1;
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(tenantForeignKeyArtifactMismatch.status, 0, 'tenant foreign key artifact with invalid data relations should fail');
assert.ok(
tenantForeignKeyArtifactMismatch.payload.checks?.some(item => item.id === 'check.db.tenant-foreign-key-audit.artifact_summary' && item.status === 'blocker'),
'tenant foreign key mismatch must be reported from the raw artifact',
);
const corsArtifactMismatch = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'api.dynamic-tenant-cors-smoke');
const artifactPath = path.join(tempDir, item.artifact);
const payload = JSON.parse(fs.readFileSync(artifactPath, 'utf8'));
payload.unknownOriginDenied = false;
fs.writeFileSync(artifactPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
item.artifactSha256 = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
return evidence;
});
assert.notEqual(corsArtifactMismatch.status, 0, 'dynamic CORS artifact mismatch should fail');
assert.ok(
corsArtifactMismatch.payload.checks?.some(item => item.id === 'check.api.dynamic-tenant-cors-smoke.artifact_summary' && item.status === 'blocker'),
'dynamic CORS mismatch should be reported from the artifact',
);
const missingLaunchPersonaSmoke = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'api.launch-persona-smoke');
@@ -239,6 +933,7 @@ const launchPersonaWithoutSvip = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke');
item.summary.student.result.entitlement.isSvip = false;
rewriteArtifact(tempDir, item, item.summary);
return evidence;
});
assert.notEqual(launchPersonaWithoutSvip.status, 0, 'launch persona smoke without SVIP should fail launch gate');
@@ -251,6 +946,7 @@ const launchPersonaLegacyAuth = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'api.launch-persona-smoke');
item.summary.authMode = 'legacy';
rewriteArtifact(tempDir, item, item.summary);
return evidence;
});
assert.notEqual(launchPersonaLegacyAuth.status, 0, 'launch persona smoke with legacy auth should fail launch gate');
@@ -296,6 +992,7 @@ const oldTaroInteractionCoverage = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke');
item.summary.pass = 26;
rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary));
return evidence;
});
assert.notEqual(oldTaroInteractionCoverage.status, 0, 'old 26-check H5 interaction evidence should fail launch gate');
@@ -308,6 +1005,7 @@ const missingAdminWriteCoverage = runGate(tempDir => {
const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'taro.h5-interaction-smoke');
item.summary.mockApi.keyRequests.platformAdminWrites = 0;
rewriteArtifact(tempDir, item, jsonArtifactPayload(gateChecks.find(spec => spec.id === item.id), item.summary));
return evidence;
});
assert.notEqual(missingAdminWriteCoverage.status, 0, 'H5 interaction evidence without platform admin writes should fail launch gate');

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,29 @@ function runReadiness(envContent, options = {}) {
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.providerRows }, null, 2), 'utf8');
args.push('--provider-config-fixture', fixtureFile);
}
if (options.tenantRows) {
const fixtureFile = path.join(tempDir, 'tenant-fixture.json');
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.tenantRows }, null, 2), 'utf8');
args.push('--tenant-config-fixture', fixtureFile);
}
if (Object.prototype.hasOwnProperty.call(options, 'environmentSafetyRow')) {
const fixtureFile = path.join(tempDir, 'environment-safety-fixture.json');
fs.writeFileSync(
fixtureFile,
JSON.stringify({ row: options.environmentSafetyRow }, null, 2),
'utf8',
);
args.push('--environment-safety-fixture', fixtureFile);
}
if (Object.prototype.hasOwnProperty.call(options, 'migrationHistoryRow')) {
const fixtureFile = path.join(tempDir, 'migration-history-fixture.json');
fs.writeFileSync(
fixtureFile,
JSON.stringify({ row: options.migrationHistoryRow }, null, 2),
'utf8',
);
args.push('--migration-history-fixture', fixtureFile);
}
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
@@ -36,6 +59,39 @@ function runReadiness(envContent, options = {}) {
return { ...result, payload };
}
function productionEnv(overrides = '') {
return `
NODE_ENV=production
${overrides}
DATABASE_URL=postgresql://tiku_api:prod_password@db.prod.internal:5432/tiku
DB_EXPECTED_RUNTIME_ROLE=tiku_api
CORS_ORIGIN=https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=s3cure-platform-admin-key-2026-06-29-mnopqr
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
`;
}
const unsafe = runReadiness(`
NODE_ENV=development
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
@@ -57,6 +113,10 @@ const unsupportedSmsProvider = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-production
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
@@ -88,6 +148,10 @@ const traditionalAliyunSmsProvider = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
@@ -123,6 +187,10 @@ const safe = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -150,10 +218,21 @@ WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_PLATFORM_DUNNING_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
WORKER_CRM_BATCH_SIZE=20
WORKER_CRM_POLL_INTERVAL_MS=10000
WORKER_COMMERCE_BATCH_SIZE=20
WORKER_COMMERCE_POLL_INTERVAL_MS=30000
WORKER_PROVIDER_BILL_POLL_INTERVAL_MS=60000
WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS=30000
WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS=30000
WORKER_ASSET_BATCH_SIZE=50
WORKER_ASSET_POLL_INTERVAL_MS=30000
WORKER_IMPORT_BATCH_SIZE=5
WORKER_IMPORT_POLL_INTERVAL_MS=10000
WORKER_IMPORT_LEASE_SECONDS=120
WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000
WORKER_PUBLIC_BANK_SYNC_BATCH_SIZE=5
WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS=60000
WORKER_EXPORT_POLL_INTERVAL_MS=10000
`);
assert.equal(safe.status, 0, `safe readiness should pass without blockers: ${safe.stdout} ${safe.stderr}`);
@@ -163,10 +242,175 @@ assert.ok(
'env-only readiness should explicitly warn that DB checks are skipped',
);
const migrationFiles = fs.readdirSync(path.join(repoRoot, 'supabase', 'migrations'))
.filter(file => file.endsWith('.sql'))
.sort();
const latestMigrationVersion = /^(\d+)_/.exec(migrationFiles.at(-1) || '')?.[1];
assert.ok(latestMigrationVersion, 'repository must have a latest numeric Supabase migration');
const currentMigrationHistory = runReadiness(productionEnv(), {
migrationHistoryRow: {
latestVersion: latestMigrationVersion,
appliedCount: migrationFiles.length,
distinctVersionCount: migrationFiles.length,
expectedVersionApplied: true,
},
});
assert.equal(currentMigrationHistory.status, 0, 'current migration history fixture should pass');
assert.ok(
currentMigrationHistory.payload.checks?.some(item => (
item.id === 'db.migrations.current' && item.status === 'pass'
)),
'readiness should pass migration history that includes the repository latest version',
);
const staleMigrationHistory = runReadiness(productionEnv(), {
migrationHistoryRow: {
latestVersion: '202607120018',
appliedCount: migrationFiles.length - 1,
distinctVersionCount: migrationFiles.length - 1,
expectedVersionApplied: false,
},
});
assert.notEqual(staleMigrationHistory.status, 0, 'stale migration history must fail readiness');
assert.ok(
staleMigrationHistory.payload.checks?.some(item => (
item.id === 'db.migrations.current' && item.status === 'blocker'
)),
'readiness should block a database missing the repository latest migration',
);
const inconsistentMigrationHistory = runReadiness(productionEnv(), {
migrationHistoryRow: {
latestVersion: latestMigrationVersion,
appliedCount: migrationFiles.length + 1,
distinctVersionCount: migrationFiles.length,
expectedVersionApplied: true,
},
});
assert.notEqual(inconsistentMigrationHistory.status, 0, 'duplicate migration history must fail readiness');
const truncatedMigrationHistory = runReadiness(productionEnv(), {
migrationHistoryRow: {
latestVersion: latestMigrationVersion,
appliedCount: migrationFiles.length - 1,
distinctVersionCount: migrationFiles.length - 1,
expectedVersionApplied: true,
},
});
assert.notEqual(
truncatedMigrationHistory.status,
0,
'migration history shorter than the repository migration set must fail readiness',
);
const dynamicTenantCorsDisabled = runReadiness(productionEnv('CORS_TENANT_DOMAINS_ENABLED=false'));
assert.notEqual(dynamicTenantCorsDisabled.status, 0, 'disabled tenant-domain CORS must fail production readiness');
assert.ok(
dynamicTenantCorsDisabled.payload.checks?.some(item => (
item.id === 'env.cors_tenant_domains_enabled' && item.status === 'blocker'
)),
'readiness must require dynamic tenant-domain CORS in production',
);
for (const [override, checkId] of [
['WORKER_IMPORT_LEASE_SECONDS=9', 'env.worker_import_lease_seconds'],
[
'WORKER_IMPORT_LEASE_SECONDS=60\nWORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000',
'env.worker_import_heartbeat_interval_ms',
],
]) {
const result = runReadiness(productionEnv(override));
assert.notEqual(result.status, 0, `${checkId} should fail production readiness`);
assert.ok(
result.payload.checks?.some(item => item.id === checkId && item.status === 'blocker'),
`${checkId} should be reported as a blocker`,
);
}
for (const [key, unsafeValue] of [
['CORS_TENANT_DOMAIN_CACHE_TTL_MS', '999'],
['CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS', '300001'],
['CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES', '99'],
]) {
const result = runReadiness(productionEnv(`${key}=${unsafeValue}`));
assert.notEqual(result.status, 0, `${key} outside the production range must fail readiness`);
assert.ok(
result.payload.checks?.some(item => (
item.id === `env.${key.toLowerCase()}` && item.status === 'blocker'
)),
`readiness must block unsafe ${key}`,
);
}
const missingExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE='));
assert.equal(
missingExpectedRole.status,
0,
'env-only readiness should defer the authoritative runtime-role identity check to --check-db',
);
assert.ok(
missingExpectedRole.payload.checks?.some(item => (
item.id === 'env.db_expected_runtime_role' && item.status === 'warn'
)),
'env-only readiness should warn when DB_EXPECTED_RUNTIME_ROLE is absent',
);
const mismatchedExpectedRole = runReadiness(productionEnv('DB_EXPECTED_RUNTIME_ROLE=tiku_worker'));
assert.notEqual(mismatchedExpectedRole.status, 0, 'DATABASE_URL role mismatch must fail readiness');
assert.ok(
mismatchedExpectedRole.payload.checks?.some(item => (
item.id === 'env.database_runtime_role' && item.status === 'blocker'
)),
'readiness must block a DATABASE_URL username that differs from DB_EXPECTED_RUNTIME_ROLE',
);
const invalidWorkerPollInterval = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
WORKER_CRM_POLL_INTERVAL_MS=0
`);
assert.notEqual(invalidWorkerPollInterval.status, 0, 'invalid production worker poll interval should fail readiness');
assert.ok(
invalidWorkerPollInterval.payload.checks?.some(item => (
item.id === 'env.worker_crm_poll_interval_ms' && item.status === 'blocker'
)),
'readiness should block invalid worker poll intervals',
);
const safeAliyunPnvs = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -200,6 +444,10 @@ const safeAliyunPnvsUnderscoreAlias = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun_pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -247,6 +495,10 @@ const pnvsTemplateParamWarning = runReadiness(
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -299,6 +551,10 @@ const mismatchedSmsProviderFixture = runReadiness(
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -350,6 +606,10 @@ const legacyTencentSmsProviderFixture = runReadiness(
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -408,6 +668,10 @@ const unsafeProviderFixture = runReadiness(
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -485,6 +749,10 @@ const missingJwksIssuer = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -517,6 +785,10 @@ const unsafePlatformAuditNotificationLocalhost = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -549,6 +821,10 @@ const unsafePlatformDunningNotificationLocalhost = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
@@ -578,4 +854,157 @@ assert.ok(
'readiness should block platform dunning notification localhost mode in production',
);
const tenantConfigBaseEnv = `
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
CORS_TENANT_DOMAINS_ENABLED=true
CORS_TENANT_DOMAIN_CACHE_TTL_MS=60000
CORS_TENANT_DOMAIN_NEGATIVE_CACHE_TTL_MS=10000
CORS_TENANT_DOMAIN_CACHE_MAX_ENTRIES=10000
AUTH_SMS_PROVIDER=aliyun-pnvs
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
`;
const unsafeTenantPublicUrl = runReadiness(tenantConfigBaseEnv, {
tenantRows: [{
tenantId: 'tenant-local-url',
slug: 'local-url',
name: 'Local URL tenant',
publicConfig: { frontend: { appUrl: 'http://127.0.0.1:5173' } },
publishedTheme: { primaryColor: '#2563eb' },
themeStatus: 'published',
publishedAt: '2026-07-11T00:00:00.000Z',
}],
});
assert.notEqual(unsafeTenantPublicUrl.status, 0, 'active tenant localhost public URLs should fail readiness');
assert.ok(
unsafeTenantPublicUrl.payload.checks?.some(item => item.id === 'db.tenant_public_urls' && item.status === 'blocker'),
'readiness should block active tenant public URLs that are not production HTTPS',
);
const tenantWithoutPublishedTheme = runReadiness(tenantConfigBaseEnv, {
tenantRows: [{
tenantId: 'tenant-default-theme',
slug: 'default-theme',
name: 'Default theme tenant',
publicConfig: { appUrl: 'https://student.gongxue100.com' },
}],
});
assert.equal(tenantWithoutPublishedTheme.status, 0, 'missing tenant theme should use platform defaults without blocking readiness');
assert.ok(
tenantWithoutPublishedTheme.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'warn'),
'readiness should warn when an active tenant has no published or branding fallback theme',
);
const tenantWithBrandingFallback = runReadiness(tenantConfigBaseEnv, {
tenantRows: [{
tenantId: 'tenant-branding-theme',
slug: 'branding-theme',
name: 'Branding theme tenant',
publicConfig: { appUrl: 'https://student.gongxue100.com' },
brandingTheme: { primaryColor: '#0f766e' },
}],
});
assert.equal(tenantWithBrandingFallback.status, 0, 'branding theme fallback should pass readiness');
assert.ok(
tenantWithBrandingFallback.payload.checks?.some(item => item.id === 'db.tenant_theme_published' && item.status === 'pass'),
'readiness should accept a non-empty tenant branding fallback theme',
);
for (const environmentSafetyRow of [
null,
{ environment: 'production', allowDestructiveTests: false },
{ environment: 'staging', allow_destructive_tests: false },
]) {
const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow });
assert.equal(
result.status,
0,
`production readiness should accept a missing or disabled production/staging marker: ${result.stdout} ${result.stderr}`,
);
assert.ok(
result.payload.checks?.some(item => (
item.id === 'db.environment.destructive_tests_disabled' && item.status === 'pass'
)),
'production readiness should record the safe destructive-test marker state',
);
}
for (const environmentSafetyRow of [
{ environment: 'local', allowDestructiveTests: false },
{ environment: 'test', allowDestructiveTests: false },
{ environment: 'ci', allowDestructiveTests: false },
{ environment: 'production', allowDestructiveTests: true },
{ environment: 'staging', allowDestructiveTests: true },
{ environment: 'unknown', allowDestructiveTests: false },
{ environment: 'production', allowDestructiveTests: 'false' },
]) {
const result = runReadiness(tenantConfigBaseEnv, { environmentSafetyRow });
assert.notEqual(
result.status,
0,
`production readiness must reject unsafe destructive-test marker ${JSON.stringify(environmentSafetyRow)}`,
);
assert.ok(
result.payload.checks?.some(item => (
item.id === 'db.environment.destructive_tests_disabled' && item.status === 'blocker'
)),
'production readiness should block unsafe destructive-test marker state',
);
}
const readinessSource = fs.readFileSync(scriptPath, 'utf8');
assert.match(
readinessSource,
/to_regclass\('app_private\.environment_safety'\)/,
'database readiness must verify that the environment safety migration exists',
);
assert.match(
readinessSource,
/from app_private\.environment_safety[\s\S]*where id = true/i,
'database readiness must read the authoritative destructive-test marker',
);
for (const gateId of [
'db.runtime_role.identity',
'db.runtime_role.attributes',
'db.runtime_role.schema_acl',
'db.runtime_role.table_acl',
'db.runtime_role.function_acl',
'db.runtime_role.ownership',
'db.runtime_role.ddl_denied',
]) {
assert.ok(readinessSource.includes(gateId), `database readiness must include ${gateId}`);
}
assert.match(
readinessSource,
/current_user[\s\S]*session_user[\s\S]*DB_EXPECTED_RUNTIME_ROLE/i,
'database readiness must verify current_user and session_user against DB_EXPECTED_RUNTIME_ROLE',
);
assert.match(
readinessSource,
/has_database_privilege[\s\S]*has_schema_privilege[\s\S]*db\.runtime_role\.ddl_denied/i,
'database readiness must verify effective persistent DDL privileges without writing to production',
);
console.log('[PASS] production readiness check script');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkFiles(entryPath));
else files.push(entryPath);
}
return files;
}
export function hashArtifactDirectory(dir) {
const files = walkFiles(dir)
.map(filePath => ({
filePath,
relativePath: normalizeSlashes(path.relative(dir, filePath)),
}))
.sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en'));
const hash = crypto.createHash('sha256');
let totalBytes = 0;
for (const file of files) {
const content = fs.readFileSync(file.filePath);
totalBytes += content.length;
hash.update(file.relativePath, 'utf8');
hash.update('\0');
hash.update(String(content.length), 'utf8');
hash.update('\0');
hash.update(content);
hash.update('\0');
}
return {
sha256: hash.digest('hex'),
files: files.length,
totalBytes,
};
}

View File

@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { runRemoteTenantCorsSmoke } from './remote-tenant-cors-smoke.js';
const origins = {
active: 'https://active.gongxue100.test',
disabled: 'https://disabled.gongxue100.test',
unknown: 'https://unknown.gongxue100.test',
};
function json(res, status, payload, headers = {}) {
res.writeHead(status, { 'content-type': 'application/json', ...headers });
res.end(JSON.stringify(payload));
}
const server = http.createServer((req, res) => {
const origin = req.headers.origin || '';
if (req.url === '/health' && req.method === 'GET' && !origin) {
json(res, 200, { ok: true });
return;
}
if (req.method === 'OPTIONS' && req.url === '/api/tenant/resolve') {
if (origin === origins.active) {
res.writeHead(204, {
'access-control-allow-origin': origin,
'access-control-allow-methods': 'GET,POST,OPTIONS',
vary: 'Origin',
});
res.end();
return;
}
json(res, 403, { code: 'CORS_ORIGIN_DENIED' });
return;
}
json(res, 404, { code: 'NOT_FOUND' });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
const summary = await runRemoteTenantCorsSmoke(
{
apiBaseUrl: `http://127.0.0.1:${address.port}`,
activeOrigin: origins.active,
disabledOrigin: origins.disabled,
unknownOrigin: origins.unknown,
timeoutMs: 5_000,
},
{ quiet: true },
);
assert.deepEqual(summary, {
failed: 0,
activeTenantOriginAllowed: true,
unknownOriginDenied: true,
disabledOriginDenied: true,
noOriginHealthAllowed: true,
statuses: { active: 204, disabled: 403, unknown: 403, health: 200 },
});
console.log('[PASS] remote dynamic tenant CORS smoke script');
} finally {
await new Promise(resolve => server.close(resolve));
}

View File

@@ -0,0 +1,168 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const DEFAULT_TIMEOUT_MS = 10_000;
function envString(env, key, fallback = '') {
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
}
function envNumber(env, key, fallback) {
const value = Number(envString(env, key));
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback;
}
function normalizeBaseUrl(value) {
return value.replace(/\/+$/, '');
}
function normalizeOrigin(value, key) {
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${key} must be a valid URL origin`);
}
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
throw new Error(`${key} must contain only scheme, host and optional port`);
}
return parsed.origin;
}
function parseArgs(argv) {
const options = { json: argv.includes('--json'), quiet: argv.includes('--quiet'), writePath: '' };
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--write') {
options.writePath = argv[index + 1] || '';
index += 1;
} else if (arg.startsWith('--write=')) {
options.writePath = arg.slice('--write='.length);
}
}
return options;
}
function buildConfig(env = process.env) {
const apiBaseUrl = envString(env, 'TENANT_CORS_API_BASE_URL', envString(env, 'API_BASE', ''));
const activeOrigin = envString(env, 'TENANT_CORS_ACTIVE_ORIGIN');
const disabledOrigin = envString(env, 'TENANT_CORS_DISABLED_ORIGIN');
const unknownOrigin = envString(env, 'TENANT_CORS_UNKNOWN_ORIGIN');
const missing = [];
if (!apiBaseUrl) missing.push('TENANT_CORS_API_BASE_URL');
if (!activeOrigin) missing.push('TENANT_CORS_ACTIVE_ORIGIN');
if (!disabledOrigin) missing.push('TENANT_CORS_DISABLED_ORIGIN');
if (!unknownOrigin) missing.push('TENANT_CORS_UNKNOWN_ORIGIN');
if (missing.length > 0) throw new Error(`Missing required remote tenant CORS smoke env: ${missing.join(', ')}`);
return {
apiBaseUrl: normalizeBaseUrl(apiBaseUrl),
activeOrigin: normalizeOrigin(activeOrigin, 'TENANT_CORS_ACTIVE_ORIGIN'),
disabledOrigin: normalizeOrigin(disabledOrigin, 'TENANT_CORS_DISABLED_ORIGIN'),
unknownOrigin: normalizeOrigin(unknownOrigin, 'TENANT_CORS_UNKNOWN_ORIGIN'),
timeoutMs: envNumber(env, 'TENANT_CORS_TIMEOUT_MS', DEFAULT_TIMEOUT_MS),
};
}
async function request(config, { origin = '', method = 'OPTIONS', pathName = '/api/tenant/resolve' } = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
try {
const response = await fetch(new URL(pathName, config.apiBaseUrl), {
method,
headers: origin
? {
origin,
'access-control-request-method': 'GET',
'access-control-request-headers': 'content-type,x-tenant-code',
}
: {},
signal: controller.signal,
});
const text = await response.text();
let payload = {};
if (text.trim()) {
try { payload = JSON.parse(text); } catch { payload = { raw: text.slice(0, 300) }; }
}
return {
status: response.status,
allowOrigin: response.headers.get('access-control-allow-origin') || '',
vary: response.headers.get('vary') || '',
payload,
};
} finally {
clearTimeout(timeout);
}
}
function assert(condition, message, detail = {}) {
if (condition) return;
const error = new Error(message);
error.detail = detail;
throw error;
}
async function runRemoteTenantCorsSmoke(inputConfig, options = {}) {
const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env);
const active = await request(config, { origin: config.activeOrigin });
assert(active.status === 204, 'active tenant Origin preflight must return HTTP 204', active);
assert(active.allowOrigin === config.activeOrigin, 'active tenant Origin must be echoed in access-control-allow-origin', active);
assert(/(?:^|,)\s*origin\s*(?:,|$)/i.test(active.vary), 'active tenant Origin response must vary by Origin', active);
const disabled = await request(config, { origin: config.disabledOrigin });
assert(disabled.status === 403, 'disabled tenant Origin preflight must return HTTP 403', disabled);
assert(!disabled.allowOrigin, 'disabled tenant Origin must not receive access-control-allow-origin', disabled);
assert(disabled.payload?.code === 'CORS_ORIGIN_DENIED', 'disabled tenant Origin must fail with CORS_ORIGIN_DENIED', disabled);
const unknown = await request(config, { origin: config.unknownOrigin });
assert(unknown.status === 403, 'unknown tenant Origin preflight must return HTTP 403', unknown);
assert(!unknown.allowOrigin, 'unknown tenant Origin must not receive access-control-allow-origin', unknown);
assert(unknown.payload?.code === 'CORS_ORIGIN_DENIED', 'unknown tenant Origin must fail with CORS_ORIGIN_DENIED', unknown);
const health = await request(config, { method: 'GET', pathName: '/health' });
assert(health.status === 200, 'health request without Origin must remain available', health);
assert(health.payload?.ok === true, 'health request without Origin must return ok=true', health);
const summary = {
failed: 0,
activeTenantOriginAllowed: true,
unknownOriginDenied: true,
disabledOriginDenied: true,
noOriginHealthAllowed: true,
statuses: {
active: active.status,
disabled: disabled.status,
unknown: unknown.status,
health: health.status,
},
};
if (!options.quiet) console.log('[PASS] remote dynamic tenant CORS smoke');
return summary;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
try {
const summary = await runRemoteTenantCorsSmoke(buildConfig(), { quiet: options.quiet || options.json });
if (options.writePath) {
const resolved = path.resolve(process.cwd(), options.writePath);
fs.mkdirSync(path.dirname(resolved), { recursive: true });
fs.writeFileSync(resolved, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
}
if (options.json) console.log(JSON.stringify(summary, null, 2));
} catch (error) {
const failure = { failed: 1, error: error.message, detail: error.detail || undefined };
if (options.json) console.log(JSON.stringify(failure, null, 2));
else {
console.error(error.message);
if (error.detail) console.error(JSON.stringify(error.detail, null, 2));
}
process.exitCode = 1;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();
export { buildConfig, runRemoteTenantCorsSmoke };

View File

@@ -41,6 +41,7 @@ try {
[
'DATABASE_URL=postgresql://postgres:real-password@db.example.com:5432/postgres',
'SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake-service-role-token-that-should-not-ship',
'GITEA_TOKEN=0123456789abcdef0123456789abcdef01234567',
'',
].join('\n'),
'utf8',
@@ -52,6 +53,7 @@ try {
assert.ok(payload.findings.some(item => item.id === 'frontend-legacy-user-header'), 'x-user-id should be detected');
assert.ok(payload.findings.some(item => item.id === 'postgres-url'), 'database URL should be detected');
assert.ok(payload.findings.some(item => item.id === 'supabase-service-role'), 'service role key should be detected');
assert.ok(payload.findings.some(item => item.id === 'git-access-token'), 'Git access token should be detected');
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}

View File

@@ -74,6 +74,9 @@ const ruleAllowlistedFiles = {
'frontend-legacy-user-header': new Set([
'scripts/repo-security-scan-test.js',
]),
'git-access-token': new Set([
'scripts/repo-security-scan-test.js',
]),
};
const rules = [
@@ -113,6 +116,13 @@ const rules = [
pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/,
message: 'Provider secret keys must not be committed.',
},
{
id: 'git-access-token',
severity: 'critical',
pattern: /\b(?:GITEA_TOKEN|GIT_TOKEN)\s*[:=]\s*["']?([A-Za-z0-9_-]{32,})/i,
message: 'Git access tokens must stay in server-side secret storage.',
validate: (match) => !/^(?:replace|example|your|rotated|placeholder)/i.test(String(match[1] || '')),
},
{
id: 'cloud-access-key',
severity: 'critical',

View File

@@ -1,13 +1,19 @@
import pg from 'pg';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const { Pool } = pg;
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const destructiveTestConfirmation = resolveDestructiveTestConfirmation();
const ids = {
mainTenant: '00000000-0000-0000-0000-000000000001',
partnerTenant: '00000000-0000-0000-0000-000000000901',
mainUser: '00000000-0000-0000-0000-000000000101',
normalAuthUser: '00000000-0000-0000-0000-00000000a101',
platformAdminAuthUser: '00000000-0000-0000-0000-00000000a999',
partnerAdminUser: '00000000-0000-0000-0000-000000000907',
};
@@ -15,74 +21,62 @@ const readChecks = [
{
name: 'tenant_branding',
sql: 'select tenant_id::text as tenant_id, brand_name as label from public.tenant_branding order by tenant_id',
expectMain: true,
expectPartner: true,
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_settings',
sql: 'select tenant_id::text as tenant_id, public_config::text as label from public.tenant_settings order by tenant_id',
expectMain: true,
expectPartner: true,
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_domains',
sql: 'select tenant_id::text as tenant_id, host as label from public.tenant_domains order by tenant_id',
expectMain: true,
expectPartner: true,
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'tenant_memberships',
sql: 'select tenant_id::text as tenant_id, role as label from public.tenant_memberships order by tenant_id, role',
expectMain: true,
expectPartner: true,
requiredTenantIds: [ids.mainTenant, ids.partnerTenant],
},
{
name: 'regions',
sql: 'select tenant_id::text as tenant_id, name as label from public.regions order by tenant_id',
expectMain: true,
expectPartner: true,
requiredTenantIds: [ids.mainTenant],
},
{
name: 'questions',
sql: 'select tenant_id::text as tenant_id, legacy_id as label from public.questions order by tenant_id, id',
expectMain: true,
expectPartner: false,
requiredTenantIds: [ids.mainTenant],
},
{
name: 'student_profiles',
sql: 'select tenant_id::text as tenant_id, user_id::text as label from public.student_profiles order by tenant_id, user_id',
expectMain: true,
expectPartner: false,
requiredTenantIds: [ids.mainTenant],
},
{
name: 'orders',
sql: 'select tenant_id::text as tenant_id, order_no as label from public.orders order by tenant_id',
expectMain: true,
expectPartner: false,
requiredTenantIds: [ids.mainTenant],
},
{
name: 'content_assets',
sql: 'select tenant_id::text as tenant_id, asset_key as label from public.content_assets order by tenant_id',
expectMain: true,
expectPartner: false,
requiredTenantIds: [ids.mainTenant],
},
{
name: 'tenant_subscriptions',
sql: 'select tenant_id::text as tenant_id, plan_code as label from public.tenant_subscriptions order by tenant_id',
expectMain: false,
expectPartner: true,
requiredTenantIds: [ids.partnerTenant],
},
{
name: 'tenant_invoices',
sql: 'select tenant_id::text as tenant_id, invoice_no as label from public.tenant_invoices order by tenant_id',
expectMain: false,
expectPartner: true,
requiredTenantIds: [ids.partnerTenant],
},
{
name: 'tenant_usage_records',
sql: 'select tenant_id::text as tenant_id, metric_key as label from public.tenant_usage_records order by tenant_id, metric_key',
expectMain: false,
expectPartner: true,
requiredTenantIds: [ids.partnerTenant],
},
];
@@ -159,14 +153,27 @@ function onlyTenantRows(rows, tenantId) {
return rows.every(row => row.tenant_id === tenantId);
}
function hasTenantRows(rows, tenantId) {
return rows.some(row => row.tenant_id === tenantId);
function tenantRows(rows, tenantId) {
return rows.filter(row => row.tenant_id === tenantId);
}
async function withRlsContext(client, { dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' }, action) {
function canonicalRows(rows) {
return rows.map(row => JSON.stringify(row)).sort();
}
function sameRows(actual, expected) {
return JSON.stringify(canonicalRows(actual)) === JSON.stringify(canonicalRows(expected));
}
async function withRlsContext(
client,
{ dbRole = 'authenticated', tenantId = '', roleClaim = 'authenticated', sub = '' },
action,
extraGrantStatements = [],
) {
await client.query('begin');
try {
for (const statement of probeGrantStatements) await client.query(statement);
for (const statement of [...probeGrantStatements, ...extraGrantStatements]) await client.query(statement);
await client.query(`set local role ${dbRole}`);
if (tenantId) {
await client.query("select set_config('request.jwt.claim.tenant_id', $1, true)", [tenantId]);
@@ -193,8 +200,19 @@ async function queryAs(client, context, sql, params = []) {
});
}
async function runReadIsolationChecks(client) {
async function captureReadBaselines(client) {
const baselines = new Map();
for (const check of readChecks) {
const result = await client.query(check.sql);
baselines.set(check.name, result.rows);
}
return baselines;
}
async function runReadIsolationChecks(client, baselines) {
for (const check of readChecks) {
const baselineRows = baselines.get(check.name) || [];
const expectedMainRows = tenantRows(baselineRows, ids.mainTenant);
const mainRows = await queryAs(client, { tenantId: ids.mainTenant }, check.sql);
assert(
onlyTenantRows(mainRows, ids.mainTenant),
@@ -203,12 +221,13 @@ async function runReadIsolationChecks(client) {
{ rows: mainRows },
);
assert(
hasTenantRows(mainRows, ids.mainTenant) === check.expectMain,
sameRows(mainRows, expectedMainRows),
`rls.read.${check.name}.main_expected_seed`,
'主租户 seed 数据存在性不符合预期',
{ expected: check.expectMain, rows: mainRows },
'主租户上下文应返回基线快照中该租户的完整数据子集',
{ expectedRows: expectedMainRows, rows: mainRows },
);
const expectedPartnerRows = tenantRows(baselineRows, ids.partnerTenant);
const partnerRows = await queryAs(client, { tenantId: ids.partnerTenant }, check.sql);
assert(
onlyTenantRows(partnerRows, ids.partnerTenant),
@@ -217,10 +236,10 @@ async function runReadIsolationChecks(client) {
{ rows: partnerRows },
);
assert(
hasTenantRows(partnerRows, ids.partnerTenant) === check.expectPartner,
sameRows(partnerRows, expectedPartnerRows),
`rls.read.${check.name}.partner_expected_seed`,
'伙伴租户 seed 数据存在性不符合预期',
{ expected: check.expectPartner, rows: partnerRows },
'伙伴租户上下文应返回基线快照中该租户的完整数据子集',
{ expectedRows: expectedPartnerRows, rows: partnerRows },
);
const anonymousRows = await queryAs(client, { dbRole: 'anon', tenantId: '', roleClaim: 'anon' }, check.sql);
@@ -233,20 +252,114 @@ async function runReadIsolationChecks(client) {
}
}
async function runPlatformAdminChecks(client) {
async function runPlatformAdminChecks(client, baselines) {
for (const check of readChecks) {
const rows = await queryAs(client, { tenantId: '', roleClaim: 'platform_admin', sub: ids.mainUser }, check.sql);
const hasMain = hasTenantRows(rows, ids.mainTenant);
const hasPartner = hasTenantRows(rows, ids.partnerTenant);
const rows = await queryAs(
client,
{ tenantId: '', roleClaim: 'platform_admin', sub: ids.platformAdminAuthUser },
check.sql,
);
const baselineRows = baselines.get(check.name) || [];
assert(
hasMain === check.expectMain && hasPartner === check.expectPartner,
sameRows(rows, baselineRows),
`rls.platform_admin.${check.name}.cross_tenant_visibility`,
'平台管理员 RLS 旁路应只暴露当前表已有的多租户 seed 数据',
{ expectedMain: check.expectMain, expectedPartner: check.expectPartner, rows },
'平台管理员 RLS 旁路应返回当前表的完整基线快照',
{ expectedRows: baselineRows, rows },
);
}
}
async function runForgedPlatformAdminChecks(client, baselines) {
for (const check of readChecks) {
const rows = await queryAs(
client,
{
tenantId: ids.mainTenant,
roleClaim: 'platform_admin',
sub: ids.normalAuthUser,
},
check.sql,
);
assert(
onlyTenantRows(rows, ids.mainTenant),
`rls.forged_platform_admin.${check.name}.no_cross_tenant_leak`,
'伪造 platform_admin claim 的普通用户不应获得跨租户可见性',
{ rows },
);
const expectedMainRows = tenantRows(baselines.get(check.name) || [], ids.mainTenant);
assert(
sameRows(rows, expectedMainRows),
`rls.forged_platform_admin.${check.name}.tenant_scope_preserved`,
'伪造 platform_admin claim 后仍应按普通租户上下文执行 RLS',
{ expectedRows: expectedMainRows, rows },
);
}
}
async function runPlatformPrivilegeEscalationCheck(client) {
const result = await withRlsContext(
client,
{
tenantId: ids.mainTenant,
roleClaim: 'platform_admin',
sub: ids.normalAuthUser,
},
async () => {
const before = await client.query(
`
select auth_user_id::text, primary_role, status, platform_permissions
from public.platform_users
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
const update = await client.query(
`
update public.platform_users
set primary_role = 'platform_admin',
status = 'disabled',
platform_permissions = '{"*":true}'::jsonb
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
const after = await client.query(
`
select auth_user_id::text, primary_role, status, platform_permissions
from public.platform_users
where auth_user_id = $1::uuid
`,
[ids.normalAuthUser],
);
return { before: before.rows, updateRowCount: update.rowCount, after: after.rows };
},
['grant select, update on public.platform_users to authenticated'],
);
assert(
result.before.length === 1,
'rls.platform_users.normal_user_self_read_probe',
'权限提升探针需要能读取普通用户自身记录',
result,
);
assert(
result.updateRowCount === 0,
'rls.platform_users.self_privilege_escalation_blocked',
'即使误授 authenticated UPDATERLS 也不应允许用户将自身提升为平台超管',
result,
);
assert(
result.after.length === 1
&& result.after[0].primary_role === result.before[0].primary_role
&& result.after[0].status === result.before[0].status
&& JSON.stringify(result.after[0].platform_permissions) === JSON.stringify(result.before[0].platform_permissions),
'rls.platform_users.sensitive_fields_unchanged',
'普通用户的角色、状态和平台权限字段不应被客户端修改',
result,
);
}
async function runWriteIsolationChecks(client) {
for (const check of writeChecks) {
try {
@@ -273,7 +386,364 @@ async function runWriteIsolationChecks(client) {
}
}
async function verifySeed(client) {
async function runQuestionVersionIntegrityChecks(client) {
const constraints = await client.query(
`
select conname, convalidated
from pg_constraint
where conrelid in ('public.questions'::regclass, 'public.question_versions'::regclass)
and conname = any($1::text[])
`,
[[
'questions_tenant_id_id_key',
'question_versions_tenant_question_id_id_key',
'question_versions_tenant_question_fkey',
'questions_tenant_current_version_fkey',
]],
);
const constraintState = new Map(
constraints.rows.map(row => [row.conname, row.convalidated === true]),
);
for (const name of [
'questions_tenant_id_id_key',
'question_versions_tenant_question_id_id_key',
'question_versions_tenant_question_fkey',
'questions_tenant_current_version_fkey',
]) {
assert(
constraintState.get(name) === true,
`schema.question_versions.${name}.validated`,
'Question version tenant integrity constraints must exist and be validated',
{ constraints: constraints.rows },
);
}
await client.query('begin');
try {
const questionA = await client.query(
`
insert into public.questions (tenant_id, legacy_id, type, status)
values ($1, $2, 'choice', 'draft')
returning id
`,
[ids.mainTenant, `question-integrity-a-${Date.now()}`],
);
const questionB = await client.query(
`
insert into public.questions (tenant_id, legacy_id, type, status)
values ($1, $2, 'choice', 'draft')
returning id
`,
[ids.mainTenant, `question-integrity-b-${Date.now()}`],
);
const versionA = await client.query(
`
insert into public.question_versions (tenant_id, question_id, version_no, content)
values ($1, $2, 1, 'question integrity probe')
returning id
`,
[ids.mainTenant, questionA.rows[0].id],
);
await client.query('savepoint cross_tenant_version');
try {
await client.query(
`
insert into public.question_versions (tenant_id, question_id, version_no, content)
values ($1, $2, 2, 'must be rejected')
`,
[ids.partnerTenant, questionA.rows[0].id],
);
fail(
'schema.question_versions.cross_tenant_parent_rejected',
'A question version must not reference a question from another tenant',
);
} catch (error) {
assert(
error.code === '23503',
'schema.question_versions.cross_tenant_parent_rejected',
'Cross-tenant question version insertion must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint cross_tenant_version');
}
await client.query('savepoint cross_question_pointer');
try {
await client.query(
'update public.questions set current_version_id = $1 where id = $2',
[versionA.rows[0].id, questionB.rows[0].id],
);
fail(
'schema.questions.cross_question_current_version_rejected',
'A question must not point at another question\'s current version',
);
} catch (error) {
assert(
error.code === '23503',
'schema.questions.cross_question_current_version_rejected',
'Cross-question current version assignment must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint cross_question_pointer');
}
const validPointer = await client.query(
'update public.questions set current_version_id = $1 where id = $2 returning id',
[versionA.rows[0].id, questionA.rows[0].id],
);
assert(
validPointer.rowCount === 1,
'schema.questions.same_question_current_version_allowed',
'A question must be able to reference its own version in the same tenant',
);
} finally {
await client.query('rollback').catch(() => {});
}
}
async function runCoreTenantForeignKeyIntegrityChecks(client) {
const constraintNames = [
'question_versions_tenant_id_id_key',
'practice_sessions_tenant_id_id_key',
'orders_tenant_id_id_key',
'content_assets_tenant_id_id_key',
'practice_sessions_tenant_user_id_key',
'answer_records_question_version_requires_question_check',
'answer_records_tenant_question_fkey',
'answer_records_tenant_question_version_pair_fkey',
'answer_records_tenant_user_session_fkey',
'favorite_questions_tenant_question_fkey',
'wrong_questions_tenant_question_fkey',
'payments_tenant_order_fkey',
'content_export_jobs_tenant_asset_fkey',
];
const constraints = await client.query(
`
select conname, convalidated
from pg_constraint
where conname = any($1::text[])
`,
[constraintNames],
);
const constraintState = new Map(
constraints.rows.map(row => [row.conname, row.convalidated === true]),
);
for (const name of constraintNames) {
assert(
constraintState.get(name) === true,
`schema.core_tenant_foreign_keys.${name}.validated`,
'Core tenant foreign key constraints must exist and be validated',
{ constraints: constraints.rows },
);
}
await client.query('begin');
try {
const parent = await client.query(
`
select
version.question_id as "questionId",
version.id as "versionId",
session.id as "sessionId",
session.user_id as "sessionUserId",
(select o.id from public.orders o where o.tenant_id = $1 order by o.id limit 1) as "orderId",
(select a.id from public.content_assets a where a.tenant_id = $1 order by a.id limit 1) as "assetId"
from lateral (
select v.id, v.question_id
from public.question_versions v
where v.tenant_id = $1
order by v.id
limit 1
) version
cross join lateral (
select s.id, s.user_id
from public.practice_sessions s
where s.tenant_id = $1
order by s.id
limit 1
) session
`,
[ids.mainTenant],
);
const references = parent.rows[0];
assert(
Object.values(references || {}).every(Boolean),
'schema.core_tenant_foreign_keys.parent_fixtures_available',
'Core tenant foreign key probes require main-tenant parent fixtures',
{ references },
);
const probes = [
{
name: 'answer_question',
sql: `insert into public.answer_records (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'answer_question_version',
sql: `insert into public.answer_records (tenant_id, user_id, question_id, question_version_id)
values ($1, $2, $3, $4)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId, references.versionId],
},
{
name: 'answer_practice_session',
sql: `insert into public.answer_records (tenant_id, user_id, practice_session_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.sessionId],
},
{
name: 'favorite_question',
sql: `insert into public.favorite_questions (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'wrong_question',
sql: `insert into public.wrong_questions (tenant_id, user_id, question_id)
values ($1, $2, $3)`,
params: [ids.partnerTenant, ids.partnerAdminUser, references.questionId],
},
{
name: 'payment_order',
sql: `insert into public.payments (tenant_id, order_id, provider, amount_cents)
values ($1, $2, 'rls-integrity-probe', 1)`,
params: [ids.partnerTenant, references.orderId],
},
{
name: 'export_asset',
sql: `insert into public.content_export_jobs (
tenant_id, export_type, format, scope_type, scope_id, asset_id
) values ($1, 'questions', 'json', 'entry', gen_random_uuid(), $2)`,
params: [ids.partnerTenant, references.assetId],
},
];
for (const probe of probes) {
const savepoint = `core_tenant_fk_${probe.name}`;
await client.query(`savepoint ${savepoint}`);
try {
await client.query(probe.sql, probe.params);
fail(
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
'Cross-tenant parent references must be rejected',
);
} catch (error) {
assert(
error.code === '23503',
`schema.core_tenant_foreign_keys.${probe.name}.cross_tenant_rejected`,
'Cross-tenant parent references must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query(`rollback to savepoint ${savepoint}`);
}
}
const validAnswer = await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id, practice_session_id
)
values ($1, $2, $3, $4, $5)
returning id
`,
[ids.mainTenant, references.sessionUserId, references.questionId, references.versionId, references.sessionId],
);
assert(
validAnswer.rowCount === 1,
'schema.core_tenant_foreign_keys.same_tenant_answer_allowed',
'A same-tenant answer record must remain writable after composite foreign keys',
);
const differentQuestion = await client.query(
`
select id
from public.questions
where tenant_id = $1 and id <> $2
order by id
limit 1
`,
[ids.mainTenant, references.questionId],
);
assert(
Boolean(differentQuestion.rows[0]?.id),
'schema.core_tenant_foreign_keys.different_question_fixture_available',
'Answer version-question integrity probe requires a second question',
);
await client.query('savepoint answer_version_question_pair');
try {
await client.query(
`
insert into public.answer_records (
tenant_id, user_id, question_id, question_version_id
) values ($1, $2, $3, $4)
`,
[ids.mainTenant, '00000000-0000-0000-0000-000000000101', differentQuestion.rows[0].id, references.versionId],
);
fail(
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
'An answer must not combine a question with another question\'s version',
);
} catch (error) {
assert(
error.code === '23503',
'schema.core_tenant_foreign_keys.answer_version_question_pair_rejected',
'Mismatched answer question/version pairs must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint answer_version_question_pair');
}
const alternateUser = await client.query(
`
select user_id as "userId"
from public.tenant_memberships
where tenant_id = $1 and user_id <> $2
order by user_id
limit 1
`,
[ids.mainTenant, references.sessionUserId],
);
assert(
Boolean(alternateUser.rows[0]?.userId),
'schema.core_tenant_foreign_keys.alternate_session_user_fixture_available',
'Answer session-user integrity probe requires another tenant user',
);
await client.query('savepoint answer_session_user_pair');
try {
await client.query(
`
insert into public.answer_records (
tenant_id, user_id, practice_session_id
) values ($1, $2, $3)
`,
[ids.mainTenant, alternateUser.rows[0].userId, references.sessionId],
);
fail(
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
'An answer must not reference another user\'s practice session',
);
} catch (error) {
assert(
error.code === '23503',
'schema.core_tenant_foreign_keys.answer_session_user_pair_rejected',
'Mismatched answer user/session pairs must fail with a foreign key violation',
{ code: error.code, message: error.message },
);
} finally {
await client.query('rollback to savepoint answer_session_user_pair');
}
} finally {
await client.query('rollback').catch(() => {});
}
}
async function verifySeed(client, baselines) {
const result = await client.query(
`
select tenant_id::text, count(*)::int as count
@@ -290,15 +760,38 @@ async function verifySeed(client) {
'需要先运行 npm run db:smoke-seed确保主租户和伙伴租户 seed 都存在',
{ rows: result.rows },
);
for (const check of readChecks) {
const rows = baselines.get(check.name) || [];
for (const tenantId of check.requiredTenantIds) {
const fixtures = tenantRows(rows, tenantId);
assert(
fixtures.length > 0,
`rls.seed.${check.name}.${tenantId === ids.mainTenant ? 'main' : 'partner'}_fixture`,
'RLS 深测表必须有明确的非空租户夹具,避免空快照假通过',
{ tenantId, rows },
);
}
}
}
async function main() {
const client = await pool.connect();
try {
await verifySeed(client);
await runReadIsolationChecks(client);
await runPlatformAdminChecks(client);
await assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation: destructiveTestConfirmation,
operation: 'RLS tenant isolation test',
});
const baselines = await captureReadBaselines(client);
await verifySeed(client, baselines);
await runReadIsolationChecks(client, baselines);
await runPlatformAdminChecks(client, baselines);
await runForgedPlatformAdminChecks(client, baselines);
await runPlatformPrivilegeEscalationCheck(client);
await runWriteIsolationChecks(client);
await runQuestionVersionIntegrityChecks(client);
await runCoreTenantForeignKeyIntegrityChecks(client);
} finally {
client.release();
await pool.end();

View File

@@ -1,9 +1,14 @@
import pg from 'pg';
import {
assertDestructiveTestDatabase,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
const { Pool } = pg;
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const tenantId = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const confirmation = resolveDestructiveTestConfirmation();
const ids = {
authUser: '00000000-0000-0000-0000-00000000a101',
@@ -98,8 +103,16 @@ const pool = new Pool({ connectionString: databaseUrl });
async function main() {
const client = await pool.connect();
let transactionStarted = false;
try {
await assertDestructiveTestDatabase({
client,
databaseUrl,
confirmation,
operation: 'smoke seed',
});
await client.query('begin');
transactionStarted = true;
await client.query(
`
@@ -109,6 +122,40 @@ async function main() {
`,
);
// A previous integration run may leave a tenant SMS provider active.
// Reset the smoke tenant so local tests never call an external SMS API.
await client.query(
`
update public.tenant_auth_providers
set status = 'disabled',
updated_at = now()
where tenant_id = $1
and provider in (
'aliyun-pnvs', 'aliyun_pnvs', 'aliyun-pnvs-sms', 'aliyun_sms_auth', 'aliyun-sms-auth',
'aliyun', 'aliyun-sms', 'aliyun_sms',
'tencent', 'tencent-sms', 'tencent_sms'
)
`,
[tenantId],
);
await client.query(
`
insert into public.tenant_auth_providers (
tenant_id, provider, status, display_name, config_public
)
values (
$1, 'mock', 'testing', '本地模拟短信', '{"channel":"local-dev"}'::jsonb
)
on conflict (tenant_id, provider)
do update set status = 'testing',
display_name = excluded.display_name,
config_public = excluded.config_public,
updated_at = now()
`,
[tenantId],
);
await client.query(
`
insert into smoke_seed_transient_questions (id)
@@ -2358,9 +2405,12 @@ async function main() {
);
await client.query('commit');
transactionStarted = false;
console.log(`Smoke seed complete for tenant ${tenantId}`);
} catch (error) {
await client.query('rollback');
if (transactionStarted) {
await client.query('rollback').catch(() => undefined);
}
throw error;
} finally {
client.release();

View File

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const migration = fs.readFileSync('supabase/migrations/202607120012_sms_send_reservation_limits.sql', 'utf8');
const routes = fs.readFileSync('apps/api/src/features/auth/routes.ts', 'utf8');
const limits = fs.readFileSync('apps/api/src/features/auth/sms-limits.ts', 'utf8');
const nginx = fs.readFileSync('scripts/deploy/nginx/tjszsb.com.conf.example', 'utf8');
const taroAuth = fs.readFileSync('apps/taro/src/services/auth.ts', 'utf8');
assert.match(migration, /unique index[^;]+idx_sms_codes_active_phone_reservation[\s\S]+status in \('pending', 'sent'\)/i);
assert.match(migration, /app_private\.sms_send_rate_limits/);
assert.match(migration, /primary key \(tenant_id, dimension, scope_hash, bucket_start\)/);
assert.match(limits, /pg_advisory_xact_lock/);
assert.match(limits, /SMS_TENANT_DAILY_LIMIT/);
assert.match(limits, /SMS_PHONE_DAILY_LIMIT/);
assert.match(limits, /SMS_IP_HOURLY_LIMIT/);
assert.match(limits, /SMS_DEVICE_HOURLY_LIMIT/);
assert.match(limits, /createHmac\('sha256', config\.authCodePepper\)/);
assert.match(routes, /reserveSmsSend/);
assert.ok(routes.indexOf('reserveSmsSend') < routes.indexOf('provider.send'), 'SMS quota must be reserved before provider cost is incurred');
assert.match(nginx, /limit_req_zone \$binary_remote_addr zone=tiku_sms_send/);
assert.match(nginx, /location = \/api\/auth\/sms\/send/);
assert.match(nginx, /limit_req zone=tiku_sms_send/);
assert.match(nginx, /proxy_set_header X-Forwarded-For \$remote_addr;/);
assert.match(taroAuth, /deviceId: smsDeviceId\(\)/);
console.log('[PASS] SMS atomic quota and proxy rate-limit contract');

View File

@@ -2,6 +2,9 @@ import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
process.env.TARO_ENV = 'h5';
process.env.TARO_APP_SUPABASE_URL = 'https://auth.example.test';
process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_test';
const authModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/services/api-auth.ts`).href);
authModule.setSupabaseAccessTokenProviderForTest(async () => 'supabase_access_token');
@@ -14,6 +17,16 @@ assert.equal(
'supabase_access_token',
);
assert.equal(
await authModule.resolveApiAuthorization({
hasTokenOverride: false,
legacyToken: 'tk_legacy_token',
legacySource: 'app_session',
}),
'tk_legacy_token',
'An explicitly active app session must remain usable for H5 SMS login',
);
assert.equal(
await authModule.resolveApiAuthorization({
authMode: 'none',
@@ -47,10 +60,21 @@ assert.equal(
await authModule.resolveApiAuthorization({
hasTokenOverride: false,
legacyToken: 'tk_legacy_token',
legacySource: 'app_session',
}),
'tk_legacy_token',
);
assert.equal(
await authModule.resolveApiAuthorization({
hasTokenOverride: false,
legacyToken: 'tk_stale_token',
legacySource: 'supabase_jwt',
}),
null,
'H5 with Supabase configured must not silently fall back to a stale non-active legacy session',
);
assert.equal(
await authModule.resolveApiAuthorization({
authMode: 'supabase',

View File

@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
const httpSource = read('apps/api/src/core/http.ts');
const serverSource = read('apps/api/src/server.ts');
const apiSource = read('apps/taro/src/services/api.ts');
const typesSource = read('apps/taro/src/types.ts');
const tenantLocatorSource = read('apps/api/src/features/tenant/locator.ts');
const tenantResolutionSource = read('apps/taro/src/app/tenant-resolution.ts');
const studentRouteSource = read('apps/api/src/features/tenant-admin/classes.ts');
const studentCursorSource = read('apps/api/src/features/tenant-admin/student-cursor.ts');
assert.match(httpSource, /meta:\s*\{ \.\.\.existingMeta, requestId \}/, 'API responses must expose requestId without discarding endpoint metadata');
assert.match(serverSource, /sendJson\(res, 200, withResponseMeta\(result, requestId\)\)/, 'Successful API responses must carry requestId metadata');
assert.match(serverSource, /withResponseMeta\(\{ \.\.\.body, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope');
assert.match(apiSource, /responseHeaderValue\(response\.header, 'x-request-id'\)/, 'The Taro API client must fall back to the response header requestId');
assert.match(apiSource, /this\.requestId = payload\.requestId/, 'ApiError must retain requestId for support and observability');
assert.doesNotMatch(typesSource, /\[key:\s*string\]:\s*unknown/, 'The shared API envelope must not silently accept arbitrary response fields');
assert.match(typesSource, /interface ApiResponseMeta[\s\S]*requestId:\s*string/, 'The Taro response envelope must type requestId metadata');
assert.match(tenantLocatorSource, /TENANT_HOST_CONFLICT/, 'Tenant host conflicts must fail closed');
assert.match(tenantLocatorSource, /TENANT_LOCATOR_REQUIRED/, 'Tenant resolution must reject a missing locator');
assert.match(tenantResolutionSource, /tenantCode:\s*!host \|\| isLocalRuntimeHost\(host\)/, 'Production H5 host resolution must suppress tenantCode overrides');
assert.ok(
studentRouteSource.includes('(tm.created_at, tm.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)'),
'Deep student pages must use a composite keyset cursor',
);
assert.ok(
studentRouteSource.includes('order by tm.created_at desc, tm.id desc')
&& studentRouteSource.includes('limit $${params.length}'),
'Student pagination must keep stable ordering and a bounded limit',
);
assert.match(studentRouteSource, /const hasMore = rows\.length > limit/, 'List responses must derive hasMore from a limit+1 query');
assert.match(studentRouteSource, /nextCursor = hasMore && lastItem/, 'List responses must only issue a next cursor when another page exists');
assert.match(studentCursorSource, /parsed\.version !== 1/, 'Opaque cursors must be versioned and fail closed');
const statusContracts = [
['order', "('pending', 'paid', 'failed', 'closed', 'refunded')", read('supabase/migrations/202606210001_core_multitenant_schema.sql')],
['refund', "('requested', 'approved', 'processing', 'succeeded', 'failed', 'rejected', 'cancelled')", read('supabase/migrations/202606290011_commerce_refunds.sql')],
['content import', "('preview', 'pending', 'importing', 'completed', 'completed_with_errors', 'failed', 'rejected')", read('supabase/migrations/202606210007_content_import_assets.sql')],
['CRM queue', "('pending', 'processing', 'retrying', 'sent', 'failed', 'discarded')", read('supabase/migrations/202606290010_crm_worker_hardening.sql')],
['commission settlement', "('draft', 'pending_review', 'approved', 'paid', 'rejected', 'cancelled')", read('supabase/migrations/202606290009_commission_settlements.sql')],
];
for (const [name, values, source] of statusContracts) {
assert.ok(source.includes(values), `${name} state values are part of the frontend compatibility boundary`);
}
console.log('[PASS] Taro API response, tenant resolution, pagination and state-machine compatibility contract');

View File

@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
function moduleUrl(relativePath) {
return pathToFileURL(path.join(taroSrc, relativePath)).href;
}
function readSource(relativePath) {
return fs.readFileSync(path.join(taroSrc, relativePath), 'utf8').replace(/\r\n/g, '\n');
}
const storageScope = await import(moduleUrl('app/storage-scope.ts'));
const tenantA = { portal: 'student', host: 'a.example.com' };
const tenantB = { portal: 'student', host: 'b.example.com' };
assert.notEqual(storageScope.tenantContextStorageKey(tenantA), storageScope.tenantContextStorageKey(tenantB));
assert.notEqual(
storageScope.tenantContextStorageKey(tenantA),
storageScope.tenantContextStorageKey({ ...tenantA, portal: 'tenant-admin' }),
);
assert.notEqual(storageScope.sessionStorageKey(tenantA, 'tenant-a'), storageScope.sessionStorageKey(tenantA, 'tenant-b'));
assert.notEqual(
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'),
storageScope.tenantDataStorageKey(tenantA, 'tenant-b', 'user-a', 'practice'),
);
assert.notEqual(
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice'),
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-b', 'practice'),
);
assert.ok(
storageScope.tenantDataStorageKey(tenantA, 'tenant-a', 'user-a', 'practice')
.startsWith(storageScope.userDataStoragePrefix(tenantA, 'tenant-a', 'user-a')),
);
assert.match(storageScope.sessionStorageKey(tenantA, 'tenant-a'), /^tiku:v2:/);
const tenantLaunch = await import(moduleUrl('app/tenant-launch.ts'));
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: 'school-a' } }), 'school-a');
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { scene: 'tenantCode%3Dschool-b' } }), 'school-b');
assert.equal(tenantLaunch.tenantCodeFromLaunch({ referrerExtraData: { tenant: 'school-c' } }), 'school-c');
assert.equal(tenantLaunch.tenantCodeFromLaunch({ query: { tenantCode: '../unsafe' } }), '');
const tenantResolution = await import(moduleUrl('app/tenant-resolution.ts'));
assert.deepEqual(
tenantResolution.tenantResolveQuery({ host: 'school.example.com:443', tenantCode: 'compiled-tenant' }),
{ host: 'school.example.com:443', tenantCode: undefined },
'A browser host must suppress a compiled tenantCode override',
);
assert.deepEqual(
tenantResolution.tenantResolveQuery({ host: 'localhost:5173', tenantCode: 'school-a' }),
{ host: 'localhost:5173', tenantCode: 'school-a' },
'Local H5 development may select an explicit tenant code',
);
assert.deepEqual(
tenantResolution.tenantResolveQuery({ host: '', tenantCode: 'school-a' }),
{ host: undefined, tenantCode: 'school-a' },
'Hostless WeApp resolution must retain its tenant code',
);
const routePath = await import(moduleUrl('app/route-path.ts'));
assert.equal(routePath.normalizePagePath('/pages/student/login/index'), '/pages/student/login/index');
assert.equal(routePath.normalizePagePath('/pages/student/login'), '/pages/student/login/index');
assert.equal(routePath.normalizePagePath('/pages/student/login/'), '/pages/student/login/index');
assert.equal(routePath.normalizePagePath('#!/pages/bootstrap'), '/pages/bootstrap/index');
assert.equal(routePath.normalizePagePath('/health'), '/health');
assert.equal(
routePath.safePageRedirectPath('/pages/student/home', 'student', '/pages/student/home/index'),
'/pages/student/home/index',
'clean student redirects must resolve to the registered Taro page',
);
assert.equal(
routePath.safePageRedirectPath('/pages/student/practice?mode=mock', 'student', '/pages/student/home/index'),
'/pages/student/practice/index?mode=mock',
'safe redirect normalization must preserve local query parameters',
);
assert.equal(
routePath.safePageRedirectPath('/pages/student/login', 'student', '/pages/student/home/index'),
'/pages/student/home/index',
'clean login redirects must not loop back to the login page',
);
assert.equal(
routePath.safePageRedirectPath('/pages/student/home', 'tenant-admin', '/pages/tenant-admin/workbench/index'),
'/pages/tenant-admin/workbench/index',
'redirects must stay inside the compiled portal',
);
const permissions = await import(moduleUrl('app/permissions.ts'));
const tenantAccess = {
role: 'tenant_operator',
permissions: { 'students:read': false, '*': true },
templatePermissions: {},
effectivePermissions: {},
menuPermissions: { students: false, content: true },
modulePermissions: {},
fieldPermissions: {},
dataScope: {},
roleDefaults: { tenant_operator: ['content:*'] },
};
assert.equal(permissions.hasTenantPermission(tenantAccess, 'students:read'), false);
assert.equal(permissions.hasTenantPermission(tenantAccess, 'content:write'), true);
assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'students', permission: 'students:read' }), false);
assert.equal(permissions.hasTenantMenuAccess(tenantAccess, { menuKey: 'content', permission: 'content:read' }), true);
assert.equal(permissions.hasPlatformPermission({ permissions: { '*': true }, effectivePermissions: {} }, 'platform:tenant:read'), true);
const theme = await import(moduleUrl('theme/tokens.ts'));
const resolvedTheme = theme.resolveTheme({
logoUrl: 'https://cdn.example.com/fallback.png',
theme: {
primaryColor: '#123456',
accentColor: 'url(javascript:alert(1))',
borderRadius: 99,
buttonRadius: 7,
customCssVars: {
'--tiku-focus-ring': '#123abc',
'--tiku-unsafe': 'url(https://tracker.example.com/pixel.png)',
'--other-product': '#ffffff',
},
},
publicAssets: {
logoUrl: 'https://cdn.example.com/logo.png',
shareImageUrl: 'https://cdn.example.com/share.png',
},
});
assert.equal(resolvedTheme.tokens.primary, '#123456');
assert.equal(resolvedTheme.tokens.accent, theme.defaultThemeTokens.accent);
assert.equal(resolvedTheme.tokens.radius, '32px');
assert.equal(resolvedTheme.tokens.radiusSmall, '7px');
assert.deepEqual(resolvedTheme.customCssVars, { '--tiku-focus-ring': '#123abc' });
assert.equal(resolvedTheme.assets.logoUrl, 'https://cdn.example.com/logo.png');
assert.equal(theme.themeCssVariables(resolvedTheme.tokens)['--tiku-primary'], '#123456');
const sessionEvents = await import(moduleUrl('app/session-events.ts'));
const reasons = [];
const unsubscribe = sessionEvents.subscribeSessionChanges(reason => reasons.push(reason));
sessionEvents.emitSessionChange('cleared');
sessionEvents.emitSessionChange('cleared');
sessionEvents.emitSessionChange('saved');
sessionEvents.emitSessionChange('cleared');
unsubscribe();
assert.deepEqual(reasons, ['cleared', 'saved', 'cleared']);
assert.match(readSource('app/session-events.ts'), /addEventListener\('storage'/);
assert.match(readSource('app/session-events.ts'), /h5SessionEventKey/);
const appSource = readSource('app.tsx');
assert.match(appSource, /<AppProvider path=\{path\}>/);
assert.match(appSource, /<ThemeProvider>/);
assert.match(appSource, /className=\{routeReady \? '' : 'route-guard-hidden'\}/);
assert.match(appSource, /\{content\}/, 'Taro page content must stay mounted while the route guard overlay is visible');
assert.doesNotMatch(appSource, /routeReady \? content : null/, 'Route readiness must not remove the Taro page instance');
assert.match(appSource, /useRouter\(true\)/, 'App must react to WeApp and subpackage route changes');
assert.match(appSource, /normalizePagePath\(router\.path/);
assert.match(appSource, /applyWeappLaunchTenant\(\)/);
assert.match(appSource, /identityKey/);
assert.match(appSource, /key=\{identityKey\}/);
assert.doesNotMatch(appSource, /katex\/dist\/katex\.min\.css/, 'KaTeX CSS must not stay in the global app entry');
assert.match(readSource('components/RichContent.tsx'), /katex-platform\.css/);
assert.match(readSource('components/katex-platform.h5.css'), /katex\/dist\/katex\.min\.css/);
assert.doesNotMatch(readSource('components/katex-platform.css'), /katex\/dist\/katex\.min\.css/);
const navigationSource = readSource('capabilities/navigation.ts');
assert.match(navigationSource, /taroWeappTenantMode\(\) !== 'launch'/, 'Fixed WeApp builds must ignore launch tenant overrides');
assert.match(navigationSource, /appEnv\.tenantCode = ''/, 'Launch mode must clear any compiled tenant fallback before parsing launch data');
assert.match(navigationSource, /tenantCodeFromLaunch/, 'Launch mode must parse query, scene, and referrer tenant data');
const themeProviderSource = readSource('theme/ThemeProvider.tsx');
assert.match(themeProviderSource, /data-tiku-theme-managed/);
assert.match(themeProviderSource, /updateManagedMeta\([^\n]+assets\.shareImageUrl\)/);
assert.match(themeProviderSource, /updateManagedFavicon\(assets\.faviconUrl\)/);
assert.match(themeProviderSource, /originalHrefAttribute/);
assert.match(themeProviderSource, /managedCustomCssVars/);
assert.match(themeProviderSource, /removeProperty\(key\)/);
assert.match(themeProviderSource, /\.\.\.resolved\.customCssVars/);
const loginSource = readSource('pages/student/login/index.tsx');
assert.match(loginSource, /bootstrapStatus === 'forbidden' \? bootstrapError : ''/);
assert.doesNotMatch(loginSource, /bootstrapStatus === 'unauthenticated' \? bootstrapError/, '401 must not be presented as a permission failure');
const tenantSettingsSource = readSource('pages/tenant-admin/settings/index.tsx');
assert.match(tenantSettingsSource, /await publishTenantTheme/);
assert.match(tenantSettingsSource, /await refreshTenant\(\)/, 'published branding must refresh the active ThemeProvider');
for (const shellPath of ['components/AdminLegacyShell.tsx', 'components/StudentLegacyShell.tsx']) {
const source = readSource(shellPath);
assert.match(source, /useApp\(\)/, `${shellPath} must consume AppProvider state`);
assert.doesNotMatch(source, /Taro\.(?:navigateTo|redirectTo|reLaunch)/, `${shellPath} must use navigation capability`);
}
const apiSource = readSource('services/api.ts');
assert.match(apiSource, /currentTenantContextStorageKey/);
assert.match(apiSource, /currentSessionStorageKey/);
assert.match(apiSource, /expiresAt <= Date\.now\(\)/);
assert.match(apiSource, /emitSessionChange\('expired'\)/);
assert.match(apiSource, /signOut\(\{ scope: 'local' \}\)/);
assert.match(apiSource, /clearActiveStorageUserData\(tenant\.tenantId\)/);
assert.match(apiSource, /tenantResolveQuery/, 'Tenant resolution requests must use the host-authority contract');
assert.match(apiSource, /TENANT_DOMAIN_NOT_BOUND/, 'An unbound domain must clear stale tenant context');
const appProviderSource = readSource('app/AppProvider.tsx');
assert.match(appProviderSource, /appEnv\.portal === 'platform-admin'\s*\? null/, 'Platform portal must not require a business tenant at bootstrap');
assert.match(appProviderSource, /appEnv\.portal !== 'platform-admin' && !tenant/, 'Only tenant-scoped portals may resolve a business tenant');
assert.match(appProviderSource, /H5 租户由当前域名确定/, 'H5 tenant switching must not override the authoritative host');
assert.match(apiSource, /clearSession\(options: \{ emit\?: boolean \} = \{\}\)/);
assert.match(apiSource, /rejectedToken.*currentToken/s);
const storageCapabilitySource = readSource('capabilities/storage.ts');
assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\)/);
assert.match(storageCapabilitySource, /getActiveStorageUserId\(tenantId\) \|\| 'anonymous'/);
assert.match(storageCapabilitySource, /removeStorageByPrefix\(userDataStoragePrefix/);
assert.match(storageCapabilitySource, /previousUserId !== userId/);
assert.match(storageCapabilitySource, /legacyTenantDataStoragePrefix/);
assert.match(storageCapabilitySource, /tiku:practice:/);
assert.match(readSource('services/storage.ts'), /getActiveStorageUserId\(scope\.tenantId\) === scope\.userId/);
assert.match(readSource('app/AppProvider.tsx'), /activateStorageUser\(tenant\.tenantId, currentUser\.id\)/);
assert.match(readSource('app/AppProvider.tsx'), /event === 'SIGNED_IN'\) clearSession\(\{ emit: false \}\)/);
const authSource = readSource('services/auth.ts');
assert.match(authSource, /clearSession\(\{ emit: false \}\)/);
assert.match(authSource, /emitSessionChange\('cleared'\)/);
assert.match(authSource, /source: 'app_session'/);
assert.match(readSource('app/AppProvider.tsx'), /payload\.session\.source !== 'app_session'/);
for (const pagePath of ['pages/student/practice/index.tsx', 'pages/student/vocabulary/index.tsx']) {
const source = readSource(pagePath);
assert.match(source, /createUserStorage/);
assert.match(source, /currentUser\?\.id/);
}
const routeGuardSource = readSource('services/routeGuard.ts');
assert.match(routeGuardSource, /normalizePagePath.*@\/app\/route-path/);
assert.match(routeGuardSource, /export \{ normalizePagePath \}/);
assert.match(routeGuardSource, /safePageRedirectPath\(path, appEnv\.portal, landingPath\(\)\)/);
assert.match(readSource('app/route-path.ts'), /path\.indexOf\('pages\/'\)/, 'route normalization must preserve student subpackage paths');
for (const pagePath of [
'pages/platform-admin/workbench/index.tsx',
'pages/tenant-admin/content/index.tsx',
'pages/tenant-admin/marketing/index.tsx',
'pages/student/ai-school/index.tsx',
'pages/student/checkout/index.tsx',
'pages/student/order-detail/index.tsx',
]) {
const source = readSource(pagePath);
assert.doesNotMatch(source, /document\.createElement|window\.location/, `${pagePath} must use a cross-platform capability`);
}
const paymentAdapter = readSource('capabilities/payment.ts');
assert.match(paymentAdapter, /isWeappRuntime\(\)/);
assert.match(paymentAdapter, /Taro\.requestPayment/);
console.log('[PASS] Taro app foundation contracts');

View File

@@ -0,0 +1,275 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
const taroRoot = path.join(repoRoot, 'apps', 'taro');
const configPath = path.join(taroRoot, 'config', 'index.ts');
const appConfigPath = path.join(taroRoot, 'src', 'app.config.ts');
const projectConfigPath = path.join(taroRoot, 'project.config.json');
const rootPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const taroPackage = JSON.parse(fs.readFileSync(path.join(taroRoot, 'package.json'), 'utf8'));
const bootstrapSource = fs.readFileSync(path.join(taroRoot, 'src', 'pages', 'bootstrap', 'index.tsx'), 'utf8');
const h5InteractionSmokeSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'taro-h5-interaction-smoke.js'), 'utf8');
const h5RuntimePatchCheck = 'node ../../scripts/taro-components-h5-runtime-patch.js --check';
const weappBuildModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'build-weapp-student.js')).href);
const weappGuardModule = await import(pathToFileURL(path.join(repoRoot, 'scripts', 'taro-weapp-release-guardrails.js')).href);
const matrix = [
{ id: 'h5.student', taroEnv: 'h5', portal: 'student', releaseMode: 'production', outputRoot: 'dist/h5-student', appScript: 'build:h5:student', rootScript: 'build:taro:h5:student' },
{ id: 'h5.tenant', taroEnv: 'h5', portal: 'tenant-admin', releaseMode: 'production', outputRoot: 'dist/h5-tenant-admin', appScript: 'build:h5:tenant', rootScript: 'build:taro:h5:tenant' },
{ id: 'h5.platform', taroEnv: 'h5', portal: 'platform-admin', releaseMode: 'production', outputRoot: 'dist/h5-platform-admin', appScript: 'build:h5:platform', rootScript: 'build:taro:h5:platform' },
{ id: 'weapp.student', taroEnv: 'weapp', portal: 'student', releaseMode: 'preview', outputRoot: 'dist/weapp-student', appScript: 'build:weapp:student', rootScript: 'build:taro:weapp:student' },
];
async function importForBuild(filePath, build) {
process.env.TARO_ENV = build.taroEnv;
process.env.TARO_APP_PORTAL = build.portal;
process.env.TARO_APP_RELEASE_MODE = build.releaseMode || 'preview';
if (build.apiBaseUrl === undefined) delete process.env.TARO_APP_API_BASE_URL;
else process.env.TARO_APP_API_BASE_URL = build.apiBaseUrl;
if (build.tenantCode === undefined) delete process.env.TARO_APP_TENANT_CODE;
else process.env.TARO_APP_TENANT_CODE = build.tenantCode;
if (build.weappTenantMode === undefined) delete process.env.TARO_APP_WEAPP_TENANT_MODE;
else process.env.TARO_APP_WEAPP_TENANT_MODE = build.weappTenantMode;
globalThis.defineAppConfig = value => value;
const moduleUrl = pathToFileURL(filePath);
moduleUrl.searchParams.set('matrix', build.id);
return (await import(moduleUrl.href)).default;
}
const originalPortal = process.env.TARO_APP_PORTAL;
const originalTaroEnv = process.env.TARO_ENV;
const originalReleaseMode = process.env.TARO_APP_RELEASE_MODE;
const originalApiBaseUrl = process.env.TARO_APP_API_BASE_URL;
const originalTenantCode = process.env.TARO_APP_TENANT_CODE;
const originalWeappTenantMode = process.env.TARO_APP_WEAPP_TENANT_MODE;
const observedOutputRoots = [];
for (const build of matrix) {
const config = await importForBuild(configPath, build);
const appConfig = await importForBuild(appConfigPath, build);
assert.equal(config.outputRoot, build.outputRoot, `${build.id} must write to ${build.outputRoot}`);
if (build.taroEnv === 'h5') {
assert.notEqual(config.h5?.useDeprecatedAdapterComponent, true, `${build.id} must keep the reviewed modern Taro component adapter`);
assert.equal(config.h5?.devServer?.host, '127.0.0.1', `${build.id} development server must bind to loopback`);
assert.deepEqual(
config.h5?.devServer?.allowedHosts,
['localhost', '127.0.0.1'],
`${build.id} development server must reject untrusted Host headers`,
);
}
const publicBuildConfig = JSON.parse(config.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
assert.equal(publicBuildConfig.portal, build.portal, `${build.id} must compile its portal into the public build config`);
assert.equal(publicBuildConfig.target, build.taroEnv, `${build.id} must compile its target into the public build config`);
assert.equal(publicBuildConfig.releaseMode, build.releaseMode, `${build.id} must compile its release mode into the public build config`);
assert.equal(publicBuildConfig.weappTenantMode, build.taroEnv === 'weapp' ? 'launch' : '', `${build.id} must compile its WeApp tenant mode`);
if (build.releaseMode === 'production') {
assert.doesNotMatch(JSON.stringify(publicBuildConfig), /(?:127\.0\.0\.1|localhost)/i, `${build.id} must not compile a local API fallback`);
}
observedOutputRoots.push(config.outputRoot);
const appScript = taroPackage.scripts?.[build.appScript] || '';
const rootScript = rootPackage.scripts?.[build.rootScript] || '';
if (build.taroEnv === 'weapp') {
assert.ok(appScript.includes('build-weapp-student.js'), `${build.appScript} must use the guarded WeApp build wrapper`);
} else {
assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${build.appScript} must verify the reviewed Taro H5 runtime patches`);
assert.ok(appScript.includes(`TARO_ENV=${build.taroEnv}`), `${build.appScript} must pin TARO_ENV=${build.taroEnv}`);
assert.ok(appScript.includes(`TARO_APP_PORTAL=${build.portal}`), `${build.appScript} must pin TARO_APP_PORTAL=${build.portal}`);
assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=production'), `${build.appScript} must fail closed on missing production runtime config`);
assert.ok(appScript.includes(`--type ${build.taroEnv}`), `${build.appScript} must build the ${build.taroEnv} target`);
}
assert.ok(rootScript.includes('@tiku-saas/taro') && rootScript.includes(build.appScript), `${build.rootScript} must delegate to the Taro workspace script`);
if (build.taroEnv === 'h5') {
assert.equal(appConfig.subPackages, undefined, `${build.id} must not emit mini-program subpackages`);
} else {
assert.deepEqual(appConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must stay minimal');
assert.equal(appConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp must keep all business pages in the student subpackage');
assert.equal(appConfig.lazyCodeLoading, 'requiredComponents', 'Student WeApp must enable required-component lazy loading');
}
}
assert.equal(new Set(observedOutputRoots).size, matrix.length, 'Every platform/portal build must have an isolated output directory');
assert.ok(!taroPackage.scripts?.['build:weapp:tenant'], 'Tenant admin is H5-only and must not expose a WeApp build');
assert.ok(!taroPackage.scripts?.['build:weapp:platform'], 'Platform admin is H5-only and must not expose a WeApp build');
assert.ok(rootPackage.scripts?.['build:taro:h5:preview'], 'Root package must expose the three-portal H5 preview build');
assert.equal(
taroPackage.scripts?.postinstall,
'node ../../scripts/taro-components-h5-runtime-patch.js --apply',
'Taro workspace installation must apply the reviewed H5 runtime patches',
);
assert.ok(taroPackage.scripts?.['dev:h5']?.startsWith(`${h5RuntimePatchCheck} && `), 'H5 development must verify the runtime patches');
for (const item of [
['student', 'student'],
['tenant', 'tenant-admin'],
['platform', 'platform-admin'],
]) {
const [scriptSuffix, portal] = item;
const appScriptName = `build:h5:${scriptSuffix}:preview`;
const rootScriptName = `build:taro:h5:${scriptSuffix}:preview`;
const appScript = taroPackage.scripts?.[appScriptName] || '';
const rootScript = rootPackage.scripts?.[rootScriptName] || '';
assert.ok(appScript.startsWith(`${h5RuntimePatchCheck} && `), `${appScriptName} must verify the reviewed Taro H5 runtime patches`);
assert.ok(appScript.includes(`TARO_APP_PORTAL=${portal}`), `${appScriptName} must pin TARO_APP_PORTAL=${portal}`);
assert.ok(appScript.includes('TARO_APP_RELEASE_MODE=preview'), `${appScriptName} must compile preview mode`);
assert.ok(rootScript.includes(appScriptName), `${rootScriptName} must delegate to ${appScriptName}`);
}
assert.match(h5InteractionSmokeSource, /assertServeOnlyPreviewBuild/);
assert.match(h5InteractionSmokeSource, /not a preview build/);
assert.match(h5InteractionSmokeSource, /assertRuntimeHealthy/);
assert.match(h5InteractionSmokeSource, /Runtime\.exceptionThrown/);
assert.match(h5InteractionSmokeSource, /runCrossPortalRuntimeProbe/);
assert.match(h5InteractionSmokeSource, /runTaroInputWatcherRaceProbe/);
assert.match(h5InteractionSmokeSource, /runTaroButtonLoadingRaceProbe/);
for (const portal of ['tenant-admin', 'platform-admin']) {
await assert.rejects(
() => importForBuild(appConfigPath, { id: `unsupported.weapp.${portal}`, taroEnv: 'weapp', portal }),
/H5-only/,
`${portal} WeApp configuration must fail fast instead of producing an unsupported admin mini-program`,
);
}
const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf8'));
assert.equal(projectConfig.miniprogramRoot, 'dist/weapp-student/', 'WeChat developer tools must open the isolated student WeApp output');
assert.ok(
bootstrapSource.includes('redirectToLogin') && bootstrapSource.includes('if (!authorized)'),
'Student WeApp bootstrap page must route unauthenticated users into the login page inside the student subpackage',
);
assert.ok(taroPackage.scripts?.['build:weapp:student:production']?.includes('--production'), 'Production WeApp build must use strict public config validation');
assert.ok(rootPackage.scripts?.['build:taro:weapp:student:production'], 'Root package must expose the production WeApp build');
const productionWeappConfig = await importForBuild(configPath, {
id: 'weapp.student.production',
taroEnv: 'weapp',
portal: 'student',
releaseMode: 'production',
apiBaseUrl: 'https://api.gongxue100.com',
tenantCode: 'campus-north',
weappTenantMode: 'fixed',
});
const productionPublicConfig = JSON.parse(productionWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
assert.deepEqual(
productionPublicConfig,
{
portal: 'student',
target: 'weapp',
releaseMode: 'production',
weappTenantMode: 'fixed',
apiBaseUrl: 'https://api.gongxue100.com',
supabaseUrl: '',
supabasePublishableKey: '',
tenantCode: 'campus-north',
},
'Production WeApp public config must be fully resolved at compile time',
);
assert.doesNotMatch(JSON.stringify(productionPublicConfig), /(?:127\.0\.0\.1|localhost)/i, 'Production WeApp compile constants must not contain local fallbacks');
const launchWeappConfig = await importForBuild(configPath, {
id: 'weapp.student.launch',
taroEnv: 'weapp',
portal: 'student',
releaseMode: 'production',
apiBaseUrl: 'https://api.gongxue100.com',
tenantCode: 'inherited-tenant',
weappTenantMode: 'launch',
});
const launchPublicConfig = JSON.parse(launchWeappConfig.defineConstants?.__TARO_PUBLIC_BUILD_CONFIG__ || '{}');
assert.equal(launchPublicConfig.weappTenantMode, 'launch');
assert.equal(launchPublicConfig.tenantCode, '', 'Launch mode must clear an inherited compile-time tenant code');
assert.deepEqual(
weappBuildModule.resolveWeappBuildConfig({
TARO_APP_API_BASE_URL: 'https://api.gongxue100.com/',
WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70',
TARO_APP_WEAPP_TENANT_MODE: 'fixed',
TARO_APP_TENANT_CODE: 'campus-north',
}, { production: true }),
{
production: true,
tenantMode: 'fixed',
tenantCode: 'campus-north',
apiBaseUrl: 'https://api.gongxue100.com',
appId: 'wx6f3a9c2d4e8b1a70',
},
);
assert.equal(weappBuildModule.resolveWeappTenantMode({}, false), 'launch');
assert.equal(weappBuildModule.resolveWeappTenantMode({ TARO_APP_TENANT_CODE: 'campus-north' }, false), 'fixed');
assert.throws(
() => weappBuildModule.resolveWeappBuildConfig({
TARO_APP_API_BASE_URL: 'https://api.gongxue100.com',
WECHAT_MINIAPP_APP_ID: 'wx6f3a9c2d4e8b1a70',
TARO_APP_WEAPP_TENANT_MODE: 'launch',
TARO_APP_TENANT_CODE: 'inherited-tenant',
}, { production: true }),
/must be empty.*launch/,
);
for (const apiBaseUrl of [
'http://api.gongxue100.com',
'https://localhost:8787',
'https://127.0.0.1:8787',
'https://[::1]:8787',
'https://api.example',
'https://api.example.test',
'https://api.internal.local',
]) {
assert.throws(() => weappBuildModule.validateProductionApiBaseUrl(apiBaseUrl), /TARO_APP_API_BASE_URL/);
}
for (const appId of ['touristappid', 'wx0000000000000000', 'wx0123456789abcdef']) {
assert.throws(() => weappBuildModule.validateProductionWechatAppId(appId), /WECHAT_MINIAPP_APP_ID/);
}
for (const tenantCode of ['tenant-production', 'replace-with-tenant-code', 'example', 'test', 'demo', 'smoke', 'placeholder', 'changeme']) {
assert.throws(() => weappBuildModule.validateProductionTenantCode(tenantCode), /placeholder tenant code/);
}
const guardFixture = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-weapp-guard-'));
try {
fs.mkdirSync(path.join(guardFixture, 'pages', 'student'), { recursive: true });
fs.writeFileSync(path.join(guardFixture, 'project.config.json'), JSON.stringify({
appid: 'wx6f3a9c2d4e8b1a70',
setting: { urlCheck: true },
}));
fs.writeFileSync(path.join(guardFixture, 'app.json'), JSON.stringify({
pages: ['pages/bootstrap/index'],
subPackages: [{ root: 'pages/student', pages: ['home/index'] }],
}));
fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify(productionPublicConfig)};`);
fs.writeFileSync(path.join(guardFixture, 'pages', 'student', 'home.js'), 'module.exports = {};');
const releaseChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true });
assert.equal(releaseChecks.some(check => check.status === 'fail'), false, JSON.stringify(releaseChecks));
fs.writeFileSync(path.join(guardFixture, 'common.js'), `const config=${JSON.stringify({
...productionPublicConfig,
weappTenantMode: 'launch',
tenantCode: 'compiled-fallback',
})};`);
const unsafeLaunchChecks = weappGuardModule.inspectWeappRelease({ distRoot: guardFixture, requireProduction: true });
assert.ok(unsafeLaunchChecks.some(check => check.id === 'weapp.public_config.tenant_code' && check.status === 'fail'));
} finally {
fs.rmSync(guardFixture, { recursive: true, force: true });
}
for (const invalid of [
{ id: 'invalid.portal', taroEnv: 'h5', portal: 'studnet' },
{ id: 'invalid.target', taroEnv: '../h5', portal: 'student' },
]) {
await assert.rejects(() => importForBuild(configPath, invalid), /Unsupported Taro/, `${invalid.id} must fail fast`);
}
for (const [key, value] of [
['TARO_APP_PORTAL', originalPortal],
['TARO_ENV', originalTaroEnv],
['TARO_APP_RELEASE_MODE', originalReleaseMode],
['TARO_APP_API_BASE_URL', originalApiBaseUrl],
['TARO_APP_TENANT_CODE', originalTenantCode],
['TARO_APP_WEAPP_TENANT_MODE', originalWeappTenantMode],
]) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
console.log(`[PASS] Taro build matrix contract (${matrix.map(item => `${item.id}=${item.outputRoot}`).join(', ')})`);

View File

@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
enforceTaroH5RuntimePatches,
replaceExactlyOnce,
taroButtonLoadingPatch,
taroH5RuntimePatchDefinition,
taroInputWatcherPatch,
} from './taro-components-h5-runtime-patch.js';
const repoRoot = process.cwd();
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function installedPristineSource(patch) {
const target = path.join(repoRoot, 'node_modules', '@tarojs', 'components', patch.targetRelativePath);
const installed = fs.readFileSync(target, 'utf8');
const pristine = installed.includes(patch.after) ? installed.replace(patch.after, patch.before) : installed;
return pristine;
}
const pristineSources = new Map([
[taroInputWatcherPatch.id, installedPristineSource(taroInputWatcherPatch)],
[taroButtonLoadingPatch.id, installedPristineSource(taroButtonLoadingPatch)],
]);
function createFixture(options = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-taro-h5-runtime-patch-'));
const taroManifest = {
name: '@tiku-saas/taro',
version: '0.1.0',
scripts: { postinstall: taroH5RuntimePatchDefinition.postinstallCommand },
devDependencies: {
'@tarojs/components': options.declaredVersion || taroH5RuntimePatchDefinition.packageVersion,
},
};
writeJson(path.join(root, 'apps', 'taro', 'package.json'), taroManifest);
writeJson(path.join(root, 'package-lock.json'), {
lockfileVersion: 3,
packages: {
'apps/taro': {
hasInstallScript: true,
devDependencies: {
'@tarojs/components': options.lockWorkspaceVersion || taroH5RuntimePatchDefinition.packageVersion,
},
},
'node_modules/@tarojs/components': {
version: options.lockVersion || taroH5RuntimePatchDefinition.packageVersion,
integrity: options.integrity || taroH5RuntimePatchDefinition.lockIntegrity,
},
},
});
writeJson(path.join(root, 'node_modules', '@tarojs', 'components', 'package.json'), {
name: '@tarojs/components',
version: options.installedVersion || taroH5RuntimePatchDefinition.packageVersion,
});
const targets = new Map();
for (const patch of taroH5RuntimePatchDefinition.patches) {
const target = path.join(root, 'node_modules', '@tarojs', 'components', patch.targetRelativePath);
targets.set(patch.id, target);
if (options.missingTarget !== patch.id) {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(
target,
options.sources?.[patch.id] ?? pristineSources.get(patch.id),
'utf8',
);
}
}
return { root, targets };
}
function targetContents(fixture) {
return Object.fromEntries([...fixture.targets].map(([id, target]) => [
id,
fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : null,
]));
}
const workingState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
assert.equal(workingState.status, 'pass');
assert.equal(workingState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256);
assert.equal(workingState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256);
const applied = createFixture();
try {
const first = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' });
assert.equal(first.patches.inputWatcher.state, 'patched-now');
assert.equal(first.patches.buttonLoading.state, 'patched-now');
const second = enforceTaroH5RuntimePatches({ root: applied.root, mode: 'apply' });
assert.equal(second.patches.inputWatcher.state, 'patched');
assert.equal(second.patches.buttonLoading.state, 'patched');
assert.equal(enforceTaroH5RuntimePatches({ root: applied.root, mode: 'check' }).status, 'pass');
} finally {
fs.rmSync(applied.root, { recursive: true, force: true });
}
const pristineCheck = createFixture();
try {
const before = targetContents(pristineCheck);
assert.throws(
() => enforceTaroH5RuntimePatches({ root: pristineCheck.root, mode: 'check' }),
/patch is not applied/,
);
assert.deepEqual(targetContents(pristineCheck), before, '--check must never modify a pristine install');
} finally {
fs.rmSync(pristineCheck.root, { recursive: true, force: true });
}
for (const [name, options, pattern] of [
['declared version', { declaredVersion: '4.2.1' }, /must pin/],
['lock integrity', { integrity: 'sha512-unreviewed' }, /unexpected.*integrity/],
['installed version', { installedVersion: '4.2.1-beta.2' }, /does not match/],
['missing Input target', { missingTarget: taroInputWatcherPatch.id }, /target is missing/],
['missing Button target', { missingTarget: taroButtonLoadingPatch.id }, /target is missing/],
['unknown Input content', { sources: { [taroInputWatcherPatch.id]: `${pristineSources.get(taroInputWatcherPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/],
['unknown Button content', { sources: { [taroButtonLoadingPatch.id]: `${pristineSources.get(taroButtonLoadingPatch.id)}\n// tampered\n` } }, /unreviewed.*hash/],
]) {
const fixture = createFixture(options);
try {
const before = targetContents(fixture);
assert.throws(() => enforceTaroH5RuntimePatches({ root: fixture.root, mode: 'apply' }), pattern, name);
assert.deepEqual(targetContents(fixture), before, `${name} failure must not modify either target`);
} finally {
fs.rmSync(fixture.root, { recursive: true, force: true });
}
}
assert.throws(() => replaceExactlyOnce('safe', 'unsafe', 'guarded'), /found 0/);
assert.throws(() => replaceExactlyOnce('unsafe unsafe', 'unsafe', 'guarded'), /found 2/);
assert.equal(replaceExactlyOnce('before unsafe after', 'unsafe', 'guarded'), 'before guarded after');
console.log('[PASS] Taro H5 runtime patch contract');

View File

@@ -0,0 +1,198 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const postinstallCommand = 'node ../../scripts/taro-components-h5-runtime-patch.js --apply';
const packageContract = Object.freeze({
packageName: '@tarojs/components',
packageVersion: '4.2.0',
lockIntegrity: 'sha512-SQIK5UxKfmkhV0MdhmC0KV6duSkBtF9C8sX3dF6afKX8DvULXP5dirH8Y4bJEL+Mt+dkhlXiBmuSk/C2KB7sVg==',
});
export const taroInputWatcherPatch = Object.freeze({
id: 'inputWatcher',
label: 'Input watcher',
targetRelativePath: 'dist/components/taro-input-core.js',
pristineSha256: '2483ffc5727959174e988c7171d7f7bb0a6300851cae1a13699d62a7d4769f95',
patchedSha256: '260bb8a07d66eaf3398904acb94a7c2cacabe4411b70a01fb0d03931fe95c499',
before: 'if (this.inputRef.value !== value) {',
after: 'if (this.inputRef && this.inputRef.value !== value) {',
});
export const taroButtonLoadingPatch = Object.freeze({
id: 'buttonLoading',
label: 'Button loading node',
targetRelativePath: 'dist/components/taro-button-core.js',
pristineSha256: 'de5dfab0fc4c68a388b996b059255c57cc1ec52891238e7aacea588e7a9dd63e',
patchedSha256: '428db74e51382c68bc10211ff7815d494b086de465fdef97ca09f5b7ab8368ea',
before: 'loading && h("i", { class: \'weui-loading\' })',
after: 'h("i", { class: \'weui-loading\', style: { display: loading ? \'inline-block\' : \'none\' } })',
});
export const taroH5RuntimePatchDefinition = Object.freeze({
...packageContract,
postinstallCommand,
patches: Object.freeze([taroInputWatcherPatch, taroButtonLoadingPatch]),
});
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
function occurrences(source, needle) {
return source.split(needle).length - 1;
}
export function replaceExactlyOnce(source, before, after, label = 'Taro H5 runtime patch') {
const count = occurrences(source, before);
assert(count === 1, `expected the ${label} target exactly once, found ${count}`);
return source.replace(before, after);
}
function validateRepositoryContract(root, definition) {
const taroManifestPath = path.join(root, 'apps', 'taro', 'package.json');
const lockPath = path.join(root, 'package-lock.json');
const taroManifest = readJson(taroManifestPath);
const lock = readJson(lockPath);
assert(
taroManifest.devDependencies?.[definition.packageName] === definition.packageVersion,
`apps/taro/package.json must pin ${definition.packageName}@${definition.packageVersion}`,
);
assert(
taroManifest.scripts?.postinstall === definition.postinstallCommand,
'apps/taro postinstall must apply the reviewed Taro H5 runtime patches',
);
assert(lock.packages?.['apps/taro']?.hasInstallScript === true, 'package-lock.json must record the Taro workspace install hook');
assert(
lock.packages?.['apps/taro']?.devDependencies?.[definition.packageName] === definition.packageVersion,
`package-lock.json must pin the Taro workspace to ${definition.packageName}@${definition.packageVersion}`,
);
const lockEntry = lock.packages?.[`node_modules/${definition.packageName}`];
assert(lockEntry?.version === definition.packageVersion, `package-lock.json must resolve ${definition.packageName}@${definition.packageVersion}`);
assert(lockEntry?.integrity === definition.lockIntegrity, `package-lock.json has an unexpected ${definition.packageName} integrity`);
return taroManifestPath;
}
function resolveInstalledPackage(root, definition, taroManifestPath) {
const requireFromTaro = createRequire(taroManifestPath);
const installedManifestPath = requireFromTaro.resolve(`${definition.packageName}/package.json`);
const installedManifest = readJson(installedManifestPath);
assert(
installedManifest.version === definition.packageVersion,
`installed ${definition.packageName}@${installedManifest.version} does not match the reviewed ${definition.packageVersion}`,
);
return path.dirname(installedManifestPath);
}
function validatePatchedText(source, patch) {
assert(occurrences(source, patch.before) === 0, `patched Taro ${patch.label} still contains the unsafe expression`);
assert(occurrences(source, patch.after) === 1, `patched Taro ${patch.label} guard is missing or duplicated`);
}
function planPatch(root, installedPackageRoot, patch, mode) {
const targetPath = path.join(installedPackageRoot, patch.targetRelativePath);
assert(fs.existsSync(targetPath), `Taro ${patch.label} target is missing: ${targetPath}`);
const source = fs.readFileSync(targetPath, 'utf8');
const installedSha256 = sha256(source);
let state = 'patched';
let finalSource = source;
if (installedSha256 === patch.pristineSha256) {
state = 'pristine';
assert(mode === 'apply', `reviewed Taro ${patch.label} patch is not applied; run npm install or the patch command`);
finalSource = replaceExactlyOnce(source, patch.before, patch.after, patch.label);
assert(sha256(finalSource) === patch.patchedSha256, `Taro ${patch.label} patch output hash is unexpected`);
validatePatchedText(finalSource, patch);
state = 'patched-now';
} else if (installedSha256 === patch.patchedSha256) {
validatePatchedText(source, patch);
} else {
throw new Error(
`unreviewed ${packageContract.packageName} ${patch.label} target hash ${installedSha256}; do not apply the patch to unknown package contents`,
);
}
const finalSha256 = sha256(finalSource);
assert(finalSha256 === patch.patchedSha256, `installed Taro ${patch.label} patch hash does not match the reviewed result`);
return {
id: patch.id,
state,
targetPath,
target: path.relative(root, targetPath).replace(/\\/g, '/'),
source,
finalSource,
pristineSha256: patch.pristineSha256,
patchedSha256: patch.patchedSha256,
installedSha256: finalSha256,
};
}
export function enforceTaroH5RuntimePatches({
root = scriptRoot,
mode = 'check',
definition = taroH5RuntimePatchDefinition,
} = {}) {
assert(mode === 'apply' || mode === 'check', 'mode must be apply or check');
const taroManifestPath = validateRepositoryContract(root, definition);
const installedPackageRoot = resolveInstalledPackage(root, definition, taroManifestPath);
// Validate every target before writing either file so an unknown package state fails atomically.
const plans = definition.patches.map(patch => planPatch(root, installedPackageRoot, patch, mode));
if (mode === 'apply') {
for (const plan of plans) {
if (plan.finalSource !== plan.source) fs.writeFileSync(plan.targetPath, plan.finalSource, 'utf8');
}
}
return {
schemaVersion: 1,
status: 'pass',
package: definition.packageName,
version: definition.packageVersion,
patches: Object.fromEntries(plans.map(({ id, state, target, pristineSha256, patchedSha256, installedSha256 }) => [
id,
{ state, target, pristineSha256, patchedSha256, installedSha256 },
])),
};
}
function main() {
const argv = process.argv.slice(2);
const apply = argv.includes('--apply');
const check = argv.includes('--check');
assert(apply !== check, 'pass exactly one of --apply or --check');
const result = enforceTaroH5RuntimePatches({ mode: apply ? 'apply' : 'check' });
if (argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
else {
const summary = Object.entries(result.patches)
.map(([id, patch]) => `${id}=${patch.state}:${patch.installedSha256}`)
.join(', ');
console.log(`[PASS] ${result.package}@${result.version} H5 runtime patches (${summary})`);
}
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
try {
main();
} catch (error) {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}

View File

@@ -1,14 +1,26 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import selfsigned from 'selfsigned';
const repoRoot = process.cwd();
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
const smokeApiHostname = 'api-smoke.gongxue100.com';
const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], {
days: 1,
keySize: 2048,
algorithm: 'sha256',
extensions: [{
name: 'subjectAltName',
altNames: [{ type: 2, value: smokeApiHostname }],
}],
});
const ids = {
tenant: '00000000-0000-4000-8000-000000000001',
@@ -63,9 +75,18 @@ function parseArgs(argv) {
return {
json: argv.includes('--json'),
keepBrowser: argv.includes('--keep-browser'),
serveOnly: argv.includes('--serve-only'),
};
}
function waitForShutdownSignal() {
return new Promise(resolve => {
const shutdown = signal => resolve(signal);
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
});
}
function shanghaiTimestampForFile(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
@@ -866,31 +887,48 @@ function mockApiPayload(pathname, method, query, body) {
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
}
async function createMockApiServer() {
async function createMockApiServer(options = {}) {
const requests = [];
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
let authenticated = true;
const serveOverHttp = options.serveOverHttp === true;
const requestHandler = async (req, res) => {
const url = new URL(req.url || '/', serveOverHttp ? 'http://127.0.0.1' : `https://${smokeApiHostname}`);
if (req.method === 'OPTIONS') {
jsonResponse(res, 204, {});
return;
}
const body = await requestBody(req);
requests.push({
const requestRecord = {
method: req.method || 'GET',
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
body,
});
status: 0,
};
requests.push(requestRecord);
try {
if (url.pathname === '/api/auth/me' && !authenticated) {
requestRecord.status = 401;
jsonResponse(res, 401, { error: 'Invalid or expired session', code: 'AUTH_SESSION_INVALID' });
return;
}
requestRecord.status = 200;
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
} catch (error) {
requestRecord.status = 500;
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
}
});
};
const server = serveOverHttp
? http.createServer(requestHandler)
: https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, requestHandler);
const port = await listen(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: serveOverHttp ? `http://127.0.0.1:${port}` : `https://${smokeApiHostname}:${port}`,
requests,
setAuthenticated(value) {
authenticated = Boolean(value);
},
close: () => closeServer(server),
};
}
@@ -902,7 +940,7 @@ async function createStaticServer(portal, apiBaseUrl) {
apiBaseUrl,
supabaseUrl: 'https://auth.example.test',
supabasePublishableKey: 'sb_publishable_mock_key_for_h5_interaction_smoke',
tenantCode: 'master',
tenantCode: '',
};
assertDistExists(portal);
@@ -913,6 +951,11 @@ async function createStaticServer(portal, apiBaseUrl) {
jsonResponse(res, 200, runtimeConfig);
return;
}
if (requestPath === '/favicon.ico') {
res.writeHead(204, { 'cache-control': 'public, max-age=86400' });
res.end();
return;
}
const filePath = resolveStaticPath(distDir, requestPath);
if (!filePath) {
@@ -956,6 +999,18 @@ function assertDistExists(portal) {
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
}
function assertServeOnlyPreviewBuild(portal) {
const distDir = path.join(distRoot, portal.dist);
const scripts = fs.readdirSync(path.join(distDir, 'js'))
.filter(fileName => fileName.endsWith('.js'))
.map(fileName => fs.readFileSync(path.join(distDir, 'js', fileName), 'utf8'));
if (!scripts.some(source => /["']releaseMode["']\s*:\s*["']preview["']/.test(source))) {
throw new Error(
`${relative(distDir)} is not a preview build. Run npm run build:taro:h5:preview before npm run serve:taro:h5:qa; rebuild all three production H5 artifacts after visual QA.`,
);
}
}
function listen(server) {
return new Promise((resolve, reject) => {
server.once('error', reject);
@@ -1018,6 +1073,8 @@ async function startBrowser(options) {
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-'));
const args = [
'--headless=new',
'--ignore-certificate-errors',
`--host-resolver-rules=MAP ${smokeApiHostname} 127.0.0.1`,
'--disable-gpu',
'--disable-dev-shm-usage',
'--no-first-run',
@@ -1044,8 +1101,10 @@ async function startBrowser(options) {
}
class CdpPage {
constructor(wsUrl) {
constructor(wsUrl, debugPort, targetId) {
this.wsUrl = wsUrl;
this.debugPort = debugPort;
this.targetId = targetId;
this.id = 1;
this.pending = new Map();
this.events = [];
@@ -1113,8 +1172,23 @@ class CdpPage {
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
}
close() {
this.ws.close();
async setViewport(width, height) {
await this.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
});
}
async close() {
try {
if (this.targetId) {
await fetch(`http://127.0.0.1:${this.debugPort}/json/close/${encodeURIComponent(this.targetId)}`);
}
} finally {
this.ws.close();
}
}
diagnosticEvents() {
@@ -1128,10 +1202,16 @@ class CdpPage {
].includes(event.method))
.map(event => {
if (event.method === 'Runtime.exceptionThrown') {
const details = event.params?.exceptionDetails || {};
return {
method: event.method,
text: event.params?.exceptionDetails?.text,
description: event.params?.exceptionDetails?.exception?.description,
timestamp: event.params?.timestamp,
text: details.text,
description: details.exception?.description,
url: details.url,
lineNumber: details.lineNumber,
columnNumber: details.columnNumber,
stackTrace: details.stackTrace,
};
}
if (event.method === 'Runtime.consoleAPICalled') {
@@ -1170,6 +1250,67 @@ class CdpPage {
.slice(-20);
}
runtimeFailures({ allowedHttp = [] } = {}) {
const allowedResponse = (status, url) => allowedHttp.some(rule => {
const statusMatches = rule.status === undefined || status === undefined || Number(rule.status) === Number(status);
const urlMatches = !rule.path || String(url || '').includes(rule.path);
return statusMatches && urlMatches;
});
const failures = [];
const requestUrls = new Map(
this.events
.filter(event => event.method === 'Network.requestWillBeSent')
.map(event => [event.params?.requestId, event.params?.request?.url]),
);
for (const event of this.events) {
if (event.method === 'Runtime.exceptionThrown') {
const details = event.params?.exceptionDetails || {};
failures.push({
method: event.method,
timestamp: event.params?.timestamp,
text: details.text,
description: details.exception?.description,
url: details.url,
lineNumber: details.lineNumber,
columnNumber: details.columnNumber,
stackTrace: details.stackTrace,
});
continue;
}
if (event.method === 'Runtime.consoleAPICalled' && ['error', 'assert'].includes(event.params?.type)) {
failures.push({
method: event.method,
type: event.params?.type,
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 8),
});
continue;
}
if (event.method === 'Log.entryAdded' && event.params?.entry?.level === 'error') {
const entry = event.params.entry;
if (!allowedResponse(undefined, entry.url)) {
failures.push({ method: event.method, level: entry.level, text: entry.text, url: entry.url });
}
continue;
}
if (event.method === 'Network.loadingFailed') {
const failure = event.params || {};
const url = requestUrls.get(failure.requestId) || '';
if (failure.canceled || failure.errorText === 'net::ERR_ABORTED') continue;
if (allowedResponse(undefined, url)) continue;
failures.push({ method: event.method, errorText: failure.errorText, type: failure.type, url });
continue;
}
if (event.method === 'Network.responseReceived') {
const response = event.params?.response || {};
if (response.status >= 400 && !allowedResponse(response.status, response.url)) {
failures.push({ method: event.method, status: response.status, url: response.url });
}
}
}
return failures.slice(-30);
}
async acceptDialogs() {
const events = this.events.filter(event => event.method === 'Page.javascriptDialogOpening');
this.events = this.events.filter(event => event.method !== 'Page.javascriptDialogOpening');
@@ -1180,21 +1321,38 @@ class CdpPage {
}
async function newPage(browser, url) {
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent('about:blank')}`, { method: 'PUT' });
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
const target = await response.json();
return new CdpPage(target.webSocketDebuggerUrl).connect();
const page = await new CdpPage(target.webSocketDebuggerUrl, browser.debugPort, target.id).connect();
if (url) await page.navigate(url);
return page;
}
function assertRuntimeHealthy(page, label, options = {}) {
const failures = page.runtimeFailures(options);
if (failures.length) {
throw new Error(`${label} emitted browser runtime errors:\n${JSON.stringify(failures, null, 2)}`);
}
}
async function waitUntil(label, fn, timeoutMs = 10_000) {
const started = Date.now();
let lastValue;
let lastError = '';
while (Date.now() - started < timeoutMs) {
lastValue = await fn().catch(error => ({ error: error.message }));
try {
lastValue = await fn();
lastError = '';
} catch (error) {
lastValue = undefined;
lastError = error instanceof Error ? error.message : String(error);
}
if (lastValue) return lastValue;
await delay(200);
}
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`);
const errorDetail = lastError ? ` Last error: ${lastError}` : '';
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}.${errorDetail}`);
}
async function bodyText(page) {
@@ -1226,10 +1384,18 @@ async function assertNoText(page, text) {
}
async function waitForPath(page, pathPart, timeoutMs = 10_000) {
await waitUntil(`path "${pathPart}"`, async () => {
const pathValue = await currentPath(page);
return pathValue.includes(pathPart);
}, timeoutMs);
try {
await waitUntil(`path "${pathPart}"`, async () => {
const pathValue = await currentPath(page);
return pathValue.includes(pathPart);
}, timeoutMs);
} catch (error) {
const [pathValue, textContent] = await Promise.all([
currentPath(page).catch(() => ''),
bodyText(page).catch(() => ''),
]);
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
}
}
async function clickText(page, text) {
@@ -1283,16 +1449,12 @@ async function clickText(page, text) {
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
target.click();
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
})()
`);
await page.acceptDialogs();
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
await delay(350);
await page.acceptDialogs();
return result;
@@ -1351,16 +1513,12 @@ async function clickTextInSection(page, sectionTitle, text) {
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
target.click();
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
})()
`);
await page.acceptDialogs();
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
await delay(350);
await page.acceptDialogs();
return result;
@@ -1439,15 +1597,11 @@ async function clickVisibleTextCandidate(page, texts) {
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
}
target.click();
return { ok: true, text: textOf(target).slice(0, 120), x, y };
})()
`);
if (result?.ok) {
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y).catch(() => {});
await delay(250);
await page.acceptDialogs();
}
@@ -1701,6 +1855,35 @@ async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000)
async function runStudentJourney(browser, portal, api) {
const checks = [];
const assertStudentRuntimeHealthy = label => assertRuntimeHealthy(page, label, {
allowedHttp: [
{ path: 'https://assets.example.test/' },
{ path: 'https://pay.example.test/' },
],
});
api.setAuthenticated(false);
const authRequestStart = api.requests.length;
const loginPage = await newPage(browser, `${portal.baseUrl}/pages/student/login/index`);
try {
await waitForText(loginPage, '欢迎回来');
await waitForText(loginPage, '手机号登录');
await waitForText(loginPage, '发送验证码');
await waitUntil('unauthenticated /api/auth/me response', async () => {
return api.requests
.slice(authRequestStart)
.some(item => item.path === '/api/auth/me' && item.method === 'GET' && item.status === 401);
});
assertRuntimeHealthy(loginPage, 'Student login flow', {
allowedHttp: [{ status: 401, path: '/api/auth/me' }],
});
checks.push({ id: 'student.login.unauthenticated_401', status: 'pass', detail: '登录页已真实收到 /api/auth/me 401' });
} finally {
try {
await loginPage.close();
} finally {
api.setAuthenticated(true);
}
}
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, '今日学习');
@@ -1743,6 +1926,7 @@ async function runStudentJourney(browser, portal, api) {
await clickText(page, '刷新状态');
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
assertStudentRuntimeHealthy('Student H5 journey through checkout');
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
await waitForText(page, '开始复习');
@@ -1759,6 +1943,7 @@ async function runStudentJourney(browser, portal, api) {
await clickText(page, '认识');
await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST');
checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' });
assertStudentRuntimeHealthy('Student H5 journey through vocabulary');
await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册');
await waitForText(page, '高等数学手册');
@@ -1775,6 +1960,7 @@ async function runStudentJourney(browser, portal, api) {
await waitForText(page, '确认下载');
await waitForApiRequest(api, '/api/catalog/assets/download', 'GET');
checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' });
assertStudentRuntimeHealthy('Student H5 journey through signed assets');
await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析');
await waitForText(page, '本题视频解析');
@@ -1791,6 +1977,7 @@ async function runStudentJourney(browser, portal, api) {
await waitForText(page, '推荐结果');
await waitForText(page, '天津职业大学');
checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' });
assertStudentRuntimeHealthy('Student H5 journey through scoreline and AI school');
await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心');
await waitForText(page, '入门勋章已发放');
@@ -1798,9 +1985,10 @@ async function runStudentJourney(browser, portal, api) {
await waitForApiRequest(api, '/api/profile/notifications/status', 'POST');
checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' });
assertStudentRuntimeHealthy('Student H5 journey');
return checks;
} finally {
page.close();
await page.close();
}
}
@@ -1928,9 +2116,10 @@ async function runTenantJourney(browser, portal, api) {
throw new Error(`${error.message}\nMember diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
}
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布角色模板和成员绑定 API 已触发' });
assertRuntimeHealthy(page, 'Tenant admin H5 journey');
return checks;
} finally {
page.close();
await page.close();
}
}
@@ -2016,9 +2205,109 @@ async function runPlatformJourney(browser, portal, api) {
await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT');
await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH');
checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' });
assertRuntimeHealthy(page, 'Platform admin H5 journey');
return checks;
} finally {
page.close();
await page.close();
}
}
async function runCrossPortalRuntimeProbe(browser, staticServers) {
const student = staticServers.find(item => item.portal === 'student');
const tenant = staticServers.find(item => item.portal === 'tenant-admin');
const platform = staticServers.find(item => item.portal === 'platform-admin');
const page = await newPage(browser, `${student.baseUrl}${student.landingPath}`);
try {
await waitForText(page, '今日学习');
await navigateAndExpect(page, tenant.baseUrl, tenant.landingPath, '工学题库商户后台');
await page.setViewport(390, 844);
await navigateAndExpect(page, platform.baseUrl, platform.landingPath, 'SaaS 平台后台');
assertRuntimeHealthy(page, 'Cross-portal desktop-to-mobile H5 probe');
return { status: 'pass', portals: ['student', 'tenant-admin', 'platform-admin'], mobileViewport: '390x844' };
} finally {
await page.close();
}
}
async function runTaroInputWatcherRaceProbe(browser, portal) {
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, 'SaaS 平台后台');
const result = await page.evaluate(`(async () => {
await customElements.whenDefined('taro-input-core');
const element = document.createElement('taro-input-core');
element.className = 'runtime-input-watcher-race-probe';
element.value = 'before-mount';
document.body.appendChild(element);
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const input = element.querySelector('input');
const beforeMountValue = input?.value || '';
element.value = 'after-mount';
await new Promise(resolve => requestAnimationFrame(resolve));
const afterMountValue = input?.value || '';
element.remove();
return { beforeMountValue, afterMountValue };
})()`);
if (result?.beforeMountValue !== 'before-mount' || result?.afterMountValue !== 'after-mount') {
throw new Error(`Taro Input watcher race probe did not synchronize values: ${JSON.stringify(result)}`);
}
assertRuntimeHealthy(page, 'Taro Input watcher pre-mount race probe');
return { status: 'pass', ...result };
} finally {
await page.close();
}
}
async function runTaroButtonLoadingRaceProbe(browser, portal) {
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
try {
await waitForText(page, 'SaaS 平台后台');
const result = await page.evaluate(`(async () => {
await customElements.whenDefined('taro-button-core');
const element = document.createElement('taro-button-core');
element.className = 'runtime-button-loading-race-probe';
element.textContent = '运行时按钮探针';
document.body.appendChild(element);
if (typeof element.componentOnReady === 'function') await element.componentOnReady();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const loadingNode = element.querySelector('.weui-loading');
const initialChildCount = element.children.length;
const initialDisplay = loadingNode ? getComputedStyle(loadingNode).display : '';
for (let index = 0; index < 200; index += 1) {
element.loading = index % 2 === 0;
}
element.loading = true;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const activeNode = element.querySelector('.weui-loading');
const activeDisplay = activeNode ? getComputedStyle(activeNode).display : '';
const activeChildCount = element.children.length;
element.loading = false;
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const inactiveNode = element.querySelector('.weui-loading');
const inactiveDisplay = inactiveNode ? getComputedStyle(inactiveNode).display : '';
const inactiveChildCount = element.children.length;
element.remove();
return {
loadingNodeStable: Boolean(loadingNode && loadingNode === activeNode && activeNode === inactiveNode),
childCounts: [initialChildCount, activeChildCount, inactiveChildCount],
displays: { initial: initialDisplay, active: activeDisplay, inactive: inactiveDisplay },
};
})()`);
if (
!result?.loadingNodeStable
|| new Set(result.childCounts || []).size !== 1
|| result?.displays?.active === 'none'
|| result?.displays?.inactive !== 'none'
) {
throw new Error(`Taro Button loading race probe did not keep a stable loading node: ${JSON.stringify(result)}`);
}
assertRuntimeHealthy(page, 'Taro Button loading node race probe');
return { status: 'pass', toggles: 200, ...result };
} finally {
await page.close();
}
}
@@ -2055,16 +2344,40 @@ function writeReport(payload) {
async function main() {
const options = parseArgs(process.argv.slice(2));
const api = await createMockApiServer();
const api = await createMockApiServer({ serveOverHttp: options.serveOnly });
const staticServers = [];
let browser = null;
try {
if (options.serveOnly) portals.forEach(assertServeOnlyPreviewBuild);
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
if (options.serveOnly) {
const payload = {
mockApi: api.baseUrl,
portals: staticServers.map(item => ({
portal: item.portal,
url: `${item.baseUrl}${item.landingPath}`,
})),
};
console.log(JSON.stringify(payload, null, 2));
console.log('[smoke] browser QA servers are ready; press Ctrl+C to stop');
await waitForShutdownSignal();
return;
}
browser = await startBrowser(options);
const checks = [];
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin'), api));
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin'), api));
const crossPortal = await runCrossPortalRuntimeProbe(browser, staticServers);
const inputWatcherRace = await runTaroInputWatcherRaceProbe(
browser,
staticServers.find(item => item.portal === 'platform-admin'),
);
const buttonLoadingRace = await runTaroButtonLoadingRaceProbe(
browser,
staticServers.find(item => item.portal === 'platform-admin'),
);
const runtimeHealth = { status: 'pass', crossPortal, inputWatcherRace, buttonLoadingRace };
const payload = {
generatedAt: new Date().toISOString(),
@@ -2074,6 +2387,7 @@ async function main() {
},
browser: {
executable: browser.executable,
runtimeHealth,
},
staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })),
mockApi: {

View File

@@ -237,8 +237,6 @@ function validateDistArtifact(portal, distName, options, collector) {
const textFiles = walkFiles(dir, ['.html', '.js', '.css', '.json', '.txt']).filter(filePath => !filePath.endsWith('.LICENSE.txt'));
const artifactViolations = [];
for (const filePath of textFiles) {
const stat = fs.statSync(filePath);
if (stat.size > 5 * 1024 * 1024) continue;
const text = readText(filePath);
for (const rule of artifactForbiddenPatterns) {
if (rule.pattern.test(text)) artifactViolations.push({ file: relative(filePath), rule: rule.id, message: rule.message });

View File

@@ -48,6 +48,7 @@ assert.equal(loosePayload.schemaVersion, 1);
assert.equal(loosePayload.summary.portals, 3);
assert.equal(loosePayload.portals.length, 3);
assert.ok(loosePayload.portals.every(item => item.buildCommand.startsWith('npm run build:taro:h5:')));
assert.ok(loosePayload.portals.filter(item => item.dist.exists).every(item => /^[0-9a-f]{64}$/.test(item.dist.treeSha256)));
assert.ok(loosePayload.checks.some(item => item.id === 'build.student.portal_env' && item.status === 'pass'));
const strictMissingRuntime = run(['--require-runtime-config']);

View File

@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { hashArtifactDirectory } from './release-artifact-hash.js';
const repoRoot = process.cwd();
const taroRoot = path.join(repoRoot, 'apps', 'taro');
@@ -163,6 +164,7 @@ function summarize(checks, portalResults) {
portals: portals.length,
distReady: portalResults.filter(item => item.dist?.exists && item.dist?.indexHtml).length,
runtimeConfigs: portalResults.filter(item => item.runtimeConfig?.exists).length,
treeHashes: portalResults.filter(item => /^[0-9a-f]{64}$/.test(item.dist?.treeSha256 || '')).length,
};
}
@@ -291,6 +293,7 @@ function inspectDist(portal, collector, options) {
dir: relative(dir),
indexHtml: fs.existsSync(indexPath),
indexSha256: '',
treeSha256: '',
files: 0,
totalBytes: 0,
assetReferences: 0,
@@ -303,9 +306,10 @@ function inspectDist(portal, collector, options) {
}
collector.pass(`dist.${portal.portal}.exists`, 'H5 dist directory exists', { dir: result.dir });
const files = walkFiles(dir);
result.files = files.length;
result.totalBytes = files.reduce((sum, filePath) => sum + fs.statSync(filePath).size, 0);
const tree = hashArtifactDirectory(dir);
result.files = tree.files;
result.totalBytes = tree.totalBytes;
result.treeSha256 = tree.sha256;
if (!result.indexHtml) {
if (options.requireDist) collector.fail(`dist.${portal.portal}.index`, 'H5 index.html is missing', { file: relative(indexPath) });
@@ -322,6 +326,7 @@ function inspectDist(portal, collector, options) {
collector.pass(`dist.${portal.portal}.index`, 'H5 index.html is deployable', {
file: relative(indexPath),
sha256: result.indexSha256,
treeSha256: result.treeSha256,
assetReferences: result.assetReferences,
});
}
@@ -375,7 +380,7 @@ function printHuman(manifest) {
console.log(`Taro H5 release manifest: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
for (const portal of manifest.portals) {
console.log(`[${portal.portal}] ${portal.buildCommand}`);
console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`);
console.log(` dist=${portal.dist.dir} files=${portal.dist.files} bytes=${portal.dist.totalBytes} tree=${portal.dist.treeSha256 || 'missing'} runtime=${portal.runtimeConfig.exists ? 'present' : 'missing'}`);
console.log(` landing=${portal.landingPath}`);
}
for (const item of manifest.checks.filter(check => check.status !== 'pass')) {

View File

@@ -1,10 +1,22 @@
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import process from 'node:process';
import selfsigned from 'selfsigned';
const repoRoot = process.cwd();
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
const smokeApiHostname = 'api-smoke.gongxue100.com';
const smokeTls = selfsigned.generate([{ name: 'commonName', value: smokeApiHostname }], {
days: 1,
keySize: 2048,
algorithm: 'sha256',
extensions: [{
name: 'subjectAltName',
altNames: [{ type: 2, value: smokeApiHostname }],
}],
});
const portals = [
{
@@ -98,6 +110,35 @@ function textResponse(response, statusCode, body, headers = {}) {
}
async function request(input, options = {}) {
const target = new URL(input);
if (target.protocol === 'https:' && target.hostname === smokeApiHostname) {
return new Promise((resolve, reject) => {
const request = https.request({
protocol: 'https:',
hostname: '127.0.0.1',
port: target.port,
path: `${target.pathname}${target.search}`,
method: options.method || 'GET',
headers: { host: target.host, ...(options.headers || {}) },
servername: smokeApiHostname,
rejectUnauthorized: false,
}, response => {
let text = '';
response.setEncoding('utf8');
response.on('data', chunk => {
text += chunk;
});
response.on('end', () => resolve({
ok: response.statusCode >= 200 && response.statusCode < 300,
status: response.statusCode || 0,
headers: response.headers,
text,
}));
});
request.on('error', reject);
request.end();
});
}
const response = await fetch(input, {
method: options.method || 'GET',
headers: options.headers || {},
@@ -113,8 +154,8 @@ async function request(input, options = {}) {
async function createMockApiServer() {
const requests = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url || '/', 'http://127.0.0.1');
const server = https.createServer({ key: smokeTls.private, cert: smokeTls.cert }, (req, res) => {
const url = new URL(req.url || '/', `https://${smokeApiHostname}`);
requests.push({
method: req.method,
path: url.pathname,
@@ -159,7 +200,7 @@ async function createMockApiServer() {
const port = await listen(server);
return {
baseUrl: `http://127.0.0.1:${port}`,
baseUrl: `https://${smokeApiHostname}:${port}`,
requests,
close: () => closeServer(server),
};
@@ -172,7 +213,7 @@ async function createStaticServer(portal, apiBaseUrl) {
apiBaseUrl,
supabaseUrl: 'https://auth.example.test',
supabasePublishableKey: 'sb_publishable_mock_key_for_static_smoke',
tenantCode: 'master',
tenantCode: '',
};
const server = http.createServer((req, res) => {
@@ -259,9 +300,10 @@ function validateRuntimeConfig(config, portal) {
if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`);
if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`);
if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`);
if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke');
if (!String(config.apiBaseUrl || '').startsWith(`https://${smokeApiHostname}:`)) errors.push('apiBaseUrl must point to the HTTPS mock API host in smoke');
if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config');
if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required');
if (String(config.tenantCode || '').trim()) errors.push('production H5 tenantCode must be empty and resolved from the browser origin');
for (const [key, value] of Object.entries(config)) {
if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`);
}
@@ -326,8 +368,8 @@ async function smokePortal(portal, api) {
if (runtimeConfig) {
const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl);
resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host);
if (runtimeConfig.tenantCode) resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } });
let tenantPayload = null;
try {
@@ -337,8 +379,12 @@ async function smokePortal(portal, api) {
}
checks.push({
id: `${portal.portal}.tenant_resolve_contract`,
ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false,
detail: `status=${tenantResolve.status}`,
ok: tenantResolve.ok
&& Boolean(tenantPayload?.item?.tenantId)
&& tenantPayload.item.features?.enableLeaderboard === false
&& Boolean(api.requests.at(-1)?.query?.host)
&& !Object.prototype.hasOwnProperty.call(api.requests.at(-1)?.query || {}, 'tenantCode'),
detail: `status=${tenantResolve.status} query=${JSON.stringify(api.requests.at(-1)?.query || {})}`,
});
} else {
checks.push({

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
@@ -11,7 +12,7 @@ const contracts = [
file: 'pages/student/home/index.tsx',
mustContain: [
'loadStudentDashboard',
'loadCurrentUser',
'useApp',
'/pages/student/catalog/index',
'/pages/student/review/index',
'/pages/student/profile/index',
@@ -75,10 +76,20 @@ const contracts = [
'createOrder',
'createPayment',
'loadOrderStatus',
'Taro.requestPayment',
'launchPayment',
'/pages/student/order-detail/index?orderNo=',
],
},
{
id: 'cross-platform.payment-adapter',
file: 'capabilities/payment.ts',
mustContain: [
'isWeappRuntime',
'Taro.requestPayment',
'openExternalUrl',
'copyText',
],
},
{
id: 'student.profile-center',
file: 'pages/student/profile/index.tsx',
@@ -103,8 +114,8 @@ const contracts = [
id: 'tenant.workbench-permission-modules',
file: 'pages/tenant-admin/workbench/index.tsx',
mustContain: [
'loadTenantPermissions',
'canOpenModule',
'useApp',
'canTenantMenu',
'/pages/tenant-admin/dashboard/index',
'/pages/tenant-admin/students/index',
'/pages/tenant-admin/content/index',
@@ -299,6 +310,7 @@ const contracts = [
const routeContracts = [
{
id: 'student-first-web-launch',
portal: 'student',
routes: [
'pages/student/home/index',
'pages/student/catalog/index',
@@ -312,6 +324,7 @@ const routeContracts = [
},
{
id: 'tenant-admin-launch',
portal: 'tenant-admin',
routes: [
'pages/tenant-admin/workbench/index',
'pages/tenant-admin/dashboard/index',
@@ -324,6 +337,7 @@ const routeContracts = [
},
{
id: 'platform-admin-launch',
portal: 'platform-admin',
routes: [
'pages/platform-admin/workbench/index',
'pages/platform-admin/tenants/index',
@@ -346,15 +360,29 @@ function assertNotContains(text, token, label) {
assert.ok(!text.includes(token), `${label} must not contain ${token}`);
}
function parseAppRoutes() {
const text = readText('app.config.ts');
const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m);
assert.ok(match, 'apps/taro/src/app.config.ts must define pages');
return new Set([...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)].map(item => item[1]));
async function loadPortalRoutes(portal) {
process.env.TARO_APP_PORTAL = portal;
process.env.TARO_ENV = 'h5';
globalThis.defineAppConfig = value => value;
const moduleUrl = pathToFileURL(path.join(taroSrc, 'app.config.ts'));
moduleUrl.searchParams.set('persona', portal);
return new Set(((await import(moduleUrl.href)).default.pages || []));
}
const appRoutes = parseAppRoutes();
const originalPortal = process.env.TARO_APP_PORTAL;
const originalTaroEnv = process.env.TARO_ENV;
const routesByPortal = {
student: await loadPortalRoutes('student'),
'tenant-admin': await loadPortalRoutes('tenant-admin'),
'platform-admin': await loadPortalRoutes('platform-admin'),
};
if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL;
else process.env.TARO_APP_PORTAL = originalPortal;
if (originalTaroEnv === undefined) delete process.env.TARO_ENV;
else process.env.TARO_ENV = originalTaroEnv;
for (const contract of routeContracts) {
const appRoutes = routesByPortal[contract.portal];
for (const route of contract.routes) {
assert.ok(appRoutes.has(route), `${contract.id} route must be registered: ${route}`);
assert.ok(fs.existsSync(path.join(taroSrc, `${route}.tsx`)), `${contract.id} route file must exist: ${route}.tsx`);

View File

@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
@@ -33,15 +34,6 @@ function walkIndexPages(dir) {
return files;
}
function parseAppRoutes() {
const text = readText(appConfigPath);
const match = text.match(/const\s+allPageRoutes\s*=\s*\[([\s\S]*?)\]/m);
assert.ok(match, 'apps/taro/src/app.config.ts must define const allPageRoutes = [...]');
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
.map(item => item[1].trim())
.filter(route => route.startsWith('pages/'));
}
function parsePortalLandingRoutes() {
const text = readText(appConfigPath);
const match = text.match(/const\s+portalLandingRoutes\s*:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\}/m);
@@ -66,11 +58,69 @@ function uniqueSorted(items) {
return [...new Set(items)].sort();
}
const appRoutes = parseAppRoutes();
const appRouteSet = new Set(appRoutes);
async function loadAppConfig(portal, taroEnv) {
process.env.TARO_APP_PORTAL = portal;
process.env.TARO_ENV = taroEnv;
globalThis.defineAppConfig = value => value;
const moduleUrl = pathToFileURL(appConfigPath);
moduleUrl.searchParams.set('portal', portal);
moduleUrl.searchParams.set('env', taroEnv);
return (await import(moduleUrl.href)).default;
}
function expandedRoutes(config) {
const mainRoutes = config.pages || [];
const packageRoutes = (config.subPackages || config.subpackages || []).flatMap(item => (
(item.pages || []).map(route => `${String(item.root || '').replace(/\/$/, '')}/${String(route).replace(/^\//, '')}`)
));
return [...mainRoutes, ...packageRoutes];
}
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
const actualRouteSet = new Set(actualRoutes);
const portalLandingRouteMap = parsePortalLandingRoutes();
const originalPortal = process.env.TARO_APP_PORTAL;
const originalTaroEnv = process.env.TARO_ENV;
const portalConfigs = {
student: await loadAppConfig('student', 'h5'),
'tenant-admin': await loadAppConfig('tenant-admin', 'h5'),
'platform-admin': await loadAppConfig('platform-admin', 'h5'),
};
const studentWeappConfig = await loadAppConfig('student', 'weapp');
if (originalPortal === undefined) delete process.env.TARO_APP_PORTAL;
else process.env.TARO_APP_PORTAL = originalPortal;
if (originalTaroEnv === undefined) delete process.env.TARO_ENV;
else process.env.TARO_ENV = originalTaroEnv;
const sharedH5Routes = ['pages/bootstrap/index', 'pages/student/login/index'];
const expectedRoutesByPortal = {
student: actualRoutes.filter(route => route === 'pages/bootstrap/index' || route.startsWith('pages/student/')),
'tenant-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/tenant-admin/')),
'platform-admin': actualRoutes.filter(route => sharedH5Routes.includes(route) || route.startsWith('pages/platform-admin/')),
};
for (const [portal, config] of Object.entries(portalConfigs)) {
const routes = config.pages || [];
assert.equal(routes.length, new Set(routes).size, `${portal} H5 config must not contain duplicate page routes`);
assert.equal(routes[0], portalLandingRouteMap[portal], `${portal} H5 landing route must be the first page`);
assert.deepEqual(
uniqueSorted(routes),
uniqueSorted(expectedRoutesByPortal[portal]),
`${portal} H5 build must package only its own portal pages plus shared bootstrap/login pages`,
);
}
assert.deepEqual(studentWeappConfig.pages, ['pages/bootstrap/index'], 'Student WeApp main package must contain only the bootstrap page');
assert.equal(studentWeappConfig.subPackages?.length, 1, 'Student WeApp must use one stable student subpackage');
assert.equal(studentWeappConfig.subPackages?.[0]?.root, 'pages/student', 'Student WeApp subpackage root must preserve existing student routes');
assert.deepEqual(
uniqueSorted(expandedRoutes(studentWeappConfig)),
uniqueSorted(expectedRoutesByPortal.student),
'Student WeApp main package and subpackage must cover every student route without admin pages',
);
const appRoutes = uniqueSorted(Object.values(portalConfigs).flatMap(config => config.pages || []));
const appRouteSet = new Set(appRoutes);
assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes');
@@ -127,4 +177,4 @@ for (const route of uniqueSorted(handoffRoutes)) {
assert.ok(actualRouteSet.has(route), `Frontend handoff doc references a page file that is missing: ${route}`);
}
console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages)`);
console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages; H5 portals cropped; WeApp student subpackage verified)`);

View File

@@ -2,7 +2,27 @@ import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
const repoRoot = process.cwd();
globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = {
portal: 'student',
target: 'weapp',
releaseMode: 'production',
weappTenantMode: 'fixed',
apiBaseUrl: 'https://compiled-api.gongxue100.com',
supabaseUrl: 'https://compiled-auth.gongxue100.com',
supabasePublishableKey: 'sb_publishable_compiled_key',
tenantCode: 'compiled-tenant',
};
const envModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`).href);
delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__;
assert.equal(envModule.appEnv.portal, 'student');
assert.equal(envModule.appEnv.apiBaseUrl, 'https://compiled-api.gongxue100.com');
assert.equal(envModule.appEnv.supabaseUrl, 'https://compiled-auth.gongxue100.com');
assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_compiled_key');
assert.equal(envModule.appEnv.tenantCode, 'compiled-tenant');
assert.equal(envModule.taroRuntimeEnv(), 'weapp');
assert.equal(envModule.isWeappRuntime(), true);
assert.equal(envModule.taroWeappTenantMode(), 'fixed');
envModule.applyRuntimeConfig({
portal: 'tenant-admin',
@@ -18,6 +38,9 @@ assert.equal(envModule.appEnv.supabaseUrl, 'https://auth.gongxue100.com');
assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_public_key');
assert.equal(envModule.appEnv.tenantCode, 'tenant-a');
envModule.applyRuntimeConfig({ tenantCode: '' });
assert.equal(envModule.appEnv.tenantCode, '', 'An empty runtime tenantCode must clear a compiled tenant fallback');
envModule.applyRuntimeConfig({
TARO_APP_PORTAL: 'platform-admin',
TARO_APP_API_BASE_URL: 'https://api2.gongxue100.com',
@@ -50,4 +73,100 @@ assert.throws(
/Unknown key in runtime config: unexpectedFeatureFlag/,
);
globalThis.__TARO_PUBLIC_BUILD_CONFIG__ = {
portal: 'student',
target: 'h5',
releaseMode: 'production',
weappTenantMode: '',
apiBaseUrl: '',
supabaseUrl: '',
supabasePublishableKey: '',
tenantCode: '',
};
globalThis.window = {
location: { origin: 'https://student.example.test' },
fetch: async () => {
throw new Error('network unavailable');
},
};
const productionH5Url = pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`);
productionH5Url.searchParams.set('runtime-config-test', 'production-h5');
const productionH5Env = await import(productionH5Url.href);
assert.equal(productionH5Env.appEnv.apiBaseUrl, '', 'Production H5 must not compile a local API fallback');
assert.equal(productionH5Env.taroReleaseMode(), 'production');
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/Production H5 runtime-config\.json request failed: network unavailable/,
'Production H5 must fail closed when runtime config cannot be fetched',
);
globalThis.window.fetch = async () => ({ ok: false, status: 404 });
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/Production H5 runtime-config\.json request failed with status 404/,
'Production H5 must fail closed when runtime config is missing',
);
globalThis.window.fetch = async () => ({ ok: true, status: 200, text: async () => '' });
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/Production H5 runtime-config\.json is empty or unreadable/,
'Production H5 must fail closed when runtime config is unreadable',
);
globalThis.window.fetch = async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ portal: 'student', apiBaseUrl: '' }),
});
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/Production H5 runtime-config\.json apiBaseUrl must be an absolute HTTPS URL and must not use localhost or loopback/,
'Production H5 must fail closed when runtime config omits its API endpoint',
);
for (const apiBaseUrl of [
'http://api.gongxue100.com',
'https://localhost:8787',
'https://127.0.0.1:8787',
'https://[::1]:8787',
]) {
globalThis.window.fetch = async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({ portal: 'student', apiBaseUrl }),
});
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/must be an absolute HTTPS URL and must not use localhost or loopback/,
`Production H5 must reject unsafe API endpoint ${apiBaseUrl}`,
);
}
globalThis.window.fetch = async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
portal: 'student',
apiBaseUrl: 'https://api.gongxue100.com/',
supabaseUrl: 'https://auth.gongxue100.com/',
supabasePublishableKey: 'sb_publishable_runtime_test',
tenantCode: '',
}),
});
await productionH5Env.loadRuntimeConfig();
assert.equal(productionH5Env.appEnv.apiBaseUrl, 'https://api.gongxue100.com');
assert.equal(productionH5Env.appEnv.tenantCode, '', 'Production H5 must remain domain resolved');
globalThis.window.fetch = async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
portal: 'student',
apiBaseUrl: 'https://api.gongxue100.com',
tenantCode: 'campus-north',
}),
});
await assert.rejects(
() => productionH5Env.loadRuntimeConfig(),
/tenantCode must be empty; tenant is resolved from the browser origin/,
'Production H5 must reject a fixed tenant override',
);
delete globalThis.window;
delete globalThis.__TARO_PUBLIC_BUILD_CONFIG__;
console.log('[PASS] Taro runtime config guardrails');

View File

@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
allowedInvalidEdges,
npmAuditArgs,
securedBundleDependencies,
validateAuditPayload,
validateNpmLsPayload,
} from './taro-supply-chain-audit.js';
import {
enforceTaroH5RuntimePatches,
taroButtonLoadingPatch,
taroH5RuntimePatchDefinition,
taroInputWatcherPatch,
} from './taro-components-h5-runtime-patch.js';
const repoRoot = process.cwd();
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const packageLock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'));
const taroPackage = JSON.parse(fs.readFileSync(path.join(repoRoot, 'apps', 'taro', 'package.json'), 'utf8'));
assert.equal(taroPackage.devDependencies?.['@tarojs/components'], taroH5RuntimePatchDefinition.packageVersion);
assert.equal(taroPackage.scripts?.postinstall, taroH5RuntimePatchDefinition.postinstallCommand);
assert.equal(packageLock.packages?.['apps/taro']?.hasInstallScript, true);
const patchState = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
assert.equal(patchState.status, 'pass');
assert.equal(patchState.patches.inputWatcher.installedSha256, taroInputWatcherPatch.patchedSha256);
assert.equal(patchState.patches.buttonLoading.installedSha256, taroButtonLoadingPatch.patchedSha256);
for (const [name, expected] of Object.entries(securedBundleDependencies)) {
assert.equal(packageJson.overrides?.[name], expected.version, `${name} override must stay exact`);
assert.equal(packageLock.packages?.[`node_modules/${name}`]?.version, expected.version, `${name} lock version must stay exact`);
assert.equal(packageLock.packages?.[`node_modules/${name}`]?.integrity, expected.integrity, `${name} lock integrity must stay exact`);
}
const dependencyNode = (name, parents) => ({
version: securedBundleDependencies[name].version,
invalid: parents.map(parent => `"${parent.declared}" from node_modules/${parent.parent}`).join(', '),
problems: [`invalid: ${name}@${securedBundleDependencies[name].version} /repo/node_modules/${name}`],
});
const edgesFor = dependency => allowedInvalidEdges.filter(edge => edge.dependency === dependency);
const validLs = {
error: { code: 'ELSPROBLEMS' },
problems: Object.entries(securedBundleDependencies).map(([name, expected]) => `invalid: ${name}@${expected.version} /repo/node_modules/${name}`),
dependencies: {
'@tiku-saas/taro': {
dependencies: {
'@tarojs/components': {
dependencies: {
swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components')),
},
},
'@tarojs/plugin-platform-h5': {
dependencies: {
'@tarojs/components-react': {
dependencies: {
swiper: dependencyNode('swiper', edgesFor('swiper').filter(edge => edge.parent === '@tarojs/components-react')),
},
},
'@tarojs/taro-h5': {
dependencies: {
'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/taro-h5')),
},
},
'lodash-es': dependencyNode('lodash-es', edgesFor('lodash-es').filter(edge => edge.parent === '@tarojs/plugin-platform-h5')),
},
},
},
},
},
};
const lsSummary = validateNpmLsPayload(validLs);
assert.equal(lsSummary.edges.length, 4);
assert.throws(
() => validateNpmLsPayload({ ...validLs, problems: [...validLs.problems, 'extraneous: unsafe@1.0.0 /repo/node_modules/unsafe'] }),
/unexpected problem|unapproved problem/,
'new npm ls problems must fail closed',
);
const missingEdgeLs = structuredClone(validLs);
delete missingEdgeLs.dependencies['@tiku-saas/taro'].dependencies['@tarojs/plugin-platform-h5'].dependencies['@tarojs/components-react'];
assert.throws(() => validateNpmLsPayload(missingEdgeLs), /once per reviewed invalid edge|invalid-edge set changed/, 'the exception set must not silently shrink or change');
const auditFixture = {
vulnerabilities: {
'@tarojs/cli': { severity: 'high', isDirect: true },
download: { severity: 'critical', isDirect: false, via: [] },
'git-clone': { severity: 'high', isDirect: false, via: [{ source: 1093404, severity: 'high' }] },
esbuild: { severity: 'moderate', isDirect: false },
},
metadata: {
vulnerabilities: { info: 0, low: 0, moderate: 1, high: 2, critical: 1, total: 4 },
},
};
const auditSummary = validateAuditPayload(auditFixture);
assert.equal(auditSummary.reviewedHighCritical.length, 3);
assert.deepEqual(auditSummary.reviewedAdvisories, [1093404]);
assert.throws(
() => validateAuditPayload({
...auditFixture,
vulnerabilities: { ...auditFixture.vulnerabilities, swiper: { severity: 'critical', isDirect: false } },
}),
/still reports swiper/,
'bundle dependency advisories must fail the gate',
);
assert.throws(
() => validateAuditPayload({
...auditFixture,
vulnerabilities: { ...auditFixture.vulnerabilities, 'new-build-risk': { severity: 'high', isDirect: false } },
}),
/new unreviewed high/,
'new high or critical toolchain findings must require review',
);
assert.throws(
() => validateAuditPayload({
...auditFixture,
vulnerabilities: {
...auditFixture.vulnerabilities,
'git-clone': { severity: 'high', isDirect: false, via: [{ source: 9999999, severity: 'high' }] },
},
}),
/new unreviewed high Taro advisory/,
'new advisories on an already allowlisted package must require review',
);
const auditArgs = npmAuditArgs('https://registry.npmjs.org/');
assert.ok(auditArgs.includes('--workspace'));
assert.ok(auditArgs.includes('@tiku-saas/taro'));
assert.equal(auditArgs.some(arg => arg === '--omit=dev' || arg.startsWith('--omit=')), false, 'Taro audit must include dependencies that are marked dev but bundled into H5');
console.log('[PASS] Taro supply-chain audit contract');

View File

@@ -0,0 +1,316 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { enforceTaroH5RuntimePatches } from './taro-components-h5-runtime-patch.js';
const repoRoot = process.cwd();
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
export const securedBundleDependencies = Object.freeze({
'lodash-es': Object.freeze({
version: '4.18.1',
integrity: 'sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==',
}),
swiper: Object.freeze({
version: '12.1.2',
integrity: 'sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==',
}),
});
export const allowedInvalidEdges = Object.freeze([
Object.freeze({ parent: '@tarojs/components', dependency: 'swiper', declared: '11.1.15' }),
Object.freeze({ parent: '@tarojs/components-react', dependency: 'swiper', declared: '11.1.15' }),
Object.freeze({ parent: '@tarojs/plugin-platform-h5', dependency: 'lodash-es', declared: '4.17.21' }),
Object.freeze({ parent: '@tarojs/taro-h5', dependency: 'lodash-es', declared: '4.17.21' }),
]);
export const reviewedHighCriticalToolchainPackages = new Set([
'@tarojs/cli',
'@tarojs/plugin-doctor',
'@tarojs/webpack5-runner',
'cacheable-request',
'decompress',
'download',
'download-git-repo',
'git-clone',
'glob',
'got',
'html-minifier',
'http-cache-semantics',
'serialize-javascript',
]);
export const reviewedHighCriticalAdvisories = new Set([
1093404,
1102456,
1105440,
1109842,
1113686,
1122670,
]);
export const npmLsArgs = Object.freeze([
'ls',
...Object.keys(securedBundleDependencies),
'--workspace',
'@tiku-saas/taro',
'--all',
'--json',
]);
export function npmAuditArgs(registry = process.env.NPM_AUDIT_REGISTRY || 'https://registry.npmjs.org/') {
return [
'audit',
`--registry=${registry}`,
'--workspace',
'@tiku-saas/taro',
'--audit-level=high',
'--json',
];
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function dependencyProblem(problem) {
const match = /^invalid: ((?:@[^/\s]+\/)?[^@\s]+)@([^\s]+)\s+(.+)$/.exec(String(problem || ''));
return match ? { name: match[1], version: match[2], location: match[3] } : null;
}
function expectedProblemKey(name) {
return `${name}@${securedBundleDependencies[name].version}`;
}
function collectTreeProblems(node, dependencyName = '', state = { problems: [], invalidEdges: [] }) {
if (node?.error?.code === 'ELSPROBLEMS' && !state.errorCode) state.errorCode = node.error.code;
for (const problem of node?.problems || []) state.problems.push(String(problem));
if (node?.invalid) {
assert(securedBundleDependencies[dependencyName], `npm ls reported an unexpected invalid dependency: ${dependencyName}`);
const edgePattern = /"([^"]+)" from node_modules\/((?:@[^/,]+\/)?[^,\s]+)/g;
let match;
let matched = false;
while ((match = edgePattern.exec(String(node.invalid)))) {
matched = true;
state.invalidEdges.push({ parent: match[2], dependency: dependencyName, declared: match[1] });
}
assert(matched, `npm ls returned an unparseable invalid edge for ${dependencyName}: ${node.invalid}`);
}
assert(!node?.extraneous, `npm ls reported an extraneous dependency: ${dependencyName}`);
assert(!node?.missing, `npm ls reported a missing dependency: ${dependencyName}`);
for (const [name, dependency] of Object.entries(node?.dependencies || {})) {
collectTreeProblems(dependency, name, state);
}
return state;
}
export function validateNpmLsPayload(payload) {
assert(payload && typeof payload === 'object', 'npm ls did not return a JSON object');
assert(payload.error?.code === 'ELSPROBLEMS', 'npm ls must fail only with the reviewed Taro exact-dependency ELSPROBLEMS result');
const state = collectTreeProblems(payload);
const problemCounts = new Map();
const actualProblemKeys = new Set();
for (const problem of state.problems) {
const parsed = dependencyProblem(problem);
assert(parsed, `npm ls returned an unexpected problem: ${problem}`);
assert(securedBundleDependencies[parsed.name], `npm ls returned an unapproved problem: ${problem}`);
assert(parsed.version === securedBundleDependencies[parsed.name].version, `npm ls problem uses an unexpected ${parsed.name} version: ${problem}`);
assert(parsed.location.replace(/\\/g, '/').endsWith(`/node_modules/${parsed.name}`), `npm ls problem has an unexpected location: ${problem}`);
const key = `${parsed.name}@${parsed.version}`;
actualProblemKeys.add(key);
problemCounts.set(key, (problemCounts.get(key) || 0) + 1);
}
const expectedProblemKeys = new Set(Object.keys(securedBundleDependencies).map(expectedProblemKey));
assert(actualProblemKeys.size === expectedProblemKeys.size, 'npm ls must report exactly the two reviewed invalid packages');
for (const key of expectedProblemKeys) {
assert(actualProblemKeys.has(key), `npm ls did not report the reviewed invalid package ${key}`);
const dependency = key.slice(0, key.lastIndexOf('@'));
const expectedCount = 1 + allowedInvalidEdges.filter(edge => edge.dependency === dependency).length;
assert(problemCounts.get(key) === expectedCount, `npm ls must report ${key} once at the root and once per reviewed invalid edge`);
}
const actualEdges = new Set(state.invalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`));
const expectedEdges = new Set(allowedInvalidEdges.map(edge => `${edge.parent}>${edge.dependency}@${edge.declared}`));
assert(actualEdges.size === expectedEdges.size, `npm ls invalid-edge set changed: expected ${expectedEdges.size}, received ${actualEdges.size}`);
for (const edge of expectedEdges) assert(actualEdges.has(edge), `npm ls did not report the reviewed invalid edge ${edge}`);
return {
packages: [...expectedProblemKeys].sort(),
edges: allowedInvalidEdges.map(edge => ({ ...edge })),
};
}
export function validateAuditPayload(payload) {
assert(payload && typeof payload === 'object', 'npm audit did not return a JSON object');
assert(payload.metadata?.vulnerabilities, 'npm audit payload is missing vulnerability metadata');
const vulnerabilities = payload.vulnerabilities || {};
for (const dependency of Object.keys(securedBundleDependencies)) {
assert(!vulnerabilities[dependency], `npm audit still reports ${dependency}; the bundle mitigation is not effective`);
}
const reviewed = [];
const moderate = [];
const observedAdvisories = new Set();
for (const [name, vulnerability] of Object.entries(vulnerabilities)) {
const severity = String(vulnerability.severity || '').toLowerCase();
if (severity === 'high' || severity === 'critical') {
assert(reviewedHighCriticalToolchainPackages.has(name), `new unreviewed ${severity} Taro vulnerability: ${name}`);
for (const via of vulnerability.via || []) {
if (!via || typeof via !== 'object') continue;
const viaSeverity = String(via.severity || '').toLowerCase();
if (viaSeverity !== 'high' && viaSeverity !== 'critical') continue;
assert(reviewedHighCriticalAdvisories.has(via.source), `new unreviewed ${viaSeverity} Taro advisory ${via.source} in ${name}`);
observedAdvisories.add(via.source);
}
reviewed.push({ name, severity, direct: Boolean(vulnerability.isDirect) });
} else {
moderate.push({ name, severity, direct: Boolean(vulnerability.isDirect) });
}
}
return {
counts: { ...payload.metadata.vulnerabilities },
reviewedAdvisories: [...observedAdvisories].sort((left, right) => left - right),
reviewedHighCritical: reviewed.sort((left, right) => left.name.localeCompare(right.name)),
other: moderate.sort((left, right) => left.name.localeCompare(right.name)),
};
}
function findPackageJsonFromEntry(entryPath, expectedName) {
let current = path.dirname(entryPath);
while (current !== path.dirname(current)) {
const candidate = path.join(current, 'package.json');
if (fs.existsSync(candidate)) {
const manifest = readJson(candidate);
if (manifest.name === expectedName) return candidate;
}
current = path.dirname(current);
}
throw new Error(`cannot locate package.json for ${expectedName}`);
}
function resolveDependencyPackage(parentPackagePath, dependency) {
const requireFromParent = createRequire(parentPackagePath);
try {
return requireFromParent.resolve(`${dependency}/package.json`);
} catch {
return findPackageJsonFromEntry(requireFromParent.resolve(dependency), dependency);
}
}
export function validateManifestLockAndInstall(root = repoRoot) {
const rootManifest = readJson(path.join(root, 'package.json'));
const lock = readJson(path.join(root, 'package-lock.json'));
const installed = {};
for (const [dependency, expected] of Object.entries(securedBundleDependencies)) {
assert(rootManifest.overrides?.[dependency] === expected.version, `package.json must override ${dependency} to ${expected.version}`);
const lockEntry = lock.packages?.[`node_modules/${dependency}`];
assert(lockEntry?.version === expected.version, `package-lock.json must resolve ${dependency} to ${expected.version}`);
assert(lockEntry?.integrity === expected.integrity, `package-lock.json has an unexpected integrity for ${dependency}`);
const installedManifest = readJson(path.join(root, 'node_modules', dependency, 'package.json'));
assert(installedManifest.version === expected.version, `node_modules has ${dependency}@${installedManifest.version}; run npm ci to install the secured lock`);
installed[dependency] = installedManifest.version;
}
for (const edge of allowedInvalidEdges) {
const parentPackagePath = path.join(root, 'node_modules', ...edge.parent.split('/'), 'package.json');
assert(fs.existsSync(parentPackagePath), `reviewed Taro parent package is not installed: ${edge.parent}`);
const parentManifest = readJson(parentPackagePath);
assert(parentManifest.dependencies?.[edge.dependency] === edge.declared, `${edge.parent} no longer declares the reviewed ${edge.dependency}@${edge.declared} edge; remove or update the exception`);
const resolvedPackagePath = resolveDependencyPackage(parentPackagePath, edge.dependency);
const resolvedManifest = readJson(resolvedPackagePath);
assert(resolvedManifest.version === securedBundleDependencies[edge.dependency].version, `${edge.parent} resolves ${edge.dependency}@${resolvedManifest.version} instead of the secured override`);
}
return installed;
}
function runNpm(args) {
return spawnSync(npmCommand, args, {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
env: process.env,
});
}
function parseCommandJson(result, label) {
try {
return JSON.parse(result.stdout || '{}');
} catch (error) {
throw new Error(`${label} did not return valid JSON: ${error.message}`);
}
}
export function runSupplyChainAudit() {
const versions = validateManifestLockAndInstall();
const h5RuntimePatches = enforceTaroH5RuntimePatches({ root: repoRoot, mode: 'check' });
const lsResult = runNpm(npmLsArgs);
assert(lsResult.status === 1, `npm ls returned ${lsResult.status}; expected the reviewed ELSPROBLEMS status`);
const invalid = validateNpmLsPayload(parseCommandJson(lsResult, 'npm ls'));
const auditResult = runNpm(npmAuditArgs());
assert(auditResult.status === 0 || auditResult.status === 1, `npm audit failed operationally with status ${auditResult.status}: ${auditResult.stderr}`);
const audit = validateAuditPayload(parseCommandJson(auditResult, 'npm audit'));
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
status: 'pass-with-reviewed-toolchain-risk',
securedBundleDependencies: versions,
h5RuntimePatches,
npmLs: invalid,
audit,
riskBoundary: {
appliesTo: 'Taro 4.2.0 CLI and build toolchain; swiper and lodash-es bundle advisories are mitigated',
controls: [
'build only on isolated trusted runners',
'do not expose Taro development servers to public networks',
'do not process untrusted templates, archives, repositories, or CLI arguments',
'publish only reviewed apps/taro/dist static artifacts',
],
},
};
}
function printHuman(result) {
console.log('[PASS] Taro supply-chain override and install state');
console.log(`[PASS] bundle dependencies: ${Object.entries(result.securedBundleDependencies).map(([name, version]) => `${name}@${version}`).join(', ')}`);
const patchSummary = Object.entries(result.h5RuntimePatches.patches)
.map(([id, patch]) => `${id}=${patch.installedSha256}`)
.join(', ');
console.log(`[PASS] Taro H5 runtime patches: ${result.h5RuntimePatches.version} ${patchSummary}`);
console.log(`[PASS] npm ls exceptions restricted to ${result.npmLs.edges.length} reviewed Taro exact-dependency edges`);
console.log(`[WARN] full Taro workspace audit remains ${result.audit.counts.total} findings (${result.audit.counts.critical} critical, ${result.audit.counts.high} high, ${result.audit.counts.moderate} moderate)`);
console.log('[WARN] remaining high/critical findings are confined to the reviewed Taro CLI/build-toolchain package allowlist');
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const json = process.argv.includes('--json');
try {
const result = runSupplyChainAudit();
if (json) console.log(JSON.stringify(result, null, 2));
else printHuman(result);
} catch (error) {
if (json) console.log(JSON.stringify({ schemaVersion: 1, status: 'fail', error: error.message }, null, 2));
else console.error(`[FAIL] ${error.message}`);
process.exitCode = 1;
}
}

View File

@@ -0,0 +1,221 @@
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import {
validateProductionApiBaseUrl,
validateProductionTenantCode,
validateProductionWechatAppId,
validateTenantCodeFormat,
} from './build-weapp-student.js';
const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
const defaultDistRoot = path.join(repoRoot, 'apps', 'taro', 'dist', 'weapp-student');
function walkFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const filePath = path.join(dir, entry.name);
return entry.isDirectory() ? walkFiles(filePath) : [filePath];
});
}
function directoryBytes(dir) {
return walkFiles(dir).reduce((total, filePath) => total + fs.statSync(filePath).size, 0);
}
function parsedJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
export function extractCompiledPublicBuildConfig(source) {
const normalizedSource = source.replace(/"([A-Za-z][A-Za-z0-9]*)":/g, '$1:');
const marker = 'weappTenantMode:';
const configs = [];
let markerIndex = normalizedSource.indexOf(marker);
while (markerIndex >= 0) {
const start = normalizedSource.lastIndexOf('{portal:', markerIndex);
if (start >= 0) {
let inString = false;
let escaped = false;
let depth = 0;
for (let index = start; index < normalizedSource.length; index += 1) {
const char = normalizedSource[index];
if (inString) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') inString = true;
else if (char === '{') depth += 1;
else if (char === '}') {
depth -= 1;
if (depth === 0) {
const candidate = normalizedSource.slice(start, index + 1);
const keys = ['portal', 'target', 'releaseMode', 'weappTenantMode', 'apiBaseUrl', 'supabaseUrl', 'supabasePublishableKey', 'tenantCode'];
const config = Object.fromEntries(keys.map(key => {
const match = candidate.match(new RegExp(`(?:^|[,\\{])${key}:"((?:\\\\.|[^"\\\\])*)"`));
if (!match) return [key, ''];
return [key, JSON.parse(`"${match[1]}"`)];
}));
if (config.portal && config.target && config.weappTenantMode) configs.push(config);
break;
}
}
}
}
markerIndex = normalizedSource.indexOf(marker, markerIndex + marker.length);
}
const unique = [...new Map(configs.map(config => [JSON.stringify(config), config])).values()];
if (!unique.length) throw new Error('Compiled __TARO_PUBLIC_BUILD_CONFIG__ was not found in common.js');
if (unique.length > 1) throw new Error('Multiple conflicting public build configs were found in common.js');
return unique[0];
}
function validationError(validate, value) {
try {
validate(value);
return '';
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
}
export function inspectWeappRelease({ distRoot = defaultDistRoot, requireProduction = false } = {}) {
const checks = [];
const push = (status, id, message, details = {}) => checks.push({ status, id, message, details });
if (!fs.existsSync(distRoot)) {
push('fail', 'weapp.dist.exists', 'Student WeApp output is missing', { dist: path.relative(repoRoot, distRoot) });
return checks;
}
push('pass', 'weapp.dist.exists', 'Student WeApp output exists');
const projectConfigPath = path.join(distRoot, 'project.config.json');
const appConfigPath = path.join(distRoot, 'app.json');
const commonPath = path.join(distRoot, 'common.js');
if (![projectConfigPath, appConfigPath, commonPath].every(fs.existsSync)) {
push('fail', 'weapp.artifacts.required', 'WeApp output is missing project.config.json, app.json, or common.js');
return checks;
}
const projectConfig = parsedJson(projectConfigPath);
const appConfig = parsedJson(appConfigPath);
const appId = String(projectConfig.appid || '');
const appIdError = validationError(validateProductionWechatAppId, appId);
if (requireProduction && appIdError) push('fail', 'weapp.appid', appIdError);
else push(appIdError ? 'warn' : 'pass', 'weapp.appid', appIdError || 'WeApp AppID is production-ready');
if (requireProduction && projectConfig.setting?.urlCheck !== true) push('fail', 'weapp.url_check', 'Production WeApp must enable legal-domain URL checks');
else push(projectConfig.setting?.urlCheck === true ? 'pass' : 'warn', 'weapp.url_check', projectConfig.setting?.urlCheck === true ? 'WeApp legal-domain URL checks are enabled' : 'WeApp URL checks are disabled for preview');
let compiledConfig = null;
try {
compiledConfig = extractCompiledPublicBuildConfig(fs.readFileSync(commonPath, 'utf8'));
push('pass', 'weapp.public_config.found', 'Compiled public build config was found in common.js');
} catch (error) {
push('fail', 'weapp.public_config.found', error instanceof Error ? error.message : String(error));
}
if (compiledConfig) {
if (compiledConfig.portal === 'student' && compiledConfig.target === 'weapp') {
push('pass', 'weapp.public_config.identity', 'Compiled public build config targets the student WeApp');
} else {
push('fail', 'weapp.public_config.identity', 'Compiled public build config must target portal=student and target=weapp', { compiledConfig });
}
if (requireProduction && compiledConfig.releaseMode !== 'production') {
push('fail', 'weapp.public_config.release_mode', 'Production guard requires releaseMode=production', { releaseMode: compiledConfig.releaseMode });
} else {
push(compiledConfig.releaseMode === 'production' ? 'pass' : 'warn', 'weapp.public_config.release_mode', `Compiled release mode is ${compiledConfig.releaseMode || 'missing'}`);
}
const tenantMode = String(compiledConfig.weappTenantMode || '');
if (tenantMode !== 'fixed' && tenantMode !== 'launch') {
push('fail', 'weapp.public_config.tenant_mode', 'Compiled weappTenantMode must be fixed or launch');
} else {
push('pass', 'weapp.public_config.tenant_mode', `Compiled WeApp tenant mode is ${tenantMode}`);
const tenantCode = String(compiledConfig.tenantCode || '');
if (tenantMode === 'launch') {
if (tenantCode) push('fail', 'weapp.public_config.tenant_code', 'Launch mode must not retain a compiled tenant fallback', { tenantCode });
else push('pass', 'weapp.public_config.tenant_code', 'Launch mode contains no compiled tenant fallback');
} else {
const tenantError = validationError(requireProduction ? validateProductionTenantCode : validateTenantCodeFormat, tenantCode);
if (tenantError) push('fail', 'weapp.public_config.tenant_code', tenantError);
else push('pass', 'weapp.public_config.tenant_code', 'Fixed mode contains a valid compiled tenant code');
}
}
const apiBaseUrl = String(compiledConfig.apiBaseUrl || '');
const apiError = validationError(validateProductionApiBaseUrl, apiBaseUrl);
if (requireProduction && apiError) push('fail', 'weapp.public_config.api', apiError);
else push(apiError ? 'warn' : 'pass', 'weapp.public_config.api', apiError || 'Compiled API endpoint is production-ready');
}
const mainPages = Array.isArray(appConfig.pages) ? appConfig.pages : [];
const subpackages = appConfig.subPackages || appConfig.subpackages || [];
if (mainPages.length === 1 && mainPages[0] === 'pages/bootstrap/index') push('pass', 'weapp.main_pages', 'WeApp main package contains only bootstrap');
else push('fail', 'weapp.main_pages', 'WeApp main package must contain only bootstrap', { mainPages });
if (subpackages.length === 1 && subpackages[0]?.root === 'pages/student') push('pass', 'weapp.subpackage', 'Student pages use one subpackage');
else push('fail', 'weapp.subpackage', 'Student WeApp subpackage contract is invalid');
const totalBytes = directoryBytes(distRoot);
const subpackageBytes = directoryBytes(path.join(distRoot, 'pages', 'student'));
const mainBytes = totalBytes - subpackageBytes;
if (totalBytes > 4 * 1024 * 1024) push('fail', 'weapp.size.total', 'WeApp output exceeds the 4 MiB release budget', { totalBytes });
else push('pass', 'weapp.size.total', 'WeApp output is within the 4 MiB release budget', { totalBytes });
if (mainBytes > 2 * 1024 * 1024) push('fail', 'weapp.size.main', 'WeApp main package exceeds the 2 MiB release budget', { mainBytes });
else push('pass', 'weapp.size.main', 'WeApp main package is within the 2 MiB release budget', { mainBytes });
const forbidden = [
['database-url', /postgres(?:ql)?:\/\//i],
['service-role', /\b(?:service_role|sb_secret_)\b/i],
['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/i],
];
const violations = [];
const localEndpointFiles = [];
const allowedDependencyLocalEndpoints = [];
for (const filePath of walkFiles(distRoot).filter(item => /\.(?:js|json|wxml|wxss|txt)$/.test(item))) {
const source = fs.readFileSync(filePath, 'utf8');
const relativeFile = path.relative(repoRoot, filePath);
const localEndpoints = source.match(/https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?/gi) || [];
for (const endpoint of localEndpoints) {
if (relativeFile === 'apps/taro/dist/weapp-student/vendors.js' && endpoint.toLowerCase() === 'http://localhost:9999') {
allowedDependencyLocalEndpoints.push({ file: relativeFile, endpoint, dependency: '@supabase/auth-js' });
} else {
localEndpointFiles.push(relativeFile);
}
}
for (const [id, pattern] of forbidden) {
if (pattern.test(source)) violations.push({ id, file: relativeFile });
}
}
if (allowedDependencyLocalEndpoints.length) {
push('pass', 'weapp.dependency_local_placeholder', 'Known inert Supabase SDK local placeholder is explicitly allowlisted', { matches: allowedDependencyLocalEndpoints });
}
if (localEndpointFiles.length) push(requireProduction ? 'fail' : 'warn', 'weapp.local_endpoints', 'WeApp output contains local API endpoints', { files: [...new Set(localEndpointFiles)].slice(0, 20) });
else push('pass', 'weapp.local_endpoints', 'WeApp output contains no local API endpoints');
if (violations.length) push('fail', 'weapp.forbidden_patterns', 'WeApp output contains secret-looking values', { violations: violations.slice(0, 20) });
else push('pass', 'weapp.forbidden_patterns', 'WeApp output contains no secret-looking values');
return checks;
}
export function runGuardrails({ argv = process.argv.slice(2), distRoot = defaultDistRoot } = {}) {
const requireProduction = argv.includes('--production');
const json = argv.includes('--json');
const checks = inspectWeappRelease({ distRoot, requireProduction });
const summary = checks.reduce((result, item) => ({ ...result, [item.status]: result[item.status] + 1 }), { fail: 0, warn: 0, pass: 0 });
const payload = { summary, checks };
if (json) console.log(JSON.stringify(payload, null, 2));
else {
console.log(`Taro WeApp release guardrails: ${summary.fail} fail(s), ${summary.warn} warning(s), ${summary.pass} pass(es)`);
checks.filter(item => item.status !== 'pass').forEach(item => console.log(`[${item.status.toUpperCase()}] ${item.id}: ${item.message}`));
}
return summary.fail > 0 ? 1 : 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
process.exitCode = runGuardrails();
}

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import {
EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT,
EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256,
buildTenantForeignKeyViolationQuery,
tenantForeignKeyExceptions,
tenantForeignKeySchemaSha256,
} from './lib/tenant-foreign-key-audit.js';
import {
assertTenantForeignKeyAuditTarget,
parseTenantForeignKeyAuditOptions,
} from './tenant-foreign-key-audit.js';
const relation = overrides => ({
childTable: 'child_rows',
constraintName: 'child_rows_parent_id_fkey',
childColumns: ['parent_id'],
parentTable: 'parent_rows',
parentColumns: ['id'],
validated: true,
updateAction: 'a',
deleteAction: 'a',
...overrides,
});
assert.equal(EXPECTED_TENANT_FOREIGN_KEY_RELATION_COUNT, 189);
assert.match(EXPECTED_TENANT_FOREIGN_KEY_SCHEMA_SHA256, /^[0-9a-f]{64}$/);
assert.equal(tenantForeignKeyExceptions().length, 3);
const digestA = tenantForeignKeySchemaSha256([relation({ childTable: 'b' }), relation({ childTable: 'a' })]);
const digestB = tenantForeignKeySchemaSha256([relation({ childTable: 'a' }), relation({ childTable: 'b' })]);
assert.equal(digestA, digestB, 'schema fingerprint must not depend on catalog row ordering');
const defaultQuery = buildTenantForeignKeyViolationQuery([relation({})]);
assert.match(defaultQuery, /child\.tenant_id is distinct from parent\.tenant_id/i);
const globalRuleQuery = buildTenantForeignKeyViolationQuery([
relation({
childTable: 'platform_audit_alerts',
constraintName: 'platform_audit_alerts_rule_id_fkey',
childColumns: ['rule_id'],
parentTable: 'platform_audit_alert_rules',
}),
]);
assert.match(globalRuleQuery, /parent\.tenant_id is not null and child\.tenant_id is distinct from parent\.tenant_id/i);
const platformBankQuery = buildTenantForeignKeyViolationQuery([
relation({
childTable: 'tenant_question_bank_adoptions',
constraintName: 'tenant_question_bank_adoptions_source_question_bank_id_fkey',
childColumns: ['source_question_bank_id'],
parentTable: 'question_banks',
}),
]);
assert.match(platformBankQuery, /parent\.source_scope is distinct from 'platform'/i);
assert.throws(
() => parseTenantForeignKeyAuditOptions([], {}),
/DATABASE_URL is required/,
);
assert.throws(
() => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@127.0.0.1:5432/postgres'),
/reserved for tikupro-pg/,
);
assert.throws(
() => assertTenantForeignKeyAuditTarget('postgresql://postgres:postgres@tikupro-pg:55432/postgres'),
/tikupro-pg targets are forbidden/,
);
assert.deepEqual(
assertTenantForeignKeyAuditTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'),
{ host: '127.0.0.1', port: '55432', database: 'postgres', user: 'postgres' },
);
const readiness = fs.readFileSync('scripts/production-readiness-check.js', 'utf8');
assert.match(readiness, /db\.tenant_foreign_keys\.schema/);
const launchGate = fs.readFileSync('scripts/production-launch-gate.js', 'utf8');
assert.match(launchGate, /db\.tenant-foreign-key-audit/);
console.log('[PASS] tenant foreign key audit contract');

View File

@@ -0,0 +1,128 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import pg from 'pg';
import {
assertDestructiveTestDatabase,
describeDatabaseTarget,
resolveDestructiveTestConfirmation,
} from './lib/destructive-test-database-guard.js';
import {
TENANT_FOREIGN_KEY_AUDIT_KIND,
auditTenantForeignKeyData,
loadTenantForeignKeyRelations,
summarizeTenantForeignKeySchema,
tenantForeignKeyExceptions,
} from './lib/tenant-foreign-key-audit.js';
const { Client } = pg;
const BLOCKED_TARGET_PATTERN = /(?:^|[-_.])tikupro(?:-pg)?(?:$|[-_.])/i;
function argumentValue(argv, name) {
const index = argv.indexOf(name);
if (index >= 0) return String(argv[index + 1] || '').trim();
const prefix = `${name}=`;
const item = argv.find(value => value.startsWith(prefix));
return item ? item.slice(prefix.length).trim() : '';
}
export function parseTenantForeignKeyAuditOptions(argv = process.argv.slice(2), env = process.env) {
const databaseUrl = String(env.DATABASE_URL || '').trim();
if (!databaseUrl) throw new Error('DATABASE_URL is required');
const timeoutValue = argumentValue(argv, '--statement-timeout-ms') || env.TENANT_FK_AUDIT_STATEMENT_TIMEOUT_MS || '120000';
const statementTimeoutMs = Number(timeoutValue);
if (!Number.isInteger(statementTimeoutMs) || statementTimeoutMs < 1_000 || statementTimeoutMs > 900_000) {
throw new Error('statement timeout must be an integer between 1000 and 900000 milliseconds');
}
return {
databaseUrl,
statementTimeoutMs,
confirmation: resolveDestructiveTestConfirmation(env, argv),
json: argv.includes('--json'),
quiet: argv.includes('--quiet'),
writePath: argumentValue(argv, '--write'),
};
}
export function assertTenantForeignKeyAuditTarget(databaseUrl) {
const target = describeDatabaseTarget(databaseUrl);
const host = target.host.replace(/^\[(.*)\]$/, '$1').toLowerCase();
if (['127.0.0.1', 'localhost', '::1'].includes(host) && target.port === '5432') {
throw new Error('Refusing tenant foreign key audit: local port 5432 is reserved for tikupro-pg');
}
if ([target.host, target.database, target.user].some(value => BLOCKED_TARGET_PATTERN.test(value))) {
throw new Error('Refusing tenant foreign key audit: tikupro-pg targets are forbidden');
}
return target;
}
export async function runTenantForeignKeyAudit(options) {
const startedAt = new Date();
const target = assertTenantForeignKeyAuditTarget(options.databaseUrl);
const client = new Client({
connectionString: options.databaseUrl,
application_name: 'tiku-tenant-foreign-key-audit',
});
await client.connect();
try {
const safety = await assertDestructiveTestDatabase({
client,
databaseUrl: options.databaseUrl,
confirmation: options.confirmation,
operation: 'tenant foreign key full-data audit on an isolated clone',
});
const relations = await loadTenantForeignKeyRelations(client);
const schema = summarizeTenantForeignKeySchema(relations);
const violations = schema.schemaMatches
? await auditTenantForeignKeyData(client, relations, options.statementTimeoutMs)
: [];
const completedAt = new Date();
return {
schemaVersion: 1,
kind: TENANT_FOREIGN_KEY_AUDIT_KIND,
startedAt: startedAt.toISOString(),
completedAt: completedAt.toISOString(),
durationMs: completedAt.getTime() - startedAt.getTime(),
target,
safety: { databaseEnvironment: safety.environment },
schema,
exceptions: tenantForeignKeyExceptions(),
data: {
auditedRelations: schema.schemaMatches ? relations.length : 0,
invalidRelations: violations.length,
violations,
},
status: schema.schemaMatches && violations.length === 0 ? 'pass' : 'fail',
};
} finally {
await client.end();
}
}
async function main() {
let options;
try {
options = parseTenantForeignKeyAuditOptions();
const artifact = await runTenantForeignKeyAudit(options);
if (options.writePath) {
const outputPath = path.resolve(process.cwd(), options.writePath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
}
if (options.json) console.log(JSON.stringify(artifact, null, 2));
else if (!options.quiet) {
console.log(`Tenant foreign key audit: ${artifact.status.toUpperCase()}`);
console.log(`Relations: ${artifact.schema.relationCount}; exceptions: ${artifact.schema.exceptionCount}; invalid: ${artifact.data.invalidRelations}`);
if (options.writePath) console.log(`Artifact: ${path.resolve(process.cwd(), options.writePath)}`);
}
if (artifact.status !== 'pass') process.exitCode = 1;
} catch (error) {
const failure = { status: 'fail', error: error instanceof Error ? error.message : String(error) };
if (options?.json || process.argv.includes('--json')) console.log(JSON.stringify(failure, null, 2));
else console.error(failure.error);
process.exitCode = 1;
}
}
const currentFile = fileURLToPath(import.meta.url);
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) await main();

View File

@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
const {
hasResolvedTenantPermission,
hasTenantPermission,
} = await import('../apps/api/src/features/tenant-admin/auth.ts');
function auth(role, permissions = {}, templatePermissions = {}) {
return { role, permissions, templatePermissions };
}
assert.equal(
hasTenantPermission(auth('teacher'), 'content:questions:write'),
true,
'teacher role defaults should grant content permissions',
);
assert.equal(
hasTenantPermission(auth('teacher', { 'content:*': false }), 'content:questions:write'),
false,
'member wildcard deny must override role defaults',
);
assert.equal(
hasTenantPermission(auth('tenant_operator', { 'content:questions:write': false }), 'content:questions:write'),
false,
'member exact deny must override role defaults',
);
assert.equal(
hasTenantPermission(auth('teacher', { 'content:questions:write': true, 'content:*': false }), 'content:questions:write'),
true,
'member exact allow must take precedence over a broader member deny',
);
assert.equal(
hasTenantPermission(auth('teacher', {}, { 'content:*': false }), 'content:questions:write'),
false,
'template wildcard deny must override role defaults',
);
assert.equal(
hasTenantPermission(auth('teacher', { 'content:*': false }, { 'content:questions:write': true }), 'content:questions:write'),
false,
'member decisions must take precedence over template decisions',
);
assert.equal(
hasResolvedTenantPermission(auth('teacher'), 'content:analytics:read', false),
false,
'a caller may narrow role defaults for sensitive content permissions',
);
assert.equal(
hasResolvedTenantPermission(auth('teacher', { 'content:analytics:read': true }), 'content:analytics:read', false),
true,
'an explicit member grant should work when the caller role default is denied',
);
assert.equal(
hasResolvedTenantPermission(auth('tenant_owner', { 'content:*': false }), 'content:write', true),
false,
'explicit deny must apply even to a role that is allowed by default',
);
console.log('[PASS] tenant permission resolution precedence');

View File

@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { HttpError } from '../apps/api/src/core/errors.ts';
import { normalizeTenantHost, selectTenantLocator } from '../apps/api/src/features/tenant/locator.ts';
import { resolveTenantRoute } from '../apps/api/src/features/tenant/routes.ts';
const TENANT_ID = '00000000-0000-4000-8000-000000000001';
function tenantRow(overrides = {}) {
return {
id: TENANT_ID,
slug: 'campus-a',
name: 'Campus A',
status: 'active',
mode: 'saas',
host: 'campus-a.example.com',
brand_name: 'Campus A',
short_name: 'Campus A',
slogan: null,
logo_url: null,
favicon_url: null,
service_wechat: null,
service_account_name: null,
theme: {},
public_assets: {},
feature_flags: {},
admin_feature_flags: {},
public_config: {},
...overrides,
};
}
function requestContext(url, headers = {}) {
return {
url: new URL(url),
req: { headers },
res: {},
};
}
async function expectHttpError(run, statusCode, code) {
await assert.rejects(run, error => {
assert.ok(error instanceof HttpError);
assert.equal(error.statusCode, statusCode);
assert.equal(error.code, code);
return true;
});
}
assert.equal(normalizeTenantHost('Example.COM.:443'), 'example.com');
assert.equal(normalizeTenantHost('[::1]:8787'), '::1');
assert.equal(normalizeTenantHost('::1'), '::1');
assert.equal(normalizeTenantHost('bad,forwarded.example.com'), '');
assert.deepEqual(selectTenantLocator({
origin: 'https://Campus-A.Example.com:443',
requestedHost: 'campus-a.example.com.',
requestHost: 'api.example.com',
isProduction: true,
}), {
ok: true,
locator: {
kind: 'host',
host: 'campus-a.example.com',
expectedTenantCode: null,
source: 'browser',
},
});
assert.deepEqual(selectTenantLocator({
origin: 'null',
referer: 'https://campus-a.example.com/pages/student/home',
requestHost: 'api.example.com',
isProduction: true,
}), {
ok: true,
locator: {
kind: 'host',
host: 'campus-a.example.com',
expectedTenantCode: null,
source: 'browser',
},
});
assert.equal(selectTenantLocator({
origin: 'https://campus-a.example.com',
requestedHost: 'campus-b.example.com',
isProduction: true,
}).code, 'TENANT_HOST_CONFLICT');
assert.deepEqual(selectTenantLocator({
origin: 'http://localhost:5173',
requestedHost: 'localhost:5173',
tenantCode: 'campus-a',
requestHost: 'localhost:8787',
isProduction: false,
}), {
ok: true,
locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'local-development' },
});
assert.deepEqual(selectTenantLocator({
tenantCode: 'campus-a',
requestHost: 'api.example.com',
isProduction: true,
}), {
ok: true,
locator: { kind: 'tenantCode', tenantCode: 'campus-a', source: 'headless-client' },
});
assert.equal(selectTenantLocator({
requestedHost: 'campus-a.example.com',
requestHost: 'api.example.com',
isProduction: true,
}).code, 'TENANT_HOST_UNTRUSTED');
{
const calls = [];
const result = await resolveTenantRoute(
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', {
host: 'api.example.com',
origin: 'https://campus-a.example.com',
'x-forwarded-host': 'attacker.example.com',
}),
async (sql, params) => {
calls.push({ sql, params });
return tenantRow();
},
);
assert.equal(result.tenant.id, TENANT_ID);
assert.deepEqual(calls[0].params, ['campus-a.example.com']);
assert.match(calls[0].sql, /d\.status = 'active' and t\.status = 'active'/);
}
await expectHttpError(
() => resolveTenantRoute(
requestContext('https://api.example.com/api/tenant/resolve?host=unknown.example.com', {
host: 'api.example.com',
origin: 'https://unknown.example.com',
}),
async () => null,
),
404,
'TENANT_DOMAIN_NOT_BOUND',
);
await expectHttpError(
() => resolveTenantRoute(
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com&tenantCode=campus-b', {
host: 'api.example.com',
origin: 'https://campus-a.example.com',
}),
async () => tenantRow(),
),
409,
'TENANT_LOCATOR_CONFLICT',
);
await expectHttpError(
() => resolveTenantRoute(
requestContext('https://api.example.com/api/tenant/resolve?host=campus-a.example.com', {
host: 'api.example.com',
origin: 'https://campus-b.example.com',
}),
async () => tenantRow(),
),
409,
'TENANT_HOST_CONFLICT',
);
console.log('[PASS] Tenant resolve fail-closed contract');

View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
CAPACITY_NAMESPACE,
DEFAULT_STUDENT_COUNT,
MAX_STUDENT_COUNT,
buildStudentListQuery,
containsSearchPattern,
parseCapacityOptions,
safeTarget,
} from './tenant-student-capacity.js';
const repoRoot = process.cwd();
const harnessPath = path.join(repoRoot, 'scripts', 'tenant-student-capacity.js');
const apiPath = path.join(repoRoot, 'apps', 'api', 'src', 'features', 'tenant-admin', 'classes.ts');
const migrationPath = path.join(
repoRoot,
'supabase',
'migrations',
'202607120011_tenant_student_search_pagination.sql',
);
const runbookPath = path.join(repoRoot, 'docs', 'refactor', 'tenant-student-capacity-runbook.md');
const harness = fs.readFileSync(harnessPath, 'utf8');
const api = fs.readFileSync(apiPath, 'utf8');
const migration = fs.readFileSync(migrationPath, 'utf8');
const runbook = fs.readFileSync(runbookPath, 'utf8');
const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const defaults = parseCapacityOptions([], {});
assert.equal(defaults.mode, 'plan');
assert.equal(defaults.count, DEFAULT_STUDENT_COUNT);
assert.equal(DEFAULT_STUDENT_COUNT, MAX_STUDENT_COUNT);
assert.equal(defaults.databaseUrl, '');
assert.throws(() => parseCapacityOptions(['--mode=seed'], {}), /DATABASE_URL is required/);
assert.throws(
() => parseCapacityOptions(['--count=100001'], {}),
/count must be between 10 and 100000/,
);
assert.throws(
() => parseCapacityOptions(['--tenant-slug=master'], {}),
/tenant slug must match capacity-test-/,
);
assert.equal(containsSearchPattern('a_b%c\\d'), '%a\\_b\\%c\\\\d%');
assert.throws(
() => safeTarget('postgresql://postgres:secret@127.0.0.1:5432/postgres'),
/local port 5432 is reserved for tikupro-pg/,
);
assert.throws(
() => safeTarget('postgresql://postgres:secret@tikupro-pg:5432/postgres'),
/tikupro-pg targets are forbidden/,
);
assert.deepEqual(safeTarget('postgresql://postgres:secret@127.0.0.1:55432/postgres'), {
host: '127.0.0.1',
port: '55432',
database: 'postgres',
user: 'postgres',
});
assert.match(harness, /assertDestructiveTestDatabase\s*\(/);
assert.match(harness, /pg_try_advisory_lock/);
assert.match(harness, /generate_series\(\$4::integer, \$5::integer\)/);
assert.match(harness, /on conflict \(legacy_id\)/i);
assert.match(harness, /on conflict \(tenant_id, user_id, role\)/i);
assert.match(harness, /on conflict \(tenant_id, user_id\)/i);
assert.match(harness, /raw_profile #>> '\{capacityHarness,namespace\}'/);
assert.match(harness, /metadata #>> '\{capacityHarness,namespace\}'/);
assert.match(harness, /auth_user_id is not null/);
assert.match(harness, /tm\.tenant_id <> \$3/);
assert.match(harness, /dedicated tenant contains unmanaged memberships/);
assert.match(harness, /EXPLAIN \(ANALYZE, BUFFERS, FORMAT JSON\)/i);
assert.match(harness, /MAX_STUDENT_COUNT = 100_000/);
assert.match(harness, /timingsMs/);
const databaseModeStart = harness.indexOf('async function runDatabaseMode(options)');
const databaseModeEnd = harness.indexOf('\nexport async function main', databaseModeStart);
const databaseMode = harness.slice(databaseModeStart, databaseModeEnd);
assert.ok(databaseModeStart >= 0 && databaseModeEnd > databaseModeStart);
assert.ok(
databaseMode.indexOf('assertDestructiveTestDatabase({') < databaseMode.indexOf('seedFixture(client, options)'),
'the destructive database guard must execute before fixture writes',
);
for (const shape of [{}, { cursor: true }, { keyword: true }]) {
const benchmarkSql = buildStudentListQuery(shape).sql;
for (const fragment of [
'with student_page as materialized',
'join public.platform_users u on u.id = tm.user_id',
'left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id',
'join student_page page on page.tenant_id = tcm.tenant_id and page.user_id = tcm.user_id',
'order by tm.created_at desc, tm.id desc',
]) {
assert.ok(benchmarkSql.includes(fragment), `benchmark SQL must contain ${fragment}`);
assert.ok(api.includes(fragment), `API SQL must contain ${fragment}`);
}
}
const identityExpressionFragments = [
"coalesce(u.username, '') || ' '",
"coalesce(u.name, '') || ' '",
"coalesce(u.phone, '') || ' '",
"coalesce(u.email::text, '')",
];
for (const fragment of identityExpressionFragments) {
assert.ok(buildStudentListQuery({ keyword: true }).sql.includes(fragment));
assert.ok(api.includes(fragment));
assert.ok(migration.includes(fragment.replaceAll('u.', '')));
}
assert.match(migration, /create extension if not exists pg_trgm with schema extensions/i);
assert.match(
migration,
/idx_memberships_student_keyset_page[\s\S]*\(tenant_id, status, created_at desc, id desc\)[\s\S]*where role = 'student'/i,
);
assert.match(
migration,
/idx_platform_users_identity_search_trgm[\s\S]*using gin[\s\S]*gin_trgm_ops/i,
);
assert.equal(CAPACITY_NAMESPACE, 'tiku.student-capacity.v1');
for (const scriptName of [
'perf:tenant-students:plan',
'perf:tenant-students:run',
'perf:tenant-students:evidence',
'perf:tenant-students:benchmark',
'perf:tenant-students:cleanup',
'test:tenant-students:capacity:contract',
'test:tenant-students:capacity:smoke',
]) {
assert.ok(packageJson.scripts?.[scriptName], `missing package script ${scriptName}`);
}
assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /--count=250/);
assert.match(
packageJson.scripts['test:tenant-students:capacity:smoke'],
/--tenant-slug=capacity-test-students-smoke/,
);
assert.match(packageJson.scripts['test:tenant-students:capacity:smoke'], /SMOKE_SEED_LOCAL_OR_CI_ONLY/);
assert.ok(packageJson.scripts['test:readiness'].includes('tenant-student-capacity-contract-test.js'));
assert.match(runbook, /capacity-test-/);
assert.match(runbook, /tikupro-pg/i);
assert.match(runbook, /SMOKE_SEED_LOCAL_OR_CI_ONLY/);
assert.match(runbook, /100000/);
assert.match(runbook, /perf:tenant-students:evidence/);
assert.match(runbook, /cleanupVerified=true/);
assert.match(runbook, /cleanup/i);
assert.match(runbook, /not a production SLA/i);
console.log('[PASS] tenant student capacity harness contract');

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
const {
containsSearchPattern,
decodeTenantStudentsCursor,
encodeTenantStudentsCursor,
} = await import('../apps/api/src/features/tenant-admin/student-cursor.ts');
const cursor = {
createdAt: '2026-07-12T03:04:05.678Z',
membershipId: '11111111-1111-4111-8111-111111111111',
};
const encoded = encodeTenantStudentsCursor(cursor);
assert.deepEqual(decodeTenantStudentsCursor(encoded), cursor, 'student cursor should round-trip');
assert.equal(decodeTenantStudentsCursor(''), null, 'empty cursor should start from the first page');
assert.equal(containsSearchPattern('100%_ready\\now'), '%100\\%\\_ready\\\\now%', 'student search should escape LIKE metacharacters');
for (const invalid of [
'not-a-valid-cursor',
Buffer.from(JSON.stringify({ version: 2, ...cursor })).toString('base64url'),
Buffer.from(JSON.stringify({ version: 1, createdAt: 'not-a-date', membershipId: cursor.membershipId })).toString('base64url'),
Buffer.from(JSON.stringify({ version: 1, createdAt: cursor.createdAt, membershipId: 'not-a-uuid' })).toString('base64url'),
]) {
assert.throws(
() => decodeTenantStudentsCursor(invalid),
error => error?.code === 'INVALID_STUDENT_CURSOR' && error?.statusCode === 400,
'malformed student cursors must fail closed',
);
}
console.log('[PASS] tenant student cursor contract');

View File

@@ -0,0 +1,138 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { parseWorkerCli, resolveWorkerMonth } from '../apps/worker/src/cli.ts';
const root = process.cwd();
function read(relativePath) {
return fs.readFileSync(path.join(root, relativePath), 'utf8').replace(/\r\n/g, '\n');
}
assert.throws(
() => parseWorkerCli(['--loop']),
/--job is required exactly once/,
'a production loop without an explicit job must fail closed',
);
assert.deepEqual(parseWorkerCli(['--loop', '--job', 'crm']), { job: 'crm', loop: true, month: undefined });
assert.throws(
() => parseWorkerCli(['--loop', '--job', 'platform-billing']),
/periodic and must be scheduled with --once/,
);
assert.deepEqual(
parseWorkerCli(['--once', '--job', 'platform-usage', '--month', 'previous']),
{ job: 'platform-usage', loop: false, month: resolveWorkerMonth('previous') },
);
assert.throws(() => parseWorkerCli(['--once', '--job', 'assets', '--month', 'previous']), /only supported/);
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', '--unknown']), /Unknown worker option/);
assert.throws(() => parseWorkerCli(['--once', '--job', 'crm', 'stray']), /Unexpected worker argument/);
const workerService = read('scripts/deploy/systemd/tiku-worker@.service');
const periodicService = read('scripts/deploy/systemd/tiku-worker-job@.service');
const monthlyService = read('scripts/deploy/systemd/tiku-worker-monthly-usage.service');
const target = read('scripts/deploy/systemd/tiku-workers.target');
const workerEnv = read('scripts/deploy/env/worker.env.example');
const workerConfig = read('apps/worker/src/config.ts');
const sharedDbConfig = read('packages/db/src/index.ts');
assert.match(workerService, /ExecStart=.*--loop --job %i/);
assert.match(periodicService, /Type=oneshot/);
assert.match(periodicService, /ExecStart=.*--once --job %i/);
assert.match(monthlyService, /--job platform-usage --month previous/);
assert.match(monthlyService, /--job platform-usage-overage --month previous/);
const continuousJobs = [
'crm',
'commerce',
'provider-bills',
'platform-dunning-notifications',
'platform-audit-notifications',
'assets',
'imports',
'public-banks',
'exports',
];
for (const job of continuousJobs) {
assert.match(target, new RegExp(`Requires=tiku-worker@${job}\\.service`), `missing continuous worker ${job}`);
}
const timerNames = [
'platform-billing',
'platform-usage',
'platform-dunning',
'platform-audit-alerts',
'student-supervision',
'monthly-usage',
];
for (const name of timerNames) {
const timerPath = `scripts/deploy/systemd/tiku-worker-${name}.timer`;
assert.ok(fs.existsSync(path.join(root, timerPath)), `missing timer ${timerPath}`);
assert.match(target, new RegExp(`Wants=tiku-worker-${name}\\.timer`), `target must want ${name} timer`);
const timer = read(timerPath);
if (timer.includes('OnCalendar=')) {
assert.match(timer, /\nPersistent=true\n/, `${timerPath} must catch up after downtime`);
} else {
assert.match(timer, /\nOnBootSec=/, `${timerPath} must resume its monotonic cadence after boot`);
}
assert.match(timer, /\nUnit=tiku-worker-(?:job@[^\n]+|monthly-usage\.service)\n/, `${timerPath} must name an explicit service`);
}
const envKeys = new Set(
[
...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'/g),
...sharedDbConfig.matchAll(/positiveEnvNumber\(\s*'([A-Z0-9_]+)'/g),
...sharedDbConfig.matchAll(/process\.env\.([A-Z0-9_]+)/g),
].map(match => match[1]),
);
const workerEnvKeys = [...workerEnv.matchAll(/^([A-Z][A-Z0-9_]*)=/gm)].map(match => match[1]);
for (const key of workerEnvKeys) {
if (['NODE_ENV', 'DATABASE_URL', 'DB_EXPECTED_RUNTIME_ROLE'].includes(key)) continue;
assert.ok(envKeys.has(key), `worker.env.example contains a key not read by worker config: ${key}`);
}
for (const key of [
'STORAGE_DEFAULT_BUCKET',
'WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT',
'WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN',
'WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS',
'WORKER_CRM_POLL_INTERVAL_MS',
'WORKER_COMMERCE_POLL_INTERVAL_MS',
'WORKER_PROVIDER_BILL_POLL_INTERVAL_MS',
'WORKER_PLATFORM_DUNNING_NOTIFICATION_POLL_INTERVAL_MS',
'WORKER_PLATFORM_AUDIT_NOTIFICATION_POLL_INTERVAL_MS',
'WORKER_ASSET_POLL_INTERVAL_MS',
'WORKER_IMPORT_POLL_INTERVAL_MS',
'WORKER_PUBLIC_BANK_SYNC_POLL_INTERVAL_MS',
'WORKER_EXPORT_POLL_INTERVAL_MS',
]) {
assert.match(workerEnv, new RegExp(`^${key}=`, 'm'), `worker env must document ${key}`);
}
const schedulerEnvKeys = new Set([
'WORKER_PLATFORM_USAGE_MONTH',
'WORKER_PLATFORM_USAGE_OVERAGE_MONTH',
'ALIYUN_OSS_STS_TOKEN',
'EXPORT_PDF_FONT_PATH',
]);
const codeDefaults = new Map(
[...workerConfig.matchAll(/env(?:String|Number|Boolean|List)\(\s*'([A-Z0-9_]+)'\s*,\s*([^\n,)]+)/g)]
.map(match => [match[1], match[2].trim()]),
);
for (const line of workerEnv.split('\n')) {
const match = line.match(/^([A-Z][A-Z0-9_]*)=(.*)$/);
if (!match || schedulerEnvKeys.has(match[1])) continue;
if (codeDefaults.get(match[1]) === "''") {
assert.notEqual(match[2], '', `worker env must not leave required ${match[1]} empty`);
}
}
for (const legacyKey of [
'ALIYUN_OSS_BUCKET',
'ASSET_SECURITY_SCAN_ENDPOINT',
'ASSET_SECURITY_SCAN_TOKEN',
'WORKER_POLL_INTERVAL_MS',
]) {
assert.doesNotMatch(workerEnv, new RegExp(`^${legacyKey}=`, 'm'), `worker env must not expose unused ${legacyKey}`);
}
console.log('[PASS] worker CLI and production scheduling contract');