From fd2e8656ed5e228d0498c24ff6ba24b8465f41e5 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 4 May 2026 15:12:52 +0200 Subject: [PATCH] chore: lint --- nuxt.config.ts | 2 +- server/utils/atproto-oauth.ts | 6 ++-- server/utils/job-handlers.ts | 57 +++++++++++++++++++++++++++++--- server/utils/queue.ts | 12 ++++--- server/utils/ssh-keypair.ts | 2 +- server/utils/tangled-repo.ts | 3 +- test/unit/ssh-keypair.spec.ts | 2 +- test/unit/tangled-pubkey.spec.ts | 17 +++++----- test/unit/tangled-repo.spec.ts | 52 +++++++++++++++++------------ test/utils/db.ts | 1 + 10 files changed, 109 insertions(+), 45 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index df8779f..6fd6705 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -23,7 +23,7 @@ export default defineNuxtConfig({ atprotoPrivateJwk: '', public: { url: '', - } + }, }, typescript: { nodeTsConfig: { diff --git a/server/utils/atproto-oauth.ts b/server/utils/atproto-oauth.ts index f1a05f0..3119cbc 100644 --- a/server/utils/atproto-oauth.ts +++ b/server/utils/atproto-oauth.ts @@ -114,7 +114,8 @@ function makeStateStore(): NodeSavedStateStore { const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`) if (rows.length === 0) return undefined const row = rows[0]! - return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState + const parsed: NodeSavedState = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) + return parsed }, async del(key: string) { const db = useDb() @@ -142,7 +143,8 @@ function makeSessionStore(): NodeSavedSessionStore { const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`) if (rows.length === 0) return undefined const row = rows[0]! - return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession + const parsed: NodeSavedSession = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) + return parsed }, async del(sub: string) { const db = useDb() diff --git a/server/utils/job-handlers.ts b/server/utils/job-handlers.ts index bab37ac..25783b4 100644 --- a/server/utils/job-handlers.ts +++ b/server/utils/job-handlers.ts @@ -56,13 +56,62 @@ interface BackfillInstallationPayload { page: number } +function asObject(value: unknown): Record { + if (value === null || typeof value !== 'object') { + throw new TypeError(`expected object payload, got ${typeof value}`) + } + return { ...value } +} + +function publishPubkeyPayload(value: unknown): PublishPubkeyPayload { + const o = asObject(value) + if (typeof o.did !== 'string' || typeof o.installationId !== 'number') { + throw new TypeError('invalid atproto.publish-pubkey payload') + } + return { did: o.did, installationId: o.installationId } +} + +function createRepoPayload(value: unknown): CreateRepoPayload { + const o = asObject(value) + if (typeof o.installationId !== 'number' || typeof o.githubRepoId !== 'number') { + throw new TypeError('invalid tangled.create-repo payload') + } + return { installationId: o.installationId, githubRepoId: o.githubRepoId } +} + +function backfillInstallationPayload(value: unknown): BackfillInstallationPayload { + const o = asObject(value) + if (typeof o.installationId !== 'number' || typeof o.page !== 'number') { + throw new TypeError('invalid tangled.backfill-installation payload') + } + return { installationId: o.installationId, page: o.page } +} + +function installationRepositoriesPayload(value: unknown): InstallationRepositoriesPayload { + const o = asObject(value) + if ( + typeof o.installationId !== 'number' + || (o.action !== 'added' && o.action !== 'removed') + || !Array.isArray(o.addedRepoIds) + || !Array.isArray(o.removedRepoIds) + ) { + throw new TypeError('invalid github.installation_repositories payload') + } + return { + installationId: o.installationId, + action: o.action, + addedRepoIds: o.addedRepoIds.filter((id): id is number => typeof id === 'number'), + removedRepoIds: o.removedRepoIds.filter((id): id is number => typeof id === 'number'), + } +} + export async function dispatch(envelope: JobEnvelope): Promise { if (!KNOWN_KINDS.has(envelope.kind)) { throw new Error(`unknown job kind: ${envelope.kind}`) } if (envelope.kind === 'atproto.publish-pubkey') { - const { did, installationId } = envelope.payload as PublishPubkeyPayload + const { did, installationId } = publishPubkeyPayload(envelope.payload) const client = await useOAuthClient() const session = await client.restore(did) await generateAndPublishKey({ oauthSession: session, installationId }) @@ -70,7 +119,7 @@ export async function dispatch(envelope: JobEnvelope): Promise { } if (envelope.kind === 'tangled.create-repo') { - const { installationId, githubRepoId } = envelope.payload as CreateRepoPayload + const { installationId, githubRepoId } = 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 @@ -88,7 +137,7 @@ export async function dispatch(envelope: JobEnvelope): Promise { } if (envelope.kind === 'tangled.backfill-installation') { - const { installationId, page } = envelope.payload as BackfillInstallationPayload + const { installationId, page } = backfillInstallationPayload(envelope.payload) const octokit = await installationOctokit(installationId) const { data } = await octokit.request('GET /installation/repositories', { per_page: BACKFILL_PAGE_SIZE, @@ -112,7 +161,7 @@ export async function dispatch(envelope: JobEnvelope): Promise { } if (envelope.kind === 'github.installation_repositories') { - const { installationId, action, addedRepoIds } = envelope.payload as InstallationRepositoriesPayload + const { installationId, action, addedRepoIds } = installationRepositoriesPayload(envelope.payload) if (action !== 'added') return // Fan out one tangled.create-repo job per added repo. The fan-out keeps diff --git a/server/utils/queue.ts b/server/utils/queue.ts index 69a90e6..1cebd3a 100644 --- a/server/utils/queue.ts +++ b/server/utils/queue.ts @@ -2,7 +2,7 @@ import { sql } from 'drizzle-orm' import { job } from '../db/schema' import { useDb } from './db' -export interface JobEnvelope { +export interface JobEnvelope extends Record { id: number kind: string payload: unknown @@ -42,7 +42,7 @@ export async function claim(workerId: string, leaseMs: number): Promise(sql` UPDATE ${job} SET status = 'running', @@ -65,9 +65,11 @@ export async function claim(workerId: string, leaseMs: number): Promise { const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test') // Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key. - const b64 = publicKeyOpenSsh.split(' ')[1]! + const b64 = publicKeyOpenSsh.split(' ')[1] const blob = Buffer.from(b64, 'base64') // ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key> const algoLen = blob.readUInt32BE(0) diff --git a/test/unit/tangled-pubkey.spec.ts b/test/unit/tangled-pubkey.spec.ts index 301d8b6..482bd56 100644 --- a/test/unit/tangled-pubkey.spec.ts +++ b/test/unit/tangled-pubkey.spec.ts @@ -23,6 +23,13 @@ vi.mock('@atproto/api', () => ({ }, })) +function fakeOauthSession(did: string) { + // The Agent mock above ignores its constructor argument, so we only need + // a `.did` field for the helper itself. + // eslint-disable-next-line ts/no-unsafe-type-assertion + return { did } as unknown as Parameters[0]['oauthSession'] +} + describe('generateAndPublishKey', () => { beforeEach(async () => { process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') @@ -50,12 +57,6 @@ describe('generateAndPublishKey', () => { clearDb() }) - function fakeOauthSession(did: string) { - // The Agent mock above ignores its constructor argument, so we only need - // a `.did` field for the helper itself. - return { did } as never - } - it('generates a key, publishes to PDS, and stores the encrypted private half', async () => { const result = await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:abc'), @@ -64,7 +65,7 @@ describe('generateAndPublishKey', () => { expect(result.created).toBe(true) expect(createRecordMock).toHaveBeenCalledTimes(1) - const call = createRecordMock.mock.calls[0]![0] + const call = createRecordMock.mock.calls[0][0] expect(call.repo).toBe('did:plc:abc') expect(call.collection).toBe('sh.tangled.publicKey') expect(call.record.$type).toBe('sh.tangled.publicKey') @@ -75,7 +76,7 @@ describe('generateAndPublishKey', () => { const rows = await db.select().from(sshKey) .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`) expect(rows).toHaveLength(1) - const row = rows[0]! + const row = rows[0] expect(row.publicKey).toMatch(/^ssh-ed25519 /) expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v') diff --git a/test/unit/tangled-repo.spec.ts b/test/unit/tangled-repo.spec.ts index 8735697..91686f8 100644 --- a/test/unit/tangled-repo.spec.ts +++ b/test/unit/tangled-repo.spec.ts @@ -39,9 +39,30 @@ vi.mock('../../server/utils/github-app', () => ({ clearGitHubAppCache: () => {}, })) -const fakeFetch = vi.fn<(url: string, init: RequestInit) => Promise>() +interface CapturedInit { + method?: string + headers?: Record + body?: string +} +const fakeFetch = vi.fn<(url: string, init: CapturedInit) => Promise>() const ORIGINAL_FETCH = globalThis.fetch +function fakeOauthSession(did: string) { + // eslint-disable-next-line ts/no-unsafe-type-assertion + return { did } as unknown as Parameters[0]['oauthSession'] +} + +function ghRepo(over: Partial = {}): GithubRepoLike { + return { + id: 9001, + full_name: 'alice/my-project', + private: false, + fork: false, + default_branch: 'main', + ...over, + } +} + describe('enrollRepo', () => { beforeEach(async () => { process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') @@ -56,7 +77,8 @@ describe('enrollRepo', () => { getServiceAuthMock.mockReset() putRecordMock.mockReset() fakeFetch.mockReset() - globalThis.fetch = fakeFetch as unknown as typeof fetch + // eslint-disable-next-line ts/no-unsafe-type-assertion + globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } }) putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } }) @@ -70,21 +92,6 @@ describe('enrollRepo', () => { clearDb() }) - function fakeOauthSession(did: string) { - return { did } as never - } - - function ghRepo(over: Partial = {}): GithubRepoLike { - return { - id: 9001, - full_name: 'alice/my-project', - private: false, - fork: false, - default_branch: 'main', - ...over, - } - } - it('enrolls a public, non-fork repo end to end', async () => { githubGet.mockResolvedValue({ data: ghRepo() }) fakeFetch.mockResolvedValue(new Response( @@ -111,8 +118,9 @@ describe('enrollRepo', () => { const url = fetchCall?.[0] const init = fetchCall?.[1] expect(url).toBe('https://knot1.tangled.sh/xrpc/sh.tangled.repo.create') - expect((init!.headers as Record).authorization).toBe('Bearer service-auth-jwt') - const body = JSON.parse(init!.body as string) as Record + expect(init?.headers?.authorization).toBe('Bearer service-auth-jwt') + if (typeof init?.body !== 'string') throw new TypeError('expected string body') + const body: Record = JSON.parse(init.body) expect(body.name).toBe('my-project') expect(body.source).toBe('https://github.com/alice/my-project') expect(body.defaultBranch).toBe('main') @@ -129,9 +137,9 @@ describe('enrollRepo', () => { const rows = await useDb().select().from(repoMapping) .where(sql`${repoMapping.installationId} = 1`) expect(rows).toHaveLength(1) - expect(rows[0]!.tangledRepoDid).toBe('did:plc:repo-xyz') - expect(rows[0]!.knot).toBe('knot1.tangled.sh') - expect(rows[0]!.status).toBe('active') + expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz') + expect(rows[0].knot).toBe('knot1.tangled.sh') + expect(rows[0].status).toBe('active') }) it('skips private repos', async () => { diff --git a/test/utils/db.ts b/test/utils/db.ts index 0b9ee12..1052028 100644 --- a/test/utils/db.ts +++ b/test/utils/db.ts @@ -26,5 +26,6 @@ export async function createTestDb(): Promise { if (trimmed) await pg.exec(trimmed) } + // eslint-disable-next-line ts/no-unsafe-type-assertion return drizzle(pg, { schema }) as unknown as Db } -- 2.51.2