From c71f7fd0668cde8e1f5d155c44bf0e57a5b8c59e Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:14 +0100 Subject: [PATCH 1/6] Add fail-closed tenant platform policy --- src/platform-settings.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/platform-settings.ts diff --git a/src/platform-settings.ts b/src/platform-settings.ts new file mode 100644 index 0000000..730d7b8 --- /dev/null +++ b/src/platform-settings.ts @@ -0,0 +1,27 @@ +import type { PlatformKey } from './types'; + +export interface PlatformEnableSettings { + threads_enabled?: boolean | null; + instagram_enabled?: boolean | null; + linkedin_enabled?: boolean | null; + x_enabled?: boolean | null; + facebook_enabled?: boolean | null; +} + +const PLATFORM_SETTINGS: ReadonlyArray = [ + ['threads_enabled', 'threads'], + ['instagram_enabled', 'instagram'], + ['linkedin_enabled', 'linkedin'], + ['x_enabled', 'x'], + ['facebook_enabled', 'facebook'], +]; + +/** + * SaaS platform activation is fail-closed. Missing, null, and false values are + * disabled; only an explicit persisted boolean true opts a tenant in. + */ +export function activePlatformsFromSettings(settings: PlatformEnableSettings): PlatformKey[] { + return PLATFORM_SETTINGS + .filter(([setting]) => settings[setting] === true) + .map(([, platform]) => platform); +} From 970c2d45ec9ca50094c9a02082f58b4f7b94ae00 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:28 +0100 Subject: [PATCH 2/6] Test fail-closed tenant platform activation --- test/tenant-platform-policy.test.ts | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 test/tenant-platform-policy.test.ts diff --git a/test/tenant-platform-policy.test.ts b/test/tenant-platform-policy.test.ts new file mode 100644 index 0000000..2eae47d --- /dev/null +++ b/test/tenant-platform-policy.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; + +import { activePlatformsFromSettings } from '../src/platform-settings'; + +assert.deepEqual( + activePlatformsFromSettings({}), + [], + 'missing platform settings must fail closed' +); + +assert.deepEqual( + activePlatformsFromSettings({ + threads_enabled: null, + instagram_enabled: null, + linkedin_enabled: null, + x_enabled: null, + facebook_enabled: null, + }), + [], + 'null platform settings must fail closed' +); + +assert.deepEqual( + activePlatformsFromSettings({ + threads_enabled: false, + instagram_enabled: false, + linkedin_enabled: false, + x_enabled: false, + facebook_enabled: false, + }), + [], + 'false platform settings must remain disabled' +); + +assert.deepEqual( + activePlatformsFromSettings({ + threads_enabled: true, + instagram_enabled: false, + linkedin_enabled: null, + x_enabled: true, + }), + ['threads', 'x'], + 'only explicit true settings must become active' +); + +assert.deepEqual( + activePlatformsFromSettings({ + threads_enabled: true, + instagram_enabled: true, + linkedin_enabled: true, + x_enabled: true, + facebook_enabled: true, + }), + ['threads', 'instagram', 'linkedin', 'x', 'facebook'], + 'explicit opt-in must preserve canonical platform order' +); + +console.log('tenant platform policy tests passed'); From 569128a3c594f0f051ce5e7b7051cd1cb83c6809 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:33:50 +0100 Subject: [PATCH 3/6] Run tenant platform policy in full CI --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 47ad77b..a39aa3f 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "tsx scripts/build.ts", "typecheck": "tsc --noEmit --project tsconfig.json", - "test": "npm run build && node dist/test/security-hardening.test.js && node dist/test/source-ssrf.test.js && node dist/test/browser-collector-ingest.test.js && node dist/test/slot-scheduler.test.js && node dist/test/daily-inventory-planner.test.js && node dist/test/refresh-queue-finalization.test.js && node dist/test/publish-summary.test.js && node dist/test/threads-refresh.test.js && node dist/test/linkedin-refresh.test.js && node dist/test/instagram-image-timeout.test.js && node dist/test/cost-control.test.js && node dist/test/recovery-scheduler.test.js && node dist/test/social-connector.test.js && node dist/test/meta-publication-boundary.test.js && node dist/test/cloudflare-health.test.js && node dist/test/exclusive-run-gate.test.js && node dist/test/runtime-scope.test.js", + "test": "npm run build && node dist/test/security-hardening.test.js && node dist/test/source-ssrf.test.js && node dist/test/browser-collector-ingest.test.js && node dist/test/slot-scheduler.test.js && node dist/test/daily-inventory-planner.test.js && node dist/test/refresh-queue-finalization.test.js && node dist/test/publish-summary.test.js && node dist/test/threads-refresh.test.js && node dist/test/linkedin-refresh.test.js && node dist/test/instagram-image-timeout.test.js && node dist/test/cost-control.test.js && node dist/test/recovery-scheduler.test.js && node dist/test/social-connector.test.js && node dist/test/meta-publication-boundary.test.js && node dist/test/cloudflare-health.test.js && node dist/test/exclusive-run-gate.test.js && node dist/test/runtime-scope.test.js && node dist/test/tenant-platform-policy.test.js", "smoke:dist": "node dist/src/cli.js status", "ci": "npm run typecheck && npm test && npm run smoke:dist", "dev": "tsx src/agent.ts", From 11f69cd813b13dc0de307f1408eb802e9c4c2bca Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:45:46 +0100 Subject: [PATCH 4/6] Fail closed platform activation for missing tenant settings --- src/supabase-worker.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/supabase-worker.ts b/src/supabase-worker.ts index 3745741..bfdff2d 100644 --- a/src/supabase-worker.ts +++ b/src/supabase-worker.ts @@ -7,6 +7,7 @@ import * as cloudinary from './cloudinary'; import * as instagram from './instagram'; import * as linkedin from './linkedin'; import * as logger from './logger'; +import { activePlatformsFromSettings } from './platform-settings'; import * as threads from './threads'; import * as x from './x'; import { buildDailyInventoryPlan, type DailyInventoryQueueRow } from './daily-inventory-planner'; @@ -1514,12 +1515,7 @@ async function loadTenantContext(userId: string): Promise { }))[0]; const credentials = decryptTenantCredentials(credentialRow); - const activePlatforms: PlatformKey[] = []; - if (settings.threads_enabled ?? true) activePlatforms.push('threads'); - if (settings.instagram_enabled ?? true) activePlatforms.push('instagram'); - if (settings.linkedin_enabled ?? true) activePlatforms.push('linkedin'); - if (settings.x_enabled ?? false) activePlatforms.push('x'); - if (settings.facebook_enabled ?? false) activePlatforms.push('facebook'); + const activePlatforms = activePlatformsFromSettings(settings); return { userId, From 099489ba42a290cd7e1c81b0b224de8a020133a1 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:46:47 +0100 Subject: [PATCH 5/6] Record merged D03 evidence and D22 boundary --- docs/reliability-repair-progress.md | 34 +++++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/reliability-repair-progress.md b/docs/reliability-repair-progress.md index a8cacb4..d79a1a7 100644 --- a/docs/reliability-repair-progress.md +++ b/docs/reliability-repair-progress.md @@ -4,7 +4,7 @@ This file records only implementation status that is evidenced by repository cha ## Sequence 1: release identity and complete CI gate -Status: implemented on `codex/reliability-foundation-1` and proven by upstream pull-request CI on head `a73d1d9202f5013a944b9a2320ebb22be7caf4b1`. +Status: merged to upstream `main` through PR #2. The final pre-merge branch head was proven by the complete upstream pull-request CI gate before merge. Evidence: @@ -16,7 +16,7 @@ Evidence: ## D03 containment: overlapping tenant runtime state -Status: implemented and proven by upstream pull-request CI on head `ee377775030d27117b2568bc10426da8304c7a24`. +Status: merged to upstream `main` through PR #3 after fresh upstream CI passed on the branch synchronised to the PR #2 merge commit. The Cloudflare scheduled and authenticated `/tick` job-drain entry points share one per-isolate exclusive run gate. This prevents two overlapping SaaS drains in the same Worker isolate from entering the mutable tenant runtime concurrently. Separate Worker isolates do not share process globals. @@ -24,9 +24,9 @@ The containment regression deliberately interleaves two executions, proves maxim ## D03 runtime isolation: process-global config and token callbacks -Status: implemented on `codex/immutable-tenant-runtime`; upstream CI evidence is still required before this layer can be marked proven. +Status: merged to upstream `main` through PR #4. Fresh upstream CI run #88 passed `npm ci` and the complete `npm run ci` gate on synchronised head `f68e4434efc18053d11c85ff1b3d2dd334c5dc3d` before merge. The upstream PR #4 merge commit is `033b9b578c120bec0b725311eba1db3d3bfe5530`. -The Worker now installs async-scoped accessors on the existing config object only after Cloudflare bindings have been copied into `process.env`. Each scheduled/authenticated SaaS drain then runs inside its own `AsyncLocalStorage` context. +The Worker installs async-scoped accessors on the existing config object only after Cloudflare bindings have been copied into `process.env`. Each scheduled/authenticated SaaS drain then runs inside its own `AsyncLocalStorage` context. Within that context: @@ -38,16 +38,26 @@ Within that context: The current `processPendingSupabaseJobs()` implementation remains serial, so tenant runtime mutation is restored between jobs inside a drain. The earlier exclusive run gate remains defence-in-depth but is no longer the only boundary preventing overlapping Worker invocations from sharing config or token callbacks. -Acceptance evidence required before this layer is considered proven: +The proven regression covers overlapping tenant credentials, scope-local Threads/LinkedIn/X persistence callbacks, rotated-token isolation, base-config isolation and failure cleanup. Explicit provider-client arguments remain desirable architectural cleanup, but the process-global cross-tenant safety defect is no longer the active blocker. -- two deliberately overlapping runtime scopes resolve different OpenAI/provider credentials; -- Threads, LinkedIn and X token rotations invoke only the persistence callback belonging to their own scope; -- rotated credentials remain visible inside the originating scope but do not change the base config or the other scope; -- a failed scoped execution cannot leak its config into the next execution; -- the complete `npm run ci` gate passes on the exact branch SHA. +No provider is re-enabled and no deployment was performed by these D03 repairs. -No provider is re-enabled and no deployment is performed by this refactor. +## D22: fail-closed tenant platform activation + +Status: implemented on `codex/fail-closed-platform-settings`; upstream pull-request CI evidence is required before merge. + +Tenant platform activation now has one explicit policy: a platform is active only when its persisted `*_enabled` setting is exactly boolean `true`. Missing settings rows, missing fields, `null` and `false` all remain disabled. + +The regression covers: + +- a missing settings object enabling no platforms; +- all-null flags enabling no platforms; +- all-false flags enabling no platforms; +- mixed settings enabling only explicit `true` entries; +- canonical platform ordering when all five platforms are explicitly enabled. + +No provider is re-enabled, no credential semantics change, no queue or billing behaviour changes, and no deployment is performed by this repair. ## Next bounded repair -After runtime isolation is green, make missing tenant platform settings fail closed (D22), then proceed to atomic database claims/fencing and publication-attempt identity. Meta publication remains disabled until the publication ledger and provider-specific restoration work are ready. +After D22 is green and merged, proceed to atomic database claims/fencing for jobs, sources and angles, then publication-attempt identity and unknown-outcome handling. Meta publication remains disabled until the publication ledger and provider-specific restoration work are ready. From c6e86772dc95593aece6f683120f5ae0aa2fb143 Mon Sep 17 00:00:00 2001 From: Ayobami Haastrup <47716486+AyobamiH@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:19:17 +0100 Subject: [PATCH 6/6] Separate authorised source merges from schema-first Worker deployment --- .../workflows/deploy-cloudflare-worker.yml | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-cloudflare-worker.yml b/.github/workflows/deploy-cloudflare-worker.yml index 20f9109..cb0bc08 100644 --- a/.github/workflows/deploy-cloudflare-worker.yml +++ b/.github/workflows/deploy-cloudflare-worker.yml @@ -1,30 +1,47 @@ name: deploy-cloudflare-worker +# Source merges are not deployment authorisation. Keep publication rollout +# manual while the schema-first staging, drain and canary gates are outstanding. on: - push: - branches: - - main - paths: - - 'config.ts' - - 'content-os/**' - - 'package-lock.json' - - 'package.json' - - 'scripts/**' - - 'src/**' - - 'test/**' - - 'tsconfig.json' - - 'wrangler.toml' - - '.github/workflows/deploy-cloudflare-worker.yml' workflow_dispatch: + inputs: + expected_sha: + description: 'Full upstream main SHA reviewed for this deployment' + required: true + type: string + rollout_preflight_confirmed: + description: 'Owner confirms schema capability, legacy drain and staging evidence for this SHA' + required: true + type: boolean + default: false permissions: contents: read +concurrency: + group: production-worker-deployment + cancel-in-progress: false + jobs: deploy: + if: github.repository == 'OneClickPostFactory/social-agents' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest + timeout-minutes: 20 steps: + - name: Require exact release identity and explicit rollout approval + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + RELEASE_SHA: ${{ github.sha }} + ROLLOUT_PREFLIGHT_CONFIRMED: ${{ inputs.rollout_preflight_confirmed }} + run: | + set -eu + test "$ROLLOUT_PREFLIGHT_CONFIRMED" = 'true' + test "$EXPECTED_SHA" = "$RELEASE_SHA" + echo "$EXPECTED_SHA" | grep -Eq '^[0-9a-f]{40}$' - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: '24'