// How the browser writes a record: by running the CLI. // // Not a browser reimplementation of it — the actual `runCli()` from `@radial/sidecar`, with the same // argument vector `radial goal create` takes at a shell. That layer is already pure (its Node // dependencies live in `cli.ts` and `socket.ts`, never in `commands.ts`), so the record this tab // writes is constructed by the same code the daemon and the terminal construct theirs with. It is // the write half of the claim §1 makes about the fold: the UI is a second implementation of Radial, // not a client of one. // // What the browser supplies is the four seams `CliDependencies` declares: // // - `writer` the human's own OAuth session (`auth.svelte.ts`), never a shared credential // - `resolver` the space's read transport, so a `--project` argument is pinned to the CID that // was actually read rather than to whatever the URI names later // - `resolveActor` handle → DID, asked of the actor's own PDS // - `warn` soft policy (design §7) arriving as a toast rather than as stderr // // There is deliberately no `readText`: a browser has no filesystem, so `--body-file` and friends // simply do not exist here and the inline spellings are what the compose cards produce. // // And deliberately no `hasApprovingReview`. The CLI supplies it because a shell has no index and has // to go and materialize the space to answer; it can only warn *after* the record is written. This tab // is already holding the fold, so `requests.ts` answers the same question before the write — on the // button and on the card, where a warning can still change what a human does. Wiring it here would // only make the app say the same sentence a third time, to somebody who has already read it twice. // // 6.7 adds two more, and they go the other way, because they are not warnings: // // - `resolveMembership` the grant a `member remove` revokes. A removal that named a stale grant is // one the fold drops, so this must be the grant the index holds. // - `findArtifactType` the duplicate-name guard `type create` refuses on. Unlike soft policy this // is a REFUSAL, and a refusal that lives only in a form is a rule the form // owns rather than one the command does. The browser is meant to be a second // implementation of Radial, so the command's own guard is armed here too — // the registry form says it earlier and better, and this is what makes it // true rather than merely displayed. // // Both are answered out of the fold this tab is already holding rather than off the network: the // CLI's versions of them go and look because a shell has nothing to look in. // // A PRIVATE space swaps two of those seams and adds no third path (design §18, ADR §11). `writer` // becomes the `EnvelopeWriter` this tab's replica was opened with — it seals, admits through gate 1 // and commits into the local store, because there is no PDS to be the authority — and `resolver` // becomes `privateResolver`, since a strongref argument names a record that exists only here. Every // command above them is the same code: `runCli(['goal','create',…])` does not know which bus it is // on, which is the property the seam exists for. import { FetchRepoTransport, ForeignStrongRefResolver, StrongRefResolver, XrpcError, parseAtUri, resolveHandleDid, type RepoReadTransport, } from '@radial/atproto' import { privateBlobRef, type Collection, type RecordByCollection, type StoredRecord, type StrongRef, } from '@radial/core' import { privateResolver, runCli, type CliDependencies, type CommandResult, type RecordWriter, } from '@radial/sidecar' import { signedIn, type Credentials } from './auth.svelte.js' import { Identities } from './identity.js' import { altFromName, imageMarkdown, readImageFile, type PickedFile } from './images.js' import type { PrivateReplica } from './private-space.js' import { privateReplica, publishPrivateWrite, publishWritten, session, spaceTransport, } from './session.svelte.js' import { toast } from './ui.svelte.js' /** * The seams every command runs through, whichever transport is behind them. Split out because * `createSpace` is the one write with no space open, and so with no space transport to borrow. */ function dependencies( credentials: Credentials, transport: RepoReadTransport, writer: RecordWriter = credentials.writer, ): CliDependencies { return { writer, resolver: new StrongRefResolver(transport), foreignResolver: new ForeignStrongRefResolver(transport), // A handle is rebindable and a DID is not, so every actor argument is resolved at write time. // The directory asked is the writer's own PDS: a browser has no DNS, and the identity we are // most certain of is the one that just signed the request. resolveActor: async (identifier) => resolveHandleDid(identifier, { directory: await transport.resolvePds(credentials.did) }), resolveMembership: async (spaceUri, did) => { const index = session.space?.index const grant = index?.space.uri === spaceUri ? index.members.find((member) => member.active && member.did === did)?.membership : undefined if (!grant) throw new Error(`${did} holds no active membership grant in this space`) return grant }, findArtifactType: async (name, spaceUri) => { const index = session.space?.index if (index?.space.uri !== spaceUri) return undefined return index.artifactTypes.find((record) => record.value.name === name)?.uri }, warn: (text) => toast(text), } } /** * The same seams, pointed at this tab's private replica. * * Three of them change and the rest are shared on purpose: `resolveActor` still asks a PDS, because * a handle is a public fact about an identity whatever space it is used in, and `resolveMembership` * and `findArtifactType` still read `session.space.index` — which IS the private fold when a private * space is open, so they need no private version at all. * * `privateSpace` is the deliberate opening of `runCli`'s guard: it names the one space this writer * may write into, and every command checks the space it is about to touch against it. Without it a * private space is refused; with it, a public one is. That is what makes "an envelope written into * a public space would reach nobody" a refusal rather than a silent loss. */ function privateDependencies( credentials: Credentials, transport: RepoReadTransport, replica: PrivateReplica, ): CliDependencies { if (!replica.writer) { throw new Error( `This browser holds no signing key for ${credentials.did} in ${replica.spaceUri}, so it cannot ` + 'write into it. Accept the space ticket in this browser to publish one.', ) } return { ...dependencies(credentials, transport, replica.writer), resolver: privateResolver(replica.stores.records, replica.spaceUri), privateSpace: { uri: replica.spaceUri, blobs: replica.stores.blobs }, } } const OPTIMISTIC_REV = '\uffff-ui-write' /** * The one write failure a reader cannot act on as reported: their session predates the permission it * needs. * * The scope is derived from `RADIAL_WRITES`, so shipping a new collection — `closeGoal`, most * recently — narrows what an *already issued* token authorizes relative to what the app now writes. * The PDS is right to refuse, and it refuses in its own words ("Bad token scope"), which name nothing * a person can do about it. Signing out and back in re-authorizes with the current scope, so that is * what the message says. * * Detection is by the PDS's own error codes rather than by the prose: `InvalidToken` and * `InsufficientScope` are what a granular-scope refusal arrives as, and the two are checked with the * status so an unrelated 400 keeps its own message. Anything else passes through untouched — a * message the app invented over a failure it did not understand is worse than the server's. */ export function writeFailure(error: unknown): unknown { const scopeRefusal = error instanceof XrpcError && (error.status === 401 || error.status === 403) && ['InvalidToken', 'InsufficientScope', 'BadTokenScope'].includes(error.error) if (!scopeRefusal) return error return new Error( 'This sign-in was authorized before Radial asked for this permission — ' + 'sign out and sign in again to grant it. Nothing was written.', ) } /** * Keep the validated values sent by runCli beside the durable refs returned by the PDS. That pair * is everything the fold needs except the repo revision, which is observer-local and reconciled by * the next poll. */ function recordingWriter(writer: RecordWriter, did: string, records: StoredRecord[]): RecordWriter { const remember = ( collection: K, value: RecordByCollection[K], ref: StrongRef, ): StrongRef => { const parts = parseAtUri(ref.uri) records.push({ did, collection, rkey: parts.rkey, uri: ref.uri, cid: ref.cid, rev: OPTIMISTIC_REV, firstSeenAt: new Date().toISOString(), value: structuredClone(value), } as StoredRecord) return ref } return { create: async (collection, value, options) => remember(collection, value, await writer.create(collection, value, options)), ...(writer.put ? { put: async ( collection: K, uri: string, value: RecordByCollection[K], options?: { swapRecord?: string }, ) => remember(collection, value, await writer.put!(collection, uri, value, options)), } : {}), } } /** * Run one command and leave the screen showing its result. Once the PDS acknowledges each write, * fold that exact URI, CID and value locally instead of waiting for a second network round trip to * re-read it. The normal poll remains the authority for the repo revision and reconciles it later. */ export async function write(args: string[]): Promise { const credentials = signedIn() if (!credentials) throw new Error('Sign in to write.') const transport = spaceTransport() if (!transport) { throw new Error('The demo is read-only. Open a live space to make changes.') } const replica = privateReplica() if (replica) return writePrivate(args, credentials, transport, replica) const records: StoredRecord[] = [] const writer = recordingWriter(credentials.writer, credentials.did, records) let result: CommandResult try { result = await runCli(args, dependencies(credentials, transport, writer)) } catch (failure) { // A command can write several records; whatever landed before the refusal is still folded, since // the PDS acknowledged it and the poll would find it anyway. publishWritten(records) throw writeFailure(failure) } publishWritten(records) return result } /** * One command against a private space. * * There is no `recordingWriter` here, and its absence is the point: the envelope writer already put * the record in this replica's store before it returned the strongref, because the store IS the * commit (`writer.ts`). Replaying an optimistic copy over it would invent a provenance — a repo * revision, and a version with no signing device — for a record that has neither. * * A command that fails halfway leaves whatever it already committed, exactly as the public path * does, so the refold happens either way. */ async function writePrivate( args: string[], credentials: Credentials, transport: RepoReadTransport, replica: PrivateReplica, ): Promise { let result: CommandResult try { result = await runCli(args, privateDependencies(credentials, transport, replica)) } catch (failure) { await publishPrivateWrite().catch(() => {}) throw failure } // Awaited, unlike the public path's: this is where a browser that could not make the write durable // says so, and a private record that is not durable is a record that exists nowhere else (§17). await publishPrivateWrite() return result } export interface UploadedImage { /** The `at://` URI of the image record — the durable name a body refers to. */ uri: string /** `![alt](radial-image:…)`, ready to go in at the caret. */ markdown: string /** The alt text that was derived, so a composer can select it for editing. */ alt: string } /** * A file, uploaded and made durable, in the order that makes it durable. * * Validation first, and locally: a file that is the wrong type or over the ceiling is refused before * anything crosses the network, because the alternative is asking somebody's PDS to reject 40 MB of * video on our behalf. Then the blob, then the record — and the record is the point, since atproto * reclaims a blob nothing references (`images.ts`). * * The record goes through `runCli` like every other write in this app, so the value is built by the * same code the CLI builds it with and validated against the same lexicon. What does NOT go through * `runCli` is the upload: a `File` is a browser object, and `CliDependencies` is deliberately the * short list of seams a shell and a tab can both supply. * * A failure after the upload but before the record is reported as a failure, full stop. The bytes * may sit on the PDS for a while, but they are not referenced by anything, so presenting them as an * image somebody now has would be presenting something the PDS is entitled to delete. */ export async function uploadImage(file: PickedFile): Promise { const credentials = signedIn() if (!credentials) throw new Error('Sign in to add an image — it is stored in your own account.') const transport = spaceTransport() if (!transport) { throw new Error('The demo is read-only. Open a live space to make changes.') } const candidate = await readImageFile(file) const alt = altFromName(candidate.name) const replica = privateReplica() if (replica) return uploadPrivateImage(candidate, alt, credentials, transport, replica) let blob try { blob = await credentials.uploadBlob(candidate.bytes, candidate.mimeType) } catch (failure) { throw writeFailure(failure) } const records: StoredRecord[] = [] const writer = recordingWriter(credentials.writer, credentials.did, records) let result: CommandResult try { result = await runCli( ['image', 'create', '--blob', JSON.stringify(blob), '--alt', alt, '--name', candidate.name], dependencies(credentials, transport, writer), ) } catch (failure) { // The blob landed and nothing points at it. Say that plainly: an unreferenced upload is not // storage, and offering a retry is the only honest thing to do with it. throw new Error( `${message(writeFailure(failure))} The image was uploaded but not saved, so nothing was inserted — try again.`, ) } publishWritten(records) return { uri: result.primary.uri, markdown: imageMarkdown(result.primary.uri, alt), alt } } const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)) /** * The same upload against a private space: the bytes go into this replica, never to a PDS (§18). * * The order is the public path's for the public path's reason, arrived at from the other end. There * a record is written second because atproto reclaims a blob nothing references; here it is written * second because a private `image` record is refused unless the bytes it names are ones this replica * actually holds — a private blob has no PDS to be fetched from later, so a record naming bytes * nobody has is a hole in the corpus rather than a slow load. * * The blob's name is the CID of its bytes, computed here and recomputed by the store: content * addressing is the whole authentication, and it is atproto's own rule, so this record is * byte-identical to the one a public upload would have produced (ADR §15). */ async function uploadPrivateImage( candidate: { bytes: Uint8Array; mimeType: string; name: string }, alt: string, credentials: Credentials, transport: RepoReadTransport, replica: PrivateReplica, ): Promise { const blob = await privateBlobRef(candidate.bytes, candidate.mimeType) await replica.stores.blobs.put(candidate.bytes, blob.ref.$link) let result: CommandResult try { result = await runCli( ['image', 'create', '--blob', JSON.stringify(blob), '--alt', alt, '--name', candidate.name], privateDependencies(credentials, transport, replica), ) } catch (failure) { // The bytes are in this replica and nothing points at them. Unlike an orphaned PDS upload, which // the server eventually reclaims, these stay until the space is forgotten — which is why the // sentence offers the retry rather than implying the picture is somewhere it can be seen. throw new Error( `${message(failure)} The image was stored in this replica but no record names it, so nothing was inserted — try again.`, ) } await publishPrivateWrite() return { uri: result.primary.uri, markdown: imageMarkdown(result.primary.uri, alt), alt } } /** * The one write that happens outside a space, because it is the write that makes one. * * Everything else in this app targets a record inside a space it has already read, and borrows that * space's transport to pin its arguments. `space create` resolves nothing and pins nothing — it * writes a space record and seeds the registry into the author's own repo — so it brings its own * transport rather than waiting for one to exist. * * There is no refresh afterwards either: there is nothing yet to refresh. The caller opens the space * it just created, which reads the record over the wire like any other, and a space that could not * be re-read is still a space that exists. */ export async function createSpace(args: string[]): Promise { const credentials = signedIn() if (!credentials) throw new Error('Sign in to create a space — it is stored in your own account.') const transport = spaceTransport() ?? new FetchRepoTransport(new Identities().pdsResolver) try { return await runCli(args, dependencies(credentials, transport)) } catch (failure) { throw writeFailure(failure) } } /** * The same write, into a private space's replica: the genesis records of a space that does not exist * yet (design §18, ADR §23). * * It takes a replica rather than deriving one because on this path the store comes FIRST. A browser's * replica is a database named after the space URI, so the URI has to be minted and the store opened * before the space record can be sealed into it — which is why `space create --private` writes at the * rkey that URI names rather than at a fresh TID (`commands.ts`). `private-mode.ts` does that half; * this is the one line of it that is a write, kept here beside `createSpace` because both are the * same `runCli` invocation over the same argument vector and differ only in which writer refuses. * * The settle is the private path's, for §17's reason and not for tidiness: a genesis record that did * not reach durable storage is the only copy of a space, and a founder told "created" over a store * that dropped it would go on to publish a bookmark naming nothing. */ export async function writeGenesis( replica: PrivateReplica, args: string[], /** The transport the founding flow already resolved; a second one would read the same repos. */ transport?: RepoReadTransport, ): Promise { const credentials = signedIn() if (!credentials) throw new Error('Sign in to create a space — its device key is stored in your own account.') const reads = transport ?? spaceTransport() ?? new FetchRepoTransport(new Identities().pdsResolver) const result = await runCli(args, privateDependencies(credentials, reads, replica)) await replica.settle() return result }