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
3 changes: 2 additions & 1 deletion api/dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { supabase } from '../lib/db.mjs'
import { parseRange } from '../lib/range.mjs'
import { summarizeDaily, dayKey } from '../lib/analytics.mjs'
import { getUserSync, getCoverage } from '../lib/sync.mjs'
import { syncErrorMessage } from '../lib/sync-errors.mjs'
import { rangeCoverageStatus } from '../lib/coverage.mjs'
import { requestQuery } from '../lib/request-query.mjs'

Expand Down Expand Up @@ -144,7 +145,7 @@ export default async function handler(req, res) {
lastSyncedAt: sync.last_synced_at,
resumeAt: sync.resume_at || null,
updatedAt: sync.updated_at,
error: sync.error,
error: syncErrorMessage(sync.error),
}
: { status: 'idle', progress: 0 },
rangeCoverage,
Expand Down
3 changes: 2 additions & 1 deletion api/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { waitUntil } from '@vercel/functions'
import { requireUser } from '../lib/require-user.mjs'
import { forbidCrossSite } from '../lib/same-origin.mjs'
import { runSync, getUserSync } from '../lib/sync.mjs'
import { syncErrorMessage } from '../lib/sync-errors.mjs'
import { requestQuery } from '../lib/request-query.mjs'

const shape = (s) =>
Expand All @@ -16,7 +17,7 @@ const shape = (s) =>
resumeAt: s.resume_at,
lastSyncedAt: s.last_synced_at,
updatedAt: s.updated_at,
error: s.error,
error: syncErrorMessage(s.error),
}
: { status: 'idle', progress: 0 }

