// Deploy the repository browser to the account's own site. // // The build is one self-contained document, so the site record names one // file and a visitor holding cached HTML can never ask for an asset the // record no longer carries. // // Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… npm run deploy --workspace @pdsjs/git-ui import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { cidToString, createBlobCid } from '@pdsjs/core/repo'; import { SCOPE } from '../src/lib/scope.js'; const PDS = process.env.PDS_URL; const DID = process.env.PDS_DID; if (!PDS || !DID) throw new Error('Set PDS_URL and PDS_DID.'); const SITE = process.env.GIT_SITE || 'git'; const SITES = 'dev.pdsjs.site.deploy'; const DEFS = 'dev.pdsjs.query.def'; const REPOS = 'dev.pdsjs.git.repo'; const IDENTS = 'dev.pdsjs.git.identity'; const QUERY = 'git-repos'; const SORT = { field: 'updatedAt', direction: 'desc' }; async function xrpc(path, init = {}) { const response = await fetch(`${PDS}/xrpc/${path}`, init); if (!response.ok) { throw new Error( `${path.split('?')[0]} ${response.status}: ${(await response.text()).slice(0, 200)}`, ); } return response.json(); } const password = process.env.PDS_APP_PASSWORD; if (!password) throw new Error('Set PDS_APP_PASSWORD.'); const session = await xrpc('com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: DID, password }), }); const auth = { Authorization: `Bearer ${session.accessJwt}` }; /** The fields a rebuild moves on its own, which say nothing about the app. */ const TIMESTAMPS = ['createdAt', 'updatedAt']; /** * A record as one string, with the keys in one order and the timestamps * blanked. The PDS returns a record decoded from CBOR, so its key order is * its own and never the order this file writes. * @param {unknown} value * @returns {string} */ function comparable(value) { if (Array.isArray(value)) return `[${value.map(comparable).join(',')}]`; if (value === null || typeof value !== 'object') return JSON.stringify(value); const entries = Object.entries(value) .filter(([key]) => !TIMESTAMPS.includes(key)) .sort(([a], [b]) => (a < b ? -1 : 1)); return `{${entries.map(([k, v]) => `${k}:${comparable(v)}`).join(',')}}`; } /** @typedef {{mimeType: string, bytes: Uint8Array}} PendingBlob */ /** Blobs the next record names, held until that record is known to be new. */ const pending = /** @type {PendingBlob[]} */ ([]); /** * The blob object a record names, hashed here rather than by the server. A * deploy that changes nothing sends none of these bytes, and the PDS already * holds every one of them under the record it is about to keep. * @param {string} mimeType * @param {string} content * @returns {Promise>} */ async function blobFor(mimeType, content) { const bytes = new TextEncoder().encode(content); pending.push({ mimeType, bytes }); return { $type: 'blob', ref: { $link: cidToString(await createBlobCid(bytes)) }, mimeType, size: bytes.length, }; } /** * Write the record, unless the one already there differs only in its * timestamps. An install pins the manifest by CID and offers an update when * that CID moves, so a rebuild that changes nothing has to write nothing. * @param {string} collection @param {string} rkey @param {Record} record */ async function put(collection, rkey, record) { const value = { $type: collection, ...record }; const current = await fetch( `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${collection}&rkey=${rkey}`, ); if (current.ok) { const existing = (await current.json()).value; if (comparable(existing) === comparable(value)) { pending.length = 0; console.error(`keep ${collection}/${rkey}`); return; } } // The PDS rejects a record naming a blob it does not hold, so the queue // drains first. for (const upload of pending.splice(0)) { const res = await fetch(`${PDS}/xrpc/com.atproto.repo.uploadBlob`, { method: 'POST', headers: { ...auth, 'Content-Type': upload.mimeType }, body: upload.bytes, }); if (!res.ok) throw new Error(`uploadBlob ${res.status}`); } await xrpc('com.atproto.repo.putRecord', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: DID, collection, rkey, record: value }), }); console.error(`put ${collection}/${rkey}`); } // ---- the query the app reads ---- await put(DEFS, QUERY, { collection: REPOS, sort: SORT, createdAt: new Date().toISOString(), }); // ---- the account's own git identities ---- // GIT_IDENTS is a comma-separated list of commit author addresses, or bare // names for a history written without one. Append `=did:plc:...` to say the // author is somebody other than this account, which is how a project account // attributes commits to the people who wrote them. // // GIT_IDENTS='me@example.com,them@example.com=did:plc:...' // // The record is the account's, not the site's: it holds for every repository // the account serves. Leaving GIT_IDENTS unset leaves whatever the account // already claims untouched. const idents = (process.env.GIT_IDENTS || '') .split(',') .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => { const [subject, did] = entry.split('=').map((part) => part.trim()); const claim = subject.includes('@') ? { email: subject } : { name: subject }; return did ? { ...claim, did } : claim; }); if (idents.length > 0) { const claimed = await fetch( `${PDS}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=${IDENTS}&rkey=self`, ); const createdAt = claimed.ok ? ((await claimed.json()).value.createdAt ?? new Date().toISOString()) : new Date().toISOString(); await put(IDENTS, 'self', { idents, createdAt, updatedAt: new Date().toISOString(), }); console.error( `claimed ${idents.length} git ${idents.length === 1 ? 'ident' : 'idents'}.`, ); } // ---- the built document ---- const distIndex = fileURLToPath(new URL('../dist/index.html', import.meta.url)); let html; try { html = readFileSync(distIndex, 'utf8'); } catch { throw new Error('No build found. Run `npm run build` first.'); } // ---- the OAuth registration ---- // A visitor signs in to review, and their authorization server reads this // app's registration from the site itself. A public client: no secret to // keep, so PKCE and DPoP carry the whole burden of proving that the browser // redeeming a code is the browser that asked for one. // // The site's origin is the account's own arrangement, and the authorization // server refuses a registration that names anywhere else. The deploy cannot // know it, so the registration names a token the server fills in with the // origin each request arrives on. One deploy then signs in on any origin the // site is served from, and an install that copies these files carries a // registration that is right for wherever it lands. const ORIGIN_TOKEN = '__PDSJS_ORIGIN__'; const clientMetadata = { client_id: `${ORIGIN_TOKEN}/client-metadata.json`, client_name: 'Repositories', client_uri: ORIGIN_TOKEN, redirect_uris: [`${ORIGIN_TOKEN}/callback`], scope: SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, }; // ---- deploy ---- const blob = await blobFor('text/html; charset=utf-8', html); const files = [ { path: 'index.html', blob, contentType: 'text/html; charset=utf-8', cacheControl: 'public, max-age=300', }, ]; files.push({ path: 'client-metadata.json', blob: await blobFor( 'application/json', `${JSON.stringify(clientMetadata, null, 2)}\n`, ), contentType: 'application/json', // The server fills the origin token in per request, so the bytes differ // by origin; a short cache holds, keyed by the origin's own URL. cacheControl: 'public, max-age=60', originTemplate: true, }); await put(SITES, SITE, { files, fallback: 'index.html', createdAt: new Date().toISOString(), }); console.error( `${SITE}: ${Math.round(Number(blob.size) / 1024)}KB, ${files.length} file${files.length === 1 ? '' : 's'}.`, ); // ---- publish it as an installable app ---- const ICON = ` `; const icon = await blobFor('image/svg+xml', ICON); const deployed = await xrpc( `com.atproto.repo.getRecord?repo=${DID}&collection=${SITES}&rkey=${SITE}`, ); await put('dev.pdsjs.app.manifest', 'git', { name: 'Git', description: 'A public forge for the git repositories in your account. Visitors browse trees, files, history and commit diffs with no sign-in, because the page reads the repository records through a query and opens the bundle blobs itself.', icon, site: { uri: deployed.uri, cid: deployed.cid }, suggestedSite: 'git', primitives: ['serve', 'query'], external: false, queries: [{ name: QUERY, collection: REPOS, sort: SORT }], // The page asks the account's own server what it serves. Both settings // override that answer, for a deployment behind a proxy that withholds the // open origin, or an owner who wants one of the two links hidden. settings: [ { key: 'httpClone', label: 'Clone over https', type: 'select', options: ['auto', 'on', 'off'], default: 'auto', }, { key: 'rawFiles', label: 'Link to raw file paths', type: 'select', options: ['auto', 'on', 'off'], default: 'auto', }, ], createdAt: new Date().toISOString(), }); console.error(`Installable at at://${DID}/dev.pdsjs.app.manifest/git`);