0
2
Private galleries on the October 29 spaces alpha
server/helpers/spaceSupport.ts+13−4
| @@ -30,14 +30,23 @@ const PROBE_TIMEOUT_MS = 5000; | ||
* The space methods grain needs, in the order a shared gallery uses them. | ||
* Each entry is a set of accepted spellings: the proposal renamed things after | ||
* implementations shipped, so a server may answer to either name and one is | ||
* enough. See pds.js docs/permissioned-data.md, "Two spellings". | ||
* enough. See pds.js docs/permissioned-data.md, "Two spellings". The first | ||
* names the method when it is missing. | ||
* | ||
* The reference alpha since 0.0.0-spaces-alpha-20261029171913 has no | ||
* simplespace: it creates a space under com.atproto.space and keeps a member | ||
* list under com.atproto.memberspace (spaces/client.ts asks for both). | ||
*/ | ||
const REQUIRED_METHODS: string[][] = [ | ||
["com.atproto.simplespace.createSpace"], | ||
["com.atproto.simplespace.createSpace", "com.atproto.space.createSpace"], | ||
// The alpha spells this `putMember` and pairs it with `removeMember`; pds.js | ||
// says `addMember`. Same operation, and either is enough. | ||
["com.atproto.simplespace.addMember", "com.atproto.simplespace.putMember"], | ||
["com.atproto.simplespace.listMembers"], | ||
[ | ||
"com.atproto.simplespace.addMember", | ||
"com.atproto.simplespace.putMember", | ||
"com.atproto.memberspace.putMember", | ||
], | ||
["com.atproto.simplespace.listMembers", "com.atproto.memberspace.listMembers"], | ||
["com.atproto.simplespace.getSpace", "com.atproto.space.getSpace"], | ||
["com.atproto.space.createRecord"], | ||
["com.atproto.space.listRecords"], | ||
server/spaces/client.ts+85−0
| @@ -82,6 +82,91 @@ export function parseSpaceUri(uri: string): { authority: string; skey: string } | ||
return { authority: match[1], skey: match[3] }; | ||
} | ||
| ||
// --- A gallery's space and its readers, on the author's own PDS ---------------- | ||
// | ||
// Three servers serve these, under two sets of names. The reference | ||
// `@atproto/pds` alpha, since 0.0.0-spaces-alpha-20261029171913, creates a | ||
// space with com.atproto.space.createSpace and keeps its member list under | ||
// com.atproto.memberspace. pds.js and zds still answer to | ||
// com.atproto.simplespace, with the proposal's shape (see AGENTS.md, "The second | ||
// implementation"). Each call asks the reference name first and the older one | ||
// when the server says it has no such method. | ||
| ||
type PdsAttempt = { nsid: string } & NonNullable<Parameters<PdsCall>[1]>; | ||
| ||
/** A server's way of saying it serves no such method: pds.js's 501, zds's 404. */ | ||
function notServed(err: unknown): boolean { | ||
const status = (err as { status?: unknown })?.status; | ||
return status === 404 || status === 501; | ||
} | ||
| ||
/** The first of `attempts` the PDS serves. Any other failure is the answer. */ | ||
async function firstServed(pds: PdsCall, attempts: PdsAttempt[]): Promise<Record<string, unknown>> { | ||
for (const [i, { nsid, ...options }] of attempts.entries()) { | ||
try { | ||
return await pds(nsid, options); | ||
} catch (err) { | ||
if (i === attempts.length - 1 || !notServed(err)) throw err; | ||
} | ||
} | ||
throw new Error("no attempts"); | ||
} | ||
| ||
/** Make the space a private gallery is, anchored on its author, readable by its member list. */ | ||
export function createGallerySpace(pds: PdsCall, author: string, skey: string) { | ||
return firstServed(pds, [ | ||
{ | ||
nsid: "com.atproto.space.createSpace", | ||
method: "POST", | ||
body: { | ||
spaceType: GALLERY_SPACE_TYPE, | ||
skey, | ||
policy: { $type: "com.atproto.memberspace.policy" }, | ||
}, | ||
}, | ||
{ | ||
// `did` names the authority: pds.js infers it from the session, zds | ||
// requires it. The config is nested, with the policy as a bare string, | ||
// the one spelling both read. | ||
nsid: "com.atproto.simplespace.createSpace", | ||
method: "POST", | ||
body: { | ||
did: author, | ||
type: GALLERY_SPACE_TYPE, | ||
skey, | ||
config: { | ||
policy: "member-list", | ||
appAccess: { $type: "com.atproto.simplespace.defs#open" }, | ||
}, | ||
}, | ||
}, | ||
]); | ||
} | ||
| ||
/** Let `did` read a gallery. Only its author writes, so a member never does. */ | ||
export function addGalleryReader(pds: PdsCall, space: string, did: string) { | ||
return firstServed(pds, [ | ||
{ | ||
nsid: "com.atproto.memberspace.putMember", | ||
method: "POST", | ||
body: { space, did, read: true, write: false }, | ||
}, | ||
{ nsid: "com.atproto.simplespace.addMember", method: "POST", body: { space, did } }, | ||
]); | ||
} | ||
| ||
/** A gallery's member list, as its authority keeps it. */ | ||
export function listGalleryMembers(pds: PdsCall, space: string, limit: number) { | ||
return firstServed( | ||
pds, | ||
["com.atproto.memberspace.listMembers", "com.atproto.simplespace.listMembers"].map((nsid) => ({ | ||
nsid, | ||
method: "GET" as const, | ||
params: { space, limit }, | ||
})), | ||
); | ||
} | ||
| ||
// --- Identity --------------------------------------------------------------- | ||
| ||
const pdsCache = new Map<string, { endpoint: string; expires: number }>(); | ||
server/spaces/errors.ts+8−2
| @@ -39,8 +39,14 @@ export async function throwSpaceError(err: unknown, db: Db, viewerDid: string): | ||
} | ||
| ||
// The space's authority will not issue this reader a credential. hatk names | ||
// the refusal, which the authority answers with a 400. | ||
if (isNotAuthorized(err)) { | ||
// the refusal, which the authority answers with a 400. The reference alpha, | ||
// since 0.0.0-spaces-alpha-20261029171913, answers a refused reader as though | ||
// the space did not exist, so as not to disclose that it does: to the reader | ||
// the two are the same. | ||
if ( | ||
isNotAuthorized(err) || | ||
(err instanceof SpaceCredentialError && err.code === "SpaceNotFound") | ||
) { | ||
throw new InvalidRequestError("Only members can see this", "NotAuthorized"); | ||
} | ||
| ||
server/xrpc/addSpaceMember.ts+2−2
| @@ -7,7 +7,7 @@ | ||
| ||
import { defineProcedure, InvalidRequestError } from "$hatk"; | ||
import { resolveActor } from "../helpers/resolveActor.ts"; | ||
import { parseSpaceUri } from "../spaces/client.ts"; | ||
import { addGalleryReader, parseSpaceUri } from "../spaces/client.ts"; | ||
import { throwSpaceError } from "../spaces/errors.ts"; | ||
| ||
export default defineProcedure("social.grain.unspecced.addSpaceMember", async (ctx) => { | ||
| @@ -37,7 +37,7 @@ export default defineProcedure("social.grain.unspecced.addSpaceMember", async (c | ||
} | ||
| ||
try { | ||
await pds("com.atproto.simplespace.addMember", { method: "POST", body: { space, did } }); | ||
await addGalleryReader(pds, space, did); | ||
} catch (err) { | ||
return throwSpaceError(err, db, viewer.did); | ||
} | ||
server/xrpc/createPrivateGallery.ts+4−21
| @@ -14,7 +14,7 @@ | ||
import { defineProcedure, InvalidRequestError } from "$hatk"; | ||
import { resolveActor } from "../helpers/resolveActor.ts"; | ||
import { getSpaceSupport, pdsEndpointFor } from "../helpers/spaceSupport.ts"; | ||
import { GALLERY_SPACE_TYPE, spaceUri } from "../spaces/client.ts"; | ||
import { addGalleryReader, createGallerySpace, spaceUri } from "../spaces/client.ts"; | ||
| ||
interface PhotoInput { | ||
photo: unknown; | ||
| @@ -53,25 +53,8 @@ export default defineProcedure("social.grain.unspecced.createPrivateGallery", as | ||
const space = spaceUri(viewer.did, rkey); | ||
const createdAt = new Date().toISOString(); | ||
| ||
// `did` names the authority. pds.js infers it from the session and treats the | ||
// field as optional; zds requires it. Sending it satisfies both, and it can | ||
// only ever be the caller — nobody anchors a space on somebody else's account. | ||
// | ||
// The config goes in `config` rather than beside it, with the policy as the | ||
// bare string the proposal's `#spaceConfig` uses. That is the one spelling | ||
// both servers read: pds.js accepts either shape, zds only this one. | ||
await pds("com.atproto.simplespace.createSpace", { | ||
method: "POST", | ||
body: { | ||
did: viewer.did, | ||
type: GALLERY_SPACE_TYPE, | ||
skey: rkey, | ||
config: { | ||
policy: "member-list", | ||
appAccess: { $type: "com.atproto.simplespace.defs#open" }, | ||
}, | ||
}, | ||
}); | ||
// Anchored on the caller: nobody makes a space on somebody else's account. | ||
await createGallerySpace(pds, viewer.did, rkey); | ||
| ||
// Sequential rather than concurrent: the authority applies these to one | ||
// member list, and a handful of readers is not worth racing. | ||
| @@ -83,7 +66,7 @@ export default defineProcedure("social.grain.unspecced.createPrivateGallery", as | ||
// authority issues. | ||
for (const did of members) { | ||
if (did === viewer.did) continue; // the authority is authorized without being listed | ||
await pds("com.atproto.simplespace.addMember", { method: "POST", body: { space, did } }); | ||
await addGalleryReader(pds, space, did); | ||
await db.run( | ||
`INSERT INTO _space_invites (space, member_did, author_did, created_at) | ||
VALUES ($1, $2, $3, $4) | ||
server/xrpc/getSpaceMembers.ts+5−8
| @@ -7,7 +7,7 @@ | ||
// this answers what the space actually holds. | ||
| ||
import { defineQuery, InvalidRequestError } from "$hatk"; | ||
import { parseSpaceUri } from "../spaces/client.ts"; | ||
import { listGalleryMembers, parseSpaceUri } from "../spaces/client.ts"; | ||
import { throwSpaceError } from "../spaces/errors.ts"; | ||
| ||
interface MemberRow { | ||
| @@ -30,13 +30,10 @@ export default defineQuery("social.grain.unspecced.getSpaceMembers", async (ctx) | ||
| ||
let dids: string[]; | ||
try { | ||
const body = await pds("com.atproto.simplespace.listMembers", { | ||
method: "GET", | ||
params: { space: params.space, limit: 100 }, | ||
}); | ||
// createSpace puts the owner on their own member list, since membership is | ||
// what a member-list policy consults. True, and not what "shared with" | ||
// means to the person reading it. | ||
const body = await listGalleryMembers(pds, params.space, 100); | ||
// pds.js's createSpace puts the owner on their own member list, since | ||
// membership is what a member-list policy consults. True, and not what | ||
// "shared with" means to the person reading it. | ||
dids = ((body.members ?? []) as MemberRow[]) | ||
.map((m) => m.did) | ||
.filter((did) => did !== authority); | ||
test/gallerySpace.test.tsnew+87−0
| @@ -0,0 +1,87 @@ | ||
// A gallery's space on the author's PDS, asked for under the reference alpha's | ||
// names first and pds.js's and zds's simplespace names when those aren't served. | ||
import { describe, expect, test, vi } from "vitest"; | ||
import { | ||
addGalleryReader, | ||
createGallerySpace, | ||
listGalleryMembers, | ||
} from "../server/spaces/client.ts"; | ||
| ||
const AUTHOR = "did:plc:author"; | ||
const READER = "did:plc:reader"; | ||
const SPACE = `at://${AUTHOR}/space/social.grain.privateGallery/g1`; | ||
| ||
/** A PDS that serves only `served`, answering anything else as `missing` does. */ | ||
function pdsServing(served: string[], missing = 501) { | ||
return vi.fn(async (nsid: string) => { | ||
if (served.includes(nsid)) return { members: [{ did: READER }] }; | ||
throw Object.assign(new Error("MethodNotImplemented"), { status: missing }); | ||
}); | ||
} | ||
| ||
describe("a gallery's space", () => { | ||
test("is made with the reference alpha's createSpace and a memberspace policy", async () => { | ||
const pds = pdsServing(["com.atproto.space.createSpace"]); | ||
await createGallerySpace(pds, AUTHOR, "g1"); | ||
expect(pds).toHaveBeenCalledTimes(1); | ||
expect(pds).toHaveBeenCalledWith("com.atproto.space.createSpace", { | ||
method: "POST", | ||
body: { | ||
spaceType: "social.grain.privateGallery", | ||
skey: "g1", | ||
policy: { $type: "com.atproto.memberspace.policy" }, | ||
}, | ||
}); | ||
}); | ||
| ||
test("falls back to simplespace, in the shape pds.js and zds read", async () => { | ||
for (const missing of [404, 501]) { | ||
const pds = pdsServing(["com.atproto.simplespace.createSpace"], missing); | ||
await createGallerySpace(pds, AUTHOR, "g1"); | ||
expect(pds).toHaveBeenLastCalledWith("com.atproto.simplespace.createSpace", { | ||
method: "POST", | ||
body: expect.objectContaining({ | ||
did: AUTHOR, | ||
type: "social.grain.privateGallery", | ||
config: expect.objectContaining({ policy: "member-list" }), | ||
}), | ||
}); | ||
} | ||
}); | ||
| ||
test("doesn't fall back past a refusal", async () => { | ||
const pds = vi.fn(async () => { | ||
throw Object.assign(new Error("SpaceAlreadyExists"), { status: 400 }); | ||
}); | ||
await expect(createGallerySpace(pds, AUTHOR, "g1")).rejects.toThrow("SpaceAlreadyExists"); | ||
expect(pds).toHaveBeenCalledTimes(1); | ||
}); | ||
| ||
test("lets a reader read and not write", async () => { | ||
const pds = pdsServing(["com.atproto.memberspace.putMember"]); | ||
await addGalleryReader(pds, SPACE, READER); | ||
expect(pds).toHaveBeenCalledWith("com.atproto.memberspace.putMember", { | ||
method: "POST", | ||
body: { space: SPACE, did: READER, read: true, write: false }, | ||
}); | ||
}); | ||
| ||
test("adds a reader with pds.js's addMember where that's what's served", async () => { | ||
const pds = pdsServing(["com.atproto.simplespace.addMember"]); | ||
await addGalleryReader(pds, SPACE, READER); | ||
expect(pds).toHaveBeenLastCalledWith("com.atproto.simplespace.addMember", { | ||
method: "POST", | ||
body: { space: SPACE, did: READER }, | ||
}); | ||
}); | ||
| ||
test("lists members under either name", async () => { | ||
for (const name of [ | ||
"com.atproto.memberspace.listMembers", | ||
"com.atproto.simplespace.listMembers", | ||
]) { | ||
const body = await listGalleryMembers(pdsServing([name]), SPACE, 100); | ||
expect(body.members).toEqual([{ did: READER }]); | ||
} | ||
}); | ||
}); | ||
test/spaces.live.test.ts+6−30
| @@ -13,6 +13,8 @@ import { readFileSync } from "node:fs"; | ||
import { generateSpaceSigKey, spaceSigHeaders } from "@hatk/hatk/spaces"; | ||
import { beforeAll, describe, expect, test } from "vitest"; | ||
import { | ||
addGalleryReader, | ||
createGallerySpace, | ||
listSpaceRecords, | ||
listSpaceRepos, | ||
type PdsCall, | ||
| @@ -125,41 +127,15 @@ describe.skipIf(!live)("permissioned spaces, live", () => { | ||
const skey = `test${Date.now().toString(36)}`; | ||
space = spaceUri(author.did, skey); | ||
| ||
// The same body createPrivateGallery sends — the one spelling every server | ||
// that implements the proposal reads. | ||
await authorPds.pds("com.atproto.simplespace.createSpace", { | ||
method: "POST", | ||
body: { | ||
did: author.did, | ||
type: "social.grain.privateGallery", | ||
skey, | ||
config: { | ||
policy: "member-list", | ||
appAccess: { $type: "com.atproto.simplespace.defs#open" }, | ||
}, | ||
}, | ||
}); | ||
await authorPds.pds("com.atproto.simplespace.addMember", { | ||
method: "POST", | ||
body: { space, did: reader.did }, | ||
}); | ||
// The same calls createPrivateGallery makes. | ||
await createGallerySpace(authorPds.pds, author.did, skey); | ||
await addGalleryReader(authorPds.pds, space, reader.did); | ||
| ||
// A second gallery the reader is never added to, so the refusal below is | ||
// about membership rather than about a space that does not exist. | ||
const closedSkey = `closed${Date.now().toString(36)}`; | ||
closedSpace = spaceUri(author.did, closedSkey); | ||
await authorPds.pds("com.atproto.simplespace.createSpace", { | ||
method: "POST", | ||
body: { | ||
did: author.did, | ||
type: "social.grain.privateGallery", | ||
skey: closedSkey, | ||
config: { | ||
policy: "member-list", | ||
appAccess: { $type: "com.atproto.simplespace.defs#open" }, | ||
}, | ||
}, | ||
}); | ||
await createGallerySpace(authorPds.pds, author.did, closedSkey); | ||
await authorPds.pds("com.atproto.space.createRecord", { | ||
method: "POST", | ||
body: { | ||
test/spaceSupport.test.ts+12−0
| @@ -69,6 +69,18 @@ describe("probeSpaceSupport", () => { | ||
expect(result.pds).toBe(PDS); | ||
}); | ||
| ||
test("accepts the reference alpha's names, which have no simplespace", async () => { | ||
const alpha = SPACE_METHODS.filter((m) => !m.startsWith("com.atproto.simplespace.")).concat( | ||
"com.atproto.space.createSpace", | ||
"com.atproto.space.getSpace", | ||
"com.atproto.memberspace.putMember", | ||
"com.atproto.memberspace.listMembers", | ||
); | ||
stubFetch(describeResponse(alpha)); | ||
| ||
expect(await probeSpaceSupport(PDS)).toMatchObject({ supported: true, missing: [] }); | ||
}); | ||
| ||
test("accepts either spelling of getSpace", async () => { | ||
// The proposal renamed things after implementations shipped, so a server | ||
// may answer to com.atproto.space.getSpace instead. | ||
AGENTS.md+9−0
| @@ -118,6 +118,15 @@ does too, and it is stricter, so it is the one that finds our assumptions: | ||
`spaceScopes` in `hatk.config.ts`. | ||
- `com.atproto.simplespace.createSpace` requires `did`, and reads `policy` and | ||
`appAccess` from a nested `config` with the policy as a bare string. | ||
| ||
The reference `@atproto/pds` alpha has no `simplespace` since | ||
`0.0.0-spaces-alpha-20261029171913`: a space is created with | ||
`com.atproto.space.createSpace` (`spaceType`, `skey`, and a `policy` such as | ||
`{$type: "com.atproto.memberspace.policy"}`), and its member list kept with | ||
`com.atproto.memberspace.putMember`, `removeMember` and `listMembers`, where | ||
`read` and `write` are required. `server/spaces/client.ts` asks for those names | ||
first and falls back to `simplespace` on a 404 or 501. | ||
| ||
- Space reads name the repo `repo`, never `did`. pds.js hosts one account and | ||
falls back to it, which hides the difference. | ||
- `com.atproto.space.listRepos` is credential-only. A session does not work | ||