diff --git a/.changeset/handle-picker-flags-reserved-handles.md b/.changeset/handle-picker-flags-reserved-handles.md new file mode 100644 index 00000000..a4f15da4 --- /dev/null +++ b/.changeset/handle-picker-flags-reserved-handles.md @@ -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. diff --git a/packages/pds-core/src/__tests__/reserved-handle.test.ts b/packages/pds-core/src/__tests__/reserved-handle.test.ts new file mode 100644 index 00000000..9074ad7c --- /dev/null +++ b/packages/pds-core/src/__tests__/reserved-handle.test.ts @@ -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') + }) +}) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index bdbe8c71..1ac7b645 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -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, @@ -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' }) diff --git a/packages/pds-core/src/lib/reserved-handle.ts b/packages/pds-core/src/lib/reserved-handle.ts new file mode 100644 index 00000000..80414201 --- /dev/null +++ b/packages/pds-core/src/lib/reserved-handle.ts @@ -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 + } +}