Skip to content
Draft
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
9 changes: 9 additions & 0 deletions .changeset/handle-picker-flags-reserved-handles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

Reserved handles are now shown as unavailable before account creation.

**Affects:** End users

**End users:** the live handle check no longer labels names reserved by the service, such as `admin`, `support`, or `www`, as available only to reject them after submission. The picker reports them as unavailable immediately so you can choose another name without losing progress.
33 changes: 33 additions & 0 deletions packages/pds-core/src/__tests__/reserved-handle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { isReservedServiceHandle } from '../lib/reserved-handle.js'

const SERVICE_DOMAINS = ['.pds.example.com']

describe('isReservedServiceHandle', () => {
it.each(['admin', 'www', 'support', 'help', 'api', 'bsky'])(
'flags upstream-reserved local part %s',
(local) => {
expect(
isReservedServiceHandle(`${local}.pds.example.com`, SERVICE_DOMAINS),
).toBe(true)
},
)

it('accepts a normal unclaimed local part', () => {
expect(
isReservedServiceHandle('alice.pds.example.com', SERVICE_DOMAINS),
).toBe(false)
})

it('uses upstream normalization before checking the reserved list', () => {
expect(
isReservedServiceHandle('ADMIN.pds.example.com', SERVICE_DOMAINS),
).toBe(true)
})

it('rethrows unrelated upstream validation failures', () => {
expect(() =>
isReservedServiceHandle('ab.pds.example.com', SERVICE_DOMAINS),
).toThrow('Handle too short')
})
})
10 changes: 9 additions & 1 deletion packages/pds-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
validateClientMetadataForPreview,
} from '@certified-app/shared'
import { shouldRewriteSecFetchSite } from './lib/sec-fetch-site-rewrite.js'
import { isReservedServiceHandle } from './lib/reserved-handle.js'
import {
findInsertionIndex,
installCssInjectionMiddleware,
Expand Down Expand Up @@ -1022,7 +1023,14 @@ async function main() {
}
try {
const account = await pds.ctx.accountManager.getAccount(handle)
res.json({ exists: !!account })
const reserved = isReservedServiceHandle(
handle,
pds.ctx.cfg.identity.serviceHandleDomains,
)
// `exists` is the endpoint's historical name for "unavailable".
// Include upstream-reserved handles so the picker cannot promise a
// handle which account creation will reject moments later.
res.json({ exists: !!account || reserved })
} catch (err) {
logger.error({ err, handle }, 'Failed to check handle availability')
res.status(503).json({ error: 'handle_check_failed' })
Expand Down
33 changes: 33 additions & 0 deletions packages/pds-core/src/lib/reserved-handle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
baseNormalizeAndValidate,
ensureHandleServiceConstraints,
} from '@atproto/pds/dist/handle/index.js'

type HandleConstraintError = {
customErrorName?: unknown
}

/**
* Return whether upstream rejects a service handle specifically because its
* local part is reserved. Other validation failures and unexpected errors are
* rethrown so callers do not silently misclassify them as an availability hit.
*/
export function isReservedServiceHandle(
fullHandle: string,
serviceHandleDomains: string[],
): boolean {
try {
const normalized = baseNormalizeAndValidate(fullHandle)
ensureHandleServiceConstraints(normalized, serviceHandleDomains)
return false
} catch (err: unknown) {
if (
typeof err === 'object' &&
err !== null &&
(err as HandleConstraintError).customErrorName === 'HandleNotAvailable'
) {
return true
}
throw err
}
}
Loading