From f8e3dff44a1154946c85cc2e1db60ef8798e22f2 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 13 Aug 2026 10:22:16 -0700 Subject: [PATCH] feat(git): private repositories in the account page, and a blob privacy fix The public com.atproto.sync.getBlob and sync.listBlobs endpoints served any blob in the account's store, including a private repository's bundle chunks. They now refuse a blob that only space records reference; the authenticated com.atproto.space.getBlob remains the path to those. A blob between upload and its first reference stays served, which the public write flow needs, and the gap closes at the first reference. The repositories page now covers private repos the way GitHub covers its own. The listing shows space-held repos with a Private badge and space-form clone URLs; browsing reads their records and blobs from storage through a space reader the platform injects, since the account page is the owner's own surface. A New repository dialog picks visibility, creates the space behind a private repo (one space per repository by default, both fields editable), and shows the push commands. The repo page shows the space's member list with add by handle or DID and remove, backed by new account API endpoints and a SpaceAdminPort built by @pdsjs/spaces and injected by the node and cloudflare adapters. Co-Authored-By: Claude Fable 5 --- .changeset/eighty-donuts-jam.md | 17 ++ docs/permissioned-data.md | 7 + packages/account-ui/src/app.jsx | 9 +- packages/account-ui/src/lib/query.js | 1 + .../account-ui/src/pages/git-new-repo.jsx | 231 +++++++++++++++ .../account-ui/src/pages/git-repo-page.jsx | 129 ++++++++- packages/account-ui/src/pages/git.jsx | 64 +++-- packages/cloudflare/src/index.js | 55 +++- packages/core/src/handlers/account.js | 263 ++++++++++++++---- packages/core/src/handlers/xrpc-blob.js | 34 ++- packages/core/src/pds.js | 4 + packages/core/src/ports.js | 14 + packages/core/src/space-browser.js | 5 + packages/git/README.md | 8 +- packages/git/src/browser.js | 65 +++-- packages/git/test/space-remote.test.js | 178 ++++++++++++ packages/node/src/index.js | 44 ++- packages/readonly/src/index.js | 6 + packages/spaces/package.json | 3 +- packages/spaces/src/admin.js | 77 +++++ packages/storage-sqlite/src/index.js | 10 + test/account-api.test.js | 206 ++++++++++++++ test/xrpc-blob.test.js | 60 ++++ 23 files changed, 1381 insertions(+), 109 deletions(-) create mode 100644 .changeset/eighty-donuts-jam.md create mode 100644 packages/account-ui/src/pages/git-new-repo.jsx create mode 100644 packages/spaces/src/admin.js 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 }); -- 2.51.2