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} +
+ + + + +
+

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. +

+
+ +
+ {/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} + +
    + + +
    +
  • + {/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" }