···5151export function explainRepoError(raw: string): string | null {
5252 const err = raw.toLowerCase()
53535454- if (err.includes('sufficient access permissions') || err.includes('accesscontrol')) {
5555- return 'Tangled rejected the mirror, usually because a repo with this name already exists on your account. Rename or remove the existing one, then resync.'
5454+ if (
5555+ err.includes('a repo with this name already exists')
5656+ || err.includes('sufficient access permissions')
5757+ || err.includes('accesscontrol')
5858+ ) {
5959+ return 'A repo with this name already exists on your tangled account. Rename or remove the existing one, then resync.'
5660 }
5761 if (err.includes('invalid path sequence')) {
5862 return 'The repository name can\'t be used as a tangled repo name. This one can\'t be mirrored as-is.'
+99-2
server/utils/tangled-repo.ts
···90909191export interface EnrolResult {
9292 status: 'enrolled' | 'already' | 'skipped'
9393- reason?: 'private' | 'fork' | 'no-identity'
9393+ reason?: 'private' | 'fork' | 'no-identity' | 'name-conflict'
9494+}
9595+9696+/**
9797+ * User-facing message stored in `repo_mapping.lastError` when enrolment is
9898+ * blocked by a name that already exists on the user's tangled account. Kept as
9999+ * a constant so `repo-health` and tests can match it exactly. Issue #4.
100100+ */
101101+export const NAME_CONFLICT_ERROR
102102+ = 'a repo with this name already exists on your tangled account; rename or remove it, then resync'
103103+104104+/**
105105+ * True when the knot's `repo.create` rejection is a name collision: the user
106106+ * already has a `sh.tangled.repo` of that name, so the knot refuses to mint a
107107+ * second. The knot surfaces this as an `AccessControl` 400 ("DID does not have
108108+ * sufficient access permissions").
109109+ */
110110+function isNameConflictResponse(status: number, body: string): boolean {
111111+ if (status !== 400) return false
112112+ const lc = body.toLowerCase()
113113+ return lc.includes('accesscontrol') || lc.includes('sufficient access permissions')
114114+}
115115+116116+/**
117117+ * List the names of the user's existing `sh.tangled.repo` records. Used to
118118+ * detect a collision before calling the knot so we fail fast with a clear
119119+ * status instead of a knot 400 (issue #4).
120120+ */
121121+export async function listExistingRepoNames(agent: Agent, did: string): Promise<Set<string>> {
122122+ const names = new Set<string>()
123123+ let cursor: string | undefined
124124+ do {
125125+ // eslint-disable-next-line no-await-in-loop -- sequential pagination
126126+ const page = await agent.com.atproto.repo.listRecords({
127127+ repo: did,
128128+ collection: REPO_LEXICON,
129129+ limit: LIST_RECORDS_PAGE_SIZE,
130130+ cursor,
131131+ })
132132+ for (const rec of page.data.records) {
133133+ const value = rec.value as Record<string, unknown>
134134+ if (typeof value.name === 'string') names.add(value.name)
135135+ }
136136+ cursor = page.data.cursor
137137+ } while (cursor)
138138+ return names
94139}
9514096141/**
···123168}): Promise<EnrolResult> {
124169 const db = useDb()
125170126126- const existing = await db.select({ id: repoMapping.id, status: repoMapping.status })
171171+ const existing = await db.select({ id: repoMapping.id, status: repoMapping.status, tangledFullName: repoMapping.tangledFullName })
127172 .from(repoMapping)
128173 .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`)
129174 if (existing.length > 0 && !opts.force) {
···150195151196 // 3. Service-auth JWT for the knot procedure.
152197 const agent = new Agent(opts.oauthSession)
198198+199199+ // Fail fast on a name collision (issue #4): if the user already has a
200200+ // `sh.tangled.repo` of this name that isn't the one this mapping owns, the
201201+ // knot would reject `repo.create` with an opaque `AccessControl` 400 and the
202202+ // job would retry to exhaustion. Record a clear error status instead and
203203+ // stop, so the dashboard can explain it and the user can act.
204204+ const ownName = `${opts.oauthSession.did}/${name}`
205205+ const alreadyOurs = existing[0]?.tangledFullName === ownName
206206+ if (!alreadyOurs) {
207207+ const existingNames = await listExistingRepoNames(agent, opts.oauthSession.did)
208208+ if (existingNames.has(name)) {
209209+ await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR)
210210+ return { status: 'skipped', reason: 'name-conflict' }
211211+ }
212212+ }
213213+153214 const aud = `did:web:${knot}`
154215 const exp = Math.floor(Date.now() / 1000) + 60
155216 const { data: { token } } = await agent.com.atproto.server.getServiceAuth({
···177238 })
178239 if (!knotResponse.ok) {
179240 const body = await knotResponse.text()
241241+ // A name collision can still race in between the pre-check and this call
242242+ // (or the pre-check's listRecords lagged the firehose). Treat it as the
243243+ // same terminal, user-actionable condition rather than a retryable throw.
244244+ if (isNameConflictResponse(knotResponse.status, body)) {
245245+ await recordEnrolError(db, opts, repo.full_name, NAME_CONFLICT_ERROR)
246246+ return { status: 'skipped', reason: 'name-conflict' }
247247+ }
180248 throw new Error(`knot ${knot} returned ${knotResponse.status}: ${body}`)
181249 }
182250 const knotJson: { repoDid?: string } = await knotResponse.json()
···234302 }
235303236304 return { status: 'enrolled' }
305305+}
306306+307307+/**
308308+ * Upsert a `repo_mapping` row in `error` state with a user-facing `lastError`.
309309+ * Used when enrolment can't complete but we still want the repo to show on the
310310+ * dashboard with an explanation rather than vanish silently.
311311+ */
312312+async function recordEnrolError(
313313+ db: ReturnType<typeof useDb>,
314314+ opts: { installationId: number, githubRepoId: number },
315315+ githubFullName: string,
316316+ message: string,
317317+): Promise<void> {
318318+ const existing = await db.select({ id: repoMapping.id })
319319+ .from(repoMapping)
320320+ .where(sql`${repoMapping.installationId} = ${opts.installationId} AND ${repoMapping.githubRepoId} = ${opts.githubRepoId}`)
321321+ if (existing.length > 0) {
322322+ await db.update(repoMapping)
323323+ .set({ status: 'error', lastError: message, updatedAt: new Date() })
324324+ .where(sql`${repoMapping.id} = ${existing[0]!.id}`)
325325+ return
326326+ }
327327+ await db.insert(repoMapping).values({
328328+ installationId: opts.installationId,
329329+ githubRepoId: opts.githubRepoId,
330330+ githubFullName,
331331+ status: 'error',
332332+ lastError: message,
333333+ })
237334}
238335239336export interface SyncMetadataResult {
+2
test/unit/repo-health.spec.ts
···62626363describe('explainRepoError', () => {
6464 it('maps the production error strings we actually emit', () => {
6565+ expect(explainRepoError('a repo with this name already exists on your tangled account; rename or remove it, then resync')).toMatch(/already exists/i)
6666+ expect(explainRepoError('knot knot1.tangled.sh returned 400: {"error":"AccessControl"}')).toMatch(/already exists/i)
6567 expect(explainRepoError('receive-pack: unpack error (pack signature mismatch detected)')).toMatch(/transient connection/i)
6668 expect(explainRepoError('receive-pack: no advertisement (stderr: ssh error: Timed out while waiting for handshake)')).toMatch(/transient connection/i)
6769 expect(explainRepoError('knot rejected our ssh key; stopping sync')).toMatch(/ssh key/i)
+59
test/unit/tangled-repo.spec.ts
···88 buildReadOnlyDescription,
99 enrollRepo,
1010 mergeRepoRecord,
1111+ NAME_CONFLICT_ERROR,
1112 stripReadOnlyMarker,
1213 syncRepoMetadata,
1314} from '../../server/utils/tangled-repo'
···96979798 getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } })
9899 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } })
100100+ // Default: the user has no existing tangled repos, so the name-conflict
101101+ // pre-check passes. Individual tests override this to force a collision.
102102+ listRecordsMock.mockResolvedValue({ data: { records: [] } })
99103 })
100104101105 afterEach(() => {
···185189 })
186190 expect(result).toEqual({ status: 'skipped', reason: 'fork' })
187191 expect(fakeFetch).not.toHaveBeenCalled()
192192+ })
193193+194194+ it('skips enrolment and records an error when the name already exists on tangled', async () => {
195195+ githubGet.mockResolvedValue({ data: ghRepo() })
196196+ listRecordsMock.mockResolvedValue({
197197+ data: { records: [{ uri: 'at://did:plc:abc/sh.tangled.repo/existing', cid: 'bafy', value: { name: 'my-project' } }] },
198198+ })
199199+200200+ const result = await enrollRepo({
201201+ oauthSession: fakeOauthSession('did:plc:abc'),
202202+ installationId: 1,
203203+ githubRepoId: 9001,
204204+ })
205205+ expect(result).toEqual({ status: 'skipped', reason: 'name-conflict' })
206206+ // Knot procedure never called; PDS record never written.
207207+ expect(fakeFetch).not.toHaveBeenCalled()
208208+ expect(putRecordMock).not.toHaveBeenCalled()
209209+210210+ const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`)
211211+ expect(rows).toHaveLength(1)
212212+ expect(rows[0]!.status).toBe('error')
213213+ expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR)
214214+ expect(rows[0]!.tangledRepoDid).toBeNull()
215215+ })
216216+217217+ it('classifies a knot AccessControl 400 as a name conflict rather than retrying', async () => {
218218+ githubGet.mockResolvedValue({ data: ghRepo() })
219219+ // Pre-check sees no collision (firehose lag), but the knot rejects.
220220+ fakeFetch.mockResolvedValue(new Response(
221221+ JSON.stringify({ error: 'AccessControl', message: 'DID does not have sufficient access permissions for this operation' }),
222222+ { status: 400 },
223223+ ))
224224+225225+ const result = await enrollRepo({
226226+ oauthSession: fakeOauthSession('did:plc:abc'),
227227+ installationId: 1,
228228+ githubRepoId: 9001,
229229+ })
230230+ expect(result).toEqual({ status: 'skipped', reason: 'name-conflict' })
231231+232232+ const rows = await useDb().select().from(repoMapping).where(sql`${repoMapping.installationId} = 1`)
233233+ expect(rows).toHaveLength(1)
234234+ expect(rows[0]!.status).toBe('error')
235235+ expect(rows[0]!.lastError).toBe(NAME_CONFLICT_ERROR)
236236+ })
237237+238238+ it('still throws on non-conflict knot errors so the queue retries', async () => {
239239+ githubGet.mockResolvedValue({ data: ghRepo() })
240240+ fakeFetch.mockResolvedValue(new Response('upstream boom', { status: 502 }))
241241+242242+ await expect(enrollRepo({
243243+ oauthSession: fakeOauthSession('did:plc:abc'),
244244+ installationId: 1,
245245+ githubRepoId: 9001,
246246+ })).rejects.toThrow(/returned 502/)
188247 })
189248190249 it('no-ops if a mapping already exists', async () => {