From a79405e0048554b6bacd672ac8f779b9f6dfee6f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 29 Jul 2026 01:13:34 +0100 Subject: [PATCH] fix(pds-core): reject reserved handles during live checks Use upstream handle normalization and service constraints instead of duplicating the reserved list. Preserve unrelated validation failures and report reserved names through the existing unavailable response. --- .../handle-picker-flags-reserved-handles.md | 9 +++++ .../src/__tests__/reserved-handle.test.ts | 33 +++++++++++++++++++ packages/pds-core/src/index.ts | 10 +++++- packages/pds-core/src/lib/reserved-handle.ts | 33 +++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 .changeset/handle-picker-flags-reserved-handles.md create mode 100644 packages/pds-core/src/__tests__/reserved-handle.test.ts create mode 100644 packages/pds-core/src/lib/reserved-handle.ts 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 + } +}