Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/deployment-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,22 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Production configuration validation (critical)
- name: Production configuration validation (advisory)
# Audit 2026-05-28: this check has been failing on every main
# push for months because it tries to verify production secrets
# (FOUNDER_EMAILS, STRIPE_*, RESEND_*, UPSTASH_*, CRON_SECRET,
# SENTRY_*) against process.env in CI, but those secrets live
# only in Vercel — they were never mirrored to GitHub Actions
# secrets. The workflow can't actually block production deploys
# either, because Vercel's git integration deploys main on its
# own schedule independent of this gate.
#
# Demoted to advisory so the summary still surfaces what's
# missing in CI (operators can mirror as needed), but a red gate
# doesn't hide real failures from the rest of the pipeline.
# Re-promote to blocking once GitHub Actions secrets mirror
# Vercel prod and this step passes clean for a week.
continue-on-error: true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the env demotion cover the build step too

When GitHub Actions does not have FOUNDER_EMAILS mirrored (one of the secrets this new comment says lives only in Vercel), this step being advisory does not make the gate green: the same job still runs Build verification with npm run build later in .github/workflows/deployment-gates.yml:116-117, and package.json:20-21 defines a prebuild that runs CHECK_ENV_STRICT=1 npm run check-env. In the default non-Vercel profile, scripts/check-env.js:51-58 still treats FOUNDER_EMAILS as required, so the workflow remains failed for the stated environment instead of merely surfacing the production-config warning.

Useful? React with 👍 / 👎.

run: npm run check:production-config

