chadtmiller.com/hatk
AT Protocol app framework
1
0
A reader refused as SpaceNotFound is refused, not told the space is gone
docs/spaces-appview.md+4−0
| @@ -62,6 +62,10 @@ Verified in the reference PDS and pds.js, not from the lexicon prose alone. | ||
starts with a delegation token from the reader's own PDS. A space whose read | ||
policy is `public` issues one to a request with no token; any other space | ||
answers such a request `SpaceNotFound`, so as not to disclose that it exists. | ||
The reference PDS, since `0.0.0-spaces-alpha-20261029171913`, answers a | ||
reader it refuses the same way, so only `SpaceDeleted` means a space is gone: | ||
hatk reports that refusal as `UserNotAuthorized`, tries the next reader, and | ||
keeps following the space. | ||
The index still serves space rows only to a viewer the authority admitted by | ||
name. | ||
- **Only the authority decides.** Repo hosts check that the credential is | ||
packages/hatk/src/spaces/credential.ts+18−2
| @@ -61,8 +61,14 @@ export function isNotAuthorized(err: unknown): boolean { | ||
) | ||
} | ||
| ||
/** | ||
* A space its authority says is deleted. Only `SpaceDeleted` says so: an | ||
* authority answers `SpaceNotFound` to anyone it refuses, so as not to | ||
* disclose that the space exists, and the mints below report that as the | ||
* refusal it is. | ||
*/ | ||
export function isSpaceGone(err: unknown): boolean { | ||
return err instanceof SpaceCredentialError && (err.code === 'SpaceDeleted' || err.code === 'SpaceNotFound') | ||
return err instanceof SpaceCredentialError && err.code === 'SpaceDeleted' | ||
} | ||
| ||
export interface SpaceCredential { | ||
| @@ -167,7 +173,17 @@ export async function mintSpaceCredential( | ||
throw new SpaceCredentialError(err?.status ?? 502, 'DelegationFailed', err?.message ?? 'delegation failed') | ||
} | ||
| ||
return credentialFor(space, readerDid, token) | ||
try { | ||
return await credentialFor(space, readerDid, token) | ||
} catch (err) { | ||
// The reference PDS, since 0.0.0-spaces-alpha-20261029171913, refuses a | ||
// reader this way rather than with UserNotAuthorized. Another reader may | ||
// yet be let in, so it is a refusal of this one, not news of the space. | ||
if (err instanceof SpaceCredentialError && err.code === 'SpaceNotFound') { | ||
throw new SpaceCredentialError(err.status, 'UserNotAuthorized', err.message) | ||
} | ||
throw err | ||
} | ||
} | ||
| ||
/** | ||
packages/hatk/test/spaces-credential.test.ts+24−1
| @@ -234,11 +234,34 @@ test('candidates are tried in order and the first that works is kept', async () | ||
test('a deleted space stops the walk rather than trying everyone', async () => { | ||
// No other reader would fare better, and walking the whole session table to | ||
// learn that costs a round trip per person signed in. | ||
fetchMock.mockImplementation(async () => Response.json({ error: 'SpaceNotFound' }, { status: 404 })) | ||
fetchMock.mockImplementation(async () => Response.json({ error: 'SpaceDeleted' }, { status: 400 })) | ||
await expect(getSpaceCredential(oauth as any, SPACE, [READER, OTHER_READER])).rejects.toThrow() | ||
expect(fetchMock).toHaveBeenCalledTimes(1) | ||
}) | ||
| ||
test('a reader refused as though the space did not exist is a refusal, not a deletion', async () => { | ||
// The reference PDS answers a reader it refuses SpaceNotFound, so as not to | ||
// disclose that the space exists. | ||
fetchMock.mockImplementation(async () => Response.json({ error: 'SpaceNotFound' }, { status: 400 })) | ||
const err = await mintSpaceCredential(oauth as any, SPACE, READER).catch((e) => e) | ||
expect(err.code).toBe('UserNotAuthorized') | ||
expect(isNotAuthorized(err)).toBe(true) | ||
expect(isSpaceGone(err)).toBe(false) | ||
}) | ||
| ||
test('one reader refused that way, the next is still asked', async () => { | ||
fetchMock.mockImplementation(async (_url: string, init: RequestInit) => | ||
JSON.parse(init.body as string).delegationToken === 'refused-token' | ||
? Response.json({ error: 'SpaceNotFound' }, { status: 400 }) | ||
: Response.json({ credential: credentialJwt(inTenMinutes()) }), | ||
) | ||
pdsXrpc.mockImplementation(async (_config: unknown, viewer: { did: string }) => ({ | ||
token: viewer.did === READER ? 'refused-token' : 'delegation-token', | ||
})) | ||
const credential = await getSpaceCredential(oauth as any, SPACE, [READER, OTHER_READER]) | ||
expect(credential.readerDid).toBe(OTHER_READER) | ||
}) | ||
| ||
test('with no candidate able to read and no public access, the failure says so', async () => { | ||
fetchMock.mockImplementation(async () => Response.json({ error: 'SpaceNotFound' }, { status: 400 })) | ||
const err = await getSpaceCredential(oauth as any, SPACE, []).catch((e) => e) | ||