~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/hatk

AT Protocol app framework

1
0

A reader refused as SpaceNotFound is refused, not told the space is gone

Merged chadtmiller.com asked to merge into main from chadtmiller.com/hatk:refused-reader-not-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)