diff --git a/.changeset/eighty-donuts-jam.md b/.changeset/eighty-donuts-jam.md new file mode 100644 index 0000000..afbca86 --- /dev/null +++ b/.changeset/eighty-donuts-jam.md @@ -0,0 +1,17 @@ +--- +'@pdsjs/core': patch +'@pdsjs/node': patch +'@pdsjs/cloudflare': patch +'@pdsjs/storage-sqlite': patch +'@pdsjs/readonly': patch +'@pdsjs/git': patch +'@pdsjs/spaces': patch +--- + +Private repositories in the account page, and a blob privacy fix. The +repositories page lists space-held repos with a Private badge, browses their +files, and manages the space's members; a New repository dialog creates the +space behind a private repo and shows the push commands. The public +`com.atproto.sync.getBlob` and `sync.listBlobs` endpoints no longer serve or +list a blob that only space records reference; `com.atproto.space.getBlob` +remains the authenticated path to those. diff --git a/docs/permissioned-data.md b/docs/permissioned-data.md index 88f0602..0590ec6 100644 --- a/docs/permissioned-data.md +++ b/docs/permissioned-data.md @@ -91,6 +91,13 @@ was never written cannot cost a blob either. holder to that space, not to the account's whole blob store. The reference implementation is looser here. +The public endpoints hold the same line from the other side: +`com.atproto.sync.getBlob` refuses a blob that only space records reference, +and `sync.listBlobs` leaves it out. One window stays open by design: a blob +between its upload and the write that references it has no links yet, and the +public write flow needs such blobs served. The gap closes at the first +reference. + ## Enabling it Set `PDS_ENABLE_SPACES=true`. On Cloudflare that is a `[vars]` entry in diff --git a/packages/account-ui/src/app.jsx b/packages/account-ui/src/app.jsx index 1ca4678..109584d 100644 --- a/packages/account-ui/src/app.jsx +++ b/packages/account-ui/src/app.jsx @@ -251,16 +251,21 @@ export function App() { body = ; } else if (path === GIT_PATH) { page = PAGES[GIT_PATH]; - body = ; + body = ; } else if (gitRepoName) { + const repoSpace = params.get('space') || undefined; page = { title: gitRepoName, - sub: 'A git repository stored in your account.', + sub: repoSpace + ? 'A private git repository shared through a space.' + : 'A git repository stored in your account.', back: { href: GIT_PATH, label: 'Repositories' }, }; body = ( ['blobs', filters], spaces: ['spaces'], + spaceMembers: (/** @type {string} */ space) => ['space-members', space], sites: ['sites'], siteFiles: (/** @type {string} */ name) => ['site-files', name], gitRepos: ['git-repos'], diff --git a/packages/account-ui/src/pages/git-new-repo.jsx b/packages/account-ui/src/pages/git-new-repo.jsx new file mode 100644 index 0000000..1e18d8c --- /dev/null +++ b/packages/account-ui/src/pages/git-new-repo.jsx @@ -0,0 +1,231 @@ +import { useQuery } from '@tanstack/react-query'; +import { CheckIcon, CopyIcon, PlusIcon } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '#/components/atoms/button.jsx'; +import { Notice } from '#/components/atoms/notice.jsx'; +import { Dialog } from '#/components/molecules/dialog.jsx'; +import { SelectField } from '#/components/molecules/select-field.jsx'; +import { TextField } from '#/components/molecules/text-field.jsx'; +import { get, post } from '#/lib/api.js'; +import { queryKeys, useApiMutation } from '#/lib/query.js'; + +const NEW_SPACE = '__new__'; + +/** The account's handle reversed into an NSID authority, as a starting point. */ +function defaultSpaceType(handle) { + const segments = (handle || '').split('.').filter(Boolean); + if (segments.length < 2) return ''; + return `${[...segments].reverse().join('.')}.space`; +} + +/** The shell commands GitHub shows after creating a repository, our spelling. */ +function pushInstructions(cloneUrl) { + return [`git remote add origin ${cloneUrl}`, 'git push -u origin main'].join( + '\n', + ); +} + +function CommandBlock({ text }) { + const [copied, setCopied] = useState(false); + const copy = async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + return ( +
+
+        {text}
+      
+ +
+ ); +} + +/** + * The dialog behind the New repository button. Repositories are created by + * the first push, so "creating" a public one only shows the commands, and + * creating a private one first makes sure its space exists. + * @param {{data: {handle?: string|null, did: string, features: Record}}} props + */ +export function NewRepoDialog({ data }) { + const authority = data.handle || data.did; + const canPrivate = Boolean(data.features.spaces && data.features.spaceAdmin); + + const [name, setName] = useState(''); + const [visibility, setVisibility] = useState('public'); + const [spaceChoice, setSpaceChoice] = useState(NEW_SPACE); + const [spaceType, setSpaceType] = useState(defaultSpaceType(data.handle)); + const [spaceKey, setSpaceKey] = useState(''); + /** @type {[{cloneUrl: string}|null, Function]} */ + const [created, setCreated] = useState(null); + const action = useApiMutation([queryKeys.spaces, queryKeys.gitRepos]); + + const { data: spacesData } = useQuery({ + queryKey: queryKeys.spaces, + queryFn: () => get('/spaces'), + enabled: canPrivate, + }); + const ownSpaces = (spacesData?.spaces ?? []).filter( + (space) => space.isOwner && !space.deleted, + ); + + const validName = /^[A-Za-z0-9._~-]{1,512}$/.test(name); + + const submit = async (event) => { + event.preventDefault(); + if (!validName) return; + if (visibility === 'public') { + setCreated({ cloneUrl: `atproto://${authority}/${name}` }); + return; + } + await action.run(async () => { + let uri; + if (spaceChoice === NEW_SPACE) { + ({ uri } = await post('/spaces/create', { + type: spaceType, + skey: spaceKey || name, + })); + } else { + uri = spaceChoice; + } + // at://did/space// + const segments = uri.split('/'); + setCreated({ + cloneUrl: `atproto://${authority}/space/${segments[4]}/${segments[5]}/${name}`, + }); + }); + }; + + return ( + + New repository + + } + title={created ? name : 'New repository'} + > + {created ? ( +
+ {visibility === 'private' && ( + + This repository is private. Space members you add can read it; + nobody else can. + + )} +
+ The repository appears here after the first push. From an existing + local repository: +
+ +
+ Starting from nothing: +
+ +
+ Pushing needs the helper: npm install -g @pdsjs/git, then + git-remote-atproto login {authority}. +
+
+ ) : ( +
+ setName(event.target.value)} + required + pattern="[A-Za-z0-9._~\-]{1,512}" + hint="Letters, digits, and . _ ~ -" + /> + setVisibility(value)} + options={ + canPrivate + ? [ + ['public', 'Public'], + ['private', 'Private'], + ] + : [['public', 'Public']] + } + /> + {!canPrivate && ( +
+ Private repositories need spaces enabled on this server. +
+ )} + {visibility === 'private' && ( + <> + setSpaceChoice(value)} + options={[ + [NEW_SPACE, 'New space'], + ...ownSpaces.map((space) => [ + space.uri, + `${space.spaceType} (${space.members} ${space.members === 1 ? 'member' : 'members'})`, + ]), + ]} + /> + {spaceChoice === NEW_SPACE && ( +
+ setSpaceType(event.target.value)} + required + hint="An NSID naming what the space is for. The suggestion is your handle reversed." + /> + setSpaceKey(event.target.value)} + hint="Defaults to the repository name: one space per repository, collaborators per space." + /> +
+ )} +
+ A space is the sharing unit. Its members can read every + repository in it; you add members from the repository page. +
+ + )} + {action.error && {action.error}} + + + )} +
+ ); +} diff --git a/packages/account-ui/src/pages/git-repo-page.jsx b/packages/account-ui/src/pages/git-repo-page.jsx index b762e42..a7837e4 100644 --- a/packages/account-ui/src/pages/git-repo-page.jsx +++ b/packages/account-ui/src/pages/git-repo-page.jsx @@ -5,14 +5,17 @@ import { CopyIcon, FileIcon, FolderIcon, + LockIcon, + XIcon, } from 'lucide-react'; import { useState } from 'react'; +import { Button } from '#/components/atoms/button.jsx'; import { Link } from '#/components/atoms/link.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { ListRow } from '#/components/molecules/list-row.jsx'; -import { get } from '#/lib/api.js'; +import { get, post } from '#/lib/api.js'; import { bytes } from '#/lib/format.js'; -import { queryKeys } from '#/lib/query.js'; +import { queryKeys, useApiMutation } from '#/lib/query.js'; /** A clone command with a copy button. */ function CloneCommand({ url }) { @@ -44,6 +47,88 @@ function CloneCommand({ url }) { ); } +/** + * Who can read a private repository: the space's member list, with add and + * remove. Shown only to the space's owner, whose PDS is the one that tracks + * membership. + */ +function MembersCard({ space, ownDid }) { + const [actor, setActor] = useState(''); + const { data, error } = useQuery({ + queryKey: queryKeys.spaceMembers(space), + queryFn: () => get(`/spaces/members?space=${encodeURIComponent(space)}`), + }); + const action = useApiMutation([queryKeys.spaceMembers(space)]); + + const add = async (event) => { + event.preventDefault(); + const value = actor.trim(); + if (!value) return; + await action.run(async () => { + await post('/spaces/members/add', { space, actor: value }); + setActor(''); + }); + }; + + return ( +
+
+ + Members + + can read every repository in this space + +
+ {error && {error.message}} +
+ {(data?.members ?? []).map((member) => ( +
+ + {member} + {member === ownDid ? ' (you)' : ''} + + {member !== ownDid && ( + + )} +
+ ))} +
+
+ setActor(event.target.value)} + placeholder="handle or DID" + aria-label="Add a member" + className="h-9 min-w-0 flex-1 rounded-xl border border-input bg-transparent px-3 font-mono text-[12.5px] outline-none placeholder:text-faint focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" + /> + +
+ {action.error && ( +
+ {action.error} +
+ )} +
+ ); +} + /** The clickable path above the listing: repo root, then each directory. */ function Breadcrumbs({ name, path, refHref, onNavigate }) { const segments = path.split('/').filter(Boolean); @@ -84,10 +169,10 @@ function Breadcrumbs({ name, path, refHref, onNavigate }) { * A file's content: an image inline, text inline, a notice for other binary * or oversized files. */ -function FileView({ name, refName, path }) { - const query = `repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(refName)}&path=${encodeURIComponent(path)}`; +function FileView({ name, refName, path, space }) { + const query = `repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(refName)}&path=${encodeURIComponent(path)}${space ? `&space=${encodeURIComponent(space)}` : ''}`; const { data, error } = useQuery({ - queryKey: queryKeys.gitFile({ repo: name, ref: refName, path }), + queryKey: queryKeys.gitFile({ repo: name, ref: refName, path, space }), queryFn: () => get(`/git/file?${query}`), }); if (error) return {error.message}; @@ -134,21 +219,32 @@ function FileView({ name, refName, path }) { /** * One hosted repository: clone commands, a ref picker, and the file tree at * the chosen ref, with text previews. - * @param {{name: string, refName?: string, path?: string, file?: string, onNavigate: (href: string) => void}} props + * @param {{name: string, space?: string, did?: string, refName?: string, path?: string, file?: string, onNavigate: (href: string) => void}} props */ -export function GitRepoPage({ name, refName, path = '', file, onNavigate }) { +export function GitRepoPage({ + name, + space, + did, + refName, + path = '', + file, + onNavigate, +}) { const { data: repoData, error: repoError } = useQuery({ queryKey: queryKeys.gitRepos, queryFn: () => get('/git/repos'), }); - const repo = repoData?.repos.find((r) => r.name === name); + const repo = repoData?.repos.find( + (r) => r.name === name && (r.space ?? undefined) === space, + ); const activeRef = refName || repo?.defaultBranch || ''; + const spaceQuery = space ? `&space=${encodeURIComponent(space)}` : ''; const { data: tree, error: treeError } = useQuery({ - queryKey: queryKeys.gitTree({ repo: name, ref: activeRef, path }), + queryKey: queryKeys.gitTree({ repo: name, ref: activeRef, path, space }), queryFn: () => get( - `/git/tree?repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(activeRef)}&path=${encodeURIComponent(path)}`, + `/git/tree?repo=${encodeURIComponent(name)}&ref=${encodeURIComponent(activeRef)}&path=${encodeURIComponent(path)}${spaceQuery}`, ), enabled: Boolean(repoData) && !file, }); @@ -164,6 +260,7 @@ export function GitRepoPage({ name, refName, path = '', file, onNavigate }) { /** @type {{ref?: string, path?: string, file?: string}} */ next, ) => { const query = new URLSearchParams(); + if (space) query.set('space', space); const ref = next.ref ?? activeRef; if (ref && ref !== repo.defaultBranch) query.set('ref', ref); if (next.path) query.set('path', next.path); @@ -185,6 +282,16 @@ export function GitRepoPage({ name, refName, path = '', file, onNavigate }) { {repo.httpCloneUrl && } + {repo.space && repo.spaceOwned && ( + + )} + {repo.space && !repo.spaceOwned && ( + + A private repository in a space managed by another account. Your copy + of it is stored here; the member list lives with the space's owner. + + )} +
{file ? ( - + ) : browseDisabled ? ( File browsing is not available on this server build. The repository is diff --git a/packages/account-ui/src/pages/git.jsx b/packages/account-ui/src/pages/git.jsx index 68fa763..0d8e578 100644 --- a/packages/account-ui/src/pages/git.jsx +++ b/packages/account-ui/src/pages/git.jsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { FolderGit2Icon } from 'lucide-react'; +import { FolderGit2Icon, LockIcon } from 'lucide-react'; import { Badge } from '#/components/atoms/badge.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { RowIcon } from '#/components/atoms/row-icon.jsx'; @@ -8,6 +8,7 @@ import { ListRow } from '#/components/molecules/list-row.jsx'; import { get } from '#/lib/api.js'; import { bytes, timeAgo } from '#/lib/format.js'; import { queryKeys } from '#/lib/query.js'; +import { NewRepoDialog } from '#/pages/git-new-repo.jsx'; /** One line summarizing a repository's shape. */ function repoSummary(repo) { @@ -22,10 +23,16 @@ function repoSummary(repo) { return parts.join(' ยท '); } +/** The repository page URL, carrying the space for a private repository. */ +export function repoHref(repo) { + const base = `/account/git/${encodeURIComponent(repo.name)}`; + return repo.space ? `${base}?space=${encodeURIComponent(repo.space)}` : base; +} + /** - * @param {{onNavigate: (href: string) => void}} props + * @param {{data: Object, onNavigate: (href: string) => void}} props */ -export function GitPage({ onNavigate }) { +export function GitPage({ data: account, onNavigate }) { const { data, error } = useQuery({ queryKey: queryKeys.gitRepos, queryFn: () => get('/git/repos'), @@ -36,34 +43,40 @@ export function GitPage({ onNavigate }) { } if (!data) return null; + const newRepo = ; + if (!data.repos.length) { return ( - } - title="No repositories yet" - description={ - <> - Your account can host git repositories. Install the helper with{' '} - - npm install -g @pdsjs/git - {' '} - and push to{' '} - - atproto://your.handle/repo-name - - . History is stored in your repo as bundles, so it rides along in - backups and migrates with your account. - - } - /> +
+
{newRepo}
+ } + title="No repositories yet" + description={ + <> + Your account can host git repositories. Install the helper with{' '} + + npm install -g @pdsjs/git + {' '} + and push to{' '} + + atproto://your.handle/repo-name + + . History is stored in your repo as bundles, so it rides along in + backups and migrates with your account. + + } + /> +
); } return (
+
{newRepo}
{data.repos.map((repo) => ( @@ -73,6 +86,11 @@ export function GitPage({ onNavigate }) { title={
{repo.name} + {repo.space && ( + + Private + + )} {repo.defaultBranch && ( {repo.defaultBranch} @@ -88,7 +106,7 @@ export function GitPage({ onNavigate }) { ) : undefined } - href={`/account/git/${encodeURIComponent(repo.name)}`} + href={repoHref(repo)} onNavigate={onNavigate} /> ))} diff --git a/packages/cloudflare/src/index.js b/packages/cloudflare/src/index.js index 566b6a2..3f2ccad 100644 --- a/packages/cloudflare/src/index.js +++ b/packages/cloudflare/src/index.js @@ -15,7 +15,12 @@ import { buildBlobListQuery, } from '@pdsjs/core/blob-sql'; import { decodeStoredKey, importPrivateKey, sign } from '@pdsjs/core/crypto'; -import { cborEncodeDagCbor, cidToString, createCid } from '@pdsjs/core/repo'; +import { + cborDecode, + cborEncodeDagCbor, + cidToString, + createCid, +} from '@pdsjs/core/repo'; import { createSpaceBrowser } from '@pdsjs/core/space-browser'; import { createVerifier } from '@pdsjs/core/verify'; import { PASSKEY_SCHEMA_SQL } from '@pdsjs/core/webauthn'; @@ -26,6 +31,7 @@ import { createSiteBrowser } from '@pdsjs/sites/browser'; import { createSiteHandler } from '@pdsjs/sites/handler'; import { createSiteInstaller } from '@pdsjs/sites/installer'; import { createQueryEngine } from '@pdsjs/sites/query'; +import { createSpaceAdmin } from '@pdsjs/spaces/admin'; import { createSpaceRoutes } from '@pdsjs/spaces/routes'; import { createSpaceStorage } from './space.js'; @@ -498,6 +504,13 @@ function createActorStorage(sql) { sql.exec('DELETE FROM record_blobs WHERE record_uri = ?', recordUri); }, + async listBlobRecordUris(blobCid) { + const rows = sql + .exec('SELECT record_uri FROM record_blobs WHERE blob_cid = ?', blobCid) + .toArray(); + return rows.map((r) => String(r.record_uri)); + }, + async getDid() { const row = safeOne( sql.exec("SELECT value FROM metadata WHERE key = 'did'"), @@ -941,12 +954,20 @@ export class PDSDurableObject { // endpoints do not exist. let spaceRoutes; let spaceBrowser; + let spaceAdmin; + /** @type {import('@pdsjs/core/ports').SpaceStoragePort|undefined} */ + let spaceStorageRef; if (env.PDS_ENABLE_SPACES === 'true') { // state.storage as well as .sql: commitSpaceWrite needs transactionSync, // which Durable Object SQL exposes on the storage object rather than // through exec. const spaceStorage = createSpaceStorage(state.storage.sql, state.storage); + spaceStorageRef = spaceStorage; spaceBrowser = createSpaceBrowser(spaceStorage); + spaceAdmin = createSpaceAdmin({ + spaceStorage, + getDid: () => actorStorage.getDid(), + }); spaceRoutes = createSpaceRoutes({ spaceStorage, // A space record's blobs are ordinary account blobs, uploaded through @@ -1033,6 +1054,7 @@ export class PDSDurableObject { : undefined, lexiconResolver, spaceBrowser, + spaceAdmin, accountApp, spaceRoutes, verifier, @@ -1046,7 +1068,36 @@ export class PDSDurableObject { // The closure reads this.pds only at request time, after construction. gitBrowser: env.PDS_EXPERIMENTAL_GIT_BROWSE !== 'false' - ? createGitBrowser({ xrpc: (request) => this.pds.fetch(request) }) + ? createGitBrowser({ + xrpc: (request) => this.pds.fetch(request), + // The account page is the owner's own surface, so repositories + // held in spaces read from storage directly rather than through + // the authenticated space endpoints. + spaceReader: spaceStorageRef + ? { + getRecord: async (space, rkey) => { + const row = + await /** @type {NonNullable} */ ( + spaceStorageRef + ).getSpaceRecord(space, 'dev.pdsjs.git.repo', rkey); + return row + ? { cid: row.cid, value: cborDecode(row.value) } + : null; + }, + getBlob: async (cid) => { + const did = await actorStorage.getDid(); + if (!did) return null; + const result = await blobs.get(did, cid); + if (!result) return null; + return result.data instanceof Uint8Array + ? result.data + : new Uint8Array( + /** @type {ArrayBuffer} */ (result.data), + ); + }, + } + : undefined, + }) : undefined, queryRoutes, onCommit, diff --git a/packages/core/src/handlers/account.js b/packages/core/src/handlers/account.js index 668fc99..a04caf9 100644 --- a/packages/core/src/handlers/account.js +++ b/packages/core/src/handlers/account.js @@ -122,10 +122,11 @@ function plausibleHost(value) { * @property {boolean} readOnly * @property {import('../ports.js').EmailPort|null} emailer * @property {import('../ports.js').SpaceBrowserPort|null} spaceBrowser + * @property {import('../ports.js').SpaceAdminPort|null} [spaceAdmin] * @property {import('../ports.js').SiteBrowserPort|null} siteBrowser * @property {import('../ports.js').SiteInstallerPort|null} siteInstaller * @property {boolean} [gitHttpEnabled] - Whether the platform serves read-only git smart HTTP under /git/, so the repositories page can show plain-git clone URLs - * @property {{listTree: (did: string, repo: string, ref?: string, path?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>}|null} [gitBrowser] - Read-only file browsing of hosted git repositories, built by the platform from the git package. Absent, the tree endpoints report the feature unavailable. + * @property {{listTree: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>}|null} [gitBrowser] - Read-only file browsing of hosted git repositories, built by the platform from the git package. Absent, the tree endpoints report the feature unavailable. * @property {string|null} accountApp - Built account application, as one HTML document * @property {import('../ports.js').LexiconResolverPort} [lexiconResolver] * @property {() => Promise} getDid @@ -164,6 +165,7 @@ export function createAccountHandlers(ctx) { readOnly, emailer, spaceBrowser, + spaceAdmin, siteBrowser, siteInstaller, gitHttpEnabled, @@ -804,6 +806,7 @@ export function createAccountHandlers(ctx) { files: typeof actorStorage.listBlobDetails === 'function', git: true, gitBrowse: Boolean(gitBrowser), + spaceAdmin: Boolean(spaceAdmin), }, stats: { records: collections.reduce((sum, c) => sum + c.count, 0), @@ -1450,6 +1453,111 @@ export function createAccountHandlers(ctx) { }); } + /** + * Resolve a handle to its DID, the two public ways: the well-known + * document, then DNS over HTTPS. + * @param {string} handle + * @returns {Promise} + */ + async function resolveHandleToDid(handle) { + if (!/^[a-z0-9][a-z0-9.-]{0,252}$/i.test(handle)) { + throw new Error(`not a handle: ${handle}`); + } + try { + const res = await fetch(`https://${handle}/.well-known/atproto-did`); + if (res.ok) { + const text = (await res.text()).trim(); + if (text.startsWith('did:')) return text; + } + } catch { + // Fall through to DNS. + } + const res = await fetch( + `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, + { headers: { accept: 'application/dns-json' } }, + ); + if (res.ok) { + const body = /** @type {{Answer?: Array<{data: string}>}} */ ( + await res.json() + ); + for (const answer of body.Answer ?? []) { + const text = answer.data.replace(/^"|"$/g, ''); + if (text.startsWith('did=did:')) return text.slice(4); + } + } + throw new Error(`could not resolve ${handle} to a DID`); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSpacesCreate(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (!spaceAdmin) throw new Error('Spaces are not enabled.'); + const type = typeof body.type === 'string' ? body.type.trim() : ''; + if (!type) throw new Error('A space type is required.'); + const skey = + typeof body.skey === 'string' && body.skey.trim() + ? body.skey.trim() + : undefined; + return await spaceAdmin.createSpace({ type, skey }); + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSpaceMembers(request, url) { + return accountApi(request, url, async () => { + if (!spaceBrowser) return { enabled: false, members: [] }; + const space = url.searchParams.get('space'); + if (!space) throw new Error('space parameter required'); + return { enabled: true, members: await spaceBrowser.listMembers(space) }; + }); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSpaceMembersAdd(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (!spaceAdmin) throw new Error('Spaces are not enabled.'); + const space = typeof body.space === 'string' ? body.space : ''; + const actor = typeof body.actor === 'string' ? body.actor.trim() : ''; + if (!space || !actor) { + throw new Error('A space and a handle or DID are required.'); + } + const memberDid = actor.startsWith('did:') + ? actor + : await resolveHandleToDid(actor); + await spaceAdmin.addMember(space, memberDid); + return { did: memberDid }; + }, + { write: true }, + ); + } + + /** @param {Request} request @param {URL} url */ + async function handleApiSpaceMembersRemove(request, url) { + return accountApi( + request, + url, + async (_did, body) => { + if (!spaceAdmin) throw new Error('Spaces are not enabled.'); + const space = typeof body.space === 'string' ? body.space : ''; + const memberDid = typeof body.did === 'string' ? body.did : ''; + if (!space || !memberDid) { + throw new Error('A space and a member DID are required.'); + } + await spaceAdmin.removeMember(space, memberDid); + return {}; + }, + { write: true }, + ); + } + /** @param {Request} request @param {URL} url */ async function handleApiSites(request, url) { return accountApi(request, url, async (did) => { @@ -1523,6 +1631,48 @@ export function createAccountHandlers(ctx) { * clone URLs derived from the handle and the request origin. * @param {Request} request @param {URL} url */ + /** + * The list-page summary of one git repo record's display value. + * @param {{description?: unknown, defaultBranch?: unknown, refs?: unknown, bundles?: unknown, createdAt?: unknown, updatedAt?: unknown}} value + */ + function summarizeGitRecord(value) { + const refs = /** @type {Array<{name?: unknown, sha?: unknown}>} */ ( + Array.isArray(value.refs) ? value.refs : [] + ).filter( + (ref) => typeof ref.name === 'string' && typeof ref.sha === 'string', + ); + const bundles = /** @type {Array<{parts?: Array<{size?: unknown}>}>} */ ( + Array.isArray(value.bundles) ? value.bundles : [] + ); + let size = 0; + let parts = 0; + for (const bundle of bundles) { + for (const part of bundle.parts ?? []) { + parts += 1; + if (typeof part.size === 'number') size += part.size; + } + } + return { + description: + typeof value.description === 'string' ? value.description : null, + defaultBranch: + typeof value.defaultBranch === 'string' ? value.defaultBranch : null, + refs, + branches: refs.filter((ref) => + /** @type {string} */ (ref.name).startsWith('refs/heads/'), + ).length, + tags: refs.filter((ref) => + /** @type {string} */ (ref.name).startsWith('refs/tags/'), + ).length, + bundles: bundles.length, + parts, + size, + createdAt: typeof value.createdAt === 'string' ? value.createdAt : null, + updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : null, + }; + } + + /** @param {Request} request @param {URL} url */ async function handleApiGitRepos(request, url) { return accountApi(request, url, async (did) => { const authority = (await actorStorage.getHandle()) || did; @@ -1533,66 +1683,64 @@ export function createAccountHandlers(ctx) { ); const repos = []; for (const record of page.records) { - const value = - /** @type {{name?: unknown, description?: unknown, defaultBranch?: unknown, refs?: unknown, bundles?: unknown, createdAt?: unknown, updatedAt?: unknown}} */ ( - toDisplayValue( - cborDecode( - record.value instanceof Uint8Array - ? record.value - : new Uint8Array(record.value), - ), - ) - ); + const value = /** @type {{name?: unknown}} */ ( + toDisplayValue( + cborDecode( + record.value instanceof Uint8Array + ? record.value + : new Uint8Array(record.value), + ), + ) + ); const name = typeof value.name === 'string' ? value.name : record.uri.split('/').pop() || ''; - const refs = /** @type {Array<{name?: unknown, sha?: unknown}>} */ ( - Array.isArray(value.refs) ? value.refs : [] - ).filter( - (ref) => typeof ref.name === 'string' && typeof ref.sha === 'string', - ); - const bundles = - /** @type {Array<{parts?: Array<{size?: unknown}>}>} */ ( - Array.isArray(value.bundles) ? value.bundles : [] - ); - let size = 0; - let parts = 0; - for (const bundle of bundles) { - for (const part of bundle.parts ?? []) { - parts += 1; - if (typeof part.size === 'number') size += part.size; - } - } repos.push({ name, uri: record.uri, - description: - typeof value.description === 'string' ? value.description : null, - defaultBranch: - typeof value.defaultBranch === 'string' - ? value.defaultBranch - : null, - refs, - branches: refs.filter((ref) => - /** @type {string} */ (ref.name).startsWith('refs/heads/'), - ).length, - tags: refs.filter((ref) => - /** @type {string} */ (ref.name).startsWith('refs/tags/'), - ).length, - bundles: bundles.length, - parts, - size, - createdAt: - typeof value.createdAt === 'string' ? value.createdAt : null, - updatedAt: - typeof value.updatedAt === 'string' ? value.updatedAt : null, + space: null, + spaceOwned: false, + ...summarizeGitRecord(/** @type {any} */ (value)), cloneUrl: `atproto://${authority}/${name}`, httpCloneUrl: gitHttpEnabled ? `${url.protocol}//${url.host}/git/${authority}/${name}` : null, }); } + + // Repositories in spaces: this account's own record in each space it + // holds, whether it is the space's authority or a member with a copy. + // The remote URL names the space authority, which for a member copy is + // another account, so it appears by DID rather than a handle. + if (spaceBrowser) { + for (const space of await spaceBrowser.listSpaces()) { + if (space.deletedAt) continue; + const { records } = await spaceBrowser.listRecords( + space.uri, + 'dev.pdsjs.git.repo', + 100, + ); + const skey = space.uri.split('/')[5] ?? ''; + const spaceAuthority = + space.spaceDid === did ? authority : space.spaceDid; + for (const record of records) { + const value = /** @type {{name?: unknown}} */ (record.value); + const name = + typeof value.name === 'string' ? value.name : record.rkey; + repos.push({ + name, + uri: `${space.uri}/${did}/dev.pdsjs.git.repo/${record.rkey}`, + space: space.uri, + spaceOwned: space.isOwner, + ...summarizeGitRecord(/** @type {any} */ (record.value)), + cloneUrl: `atproto://${spaceAuthority}/space/${space.spaceType}/${skey}/${name}`, + httpCloneUrl: null, + }); + } + } + } + repos.sort((a, b) => (b.updatedAt ?? b.createdAt ?? '').localeCompare( a.updatedAt ?? a.createdAt ?? '', @@ -1617,6 +1765,7 @@ export function createAccountHandlers(ctx) { repo, url.searchParams.get('ref') || undefined, url.searchParams.get('path') || '', + url.searchParams.get('space') || undefined, ); if (!listing) throw new Error(`no such repository: ${repo}`); return { enabled: true, ...listing }; @@ -1639,6 +1788,7 @@ export function createAccountHandlers(ctx) { repo, url.searchParams.get('ref') || undefined, path, + url.searchParams.get('space') || undefined, ); if (!file) throw new Error(`no such repository: ${repo}`); return { enabled: true, ...file }; @@ -1661,6 +1811,7 @@ export function createAccountHandlers(ctx) { repo, url.searchParams.get('ref') || undefined, path, + url.searchParams.get('space') || undefined, ); if (!image) throw new Error(`no such repository: ${repo}`); return new Response(/** @type {BodyInit} */ (image.bytes), { @@ -3447,6 +3598,22 @@ export function createAccountHandlers(ctx) { method: 'GET', handler: handleApiSpaces, }, + '/account/api/spaces/create': { + method: 'POST', + handler: handleApiSpacesCreate, + }, + '/account/api/spaces/members': { + method: 'GET', + handler: handleApiSpaceMembers, + }, + '/account/api/spaces/members/add': { + method: 'POST', + handler: handleApiSpaceMembersAdd, + }, + '/account/api/spaces/members/remove': { + method: 'POST', + handler: handleApiSpaceMembersRemove, + }, '/account/api/sites': { method: 'GET', handler: handleApiSites, diff --git a/packages/core/src/handlers/xrpc-blob.js b/packages/core/src/handlers/xrpc-blob.js index e85b184..1c46156 100644 --- a/packages/core/src/handlers/xrpc-blob.js +++ b/packages/core/src/handlers/xrpc-blob.js @@ -27,6 +27,18 @@ function errorResponse(error, message, status = 400) { return Response.json({ error, message }, { status }); } +/** + * Whether a record URI addresses a space record. A space record's URI puts + * the literal `space` marker where a public URI has its collection NSID, and + * an NSID always contains a dot, so the second path segment separates them. + * @param {string} uri + * @returns {boolean} + */ +function isSpaceRecordUri(uri) { + if (!uri.startsWith('at://')) return false; + return uri.slice('at://'.length).split('/')[1] === 'space'; +} + /** * @param {BlobContext} ctx * @returns {{ routes: import('../pds.js').Routes }} @@ -130,6 +142,15 @@ export function createBlobHandlers(ctx) { return errorResponse('InvalidRequest', 'Invalid CID format'); } + // A blob only space records reference is private data. This endpoint is + // unauthenticated, so such a blob is absent here; com.atproto.space.getBlob + // serves it to a session or a space credential. A blob with no references + // at all stays served: the public write flow uploads before it commits. + const uris = await actorStorage.listBlobRecordUris(cid); + if (uris.length > 0 && uris.every(isSpaceRecordUri)) { + return errorResponse('BlobNotFound', 'Blob not found', 404); + } + const result = await blobs.get(did, cid); if (!result) { return errorResponse('BlobNotFound', 'Blob not found', 404); @@ -163,8 +184,19 @@ export function createBlobHandlers(ctx) { const result = await actorStorage.listBlobs(cursor, limit); + // Space-only blobs are absent from the public listing the same way they + // are absent from getBlob. The cursor advances over the unfiltered page, + // so a filtered-out blob costs a page slot but never stalls paging. + /** @type {string[]} */ + const cids = []; + for (const cid of result.cids) { + const uris = await actorStorage.listBlobRecordUris(cid); + if (uris.length > 0 && uris.every(isSpaceRecordUri)) continue; + cids.push(cid); + } + return Response.json({ - cids: result.cids, + cids, // Match the reference PDS: omit cursor on a final page rather than // emitting `cursor: null`, which strict lexicon validators reject. ...(result.cursor ? { cursor: result.cursor } : {}), diff --git a/packages/core/src/pds.js b/packages/core/src/pds.js index 26bf51b..4b2124c 100644 --- a/packages/core/src/pds.js +++ b/packages/core/src/pds.js @@ -123,6 +123,7 @@ export class PersonalDataServer { * @param {string} [config.plcUrl] - PLC directory URL for identity operations * @param {import('./ports.js').EmailPort} [config.emailer] - Sends account mail; absent, an address can be stored but never confirmed * @param {import('./ports.js').SpaceBrowserPort} [config.spaceBrowser] - Read-only view of the account's spaces, supplied by the same platform package that builds spaceRoutes + * @param {import('./ports.js').SpaceAdminPort} [config.spaceAdmin] - Space management for the account page, supplied the same way. Absent, the account page cannot create spaces or edit members. * @param {string} [config.accountApp] - Built account application, as one HTML document. Injected rather than imported: core has no build step and no filesystem to read it from. Absent, /account serves the server-rendered pages instead. * @param {import('./ports.js').SignatureVerifierPort} [config.verifier] - Verifies a signature against a did:key. Injected so core stays free of a secp256k1 dependency. Used to check migration service tokens. * @param {(did: string) => Promise} [config.didResolver] - Resolves a DID to its DID document. Paired with verifier to check the signature of a migration service token against its issuer's key. @@ -154,6 +155,7 @@ export class PersonalDataServer { plcUrl, emailer, spaceBrowser, + spaceAdmin, accountApp, spaceRoutes, verifier, @@ -184,6 +186,7 @@ export class PersonalDataServer { this.plcUrl = plcUrl || DEFAULT_PLC_URL; this.emailer = emailer || null; this.spaceBrowser = spaceBrowser || null; + this.spaceAdmin = spaceAdmin || null; this.siteBrowser = siteBrowser || null; this.accountApp = accountApp || null; // Optional, injected by the platform package: a DID-document resolver and a @@ -367,6 +370,7 @@ export class PersonalDataServer { readOnly: this.readOnly, emailer: this.emailer, spaceBrowser: this.spaceBrowser, + spaceAdmin: this.spaceAdmin, siteBrowser: this.siteBrowser, siteInstaller: siteInstaller || null, gitHttpEnabled: Boolean(gitHttpEnabled), diff --git a/packages/core/src/ports.js b/packages/core/src/ports.js index 9355f00..44f35ee 100644 --- a/packages/core/src/ports.js +++ b/packages/core/src/ports.js @@ -273,6 +273,9 @@ * `recordTime` is the referencing record's own timestamp (epoch ms) when it * carries one; it dates the blob by the record's clock rather than arrival. * @property {(recordUri: string) => Promise} unlinkBlobsFromRecord + * @property {(blobCid: string) => Promise} listBlobRecordUris + * Every record URI the link table holds for a blob. The public blob + * endpoints read these to refuse a blob only space records reference. * @property {() => Promise} getDid * @property {(did: string) => Promise} setDid * @property {() => Promise} getPrivateKey @@ -395,10 +398,21 @@ * @property {() => Promise} listSpaces * @property {(uri: string) => Promise>} countRecords * @property {(uri: string) => Promise} countMembers + * @property {(uri: string) => Promise} listMembers * @property {(uri: string, collection: string, limit: number) => Promise<{records: Array<{rkey: string, cid: string, indexedAt: string, value: unknown}>, truncated: boolean}>} listRecords * @property {() => Promise>} listAllRecords */ +/** + * Space management for the account page, built by a platform package from + * @pdsjs/spaces; core never imports that package. The account page runs + * behind the owner's session, so these carry no scope checks of their own. + * @typedef {Object} SpaceAdminPort + * @property {(opts: {type: string, skey?: string}) => Promise<{uri: string}>} createSpace + * @property {(space: string, did: string) => Promise} addMember + * @property {(space: string, did: string) => Promise} removeMember + */ + /** * Serves static sites from the repo on non-PDS hostnames. Built by a * platform package from @pdsjs/sites; core never imports that package. A diff --git a/packages/core/src/space-browser.js b/packages/core/src/space-browser.js index 6f5107e..017c2c6 100644 --- a/packages/core/src/space-browser.js +++ b/packages/core/src/space-browser.js @@ -111,5 +111,10 @@ export function createSpaceBrowser(spaceStorage) { const { dids } = await spaceStorage.listMembers(uri, null, MEMBER_LIMIT); return dids.length; }, + + async listMembers(uri) { + const { dids } = await spaceStorage.listMembers(uri, null, MEMBER_LIMIT); + return dids; + }, }; } diff --git a/packages/git/README.md b/packages/git/README.md index a94542e..e945e55 100644 --- a/packages/git/README.md +++ b/packages/git/README.md @@ -129,8 +129,12 @@ differ. Requirements and behavior: - Pushes use a compare-and-swap the same way public pushes do. The swap field is a pds.js extension to `com.atproto.space.putRecord`; on a server without it, concurrent pushes fall back to last-write-wins. -- The read-only smart HTTP endpoint and the repository browser serve public - records only, so a space repository is reachable through the helper alone. +- The read-only smart HTTP endpoint and the public repository browser serve + public records only, so over the network a space repository is reachable + through the helper alone. The account page is the owner's surface for it: + the repositories section lists private repos with the space they live in, + browses their files, manages the space's members, and its New repository + dialog creates the space behind a private repo and shows the push commands. Each member's bundle chain is self-contained: a member's first push uploads their full history rather than an increment over the objects other chains diff --git a/packages/git/src/browser.js b/packages/git/src/browser.js index b33232f..226b0c2 100644 --- a/packages/git/src/browser.js +++ b/packages/git/src/browser.js @@ -30,9 +30,20 @@ const IMAGE_MEDIA_TYPES = { webp: 'image/webp', }; +/** + * Direct storage access for repositories held in a permissioned space. The + * account page is the owner's own surface, so the platform reads space + * records and blobs from storage rather than through the authenticated + * space endpoints. + * @typedef {Object} SpaceReader + * @property {(space: string, rkey: string) => Promise<{cid: string, value: unknown}|null>} getRecord + * @property {(cid: string) => Promise} getBlob + */ + /** * @typedef {Object} GitBrowserContext * @property {(request: Request) => Promise} xrpc + * @property {SpaceReader} [spaceReader] * @property {string} [collection] */ @@ -82,19 +93,28 @@ export function createGitBrowser(ctx) { /** * @param {string} did * @param {string} repoName + * @param {string} [space] - space AT-URI; the record and blobs then come + * from the space reader rather than the public endpoints * @returns {Promise<{repo: import('./record.js').GitRepoRecord, objects: Map}|null>} */ - async function open(did, repoName) { - const params = new URLSearchParams({ - repo: did, - collection, - rkey: repoName, - }); - const res = await xrpcGet(`/xrpc/com.atproto.repo.getRecord?${params}`); - if (!res.ok) return null; - const body = /** @type {{cid: string, value: unknown}} */ ( - await res.json() - ); + async function open(did, repoName, space) { + /** @type {{cid: string, value: unknown}|null} */ + let body; + if (space) { + if (!ctx.spaceReader) return null; + body = await ctx.spaceReader.getRecord(space, repoName); + } else { + const params = new URLSearchParams({ + repo: did, + collection, + rkey: repoName, + }); + const res = await xrpcGet(`/xrpc/com.atproto.repo.getRecord?${params}`); + body = res.ok + ? /** @type {{cid: string, value: unknown}} */ (await res.json()) + : null; + } + if (!body) return null; const repo = parseRepoRecord(body.value); if (cached && cached.cid === body.cid && cached.repo.name === repo.name) { return cached; @@ -105,6 +125,14 @@ export function createGitBrowser(ctx) { /** @type {Uint8Array[]} */ const chunks = []; for (const part of bundle.parts) { + if (space && ctx.spaceReader) { + const bytes = await ctx.spaceReader.getBlob(part.ref.$link); + if (!bytes) { + throw new Error(`bundle blob ${part.ref.$link} unavailable`); + } + chunks.push(bytes); + continue; + } const blobParams = new URLSearchParams({ did, cid: part.ref.$link }); const blobRes = await xrpcGet( `/xrpc/com.atproto.sync.getBlob?${blobParams}`, @@ -186,9 +214,10 @@ export function createGitBrowser(ctx) { * @param {string} repoName * @param {string} [ref] * @param {string} [path] + * @param {string} [space] */ - async listTree(did, repoName, ref, path = '') { - const opened = await open(did, repoName); + async listTree(did, repoName, ref, path = '', space) { + const opened = await open(did, repoName, space); if (!opened) return null; const refEntry = findRef(opened.repo, ref); if (!refEntry) throw new Error(`no such ref: ${ref}`); @@ -223,9 +252,10 @@ export function createGitBrowser(ctx) { * @param {string} repoName * @param {string} [ref] * @param {string} [path] + * @param {string} [space] */ - async readFile(did, repoName, ref, path = '') { - const opened = await open(did, repoName); + async readFile(did, repoName, ref, path = '', space) { + const opened = await open(did, repoName, space); if (!opened) return null; const { refEntry, entry, object } = blobAt(opened, ref, path); const binary = looksBinary(object.data); @@ -250,11 +280,12 @@ export function createGitBrowser(ctx) { * @param {string} repoName * @param {string} [ref] * @param {string} [path] + * @param {string} [space] */ - async readImage(did, repoName, ref, path = '') { + async readImage(did, repoName, ref, path = '', space) { const mediaType = imageMediaType(path); if (!mediaType) throw new Error(`not an image: ${path}`); - const opened = await open(did, repoName); + const opened = await open(did, repoName, space); if (!opened) return null; const { refEntry, object } = blobAt(opened, ref, path); return { diff --git a/packages/git/test/space-remote.test.js b/packages/git/test/space-remote.test.js index d58a64c..7041c53 100644 --- a/packages/git/test/space-remote.test.js +++ b/packages/git/test/space-remote.test.js @@ -285,6 +285,90 @@ describe('private git repository over a space', () => { expect(record.refs).toHaveLength(1); }); + it('the public blob endpoints refuse the private bundle chunks', async () => { + const params = new URLSearchParams({ + space: SPACE_URI, + collection: GIT_REPO_COLLECTION, + rkey: REPO, + }); + const record = + /** @type {{value: {bundles: Array<{parts: Array<{ref: {$link: string}}>}>}}} */ ( + await ( + await fetch( + `${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`, + { headers: { Authorization: `Bearer ${aliceJwt}` } }, + ) + ).json() + ).value; + const chunkCid = record.bundles[0].parts[0].ref.$link; + + const blobParams = new URLSearchParams({ did: ALICE_DID, cid: chunkCid }); + const publicBlob = await fetch( + `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`, + ); + expect(publicBlob.status).toBe(404); + + const listing = /** @type {{cids: string[]}} */ ( + await ( + await fetch(`${ALICE_BASE}/xrpc/com.atproto.sync.listBlobs?limit=1000`) + ).json() + ); + expect(listing.cids).not.toContain(chunkCid); + + // The same chunk stays readable inside the space. + const spaceBlobParams = new URLSearchParams({ + space: SPACE_URI, + cid: chunkCid, + }); + const spaceBlob = await fetch( + `${ALICE_BASE}/xrpc/com.atproto.space.getBlob?${spaceBlobParams}`, + { headers: { Authorization: `Bearer ${aliceJwt}` } }, + ); + expect(spaceBlob.ok).toBe(true); + }); + + it('a publicly referenced blob is still served publicly', async () => { + const upload = await fetch( + `${ALICE_BASE}/xrpc/com.atproto.repo.uploadBlob`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + Authorization: `Bearer ${aliceJwt}`, + }, + body: randomBytes(64), + }, + ); + expect(upload.ok).toBe(true); + const { blob } = /** @type {{blob: {ref: {$link: string}}}} */ ( + await upload.json() + ); + + const put = await fetch(`${ALICE_BASE}/xrpc/com.atproto.repo.putRecord`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${aliceJwt}`, + }, + body: JSON.stringify({ + repo: ALICE_DID, + collection: 'com.example.attachment', + rkey: 'pub', + record: { $type: 'com.example.attachment', file: blob }, + }), + }); + expect(put.ok).toBe(true); + + const blobParams = new URLSearchParams({ + did: ALICE_DID, + cid: blob.ref.$link, + }); + const res = await fetch( + `${ALICE_BASE}/xrpc/com.atproto.sync.getBlob?${blobParams}`, + ); + expect(res.ok).toBe(true); + }); + it('the record is not in the public repo', async () => { const params = new URLSearchParams({ repo: ALICE_DID, @@ -401,6 +485,100 @@ describe('private git repository over a space', () => { ); }); + it('the account page sees, browses, and manages the private repo', async () => { + const signIn = await fetch(`${ALICE_BASE}/account/sign-in`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Origin: ALICE_BASE, + }, + body: new URLSearchParams({ + username: ALICE_DID, + password: PASSWORD, + }).toString(), + redirect: 'manual', + }); + const cookie = (signIn.headers.get('set-cookie') ?? '').split(';')[0]; + expect(cookie).toBeTruthy(); + + // The repositories listing carries the space repo with its space URI. + const repos = + /** @type {{repos: Array<{name: string, space: string|null, spaceOwned: boolean, cloneUrl: string}>}} */ ( + await ( + await fetch(`${ALICE_BASE}/account/api/git/repos`, { + headers: { cookie }, + }) + ).json() + ).repos; + const spaceRepo = repos.find((r) => r.name === REPO); + expect(spaceRepo?.space).toBe(SPACE_URI); + expect(spaceRepo?.spaceOwned).toBe(true); + // The clone URL names the authority by handle. + expect(spaceRepo?.cloneUrl).toBe( + `atproto://alice.test/space/${SPACE_TYPE}/${SPACE_KEY}/${REPO}`, + ); + + // File browsing reads the space record and blobs from storage. + const treeParams = new URLSearchParams({ repo: REPO, space: SPACE_URI }); + const tree = /** @type {{entries: Array<{name: string}>}} */ ( + await ( + await fetch(`${ALICE_BASE}/account/api/git/tree?${treeParams}`, { + headers: { cookie }, + }) + ).json() + ); + expect(tree.entries.map((e) => e.name)).toContain('README.md'); + + // Member management round-trip. + const memberParams = new URLSearchParams({ space: SPACE_URI }); + const listMembers = async () => + /** @type {{members: string[]}} */ ( + await ( + await fetch( + `${ALICE_BASE}/account/api/spaces/members?${memberParams}`, + { headers: { cookie } }, + ) + ).json() + ).members; + expect(await listMembers()).toContain(BOB_DID); + + const extra = `did:plc:${randomBase32(24)}`; + const add = await fetch(`${ALICE_BASE}/account/api/spaces/members/add`, { + method: 'POST', + headers: { cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ space: SPACE_URI, actor: extra }), + }); + expect(add.ok).toBe(true); + expect(await listMembers()).toContain(extra); + + const remove = await fetch( + `${ALICE_BASE}/account/api/spaces/members/remove`, + { + method: 'POST', + headers: { cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ space: SPACE_URI, did: extra }), + }, + ); + expect(remove.ok).toBe(true); + expect(await listMembers()).not.toContain(extra); + + // Creating a space from the account page, the new-repository flow. + const create = await fetch(`${ALICE_BASE}/account/api/spaces/create`, { + method: 'POST', + headers: { cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'com.example.second', skey: 'another' }), + }); + expect(create.ok).toBe(true); + const { uri } = await create.json(); + expect(uri).toBe(`at://${ALICE_DID}/space/com.example.second/another`); + const dup = await fetch(`${ALICE_BASE}/account/api/spaces/create`, { + method: 'POST', + headers: { cookie, 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'com.example.second', skey: 'another' }), + }); + expect(dup.ok).toBe(false); + }); + it('an anonymous clone is refused', async () => { const cloneDir = join(workDir, 'clone-anon'); await expect( diff --git a/packages/node/src/index.js b/packages/node/src/index.js index 5aded09..8505fd9 100644 --- a/packages/node/src/index.js +++ b/packages/node/src/index.js @@ -359,17 +359,26 @@ export async function createServer({ // a space-unaware PDS still receives space requests from optimistic clients. let spaceRoutes; let spaceBrowser; + let spaceAdmin; + /** @type {import('@pdsjs/core/ports').SpaceStoragePort|undefined} */ + let spaceStorageRef; if (spaces) { const [{ createSpaceRoutes }, { createSpaceStorage }] = await Promise.all([ import('@pdsjs/spaces/routes'), import('@pdsjs/storage-sqlite'), ]); const { createSpaceBrowser } = await import('@pdsjs/core/space-browser'); + const { createSpaceAdmin } = await import('@pdsjs/spaces/admin'); const { decodeStoredKey, importPrivateKey, sign } = await import( '@pdsjs/core/crypto' ); const spaceStorage = createSpaceStorage(db); + spaceStorageRef = spaceStorage; spaceBrowser = createSpaceBrowser(spaceStorage); + spaceAdmin = createSpaceAdmin({ + spaceStorage, + getDid: () => actorStorage.getDid(), + }); spaceRoutes = createSpaceRoutes({ spaceStorage, // A space record's blobs are ordinary account blobs, uploaded through @@ -482,12 +491,42 @@ export async function createServer({ // it the tree endpoints report the feature unavailable and the page hides // the browser. The closure reads `pds` only at request time, well after // its assignment. - /** @type {{listTree: (did: string, repo: string, ref?: string, path?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>}|undefined} */ + /** @type {{listTree: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readFile: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise, readImage: (did: string, repo: string, ref?: string, path?: string, space?: string) => Promise<{bytes: Uint8Array, mediaType: string, size: number}|null>}|undefined} */ let gitBrowser; if (gitBrowse) { try { const { createGitBrowser } = await import('@pdsjs/git/browser'); - gitBrowser = createGitBrowser({ xrpc: (request) => pds.fetch(request) }); + const { cborDecode } = await import('@pdsjs/core/repo'); + const spaceStorage = spaceStorageRef; + gitBrowser = createGitBrowser({ + xrpc: (request) => pds.fetch(request), + // The account page is the owner's own surface, so repositories held + // in spaces read from storage directly rather than through the + // authenticated space endpoints. + spaceReader: spaceStorage + ? { + getRecord: async (space, rkey) => { + const row = await spaceStorage.getSpaceRecord( + space, + 'dev.pdsjs.git.repo', + rkey, + ); + return row + ? { cid: row.cid, value: cborDecode(row.value) } + : null; + }, + getBlob: async (cid) => { + const did = await actorStorage.getDid(); + if (!did) return null; + const result = await blobs.get(did, cid); + if (!result) return null; + return result.data instanceof Uint8Array + ? result.data + : new Uint8Array(/** @type {ArrayBuffer} */ (result.data)); + }, + } + : undefined, + }); } catch { // Optional peer not installed } @@ -509,6 +548,7 @@ export async function createServer({ lexiconResolver: resolver, emailer, spaceBrowser, + spaceAdmin, accountApp, spaceRoutes, verifier, diff --git a/packages/readonly/src/index.js b/packages/readonly/src/index.js index eac6721..b338492 100644 --- a/packages/readonly/src/index.js +++ b/packages/readonly/src/index.js @@ -308,6 +308,12 @@ function createRoutingStorage(manager) { throw new Error('Read-only: linkBlobToRecord not allowed'); }, + async listBlobRecordUris() { + // A backup snapshot carries no link table; with no space records either, + // every blob it holds is public. + return []; + }, + async unlinkBlobsFromRecord() { throw new Error('Read-only: unlinkBlobsFromRecord not allowed'); }, diff --git a/packages/spaces/package.json b/packages/spaces/package.json index 504fff3..0fd2554 100644 --- a/packages/spaces/package.json +++ b/packages/spaces/package.json @@ -20,7 +20,8 @@ "./car": "./src/car.js", "./token": "./src/token.js", "./authority": "./src/authority.js", - "./service-auth": "./src/service-auth.js" + "./service-auth": "./src/service-auth.js", + "./admin": "./src/admin.js" }, "dependencies": { "@pdsjs/core": "workspace:*" diff --git a/packages/spaces/src/admin.js b/packages/spaces/src/admin.js new file mode 100644 index 0000000..76baa1c --- /dev/null +++ b/packages/spaces/src/admin.js @@ -0,0 +1,77 @@ +// @pdsjs/spaces/admin - space management for the account page. +// +// The account page runs behind the owner's own session, so these skip the +// scope checks the XRPC management endpoints apply to third-party callers. +// They mutate the same storage the same way com.atproto.simplespace.* does. + +import { createTid } from '@pdsjs/core/repo'; +import { makeSpaceRow } from './space-row.js'; +import { formatSpaceUri } from './uri.js'; + +/** + * @param {Object} ctx + * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage + * @param {() => Promise} ctx.getDid + * @returns {import('@pdsjs/core/ports').SpaceAdminPort} + */ +export function createSpaceAdmin({ spaceStorage, getDid }) { + /** @returns {Promise} */ + async function requireDid() { + const did = await getDid(); + if (!did) throw new Error('server not initialised'); + return did; + } + + /** + * @param {string} space + * @returns {Promise} + */ + async function requireOwnedSpace(space) { + const row = await spaceStorage.getSpace(space); + if (!row?.isOwner || row.deletedAt) { + throw new Error(`no such space: ${space}`); + } + } + + return { + async createSpace({ type, skey }) { + if (!type.includes('.')) { + throw new Error(`space type must be an NSID: ${type}`); + } + if (skey && skey.length > 512) { + throw new Error('skey is too long'); + } + const did = await requireDid(); + const uri = formatSpaceUri({ + spaceDid: did, + spaceType: type, + skey: skey || createTid(), + }); + if (await spaceStorage.getSpace(uri)) { + throw new Error(`space already exists: ${uri}`); + } + await spaceStorage.putSpace(makeSpaceRow(uri, { isOwner: true })); + // The owner joins their own space: a member-list space checks membership + // before it issues a credential, and that is the default policy. + await spaceStorage.addMember(uri, did); + return { uri }; + }, + + async addMember(space, did) { + if (!did.startsWith('did:')) { + throw new Error(`not a DID: ${did}`); + } + await requireOwnedSpace(space); + await spaceStorage.addMember(space, did); + }, + + async removeMember(space, did) { + const owner = await requireDid(); + if (did === owner) { + throw new Error('the owner cannot leave their own space'); + } + await requireOwnedSpace(space); + await spaceStorage.removeMember(space, did); + }, + }; +} diff --git a/packages/storage-sqlite/src/index.js b/packages/storage-sqlite/src/index.js index dfa1c07..90214dd 100644 --- a/packages/storage-sqlite/src/index.js +++ b/packages/storage-sqlite/src/index.js @@ -225,6 +225,9 @@ export function createActorStorage(db) { unlinkBlobsFromRecord: db.prepare( 'DELETE FROM record_blobs WHERE record_uri = ?', ), + listBlobRecordUris: db.prepare( + 'SELECT record_uri FROM record_blobs WHERE blob_cid = ?', + ), getMetadata: db.prepare('SELECT value FROM metadata WHERE key = ?'), setMetadata: db.prepare( 'INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)', @@ -411,6 +414,13 @@ export function createActorStorage(db) { stmts.unlinkBlobsFromRecord.run(recordUri); }, + async listBlobRecordUris(blobCid) { + const rows = /** @type {{record_uri: string}[]} */ ( + stmts.listBlobRecordUris.all(blobCid) + ); + return rows.map((row) => row.record_uri); + }, + async getDid() { const row = /** @type {MetadataStringRow|undefined} */ ( stmts.getMetadata.get('did') diff --git a/test/account-api.test.js b/test/account-api.test.js index 28aff49..8893d66 100644 --- a/test/account-api.test.js +++ b/test/account-api.test.js @@ -215,6 +215,7 @@ function createEmailer() { * @param {Object} [options.sharedStorage] * @param {Object} [options.emailer] * @param {Object} [options.spaceBrowser] + * @param {Object} [options.spaceAdmin] * @param {Object} [options.lexiconResolver] * @param {boolean} [options.readOnly] * @param {boolean} [options.gitHttpEnabled] @@ -225,6 +226,7 @@ function createPds({ sharedStorage = createSharedStorage(), emailer, spaceBrowser, + spaceAdmin, lexiconResolver, readOnly = false, gitHttpEnabled = false, @@ -239,6 +241,7 @@ function createPds({ password: 'account-password', emailer: /** @type {any} */ (emailer), spaceBrowser: /** @type {any} */ (spaceBrowser), + spaceAdmin: /** @type {any} */ (spaceAdmin), lexiconResolver: /** @type {any} */ (lexiconResolver), readOnly, gitHttpEnabled, @@ -1977,3 +1980,206 @@ describe('git browse endpoint edge cases', () => { expect(await response.json()).toEqual({ enabled: false }); }); }); + +describe('space admin endpoints', () => { + const SPACE = `at://${DID}/space/com.example.team/main`; + + /** A spaceAdmin recording its calls. */ + function recordingAdmin() { + /** @type {string[]} */ + const calls = []; + return { + calls, + async createSpace(/** @type {{type: string, skey?: string}} */ opts) { + calls.push(`create ${opts.type} ${opts.skey ?? ''}`); + return { uri: SPACE }; + }, + async addMember(/** @type {string} */ space, /** @type {string} */ did) { + calls.push(`add ${space} ${did}`); + }, + async removeMember( + /** @type {string} */ space, + /** @type {string} */ did, + ) { + calls.push(`remove ${space} ${did}`); + }, + }; + } + + it('answers with an error when spaces are not enabled', async () => { + const pds = createPds(); + const cookie = await sessionCookie(); + for (const [path, body] of [ + ['/account/api/spaces/create', { type: 'com.example.team' }], + ['/account/api/spaces/members/add', { space: SPACE, actor: DID }], + ['/account/api/spaces/members/remove', { space: SPACE, did: DID }], + ]) { + const response = await apiPost(pds, String(path), body, cookie); + expect(response.status).toBe(400); + expect((await response.json()).error).toMatch(/not enabled/); + } + }); + + it('creates a space and requires a type', async () => { + const spaceAdmin = recordingAdmin(); + const pds = createPds({ spaceAdmin }); + const cookie = await sessionCookie(); + + const created = await apiPost( + pds, + '/account/api/spaces/create', + { type: 'com.example.team', skey: 'main' }, + cookie, + ); + expect(created.status).toBe(200); + expect((await created.json()).uri).toBe(SPACE); + expect(spaceAdmin.calls).toContain('create com.example.team main'); + + const missing = await apiPost( + pds, + '/account/api/spaces/create', + {}, + cookie, + ); + expect(missing.status).toBe(400); + expect((await missing.json()).error).toMatch(/type is required/); + }); + + it('lists members through the browser', async () => { + const pds = createPds({ + spaceBrowser: { + async listMembers() { + return [DID, 'did:plc:friend']; + }, + }, + }); + const response = await apiGet( + pds, + `/account/api/spaces/members?space=${encodeURIComponent(SPACE)}`, + await sessionCookie(), + ); + expect(await response.json()).toEqual({ + enabled: true, + members: [DID, 'did:plc:friend'], + }); + }); + + it('adds a member by DID without resolving anything', async () => { + const spaceAdmin = recordingAdmin(); + const pds = createPds({ spaceAdmin }); + const response = await apiPost( + pds, + '/account/api/spaces/members/add', + { space: SPACE, actor: 'did:plc:friend' }, + await sessionCookie(), + ); + expect(response.status).toBe(200); + expect((await response.json()).did).toBe('did:plc:friend'); + expect(spaceAdmin.calls).toContain(`add ${SPACE} did:plc:friend`); + }); + + it('resolves a handle through the well-known document', async () => { + const spaceAdmin = recordingAdmin(); + const pds = createPds({ spaceAdmin }); + const realFetch = globalThis.fetch; + globalThis.fetch = /** @type {any} */ ( + async (/** @type {any} */ input) => { + const url = String(input); + if (url === 'https://friend.example/.well-known/atproto-did') { + return new Response('did:plc:friend\n'); + } + return realFetch(input); + } + ); + try { + const response = await apiPost( + pds, + '/account/api/spaces/members/add', + { space: SPACE, actor: 'friend.example' }, + await sessionCookie(), + ); + expect(response.status).toBe(200); + expect((await response.json()).did).toBe('did:plc:friend'); + } finally { + globalThis.fetch = realFetch; + } + }); + + it('falls back to DNS and fails closed when both miss', async () => { + const spaceAdmin = recordingAdmin(); + const pds = createPds({ spaceAdmin }); + const realFetch = globalThis.fetch; + globalThis.fetch = /** @type {any} */ ( + async (/** @type {any} */ input) => { + const url = String(input); + if (url.includes('.well-known')) { + return new Response('nope', { status: 404 }); + } + if (url.startsWith('https://cloudflare-dns.com/')) { + if (url.includes('_atproto.friend.example')) { + return Response.json({ + Answer: [{ data: '"did=did:plc:dnsfriend"' }], + }); + } + return Response.json({ Answer: [] }); + } + return realFetch(input); + } + ); + try { + const resolved = await apiPost( + pds, + '/account/api/spaces/members/add', + { space: SPACE, actor: 'friend.example' }, + await sessionCookie(), + ); + expect((await resolved.json()).did).toBe('did:plc:dnsfriend'); + + const unresolved = await apiPost( + pds, + '/account/api/spaces/members/add', + { space: SPACE, actor: 'nobody.example' }, + await sessionCookie(), + ); + expect(unresolved.status).toBe(400); + expect((await unresolved.json()).error).toMatch(/could not resolve/); + } finally { + globalThis.fetch = realFetch; + } + }); + + it('refuses an actor that is not a plausible handle', async () => { + const pds = createPds({ spaceAdmin: recordingAdmin() }); + const response = await apiPost( + pds, + '/account/api/spaces/members/add', + { space: SPACE, actor: 'not a handle' }, + await sessionCookie(), + ); + expect(response.status).toBe(400); + expect((await response.json()).error).toMatch(/not a handle/); + }); + + it('removes a member and requires both fields', async () => { + const spaceAdmin = recordingAdmin(); + const pds = createPds({ spaceAdmin }); + const cookie = await sessionCookie(); + + const removed = await apiPost( + pds, + '/account/api/spaces/members/remove', + { space: SPACE, did: 'did:plc:friend' }, + cookie, + ); + expect(removed.status).toBe(200); + expect(spaceAdmin.calls).toContain(`remove ${SPACE} did:plc:friend`); + + const missing = await apiPost( + pds, + '/account/api/spaces/members/remove', + { space: SPACE }, + cookie, + ); + expect(missing.status).toBe(400); + }); +}); diff --git a/test/xrpc-blob.test.js b/test/xrpc-blob.test.js index fe32e6e..1c55ba6 100644 --- a/test/xrpc-blob.test.js +++ b/test/xrpc-blob.test.js @@ -20,8 +20,12 @@ function createBlobs({ /** @type {Map} */ const store = new Map(); + /** @type {Map} blob cid -> record URIs referencing it */ + const links = new Map(); + const actorStorage = { meta, + links, async putBlob( /** @type {string} */ cid, /** @type {string} */ mimeType, @@ -39,6 +43,9 @@ function createBlobs({ const next = from + limit < all.length ? page[page.length - 1] : null; return { cids: page, cursor: next }; }, + async listBlobRecordUris(/** @type {string} */ cid) { + return links.get(cid) ?? []; + }, }; const blobs = { @@ -225,6 +232,46 @@ describe('getBlob', () => { expect((await response.json()).message).toMatch(/Missing required/); }); + it('refuses a blob only space records reference', async () => { + const { handlers, actorStorage } = createBlobs(); + const cid = ( + await (await call(handlers, UPLOAD, { auth: FULL, body: PNG })).json() + ).blob.ref.$link; + actorStorage.links.set(cid, [ + `at://did:plc:auth/space/com.example.forum/general/${DID}/com.example.post/a`, + ]); + + const response = await call(handlers, `${GET}?did=${DID}&cid=${cid}`); + + expect(response.status).toBe(404); + expect((await response.json()).error).toBe('BlobNotFound'); + }); + + it('serves a blob a public record also references', async () => { + const { handlers, actorStorage } = createBlobs(); + const cid = ( + await (await call(handlers, UPLOAD, { auth: FULL, body: PNG })).json() + ).blob.ref.$link; + actorStorage.links.set(cid, [ + `at://did:plc:auth/space/com.example.forum/general/${DID}/com.example.post/a`, + `at://${DID}/com.example.post/b`, + ]); + + const response = await call(handlers, `${GET}?did=${DID}&cid=${cid}`); + expect(response.status).toBe(200); + }); + + it('treats a link that is not an at uri as public', async () => { + const { handlers, actorStorage } = createBlobs(); + const cid = ( + await (await call(handlers, UPLOAD, { auth: FULL, body: PNG })).json() + ).blob.ref.$link; + actorStorage.links.set(cid, ['not-a-record-uri']); + + const response = await call(handlers, `${GET}?did=${DID}&cid=${cid}`); + expect(response.status).toBe(200); + }); + it('refuses a did this server does not host', async () => { const { handlers } = createBlobs(); const response = await call( @@ -267,6 +314,19 @@ describe('listBlobs', () => { expect(body.cids).toEqual(['bafkreia', 'bafkreib']); }); + it('leaves out a blob only space records reference', async () => { + const { handlers, meta, actorStorage } = createBlobs(); + meta.set('bafkreia', { mimeType: 'image/png', size: 1 }); + meta.set('bafkreib', { mimeType: 'image/png', size: 1 }); + actorStorage.links.set('bafkreib', [ + `at://did:plc:auth/space/com.example.forum/general/${DID}/com.example.post/a`, + ]); + + const body = await (await call(handlers, LIST)).json(); + + expect(body.cids).toEqual(['bafkreia']); + }); + it('omits the cursor on a final page', async () => { const { handlers, meta } = createBlobs(); meta.set('bafkreia', { mimeType: 'image/png', size: 1 });