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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2

Private galleries on the October 29 spaces alpha

Merged chadtmiller.com asked to merge into main from grain.social/grain:spaces-alpha-20261029
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
hatk.config.ts+2−0
package-lock.json+4−4
package.json+1−1