Expand Down
3 changes: 2 additions & 1 deletion api/user.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getSession } from '../lib/auth.mjs'
import { github } from '../lib/config.mjs'
import { supabase } from '../lib/db.mjs'
import { getUserSync } from '../lib/sync.mjs'
import { syncErrorMessage } from '../lib/sync-errors.mjs'
import { revokeAllSessions, isSessionLive } from '../lib/sessions.mjs'
import { requestQuery } from '../lib/request-query.mjs'
import { forbidCrossSite } from '../lib/same-origin.mjs'
Expand Down Expand Up @@ -175,7 +176,7 @@ export default async function handler(req, res) {
progress: sync.progress,
detail: sync.detail || null,
lastSyncedAt: sync.last_synced_at,
error: sync.error,
error: syncErrorMessage(sync.error),
}
: null,
})
Expand Down
6 changes: 1 addition & 5 deletions api/webhooks/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ async function onInstallation(payload) {
.eq('installation_id', inst.id)
await supabase.from('github_installations').delete().eq('installation_id', inst.id)
for (const r of rows || []) {
await setUserSync(r.user_id, { status: 'revoked', error: 'GitHub access was revoked' })
await setUserSync(r.user_id, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED' })
}
return
}
Expand Down Expand Up @@ -221,12 +221,8 @@ async function onPullRequest(payload) {
user_id: repo.user_id,
repository_id: repo.id,
github_pr_id: pr.id,
number: pr.number,
author_user_id: pr.user.id,
author_login: pr.user.login,
state: pr.merged_at ? 'merged' : pr.state,
created_at: pr.created_at,
closed_at: pr.closed_at,
merged_at: pr.merged_at,
// No pr.title — 008_data_minimization dropped the column; PR titles are
// user-authored text Dev Ledger deliberately does not persist.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Webhook requests require a valid HMAC signature and use delivery IDs to prevent

Supabase Postgres stores normalized records for users, installations, repositories, languages, commits, pull requests, coverage, sync state, sessions, and limited operational telemetry.

The application intentionally does not persist repository source code, commit messages, pull-request titles, GitHub email addresses, OAuth tokens, installation tokens, or raw webhook payload bodies.
The application intentionally does not persist repository source code, commit messages, pull-request titles, commit or pull-request author identifiers (every stored row is already scoped to the signed-in user), raw provider exception text (sync errors persist closed taxonomy codes only), GitHub email addresses, OAuth tokens, installation tokens, or raw webhook payload bodies.

Application tables use row-level security and browser database roles have no direct table access. Server-side application queries use the backend credential and scope records to the authenticated internal user.

Expand Down
11 changes: 9 additions & 2 deletions docs/disaster-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ schema/table is *not* backed up until explicitly classified in
| `repositories` | core | yes | tracked repos + disconnect state |
| `repository_languages` | core | yes | language byte-share stats |
| `commits` | core | yes | ingested history — expensive to rebuild |
| `pull_requests` | core | yes | ingested PR rows (no titles by design) |
| `pull_requests` | core | yes | ingested PR rows (no titles/numbers/authors by design) |
| `repo_coverage` | operational | yes | derived but needs full re-ingest to rebuild — cheap to keep |
| `repo_sync` | operational | yes | phase markers; self-healing hints |
| `user_sync` | operational | yes | stale `syncing`/`locked` rows are taken over by cron's stale-lock logic |
Expand Down Expand Up @@ -257,7 +257,14 @@ it — zero production impact.
select/insert/delete + sequence usage)
- [ ] `dash_*` functions are SECURITY INVOKER; dashboard views
`security_invoker`
- [ ] `pull_requests.title` absent (privacy removal intact)
- [ ] minimized columns absent: `pull_requests.title`, `pull_requests.number`,
`pull_requests.author_user_id`, `pull_requests.author_login`,
`pull_requests.closed_at`, `commits.message`, `commits.author_user_id`,
`commits.author_login`, `commits.authored_at`, `commits.files_changed`,
`commits.is_merge`
- [ ] `user_sync.error` / `repo_sync.error` contain only closed taxonomy
codes (`GITHUB_RATE_LIMIT`, `GITHUB_ACCESS_REVOKED`,
`SYNC_HISTORY_FAILED`, `SYNC_PULLS_FAILED`, `SYNC_INTERNAL_ERROR`)
- [ ] `auth_sessions` empty (re-auth forced), `webhook_deliveries` empty,
`security_events` fresh
- [ ] a post-restore insert into `security_events` works (identity live)
Expand Down
26 changes: 15 additions & 11 deletions lib/sessions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ import { persistentTtl } from './config.mjs'

// Server-side session records. Every issued Dev Ledger session carries a
// random `sid` sealed inside the iron-session cookie; requireUser validates
// it against auth_sessions before trusting the identity. Revocation is a
// single row update — a stolen but well-formed cookie dies immediately.
// it against auth_sessions before trusting the identity. Revocation DELETES
// the row — a missing sid fails validation identically, and a stolen but
// well-formed cookie dies immediately with nothing retained.
//
// Backward compatibility: cookies issued before this shipped carry no sid.
// They fail validation → one forced re-login. Intentional: a pre-migration
// cookie has no server-side kill switch, so it must not stay trusted.
// Rows with revoked_at set are legacy (pre-delete semantics): the liveness
// filter still excludes them, and GC purges them outright.

const TABLE = 'auth_sessions'

Expand Down Expand Up @@ -44,36 +47,37 @@ export async function isSessionLive(sid) {
return Boolean(data)
}

// Revoke one session (logout).
// Revoke one session (logout): delete the row. A missing sid fails
// isSessionLive exactly like a revoked one — nothing is retained.
export async function revokeSession(sid) {
if (!supabase || !sid) return
await supabase
.from(TABLE)
.update({ revoked_at: new Date().toISOString() })
.delete()
.eq('sid', sid)
.is('revoked_at', null)
}

// Revoke every session for a user (DELETE MY DATA, credential rotation).
export async function revokeAllSessions(userId) {
if (!supabase || !userId) return
await supabase
.from(TABLE)
.update({ revoked_at: new Date().toISOString() })
.delete()
.eq('user_id', userId)
.is('revoked_at', null)
}

// Bounded retention: expired/revoked rows older than 30 days can be
// garbage-collected. Called opportunistically (login), not per request.
// Bounded retention: dead rows are removed outright. A row is dead once
// expires_at has passed (isSessionLive rejects it) or when a legacy revoked
// marker is still present (revocation is a delete now — no new row ever
// carries revoked_at). Called opportunistically (login), not per request.
export async function gcAuthSessions() {
if (!supabase) return
const cutoff = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString()
const now = new Date().toISOString()
try {
await supabase
.from(TABLE)
.delete()
.or(`expires_at.lt.${cutoff},revoked_at.lt.${cutoff}`)
.or(`expires_at.lt.${now},revoked_at.gte.1970-01-01T00:00:00.000Z`)
} catch {
/* GC is best-effort — next call retries */
}
Expand Down
46 changes: 46 additions & 0 deletions lib/sync-errors.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { RateLimitError, GitHubAuthError } from './github.mjs'

// Closed taxonomy for persisted sync errors.
//
// repo_sync.error / user_sync.error carry ONLY these codes. Raw exception
// text, provider response bodies, URLs and stack traces are never persisted:
// a thrown value is classified here at the write boundary and everything
// else collapses to SYNC_INTERNAL_ERROR. The presentation layer translates
// codes back to fixed human-readable strings via syncErrorMessage().

export const SYNC_ERROR_CODES = Object.freeze([
'GITHUB_RATE_LIMIT',
'GITHUB_ACCESS_REVOKED',
'SYNC_HISTORY_FAILED',
'SYNC_PULLS_FAILED',
'SYNC_INTERNAL_ERROR',
])

const MESSAGES = Object.freeze({
GITHUB_RATE_LIMIT: 'GitHub rate limit reached — will resume automatically',
GITHUB_ACCESS_REVOKED: 'GitHub access was revoked',
SYNC_HISTORY_FAILED: 'Repository history sync failed — will retry automatically',
SYNC_PULLS_FAILED: 'Pull request sync failed — will retry automatically',
SYNC_INTERNAL_ERROR: 'Sync failed — will retry automatically',
})

export function isSyncErrorCode(value) {
return SYNC_ERROR_CODES.includes(value)
}

// Any thrown value → a taxonomy code. `fallback` distinguishes the phase the
// failure escaped from (e.g. 'SYNC_PULLS_FAILED'); unknown throws always map
// to SYNC_INTERNAL_ERROR — no exception property is ever returned.
export function syncErrorCode(err, fallback = 'SYNC_INTERNAL_ERROR') {
if (err instanceof RateLimitError) return 'GITHUB_RATE_LIMIT'
if (err instanceof GitHubAuthError) return 'GITHUB_ACCESS_REVOKED'
return isSyncErrorCode(fallback) ? fallback : 'SYNC_INTERNAL_ERROR'
}

// Presentation boundary: stored code → fixed human-readable string. Legacy
// free-text values (pre-taxonomy rows) and anything unrecognized collapse to
// the generic message — the stored value is never echoed verbatim.
export function syncErrorMessage(stored) {
if (stored == null || stored === '') return null
return MESSAGES[stored] || MESSAGES.SYNC_INTERNAL_ERROR
}
46 changes: 22 additions & 24 deletions lib/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { supabase } from './db.mjs'
import { getInstallationOctokit, withBackoff, RateLimitError, GitHubAuthError } from './github.mjs'
import { isAttributedCommit } from './analytics.mjs'
import { coversRange, computeSyncProgress } from './coverage.mjs'
import { syncErrorCode } from './sync-errors.mjs'

// Staged, resumable GitHub ingestion.
//
Expand Down Expand Up @@ -30,12 +31,9 @@ query ($owner: String!, $name: String!, $author: ID!, $cursor: String, $since: G
nodes {
oid
committedDate
authoredDate
additions
deletions
changedFilesIfAvailable
parents { totalCount }
author { user { id login } }
author { user { id } }
}
}
}
Expand Down Expand Up @@ -316,7 +314,7 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume

if (authFailed && !seenRepoIds.size) {
// Every installation is gone on GitHub's side — the app was uninstalled.
await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked', locked_at: null })
await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED', locked_at: null })
return { ...(await getUserSync(userId)), ran: true }
}

Expand Down Expand Up @@ -427,7 +425,16 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume
// ---- phase: pulls — one installation-scoped search, runs alongside ---
const pullsTask = (async () => {
if (expired()) return
detail.pulls.count = await syncPullRequests({ userId, user, installs, octokitFor })
try {
detail.pulls.count = await syncPullRequests({ userId, user, installs, octokitFor })
} catch (err) {
// Fatal errors rethrow unchanged so the outer handler keeps their
// dedicated status path; anything else is tagged so the persisted
// record carries the phase code, never provider text.
if (isFatal(err)) throw err
if (err && typeof err === 'object') err.syncCode = 'SYNC_PULLS_FAILED'
throw err
}
detail.pulls.done = true
await beat()
})()
Expand Down Expand Up @@ -455,11 +462,11 @@ export async function runSync(userId, { budgetMs = 20000, force = false, resume
} catch (err) {
const release = { locked_at: null }
if (err instanceof RateLimitError) {
await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GitHub rate limit reached — will resume automatically', ...release })
await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GITHUB_RATE_LIMIT', ...release })
} else if (err instanceof GitHubAuthError) {
await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked', ...release })
await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED', ...release })
} else {
await setUserSync(userId, { status: 'error', error: String(err?.message || err).slice(0, 400), ...release })
await setUserSync(userId, { status: 'error', error: syncErrorCode(err, err?.syncCode), ...release })
}
return { ...(await getUserSync(userId)), ran: true }
}
Expand Down Expand Up @@ -515,11 +522,11 @@ export async function runRangeSync(userId, from, to, { budgetMs = 20000 } = {})
}
} catch (err) {
if (err instanceof RateLimitError) {
await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GitHub rate limit reached — will resume automatically' })
await setUserSync(userId, { status: 'rate_limited', resume_at: err.retryAt.toISOString(), error: 'GITHUB_RATE_LIMIT' })
return { ok: false, error: 'rate_limited' }
}
if (err instanceof GitHubAuthError) {
await setUserSync(userId, { status: 'revoked', error: 'GitHub access was revoked' })
await setUserSync(userId, { status: 'revoked', error: 'GITHUB_ACCESS_REVOKED' })
return { ok: false, error: 'revoked' }
}
// Non-fatal: fall through and report whatever coverage was achieved
Expand Down Expand Up @@ -662,7 +669,7 @@ async function syncRepoCommits({ userId, user, rs, repo, octokitFor, expired })
const rows = []
for (const node of nodes) {
if (!isAttributedCommit(node, user.github_node_id)) continue
rows.push(commitRow(node, userId, repo.id, user))
rows.push(commitRow(node, userId, repo.id))
}
if (rows.length) {
await supabase.from('commits').upsert(rows, { onConflict: 'user_id,github_sha', ignoreDuplicates: true })
Expand Down Expand Up @@ -729,7 +736,7 @@ async function syncRepoRange({ userId, user, repo, octokit, fromIso, toIso, expi
onPage: async (nodes) => {
const rows = nodes
.filter((node) => isAttributedCommit(node, user.github_node_id))
.map((node) => commitRow(node, userId, repo.id, user))
.map((node) => commitRow(node, userId, repo.id))
if (rows.length) {
await supabase.from('commits').upsert(rows, { onConflict: 'user_id,github_sha', ignoreDuplicates: true })
commits += rows.length
Expand All @@ -756,19 +763,14 @@ async function syncRepoRange({ userId, user, repo, octokit, fromIso, toIso, expi
return { commits }
}

function commitRow(node, userId, repoId, user) {
function commitRow(node, userId, repoId) {
return {
user_id: userId,
repository_id: repoId,
github_sha: node.oid,
author_user_id: user.github_user_id,
author_login: node.author.user.login,
authored_at: node.authoredDate,
committed_at: node.committedDate,
additions: node.additions || 0,
deletions: node.deletions || 0,
files_changed: node.changedFilesIfAvailable || 0,
is_merge: (node.parents?.totalCount || 0) > 1,
}
}

Expand All @@ -779,7 +781,7 @@ function isFatal(err) {
async function markRepoError(userId, repositoryId, err) {
await supabase
.from('repo_sync')
.update({ phase: 'error', error: String(err?.message || err).slice(0, 300), updated_at: new Date().toISOString() })
.update({ phase: 'error', error: syncErrorCode(err, 'SYNC_HISTORY_FAILED'), updated_at: new Date().toISOString() })
.eq('user_id', userId)
.eq('repository_id', repositoryId)
}
Expand Down Expand Up @@ -822,12 +824,8 @@ async function syncPullRequests({ userId, user, installs, octokitFor, window = n
user_id: userId,
repository_id: await repoRowByFullName(fullName),
github_pr_id: item.id,
number: item.number,
author_user_id: item.user.id,
author_login: item.user.login,
state: item.pull_request?.merged_at ? 'merged' : item.state,
created_at: item.created_at,
closed_at: item.closed_at,
merged_at: item.pull_request?.merged_at || null,
})
}
Expand Down
Loading
Loading