chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* End-to-end test of private repositories over spaces: two Node PDS
* instances, a stub PLC directory, and real git subprocesses driving the
* helper through the space form of the remote URL.
*
* Alice hosts the repository in a space she owns. She pushes and clones with
* her own session. Bob is a space member on his own PDS; his clone runs the
* whole credential chain: a session on his PDS, a delegation token, a space
* credential from Alice's PDS, and DPoP-proofed reads.
*
* Git runs asynchronously for the same reason as in git-remote.test.js: both
* PDS instances live on this process's event loop.
*/
import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { createServer as createHttpServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { derivePublicKey, hexToBytes } from '@pdsjs/core/crypto';
import { publicKeyToDidKey } from '@pdsjs/core/plc';
import { createServer } from '@pdsjs/node';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { GIT_REPO_COLLECTION } from '../src/index.js';
const execFileAsync = promisify(execFile);
const ALICE_PORT = 2476;
const BOB_PORT = 2477;
const PLC_PORT = 2478;
const ALICE_BASE = `http://127.0.0.1:${ALICE_PORT}`;
const BOB_BASE = `http://127.0.0.1:${BOB_PORT}`;
const PLC_URL = `http://127.0.0.1:${PLC_PORT}`;
const PASSWORD = 'test-password';
const REPO = 'secret-repo';
// A private repository's space: the git type, keyed by the repository.
const SPACE_TYPE = 'dev.pdsjs.git.space';
const SPACE_KEY = REPO;
/** @param {number} length */
function randomBase32(length) {
const chars = 'abcdefghijklmnopqrstuvwxyz234567';
return Array.from(randomBytes(length))
.map((b) => chars[b % 32])
.join('');
}
const ALICE_DID = `did:plc:${randomBase32(24)}`;
const BOB_DID = `did:plc:${randomBase32(24)}`;
const SPACE_URI = `at://${ALICE_DID}/space/${SPACE_TYPE}/${SPACE_KEY}`;
const REMOTE = `atproto://${ALICE_DID}/${REPO}`;
/** @type {Array<{close: () => Promise<unknown>}>} */
const servers = [];
/** @type {import('node:http').Server|null} */
let plcServer = null;
/** @type {Map<string, object>} */
const didDocs = new Map();
/** @type {string} */
let workDir = '';
/** @type {string} */
let srcDir = '';
/** @type {Record<string, string>} */
let baseEnv = {};
/** @type {string} */
let aliceJwt = '';
/** @type {string} */
let bobJwt = '';
/**
* @param {string} cwd
* @param {string[]} args
* @param {Record<string, string>} [extraEnv]
* @returns {Promise<string>} stdout
*/
async function runGit(cwd, args, extraEnv = {}) {
const { stdout } = await execFileAsync('git', args, {
cwd,
env: { ...baseEnv, ...extraEnv },
encoding: 'utf8',
});
return stdout;
}
/**
* Boot a PDS, initialise its account, and publish the account's DID document
* to the stub PLC.
* @param {string} name
* @param {number} port
* @param {string} did
* @returns {Promise<string>} the account's access JWT
*/
async function bootPds(name, port, did) {
const dir = join(workDir, name);
mkdirSync(join(dir, 'blobs'), { recursive: true });
const server = await createServer({
port,
dbPath: join(dir, 'pds.db'),
blobsDir: join(dir, 'blobs'),
jwtSecret: `test-secret-${name}`,
hostname: `127.0.0.1:${port}`,
password: PASSWORD,
plcUrl: PLC_URL,
spaces: true,
});
await server.listen();
servers.push(server);
const base = `http://127.0.0.1:${port}`;
const privateKey = randomBytes(32).toString('hex');
const init = await fetch(`${base}/init?did=${did}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
did,
privateKey,
handle: `${name}.test`,
password: PASSWORD,
}),
});
expect(init.ok).toBe(true);
const { publicKey } = await derivePublicKey(hexToBytes(privateKey));
const multibase = publicKeyToDidKey(publicKey, 'p256').slice(
'did:key:'.length,
);
didDocs.set(did, {
'@context': ['https://www.w3.org/ns/did/v1'],
id: did,
alsoKnownAs: [`at://${name}.test`],
verificationMethod: [
{
id: `${did}#atproto`,
type: 'Multikey',
controller: did,
publicKeyMultibase: multibase,
},
],
service: [
{
id: '#atproto_pds',
type: 'AtprotoPersonalDataServer',
serviceEndpoint: base,
},
],
});
const res = await fetch(`${base}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: did, password: PASSWORD }),
});
expect(res.ok).toBe(true);
const { accessJwt } = /** @type {{accessJwt: string}} */ (await res.json());
// The account's KEM key, which credential reads of its space repos ask for
// a MAC against.
const recommended = await fetch(
`${base}/xrpc/com.atproto.identity.getRecommendedDidCredentials`,
{ headers: { Authorization: `Bearer ${accessJwt}` } },
);
expect(recommended.ok).toBe(true);
const { verificationMethods } =
/** @type {{verificationMethods: {atproto_kem: string}}} */ (
await recommended.json()
);
const doc =
/** @type {{verificationMethod: Array<Record<string, string>>}} */ (
didDocs.get(did)
);
doc.verificationMethod.push({
id: `${did}#atproto_kem`,
type: 'Multikey',
controller: did,
publicKeyMultibase: verificationMethods.atproto_kem.slice(
'did:key:'.length,
),
});
return accessJwt;
}
/**
* @param {string} base
* @param {string} nsid
* @param {unknown} body
* @param {string} jwt
*/
async function xrpcPost(base, nsid, body, jwt) {
const res = await fetch(`${base}/xrpc/${nsid}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
},
body: JSON.stringify(body),
});
expect(res.ok, `${nsid}: ${res.status}`).toBe(true);
return res.json();
}
beforeAll(async () => {
workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-space-'));
plcServer = createHttpServer((req, res) => {
const doc = didDocs.get(decodeURIComponent((req.url ?? '/').slice(1)));
if (!doc) {
res.writeHead(404).end('{}');
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(doc));
});
await new Promise((resolve) =>
/** @type {import('node:http').Server} */ (plcServer).listen(PLC_PORT, () =>
resolve(undefined),
),
);
aliceJwt = await bootPds('alice', ALICE_PORT, ALICE_DID);
bobJwt = await bootPds('bob', BOB_PORT, BOB_DID);
await xrpcPost(
ALICE_BASE,
'com.atproto.space.createSpace',
{
spaceType: SPACE_TYPE,
skey: SPACE_KEY,
policy: { $type: 'com.atproto.memberspace.policy' },
},
aliceJwt,
);
await xrpcPost(
ALICE_BASE,
'com.atproto.memberspace.putMember',
{ space: SPACE_URI, did: BOB_DID, read: true, write: true },
aliceJwt,
);
// PATH shim so git's atproto:// scheme resolves to our helper CLI
const binDir = join(workDir, 'bin');
mkdirSync(binDir);
const cliPath = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'src',
'cli.js',
);
const shim = join(binDir, 'git-remote-atproto');
writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`);
chmodSync(shim, 0o755);
baseEnv = {
.../** @type {Record<string, string>} */ (process.env),
PATH: `${binDir}:${process.env.PATH}`,
ATPROTO_GIT_SERVICE: ALICE_BASE,
ATPROTO_GIT_PLC_URL: PLC_URL,
ATPROTO_GIT_CHUNK_SIZE: '1024',
GIT_TERMINAL_PROMPT: '0',
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@example.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@example.com',
};
srcDir = join(workDir, 'src-repo');
mkdirSync(srcDir);
await runGit(srcDir, ['init', '-b', 'main']);
writeFileSync(join(srcDir, 'README.md'), '# private repo over a space\n');
writeFileSync(join(srcDir, 'data.bin'), randomBytes(4096));
await runGit(srcDir, ['add', '.']);
await runGit(srcDir, ['commit', '-m', 'initial commit']);
await runGit(srcDir, ['remote', 'add', 'origin', REMOTE]);
}, 60_000);
afterAll(async () => {
for (const server of servers) await server.close();
if (plcServer) await new Promise((resolve) => plcServer?.close(resolve));
rmSync(workDir, { recursive: true, force: true });
});
const aliceEnv = () => ({
ATPROTO_GIT_IDENTIFIER: ALICE_DID,
ATPROTO_GIT_PASSWORD: PASSWORD,
});
const bobEnv = () => ({
ATPROTO_GIT_IDENTIFIER: BOB_DID,
ATPROTO_GIT_PASSWORD: PASSWORD,
ATPROTO_GIT_USER_SERVICE: BOB_BASE,
});
describe('private git repository over a space', () => {
it('the owner pushes into the space', async () => {
await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv());
const params = new URLSearchParams({
space: SPACE_URI,
collection: GIT_REPO_COLLECTION,
rkey: REPO,
});
const res = await fetch(
`${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
{ headers: { Authorization: `Bearer ${aliceJwt}` } },
);
expect(res.ok).toBe(true);
const record = /** @type {{value: {name: string, refs: unknown[]}}} */ (
await res.json()
).value;
expect(record.name).toBe(REPO);
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,
collection: GIT_REPO_COLLECTION,
rkey: REPO,
});
const res = await fetch(
`${ALICE_BASE}/xrpc/com.atproto.repo.getRecord?${params}`,
);
expect(res.status).toBe(404);
});
it('reading the space record needs authentication', async () => {
const params = new URLSearchParams({
space: SPACE_URI,
collection: GIT_REPO_COLLECTION,
rkey: REPO,
});
const res = await fetch(
`${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${params}`,
);
expect(res.status).toBe(401);
});
it('the owner clones with their own session', async () => {
const cloneDir = join(workDir, 'clone-alice');
await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv());
const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
expect(readme).toContain('private repo over a space');
});
it('a member clones through the credential chain', async () => {
const cloneDir = join(workDir, 'clone-bob');
await runGit(workDir, ['clone', REMOTE, cloneDir], bobEnv());
const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
expect(readme).toContain('private repo over a space');
});
it('a member pulls a later push', async () => {
writeFileSync(join(srcDir, 'update.txt'), 'second commit\n');
await runGit(srcDir, ['add', '.']);
await runGit(srcDir, ['commit', '-m', 'second commit']);
await runGit(srcDir, ['push', 'origin', 'main'], aliceEnv());
const cloneDir = join(workDir, 'clone-bob');
await runGit(cloneDir, ['pull', 'origin', 'main'], bobEnv());
const update = await runGit(cloneDir, ['show', 'HEAD:update.txt']);
expect(update).toContain('second commit');
});
it('a member pushes to their own record on their own PDS', async () => {
const cloneDir = join(workDir, 'clone-bob');
const aliceParams = new URLSearchParams({
space: SPACE_URI,
collection: GIT_REPO_COLLECTION,
rkey: REPO,
});
const canonicalBefore = await (
await fetch(
`${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
{ headers: { Authorization: `Bearer ${aliceJwt}` } },
)
).json();
writeFileSync(join(cloneDir, 'bob-note.txt'), 'from bob\n');
await runGit(cloneDir, ['add', '.']);
await runGit(cloneDir, ['commit', '-m', 'bob note']);
await runGit(cloneDir, ['push', 'origin', 'main'], bobEnv());
const bobRes = await fetch(
`${BOB_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
{ headers: { Authorization: `Bearer ${bobJwt}` } },
);
expect(bobRes.ok).toBe(true);
const bobRecord = /** @type {{value: {refs: Array<{sha: string}>}}} */ (
await bobRes.json()
).value;
expect(bobRecord.refs[0].sha).toBe(
(await runGit(cloneDir, ['rev-parse', 'main'])).trim(),
);
const canonicalAfter = await (
await fetch(
`${ALICE_BASE}/xrpc/com.atproto.space.getRecord?${aliceParams}`,
{ headers: { Authorization: `Bearer ${aliceJwt}` } },
)
).json();
expect(canonicalAfter.cid).toBe(canonicalBefore.cid);
});
it("a fetch lists the member's branches under their handle", async () => {
const cloneDir = join(workDir, 'clone-alice');
await runGit(cloneDir, ['fetch', 'origin'], aliceEnv());
const branches = await runGit(cloneDir, ['branch', '-r']);
expect(branches).toContain('origin/bob.test/main');
const note = await runGit(cloneDir, [
'show',
'origin/bob.test/main:bob-note.txt',
]);
expect(note).toContain('from bob');
});
it("a fresh clone checks out the space owner's main", async () => {
const cloneDir = join(workDir, 'clone-fresh');
await runGit(workDir, ['clone', REMOTE, cloneDir], aliceEnv());
expect((await runGit(cloneDir, ['branch', '--show-current'])).trim()).toBe(
'main',
);
await expect(
runGit(cloneDir, ['show', 'HEAD:bob-note.txt']),
).rejects.toThrow();
expect(await runGit(cloneDir, ['branch', '-r'])).toContain(
'origin/bob.test/main',
);
});
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}/xrpc/dev.pdsjs.git.listRepos`, {
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, and reads like a public
// repository's: the helper finds the space from the name.
expect(spaceRepo?.cloneUrl).toBe(`atproto://alice.test/${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}/xrpc/dev.pdsjs.git.getTree?${treeParams}`, {
headers: { cookie },
})
).json()
);
expect(tree.entries.map((e) => e.name)).toContain('README.md');
// Commit history reads straight from the bundle chain's commit objects.
const logParams = new URLSearchParams({ repo: REPO, space: SPACE_URI });
const log =
/** @type {{commits: Array<{message: string, author: string}>}} */ (
await (
await fetch(
`${ALICE_BASE}/xrpc/dev.pdsjs.git.listCommits?${logParams}`,
{
headers: { cookie },
},
)
).json()
);
expect(log.commits.map((c) => c.message.trim()).sort()).toEqual([
'initial commit',
'second commit',
]);
expect(log.commits[0].author).toContain('Test');
// Member management round-trip.
const memberParams = new URLSearchParams({ space: SPACE_URI });
const listMembers = async () =>
/** @type {{members: Array<{did: string}>}} */ (
await (
await fetch(
`${ALICE_BASE}/account/api/spaces/members?${memberParams}`,
{ headers: { cookie } },
)
).json()
).members.map((member) => member.did);
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('a protected branch refuses force pushes and deletion, and takes fast-forwards', 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];
const settings = await fetch(
`${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`,
{
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: REPO,
space: SPACE_URI,
description: 'guarded',
protectedBranches: ['main'],
}),
},
);
expect(settings.ok).toBe(true);
// The settings rewrite of the repo record must round-trip its blob refs:
// a fresh clone still downloads and replays the chain.
const checkDir = join(workDir, 'clone-guarded');
await runGit(workDir, ['clone', REMOTE, checkDir], aliceEnv());
// A fast-forward push is accepted.
writeFileSync(join(checkDir, 'more.txt'), 'more\n');
await runGit(checkDir, ['add', '.']);
await runGit(checkDir, ['commit', '-m', 'fast forward']);
await runGit(checkDir, ['push', 'origin', 'main'], aliceEnv());
// A force push of rewritten history is refused by the server.
await runGit(checkDir, ['commit', '--amend', '-m', 'rewritten']);
await expect(
runGit(checkDir, ['push', '--force', 'origin', 'main'], aliceEnv()),
).rejects.toThrow(/protected|fast-forward/);
// So is deleting the branch.
await expect(
runGit(checkDir, ['push', 'origin', ':main'], aliceEnv()),
).rejects.toThrow(/protected/);
// The listing reports both settings.
const repos =
/** @type {{repos: Array<{name: string, space: string|null, description: string|null, protectedBranches: string[]}>}} */ (
await (
await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
headers: { cookie },
})
).json()
).repos;
const guarded = repos.find((r) => r.name === REPO && r.space === SPACE_URI);
expect(guarded?.description).toBe('guarded');
expect(guarded?.protectedBranches).toEqual(['main']);
});
it('a repository created empty from the account page takes its first push', 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];
// Private, with a new space, the way the dialog submits it.
const createdPrivate = await fetch(
`${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`,
{
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'fresh-repo', private: true }),
},
);
expect(createdPrivate.ok).toBe(true);
const { space } = await createdPrivate.json();
expect(space).toBe(
`at://${ALICE_DID}/space/dev.pdsjs.git.space/fresh-repo`,
);
// Public too, and a duplicate name is refused.
const createdPublic = await fetch(
`${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`,
{
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'fresh-public' }),
},
);
expect(createdPublic.ok).toBe(true);
const dup = await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.createRepo`, {
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'fresh-public' }),
});
expect(dup.ok).toBe(false);
// Both list before any push, with nothing in them.
const repos =
/** @type {{repos: Array<{name: string, space: string|null, branches: number}>}} */ (
await (
await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
headers: { cookie },
})
).json()
).repos;
const fresh = repos.find((r) => r.name === 'fresh-repo');
expect(fresh?.space).toBe(space);
expect(fresh?.branches).toBe(0);
expect(
repos.find((r) => r.name === 'fresh-public' && r.space === null),
).toBeTruthy();
// The first push lands in the pre-created record through the CAS.
const pushDir = join(workDir, 'fresh-src');
mkdirSync(pushDir);
await runGit(pushDir, ['init', '-b', 'main']);
writeFileSync(join(pushDir, 'hello.txt'), 'hello\n');
await runGit(pushDir, ['add', '.']);
await runGit(pushDir, ['commit', '-m', 'first']);
await runGit(
pushDir,
['push', `atproto://${ALICE_DID}/fresh-repo`, 'main'],
aliceEnv(),
);
const after =
/** @type {{repos: Array<{name: string, branches: number}>}} */ (
await (
await fetch(`${ALICE_BASE}/xrpc/dev.pdsjs.git.listRepos`, {
headers: { cookie },
})
).json()
).repos;
expect(after.find((r) => r.name === 'fresh-repo')?.branches).toBe(1);
// Protection holds on the public path too.
await runGit(
pushDir,
['push', `atproto://${ALICE_DID}/fresh-public`, 'main'],
aliceEnv(),
);
const protect = await fetch(
`${ALICE_BASE}/xrpc/dev.pdsjs.git.updateSettings`,
{
method: 'POST',
headers: { cookie, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'fresh-public',
protectedBranches: ['main'],
}),
},
);
expect(protect.ok).toBe(true);
await runGit(pushDir, ['commit', '--amend', '-m', 'rewritten']);
await expect(
runGit(
pushDir,
['push', '--force', `atproto://${ALICE_DID}/fresh-public`, 'main'],
aliceEnv(),
),
).rejects.toThrow(/protected|fast-forward/);
});
it('an anonymous clone is refused', async () => {
const cloneDir = join(workDir, 'clone-anon');
await expect(
runGit(workDir, ['clone', REMOTE, cloneDir], {
ATPROTO_GIT_PASSWORD: '',
HOME: join(workDir, 'no-home'),
XDG_CONFIG_HOME: join(workDir, 'no-home'),
}),
).rejects.toThrow(/no credentials|exit code/i);
});
});