···
114
114
const rows = await db.select().from(atprotoState).where(sql`${atprotoState.key} = ${key}`)
115
115
if (rows.length === 0) return undefined
116
116
const row = rows[0]!
117
117
-
return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedState
117
117
+
const parsed: NodeSavedState = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce))
118
118
+
return parsed
118
119
},
119
120
async del(key: string) {
120
121
const db = useDb()
···
142
143
const rows = await db.select().from(atprotoSession).where(sql`${atprotoSession.sub} = ${sub}`)
143
144
if (rows.length === 0) return undefined
144
145
const row = rows[0]!
145
145
-
return JSON.parse(decrypt(row.valueCiphertext, row.valueNonce)) as NodeSavedSession
146
146
+
const parsed: NodeSavedSession = JSON.parse(decrypt(row.valueCiphertext, row.valueNonce))
147
147
+
return parsed
146
148
},
147
149
async del(sub: string) {
148
150
const db = useDb()
···
56
56
page: number
57
57
}
58
58
59
59
+
function asObject(value: unknown): Record<string, unknown> {
60
60
+
if (value === null || typeof value !== 'object') {
61
61
+
throw new TypeError(`expected object payload, got ${typeof value}`)
62
62
+
}
63
63
+
return { ...value }
64
64
+
}
65
65
+
66
66
+
function publishPubkeyPayload(value: unknown): PublishPubkeyPayload {
67
67
+
const o = asObject(value)
68
68
+
if (typeof o.did !== 'string' || typeof o.installationId !== 'number') {
69
69
+
throw new TypeError('invalid atproto.publish-pubkey payload')
70
70
+
}
71
71
+
return { did: o.did, installationId: o.installationId }
72
72
+
}
73
73
+
74
74
+
function createRepoPayload(value: unknown): CreateRepoPayload {
75
75
+
const o = asObject(value)
76
76
+
if (typeof o.installationId !== 'number' || typeof o.githubRepoId !== 'number') {
77
77
+
throw new TypeError('invalid tangled.create-repo payload')
78
78
+
}
79
79
+
return { installationId: o.installationId, githubRepoId: o.githubRepoId }
80
80
+
}
81
81
+
82
82
+
function backfillInstallationPayload(value: unknown): BackfillInstallationPayload {
83
83
+
const o = asObject(value)
84
84
+
if (typeof o.installationId !== 'number' || typeof o.page !== 'number') {
85
85
+
throw new TypeError('invalid tangled.backfill-installation payload')
86
86
+
}
87
87
+
return { installationId: o.installationId, page: o.page }
88
88
+
}
89
89
+
90
90
+
function installationRepositoriesPayload(value: unknown): InstallationRepositoriesPayload {
91
91
+
const o = asObject(value)
92
92
+
if (
93
93
+
typeof o.installationId !== 'number'
94
94
+
|| (o.action !== 'added' && o.action !== 'removed')
95
95
+
|| !Array.isArray(o.addedRepoIds)
96
96
+
|| !Array.isArray(o.removedRepoIds)
97
97
+
) {
98
98
+
throw new TypeError('invalid github.installation_repositories payload')
99
99
+
}
100
100
+
return {
101
101
+
installationId: o.installationId,
102
102
+
action: o.action,
103
103
+
addedRepoIds: o.addedRepoIds.filter((id): id is number => typeof id === 'number'),
104
104
+
removedRepoIds: o.removedRepoIds.filter((id): id is number => typeof id === 'number'),
105
105
+
}
106
106
+
}
107
107
+
59
108
export async function dispatch(envelope: JobEnvelope): Promise<void> {
60
109
if (!KNOWN_KINDS.has(envelope.kind)) {
61
110
throw new Error(`unknown job kind: ${envelope.kind}`)
62
111
}
63
112
64
113
if (envelope.kind === 'atproto.publish-pubkey') {
65
65
-
const { did, installationId } = envelope.payload as PublishPubkeyPayload
114
114
+
const { did, installationId } = publishPubkeyPayload(envelope.payload)
66
115
const client = await useOAuthClient()
67
116
const session = await client.restore(did)
68
117
await generateAndPublishKey({ oauthSession: session, installationId })
···
70
119
}
71
120
72
121
if (envelope.kind === 'tangled.create-repo') {
73
73
-
const { installationId, githubRepoId } = envelope.payload as CreateRepoPayload
122
122
+
const { installationId, githubRepoId } = createRepoPayload(envelope.payload)
74
123
75
124
// Find the user identity bound to this install. If OAuth hasn't completed
76
125
// yet, drop this job silently \u2014 OAuth callback re-enqueues for all
···
88
137
}
89
138
90
139
if (envelope.kind === 'tangled.backfill-installation') {
91
91
-
const { installationId, page } = envelope.payload as BackfillInstallationPayload
140
140
+
const { installationId, page } = backfillInstallationPayload(envelope.payload)
92
141
const octokit = await installationOctokit(installationId)
93
142
const { data } = await octokit.request('GET /installation/repositories', {
94
143
per_page: BACKFILL_PAGE_SIZE,
···
112
161
}
113
162
114
163
if (envelope.kind === 'github.installation_repositories') {
115
115
-
const { installationId, action, addedRepoIds } = envelope.payload as InstallationRepositoriesPayload
164
164
+
const { installationId, action, addedRepoIds } = installationRepositoriesPayload(envelope.payload)
116
165
if (action !== 'added') return
117
166
118
167
// Fan out one tangled.create-repo job per added repo. The fan-out keeps
···
2
2
import { job } from '../db/schema'
3
3
import { useDb } from './db'
4
4
5
5
-
export interface JobEnvelope {
5
5
+
export interface JobEnvelope extends Record<string, unknown> {
6
6
id: number
7
7
kind: string
8
8
payload: unknown
···
42
42
// Two conditions for a job to be claimable:
43
43
// 1. status='queued' AND run_after <= now()
44
44
// 2. status='running' AND locked_until < now() (lease expired)
45
45
-
const result = await db.execute(sql`
45
45
+
const result = await db.execute<JobEnvelope>(sql`
46
46
UPDATE ${job}
47
47
SET
48
48
status = 'running',
···
65
65
66
66
// drizzle's neon-http execute returns rows on `.rows`; pglite's returns directly.
67
67
// Normalise.
68
68
-
const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows) ?? []
69
69
-
if (rows.length === 0) return null
70
70
-
return rows[0] as JobEnvelope
68
68
+
const rows: JobEnvelope[] = Array.isArray(result)
69
69
+
? result
70
70
+
: ((result as { rows?: JobEnvelope[] }).rows ?? [])
71
71
+
const envelope = rows[0]
72
72
+
return envelope ?? null
71
73
}
72
74
73
75
/** Mark a job as completed. */
···
26
26
27
27
return {
28
28
publicKeyOpenSsh: encodeOpenSshEd25519(rawPublic, comment),
29
29
-
privateKeyPem: privateKey as string,
29
29
+
privateKeyPem: privateKey,
30
30
}
31
31
}
32
32
···
98
98
const body = await knotResponse.text()
99
99
throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`)
100
100
}
101
101
-
const { repoDid } = await knotResponse.json() as { repoDid?: string }
101
101
+
const knotJson: { repoDid?: string } = await knotResponse.json()
102
102
+
const { repoDid } = knotJson
102
103
if (!repoDid) {
103
104
throw new Error(`knot ${knot} returned no repoDid`)
104
105
}
···
20
20
const { publicKeyOpenSsh, privateKeyPem } = generateKeypair('test')
21
21
22
22
// Decode the OpenSSH public key back to raw bytes and reconstruct an SPKI key.
23
23
-
const b64 = publicKeyOpenSsh.split(' ')[1]!
23
23
+
const b64 = publicKeyOpenSsh.split(' ')[1]
24
24
const blob = Buffer.from(b64, 'base64')
25
25
// ssh-ed25519 framing: <4 bytes len><"ssh-ed25519"><4 bytes len><32 bytes raw key>
26
26
const algoLen = blob.readUInt32BE(0)
···
23
23
},
24
24
}))
25
25
26
26
+
function fakeOauthSession(did: string) {
27
27
+
// The Agent mock above ignores its constructor argument, so we only need
28
28
+
// a `.did` field for the helper itself.
29
29
+
// eslint-disable-next-line ts/no-unsafe-type-assertion
30
30
+
return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession']
31
31
+
}
32
32
+
26
33
describe('generateAndPublishKey', () => {
27
34
beforeEach(async () => {
28
35
process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
···
50
57
clearDb()
51
58
})
52
59
53
53
-
function fakeOauthSession(did: string) {
54
54
-
// The Agent mock above ignores its constructor argument, so we only need
55
55
-
// a `.did` field for the helper itself.
56
56
-
return { did } as never
57
57
-
}
58
58
-
59
60
it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
60
61
const result = await generateAndPublishKey({
61
62
oauthSession: fakeOauthSession('did:plc:abc'),
···
64
65
65
66
expect(result.created).toBe(true)
66
67
expect(createRecordMock).toHaveBeenCalledTimes(1)
67
67
-
const call = createRecordMock.mock.calls[0]![0]
68
68
+
const call = createRecordMock.mock.calls[0][0]
68
69
expect(call.repo).toBe('did:plc:abc')
69
70
expect(call.collection).toBe('sh.tangled.publicKey')
70
71
expect(call.record.$type).toBe('sh.tangled.publicKey')
···
75
76
const rows = await db.select().from(sshKey)
76
77
.where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
77
78
expect(rows).toHaveLength(1)
78
78
-
const row = rows[0]!
79
79
+
const row = rows[0]
79
80
expect(row.publicKey).toMatch(/^ssh-ed25519 /)
80
81
expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
81
82
···
39
39
clearGitHubAppCache: () => {},
40
40
}))
41
41
42
42
-
const fakeFetch = vi.fn<(url: string, init: RequestInit) => Promise<Response>>()
42
42
+
interface CapturedInit {
43
43
+
method?: string
44
44
+
headers?: Record<string, string>
45
45
+
body?: string
46
46
+
}
47
47
+
const fakeFetch = vi.fn<(url: string, init: CapturedInit) => Promise<Response>>()
43
48
const ORIGINAL_FETCH = globalThis.fetch
44
49
50
50
+
function fakeOauthSession(did: string) {
51
51
+
// eslint-disable-next-line ts/no-unsafe-type-assertion
52
52
+
return { did } as unknown as Parameters<typeof enrollRepo>[0]['oauthSession']
53
53
+
}
54
54
+
55
55
+
function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike {
56
56
+
return {
57
57
+
id: 9001,
58
58
+
full_name: 'alice/my-project',
59
59
+
private: false,
60
60
+
fork: false,
61
61
+
default_branch: 'main',
62
62
+
...over,
63
63
+
}
64
64
+
}
65
65
+
45
66
describe('enrollRepo', () => {
46
67
beforeEach(async () => {
47
68
process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
···
56
77
getServiceAuthMock.mockReset()
57
78
putRecordMock.mockReset()
58
79
fakeFetch.mockReset()
59
59
-
globalThis.fetch = fakeFetch as unknown as typeof fetch
80
80
+
// eslint-disable-next-line ts/no-unsafe-type-assertion
81
81
+
globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch
60
82
61
83
getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } })
62
84
putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } })
···
70
92
clearDb()
71
93
})
72
94
73
73
-
function fakeOauthSession(did: string) {
74
74
-
return { did } as never
75
75
-
}
76
76
-
77
77
-
function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike {
78
78
-
return {
79
79
-
id: 9001,
80
80
-
full_name: 'alice/my-project',
81
81
-
private: false,
82
82
-
fork: false,
83
83
-
default_branch: 'main',
84
84
-
...over,
85
85
-
}
86
86
-
}
87
87
-
88
95
it('enrolls a public, non-fork repo end to end', async () => {
89
96
githubGet.mockResolvedValue({ data: ghRepo() })
90
97
fakeFetch.mockResolvedValue(new Response(
···
111
118
const url = fetchCall?.[0]
112
119
const init = fetchCall?.[1]
113
120
expect(url).toBe('https://knot1.tangled.sh/xrpc/sh.tangled.repo.create')
114
114
-
expect((init!.headers as Record<string, string>).authorization).toBe('Bearer service-auth-jwt')
115
115
-
const body = JSON.parse(init!.body as string) as Record<string, unknown>
121
121
+
expect(init?.headers?.authorization).toBe('Bearer service-auth-jwt')
122
122
+
if (typeof init?.body !== 'string') throw new TypeError('expected string body')
123
123
+
const body: Record<string, unknown> = JSON.parse(init.body)
116
124
expect(body.name).toBe('my-project')
117
125
expect(body.source).toBe('https://github.com/alice/my-project')
118
126
expect(body.defaultBranch).toBe('main')
···
129
137
const rows = await useDb().select().from(repoMapping)
130
138
.where(sql`${repoMapping.installationId} = 1`)
131
139
expect(rows).toHaveLength(1)
132
132
-
expect(rows[0]!.tangledRepoDid).toBe('did:plc:repo-xyz')
133
133
-
expect(rows[0]!.knot).toBe('knot1.tangled.sh')
134
134
-
expect(rows[0]!.status).toBe('active')
140
140
+
expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz')
141
141
+
expect(rows[0].knot).toBe('knot1.tangled.sh')
142
142
+
expect(rows[0].status).toBe('active')
135
143
})
136
144
137
145
it('skips private repos', async () => {
···
26
26
if (trimmed) await pg.exec(trimmed)
27
27
}
28
28
29
29
+
// eslint-disable-next-line ts/no-unsafe-type-assertion
29
30
return drizzle(pg, { schema }) as unknown as Db
30
31
}