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
16 changes: 7 additions & 9 deletions .github/workflows/app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
72 changes: 53 additions & 19 deletions apps/console/src/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<typeof window.__CC_AUTH>> = {}) {
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()
Expand All @@ -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(
<ThemeModeProvider>
<SettingsView appMode="live" onClose={onClose} />
</ThemeModeProvider>,
))

// 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(
<ThemeModeProvider>
Expand All @@ -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()
Expand All @@ -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(
<ThemeModeProvider>
Expand All @@ -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()
Expand Down
25 changes: 12 additions & 13 deletions apps/console/src/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -65,10 +68,12 @@ export function SettingsView({
Indexing
</button>
)}
<button type="button" aria-current={pane === 'account' ? 'page' : undefined} onClick={() => setPane('account')}>
<AccountIcon />
Account
</button>
{accountsAvailable && (
<button type="button" aria-current={pane === 'account' ? 'page' : undefined} onClick={() => setPane('account')}>
<AccountIcon />
Account
</button>
)}
</nav>
</aside>

Expand Down Expand Up @@ -135,22 +140,16 @@ export function SettingsView({
</div>
</section>
</>
) : (
) : accountsAvailable && window.__CC_AUTH ? (
<>
<header className="cc-settings-header">
<p>Settings</p>
<h1>Account</h1>
<span>Sign in to keep preferences and source metadata consistent across Macs.</span>
</header>
{window.__CC_AUTH ? (
<AccountPanel />
) : (
<div className="cc-settings-empty">
Account sign-in is available in the ContextCake desktop app. Local features still work without an account.
</div>
)}
<AccountPanel />
</>
)}
) : null}
</div>
</main>
</div>
Expand Down
14 changes: 11 additions & 3 deletions apps/desktop/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
11 changes: 6 additions & 5 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 29 additions & 4 deletions apps/desktop/scripts/generate-supabase-config.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
// 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'
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.')
Expand All @@ -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)`)
4 changes: 4 additions & 0 deletions apps/desktop/src/main/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'}`,
],
},
})
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/supabase-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,27 @@ 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 = {}
let user = {}
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 */ }
Expand All @@ -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 }
}
2 changes: 1 addition & 1 deletion apps/desktop/src/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading