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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ OWNERSHIP_INGEST_ENABLED=false
# Comma-separated internal repository UUIDs only — never repo names,
# full_name, or GitHub logins. Empty/malformed = nothing can ingest.
OWNERSHIP_CANARY_REPOSITORY_IDS=

# Desktop development only: point the Tauri shell's API transport at a
# local `vercel dev` instance (e.g. http://localhost:3000). MUST stay
# unset for production desktop builds — the transport then targets
# https://devledger.site. See docs/desktop.md.
VITE_DESKTOP_API_BASE=
175 changes: 175 additions & 0 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: desktop

# Desktop build matrix. PR/main pushes produce CI artifacts only; an
# explicit `v*` tag produces the same artifacts and a DRAFT GitHub Release
# (never auto-published) with SHA-256 checksums for the package-manager
# manifests in distribution/.

on:
pull_request:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true

jobs:
# Quality gates — the desktop matrix and any tag-driven draft release
# may only run after the full web-quality suite passes. A v* tag can
# never produce even a draft release from a commit that fails these.
verify:
name: quality gates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm

- name: Install dependencies
# Lockfile-enforced; install lifecycle hooks never run (esbuild /
# fsevents resolve through optional platform binaries instead).
run: npm ci --ignore-scripts

- name: Build
run: npm run build

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm test

- name: Audit
run: npm audit --audit-level=moderate

build:
name: ${{ matrix.label }}
needs: verify
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
target: x86_64-pc-windows-msvc
bundles: nsis,msi
label: windows-x64
artifact-glob: 'src-tauri/target/**/release/bundle/{nsis,msi}/*'
- os: macos-latest
target: aarch64-apple-darwin
bundles: dmg,app
label: macos-arm64
artifact-glob: 'src-tauri/target/**/release/bundle/{dmg,macos}/*'
- os: macos-latest
target: x86_64-apple-darwin
bundles: dmg,app
label: macos-x64
artifact-glob: 'src-tauri/target/**/release/bundle/{dmg,macos}/*'
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
bundles: deb,appimage
label: linux-x64
artifact-glob: 'src-tauri/target/**/release/bundle/{deb,appimage}/*'

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: npm

- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: ${{ matrix.target }}

- uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1
with:
workspaces: src-tauri -> target

- name: Linux build dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential curl wget file patchelf \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev

- name: Install dependencies
run: npm ci --ignore-scripts

- name: Build desktop bundles
env:
# Signing is intentionally not configured — artifacts are unsigned
# until the secrets documented in docs/desktop.md are provided.
TAURI_SIGNING_PRIVATE_KEY: ''
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
run: npx tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}

- name: Collect artifacts
shell: bash
run: |
mkdir -p dist-artifacts
find src-tauri/target -type f \( \
-name '*.exe' -o -name '*.msi' -o \
-name '*.dmg' -o -name '*.deb' -o -name '*.AppImage' \) \
-path '*/bundle/*' -exec cp {} dist-artifacts/ \;
# Normalize to the canonical release-asset filename contract —
# package-manager manifests point at these exact names.
node scripts/release-assets.mjs dist-artifacts

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: desktop-${{ matrix.label }}
path: dist-artifacts/
if-no-files-found: error

release:
name: draft-release
if: startsWith(github.ref, 'refs/tags/v')
needs: [verify, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: desktop-*
path: artifacts
merge-multiple: true

- name: SHA-256 checksums
working-directory: artifacts
run: |
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt

- name: Create draft release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--draft \
--title "Dev Ledger ${GITHUB_REF_NAME}" \
--notes "Desktop builds for Windows, macOS and Linux. See SHA256SUMS.txt for checksums. Artifacts are unsigned — do not publish before signing/notarization is configured." \
artifacts/*

# NOTE: release is a DRAFT — a human verifies artifacts + checksums before
# publishing. Never publish unsigned artifacts as a stable release.
3 changes: 3 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ on:
pull_request:
push:
branches: [main]
# Release tags also get the full-history scan — a desktop draft
# release must never be cut from history containing a leaked secret.
tags: ['v*']

permissions:
contents: read
Expand Down
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ These notes define the public development contract for humans and coding agents
- Dependency audit: `npm audit --audit-level=moderate`
- Database migration: `npm run db:migrate`
- Local recovery drill: `npm run db:drill`
- Desktop dev (Tauri): `npm run desktop:dev`
- Desktop bundle build: `npm run desktop:build`
- Desktop version sync: `npm run desktop:version`

## Product invariants

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ The frontend is React + Vite. API routes run as Vercel Functions. GitHub App ing
<tr><td><code>Data</code></td><td><code>Supabase Postgres</code></td></tr>
<tr><td><code>Product analytics</code></td><td><code>PostHog</code> · <code>custom-event-only</code></td></tr>
<tr><td><code>Traffic analytics</code></td><td><code>Vercel Web Analytics</code></td></tr>
<tr><td><code>Desktop</code></td><td><code>Tauri 2</code> · <a href="docs/desktop.md">docs/desktop.md</a></td></tr>
<tr><td><code>CI</code></td><td><code>GitHub Actions</code></td></tr>
<tr><td><code>Security checks</code></td><td><code>Typecheck</code> · <code>tests</code> · <code>npm audit</code> · <code>gitleaks</code> · <code>artifact scan</code> · <code>recovery drill</code></td></tr>
</table>
Expand Down
10 changes: 9 additions & 1 deletion api/auth/callback.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { github, appUrl, sessionLeaseMs } from '../../lib/config.mjs'
import { supabase } from '../../lib/db.mjs'
import { kickSync, runSync, setUserSync, SYNC_DETAIL_INIT } from '../../lib/sync.mjs'
import { createAuthSession, gcAuthSessions, isSessionLive } from '../../lib/sessions.mjs'
import { desktopHandoff } from '../../lib/desktop-auth.mjs'
import { requestQuery } from '../../lib/request-query.mjs'
import { securityEvent } from '../../lib/security-events.mjs'

Expand All @@ -16,7 +17,10 @@ export default async function handler(req, res) {
// installation directly; without one, restart login so OAuth completes.
if (installationId && setupAction) {
if (!session.userId || (supabase && !(await isSessionLive(session.sid)))) {
res.writeHead(302, { Location: '/api/auth/login' })
// Carry a pending desktop flow through the restarted login so the
// handoff still returns to the app after re-auth.
const flow = session?.desktopFlow ? `?desktop_flow=${encodeURIComponent(session.desktopFlow)}` : ''
res.writeHead(302, { Location: `/api/auth/login${flow}` })
res.end()
return
}
Expand All @@ -42,6 +46,7 @@ export default async function handler(req, res) {
waitUntil(runSync(session.userId, { budgetMs: 45_000, force: true }).catch(() => {}))
}
}
if (await desktopHandoff(res, session, '/')) return
res.writeHead(302, { Location: '/' })
res.end()
return
Expand Down Expand Up @@ -182,12 +187,15 @@ export default async function handler(req, res) {

// No installation yet → send the user through the GitHub App install flow
// (all or selected repositories), which returns via the app Setup URL.
// session.desktopFlow intentionally survives this hop — /api/setup
// completes the deep-link handoff once the install lands.
if (!installations.length && github.appSlug) {
res.writeHead(302, { Location: `https://github.com/apps/${github.appSlug}/installations/new` })
res.end()
return
}

if (await desktopHandoff(res, session, '/')) return
res.writeHead(302, { Location: '/' })
res.end()
}
14 changes: 14 additions & 0 deletions api/auth/login.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import crypto from 'node:crypto'
import { getSession } from '../../lib/auth.mjs'
import { github, appUrl } from '../../lib/config.mjs'
import { isDesktopFlowId, isDesktopChallenge } from '../../lib/desktop-auth.mjs'
import { requestQuery } from '../../lib/request-query.mjs'

export default async function handler(req, res) {
Expand All @@ -19,6 +20,19 @@ export default async function handler(req, res) {
// OAuth round-trip without weakening state validation.
session.remember = remember
session.persistent = remember
// Desktop handoff: the app generated a flow id and opened this URL in the
// system browser. Sealed inside the session so the callback knows to
// return the session through a dev-ledger:// deep link instead of '/'.
// Anything that is not a well-formed UUID is ignored — the web flow is
// unchanged when the parameter is absent or malformed.
// The flow is honored only as a complete pair: the UUID binds the
// handoff to this app instance's login intent, and the challenge (sha256
// of a verifier held only in app memory) makes an intercepted
// dev-ledger:// URL unexchangeable — PKCE discipline for custom schemes.
if (isDesktopFlowId(query.desktop_flow) && isDesktopChallenge(query.desktop_challenge)) {
session.desktopFlow = query.desktop_flow.toLowerCase()
session.desktopChallenge = query.desktop_challenge.toLowerCase()
}
await session.save()
const redirectUri = `${appUrl}/api/auth/callback`
const url = new URL('https://github.com/login/oauth/authorize')
Expand Down
10 changes: 9 additions & 1 deletion api/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { waitUntil } from '@vercel/functions'
import { getSession, getAppOctokit } from '../lib/auth.mjs'
import { supabase } from '../lib/db.mjs'
import { isSessionLive } from '../lib/sessions.mjs'
import { desktopHandoff } from '../lib/desktop-auth.mjs'
import { kickSync, runSync } from '../lib/sync.mjs'
import { requestQuery } from '../lib/request-query.mjs'

Expand All @@ -10,7 +11,11 @@ import { requestQuery } from '../lib/request-query.mjs'
export default async function handler(req, res) {
const session = await getSession(req, res)
if (!session?.userId || (supabase && !(await isSessionLive(session.sid)))) {
res.writeHead(302, { Location: '/api/auth/login' })
// Restarting login mid-install must not strand a desktop handoff —
// carry the sealed flow id forward so the resumed OAuth still returns
// to the app. Unauthenticated installs (no flow) are unchanged.
const flow = session?.desktopFlow ? `?desktop_flow=${encodeURIComponent(session.desktopFlow)}` : ''
res.writeHead(302, { Location: `/api/auth/login${flow}` })
res.end()
return
}
Expand Down Expand Up @@ -45,6 +50,9 @@ export default async function handler(req, res) {
}
}

// A desktop login pending handoff finishes here (first-time installs
// arrive via this Setup URL rather than the OAuth callback tail).
if (await desktopHandoff(res, session, '/?installed=1')) return
res.writeHead(302, { Location: '/?installed=1' })
res.end()
}
Loading
Loading