diff --git a/apps/web/package.json b/apps/web/package.json
index 46f7644..3862a55 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -16,6 +16,7 @@
},
"dependencies": {
"@atproto/jwk-jose": "^0.2.4",
+ "@atproto/lexicon": "^0.7.10",
"@atproto/oauth-client-node": "^0.5.2"
},
"devDependencies": {
diff --git a/apps/web/src/lib/components/PageForm.svelte b/apps/web/src/lib/components/PageForm.svelte
new file mode 100644
index 0000000..c95eafc
--- /dev/null
+++ b/apps/web/src/lib/components/PageForm.svelte
@@ -0,0 +1,71 @@
+
+
+
+
+
diff --git a/apps/web/src/lib/server/admin.ts b/apps/web/src/lib/server/admin.ts
new file mode 100644
index 0000000..60f11f9
--- /dev/null
+++ b/apps/web/src/lib/server/admin.ts
@@ -0,0 +1,63 @@
+/**
+ * Session guard shared by the /admin routes: cookie → restored OAuth session →
+ * a read context pointed at the owner's PDS.
+ */
+
+import { error, redirect, type RequestEvent } from '@sveltejs/kit';
+import type { NodeOAuthClient } from '@atproto/oauth-client-node';
+import { clearSessionCookie, readSessionCookie } from './session';
+import { pdsEndpoint, resolveDidDocument } from './atmosphere/identity';
+import type { AtmosphereContext } from './atmosphere/xrpc';
+
+type OAuthSession = Awaited>;
+
+export interface AdminSession {
+ did: string;
+ session: OAuthSession;
+ /** Read context for the owner's own repo. */
+ ctx: AtmosphereContext;
+}
+
+/** Restore the signed-in session, or redirect to /login. */
+export async function requireSession(event: RequestEvent): Promise {
+ const did = await readSessionCookie(event.cookies, event.platform!.env);
+ if (!did) redirect(302, '/login');
+
+ let session: OAuthSession;
+ let didDocument: Record;
+ try {
+ session = await event.locals.oauth.restore(did);
+ didDocument = await resolveDidDocument(did);
+ } catch (err) {
+ console.error('session restore failed', err);
+ clearSessionCookie(event.cookies);
+ redirect(302, '/login');
+ }
+
+ const pds = pdsEndpoint(didDocument);
+ if (!pds) error(500, 'Your DID document lists no PDS endpoint.');
+
+ return { did, session, ctx: { pds, did } };
+}
+
+/**
+ * describeRepo doubles as a liveness check on the restored tokens, so a failure
+ * sends the user back through login rather than to an error page.
+ */
+export async function describeRepo(
+ { did, session }: AdminSession,
+ event: RequestEvent
+): Promise<{ handle: string; collections: string[] }> {
+ try {
+ const res = await session.fetchHandler(
+ `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`,
+ {}
+ );
+ if (!res.ok) throw new Error(`describeRepo returned HTTP ${res.status}`);
+ return (await res.json()) as { handle: string; collections: string[] };
+ } catch (err) {
+ console.error('describeRepo failed', err);
+ clearSessionCookie(event.cookies);
+ redirect(302, '/login');
+ }
+}
diff --git a/apps/web/src/lib/server/mooring/forms.ts b/apps/web/src/lib/server/mooring/forms.ts
new file mode 100644
index 0000000..b2bff15
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/forms.ts
@@ -0,0 +1,27 @@
+/** FormData → builder input. Values are carried back verbatim on failure. */
+
+import type { PageInput, SiteInput } from './records';
+
+function field(form: FormData, name: string): string {
+ const value = form.get(name);
+ return typeof value === 'string' ? value : '';
+}
+
+export function siteInputFromForm(form: FormData): SiteInput {
+ return {
+ name: field(form, 'name'),
+ description: field(form, 'description'),
+ // An unchecked checkbox is absent from the submission.
+ discoverable: form.get('discoverable') !== null
+ };
+}
+
+export function pageInputFromForm(form: FormData): Required {
+ return {
+ title: field(form, 'title'),
+ path: field(form, 'path'),
+ description: field(form, 'description'),
+ visibility: field(form, 'visibility'),
+ markdown: field(form, 'markdown')
+ };
+}
diff --git a/apps/web/src/lib/server/mooring/index.ts b/apps/web/src/lib/server/mooring/index.ts
new file mode 100644
index 0000000..62ff838
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/index.ts
@@ -0,0 +1,28 @@
+export {
+ DEFAULT_SITE_RKEY,
+ InvalidInput,
+ MARKDOWN_TYPE,
+ PAGE_COLLECTION,
+ SITE_COLLECTION,
+ VISIBILITIES,
+ buildEmptySiteRecord,
+ buildPageRecord,
+ buildSiteRecord,
+ findPathConflict,
+ markdownText,
+ normalizePath,
+ parseVisibility
+} from './records';
+export type { BuildContext, PageInput, SiteInput, Visibility } from './records';
+export { pageInputFromForm, siteInputFromForm } from './forms';
+export {
+ createRecord,
+ deleteRecord,
+ putRecord,
+ readPage,
+ readPagePaths,
+ readPages,
+ readSite,
+ rkeyFromUri
+} from './repo';
+export type { RepoSession, StoredRecord } from './repo';
diff --git a/apps/web/src/lib/server/mooring/records.test.ts b/apps/web/src/lib/server/mooring/records.test.ts
new file mode 100644
index 0000000..248ba3e
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/records.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from 'vitest';
+import {
+ InvalidInput,
+ MARKDOWN_TYPE,
+ PAGE_COLLECTION,
+ SITE_COLLECTION,
+ buildEmptySiteRecord,
+ buildPageRecord,
+ buildSiteRecord,
+ findPathConflict,
+ markdownText,
+ normalizePath,
+ parseVisibility
+} from './records';
+
+const NOW = '2026-08-07T12:00:00.000Z';
+const EARLIER = '2026-08-01T09:00:00.000Z';
+
+describe('normalizePath', () => {
+ it.each([
+ ['about', '/about'],
+ ['/about', '/about'],
+ [' /about ', '/about'],
+ ['/about/', '/about'],
+ ['about/team/', '/about/team'],
+ ['//about//team', '/about/team'],
+ ['/', '/'],
+ ['/co-op_2026', '/co-op_2026']
+ ])('normalizes %j to %j', (raw, expected) => {
+ expect(normalizePath(raw)).toBe(expected);
+ });
+
+ it.each([
+ [undefined, 'empty'],
+ ['', 'empty'],
+ [' ', 'whitespace only'],
+ ['/about us', 'a space'],
+ ['/about?q=1', 'a query string'],
+ ['/about#top', 'a fragment'],
+ ['/about\\team', 'a backslash'],
+ ['/../etc', 'a parent segment'],
+ ['/./here', 'a current segment'],
+ [`/${'a'.repeat(1024)}`, 'over the length limit']
+ ])('rejects %j (%s)', (raw, _reason) => {
+ expect(() => normalizePath(raw)).toThrow(InvalidInput);
+ });
+
+ it('rejects control characters', () => {
+ expect(() => normalizePath(`/about${String.fromCharCode(7)}us`)).toThrow(InvalidInput);
+ });
+});
+
+describe('parseVisibility', () => {
+ it('defaults to public', () => {
+ expect(parseVisibility(undefined)).toBe('public');
+ });
+
+ it.each(['public', 'unlisted', 'draft'])('accepts %s', (value) => {
+ expect(parseVisibility(value)).toBe(value);
+ });
+
+ it('rejects values outside the app-side set, which the open enum would allow', () => {
+ expect(() => parseVisibility('archived')).toThrow(InvalidInput);
+ });
+});
+
+describe('buildSiteRecord', () => {
+ it('builds an empty site record', () => {
+ expect(buildEmptySiteRecord(NOW)).toEqual({ $type: SITE_COLLECTION, createdAt: NOW });
+ });
+
+ it('stamps createdAt on create and sets the edited fields', () => {
+ expect(buildSiteRecord({ name: ' Wind & Wing ', description: 'Notes.' }, { now: NOW })).toEqual({
+ $type: SITE_COLLECTION,
+ name: 'Wind & Wing',
+ description: 'Notes.',
+ createdAt: NOW
+ });
+ });
+
+ it('preserves createdAt and untouched fields on update', () => {
+ const existing = {
+ $type: SITE_COLLECTION,
+ name: 'Old',
+ createdAt: EARLIER,
+ sections: [{ $type: 'page.mooring.site#heroSection', tagline: 'Moored, not anchored.' }],
+ theme: { preset: 'classic' }
+ };
+ const record = buildSiteRecord({ name: 'New' }, { now: NOW, existing });
+ expect(record).toMatchObject({
+ name: 'New',
+ createdAt: EARLIER,
+ sections: existing.sections,
+ theme: { preset: 'classic' }
+ });
+ });
+
+ it('clears a field when its input is blank', () => {
+ const existing = { $type: SITE_COLLECTION, name: 'Old', createdAt: EARLIER };
+ expect(buildSiteRecord({ name: ' ' }, { now: NOW, existing })).not.toHaveProperty('name');
+ });
+
+ it('stores only the discoverable opt-out', () => {
+ expect(buildSiteRecord({ discoverable: true }, { now: NOW })).not.toHaveProperty('discoverable');
+ expect(buildSiteRecord({ discoverable: false }, { now: NOW })).toMatchObject({
+ discoverable: false
+ });
+ });
+
+ it('rejects a name past the lexicon limit', () => {
+ expect(() => buildSiteRecord({ name: 'a'.repeat(5001) }, { now: NOW })).toThrow(InvalidInput);
+ });
+});
+
+describe('buildPageRecord', () => {
+ const input = { title: 'About', path: 'about', markdown: '# Hello' };
+
+ it('builds a page, normalizing the path and wrapping the markdown', () => {
+ expect(buildPageRecord(input, { now: NOW })).toEqual({
+ $type: PAGE_COLLECTION,
+ title: 'About',
+ path: '/about',
+ visibility: 'public',
+ content: { $type: MARKDOWN_TYPE, text: '# Hello' },
+ publishedAt: NOW,
+ createdAt: NOW
+ });
+ });
+
+ it('normalizes CRLF in the markdown body', () => {
+ const record = buildPageRecord({ ...input, markdown: 'one\r\ntwo' }, { now: NOW });
+ expect(markdownText(record)).toBe('one\ntwo');
+ });
+
+ it('drops content when the body is blank', () => {
+ expect(buildPageRecord({ ...input, markdown: ' \n ' }, { now: NOW })).not.toHaveProperty(
+ 'content'
+ );
+ });
+
+ it('omits publishedAt until the page is public', () => {
+ const draft = buildPageRecord({ ...input, visibility: 'draft' }, { now: NOW });
+ expect(draft).not.toHaveProperty('publishedAt');
+ expect(draft).not.toHaveProperty('updatedAt');
+ });
+
+ it('stamps publishedAt the first time a draft goes public', () => {
+ const draft = buildPageRecord({ ...input, visibility: 'draft' }, { now: EARLIER });
+ const published = buildPageRecord({ ...input, visibility: 'public' }, {
+ now: NOW,
+ existing: draft
+ });
+ expect(published).toMatchObject({ createdAt: EARLIER, updatedAt: NOW, publishedAt: NOW });
+ });
+
+ it('keeps the original publishedAt when a public page is unpublished and republished', () => {
+ const published = buildPageRecord(input, { now: EARLIER });
+ const hidden = buildPageRecord({ ...input, visibility: 'draft' }, {
+ now: NOW,
+ existing: published
+ });
+ expect(hidden).toMatchObject({ visibility: 'draft', publishedAt: EARLIER });
+ });
+
+ it('sets updatedAt only on update', () => {
+ const created = buildPageRecord(input, { now: EARLIER });
+ expect(created).not.toHaveProperty('updatedAt');
+ expect(buildPageRecord(input, { now: NOW, existing: created })).toMatchObject({
+ updatedAt: NOW
+ });
+ });
+
+ it('preserves fields the form does not edit', () => {
+ const existing = {
+ $type: PAGE_COLLECTION,
+ title: 'About',
+ path: '/about',
+ site: 'at://did:plc:test123/page.mooring.site/self',
+ createdAt: EARLIER
+ };
+ expect(buildPageRecord(input, { now: NOW, existing })).toMatchObject({
+ site: 'at://did:plc:test123/page.mooring.site/self'
+ });
+ });
+
+ it('requires a title', () => {
+ expect(() => buildPageRecord({ ...input, title: ' ' }, { now: NOW })).toThrow(InvalidInput);
+ });
+
+ it('rejects a bad path', () => {
+ expect(() => buildPageRecord({ ...input, path: '/a b' }, { now: NOW })).toThrow(InvalidInput);
+ });
+});
+
+describe('markdownText', () => {
+ it('returns an empty string for records with no markdown content', () => {
+ expect(markdownText(undefined)).toBe('');
+ expect(markdownText({ content: { $type: 'com.example.custom#html', html: '
' } })).toBe('');
+ });
+});
+
+describe('findPathConflict', () => {
+ const pages = [
+ { rkey: '3l1', path: '/about' },
+ { rkey: '3l2', path: '/now' }
+ ];
+
+ it('finds the rkey already holding a path', () => {
+ expect(findPathConflict(pages, '/about')).toBe('3l1');
+ });
+
+ it('ignores the page being edited', () => {
+ expect(findPathConflict(pages, '/about', '3l1')).toBeUndefined();
+ });
+
+ it('returns undefined when the path is free', () => {
+ expect(findPathConflict(pages, '/contact')).toBeUndefined();
+ });
+});
diff --git a/apps/web/src/lib/server/mooring/records.ts b/apps/web/src/lib/server/mooring/records.ts
new file mode 100644
index 0000000..8150de1
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/records.ts
@@ -0,0 +1,195 @@
+/**
+ * Builders for the page.mooring.* records the admin writes to the user's PDS.
+ * Every builder is pure and returns a record validated against the lexicon
+ * documents at the repo root.
+ */
+
+import { Lexicons, parseLexiconDoc } from '@atproto/lexicon';
+import pageLexicon from '../../../../../../lexicons/page/mooring/page.json';
+import siteLexicon from '../../../../../../lexicons/page/mooring/site.json';
+
+export const SITE_COLLECTION = 'page.mooring.site';
+export const PAGE_COLLECTION = 'page.mooring.page';
+export const MARKDOWN_TYPE = `${PAGE_COLLECTION}#markdown`;
+
+/** rkey of the owner's default site. */
+export const DEFAULT_SITE_RKEY = 'self';
+
+const lexicons = new Lexicons([parseLexiconDoc(siteLexicon), parseLexiconDoc(pageLexicon)]);
+
+/**
+ * The lexicon's visibility is an open enum, so the accepted set is enforced
+ * here instead.
+ */
+export const VISIBILITIES = ['public', 'unlisted', 'draft'] as const;
+export type Visibility = (typeof VISIBILITIES)[number];
+
+/** A page path never exceeds the lexicon's maxLength. */
+const MAX_PATH_LENGTH = 1024;
+
+/** Thrown when submitted values can't produce a valid record. */
+export class InvalidInput extends Error {}
+
+export interface SiteInput {
+ name?: string;
+ description?: string;
+ discoverable?: boolean;
+}
+
+export interface PageInput {
+ title?: string;
+ path?: string;
+ description?: string;
+ visibility?: string;
+ markdown?: string;
+}
+
+export interface BuildContext {
+ /** ISO timestamp for createdAt / updatedAt / publishedAt. */
+ now: string;
+ /** The record currently in the repo; absent when creating. */
+ existing?: Record;
+}
+
+function trimmed(value: string | undefined): string | undefined {
+ const out = value?.trim();
+ return out ? out : undefined;
+}
+
+function setOrDelete(record: Record, key: string, value: unknown): void {
+ if (value === undefined) delete record[key];
+ else record[key] = value;
+}
+
+function inheritedString(
+ existing: Record | undefined,
+ key: string
+): string | undefined {
+ const value = existing?.[key];
+ return typeof value === 'string' ? value : undefined;
+}
+
+function hasControlChar(value: string): boolean {
+ for (const char of value) {
+ const code = char.codePointAt(0)!;
+ if (code < 0x20 || code === 0x7f) return true;
+ }
+ return false;
+}
+
+function validated(nsid: string, record: Record): Record {
+ const result = lexicons.validate(nsid, record);
+ if (!result.success) throw new InvalidInput(result.error.message);
+ return result.value as Record;
+}
+
+/**
+ * Normalize a page path to a leading-slash form: `about/` and `/about` both
+ * become `/about`. Throws InvalidInput on anything that can't be a path.
+ */
+export function normalizePath(raw: string | undefined): string {
+ let path = (raw ?? '').trim();
+ if (path === '') throw new InvalidInput('A page needs a path, like /about.');
+ if (!path.startsWith('/')) path = `/${path}`;
+ path = path.replace(/\/{2,}/g, '/');
+ if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
+
+ if (path.length > MAX_PATH_LENGTH) {
+ throw new InvalidInput(`Paths are limited to ${MAX_PATH_LENGTH} characters.`);
+ }
+ if (/[\s?#\\]/.test(path) || hasControlChar(path)) {
+ throw new InvalidInput('Paths cannot contain spaces, control characters, "?", "#" or "\\".');
+ }
+ if (path.split('/').some((segment) => segment === '.' || segment === '..')) {
+ throw new InvalidInput('Paths cannot contain "." or ".." segments.');
+ }
+ return path;
+}
+
+export function parseVisibility(raw: string | undefined): Visibility {
+ const value = raw ?? 'public';
+ if (!(VISIBILITIES as readonly string[]).includes(value)) {
+ throw new InvalidInput(`Visibility must be one of: ${VISIBILITIES.join(', ')}.`);
+ }
+ return value as Visibility;
+}
+
+/**
+ * Build a page.mooring.site record. Fields the admin doesn't edit (sections,
+ * theme, icon) carry over from the existing record, which putRecord would
+ * otherwise replace.
+ */
+export function buildSiteRecord(
+ input: SiteInput,
+ { now, existing }: BuildContext
+): Record {
+ const record: Record = { ...existing, $type: SITE_COLLECTION };
+ record.createdAt = inheritedString(existing, 'createdAt') ?? now;
+ setOrDelete(record, 'name', trimmed(input.name));
+ setOrDelete(record, 'description', trimmed(input.description));
+ // Absent means discoverable; only the opt-out is stored.
+ setOrDelete(record, 'discoverable', input.discoverable === false ? false : undefined);
+ return validated(SITE_COLLECTION, record);
+}
+
+/** An empty site record — the "Create site" write. */
+export function buildEmptySiteRecord(now: string): Record {
+ return validated(SITE_COLLECTION, { $type: SITE_COLLECTION, createdAt: now });
+}
+
+/**
+ * Build a page.mooring.page record. createdAt survives an edit, updatedAt
+ * marks it, and publishedAt is stamped the first time the page is public.
+ */
+export function buildPageRecord(
+ input: PageInput,
+ { now, existing }: BuildContext
+): Record {
+ const title = trimmed(input.title);
+ if (!title) throw new InvalidInput('A page needs a title.');
+
+ const visibility = parseVisibility(input.visibility);
+ const record: Record = { ...existing, $type: PAGE_COLLECTION };
+ record.title = title;
+ record.path = normalizePath(input.path);
+ record.visibility = visibility;
+ setOrDelete(record, 'description', trimmed(input.description));
+
+ // Textareas submit CRLF; markdown is stored with plain newlines.
+ const markdown = (input.markdown ?? '').replaceAll('\r\n', '\n');
+ setOrDelete(
+ record,
+ 'content',
+ markdown.trim() ? { $type: MARKDOWN_TYPE, text: markdown } : undefined
+ );
+
+ record.createdAt = inheritedString(existing, 'createdAt') ?? now;
+ if (existing) record.updatedAt = now;
+ setOrDelete(
+ record,
+ 'publishedAt',
+ inheritedString(existing, 'publishedAt') ?? (visibility === 'public' ? now : undefined)
+ );
+
+ return validated(PAGE_COLLECTION, record);
+}
+
+/** The markdown body of a page record, for round-tripping into the edit form. */
+export function markdownText(record: Record | undefined): string {
+ const content = record?.content;
+ if (content === null || typeof content !== 'object') return '';
+ const { $type, text } = content as Record;
+ return $type === MARKDOWN_TYPE && typeof text === 'string' ? text : '';
+}
+
+/**
+ * Path uniqueness within a site is the app's job, not the lexicon's. Returns
+ * the rkey already holding the path, if any.
+ */
+export function findPathConflict(
+ pages: { rkey: string; path: string }[],
+ path: string,
+ ignoreRkey?: string
+): string | undefined {
+ return pages.find((page) => page.rkey !== ignoreRkey && page.path === path)?.rkey;
+}
diff --git a/apps/web/src/lib/server/mooring/repo.test.ts b/apps/web/src/lib/server/mooring/repo.test.ts
new file mode 100644
index 0000000..7f9f4ce
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/repo.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest';
+import type { AtmosphereContext } from '../atmosphere/xrpc';
+import {
+ createRecord,
+ deleteRecord,
+ putRecord,
+ readPagePaths,
+ readPages,
+ readSite,
+ rkeyFromUri,
+ type RepoSession
+} from './repo';
+
+const DID = 'did:plc:test123';
+
+interface Call {
+ pathname: string;
+ body: Record;
+}
+
+/** Records every procedure call and replies with a canned body. */
+function fakeSession(reply: unknown = {}): RepoSession & { calls: Call[] } {
+ const calls: Call[] = [];
+ return {
+ calls,
+ async fetchHandler(pathname, init) {
+ calls.push({ pathname, body: JSON.parse(String(init?.body)) });
+ return Response.json(reply);
+ }
+ };
+}
+
+/** Serves listRecords/getRecord for page.mooring.* out of canned data. */
+function fakeCtx(data: {
+ pages?: Record[];
+ site?: Record;
+}): AtmosphereContext {
+ const fakeFetch = (async (input: RequestInfo | URL) => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ if (url.pathname === '/xrpc/com.atproto.repo.listRecords') {
+ const records = (data.pages ?? []).map((value, i) => ({
+ uri: `at://${DID}/page.mooring.page/3l${i}`,
+ cid: `cid${i}`,
+ value
+ }));
+ return Response.json({ records });
+ }
+ if (url.pathname === '/xrpc/com.atproto.repo.getRecord') {
+ if (!data.site) return new Response('{"error":"RecordNotFound"}', { status: 400 });
+ return Response.json({
+ uri: `at://${DID}/page.mooring.site/self`,
+ cid: 'cid0',
+ value: data.site
+ });
+ }
+ return new Response('not found', { status: 404 });
+ }) as typeof fetch;
+ return { pds: 'https://pds.example', did: DID, fetch: fakeFetch };
+}
+
+describe('rkeyFromUri', () => {
+ it('takes the last segment of an AT-URI', () => {
+ expect(rkeyFromUri(`at://${DID}/page.mooring.page/3lqk2abcd`)).toBe('3lqk2abcd');
+ });
+});
+
+describe('writes', () => {
+ it('putRecord posts repo, collection, rkey and record', async () => {
+ const session = fakeSession();
+ await putRecord(session, DID, 'page.mooring.site', 'self', { $type: 'page.mooring.site' });
+ expect(session.calls).toEqual([
+ {
+ pathname: '/xrpc/com.atproto.repo.putRecord',
+ body: {
+ repo: DID,
+ collection: 'page.mooring.site',
+ rkey: 'self',
+ record: { $type: 'page.mooring.site' }
+ }
+ }
+ ]);
+ });
+
+ it('createRecord returns the rkey the PDS assigned', async () => {
+ const session = fakeSession({ uri: `at://${DID}/page.mooring.page/3lqk2abcd`, cid: 'cid0' });
+ const rkey = await createRecord(session, DID, 'page.mooring.page', { $type: 'x' });
+ expect(rkey).toBe('3lqk2abcd');
+ expect(session.calls[0].body).not.toHaveProperty('rkey');
+ });
+
+ it('createRecord fails loudly when the response carries no uri', async () => {
+ await expect(createRecord(fakeSession({}), DID, 'c', {})).rejects.toThrow('no uri');
+ });
+
+ it('deleteRecord names the record to remove', async () => {
+ const session = fakeSession();
+ await deleteRecord(session, DID, 'page.mooring.page', '3l0');
+ expect(session.calls[0]).toMatchObject({
+ pathname: '/xrpc/com.atproto.repo.deleteRecord',
+ body: { repo: DID, collection: 'page.mooring.page', rkey: '3l0' }
+ });
+ });
+
+ it('surfaces the PDS error body on failure', async () => {
+ const session: RepoSession = {
+ async fetchHandler() {
+ return new Response('{"error":"InvalidSwap"}', { status: 400 });
+ }
+ };
+ await expect(putRecord(session, DID, 'c', 'self', {})).rejects.toThrow('InvalidSwap');
+ });
+
+ it('tolerates an empty response body', async () => {
+ const session: RepoSession = {
+ async fetchHandler() {
+ return new Response('', { status: 200 });
+ }
+ };
+ await expect(deleteRecord(session, DID, 'c', 'self')).resolves.toBeUndefined();
+ });
+});
+
+describe('reads', () => {
+ it('returns undefined when there is no site record', async () => {
+ expect(await readSite(fakeCtx({}))).toBeUndefined();
+ });
+
+ it('returns the site record value', async () => {
+ const site = { $type: 'page.mooring.site', name: 'Wind & Wing', createdAt: '2026-08-07' };
+ expect(await readSite(fakeCtx({ site }))).toEqual(site);
+ });
+
+ it('orders pages by path and derives their rkeys', async () => {
+ const ctx = fakeCtx({ pages: [{ path: '/now' }, { path: '/about' }] });
+ expect(await readPages(ctx)).toMatchObject([
+ { rkey: '3l1', value: { path: '/about' } },
+ { rkey: '3l0', value: { path: '/now' } }
+ ]);
+ });
+
+ it('reduces pages to rkey/path pairs, tolerating a missing path', async () => {
+ const ctx = fakeCtx({ pages: [{ path: '/about' }, { title: 'no path' }] });
+ expect(await readPagePaths(ctx)).toEqual([
+ { rkey: '3l1', path: '' },
+ { rkey: '3l0', path: '/about' }
+ ]);
+ });
+});
diff --git a/apps/web/src/lib/server/mooring/repo.ts b/apps/web/src/lib/server/mooring/repo.ts
new file mode 100644
index 0000000..3344d29
--- /dev/null
+++ b/apps/web/src/lib/server/mooring/repo.ts
@@ -0,0 +1,119 @@
+/**
+ * Reads and writes of the owner's page.mooring.* records. Reads go through the
+ * unauthenticated XRPC helpers; writes go through the OAuth session, which
+ * signs them and resolves paths against the user's PDS.
+ */
+
+import { getRecord, listAllRecords, type AtmosphereContext } from '../atmosphere/xrpc';
+import { DEFAULT_SITE_RKEY, PAGE_COLLECTION, SITE_COLLECTION } from './records';
+
+/** The part of an OAuth session the write path needs. */
+export interface RepoSession {
+ fetchHandler(pathname: string, init?: RequestInit): Promise;
+}
+
+export interface StoredRecord {
+ rkey: string;
+ uri: string;
+ value: Record;
+}
+
+/** The rkey is the last segment of an AT-URI. */
+export function rkeyFromUri(uri: string): string {
+ return uri.slice(uri.lastIndexOf('/') + 1);
+}
+
+async function procedure(
+ session: RepoSession,
+ nsid: string,
+ body: Record
+): Promise> {
+ const res = await session.fetchHandler(`/xrpc/${nsid}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body)
+ });
+ const text = await res.text();
+ if (!res.ok) throw new Error(`${nsid} failed (HTTP ${res.status}): ${text}`);
+ if (!text) return {};
+ try {
+ return JSON.parse(text) as Record;
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Write a record at a known rkey. `validate` is left unset so the PDS checks the
+ * record against the lexicon only when it knows the lexicon.
+ */
+export async function putRecord(
+ session: RepoSession,
+ repo: string,
+ collection: string,
+ rkey: string,
+ record: Record
+): Promise {
+ await procedure(session, 'com.atproto.repo.putRecord', { repo, collection, rkey, record });
+}
+
+/** Write a new record; the PDS assigns the TID rkey. */
+export async function createRecord(
+ session: RepoSession,
+ repo: string,
+ collection: string,
+ record: Record
+): Promise {
+ const res = await procedure(session, 'com.atproto.repo.createRecord', {
+ repo,
+ collection,
+ record
+ });
+ const { uri } = res;
+ if (typeof uri !== 'string') throw new Error('createRecord returned no uri');
+ return rkeyFromUri(uri);
+}
+
+export async function deleteRecord(
+ session: RepoSession,
+ repo: string,
+ collection: string,
+ rkey: string
+): Promise {
+ await procedure(session, 'com.atproto.repo.deleteRecord', { repo, collection, rkey });
+}
+
+/** The owner's default site record; undefined when there is none. */
+export async function readSite(
+ ctx: AtmosphereContext
+): Promise | undefined> {
+ const record = await getRecord(ctx, SITE_COLLECTION, DEFAULT_SITE_RKEY);
+ return record?.value;
+}
+
+/** Every authored page, ordered by path. */
+export async function readPages(ctx: AtmosphereContext): Promise {
+ const records = await listAllRecords(ctx, PAGE_COLLECTION);
+ return records
+ .map(({ uri, value }) => ({ rkey: rkeyFromUri(uri), uri, value }))
+ .sort((a, b) => String(a.value.path ?? '').localeCompare(String(b.value.path ?? '')));
+}
+
+/** rkey → path for every authored page, for the app-side uniqueness check. */
+export async function readPagePaths(
+ ctx: AtmosphereContext
+): Promise<{ rkey: string; path: string }[]> {
+ const pages = await readPages(ctx);
+ return pages.map(({ rkey, value }) => ({
+ rkey,
+ path: typeof value.path === 'string' ? value.path : ''
+ }));
+}
+
+export async function readPage(
+ ctx: AtmosphereContext,
+ rkey: string
+): Promise | undefined> {
+ const record = await getRecord(ctx, PAGE_COLLECTION, rkey);
+ return record?.value;
+}
diff --git a/apps/web/src/routes/admin/+page.server.ts b/apps/web/src/routes/admin/+page.server.ts
index f6a8ca4..e322b86 100644
--- a/apps/web/src/routes/admin/+page.server.ts
+++ b/apps/web/src/routes/admin/+page.server.ts
@@ -1,62 +1,54 @@
-import { redirect } from '@sveltejs/kit';
+import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { clearSessionCookie, readSessionCookie } from '$lib/server/session';
+import { describeRepo, requireSession } from '$lib/server/admin';
import {
- type AtmosphereContext,
detectSources,
fetchBlueskyPosts,
fetchBlueskyProfile,
fetchDocuments,
- fetchSifaProfile,
- pdsEndpoint,
- resolveDidDocument
+ fetchSifaProfile
} from '$lib/server/atmosphere';
+import {
+ DEFAULT_SITE_RKEY,
+ InvalidInput,
+ SITE_COLLECTION,
+ buildEmptySiteRecord,
+ buildSiteRecord,
+ putRecord,
+ readSite,
+ siteInputFromForm
+} from '$lib/server/mooring';
-export const load: PageServerLoad = async ({ locals, cookies, platform }) => {
- const did = await readSessionCookie(cookies, platform!.env);
- if (!did) redirect(302, '/login');
-
- let repo: { handle: string; collections: string[] };
- try {
- const session = await locals.oauth.restore(did);
- // Smoke-test call against the user's own PDS through the OAuth session.
- const res = await session.fetchHandler(
- `/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`,
- {}
- );
- if (!res.ok) throw new Error(`describeRepo returned HTTP ${res.status}`);
- repo = (await res.json()) as { handle: string; collections: string[] };
- } catch (err) {
- console.error('session restore failed', err);
- clearSessionCookie(cookies);
- redirect(302, '/login');
- }
+/** The site record reduced to the fields the settings form edits. */
+function siteFields(record: Record) {
+ return {
+ name: typeof record.name === 'string' ? record.name : '',
+ description: typeof record.description === 'string' ? record.description : '',
+ discoverable: record.discoverable !== false
+ };
+}
+export const load: PageServerLoad = async (event) => {
+ const admin = await requireSession(event);
+ const repo = await describeRepo(admin, event);
const sources = detectSources(repo.collections);
- // Source previews are public unauthenticated reads; a failing source must
- // not take down the admin page.
- let ctx: AtmosphereContext | undefined;
- try {
- const pds = pdsEndpoint(await resolveDidDocument(did));
- if (pds) ctx = { pds, did };
- } catch (err) {
- console.error('PDS resolution failed; skipping source previews', err);
- }
- const [profile, sifaProfile, posts, documents] = ctx
- ? await Promise.all([
- fetchBlueskyProfile(ctx).catch(() => undefined),
- fetchSifaProfile(ctx).catch(() => undefined),
- fetchBlueskyPosts(ctx, { limit: 3 }).catch(() => []),
- fetchDocuments(ctx, { limit: 3 }).catch(() => [])
- ])
- : [undefined, undefined, [], []];
+ // One failing read must not take down the admin page.
+ const [site, profile, sifaProfile, posts, documents] = await Promise.all([
+ readSite(admin.ctx).catch(() => undefined),
+ fetchBlueskyProfile(admin.ctx).catch(() => undefined),
+ fetchSifaProfile(admin.ctx).catch(() => undefined),
+ fetchBlueskyPosts(admin.ctx, { limit: 3 }).catch(() => []),
+ fetchDocuments(admin.ctx, { limit: 3 }).catch(() => [])
+ ]);
return {
- did,
+ did: admin.did,
handle: repo.handle,
collections: repo.collections,
sources,
+ site: site ? siteFields(site) : undefined,
profile,
sifaProfile,
posts,
@@ -65,6 +57,32 @@ export const load: PageServerLoad = async ({ locals, cookies, platform }) => {
};
export const actions: Actions = {
+ createSite: async (event) => {
+ const admin = await requireSession(event);
+ if (await readSite(admin.ctx)) {
+ return fail(409, { message: 'You already have a site.' });
+ }
+ const record = buildEmptySiteRecord(new Date().toISOString());
+ await putRecord(admin.session, admin.did, SITE_COLLECTION, DEFAULT_SITE_RKEY, record);
+ return { message: 'Site created. Wind bless you.' };
+ },
+
+ saveSite: async (event) => {
+ const admin = await requireSession(event);
+ const input = siteInputFromForm(await event.request.formData());
+ const existing = await readSite(admin.ctx);
+ if (!existing) return fail(409, { ...input, message: 'Create your site first.' });
+
+ try {
+ const record = buildSiteRecord(input, { now: new Date().toISOString(), existing });
+ await putRecord(admin.session, admin.did, SITE_COLLECTION, DEFAULT_SITE_RKEY, record);
+ } catch (err) {
+ if (err instanceof InvalidInput) return fail(400, { ...input, message: err.message });
+ throw err;
+ }
+ return { message: 'Site settings saved.' };
+ },
+
logout: async ({ locals, cookies, platform }) => {
const did = await readSessionCookie(cookies, platform!.env);
clearSessionCookie(cookies);
diff --git a/apps/web/src/routes/admin/+page.svelte b/apps/web/src/routes/admin/+page.svelte
index 948bd4f..938ee42 100644
--- a/apps/web/src/routes/admin/+page.svelte
+++ b/apps/web/src/routes/admin/+page.svelte
@@ -1,7 +1,17 @@
@@ -20,6 +30,39 @@
{/if}
+ {#if form?.message}
+ {form.message}
+ {/if}
+
+ Your site
+ {#if site}
+
+
+ Site name
+
+
+
+ Description
+ {site.description}
+
+
+
+ Let search engines index this site
+
+ Save settings
+
+ Pages →
+ {:else}
+
+ You don't have a site yet. Creating one writes a single empty
+ page.mooring.site record to your PDS — everything else falls back to your
+ existing profile.
+
+
+ Create site
+
+ {/if}
+
Your sources
{#if data.sources.length === 0}
No site sources detected in your repo yet — your site will start from your profile alone.
@@ -65,11 +108,34 @@
padding: 0 1rem;
font-family: system-ui, sans-serif;
}
+ .settings {
+ display: grid;
+ gap: 1rem;
+ }
+ .settings label {
+ display: grid;
+ gap: 0.25rem;
+ }
+ .settings label.check {
+ grid-auto-flow: column;
+ justify-content: start;
+ align-items: center;
+ gap: 0.5rem;
+ }
+ input,
+ textarea {
+ font: inherit;
+ padding: 0.4rem;
+ }
button {
margin-top: 1rem;
padding: 0.5rem 1rem;
+ justify-self: start;
}
small {
color: #666;
}
+ .notice {
+ color: #060;
+ }
diff --git a/apps/web/src/routes/admin/pages/+page.server.ts b/apps/web/src/routes/admin/pages/+page.server.ts
new file mode 100644
index 0000000..cb44734
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/+page.server.ts
@@ -0,0 +1,31 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { requireSession } from '$lib/server/admin';
+import { PAGE_COLLECTION, deleteRecord, readPages } from '$lib/server/mooring';
+
+export const load: PageServerLoad = async (event) => {
+ const admin = await requireSession(event);
+ const pages = await readPages(admin.ctx);
+ return {
+ pages: pages.map(({ rkey, value }) => ({
+ rkey,
+ title: typeof value.title === 'string' ? value.title : '(untitled)',
+ path: typeof value.path === 'string' ? value.path : '',
+ visibility: typeof value.visibility === 'string' ? value.visibility : 'public',
+ updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined
+ }))
+ };
+};
+
+export const actions: Actions = {
+ delete: async (event) => {
+ const admin = await requireSession(event);
+ const form = await event.request.formData();
+ const rkey = form.get('rkey');
+ if (typeof rkey !== 'string' || rkey === '') {
+ return fail(400, { message: 'That delete request named no page.' });
+ }
+ await deleteRecord(admin.session, admin.did, PAGE_COLLECTION, rkey);
+ return { message: 'Page deleted.' };
+ }
+};
diff --git a/apps/web/src/routes/admin/pages/+page.svelte b/apps/web/src/routes/admin/pages/+page.svelte
new file mode 100644
index 0000000..8dd676f
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/+page.svelte
@@ -0,0 +1,70 @@
+
+
+
+ Pages — Mooring
+
+
+
+ ← Admin
+ Pages
+
+ {#if form?.message}
+ {form.message}
+ {/if}
+
+ {#if data.pages.length === 0}
+ No pages yet. An /about page is a good first one.
+ {:else}
+
+ {#each data.pages as page (page.rkey)}
+
+ {page.title}
+ {page.path}
+
+ {page.visibility}
+ {#if page.updatedAt}· updated {page.updatedAt.slice(0, 10)}{/if}
+
+
+
+ Delete
+
+
+ {/each}
+
+ {/if}
+
+ New page
+
+
+
diff --git a/apps/web/src/routes/admin/pages/[rkey]/+page.server.ts b/apps/web/src/routes/admin/pages/[rkey]/+page.server.ts
new file mode 100644
index 0000000..bb8b8b2
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/[rkey]/+page.server.ts
@@ -0,0 +1,63 @@
+import { error, fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { requireSession } from '$lib/server/admin';
+import {
+ InvalidInput,
+ PAGE_COLLECTION,
+ VISIBILITIES,
+ buildPageRecord,
+ findPathConflict,
+ markdownText,
+ normalizePath,
+ pageInputFromForm,
+ putRecord,
+ readPage,
+ readPagePaths
+} from '$lib/server/mooring';
+
+export const load: PageServerLoad = async (event) => {
+ const admin = await requireSession(event);
+ const record = await readPage(admin.ctx, event.params.rkey);
+ if (!record) error(404, 'No such page.');
+
+ return {
+ rkey: event.params.rkey,
+ visibilities: [...VISIBILITIES],
+ publishedAt: typeof record.publishedAt === 'string' ? record.publishedAt : undefined,
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : undefined,
+ values: {
+ title: typeof record.title === 'string' ? record.title : '',
+ path: typeof record.path === 'string' ? record.path : '',
+ description: typeof record.description === 'string' ? record.description : '',
+ visibility: typeof record.visibility === 'string' ? record.visibility : 'public',
+ markdown: markdownText(record)
+ }
+ };
+};
+
+export const actions: Actions = {
+ default: async (event) => {
+ const admin = await requireSession(event);
+ const { rkey } = event.params;
+ const input = pageInputFromForm(await event.request.formData());
+
+ const existing = await readPage(admin.ctx, rkey);
+ if (!existing) error(404, 'No such page.');
+
+ let saved: string;
+ try {
+ const path = normalizePath(input.path);
+ const conflict = findPathConflict(await readPagePaths(admin.ctx), path, rkey);
+ if (conflict) return fail(409, { ...input, message: `Another page already uses ${path}.` });
+
+ const record = buildPageRecord(input, { now: new Date().toISOString(), existing });
+ await putRecord(admin.session, admin.did, PAGE_COLLECTION, rkey, record);
+ saved = path;
+ } catch (err) {
+ if (err instanceof InvalidInput) return fail(400, { ...input, message: err.message });
+ throw err;
+ }
+ // Echo back the normalized path so the form shows what was stored.
+ return { ...input, path: saved, message: 'Page saved.' };
+ }
+};
diff --git a/apps/web/src/routes/admin/pages/[rkey]/+page.svelte b/apps/web/src/routes/admin/pages/[rkey]/+page.svelte
new file mode 100644
index 0000000..35e4673
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/[rkey]/+page.svelte
@@ -0,0 +1,55 @@
+
+
+
+ {data.values.title || 'Edit page'} — Mooring
+
+
+
+ ← All pages
+ Edit page
+
+ {data.rkey}
+ {#if data.publishedAt}· published {data.publishedAt.slice(0, 10)}{/if}
+ {#if data.updatedAt}· updated {data.updatedAt.slice(0, 10)}{/if}
+
+
+ {#if form?.message}
+ {form.message}
+ {/if}
+
+
+
+
+
diff --git a/apps/web/src/routes/admin/pages/new/+page.server.ts b/apps/web/src/routes/admin/pages/new/+page.server.ts
new file mode 100644
index 0000000..f2fab8f
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/new/+page.server.ts
@@ -0,0 +1,40 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { requireSession } from '$lib/server/admin';
+import {
+ InvalidInput,
+ PAGE_COLLECTION,
+ VISIBILITIES,
+ buildPageRecord,
+ createRecord,
+ findPathConflict,
+ normalizePath,
+ pageInputFromForm,
+ readPagePaths
+} from '$lib/server/mooring';
+
+export const load: PageServerLoad = async (event) => {
+ await requireSession(event);
+ return { visibilities: [...VISIBILITIES] };
+};
+
+export const actions: Actions = {
+ default: async (event) => {
+ const admin = await requireSession(event);
+ const input = pageInputFromForm(await event.request.formData());
+
+ let rkey: string;
+ try {
+ const path = normalizePath(input.path);
+ const conflict = findPathConflict(await readPagePaths(admin.ctx), path);
+ if (conflict) return fail(409, { ...input, message: `Another page already uses ${path}.` });
+
+ const record = buildPageRecord(input, { now: new Date().toISOString() });
+ rkey = await createRecord(admin.session, admin.did, PAGE_COLLECTION, record);
+ } catch (err) {
+ if (err instanceof InvalidInput) return fail(400, { ...input, message: err.message });
+ throw err;
+ }
+ redirect(303, `/admin/pages/${rkey}`);
+ }
+};
diff --git a/apps/web/src/routes/admin/pages/new/+page.svelte b/apps/web/src/routes/admin/pages/new/+page.svelte
new file mode 100644
index 0000000..cfdabb1
--- /dev/null
+++ b/apps/web/src/routes/admin/pages/new/+page.svelte
@@ -0,0 +1,41 @@
+
+
+
+ New page — Mooring
+
+
+
+ ← All pages
+ New page
+
+ {#if form?.message}
+ {form.message}
+ {/if}
+
+
+
+
+
diff --git a/docs/NEXT.md b/docs/NEXT.md
index a931296..8ff11cc 100644
--- a/docs/NEXT.md
+++ b/docs/NEXT.md
@@ -2,7 +2,7 @@
The flight plan. Each item carries enough context to start cold; update this file whenever an item lands (move it to "Done") or a new one is queued. Decisions made while working an item still go through `decisions/` as usual.
-_Last updated: 2026-08-07 (adapters merged, PR #9; next up: site/page authoring — see the worked plan under item 1)._
+_Last updated: 2026-08-07 (site/page authoring landed; next up: the deletion-honoring read cache, then the professional-presence theme)._
## Where things stand
@@ -12,17 +12,10 @@ Feasibility is done and the verdict was **build it** (see `FEASIBILITY.md`). All
### 1. Continue v1 (per ADR 0008 — the scope is ratified; don't re-scope)
-Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page. Remaining, roughly in dependency order:
+Done so far: OAuth login (loopback dev client; hosted-client path ready pending a real key + deploy) with D1-backed state/session stores; lexicon convention tests; **read-only adapters** for Bluesky (profile + posts, replies filtered), standard.site (documents/publications, `pub.leaflet.document` fallback only when no standard.site docs exist), and sifa (profile/positions/education/skills, defensively parsed) — fetch-injected modules in `apps/web/src/lib/server/atmosphere/` with unit tests, plus `detectSources` (drives the ADR 0012 default section order) and a source-overview admin page; **site/page authoring** — record builders, PDS writes through the OAuth session, and the `/admin` + `/admin/pages` CRUD routes. Remaining, roughly in dependency order:
- **Thin deletion-honoring cache** over the direct PDS reads (ADR 0010 §4) — adapters currently read live per request, fine until traffic exists.
-- **Site/page authoring — NEXT UP; no code written yet, plan worked out 2026-08-07:**
- - *Create is explicit, not automatic:* the admin shows a "Create site" button that writes the empty `page.mooring.site` record at rkey `self` (empty record = working site, ADR 0012); we deliberately don't auto-write on first login.
- - *Record builders* in `apps/web/src/lib/server/mooring/records.ts`: pure functions building site/page records, validated with `@atproto/lexicon` (add as a `web` dependency) against the lexicon JSONs statically imported from the repo-root `lexicons/` (Vite handles the JSON import; `resolveJsonModule` should already be on via the generated tsconfig). `normalizePath` enforces a leading slash and rejects junk; `createdAt` is preserved on update; `updatedAt` set on every update; `publishedAt` set the first time visibility is `public`; restrict `visibility` to the three known values app-side (the lexicon's `knownValues` is an open enum and won't reject others).
- - *Writes* go through the OAuth session (`session.fetchHandler` POST): `com.atproto.repo.putRecord` for the site (rkey `self`) and page edits, `createRecord` for new pages (PDS assigns the TID rkey), `deleteRecord` for deletes. *Reads* reuse the public `atmosphere` xrpc helpers.
- - *Admin routes:* site-settings form (name, description, discoverable) on `/admin`; `/admin/pages` list with delete; `/admin/pages/new` create form (title, path, visibility, markdown textarea); `/admin/pages/[rkey]` edit. Add a shared session-guard helper to stop repeating the cookie→restore dance per route.
- - *Tests:* unit tests for the builders (validation, path normalization, createdAt/publishedAt logic).
- - Mind the AGENTS.md "Code comments" rule throughout.
-- **The one professional-presence theme** (renderer kept separable per ADR 0005).
+- **The one professional-presence theme** (renderer kept separable per ADR 0005) — the first consumer of the authored `page.mooring.page` records; nothing renders them publicly yet.
- **Hosting wiring**: real D1 database + first `wrangler deploy`, wildcard subdomains, then Cloudflare for SaaS custom domains with `_mooring` DNS-TXT DID verification (ADR 0011).
- **First real deployed login** is the acceptance test for the OAuth stack (`callback()` wasn't exercisable in the spike); test against a self-hosted PDS too, and set a `SESSION_SECRET`.
- **Before the first real user records ship**: lexicon.community sanity-check post, then publish the lexicons on-network (`com.atproto.lexicon.schema`, rkey = NSID, `_lexicon.mooring.page` DNS TXT) and switch `OAUTH_SCOPE` to `atproto include:page.mooring.authSite blob:image/*`. Until published, the drafts stay freely editable.
@@ -37,6 +30,11 @@ Done so far: OAuth login (loopback dev client; hosted-client path ready pending
## Done
+- 2026-08-07 — **Site/page authoring landed**: the first write path. `apps/web/src/lib/server/mooring/` holds pure record builders (`records.ts`, validated with `@atproto/lexicon` — new `web` dependency — against the repo-root lexicon JSONs, statically imported so nothing touches a filesystem at runtime), FormData glue (`forms.ts`), and the PDS read/write calls (`repo.ts`). Admin routes: site-settings form on `/admin`, `/admin/pages` list + delete, `/admin/pages/new`, `/admin/pages/[rkey]`; a shared `requireSession` guard in `lib/server/admin.ts` replaced the per-route cookie→restore dance. 24 new unit tests (68 in `web`). Decisions made while building, all consistent with ADR 0012:
+ - **Create is explicit.** A "Create site" button writes the empty `page.mooring.site` at rkey `self`; first login writes nothing to the user's repo.
+ - **Builders merge onto the existing record**, because `putRecord` replaces it wholesale — otherwise saving the site name would wipe `sections`/`theme`/`icon`, which no form edits yet.
+ - **Three constraints are enforced app-side**, since the lexicon can't: `visibility` is restricted to the three `knownValues` (an open enum accepts anything), path uniqueness within a site is checked before each write, and `normalizePath` forces a leading slash and rejects spaces, control characters, `?`, `#`, `\` and `.`/`..` segments.
+ - **`validate` is left unset on the write calls**, so a PDS checks records against the lexicons once they're published on-network and skips them until then.
- 2026-08-07 — **Read-only adapters merged** (PR #9): Bluesky / standard.site (+`pub.leaflet.document` fallback) / sifa as fetch-injected modules in `apps/web/src/lib/server/atmosphere/`, shared XRPC + DID→PDS identity helpers (OAuth compat layer now delegates to the latter), `detectSources`, source-overview admin page, 13 unit tests against a fake PDS. Review follow-up set the **code-comment convention** (concise statements of fact; no ADR/research citations, nothing temporally bound, no editorializing) — recorded in AGENTS.md § Code comments and applied across the tree.
- 2026-08-03 — v1 build started. **OAuth-on-Workers spike passed** (ADR 0011 §6): `@atproto/oauth-client-node@0.5.2` runs under `nodejs_compat` with a 3-shim compat layer via documented options (HTTP handle resolver, custom DID resolver, redirect-'error' fetch emulation); @atcute fallback not needed. Blob-scope question answered: site-icon upload needs `blob:image/*` requested separately (can't live in a permission set). Research: `research/2026-08-03-oauth-workers-spike.md`. **Scaffold landed**: npm workspaces; `apps/web` (SvelteKit/Svelte 5 + adapter-cloudflare, wrangler + D1 migrations, OAuth login/callback/admin routes, HMAC-signed session cookie) smoke-tested on workerd — login POST returns a real bsky.social authorization URL with state in local D1; `packages/lexicons` (17 convention tests: NSID↔path, createdAt, 10× maxLength, permission-set integrity, sample-record validation incl. open-union + negative cases); GitHub Actions CI (check/test/build).
- 2026-08-03 — Schemas decided: ADR 0012 **Accepted**: two v1 record types — `page.mooring.site` (`key: any`, default site at rkey `self`, layout = embedded ordered `sections` open union, theme mirroring standard.site's color roles, empty record = working site) and `page.mooring.page` (`key: tid`, open `content` union with markdown member, rendering-only `visibility`), plus the `authSite` permission set; `.source` reserved, unshipped. Drafts in `lexicons/page/mooring/`, machine-validated with `@atproto/lexicon`. Evolution policy: add-optional-only. Prior art: `research/2026-07-31-lexicon-prior-art.md` (lexicon.community had nothing site-shaped). (PR #6 + ratification follow-up)
diff --git a/package-lock.json b/package-lock.json
index f9842a9..9d2f9fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"version": "0.0.1",
"dependencies": {
"@atproto/jwk-jose": "^0.2.4",
+ "@atproto/lexicon": "^0.7.10",
"@atproto/oauth-client-node": "^0.5.2"
},
"devDependencies": {
@@ -511,7 +512,6 @@
"os": [
"aix"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -529,7 +529,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -547,7 +546,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -565,7 +563,6 @@
"os": [
"android"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -583,7 +580,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -601,7 +597,6 @@
"os": [
"darwin"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -619,7 +614,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -637,7 +631,6 @@
"os": [
"freebsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -655,7 +648,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -673,7 +665,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -691,7 +682,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -709,7 +699,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -727,7 +716,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -745,7 +733,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -763,7 +750,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -781,7 +767,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -799,7 +784,6 @@
"os": [
"linux"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -817,7 +801,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -835,7 +818,6 @@
"os": [
"netbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -853,7 +835,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -871,7 +852,6 @@
"os": [
"openbsd"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -889,7 +869,6 @@
"os": [
"openharmony"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -907,7 +886,6 @@
"os": [
"sunos"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -925,7 +903,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -943,7 +920,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -961,7 +937,6 @@
"os": [
"win32"
],
- "peer": true,
"engines": {
"node": ">=18"
}