From 6a830029a08bf653a533257f15a100118ded09fa Mon Sep 17 00:00:00 2001 From: John Siracusa Date: Thu, 30 Jul 2026 01:38:25 -0400 Subject: [PATCH] feat(desktop): ship without accounts by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaged builds no longer contain sign-in. `CC_ACCOUNTS=1` brings it back; nothing is deleted. The hosted account system works. The problem is what it can legally carry. `scrubSettings` replaces `command`, `args`, `path`, `dir`, anything holding an absolute path and anything secret-shaped with markers before upload, so what actually crosses machines is a preference blob and a set of profile shapes with holes in them that you re-point by hand on the second Mac. That is a thin return for a hosted Postgres holding user rows in a product whose claim is that context stays on your machine — and it was the only thing making that claim need an asterisk. The stated justification was a hook for entitlements; Packs shipped as a free preview with payments dormant behind paymentsLive, so nothing is being entitled yet. When sync is wanted for real, the better version rides git-core.mjs/git-sync.mjs against the user's own repo, which is infrastructure this project already owns. The hosted project had zero rows in user_settings, so disabling stranded nobody. Mechanics: - generate-supabase-config.mjs writes {"accounts":"disabled"} unless CC_ACCOUNTS=1, and needs no credentials to do it. The file is still written because electron-builder treats a missing extraResources source as a build failure, and because "off" should be a state the app reads rather than infers from an absent file. - loadSupabaseConfig treats that marker as authoritative over both userData and the environment. The marker describes the artifact users downloaded, so a stale VITE_SUPABASE_* in someone's shell must not quietly switch sign-in back on. `npm run dev` never generates a packaged config, so development still resolves from env. - The renderer hides the Account pane rather than showing it empty. An Account tab that opens onto "sign-in isn't configured" advertises a feature the build does not have. Availability rides the existing static launch metadata so the pane is absent on first paint. - The release workflow stops passing Supabase secrets. This closes the #44 gate by not shipping the thing that needed validating. The three manual acceptance checks stay written down in docs/release-gates.md and come due again on any CC_ACCOUNTS=1 build. All auth and settings-sync code, migrations and tests remain and still run in CI. Only the packaging default changed. Signed-off-by: John Siracusa Co-authored-by: Claude Opus 5 --- .github/workflows/app-release.yml | 16 ++-- .../src/components/SettingsView.test.tsx | 72 ++++++++++++----- apps/console/src/components/SettingsView.tsx | 25 +++--- apps/desktop/CLAUDE.md | 14 +++- apps/desktop/README.md | 11 +-- .../scripts/generate-supabase-config.mjs | 33 +++++++- apps/desktop/src/main/main.mjs | 4 + apps/desktop/src/main/supabase-config.mjs | 12 ++- apps/desktop/src/preload.cjs | 2 +- apps/desktop/test/supabase-config.test.mjs | 68 ++++++++++++++++ .../docs/reference/updates-and-privacy.md | 8 +- docs/release-gates.md | 78 ++++++++++++------- 12 files changed, 259 insertions(+), 84 deletions(-) diff --git a/.github/workflows/app-release.yml b/.github/workflows/app-release.yml index 3c4bcc3..37a5f88 100644 --- a/.github/workflows/app-release.yml +++ b/.github/workflows/app-release.yml @@ -4,14 +4,17 @@ # Secrets (see specs/contextcake-distribution/design.md §8): # CSC_LINK / CSC_KEY_PASSWORD — Developer ID Application cert (.p12, base64) # APPLE_API_KEY / APPLE_API_KEY_ID / APPLE_API_ISSUER — notarytool App Store Connect key -# VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY — public desktop auth configuration # Key rotation: revoke in the Apple Developer portal, reissue, replace these # secrets. Keys never live in the repository. # -# With public Supabase configuration but without the complete Apple signing -# credential set, the workflow builds unsigned inspection artifacts but does -# NOT create a GitHub Release. Missing Supabase configuration fails packaging; +# Without the complete Apple signing credential set, the workflow builds +# unsigned inspection artifacts but does NOT create a GitHub Release; # unnotarized builds are never distributed (distribution spec §7). +# +# Releases ship WITHOUT accounts: the packaging step no longer receives +# Supabase configuration, so the published app has no sign-in. To build one +# that does, set CC_ACCOUNTS=1 plus SUPABASE_URL/SUPABASE_ANON_KEY. See +# docs/release-gates.md for why the default flipped. name: App Release on: @@ -108,8 +111,6 @@ jobs: CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} - VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} run: | export APPLE_API_KEY="$RUNNER_TEMP/notary-key.p8" # --publish never: on a tag build electron-builder would otherwise try @@ -120,9 +121,6 @@ jobs: - name: Build (unsigned — no release) if: steps.creds.outputs.signed != 'true' working-directory: apps/desktop - env: - VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} - VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} run: npm run dist -- --publish never - name: Gatekeeper assessment diff --git a/apps/console/src/components/SettingsView.test.tsx b/apps/console/src/components/SettingsView.test.tsx index 4e43f91..0a99b4e 100644 --- a/apps/console/src/components/SettingsView.test.tsx +++ b/apps/console/src/components/SettingsView.test.tsx @@ -9,11 +9,41 @@ let container: HTMLDivElement let root: Root function button(label: string): HTMLButtonElement { - const match = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label) + const match = findButton(label) if (!match) throw new Error(`Button not found: ${label}`) return match } +function findButton(label: string): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === label) +} + +/** A build packaged with CC_ACCOUNTS=1. The default build ships without them. */ +function withAccountsEnabled(auth: Partial> = {}) { + window.__CC_DESKTOP = { + getApiToken: vi.fn().mockResolvedValue('token'), + version: '0.0.0-test', + authState: { signedIn: false, available: true }, + cli: { getStatus: vi.fn(), install: vi.fn() }, + } as unknown as typeof window.__CC_DESKTOP + window.__CC_AUTH = { + getState: vi.fn().mockResolvedValue({ available: true, signedIn: false }), + signIn: vi.fn().mockResolvedValue(undefined), + cancelSignIn: vi.fn().mockResolvedValue({ available: true, signedIn: false }), + signOut: vi.fn(), + deleteAccount: vi.fn(), + onSessionChanged: vi.fn(() => () => {}), + onError: vi.fn(() => () => {}), + syncSettings: vi.fn().mockResolvedValue({ localOnly: false }), + pullSettings: vi.fn(), + getSyncState: vi.fn().mockResolvedValue({ status: 'idle' }), + onSyncStatus: vi.fn(() => () => {}), + onSettingsPulled: vi.fn(() => () => {}), + bootstrapTheme: vi.fn().mockResolvedValue('dark'), + ...auth, + } as unknown as typeof window.__CC_AUTH +} + beforeEach(() => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true window.localStorage.clear() @@ -32,7 +62,25 @@ afterEach(async () => { }) describe('SettingsView', () => { - it('keeps account controls inside the settings surface', async () => { + it('offers no Account pane in a build that ships without accounts', async () => { + const onClose = vi.fn() + await act(async () => root.render( + + + , + )) + + // Hidden, not empty. An Account tab that opens onto "sign-in isn't + // configured" advertises a feature the build does not have. + expect(findButton('Account')).toBeUndefined() + expect(container.textContent).not.toContain('ContextCake account') + + await act(async () => button('Back to app').click()) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('keeps account controls inside the settings surface when accounts are enabled', async () => { + withAccountsEnabled() const onClose = vi.fn() await act(async () => root.render( @@ -41,7 +89,7 @@ describe('SettingsView', () => { )) await act(async () => button('Account').click()) - expect(container.textContent).toContain('Account sign-in is available in the ContextCake desktop app.') + expect(container.textContent).toContain('ContextCake account') await act(async () => button('Back to app').click()) expect(onClose).toHaveBeenCalledOnce() @@ -61,21 +109,7 @@ describe('SettingsView', () => { it('cancels a pending OAuth attempt when leaving the Account pane', async () => { const cancelSignIn = vi.fn().mockResolvedValue({ available: true, signedIn: false }) - window.__CC_AUTH = { - getState: vi.fn().mockResolvedValue({ available: true, signedIn: false }), - signIn: vi.fn().mockResolvedValue(undefined), - cancelSignIn, - signOut: vi.fn(), - deleteAccount: vi.fn(), - onSessionChanged: vi.fn(() => () => {}), - onError: vi.fn(() => () => {}), - syncSettings: vi.fn().mockResolvedValue({ localOnly: false }), - pullSettings: vi.fn(), - getSyncState: vi.fn().mockResolvedValue({ status: 'idle' }), - onSyncStatus: vi.fn(() => () => {}), - onSettingsPulled: vi.fn(() => () => {}), - bootstrapTheme: vi.fn().mockResolvedValue('dark'), - } + withAccountsEnabled({ cancelSignIn }) await act(async () => root.render( @@ -84,7 +118,7 @@ describe('SettingsView', () => { )) await act(async () => button('Account').click()) await act(async () => button('Sign in with GitHub').click()) - expect(window.__CC_AUTH.signIn).toHaveBeenCalledOnce() + expect(window.__CC_AUTH?.signIn).toHaveBeenCalledOnce() await act(async () => button('General').click()) expect(cancelSignIn).toHaveBeenCalledOnce() diff --git a/apps/console/src/components/SettingsView.tsx b/apps/console/src/components/SettingsView.tsx index 8410757..586ace7 100644 --- a/apps/console/src/components/SettingsView.tsx +++ b/apps/console/src/components/SettingsView.tsx @@ -30,6 +30,9 @@ export function SettingsView({ const [updatesEnabled, setUpdatesEnabled] = useState(() => isUpdateCheckEnabled(appMode)) const { mode: theme, toggle: toggleTheme } = useThemeMode() const desktop = Boolean(window.__CC_DESKTOP) + // Builds ship without accounts by default, so the pane is hidden rather than + // shown empty. Browser and demo builds never had sign-in to offer. + const accountsAvailable = window.__CC_DESKTOP?.authState?.available === true && Boolean(window.__CC_AUTH) const chooseTheme = (next: 'light' | 'dark') => { if (next !== theme) toggleTheme() @@ -65,10 +68,12 @@ export function SettingsView({ Indexing )} - + {accountsAvailable && ( + + )} @@ -135,22 +140,16 @@ export function SettingsView({ - ) : ( + ) : accountsAvailable && window.__CC_AUTH ? ( <>

