chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* The git appview end to end: a real PDS holding the data with its git
* extension switched off, the appview in front of it, and real git
* subprocesses. A push goes helper-to-PDS direct; a clone goes through the
* appview with stock git, no helper.
*/
import { execFile } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { createAppViewStore } from '@pdsjs/appview/store';
import { GIT_REPO_COLLECTION } from '@pdsjs/git';
import { createServer } from '@pdsjs/node';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createRemoteAccount } from '../src/remote.js';
import { createGitHostServer } from '../src/server.js';
const execFileAsync = promisify(execFile);
const PDS_PORT = 2520;
const PDS_BASE = `http://127.0.0.1:${PDS_PORT}`;
const HOST_PORT = 2521;
const HOST_BASE = `http://127.0.0.1:${HOST_PORT}`;
const PASSWORD = 'test-password';
/** @param {number} length */
function randomBase32(length) {
const chars = 'abcdefghijklmnopqrstuvwxyz234567';
return Array.from(randomBytes(length))
.map((b) => chars[b % 32])
.join('');
}
const DID = `did:plc:${randomBase32(24)}`;
const HANDLE = 'git.test';
/** @type {Awaited<ReturnType<typeof createServer>>|null} */
let pds = null;
/** @type {Awaited<ReturnType<typeof createGitHostServer>>|null} */
let host = null;
/** @type {string} */
let workDir = '';
/** @type {string} */
let srcDir = '';
/** @type {Record<string, string>} */
let gitEnv = {};
/**
* @param {string} cwd
* @param {string[]} args
* @param {Record<string, string>} [env]
* @returns {Promise<string>} stdout
*/
async function runGit(cwd, args, env = {}) {
const { stdout } = await execFileAsync('git', args, {
cwd,
env: { ...gitEnv, ...env },
encoding: 'utf8',
});
return stdout;
}
beforeAll(async () => {
workDir = mkdtempSync(join(tmpdir(), 'pdsjs-git-host-e2e-'));
// The data store: a stock PDS with its own git hosting switched off, so
// everything the appview serves is proven to come over the wire.
pds = await createServer({
port: PDS_PORT,
dbPath: join(workDir, 'pds.db'),
blobsDir: join(workDir, 'blobs'),
jwtSecret: 'test-secret-for-git-host-e2e',
hostname: `127.0.0.1:${PDS_PORT}`,
password: PASSWORD,
experimental: { git: { external: true } },
});
await pds.listen();
const init = await fetch(`${PDS_BASE}/init?did=${DID}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
did: DID,
privateKey: randomBytes(32).toString('hex'),
handle: HANDLE,
password: PASSWORD,
}),
});
expect(init.ok).toBe(true);
// PATH shim so the push's atproto:// remote resolves to the helper CLI.
const binDir = join(workDir, 'bin');
mkdirSync(binDir);
const cliPath = join(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'git',
'src',
'cli.js',
);
const shim = join(binDir, 'git-remote-atproto');
writeFileSync(shim, `#!/bin/sh\nexec node ${cliPath} "$@"\n`);
chmodSync(shim, 0o755);
gitEnv = {
.../** @type {Record<string, string>} */ (process.env),
PATH: `${binDir}:${process.env.PATH}`,
ATPROTO_GIT_SERVICE: PDS_BASE,
ATPROTO_GIT_IDENTIFIER: DID,
ATPROTO_GIT_PASSWORD: PASSWORD,
ATPROTO_GIT_CHUNK_SIZE: '8192',
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'), '# served by the appview\n');
writeFileSync(join(srcDir, 'data.bin'), randomBytes(8192));
await runGit(srcDir, ['add', '.']);
await runGit(srcDir, ['commit', '-m', 'initial commit']);
await runGit(srcDir, [
'remote',
'add',
'origin',
`atproto://${DID}/testrepo`,
]);
// The forge: index in memory, the account reached over the wire.
const store = createAppViewStore(new Database(':memory:'));
const account = createRemoteAccount({
service: PDS_BASE,
account: DID,
});
host = createGitHostServer({
account,
store,
hostname: `127.0.0.1:${HOST_PORT}`,
onNotice: (message) => console.log(`[git-host] ${message}`),
});
await host.listen(HOST_PORT);
}, 60_000);
afterAll(async () => {
if (host) await host.close();
if (pds) await pds.close();
rmSync(workDir, { recursive: true, force: true });
});
describe('git appview e2e', () => {
it('pushes to the PDS direct and finds nothing served by the PDS itself', async () => {
await runGit(srcDir, ['push', 'origin', 'main']);
// The key is minted, so the pushed record is found by the name it
// carries.
const listed = await fetch(
`${PDS_BASE}/xrpc/com.atproto.repo.listRecords?repo=${DID}&collection=${GIT_REPO_COLLECTION}&limit=100`,
);
expect(listed.ok).toBe(true);
const { records } =
/** @type {{records: Array<{value: {name: string}}>}} */ (
await listed.json()
);
expect(records.some((row) => row.value.name === 'testrepo')).toBe(true);
// The PDS runs with git.external: its own methods and /git surface
// are gone.
const pdsRoutes = await fetch(`${PDS_BASE}/xrpc/dev.pdsjs.git.listRepos`);
expect(pdsRoutes.status).toBe(501);
});
it('serves the forge routes from the appview', async () => {
const listing = await fetch(`${HOST_BASE}/xrpc/dev.pdsjs.git.listRepos`);
expect(listing.ok).toBe(true);
const body = /** @type {{repos: Array<{name: string}>}} */ (
await listing.json()
);
expect(body.repos.map((repo) => repo.name)).toContain('testrepo');
const identity = await fetch(`${HOST_BASE}/.well-known/atproto-did`);
expect((await identity.text()).trim()).toBe(DID);
});
it('clones over plain git through the appview', async () => {
const cloneDir = join(workDir, 'clone');
await runGit(workDir, [
'clone',
`${HOST_BASE}/git/${DID}/testrepo`,
cloneDir,
]);
const readme = await runGit(cloneDir, ['show', 'HEAD:README.md']);
expect(readme).toContain('served by the appview');
expect((await runGit(cloneDir, ['branch', '--show-current'])).trim()).toBe(
'main',
);
});
it('sees a second push through the appview fetch', async () => {
writeFileSync(join(srcDir, 'second.txt'), 'second commit\n');
await runGit(srcDir, ['add', '.']);
await runGit(srcDir, ['commit', '-m', 'second']);
await runGit(srcDir, ['push', 'origin', 'main']);
const cloneDir = join(workDir, 'clone2');
await runGit(workDir, [
'clone',
`${HOST_BASE}/git/${DID}/testrepo`,
cloneDir,
]);
expect(
(await runGit(cloneDir, ['rev-list', '--count', 'HEAD'])).trim(),
).toBe('2');
});
it('serves raw files with the record cid as a strong validator', async () => {
const refs = await fetch(
`${HOST_BASE}/git/${DID}/testrepo/info/refs?service=git-upload-pack`,
);
expect(refs.ok).toBe(true);
const etag = refs.headers.get('etag');
expect(etag).toBeTruthy();
const raw = await fetch(
`${HOST_BASE}/git/${DID}/testrepo/raw/main/README.md`,
);
expect(raw.ok).toBe(true);
expect(await raw.text()).toContain('served by the appview');
const again = await fetch(
`${HOST_BASE}/git/${DID}/testrepo/raw/main/README.md`,
{ headers: { 'if-none-match': etag ?? '' } },
);
// The raw etag is the record's, so an advertisement's etag does not
// short it; what matters is the route answers.
expect([200, 304]).toContain(again.status);
});
});