// Deploy the space browser to the account's own site. // // Two files go out. The page is one self-contained document, for the reason // git-ui's is: the site record names each file by path and the HTML caches for // minutes, so a build split across hashed assets would leave a visitor holding // old HTML asking for a script the record no longer names. // // The second file is client-metadata.json, which the page never reads. An // atproto OAuth client is identified by a URL serving its registration, and // this is that URL's document. It names the origin it is served from, so a // deploy has to be told where the site answers. // // Usage: PDS_URL=… PDS_DID=… PDS_APP_PASSWORD=… SPACES_ORIGIN=… \ // npm run deploy --workspace @pdsjs/spaces-ui import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { cidToString, createBlobCid } from '@pdsjs/core/repo'; import { READ_SCOPE, WRITE_SCOPE } from '../src/lib/scopes.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.SPACES_SITE || 'spaces'; const ORIGIN = (process.env.SPACES_ORIGIN || '').replace(/\/$/, ''); if (!ORIGIN.startsWith('https://')) { throw new Error( `Set SPACES_ORIGIN to the https origin this site answers on, e.g. https://${SITE}.example.com. The OAuth client document names it, and the authorization server refuses a document that names anywhere else.`, ); } const SITES = 'dev.pdsjs.site.deploy'; 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']; /** * 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(',')}}`; } /** @type {Array<{mimeType: string, bytes: Uint8Array}>} */ const pending = []; /** * The blob object a record names, hashed here rather than by the server. A * deploy that changes nothing sends none of these bytes. * @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. * @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 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 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. Both scopes are listed because the sign-in screen offers a choice // between them. const clientMetadata = { client_id: `${ORIGIN}/client-metadata.json`, client_name: 'Spaces', client_uri: ORIGIN, redirect_uris: [`${ORIGIN}/callback`], scope: `atproto ${READ_SCOPE} ${WRITE_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 page = await blobFor('text/html; charset=utf-8', html); const metadata = await blobFor( 'application/json', `${JSON.stringify(clientMetadata, null, 2)}\n`, ); await put(SITES, SITE, { files: [ { path: 'index.html', blob: page, contentType: 'text/html; charset=utf-8', cacheControl: 'public, max-age=300', }, { path: 'client-metadata.json', blob: metadata, contentType: 'application/json', // Read by an authorization server rather than by a browser, and it is // the one file whose staleness breaks sign-in outright. cacheControl: 'public, max-age=60', }, ], fallback: 'index.html', createdAt: new Date().toISOString(), }); console.error( `${SITE}: ${Math.round(Number(page.size) / 1024)}KB page, signing in as ${clientMetadata.client_id}`, );