Settings

Account

Sign in to keep preferences and source metadata consistent across Macs.
- {window.__CC_AUTH ? ( - - ) : ( -
- Account sign-in is available in the ContextCake desktop app. Local features still work without an account. -
- )} + - )} + ) : null} diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index 2cb4d9d..d028d56 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -69,6 +69,14 @@ npm run dist # DMG + zip, ad-hoc signed in dev test asserts `userData=ContextCake`. - **Known gaps tracked as follow-ups** (not blocking merge): the updater reads the repo-wide GitHub "latest" release (see the comment in `updater.mjs`). -- `npm run pack` and `npm run dist` require public Supabase configuration through - `VITE_SUPABASE_URL` + `VITE_SUPABASE_ANON_KEY` (or the `SUPABASE_*` aliases). - Only publishable/legacy-anon keys are accepted; never use secret/service-role keys. +- **Builds ship without accounts.** `npm run pack`/`npm run dist` write + `build/supabase-config.json` as `{"accounts":"disabled"}` and need no + credentials; the packaged app has no sign-in and `loadSupabaseConfig` treats + that marker as authoritative over env and userData, so a stale + `VITE_SUPABASE_*` in a shell cannot switch sign-in back on in a shipped + artifact. Set `CC_ACCOUNTS=1` plus `SUPABASE_URL`/`SUPABASE_ANON_KEY` to build + one with accounts — only publishable/legacy-anon keys are accepted, never + secret/service-role — and clear `docs/release-gates.md` before distributing + it. The auth and settings-sync code, migrations and tests all still exist and + still run in CI; only the packaging default changed. Rationale for the + default: `docs/release-gates.md`. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c63c0fe..e8d285f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -116,8 +116,9 @@ Release with `latest-mac.yml` and `SHA256SUMS`). Requires the Apple Developer secrets described in `specs/contextcake-distribution/design.md` §8. Until those exist, `npm run dist` produces ad-hoc-signed artifacts for local testing only — they must not be distributed. -Packaged account builds require `VITE_SUPABASE_URL` and -`VITE_SUPABASE_ANON_KEY` (the `SUPABASE_*` aliases also work). The packaging -scripts generate `build/supabase-config.json` and electron-builder copies that -public project configuration into the app resources; the generated file is -gitignored. +Packaging ships **without accounts** by default: `build/supabase-config.json` is +generated as `{"accounts":"disabled"}`, no credentials are needed, and the built +app has no sign-in. To package a build with accounts, set `CC_ACCOUNTS=1` plus +`SUPABASE_URL` and `SUPABASE_ANON_KEY` (the `VITE_SUPABASE_*` aliases also work) +— and complete the manual acceptance checks in `docs/release-gates.md` before +distributing it. The generated file is gitignored either way. diff --git a/apps/desktop/scripts/generate-supabase-config.mjs b/apps/desktop/scripts/generate-supabase-config.mjs index 2ccb433..f3c7998 100644 --- a/apps/desktop/scripts/generate-supabase-config.mjs +++ b/apps/desktop/scripts/generate-supabase-config.mjs @@ -1,3 +1,17 @@ +// Writes the packaged Supabase configuration — or, by default, a marker saying +// accounts are not shipped in this build. +// +// Accounts are off unless CC_ACCOUNTS=1. The hosted account system exists and +// works, but everything it can safely sync is scrubbed of paths, commands and +// secrets before upload, so what actually crosses machines is a preference blob +// and a set of profile shapes with holes in them. That is not worth a hosted +// Postgres holding user rows in a product whose claim is that context stays on +// your machine. See docs/release-gates.md. +// +// The file is always written: electron-builder's extraResources entry treats a +// missing source as a build failure, and "accounts are off" is a state the app +// must be able to read rather than infer from an absent file. + import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -5,11 +19,23 @@ import { isPublicSupabaseKey } from '../src/main/supabase-config.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const output = process.env.CC_SUPABASE_CONFIG_OUT || path.join(here, '..', 'build', 'supabase-config.json') + +function write(config) { + fs.mkdirSync(path.dirname(output), { recursive: true }) + fs.writeFileSync(output, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) +} + +if (process.env.CC_ACCOUNTS !== '1') { + write({ accounts: 'disabled' }) + console.log(`Wrote ${output} (accounts disabled — set CC_ACCOUNTS=1 to package sign-in)`) + process.exit(0) +} + const url = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL const anonKey = (process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY || '').trim() if (!url || !anonKey) { - throw new Error('SUPABASE_URL and SUPABASE_ANON_KEY are required to package the desktop app.') + throw new Error('CC_ACCOUNTS=1 requires SUPABASE_URL and SUPABASE_ANON_KEY to package the desktop app.') } if (!isPublicSupabaseKey(anonKey)) { throw new Error('SUPABASE_ANON_KEY must be a publishable key or a legacy anon JWT; privileged keys cannot be packaged.') @@ -19,6 +45,5 @@ let parsed try { parsed = new URL(url) } catch { /* handled below */ } if (parsed?.protocol !== 'https:') throw new Error('SUPABASE_URL must be a valid HTTPS URL.') -fs.mkdirSync(path.dirname(output), { recursive: true }) -fs.writeFileSync(output, `${JSON.stringify({ url: parsed.toString().replace(/\/$/, ''), anonKey }, null, 2)}\n`, { mode: 0o600 }) -console.log(`Wrote ${output}`) +write({ accounts: 'enabled', url: parsed.toString().replace(/\/$/, ''), anonKey }) +console.log(`Wrote ${output} (accounts enabled)`) diff --git a/apps/desktop/src/main/main.mjs b/apps/desktop/src/main/main.mjs index 7e0e0ab..badca35 100644 --- a/apps/desktop/src/main/main.mjs +++ b/apps/desktop/src/main/main.mjs @@ -424,6 +424,10 @@ async function createWindow() { additionalArguments: [ `--cc-version=${app.getVersion()}`, `--cc-signed-in=${currentAuthState().signedIn ? '1' : '0'}`, + // Whether this build ships accounts at all. Static for the process, so + // the renderer can drop the Account pane on first paint rather than + // rendering it and then discovering there is nothing behind it. + `--cc-accounts=${currentAuthState().available ? '1' : '0'}`, ], }, }) diff --git a/apps/desktop/src/main/supabase-config.mjs b/apps/desktop/src/main/supabase-config.mjs index a8ff40e..7598b36 100644 --- a/apps/desktop/src/main/supabase-config.mjs +++ b/apps/desktop/src/main/supabase-config.mjs @@ -18,10 +18,19 @@ export function isPublicSupabaseKey(value) { } } +const UNCONFIGURED = { url: '', anonKey: '' } + /** * Resolve public Supabase project credentials without baking secrets into the * repository. Packaged builds may provision supabase.json in userData; local * development uses SUPABASE_* (with VITE_* accepted for the shared dev env). + * + * A packaged build that declares `accounts: "disabled"` stays disabled no + * matter what else is set. That marker is a statement about the artifact users + * downloaded, so it has to outrank the environment — otherwise a stale + * VITE_SUPABASE_* left in a shell silently re-enables sign-in in a build that + * was published without it. Development is unaffected: `npm run dev` never + * generates a packaged config, so env resolution still applies there. */ export function loadSupabaseConfig(configDir, env = process.env, packagedConfigPath = '') { let packaged = {} @@ -29,6 +38,7 @@ export function loadSupabaseConfig(configDir, env = process.env, packagedConfigP try { if (packagedConfigPath) packaged = JSON.parse(fs.readFileSync(packagedConfigPath, 'utf8')) } catch { /* an unconfigured build remains fully usable locally */ } + if (packaged.accounts === 'disabled') return UNCONFIGURED try { user = JSON.parse(fs.readFileSync(path.join(configDir, 'supabase.json'), 'utf8')) } catch { /* an unconfigured build remains fully usable locally */ } @@ -42,7 +52,7 @@ export function loadSupabaseConfig(configDir, env = process.env, packagedConfigP const parsed = new URL(url) if (parsed.protocol !== 'https:') return { url: '', anonKey: '' } } catch { - return { url: '', anonKey: '' } + return UNCONFIGURED } return { url, anonKey } } diff --git a/apps/desktop/src/preload.cjs b/apps/desktop/src/preload.cjs index d3582b1..145b3ec 100644 --- a/apps/desktop/src/preload.cjs +++ b/apps/desktop/src/preload.cjs @@ -17,7 +17,7 @@ contextBridge.exposeInMainWorld('__CC_DESKTOP', { version: arg('cc-version'), // Initial, non-PII snapshot. The live state (including optional email) is // delivered through __CC_AUTH so it never appears in process arguments. - authState: { signedIn: arg('cc-signed-in') === '1' }, + authState: { signedIn: arg('cc-signed-in') === '1', available: arg('cc-accounts') === '1' }, chooseFolder: () => ipcRenderer.invoke('contextcake:choose-folder'), cli: { getStatus: () => ipcRenderer.invoke('contextcake:cli-status'), diff --git a/apps/desktop/test/supabase-config.test.mjs b/apps/desktop/test/supabase-config.test.mjs index 5d74db7..dbf750d 100644 --- a/apps/desktop/test/supabase-config.test.mjs +++ b/apps/desktop/test/supabase-config.test.mjs @@ -1,15 +1,27 @@ import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' import test from 'node:test' import { isPublicSupabaseKey, loadSupabaseConfig } from '../src/main/supabase-config.mjs' +const GENERATOR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'generate-supabase-config.mjs') + function legacyKey(role) { const payload = Buffer.from(JSON.stringify({ role })).toString('base64url') return `eyJhbGciOiJIUzI1NiJ9.${payload}.signature` } +function generate(env, out) { + execFileSync(process.execPath, [GENERATOR], { + env: { ...process.env, CC_ACCOUNTS: '', SUPABASE_URL: '', SUPABASE_ANON_KEY: '', VITE_SUPABASE_URL: '', VITE_SUPABASE_ANON_KEY: '', ...env, CC_SUPABASE_CONFIG_OUT: out }, + stdio: 'pipe', + }) + return JSON.parse(fs.readFileSync(out, 'utf8')) +} + test('config priority is environment, userData, then packaged public config', (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'contextcake-config-')) t.after(() => fs.rmSync(dir, { recursive: true, force: true })) @@ -47,3 +59,59 @@ test('non-HTTPS project URLs leave auth unavailable', () => { SUPABASE_URL: 'http://example.test', SUPABASE_ANON_KEY: 'public', }), { url: '', anonKey: '' }) }) + +test('accounts are off unless the build asks for them', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'contextcake-accounts-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const out = path.join(dir, 'supabase-config.json') + + // Default build: no credentials needed, and the file is still written — + // electron-builder treats a missing extraResources source as a build failure. + assert.deepEqual(generate({}, out), { accounts: 'disabled' }) + + // Leftover credentials in the environment must not switch accounts on. + assert.deepEqual(generate({ + SUPABASE_URL: 'https://example.supabase.co', SUPABASE_ANON_KEY: 'sb_publishable_x', + }, out), { accounts: 'disabled' }) +}) + +test('a disabled build stays disabled whatever the environment says', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'contextcake-disabled-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const packaged = path.join(dir, 'packaged.json') + fs.writeFileSync(packaged, JSON.stringify({ accounts: 'disabled' })) + fs.mkdirSync(path.join(dir, 'user')) + fs.writeFileSync(path.join(dir, 'user', 'supabase.json'), JSON.stringify({ + url: 'https://user.supabase.co', anonKey: 'sb_publishable_user', + })) + + // The marker describes the artifact users downloaded, so it outranks both a + // userData file and the environment. Otherwise a stale VITE_SUPABASE_* in + // someone's shell quietly turns sign-in back on in a build that shipped + // without it. + assert.deepEqual(loadSupabaseConfig(path.join(dir, 'user'), { + SUPABASE_URL: 'https://env.supabase.co', SUPABASE_ANON_KEY: 'sb_publishable_env', + }, packaged), { url: '', anonKey: '' }) +}) + +test('CC_ACCOUNTS=1 still demands valid public credentials', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'contextcake-enabled-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const out = path.join(dir, 'supabase-config.json') + + assert.throws(() => generate({ CC_ACCOUNTS: '1' }, out), /requires SUPABASE_URL/) + assert.throws(() => generate({ + CC_ACCOUNTS: '1', SUPABASE_URL: 'https://example.supabase.co', SUPABASE_ANON_KEY: 'sb_secret_never', + }, out), /publishable key or a legacy anon JWT/) + assert.throws(() => generate({ + CC_ACCOUNTS: '1', SUPABASE_URL: 'http://example.supabase.co', SUPABASE_ANON_KEY: 'sb_publishable_x', + }, out), /valid HTTPS URL/) + + assert.deepEqual(generate({ + CC_ACCOUNTS: '1', SUPABASE_URL: 'https://example.supabase.co', SUPABASE_ANON_KEY: 'sb_publishable_x', + }, out), { accounts: 'enabled', url: 'https://example.supabase.co', anonKey: 'sb_publishable_x' }) + + assert.deepEqual(loadSupabaseConfig(path.join(dir, 'user'), {}, out), { + url: 'https://example.supabase.co', anonKey: 'sb_publishable_x', + }) +}) diff --git a/apps/site/src/content/docs/docs/reference/updates-and-privacy.md b/apps/site/src/content/docs/docs/reference/updates-and-privacy.md index e646f13..0f9b93d 100644 --- a/apps/site/src/content/docs/docs/reference/updates-and-privacy.md +++ b/apps/site/src/content/docs/docs/reference/updates-and-privacy.md @@ -63,9 +63,11 @@ local application settings file. ## Optional desktop accounts -> **Availability:** Accounts are part of the next signed ContextCake for Mac release. -> They are not considered live until the packaged GitHub flow and deletion/sync -> acceptance checks pass. +> **Availability: not shipped.** Released builds of ContextCake for Mac contain +> no sign-in, send nothing to any account service, and store nothing about you +> on a server. There is no ContextCake account to create. The section below +> describes a capability the app can be built with but currently is not; it is +> kept so the behavior is documented if that ever changes. ContextCake for Mac works fully while signed out. Signing in adds settings sync; it does not gate the local engine, sources, profiles, resolve tools, or MCP server. diff --git a/docs/release-gates.md b/docs/release-gates.md index e48c12e..9fac8a6 100644 --- a/docs/release-gates.md +++ b/docs/release-gates.md @@ -8,11 +8,41 @@ the answer to "was this validated?" is a file rather than a memory. `## Closed:` with its evidence — not deleting it. To ship with a gate knowingly unmet, say so in the file; the point is that the decision is written down. -## Open: hosted OAuth and settings sync (#44) +## Closed: hosted OAuth and settings sync (#44) -**Status: breached.** PR #38 merged 2026-07-15 deferring three hosted acceptance -checks to #44 with the note that they "remain required before publishing the -desktop account-sync release." Three releases have shipped since: +**Resolved 2026-07-30 by not shipping the feature.** Accounts are off by default +(`CC_ACCOUNTS`, see below), so there is no hosted account system in a published +build to validate. The three manual checks below stay written down because they +become due again the moment anyone packages with `CC_ACCOUNTS=1`. + +The hosted project had **zero rows in `user_settings`** when this was decided — +nobody had signed in, so disabling stranded no one. + +### Why the feature was disabled rather than validated + +Everything the sync path can safely carry is scrubbed before upload: +`scrubSettings` replaces `command`, `args`, `path`, `dir`, anything containing an +absolute path, and anything secret-shaped with markers. What crosses machines is +a preference blob and profile shapes with holes in them, which you then re-point +by hand on the second Mac. + +That is a thin return for a hosted Postgres holding user rows in a product whose +claim is that context stays on your machine — and it was the only thing making +that claim need an asterisk. The stated justification was a hook for +entitlements, and Packs shipped as a free preview with payments dormant behind +`paymentsLive`, so nothing is being entitled yet. + +The code, migrations and tests are all still here and still run in CI. Turning +accounts back on is one environment variable, and the better version — settings +sync over the user's own git repo, on the `git-core.mjs` / `git-sync.mjs` rails +team sync already uses — needs no hosted service at all. + +### What was actually shipped unvalidated + +Kept as the record of the process failure. PR #38 merged 2026-07-15 deferring +three hosted acceptance checks to #44 with the note that they "remain required +before publishing the desktop account-sync release." Three releases shipped +since: | Release | Date | Account sync reachable? | |---|---|---| @@ -20,14 +50,14 @@ desktop account-sync release." Three releases have shipped since: | 0.2.0 | 2026-07-20 | yes | | 0.3.0 | 2026-07-29 | yes | -Reachable, not merely present: `apps/desktop/scripts/generate-supabase-config.mjs` -throws unless `SUPABASE_URL` and `SUPABASE_ANON_KEY` are set, `app-release.yml` -supplies both from repository secrets, and `AccountPanel` renders unconditionally -in Settings with Sign in, Sign out, and Delete account. Every published build can -sign a user into the hosted project. +Reachable, not merely present: packaging threw unless `SUPABASE_URL` and +`SUPABASE_ANON_KEY` were set, `app-release.yml` supplied both from repository +secrets, and `AccountPanel` rendered unconditionally in Settings with Sign in, +Sign out and Delete account. Every published build could sign a user into the +hosted project. Nobody did. -This is a process failure, not a defect report. Nothing here says the feature is -broken — it says nobody has confirmed it works on the artifact users download. +This was a process failure, not a defect report. Nothing here says the feature is +broken — it says nobody had confirmed it works on the artifact users download. ### What CI already discharges @@ -42,17 +72,19 @@ run by the `desktop` job in `ci.yml`) and do not need to be repeated by hand: | Paths, MCP commands/args, cache dirs and credential references never sync as runnable config | `settings-sync.test.mjs` — scrub, allowlist, quarantine, path-shaped keys | | A second client converges: tombstones, account switch, last-write-wins | `settings-sync.test.mjs` — dirty tombstone, shadow discard, remote-row precedence | -### What still needs a human +### What would still need a human, if accounts are ever re-enabled Three things cannot be simulated: a **packaged** app, a **second physical -machine**, and the **hosted** database. Each needs John — the OAuth flow requires -entering real credentials, which is not something an agent should do on his -behalf. +machine**, and the **hosted** database. Each needs a person — the OAuth flow +requires entering real credentials. + +**These are due again on any build packaged with `CC_ACCOUNTS=1`.** Do not ship +one without them. **1. Packaged OAuth and Keychain persistence** ```bash -cd apps/desktop && SUPABASE_URL= SUPABASE_ANON_KEY= npm run dist +cd apps/desktop && CC_ACCOUNTS=1 SUPABASE_URL= SUPABASE_ANON_KEY= npm run dist ``` Then, on the installed app: complete the GitHub browser flow, confirm @@ -71,7 +103,7 @@ With a disposable account, invoke Delete account. Confirm in the hosted project that the Auth user and the `user_settings` row are both gone, that local session state is cleared, and that the app still works signed out. -### Evidence to record when closing +### Evidence to record Packaged version and commit SHA · macOS versions and devices used · redacted screenshots or logs per check · confirmation the hosted records were removed. @@ -81,12 +113,6 @@ screenshots or logs per check · confirmation the hosted records were removed. Nothing connected the open issue to the release workflow. A gate declared only in an issue is a gate only a person can enforce, and the person was busy shipping. -Fixed by the "Verify no release gate is open" step in `app-release.yml`, added -alongside this file: tagging `app-v0.3.1` now fails while the section above still -says `## Open:`. That is the intended behavior, not an obstacle to route around — -the next release is blocked until these three checks are done or the decision to -ship without them is written here. - -## Closed: - -*(none yet — the first gate to close will be #44, above)* +Fixed by the "Verify no release gate is open" step in `app-release.yml`: a tag +push fails while any `## Open:` section remains in this file. That is the +intended behavior, not an obstacle to route around.