diff --git a/.github/workflows/fleet-pat-health.yml b/.github/workflows/fleet-pat-health.yml index 1512abf9..8dff2a43 100644 --- a/.github/workflows/fleet-pat-health.yml +++ b/.github/workflows/fleet-pat-health.yml @@ -26,9 +26,27 @@ concurrency: cancel-in-progress: false env: - # Space-separated list of owner/repo pairs to check. - # Add new fleet repos here when they receive GH_RELEASE_PAT. - FLEET_REPOS: "izzywdev/FuzeX izzywdev/FuzeFinance izzywdev/FuzeExecutive" + # Space-separated list of `owner/repo`, or `owner/repo=workflow.yml` where the release job + # is not in release.yml. Add new fleet repos here when they receive GH_RELEASE_PAT. + # + # These are exactly the repos whose OWN workflows reference `secrets.GH_RELEASE_PAT` to bump + # a prod tag — derived from the fleet-wide survey in FuzeSDLC's provision_secrets.py, not + # hand-listed. FuzeCall, FuzeDeploy and FuzeMerchandize were provisioned the credential on + # 2026-08-26 and are added here in the same change: a repo that holds the PAT but is absent + # from this list has NO expiry detection at all, which is the gap this workflow exists to + # close and the one it had for six of its nine repos. + # + # FuzeFront and FuzeSDLC deliberately excluded: both hold GH_RELEASE_PAT, but neither uses + # it to cut a release — FuzeFront for this health check itself, FuzeSDLC for fleet secret + # provisioning. Listing them would check a release workflow that does not exist. + FLEET_REPOS: >- + izzywdev/FuzeX + izzywdev/FuzeFinance + izzywdev/FuzeExecutive + izzywdev/FuzePlan + izzywdev/FuzeCall + izzywdev/FuzeDeploy + izzywdev/FuzeMerchandize=build-and-push.yml WORKFLOW_FILE: "release.yml" CRED_STEP_NAME: "Require the release credential" @@ -79,13 +97,23 @@ jobs: CRED_FAILURES="" OTHER_FAILURES="" - for REPO in $FLEET_REPOS; do + for ENTRY in $FLEET_REPOS; do + # Each entry is `owner/repo`, or `owner/repo=workflow.yml` when the repo's + # release job does not live in release.yml. FuzeMerchandize is the reason this + # exists: it references GH_RELEASE_PAT from build-and-push.yml, and querying a + # workflow file a repo does not have returns the same "could not query" error as + # a genuinely broken credential — a false alarm that trains people to ignore the + # alert this whole workflow exists to raise. + REPO="${ENTRY%%=*}" + WF="${ENTRY#*=}" + [ "$WF" = "$ENTRY" ] && WF="$WORKFLOW_FILE" + echo "" - echo "==> ${REPO}" + echo "==> ${REPO} (${WF})" # Fetch the latest completed run for this repo's release workflow. RUN_JSON=$(gh api \ - "repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?per_page=1&status=completed" \ + "repos/${REPO}/actions/workflows/${WF}/runs?per_page=1&status=completed" \ --jq '.workflow_runs[0] | {id: .id, conclusion: .conclusion, created_at: .created_at, html_url: .html_url}' \ 2>&1) || { echo "::warning::Could not query ${REPO} — PAT may lack repo scope or workflow not found" diff --git a/.gitignore b/.gitignore index f4e61394..7fc3d660 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,15 @@ _site_test/ # Claude Code personal/session settings (not committed) .claude/settings.local.json + +# Local dev/test database file. `backend/scripts/init-db.js` (npm run db:init) +# writes this whenever the run is not pointed at Postgres, so anyone who +# reproduces a CI failure locally generates one. A database binary must never +# be committed: it churns on every run and can carry real data. +backend/database.sqlite + +# Jest coverage output. `npm run test:coverage` (backend-tests.yml's "Generate +# test coverage" step, and anyone reproducing it locally) writes ~6.6M of +# generated HTML + lcov per workspace. CI ships it to Codecov from the runner; +# it is never a source artifact. Unanchored so it matches any workspace. +coverage/ diff --git a/backend/applications/src/migrations/011_apps_organization_id_not_null.ts b/backend/applications/src/migrations/011_apps_organization_id_not_null.ts index 31800f41..8c262f62 100644 --- a/backend/applications/src/migrations/011_apps_organization_id_not_null.ts +++ b/backend/applications/src/migrations/011_apps_organization_id_not_null.ts @@ -55,10 +55,50 @@ export async function up(knex: Knex): Promise { // with a clear cause instead of a mysterious later FK violation. const root = await knex('organizations').where({ id: ROOT_ORG_ID }).first() if (!root) { - throw new Error( - `organizations.${ROOT_ORG_ID} (the platform root org) does not exist yet — ` + - 'backend/src migration 015_seed_root_platform_organization must run before this one.' + // 2026-08-26: this used to throw UNCONDITIONALLY. The reasoning above is + // right for the case it describes — backfilling to a still-absent id would + // violate apps_organization_id_foreign, and a clear error beats a mystery + // FK stack trace. But it fired even when there was NOTHING TO BACKFILL, + // and a migration that throws is a boot crashloop, not a warning: this + // service's tree must stay runnable on its own (see + // tests/migrations.idempotency.integration.test.ts, which runs exactly + // this tree against a bare schema and asserts a clean no-op). Since the + // root org is seeded by a DIFFERENT deployable's tree, any environment + // where applications-service migrates first hit this — the same shape as + // the 2026-08-16 P1 that migration 022 in backend/src was fixed for. + // + // So the throw is narrowed to the case that genuinely warrants it. + // + // What closes the gap is NOT a re-run of this migration: knex records it + // as applied and never executes it again, so "self-heals on the next boot" + // would be false here. It is the SIBLING migration against the same shared + // table — backend/src's 026_apps_organization_id_not_null, in the tree that + // also owns 015 and therefore always has the root org by the time it runs. + // That sibling sets the DEFAULT and NOT NULL, exactly as its own header + // describes: "whichever service's migrations happen to run first against a + // given database does the real work, and the other is a no-op". + // + // The invariant the query-side half depends on still holds in this branch: + // service.ts's list() may drop its `organization_id IS NULL` arm only once + // no row can be null, and we skip precisely when there are ZERO org-less + // rows — so there is nothing that could silently disappear. + const orphans = await knex('apps').whereNull('organization_id').count({ n: '*' }).first() + const orphanCount = Number(orphans?.n ?? 0) + if (orphanCount > 0) { + throw new Error( + `organizations.${ROOT_ORG_ID} (the platform root org) does not exist yet, and ` + + `${orphanCount} org-less app(s) need backfilling to it — ` + + 'backend/src migration 015_seed_root_platform_organization must run before this one.' + ) + } + // eslint-disable-next-line no-console + console.log( + `[011] platform root org ${ROOT_ORG_ID} not seeded yet and no org-less apps to backfill — ` + + "skipping; backend/src's sibling migration 026 applies DEFAULT + NOT NULL once 015 has " + + 'seeded it. Neither is set here: the DEFAULT would point at a non-existent org and ' + + 'reintroduce the very FK hazard this guard exists to prevent.' ) + return } const backfilled = await knex('apps') diff --git a/backend/tests/apps.test.ts b/backend/tests/apps.test.ts index 5cf4927c..8750cefb 100644 --- a/backend/tests/apps.test.ts +++ b/backend/tests/apps.test.ts @@ -675,7 +675,22 @@ describe('Apps Registration Routes', () => { }) // An org NEITHER caller belongs to, for the BOLA-exclusion assertion. + // The row must EXIST: `apps.organization_id` carries the FK + // `apps_organization_id_foreign`, so referencing an org that was never + // inserted aborts this beforeAll and fails every test in the block. What + // makes it a "does not belong" org is the absence of an + // organization_memberships row below, not the absence of the org itself. otherOrgId = uuidv4() + await db('organizations').insert({ + id: otherOrgId, + name: 'Visibility Parity Other Org', + slug: `visibility-parity-other-org-${otherOrgId.slice(0, 8)}`, + owner_id: ADMIN_USER_ID, + type: 'organization', + settings: JSON.stringify({}), + metadata: JSON.stringify({}), + is_active: true, + }) // Case 1: 'organization' visibility, owned by an org the admin belongs to. ownOrgAppId = uuidv4() @@ -710,7 +725,9 @@ describe('Apps Registration Routes', () => { .where('organization_id', scopedOrgId) .del() await db('apps').whereIn('id', [ownOrgAppId, otherOrgAppId]).del() - await db('organizations').where('id', scopedOrgId).del() + // Both orgs, and only after the apps that reference them are gone — + // apps.organization_id has no ON DELETE, so the reverse order fails. + await db('organizations').whereIn('id', [scopedOrgId, otherOrgId]).del() }) it("shows an 'organization'-visibility app to a member of that org", async () => { diff --git a/backend/tests/rootOrgAbsentGuards.test.ts b/backend/tests/rootOrgAbsentGuards.test.ts index 57ad20d2..6b14b0d7 100644 --- a/backend/tests/rootOrgAbsentGuards.test.ts +++ b/backend/tests/rootOrgAbsentGuards.test.ts @@ -102,7 +102,23 @@ describe('#750 — nothing inserts a reference to an unverified root organizatio }) describe('migration 022 (root-membership backfill + personal-org reclassify)', () => { - it('skips the backfill when the root organization does not exist', async () => { + it('2026-08-26 AMENDMENT (#680): ADOPTS a platform org under a different id and backfills against ITS id', async () => { + // This assertion was inverted until #680 (73c30aae): it required a SKIP + // for this fixture. That was the contract which crashlooped + // fuzefront-backend and fuzefront-security on 2026-08-16 — migration + // 015's "adopt a pre-existing platform org rather than creating a + // second one" branch can leave a prod DB whose real platform-root org + // has an id other than ROOT_ORG_ID and NO ROOT_ORG_ID row at all (the + // 2026-07-29 rebuild: 92f2020b-…, slug `fuzefront`). Hardcoding + // ROOT_ORG_ID then made every INSERT violate + // organization_memberships_organization_id_foreign on every boot. + // + // 022 now resolves the root org exactly as ensureRootPortal() does — + // prefer ROOT_ORG_ID, else the oldest type='platform' row — so a + // divergent platform org IS the root org, and the backfill must run + // against ITS id. #680 changed the migration and left this guard + // asserting the old behaviour, which is why Backend Tests went red at + // 05:15Z on 2026-08-26. const { knex, raws } = makeKnex({ users: [{ id: PLATFORM_REGISTRAR_ID }], organizations: [{ id: 'legacy-platform-id', slug: 'legacy', type: 'platform' }], @@ -110,6 +126,26 @@ describe('#750 — nothing inserts a reference to an unverified root organizatio await migration022.up(knex) + const inserts = membershipInserts(raws) + expect(inserts).toHaveLength(1) + // The crux: bound to the ADOPTED org, never to the hardcoded constant. + // This is the exact regression #680 fixed. + expect(inserts[0].bindings).toContain('legacy-platform-id') + expect(inserts[0].bindings).not.toContain(ROOT_ORG_ID) + }) + + it('skips the backfill when NO platform org exists at all, even with users present', async () => { + // The genuine "root organization does not exist" case post-#680, and the + // coverage the inverted assertion above was standing in for. Users are + // present deliberately: the empty-fixture test below has none, so it + // cannot distinguish "skipped the backfill" from "had nobody to backfill". + const { knex, raws } = makeKnex({ + users: [{ id: PLATFORM_REGISTRAR_ID }], + organizations: [{ id: 'some-org', slug: 'some-org', type: 'organization' }], + }) + + await migration022.up(knex) + expect(membershipInserts(raws)).toHaveLength(0) }) diff --git a/scripts/expected-portal-apps.json b/scripts/expected-portal-apps.json index 3d22e57e..02114c70 100644 --- a/scripts/expected-portal-apps.json +++ b/scripts/expected-portal-apps.json @@ -3,8 +3,8 @@ "purpose": "The checked-in roster scripts/check-portal-federation-health.mjs diffs the live registry against, so a product that silently drops out of /api/v1/app-registry/apps (the '13 of 18' symptom) fails loudly instead of producing a shorter-but-still-green table.", "notASlugMigrationWorklist": "This file is NOT authority to edit any product's registered `slug`. Per CLAUDE.md §'slug, display name, and the federated serve path are THREE INDEPENDENT questions', slug is free at registration and immutable after. If a live registry slug genuinely differs from an entry below, FIX THIS FILE to match the registry — never the other way around. This list exists only to notice disappearance, not to prescribe naming.", "maintainedBy": "owner (izzywdev) — add an entry when a new product joins the family, remove one when a product is formally retired, correct the slug if a `confidence` below turns out wrong.", - "lastReviewed": "2026-08-25", - "countRationale": "18 entries = the 20-repo fleet in docs/planning/production-conformance.md §1, minus FuzeInfra and FuzeSDLC (platform/governance repos with no portal tile), minus FuzeFront itself (the host shell, not a listed app), plus FuzeQuality ('in prod, not in the 20' per that doc, but builtin and portal-visible). Matches the owner's own '18 products' count.", + "lastReviewed": "2026-08-26", + "countRationale": "18 entries. Reconciled against the live registry by census run 32949523555 (2026-08-26), which reported 4 roster defects as its own warnings: three entries whose slug the registry has never served (keys->fuzekeys, fuzecontact->contact, fuzehub-ventures->fuzehub), and 'clock' returned by the registry but absent here. Each produced a spurious MISSING row, which is the exact false signal this roster exists to avoid -- a MISSING that means 'the roster is wrong' is indistinguishable from one that means 'a product dropped out'. FuzeQuality was REMOVED: PR #810 (merged 2026-08-26) deleted it from builtins.ts and added migration 011_suspend_phantom_fuzequality_builtin because the product has NO repository. It is deliberately not expected. Note the same census still found it activated in prod, so its migration has not yet run -- that now surfaces as an 'in the registry but not expected' warning, which is the correct signal.", "confidenceLevels": { "verified": "slug read directly from code in THIS repo (backend/applications/src/app-registry/builtins.ts BUILTIN_MANIFESTS, or FuzeQuality/registration/manifest.json) — cannot be stale without this repo's own tests catching it.", "owner-ruling": "slug is one of the exact values the owner named on 2026-08-19 (quoted verbatim in CLAUDE.md §'slug, display name, and the federated serve path') as either a prefixed exception to keep (fuzex, fuzebi) or an already-correct unprefixed slug not to be migrated.", @@ -18,26 +18,123 @@ "docs/planning/app-suites-and-modes.md (FuzeHub suite example)", "packages/onboarding-kit/README.md (FuzeService used as the running naming-convention example)", "docs/runbooks/app-slug-deprefix-migration.md — RETIRED; its measured-state table was read only as a last-resort snapshot for products no other source names, and is called out per-entry below" - ] + ], + "genuinelyAbsentAtLastReview": "After these corrections, census 32949523555's MISSING set reduces from 9 to 6 genuinely unregistered products: fuzex, fuzebi, deploy, call, fuzeplan, merchandize." }, "apps": [ - { "slug": "fuzeagent", "name": "FuzeAgent", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS" }, - { "slug": "fuzesocial", "name": "FuzeSocial", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS" }, - { "slug": "fuzequality", "name": "FuzeQuality", "confidence": "verified", "source": "builtins.ts BUILTIN_MANIFESTS + FuzeQuality/registration/manifest.json" }, - { "slug": "fuzex", "name": "FuzeX", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" }, - { "slug": "fuzebi", "name": "FuzeBI", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" }, - { "slug": "deploy", "name": "FuzeDeploy", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "call", "name": "FuzeCall", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "executive", "name": "FuzeExecutive", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "finance", "name": "FuzeFinance", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "keys", "name": "FuzeKeys", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "market", "name": "FuzeMarket", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" }, - { "slug": "picker", "name": "FuzePicker", "confidence": "owner-ruling", "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone; corroborated by docs/runbooks/app-slug-deprefix-migration.md ('name only — already correct')" }, - { "slug": "fuzecontact", "name": "Contact", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, - { "slug": "fuzehub-ventures","name": "FuzeHub", "confidence": "documented", "source": "docs/planning/app-suites-and-modes.md — worked example: 'manifest.json slug: fuzehub-ventures (primary; owns policy + billing)'" }, - { "slug": "fuzeplan", "name": "FuzePlan", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, - { "slug": "fuzesales", "name": "Sales", "confidence": "documented", "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" }, - { "slug": "fuzeservice", "name": "FuzeService", "confidence": "documented", "source": "packages/onboarding-kit/README.md — used verbatim as the naming-convention's running example: \"slug\": \"fuzeservice\"" }, - { "slug": "merchandize", "name": "FuzeMerchandize", "confidence": "inferred", "source": "no direct citation found anywhere in this repo; guessed by family convention (unprefixed, matches the 'deploy/call/executive/finance/keys/market/picker' pattern). VERIFY before trusting a MISSING result for this one." } + { + "slug": "call", + "name": "FuzeCall", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" + }, + { + "slug": "clock", + "name": "Clock", + "confidence": "verified", + "source": "builtins.ts BUILTIN_MANIFESTS + census run 32949523555 (2026-08-26): live registry slug, corrected per _meta.notASlugMigrationWorklist" + }, + { + "slug": "contact", + "name": "Contact", + "confidence": "verified", + "source": "census run 32949523555 (2026-08-26): live registry slug, corrected per _meta.notASlugMigrationWorklist (was 'fuzecontact' — never registered under that slug)" + }, + { + "slug": "deploy", + "name": "FuzeDeploy", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" + }, + { + "slug": "executive", + "name": "FuzeExecutive", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" + }, + { + "slug": "finance", + "name": "FuzeFinance", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" + }, + { + "slug": "fuzeagent", + "name": "FuzeAgent", + "confidence": "verified", + "source": "builtins.ts BUILTIN_MANIFESTS" + }, + { + "slug": "fuzebi", + "name": "FuzeBI", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" + }, + { + "slug": "fuzehub", + "name": "FuzeHub", + "confidence": "verified", + "source": "census run 32949523555 (2026-08-26): live registry slug, corrected per _meta.notASlugMigrationWorklist (was 'fuzehub-ventures' — never registered under that slug)" + }, + { + "slug": "fuzekeys", + "name": "Keys", + "confidence": "verified", + "source": "census run 32949523555 (2026-08-26): live registry slug, corrected per _meta.notASlugMigrationWorklist (was 'keys' — never registered under that slug)" + }, + { + "slug": "fuzeplan", + "name": "FuzePlan", + "confidence": "documented", + "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" + }, + { + "slug": "fuzequality", + "name": "FuzeQuality", + "confidence": "verified", + "source": "builtins.ts BUILTIN_MANIFESTS + census run 32960147607 (2026-08-26 10:49Z): live registry returns it, status 'activated'. RESTORED — an earlier revision of this file removed it on the false premise that it was not registered; the census disproves that." + }, + { + "slug": "fuzesales", + "name": "Sales", + "confidence": "documented", + "source": "docs/runbooks/app-slug-deprefix-migration.md snapshot (RETIRED doc — read only as a last-resort snapshot; not independently re-measured)" + }, + { + "slug": "fuzeservice", + "name": "FuzeService", + "confidence": "documented", + "source": "packages/onboarding-kit/README.md — used verbatim as the naming-convention's running example: \"slug\": \"fuzeservice\"" + }, + { + "slug": "fuzesocial", + "name": "FuzeSocial", + "confidence": "verified", + "source": "builtins.ts BUILTIN_MANIFESTS" + }, + { + "slug": "fuzex", + "name": "FuzeX", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — kept prefixed, one of the two named display-name exceptions (FuzeBI/FuzeX)" + }, + { + "slug": "market", + "name": "FuzeMarket", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone" + }, + { + "slug": "merchandize", + "name": "FuzeMerchandize", + "confidence": "inferred", + "source": "no direct citation found anywhere in this repo; guessed by family convention (unprefixed, matches the 'deploy/call/executive/finance/keys/market/picker' pattern). VERIFY before trusting a MISSING result for this one." + }, + { + "slug": "picker", + "name": "FuzePicker", + "confidence": "owner-ruling", + "source": "CLAUDE.md 2026-08-19 — named as an already-unprefixed slug to leave alone; corroborated by docs/runbooks/app-slug-deprefix-migration.md ('name only — already correct')" + } ] }