diff --git a/.env.example b/.env.example index a090307..7b27415 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,14 @@ NUXT_ATPROTO_PRIVATE_JWK={"kty":"EC","kid":"...","crv":"P-256","x":"...","y":".. # --------------------------------------------------------------------------- NUXT_ENCRYPTION_KEY= +# --------------------------------------------------------------------------- +# Dashboard session password. Used by h3's `useSession` to seal the +# `synchub-session` cookie. 32+ characters of entropy. +# Generate with: pnpm gen:encryption-key (any sufficiently long random string +# works; the base64 output of 32 random bytes is convenient). +# --------------------------------------------------------------------------- +NUXT_SESSION_PASSWORD=<32+ char random string> + # --------------------------------------------------------------------------- # GitHub App credentials. After creating the App at # https://github.com/settings/apps/new, copy: diff --git a/app/middleware/authenticated.ts b/app/middleware/authenticated.ts new file mode 100644 index 0000000..5233311 --- /dev/null +++ b/app/middleware/authenticated.ts @@ -0,0 +1,23 @@ +export default defineNuxtRouteMiddleware(async () => { + // On SSR we must forward the request cookies so the whoami probe sees the + // session. On the client, same-origin `$fetch` already sends cookies. + const headers: Record = {} + if (import.meta.server) { + const cookie = useRequestHeader('cookie') + if (cookie) headers.cookie = cookie + } + + try { + await $fetch('/api/me/whoami', { headers }) + } + catch (err: unknown) { + const status = err && typeof err === 'object' + ? (('statusCode' in err && typeof err.statusCode === 'number' ? err.statusCode : undefined) + ?? ('status' in err && typeof err.status === 'number' ? err.status : undefined)) + : undefined + if (status === 401) { + return navigateTo('/', { redirectCode: 302 }) + } + throw err + } +}) diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue new file mode 100644 index 0000000..3eafe7d --- /dev/null +++ b/app/pages/dashboard.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 6fd6705..c8a00b5 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -21,6 +21,7 @@ export default defineNuxtConfig({ workerBudgetMs: '', encryptionKey: '', atprotoPrivateJwk: '', + sessionPassword: '', public: { url: '', }, diff --git a/server/api/atproto/callback.get.ts b/server/api/atproto/callback.get.ts index 3ae04c7..0b05e61 100644 --- a/server/api/atproto/callback.get.ts +++ b/server/api/atproto/callback.get.ts @@ -1,5 +1,6 @@ import { userIdentity } from '~~/server/db/schema' import { enqueue } from '~~/server/utils/queue' +import { writeSession } from '~~/server/utils/server-session' import { generateAndPublishKey } from '~~/server/utils/tangled-pubkey' export default defineEventHandler(async event => { @@ -41,5 +42,10 @@ export default defineEventHandler(async event => { // worker tick. await enqueue('tangled.backfill-installation', { installationId, page: 1 }) + // Sealed cookie session for the dashboard. Handle resolution is deferred: + // for v1 we surface the DID and the GitHub install's `accountLogin`, which + // we already have. A handle resolver can populate `session.handle` later. + await writeSession(event, { did: session.did, installationId }) + await sendRedirect(event, '/dashboard', 302) }) diff --git a/server/api/me/dashboard.get.ts b/server/api/me/dashboard.get.ts new file mode 100644 index 0000000..b5f38a4 --- /dev/null +++ b/server/api/me/dashboard.get.ts @@ -0,0 +1,123 @@ +import { sql } from 'drizzle-orm' +import { installation, repoMapping, sshKey, userIdentity } from '~~/server/db/schema' +import { useDb } from '~~/server/utils/db' +import { requireSession } from '~~/server/utils/server-session' + +export interface DashboardRepo { + id: number + githubRepoId: number + githubFullName: string + tangledRepoDid: string | null + tangledFullName: string | null + knot: string | null + status: string + lastError: string | null + disabledAt: string | null + lastSyncedRefs: Record + lastSyncedAt: string | null + refCount: number +} + +export interface DashboardPayload { + did: string + handle: string | null + installation: { + id: number + accountLogin: string + accountType: string + suspendedAt: string | null + } | null + hasSshKey: boolean + sshKey: { + publicKey: string + createdAt: string + rotatedAt: string | null + } | null + repos: DashboardRepo[] +} + +export default defineEventHandler(async (event): Promise => { + const session = await requireSession(event) + const db = useDb() + + const [identityRow, installRows, repoRows, sshKeyRows] = await Promise.all([ + db.select({ did: userIdentity.did, handle: userIdentity.handle }) + .from(userIdentity) + .where(sql`${userIdentity.did} = ${session.did}`) + .limit(1), + db.select({ + id: installation.id, + accountLogin: installation.accountLogin, + accountType: installation.accountType, + suspendedAt: installation.suspendedAt, + }) + .from(installation) + .where(sql`${installation.id} = ${session.installationId}`) + .limit(1), + db.select().from(repoMapping) + .where(sql`${repoMapping.installationId} = ${session.installationId}`) + .orderBy(sql`${repoMapping.githubFullName}`), + db.select({ + publicKey: sshKey.publicKey, + createdAt: sshKey.createdAt, + rotatedAt: sshKey.rotatedAt, + }) + .from(sshKey) + .where(sql`${sshKey.installationId} = ${session.installationId} AND ${sshKey.did} = ${session.did}`) + .limit(1), + ]) + + const installRow = installRows[0] ?? null + const sshKeyRow = sshKeyRows[0] ?? null + + const repos: DashboardRepo[] = repoRows.map(row => { + // Schema audit prefers no new column for "last push time": `updatedAt` + // moves on any mapping mutation (status, errors, disable toggle), so it's + // a noisy proxy for "last sync". When at least one ref has synced, + // surface `updatedAt` as a best-effort timestamp; when refs is empty, we + // have nothing meaningful to show. + // eslint-disable-next-line ts/no-unsafe-type-assertion -- jsonb column is typed `unknown` + const refs = (row.lastSyncedRefs ?? {}) as Record + const refKeys = Object.keys(refs) + return { + id: row.id, + githubRepoId: row.githubRepoId, + githubFullName: row.githubFullName, + tangledRepoDid: row.tangledRepoDid, + tangledFullName: row.tangledFullName, + knot: row.knot, + status: row.status, + lastError: row.lastError, + disabledAt: row.disabledAt?.toISOString() ?? null, + lastSyncedRefs: refs, + lastSyncedAt: refKeys.length > 0 && row.updatedAt ? row.updatedAt.toISOString() : null, + refCount: refKeys.length, + } + }) + + const installationPayload: DashboardPayload['installation'] = installRow + ? { + id: installRow.id, + accountLogin: installRow.accountLogin, + accountType: installRow.accountType, + suspendedAt: installRow.suspendedAt?.toISOString() ?? null, + } + : null + + const sshKeyPayload: DashboardPayload['sshKey'] = sshKeyRow + ? { + publicKey: sshKeyRow.publicKey, + createdAt: sshKeyRow.createdAt.toISOString(), + rotatedAt: sshKeyRow.rotatedAt?.toISOString() ?? null, + } + : null + + return { + did: session.did, + handle: identityRow[0]?.handle ?? session.handle ?? null, + installation: installationPayload, + hasSshKey: sshKeyPayload !== null, + sshKey: sshKeyPayload, + repos, + } +}) diff --git a/server/api/me/rotate-key.post.ts b/server/api/me/rotate-key.post.ts new file mode 100644 index 0000000..41a0fbc --- /dev/null +++ b/server/api/me/rotate-key.post.ts @@ -0,0 +1,21 @@ +import { enqueue } from '~~/server/utils/queue' +import { requireSession } from '~~/server/utils/server-session' + +/** + * Enqueue an SSH key rotation for the current `(did, installationId)`. + * + * The actual rotation runs in the worker: delete the existing + * `sh.tangled.publicKey` PDS record, drop the local row, generate a fresh + * keypair, publish the new public half. Doing this via the queue (rather + * than inline like the signup path) means a slow PDS doesn't tie up the + * request and we get retry semantics for free. + */ +export default defineEventHandler(async event => { + const session = await requireSession(event) + const row = await enqueue('atproto.publish-pubkey', { + did: session.did, + installationId: session.installationId, + force: true, + }) + return { jobId: row?.id ?? null } +}) diff --git a/server/api/me/whoami.get.ts b/server/api/me/whoami.get.ts new file mode 100644 index 0000000..2a67766 --- /dev/null +++ b/server/api/me/whoami.get.ts @@ -0,0 +1,12 @@ +import { requireSession } from '~~/server/utils/server-session' + +/** + * Lightweight session probe for the Nuxt `authenticated` middleware. Returns + * the session payload on success, 401 otherwise. The dashboard endpoint + * already requires a session, but the middleware needs a cheap call that + * doesn't touch the DB. + */ +export default defineEventHandler(async event => { + const session = await requireSession(event) + return session +}) diff --git a/server/api/repos/[id]/disable.post.ts b/server/api/repos/[id]/disable.post.ts new file mode 100644 index 0000000..33f7c6d --- /dev/null +++ b/server/api/repos/[id]/disable.post.ts @@ -0,0 +1,30 @@ +import { and, eq } from 'drizzle-orm' +import { repoMapping } from '~~/server/db/schema' +import { useDb } from '~~/server/utils/db' +import { requireSession } from '~~/server/utils/server-session' + +/** + * Pause sync for one mapping. The worker checks `disabledAt` on every push + * and skips disabled rows; we leave `status` alone so re-enabling restores + * the prior state. + */ +export default defineEventHandler(async event => { + const session = await requireSession(event) + const mappingId = Number(getRouterParam(event, 'id')) + if (!Number.isFinite(mappingId)) { + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) + } + + const db = useDb() + const updated = await db.update(repoMapping) + .set({ disabledAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(repoMapping.id, mappingId), + eq(repoMapping.installationId, session.installationId), + )) + .returning({ id: repoMapping.id }) + if (updated.length === 0) { + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) + } + return { ok: true } +}) diff --git a/server/api/repos/[id]/enable.post.ts b/server/api/repos/[id]/enable.post.ts new file mode 100644 index 0000000..aac2a5d --- /dev/null +++ b/server/api/repos/[id]/enable.post.ts @@ -0,0 +1,26 @@ +import { and, eq } from 'drizzle-orm' +import { repoMapping } from '~~/server/db/schema' +import { useDb } from '~~/server/utils/db' +import { requireSession } from '~~/server/utils/server-session' + +/** Clear `disabledAt`, resuming sync for this mapping. */ +export default defineEventHandler(async event => { + const session = await requireSession(event) + const mappingId = Number(getRouterParam(event, 'id')) + if (!Number.isFinite(mappingId)) { + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) + } + + const db = useDb() + const updated = await db.update(repoMapping) + .set({ disabledAt: null, updatedAt: new Date() }) + .where(and( + eq(repoMapping.id, mappingId), + eq(repoMapping.installationId, session.installationId), + )) + .returning({ id: repoMapping.id }) + if (updated.length === 0) { + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) + } + return { ok: true } +}) diff --git a/server/api/repos/[id]/resync.post.ts b/server/api/repos/[id]/resync.post.ts new file mode 100644 index 0000000..9b687f5 --- /dev/null +++ b/server/api/repos/[id]/resync.post.ts @@ -0,0 +1,39 @@ +import { and, eq } from 'drizzle-orm' +import { repoMapping } from '~~/server/db/schema' +import { useDb } from '~~/server/utils/db' +import { enqueue } from '~~/server/utils/queue' +import { requireSession } from '~~/server/utils/server-session' + +/** + * Enqueue a forced `tangled.create-repo` job for one mapping. The handler + * normally no-ops when a mapping already exists; the `force: true` envelope + * flag tells it to re-run the enrolment flow. + */ +export default defineEventHandler(async event => { + const session = await requireSession(event) + const mappingId = Number(getRouterParam(event, 'id')) + if (!Number.isFinite(mappingId)) { + throw createError({ statusCode: 400, statusMessage: 'invalid mapping id' }) + } + + const db = useDb() + const rows = await db.select({ + githubRepoId: repoMapping.githubRepoId, + }) + .from(repoMapping) + .where(and( + eq(repoMapping.id, mappingId), + eq(repoMapping.installationId, session.installationId), + )) + .limit(1) + if (rows.length === 0) { + throw createError({ statusCode: 404, statusMessage: 'mapping not found' }) + } + + const row = await enqueue('tangled.create-repo', { + installationId: session.installationId, + githubRepoId: rows[0]!.githubRepoId, + force: true, + }) + return { jobId: row?.id ?? null } +}) diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index ba8605c..40272d1 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -7,7 +7,7 @@ import type { JobEnvelope } from './queue' import { enqueue } from './queue' import { type CreateRefPayload, type DeleteRefPayload, syncCreateRef, syncDeleteRef } from './sync-ref' import { syncPush, type PushPayload } from './sync-push' -import { generateAndPublishKey } from './tangled-pubkey' +import { generateAndPublishKey, rotateKey } from './tangled-pubkey' import { enrollRepo, syncRepoMetadata } from './tangled-repo' /** @@ -41,11 +41,19 @@ const BACKFILL_PAGE_SIZE = 100 interface PublishPubkeyPayload { did: string installationId: number + /** + * Dashboard "Rotate SSH key" sets this. Causes the handler to call + * `rotateKey()` (delete old PDS record + DB row, then re-publish) rather + * than the no-op-if-exists `generateAndPublishKey()` used at signup. + */ + force?: boolean } interface CreateRepoPayload { installationId: number githubRepoId: number + /** Dashboard "Resync now" sets this; see `enrollRepo` for semantics. */ + force?: boolean } interface InstallationRepositoriesPayload { @@ -89,7 +97,7 @@ function publishPubkeyPayload(value: unknown): PublishPubkeyPayload { if (typeof o.did !== 'string' || typeof o.installationId !== 'number') { throw new TypeError('invalid atproto.publish-pubkey payload') } - return { did: o.did, installationId: o.installationId } + return { did: o.did, installationId: o.installationId, force: o.force === true } } function createRepoPayload(value: unknown): CreateRepoPayload { @@ -97,7 +105,11 @@ function createRepoPayload(value: unknown): CreateRepoPayload { if (typeof o.installationId !== 'number' || typeof o.githubRepoId !== 'number') { throw new TypeError('invalid tangled.create-repo payload') } - return { installationId: o.installationId, githubRepoId: o.githubRepoId } + return { + installationId: o.installationId, + githubRepoId: o.githubRepoId, + force: o.force === true, + } } function backfillInstallationPayload(value: unknown): BackfillInstallationPayload { @@ -198,15 +210,16 @@ export async function dispatch(envelope: JobEnvelope): Promise { } if (envelope.kind === 'atproto.publish-pubkey') { - const { did, installationId } = publishPubkeyPayload(envelope.payload) + const { did, installationId, force } = publishPubkeyPayload(envelope.payload) const client = await useOAuthClient() const session = await client.restore(did) - await generateAndPublishKey({ oauthSession: session, installationId }) + if (force) await rotateKey({ oauthSession: session, installationId }) + else await generateAndPublishKey({ oauthSession: session, installationId }) return } if (envelope.kind === 'tangled.create-repo') { - const { installationId, githubRepoId } = createRepoPayload(envelope.payload) + const { installationId, githubRepoId, force } = createRepoPayload(envelope.payload) // Find the user identity bound to this install. If OAuth hasn't completed // yet, drop this job silently \u2014 OAuth callback re-enqueues for all @@ -219,7 +232,7 @@ export async function dispatch(envelope: JobEnvelope): Promise { const client = await useOAuthClient() const session = await client.restore(identity[0]!.did) - await enrollRepo({ oauthSession: session, installationId, githubRepoId }) + await enrollRepo({ oauthSession: session, installationId, githubRepoId, force }) return } diff --git a/server/utils/require-session.ts b/server/utils/require-session.ts new file mode 100644 index 0000000..17c1116 --- /dev/null +++ b/server/utils/require-session.ts @@ -0,0 +1,2 @@ +export { getSessionData, requireSession, writeSession } from './server-session' +export type { SynchubSessionData } from './server-session' diff --git a/server/utils/server-session.ts b/server/utils/server-session.ts new file mode 100644 index 0000000..76e3aa9 --- /dev/null +++ b/server/utils/server-session.ts @@ -0,0 +1,73 @@ +import type { H3Event, SessionConfig } from 'h3' + +/** + * Cookie-backed session for the dashboard. The OAuth callback writes the + * session after a successful AT Proto login; `requireSession()` reads it on + * every dashboard / `/api/me/*` / `/api/repos/*` request. + * + * Iron-session-style sealed cookie via h3's built-in `useSession`. The + * `password` comes from `NUXT_SESSION_PASSWORD` (32+ chars). We read + * `process.env` directly so the helper is callable outside a request context + * for tests, mirroring `encryption.ts`. + * + * v1 stores `installationId` alongside `did`: a user may have installed the + * GitHub App on more than one account (personal + an org), but the session + * pins us to the one they last authenticated against. See the dashboard + * "Connect a different installation?" link for the workaround. + */ +export interface SynchubSessionData { + did: string + installationId: number + handle?: string +} + +const COOKIE_NAME = 'synchub-session' +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days + +function sessionPassword(): string { + const raw = process.env.NUXT_SESSION_PASSWORD + if (!raw || raw.length < 32) { + throw new Error('NUXT_SESSION_PASSWORD is not set (need 32+ characters of entropy)') + } + return raw +} + +export function sessionConfig(): SessionConfig { + return { + name: COOKIE_NAME, + password: sessionPassword(), + maxAge: COOKIE_MAX_AGE_SECONDS, + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: !(process.env.NUXT_PUBLIC_URL?.startsWith('http://127.0.0.1') + || process.env.NUXT_PUBLIC_URL?.startsWith('http://localhost')), + path: '/', + }, + } +} + +export async function getSessionData(event: H3Event): Promise { + const session = await useSession(event, sessionConfig()) + const { did, installationId } = session.data + if (typeof did !== 'string' || typeof installationId !== 'number') return null + return { did, installationId, handle: session.data.handle } +} + +/** + * Return the current session or throw a 401. Use from any handler that needs + * an authenticated user. The thrown error is consumed by Nitro and rendered + * as `{ statusCode: 401, statusMessage: 'unauthenticated' }`. + */ +export async function requireSession(event: H3Event): Promise { + const data = await getSessionData(event) + if (!data) { + throw createError({ statusCode: 401, statusMessage: 'unauthenticated' }) + } + return data +} + +export async function writeSession(event: H3Event, data: SynchubSessionData): Promise { + const session = await useSession(event, sessionConfig()) + await session.update(data) +} diff --git a/server/utils/tangled-pubkey.ts b/server/utils/tangled-pubkey.ts index 75e57a8..dad5bd6 100644 --- a/server/utils/tangled-pubkey.ts +++ b/server/utils/tangled-pubkey.ts @@ -67,3 +67,51 @@ export async function generateAndPublishKey(opts: { return { created: true } } + +/** + * Rotate the SSH key for `(installationId, did)`. + * + * Delete the existing `sh.tangled.publicKey` PDS record (best-effort: if the + * record is already gone on the PDS we proceed), drop the local row, then + * fall through to `generateAndPublishKey` to mint a fresh key. Pushes + * already in flight with the old key will fail and get retried with the new + * one via the queue's normal backoff. + */ +export async function rotateKey(opts: { + oauthSession: OAuthSession + installationId: number + keyName?: string +}): Promise<{ created: boolean }> { + const db = useDb() + const did = opts.oauthSession.did + + const existing = await db.select({ id: sshKey.id, rkey: sshKey.tangledKeyRkey }) + .from(sshKey) + .where(sql`${sshKey.installationId} = ${opts.installationId} AND ${sshKey.did} = ${did}`) + + if (existing.length > 0) { + const row = existing[0]! + if (row.rkey) { + const agent = new Agent(opts.oauthSession) + try { + await agent.com.atproto.repo.deleteRecord({ + repo: did, + collection: PUBKEY_LEXICON, + rkey: row.rkey, + }) + } + catch (err) { + // If the record is already gone (404) we can safely continue; any + // other error means the PDS rejected the delete and we should bail + // rather than leave the user with two records. + const status = err && typeof err === 'object' && 'status' in err && typeof err.status === 'number' + ? err.status + : undefined + if (status !== 404) throw err + } + } + await db.delete(sshKey).where(sql`${sshKey.id} = ${row.id}`) + } + + return generateAndPublishKey(opts) +} diff --git a/server/utils/tangled-repo.ts b/server/utils/tangled-repo.ts index edafb9c..56475cf 100644 --- a/server/utils/tangled-repo.ts +++ b/server/utils/tangled-repo.ts @@ -111,13 +111,22 @@ export async function enrollRepo(opts: { oauthSession: OAuthSession installationId: number githubRepoId: number + /** + * Used by the dashboard "Resync now" action. When true, ignore an existing + * `repo_mapping` row in `active` state and re-run the enrolment flow. Note + * this still performs the knot procedure call, which mints a *new* + * `repoDid`; v1 then overwrites the mapping with the new identity. A + * more surgical "poke the knot to re-sync from source" path is a future + * improvement. + */ + force?: boolean }): Promise { const db = useDb() - const existing = await db.select({ id: repoMapping.id }) + const existing = await db.select({ id: repoMapping.id, status: repoMapping.status }) .from(repoMapping) .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`) - if (existing.length > 0) { + if (existing.length > 0 && !opts.force) { return { status: 'already' } } @@ -195,16 +204,34 @@ export async function enrollRepo(opts: { record, }) - // 6. Persist mapping. - await db.insert(repoMapping).values({ - installationId: opts.installationId, - githubRepoId: opts.githubRepoId, - githubFullName: repo.full_name, - tangledRepoDid: repoDid, - tangledFullName: `${opts.oauthSession.did}/${name}`, - knot, - status: 'active', - }) + // 6. Persist mapping. On a forced resync the row already exists; update + // in place so we retain `lastSyncedRefs` (the worker uses it for ref-tip + // dedupe) but refresh the tangled-side identifiers and clear any prior + // error. + if (existing.length > 0) { + await db.update(repoMapping) + .set({ + githubFullName: repo.full_name, + tangledRepoDid: repoDid, + tangledFullName: `${opts.oauthSession.did}/${name}`, + knot, + status: 'active', + lastError: null, + updatedAt: new Date(), + }) + .where(sql`${repoMapping.id} = ${existing[0]!.id}`) + } + else { + await db.insert(repoMapping).values({ + installationId: opts.installationId, + githubRepoId: opts.githubRepoId, + githubFullName: repo.full_name, + tangledRepoDid: repoDid, + tangledFullName: `${opts.oauthSession.did}/${name}`, + knot, + status: 'active', + }) + } return { status: 'enrolled' } } diff --git a/test/unit/server-session.spec.ts b/test/unit/server-session.spec.ts new file mode 100644 index 0000000..7f6b75c --- /dev/null +++ b/test/unit/server-session.spec.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { sessionConfig } from '../../server/utils/server-session' + +const ORIGINAL_PASSWORD = process.env.NUXT_SESSION_PASSWORD +const ORIGINAL_PUBLIC_URL = process.env.NUXT_PUBLIC_URL + +describe('server-session: sessionConfig', () => { + beforeEach(() => { + process.env.NUXT_SESSION_PASSWORD = 'a'.repeat(32) + process.env.NUXT_PUBLIC_URL = 'http://127.0.0.1:3000' + }) + + afterEach(() => { + if (ORIGINAL_PASSWORD === undefined) delete process.env.NUXT_SESSION_PASSWORD + else process.env.NUXT_SESSION_PASSWORD = ORIGINAL_PASSWORD + if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.NUXT_PUBLIC_URL + else process.env.NUXT_PUBLIC_URL = ORIGINAL_PUBLIC_URL + }) + + it('throws if NUXT_SESSION_PASSWORD is missing', () => { + delete process.env.NUXT_SESSION_PASSWORD + expect(() => sessionConfig()).toThrow(/NUXT_SESSION_PASSWORD/) + }) + + it('throws if NUXT_SESSION_PASSWORD is too short', () => { + process.env.NUXT_SESSION_PASSWORD = 'short' + expect(() => sessionConfig()).toThrow(/32\+ characters/) + }) + + it('marks the cookie non-secure on 127.0.0.1 loopback dev', () => { + process.env.NUXT_PUBLIC_URL = 'http://127.0.0.1:3000' + const config = sessionConfig() + expect(config.cookie).toMatchObject({ secure: false, sameSite: 'lax', httpOnly: true, path: '/' }) + expect(config.name).toBe('synchub-session') + expect(config.maxAge).toBe(60 * 60 * 24 * 30) + }) + + it('marks the cookie non-secure on localhost loopback dev', () => { + process.env.NUXT_PUBLIC_URL = 'http://localhost:3000' + const config = sessionConfig() + expect(config.cookie).toMatchObject({ secure: false }) + }) + + it('marks the cookie secure when the deploy URL is not loopback', () => { + process.env.NUXT_PUBLIC_URL = 'https://synchub.to' + const config = sessionConfig() + expect(config.cookie).toMatchObject({ secure: true }) + }) +}) diff --git a/test/unit/tangled-pubkey.spec.ts b/test/unit/tangled-pubkey.spec.ts index 482bd56..64d02ce 100644 --- a/test/unit/tangled-pubkey.spec.ts +++ b/test/unit/tangled-pubkey.spec.ts @@ -4,12 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installation, sshKey } from '../../server/db/schema' import { clearDb, setDb, useDb } from '../../server/utils/db' import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption' -import { generateAndPublishKey } from '../../server/utils/tangled-pubkey' +import { generateAndPublishKey, rotateKey } from '../../server/utils/tangled-pubkey' import { createTestDb } from '../utils/db' const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record }) => Promise<{ data: { uri: string, cid: string } }>>() +const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise>() vi.mock('@atproto/api', () => ({ Agent: class { @@ -17,6 +18,7 @@ vi.mock('@atproto/api', () => ({ atproto: { repo: { createRecord: createRecordMock, + deleteRecord: deleteRecordMock, }, }, } @@ -45,9 +47,11 @@ describe('generateAndPublishKey', () => { }) createRecordMock.mockReset() + deleteRecordMock.mockReset() createRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, }) + deleteRecordMock.mockResolvedValue({}) }) afterEach(() => { @@ -117,3 +121,114 @@ describe('generateAndPublishKey', () => { expect(rows).toHaveLength(0) }) }) + +describe('rotateKey', () => { + beforeEach(async () => { + process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') + clearEncryptionKeyCache() + + setDb(await createTestDb()) + await useDb().insert(installation).values({ + id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', + }) + + createRecordMock.mockReset() + deleteRecordMock.mockReset() + let counter = 0 + createRecordMock.mockImplementation(async () => { + counter += 1 + return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } + }) + deleteRecordMock.mockResolvedValue({}) + }) + + afterEach(() => { + if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY + else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY + clearEncryptionKeyCache() + clearDb() + }) + + it('deletes the old PDS record and publishes a fresh key', async () => { + await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + const db = useDb() + const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) + expect(before).toHaveLength(1) + const oldPubKey = before[0].publicKey + const oldRkey = before[0].tangledKeyRkey + + const result = await rotateKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + expect(result.created).toBe(true) + + expect(deleteRecordMock).toHaveBeenCalledTimes(1) + const del = deleteRecordMock.mock.calls[0][0] + expect(del.repo).toBe('did:plc:abc') + expect(del.collection).toBe('sh.tangled.publicKey') + expect(del.rkey).toBe(oldRkey) + + const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) + expect(after).toHaveLength(1) + expect(after[0].publicKey).not.toBe(oldPubKey) + expect(after[0].tangledKeyRkey).toBe('rkey-2') + }) + + it('proceeds when the PDS reports the record is already gone (404)', async () => { + await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + + const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 }) + deleteRecordMock.mockRejectedValueOnce(notFound) + + const result = await rotateKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + expect(result.created).toBe(true) + + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(1) + expect(rows[0].tangledKeyRkey).toBe('rkey-2') + }) + + it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => { + await generateAndPublishKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + + deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) + + await expect(rotateKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + })).rejects.toThrow(/boom/) + + // Old row still present, no second createRecord call. + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(1) + expect(rows[0].tangledKeyRkey).toBe('rkey-1') + expect(createRecordMock).toHaveBeenCalledTimes(1) + }) + + it('mints a fresh key even if there is no existing row', async () => { + const result = await rotateKey({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + }) + expect(result.created).toBe(true) + expect(deleteRecordMock).not.toHaveBeenCalled() + const db = useDb() + const rows = await db.select().from(sshKey) + expect(rows).toHaveLength(1) + }) +}) diff --git a/test/unit/tangled-repo.spec.ts b/test/unit/tangled-repo.spec.ts index 86ba763..496afbf 100644 --- a/test/unit/tangled-repo.spec.ts +++ b/test/unit/tangled-repo.spec.ts @@ -204,6 +204,44 @@ describe('enrollRepo', () => { expect(githubGet).not.toHaveBeenCalled() }) + it('re-enrolls when force=true and updates the existing mapping in place', async () => { + await useDb().insert(repoMapping).values({ + installationId: 1, + githubRepoId: 9001, + githubFullName: 'alice/my-project', + tangledRepoDid: 'did:plc:old-repo', + tangledFullName: 'did:plc:abc/my-project', + knot: 'knot1.tangled.sh', + status: 'error', + lastError: 'previous failure', + lastSyncedRefs: { 'refs/heads/main': 'deadbeef' }, + }) + + githubGet.mockResolvedValue({ data: ghRepo() }) + fakeFetch.mockResolvedValue(new Response( + JSON.stringify({ repoDid: 'did:plc:repo-new' }), + { status: 200 }, + )) + + const result = await enrollRepo({ + oauthSession: fakeOauthSession('did:plc:abc'), + installationId: 1, + githubRepoId: 9001, + force: true, + }) + expect(result.status).toBe('enrolled') + + const rows = await useDb().select().from(repoMapping) + .where(sql`${repoMapping.installationId} = 1`) + expect(rows).toHaveLength(1) + expect(rows[0].tangledRepoDid).toBe('did:plc:repo-new') + expect(rows[0].status).toBe('active') + expect(rows[0].lastError).toBeNull() + // lastSyncedRefs preserved across the resync so the push worker keeps + // dedupe state. + expect(rows[0].lastSyncedRefs).toEqual({ 'refs/heads/main': 'deadbeef' }) + }) + it('throws and writes nothing if the knot rejects the procedure', async () => { githubGet.mockResolvedValue({ data: ghRepo() }) fakeFetch.mockResolvedValue(new Response('nope', { status: 500 }))