- name: TypeScript compilation (critical)
Expand Down
24 changes: 4 additions & 20 deletions e2e/auth-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ test.describe('Auth provisioning invariant', () => {
.delete()
.eq('organization_id', orgId);
await admin.from('org_members').delete().eq('organization_id', orgId);
await admin.from('orgs').delete().eq('id', orgId);
// public.orgs dropped by migration 20260624051 (R2 Phase B).
await admin.from('organizations').delete().eq('id', orgId);
}

Expand Down Expand Up @@ -361,25 +361,9 @@ test.describe('Auth provisioning invariant', () => {
const orgId = org!.id as string;
createdOrgIds.add(orgId);

{
// Mirror to legacy `orgs` table — propagate errors so silent
// upsert failures don't leak reverse-direction orphans (v4-001).
const { error: legacyOrgsError } = await admin.from('orgs').upsert(
{
id: orgId,
name: `QA ${framework.slug.toUpperCase()} Org`,
created_by: userId,
created_at: now,
updated_at: now,
},
{ onConflict: 'id' },
);
if (legacyOrgsError) {
throw new Error(
`legacy_orgs_mirror_failed: ${legacyOrgsError.message}`,
);
}
}
// Legacy `public.orgs` mirror removed: migration 20260624051
// (R2 Phase B, commit 6126ab21) dropped the table after repointing
// every dependent FK to organizations(id).

await admin.from('org_members').insert({
organization_id: orgId,
Expand Down
31 changes: 6 additions & 25 deletions e2e/auth/mfa-enforcement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,9 @@ test.afterAll(async () => {
try {
await admin.from('org_subscriptions').delete().eq('organization_id', orgId);
await admin.from('org_members').delete().eq('organization_id', orgId);
// `organizations` is canonical; the DB trigger
// `trg_mirror_organizations_delete_to_orgs` (migration 20260624029)
// removes the matching `orgs` row automatically. Deleting `orgs`
// explicitly would race the trigger and previously produced drift
// when organizations.delete failed silently (Supabase .delete()
// returns {error} rather than throwing).
// public.orgs (and its mirror triggers) dropped by migration
// 20260624051 (R2 Phase B, commit 6126ab21). organizations(id) is
// now the only source of truth.
await admin.from('organizations').delete().eq('id', orgId);
} catch {
// ignore
Expand Down Expand Up @@ -122,25 +119,9 @@ async function provisionMfaUser() {
}
createdOrgIds.push(org.id);

// Mirror to legacy `orgs` table (v4-001). The bootstrap path normally
// does this for real users, but this spec inserts directly via admin
// client and never triggers bootstrap. Without this mirror the
// organizations row leaves a reverse-direction orphan.
const { error: legacyOrgsError } = await admin.from('orgs').upsert(
{
id: org.id,
name: `MFA Test Org ${id}`,
created_by: created.user.id,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{ onConflict: 'id' },
);
if (legacyOrgsError) {
throw new Error(
`Failed to mirror MFA test org to legacy orgs: ${legacyOrgsError.message}`,
);
}
// Legacy public.orgs mirror removed: migration 20260624051 (R2 Phase B,
// commit 6126ab21) dropped the table after repointing every dependent
// FK to organizations(id).

await admin.from('org_members').insert({
user_id: created.user.id,
Expand Down
10 changes: 9 additions & 1 deletion e2e/onboarding-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import { expect, test } from '@playwright/test';
import { createClient } from '@supabase/supabase-js';

Expand Down Expand Up @@ -361,11 +362,17 @@ test.describe('Onboarding first-session flow', () => {
.select('id')
.single();
const taskId = (taskRow as { id: string } | null)?.id;
// file_hash is NOT NULL since commit 7fd40ffa (audit 2026-05-27).
const evidenceContent = `e2e-onboarding evidence ${unique}`;
const evidenceHash = createHash('sha256')
.update(evidenceContent, 'utf8')
.digest('hex');
await admin.from('org_evidence').insert({
organization_id: orgId,
task_id: taskId,
file_name: 'e2e-evidence.pdf',
file_path: `evidence/e2e-${unique}.pdf`,
file_hash: evidenceHash,
});

await page.goto('/app', { waitUntil: 'domcontentloaded' });
Expand Down Expand Up @@ -411,7 +418,8 @@ test.describe('Onboarding first-session flow', () => {
.eq('organization_id', orgId);
await admin.from('org_members').delete().eq('organization_id', orgId);
await admin.from('organizations').delete().eq('id', orgId);
await admin.from('orgs').delete().eq('id', orgId);
// public.orgs dropped by migration 20260624051 (R2 Phase B); the
// legacy mirror delete used to live here.
}
if (userId) {
await admin.auth.admin.deleteUser(userId);
Expand Down
18 changes: 5 additions & 13 deletions e2e/product-walkthrough.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ test.afterAll(async () => {
.delete()
.eq('organization_id', orgId);
await admin.from('org_members').delete().eq('organization_id', orgId);
await admin.from('orgs').delete().eq('id', orgId);
// public.orgs dropped by migration 20260624051 (R2 Phase B).
await admin.from('organizations').delete().eq('id', orgId);
}
}
Expand Down Expand Up @@ -180,17 +180,9 @@ async function createQAUser(

const orgId = org.id;

// Create legacy orgs entry
await admin.from('orgs').upsert(
{
id: orgId,
name: fallbackName,
created_by: userId,
created_at: now,
updated_at: now,
},
{ onConflict: 'id' },
);
// Legacy public.orgs mirror removed: migration 20260624051 (R2 Phase B,
// commit 6126ab21) dropped the table after repointing every dependent
// FK to organizations(id).

// Create membership
await admin.from('org_members').insert({
Expand Down Expand Up @@ -516,7 +508,7 @@ test.describe('E) Edge Cases', () => {
// Cleanup
await admin.from('org_members').delete().eq('organization_id', org.id);
await admin.from('organizations').delete().eq('id', org.id);
await admin.from('orgs').delete().eq('id', org.id);
// public.orgs dropped by migration 20260624051 (R2 Phase B).
}

await admin.auth.admin.deleteUser(userId);
Expand Down
18 changes: 6 additions & 12 deletions e2e/trial-provisioning-guarantee.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,18 +126,12 @@ async function cleanupUser(userId: string) {
}
}

async function mirrorLegacyOrg(org: { id: string; name?: string | null }) {
const { error } = await admin!.from('orgs').upsert(
{
id: org.id,
name: org.name ?? `Legacy Trial Org ${org.id}`,
created_by: null,
updated_at: new Date().toISOString(),
},
{ onConflict: 'id' },
);

expect(error).toBeNull();
async function mirrorLegacyOrg(_org: { id: string; name?: string | null }) {
// No-op since migration 20260624051 (R2 Phase B, commit 6126ab21)
// dropped public.orgs after repointing every dependent FK to
// organizations(id). Kept as a function so the call sites compile
// without churn; safe to inline-delete once the next round of e2e
// refactors runs.
}

test.describe('Legacy Trialing Subscription - Data Integrity', () => {
Expand Down
19 changes: 18 additions & 1 deletion scripts/check-restore-test-recency.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ if (!latest) {
// First-run state: ledger empty. Warn but don't fail — gives the
// operator a window to record the first drill without blocking
// deploys. After the first row lands, the 35-day gap rule kicks in.
//
// OPERATOR HEADS-UP: the moment the FIRST restore_test_runs row
// lands, this check stops being warn-only — it starts blocking after
// (first row's performed_at + 35 days). Calendar a follow-up drill
// within that window or this gate will go red on the next main push
// past day 35.
console.warn(
`⚠️ No restore_test_runs row recorded yet. Run the first DR drill — ` +
`see docs/operations/pitr-restore-runbook.md. ` +
Expand All @@ -43,6 +49,11 @@ if (!latest) {
process.exit(0);
}

// After-first-row state: emit the time-bomb date in the success path
// so operators see "next drill due by YYYY-MM-DD" in their CI summary
// without having to do the arithmetic. Quiet on warn-only path (above)
// since there's no row to anchor the math.

const days = latest.days_since;
if (days > MAX_AGE_DAYS) {
console.error(
Expand All @@ -53,5 +64,11 @@ if (days > MAX_AGE_DAYS) {
process.exit(1);
}

console.log(`✓ Latest restore test: ${latest.outcome} (${days} days ago, within ${MAX_AGE_DAYS}-day window).`);
const nextDueIso = new Date(
new Date(latest.performed_at).getTime() + MAX_AGE_DAYS * 86_400_000,
).toISOString().slice(0, 10);
console.log(
`✓ Latest restore test: ${latest.outcome} (${days} days ago, within ${MAX_AGE_DAYS}-day window). ` +
`Next drill due by ${nextDueIso} or this gate goes red.`,
);
process.exit(0);
Loading