diff --git a/.changeset/micropub-contacts.md b/.changeset/micropub-contacts.md new file mode 100644 index 00000000..61f93d70 --- /dev/null +++ b/.changeset/micropub-contacts.md @@ -0,0 +1,6 @@ +--- +"@dwk/micropub": minor +--- + +Add the opt-in proposed Contacts (`q=contact`) extension with a private, +injectable h-card store, lifecycle actions, filtering, and pagination. diff --git a/packages/micropub/README.md b/packages/micropub/README.md index d31ab0b6..b866e2b5 100644 --- a/packages/micropub/README.md +++ b/packages/micropub/README.md @@ -14,7 +14,7 @@ in D1, and backs its media endpoint with R2. ## Usage ```ts -import { createMicropub } from "@dwk/micropub"; +import { createMicropub, createMicropubContactStore } from "@dwk/micropub"; const micropub = createMicropub({ baseUrl: "https://example.com", @@ -29,6 +29,7 @@ const micropub = createMicropub({ // serving/access-control layer; Micropub itself does not enforce them. extensions: { proposed: true }, audiences: [{ uid: "family", name: "Family" }], + contacts: createMicropubContactStore, }); export default { @@ -56,6 +57,8 @@ The handler fails loudly at startup if any of these are missing: - **Media endpoint**: streams uploads to R2 and serves them back. - **Queries**: `q=config`, `q=source` (with a `properties[]` filter), and `q=syndicate-to`. +- **Opt-in Contacts** (`q=contact`): a private h-card address book with + filtered, paginated listing and create/update/delete lifecycle actions. - **Opt-in proposed metadata**: named private-post `audience` values and `location-visibility` (`public`, `private`, or textual-only `text`). They are persisted and returned by `q=source`; the site or WAC layer enforces diff --git a/packages/micropub/src/config.ts b/packages/micropub/src/config.ts index dd2c658a..a34f4ebd 100644 --- a/packages/micropub/src/config.ts +++ b/packages/micropub/src/config.ts @@ -10,6 +10,10 @@ import { canonicalizeProfileUrl } from "@dwk/indieauth"; import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log"; import type { FediverseSyndicationConfig } from "./fediverse.js"; +import type { + MicropubContactStore, + MicropubContactStoreEnv, +} from "./contacts.js"; import type { Mf2Object, MicropubCommands } from "./mf2.js"; /** @@ -80,6 +84,11 @@ export interface SyndicationTarget { export type SyndicationTargetsProvider = () => Promise | readonly SyndicationTarget[]; +/** Builds a request-bound Contacts store from the composed Worker bindings. */ +export type MicropubContactStoreProvider = ( + env: MicropubContactStoreEnv, +) => MicropubContactStore; + /** * Derive the canonical URL of a newly created post from its microformats2 * object and the parsed `mp-*` commands. Returning a relative path is allowed; @@ -124,6 +133,11 @@ export interface MicropubConfig { * or enforce access control for any audience. */ readonly audiences?: readonly AudienceConfig[]; + /** + * Private h-card store for the proposed Contacts extension. Contacts are + * advertised only when this is set and the proposed group is enabled. + */ + readonly contacts?: MicropubContactStore | MicropubContactStoreProvider; /** * Post types advertised as `post-types` in `q=config` (the stable Supported * Vocabulary extension). Omitted from the response when unset, or when the @@ -194,6 +208,8 @@ export interface ResolvedConfig { readonly audiences: readonly AudienceConfig[]; /** Precomputed membership set for validating proposed audience IDs. */ readonly audienceIds: ReadonlySet; + /** Normalized Contacts store provider, when the extension is configured. */ + readonly contacts?: MicropubContactStoreProvider; readonly postTypes?: readonly PostTypeConfig[]; /** Normalized to an async provider regardless of the configured shape. */ readonly syndicateTo: () => Promise; @@ -256,6 +272,13 @@ function normalizeSyndicateTo( return async () => syndicateTo; } +function normalizeContactStore( + contacts: MicropubContactStore | MicropubContactStoreProvider | undefined, +): MicropubContactStoreProvider | undefined { + if (contacts === undefined) return undefined; + return typeof contacts === "function" ? contacts : () => contacts; +} + function pathOf(absoluteUrl: string, label: string): string { try { return new URL(absoluteUrl).pathname; @@ -285,6 +308,7 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig { const micropubEndpoint = config.micropubEndpoint ?? `${origin}/micropub`; const mediaEndpoint = config.mediaEndpoint ?? `${origin}/media`; + const contactStore = normalizeContactStore(config.contacts); const audiences = config.audiences ?? []; const audienceIds = new Set(); for (const audience of audiences) { @@ -319,6 +343,7 @@ export function resolveConfig(config: MicropubConfig): ResolvedConfig { }, audiences, audienceIds, + ...(contactStore ? { contacts: contactStore } : {}), ...(config.postTypes ? { postTypes: config.postTypes } : {}), syndicateTo: normalizeSyndicateTo(config.syndicateTo), ...(config.fediverse ? { fediverse: config.fediverse } : {}), diff --git a/packages/micropub/src/contacts.test.ts b/packages/micropub/src/contacts.test.ts new file mode 100644 index 00000000..78b1a257 --- /dev/null +++ b/packages/micropub/src/contacts.test.ts @@ -0,0 +1,38 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import { + canonicalContactUrl, + contactWrite, + createMicropubContactStore, + type MicropubContactStoreEnv, +} from "./contacts.js"; + +const harness = env as unknown as MicropubContactStoreEnv; + +describe("Micropub contact store", () => { + it("canonicalizes, searches nested unknown properties, and orders deterministically", async () => { + expect(canonicalContactUrl({ url: ["HTTPS://EXAMPLE.COM:443"] })).toBe( + "https://example.com/", + ); + const store = createMicropubContactStore(harness); + const suffix = crypto.randomUUID(); + const beta = contactWrite( + `beta-${suffix}`, + { name: ["Beta"], note: [{ value: "Needle" }] }, + 1, + ); + const alpha = contactWrite(`alpha-${suffix}`, { name: ["Alpha"] }, 1); + expect(await store.create(beta)).toBe("created"); + expect(await store.create(alpha)).toBe("created"); + expect( + (await store.list({ filter: "needle", limit: 10, offset: 0 })).map( + (contact) => contact.id, + ), + ).toEqual([beta.id]); + const ours = (await store.list({ limit: 10, offset: 0 })).filter( + (contact) => contact.id.endsWith(suffix), + ); + expect(ours.map((contact) => contact.id)).toEqual([alpha.id, beta.id]); + }); +}); diff --git a/packages/micropub/src/contacts.ts b/packages/micropub/src/contacts.ts new file mode 100644 index 00000000..0bfaee32 --- /dev/null +++ b/packages/micropub/src/contacts.ts @@ -0,0 +1,290 @@ +/** + * Private h-card contacts for the proposed Micropub `q=contact` extension. + * + * Contacts have their own injected store seam rather than sharing the posts + * table. The D1 implementation is strongly consistent and stores the source + * properties unchanged; derived search and sort keys make the bounded + * autocomplete query safe without assigning meaning to unknown h-card fields. + * + * @see spec/packages/micropub.md#proposed-contacts-qcontact + */ + +export const INTERNAL_CONTACT_URL = "_internal_url"; + +export interface ContactRecord { + readonly id: string; + readonly properties: Record; + readonly identityUrl: string | null; + readonly createdAt: number; + readonly updatedAt: number; +} + +export interface ContactWrite { + readonly id: string; + readonly properties: Record; + readonly identityUrl: string | null; + readonly sortKey: string; + readonly searchText: string; + readonly now: number; +} + +export interface ContactListQuery { + readonly filter?: string; + readonly limit: number; + readonly offset: number; +} + +export interface MicropubContactStore { + init(): Promise; + create(contact: ContactWrite): Promise<"created" | "conflict">; + get(id: string): Promise; + update(contact: ContactWrite): Promise<"updated" | "not_found" | "conflict">; + delete(id: string): Promise; + list(query: ContactListQuery): Promise; +} + +export interface MicropubContactStoreEnv { + readonly MICROPUB_DB: D1Database; +} + +const SCHEMA = `CREATE TABLE IF NOT EXISTS micropub_contacts ( + id TEXT PRIMARY KEY, + properties TEXT NOT NULL, + identity_url TEXT UNIQUE, + sort_key TEXT NOT NULL, + search_text TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +)`; + +const INDEXES = [ + "CREATE INDEX IF NOT EXISTS micropub_contacts_order ON micropub_contacts (sort_key, id)", +]; + +interface ContactRow { + readonly id: string; + readonly properties: string; + readonly identity_url: string | null; + readonly created_at: number; + readonly updated_at: number; +} + +function rowToContact(row: ContactRow): ContactRecord { + return { + id: row.id, + properties: JSON.parse(row.properties) as Record, + identityUrl: row.identity_url, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function createMicropubContactStore( + env: MicropubContactStoreEnv, +): MicropubContactStore { + if (!env.MICROPUB_DB) { + throw new Error("@dwk/micropub: missing required D1 binding `MICROPUB_DB`"); + } + const db = env.MICROPUB_DB; + let ready: Promise | null = null; + const ensureSchema = (): Promise => { + ready ??= db + .prepare(SCHEMA) + .run() + .then(async () => { + await db.batch(INDEXES.map((sql) => db.prepare(sql))); + }) + .catch((err: unknown) => { + ready = null; + throw err; + }); + return ready; + }; + const get = async (id: string): Promise => { + await ensureSchema(); + const row = await db + .prepare( + `SELECT id, properties, identity_url, created_at, updated_at + FROM micropub_contacts WHERE id = ?`, + ) + .bind(id) + .first(); + return row ? rowToContact(row) : null; + }; + + return { + async init() { + await ensureSchema(); + }, + async create(contact) { + await ensureSchema(); + const result = await db + .prepare( + `INSERT OR IGNORE INTO micropub_contacts + (id, properties, identity_url, sort_key, search_text, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + contact.id, + JSON.stringify(contact.properties), + contact.identityUrl, + contact.sortKey, + contact.searchText, + contact.now, + contact.now, + ) + .run(); + return result.meta.changes > 0 ? "created" : "conflict"; + }, + get, + async update(contact) { + await ensureSchema(); + const result = await db + .prepare( + `UPDATE OR IGNORE micropub_contacts + SET properties = ?, identity_url = ?, sort_key = ?, search_text = ?, + updated_at = ? + WHERE id = ?`, + ) + .bind( + JSON.stringify(contact.properties), + contact.identityUrl, + contact.sortKey, + contact.searchText, + contact.now, + contact.id, + ) + .run(); + if (result.meta.changes > 0) return "updated"; + return (await get(contact.id)) === null ? "not_found" : "conflict"; + }, + async delete(id) { + await ensureSchema(); + const result = await db + .prepare("DELETE FROM micropub_contacts WHERE id = ?") + .bind(id) + .run(); + return result.meta.changes > 0; + }, + async list({ filter, limit, offset }) { + await ensureSchema(); + const { results } = await db + .prepare( + `SELECT id, properties, identity_url, created_at, updated_at + FROM micropub_contacts + WHERE ? IS NULL OR instr(search_text, ?) > 0 + ORDER BY sort_key ASC, id ASC + LIMIT ? OFFSET ?`, + ) + .bind(filter ?? null, filter ?? "", limit, offset) + .all(); + return results.map(rowToContact); + }, + }; +} + +function stringsWithin(value: unknown, seen: Set): string[] { + if (typeof value === "string") return [value]; + if (typeof value !== "object" || value === null || seen.has(value)) return []; + seen.add(value); + const values = Array.isArray(value) ? value : Object.values(value); + return values.flatMap((child) => stringsWithin(child, seen)); +} + +function firstNonEmptyString( + values: readonly unknown[] | undefined, +): string | undefined { + return values?.find( + (value): value is string => typeof value === "string" && value.length > 0, + ); +} + +export function normalizeContactText(value: string): string { + return value.normalize("NFKC").toLowerCase(); +} + +export function canonicalContactUrl( + properties: Readonly>, +): string | null { + const first = properties.url?.[0]; + if (first === undefined) return null; + if (typeof first !== "string") { + throw new ContactValidationError( + "the first `url` value must be an absolute http or https URL", + ); + } + let url: URL; + try { + url = new URL(first); + } catch { + throw new ContactValidationError( + "the first `url` value must be an absolute http or https URL", + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new ContactValidationError( + "the first `url` value must be an absolute http or https URL", + ); + } + if (url.pathname === "") url.pathname = "/"; + return url.toString(); +} + +export class ContactValidationError extends Error {} + +export function contactWrite( + id: string, + properties: Readonly>, + now: number, +): ContactWrite { + if (INTERNAL_CONTACT_URL in properties) { + throw new ContactValidationError( + `\`${INTERNAL_CONTACT_URL}\` is response metadata and cannot be stored`, + ); + } + const copied: Record = {}; + for (const [key, values] of Object.entries(properties)) { + if (values.length === 0) { + throw new ContactValidationError( + `\`${key}\` must not be an empty property array`, + ); + } + copied[key] = [...values]; + } + const identity = ["name", "nickname", "url", "email"] + .map((key) => firstNonEmptyString(copied[key])) + .find((value) => value !== undefined); + if (identity === undefined) { + throw new ContactValidationError( + "a contact needs a non-empty `name`, `nickname`, `url`, or `email` value", + ); + } + const identityUrl = canonicalContactUrl(copied); + const strings = stringsWithin(copied, new Set()); + const sortText = + firstNonEmptyString(copied.name) ?? + firstNonEmptyString(copied.nickname) ?? + firstNonEmptyString(copied.url) ?? + firstNonEmptyString(copied.email) ?? + id; + return { + id, + properties: copied, + identityUrl, + sortKey: normalizeContactText(sortText), + searchText: strings.map(normalizeContactText).join("\u0000"), + now, + }; +} + +export function contactView( + record: ContactRecord, + internalUrl: string, +): Record { + const view: Record = {}; + for (const [key, values] of Object.entries(record.properties)) { + view[key] = values.length === 1 ? values[0] : [...values]; + } + view[INTERNAL_CONTACT_URL] = internalUrl; + return view; +} diff --git a/packages/micropub/src/handler.ts b/packages/micropub/src/handler.ts index f7678248..7ff16f30 100644 --- a/packages/micropub/src/handler.ts +++ b/packages/micropub/src/handler.ts @@ -44,6 +44,12 @@ import { type MicropubStoreEnv, type PostRecord, } from "./store.js"; +import { + ContactValidationError, + contactView, + contactWrite, + type MicropubContactStore, +} from "./contacts.js"; import { authorize, tokenFromHeader, type AuthEnv } from "./auth.js"; import { syndicateEntry } from "./fediverse.js"; @@ -178,6 +184,87 @@ function parseLimitParam(raw: string | null): number | undefined { return Math.min(n, MAX_CATEGORY_LIMIT); } +function contactsEnabled(config: ResolvedConfig): boolean { + return config.extensions.proposed && config.contacts !== undefined; +} + +function contactInternalUrl(config: ResolvedConfig, id: string): string { + return `${config.micropubEndpoint.replace(/\/$/, "")}/contacts/${encodeURIComponent(id)}`; +} + +function contactIdFromUrl(raw: string, config: ResolvedConfig): string | null { + try { + const target = new URL(raw); + const endpoint = new URL(config.micropubEndpoint); + const prefix = `${endpoint.pathname.replace(/\/$/, "")}/contacts/`; + if ( + target.origin !== endpoint.origin || + !target.pathname.startsWith(prefix) || + target.search || + target.hash + ) + return null; + const encoded = target.pathname.slice(prefix.length); + return encoded && !encoded.includes("/") + ? decodeURIComponent(encoded) + : null; + } catch { + return null; + } +} + +function parseContactPage(params: URLSearchParams): { + filter?: string; + limit: number; + offset: number; +} { + const allowed = new Set(["q", "filter", "search", "limit", "offset"]); + for (const key of params.keys()) { + if (!allowed.has(key)) + throw new ContactValidationError( + `unsupported contact query parameter \`${key}\``, + ); + } + const one = (name: string): string | null => { + const values = params.getAll(name); + if (values.length > 1) + throw new ContactValidationError(`\`${name}\` must not be repeated`); + return values[0] ?? null; + }; + if (params.getAll("q").length !== 1) + throw new ContactValidationError("`q` must be supplied exactly once"); + const filter = one("filter"); + const search = one("search"); + if (filter !== null && search !== null) + throw new ContactValidationError( + "`filter` and `search` cannot be combined", + ); + const limit = one("limit"); + const offset = one("offset"); + if ( + limit !== null && + (!/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 100) + ) { + throw new ContactValidationError( + "`limit` must be a base-10 integer from 1 to 100", + ); + } + if ( + offset !== null && + (!/^\d+$/.test(offset) || !Number.isSafeInteger(Number(offset))) + ) { + throw new ContactValidationError( + "`offset` must be a non-negative base-10 integer", + ); + } + const selected = filter ?? search; + return { + ...(selected ? { filter: selected.normalize("NFKC").toLowerCase() } : {}), + limit: limit === null ? 100 : Number(limit), + offset: offset === null ? 0 : Number(offset), + }; +} + // --- Body parsing ----------------------------------------------------------- /** @@ -406,6 +493,25 @@ async function handleMediaGet( // --- Queries ---------------------------------------------------------------- +async function handleContactQuery( + params: URLSearchParams, + config: ResolvedConfig, + store: MicropubContactStore, +): Promise { + try { + const contacts = await store.list(parseContactPage(params)); + return json({ + contacts: contacts.map((contact) => + contactView(contact, contactInternalUrl(config, contact.id)), + ), + }); + } catch (err) { + if (err instanceof ContactValidationError) + return error("invalid_request", err.message, 400); + throw err; + } +} + /** Handle `GET` to the Micropub endpoint: `q=config`/`source`/`syndicate-to`. */ async function handleQuery( request: Request, @@ -439,6 +545,7 @@ async function handleQuery( // only when configured and its group is on. const supportedQueries = ["source", "config", "syndicate-to"]; if (config.extensions.stable) supportedQueries.push("category"); + if (contactsEnabled(config)) supportedQueries.push("contact"); return json({ "media-endpoint": config.mediaEndpoint, "syndicate-to": await config.syndicateTo(), @@ -481,6 +588,9 @@ async function handleQuery( }); return json({ categories }); } + if (q === "contact" && config.extensions.proposed && config.contacts) { + return handleContactQuery(params, config, config.contacts(env)); + } if (q === "source") { const filter = [ ...params.getAll("properties[]"), @@ -591,6 +701,138 @@ async function parseRequest(request: Request): Promise { return parseFormBody(await readForm(request)); } +function contactResponse(location: string): Response { + return new Response(JSON.stringify({ _internal_url: location }), { + status: 201, + headers: { "content-type": "application/json", location, ...CORS_HEADERS }, + }); +} + +function contactUpdateHasInternalUrl( + ops: ReturnType, +): boolean { + return ( + ops.replace?._internal_url !== undefined || + ops.add?._internal_url !== undefined || + (Array.isArray(ops.delete) + ? ops.delete.includes("_internal_url") + : ops.delete?._internal_url !== undefined) + ); +} + +async function handleContactAction( + parsed: ParsedBody, + rawJson: unknown, + isJson: boolean, + config: ResolvedConfig, + store: MicropubContactStore, +): Promise { + const action = parsed.action ?? "create"; + if (action === "create") { + const mf2 = + !isJson && parsed.url + ? { + type: parsed.mf2.type, + properties: { ...parsed.mf2.properties, url: [parsed.url] }, + } + : parsed.mf2; + if (!mf2.type.includes("h-card")) { + return error( + "invalid_request", + "a contact create must include the `h-card` microformats type", + 400, + ); + } + const id = crypto.randomUUID(); + try { + if ( + (await store.create( + contactWrite(id, mf2.properties, Math.floor(Date.now() / 1000)), + )) === "conflict" + ) { + return error("invalid_request", "a contact already has that URL", 409); + } + } catch (err) { + if (err instanceof ContactValidationError) + return error("invalid_request", err.message, 400); + throw err; + } + const location = contactInternalUrl(config, id); + emit(config, "info", MicropubLogEvent.ActionCompleted, { + action: "contact-create", + }); + return contactResponse(location); + } + if (!parsed.url) + return error( + "invalid_request", + "`url` is required for contact updates", + 400, + ); + const id = contactIdFromUrl(parsed.url, config); + if (!id) + return error("invalid_request", "no contact exists at that URL", 404); + if (action === "delete") { + if (!(await store.delete(id))) + return error("invalid_request", "no contact exists at that URL", 404); + emit(config, "info", MicropubLogEvent.ActionCompleted, { + action: "contact-delete", + }); + return noContent(); + } + if (action !== "update") + return error( + "invalid_request", + `unsupported contact action \`${action}\``, + 400, + ); + if (!isJson) + return error( + "invalid_request", + "contact `update` requests must use `application/json`", + 400, + ); + const record = await store.get(id); + if (!record) + return error("invalid_request", "no contact exists at that URL", 404); + let ops; + try { + ops = parseUpdateOperations(rawJson); + } catch (err) { + if (err instanceof Mf2ParseError) + return error("invalid_request", err.message, 400); + throw err; + } + if (contactUpdateHasInternalUrl(ops)) { + return error( + "invalid_request", + "`_internal_url` is response metadata and cannot be modified", + 400, + ); + } + try { + const result = await store.update( + contactWrite( + id, + applyUpdate(record.properties, ops), + Math.floor(Date.now() / 1000), + ), + ); + if (result === "not_found") + return error("invalid_request", "no contact exists at that URL", 404); + if (result === "conflict") + return error("invalid_request", "a contact already has that URL", 409); + } catch (err) { + if (err instanceof ContactValidationError) + return error("invalid_request", err.message, 400); + throw err; + } + emit(config, "info", MicropubLogEvent.ActionCompleted, { + action: "contact-update", + }); + return noContent(); +} + /** Handle `POST` to the Micropub endpoint: dispatch on the action verb. */ async function handleAction( request: Request, @@ -635,6 +877,8 @@ async function handleAction( } const action = parsed.action ?? "create"; + const isContactRequest = + new URL(request.url).searchParams.get("q") === "contact"; // RFC 6750 ยง2: a client MUST NOT use more than one method to transmit the // token. Reject โ€” before authorizing โ€” when the token is present in BOTH the @@ -669,8 +913,8 @@ async function handleAction( } // Authorized: only now stream any uploaded multipart files to R2 and fold - // their URLs into the create. (Files on a non-create action are ignored, so - // they never produce orphaned blobs.) + // their URLs into the create, including contact h-cards. (Files on a + // non-create action are ignored, so they never produce orphaned blobs.) if (pendingFiles.length > 0 && action === "create") { try { parsed = await foldUploadedMedia(parsed, pendingFiles, env, config); @@ -685,6 +929,14 @@ async function handleAction( } } + if (isContactRequest) { + const contacts = config.contacts; + if (!config.extensions.proposed || !contacts) { + return error("invalid_request", "unsupported query `q=contact`", 400); + } + return handleContactAction(parsed, rawJson, isJson, config, contacts(env)); + } + switch (action) { case "create": return doCreate(parsed.mf2, parsed.commands, config, store); diff --git a/packages/micropub/src/index.test.ts b/packages/micropub/src/index.test.ts index 1f5f450b..10c0b29d 100644 --- a/packages/micropub/src/index.test.ts +++ b/packages/micropub/src/index.test.ts @@ -2,7 +2,7 @@ import { env } from "cloudflare:test"; import { signAccessToken, createIndieAuthStore } from "@dwk/indieauth"; import { beforeEach, describe, expect, it } from "vitest"; -import { createMicropub } from "./index.js"; +import { createMicropub, createMicropubContactStore } from "./index.js"; import type { MicropubEnv } from "./index.js"; const harness = env as unknown as MicropubEnv; @@ -143,10 +143,18 @@ const handler = createMicropub({ me: ME, syndicateTo: [{ uid: "https://twitter.com/alice", name: "Alice on Twitter" }], }); +const contactStore = createMicropubContactStore(harness); +const contactsHandler = createMicropub({ + baseUrl: BASE, + me: ME, + extensions: { proposed: true }, + contacts: createMicropubContactStore, +}); beforeEach(async () => { await createIndieAuthStore(harness).init(); await (await import("./store.js")).createMicropubStore(harness).init(); + await contactStore.init(); await (await import("./replay.js")).createDpopReplayStore(harness).init(); }); @@ -1976,3 +1984,193 @@ describe("@dwk/micropub DPoP htu binding behind a proxy", () => { expect(res.status).toBe(201); }); }); + +describe("@dwk/micropub proposed contacts", () => { + it("advertises and manages private h-cards", async () => { + const creator = await mintToken("create"); + const config = await contactsHandler( + new Request(`${MICROPUB}?q=config`, { + headers: await authHeaders(creator, "GET", MICROPUB), + }), + harness, + ctx, + ); + expect(((await config.json()) as { q: string[] }).q).toContain("contact"); + const created = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...(await authHeaders(creator, "POST", MICROPUB)), + }, + body: new URLSearchParams({ + h: "card", + name: "Ada Lovelace", + url: "HTTPS://Ada.EXAMPLE:443", + nickname: "ada", + }), + }), + harness, + ctx, + ); + expect(created.status).toBe(201); + const location = created.headers.get("location"); + const listed = await contactsHandler( + new Request(`${MICROPUB}?q=contact&filter=ada`, { + headers: await authHeaders(creator, "GET", MICROPUB), + }), + harness, + ctx, + ); + expect((await listed.json()) as Record).toMatchObject({ + contacts: [{ name: "Ada Lovelace", _internal_url: location }], + }); + const deniedDelete = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...(await authHeaders(creator, "POST", MICROPUB)), + }, + body: new URLSearchParams({ action: "delete", url: location ?? "" }), + }), + harness, + ctx, + ); + expect(deniedDelete.status).toBe(403); + expect(((await deniedDelete.json()) as { error: string }).error).toBe( + "insufficient_scope", + ); + const mediaReader = await mintToken("media"); + const mediaScopedRead = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + headers: await authHeaders(mediaReader, "GET", MICROPUB), + }), + harness, + ctx, + ); + expect(mediaScopedRead.status).toBe(200); + const updater = await mintToken("update"); + const protectedMetadata = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(await authHeaders(updater, "POST", MICROPUB)), + }, + body: JSON.stringify({ + action: "update", + url: location, + replace: { _internal_url: ["forged"] }, + }), + }), + harness, + ctx, + ); + expect(protectedMetadata.status).toBe(400); + const updated = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(await authHeaders(updater, "POST", MICROPUB)), + }, + body: JSON.stringify({ + action: "update", + url: location, + replace: { name: ["Ada King"] }, + }), + }), + harness, + ctx, + ); + expect(updated.status).toBe(204); + const deleter = await mintToken("delete"); + const deleted = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...(await authHeaders(deleter, "POST", MICROPUB)), + }, + body: new URLSearchParams({ action: "delete", url: location ?? "" }), + }), + harness, + ctx, + ); + expect(deleted.status).toBe(204); + }); + + it("folds an uploaded photo into a multipart contact create", async () => { + const creator = await mintToken("create"); + const form = new FormData(); + form.set("h", "card"); + form.set("name", "Photo Contact"); + form.set( + "photo", + new File([new Uint8Array([8, 6, 7, 5, 3, 0, 9])], "photo.png", { + type: "image/png", + }), + ); + const created = await contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: await authHeaders(creator, "POST", MICROPUB), + body: form, + }), + harness, + ctx, + ); + expect(created.status).toBe(201); + const listed = await contactsHandler( + new Request(`${MICROPUB}?q=contact&filter=photo%20contact`, { + headers: await authHeaders(creator, "GET", MICROPUB), + }), + harness, + ctx, + ); + const body = (await listed.json()) as { + contacts: Array<{ photo?: string }>; + }; + const photo = body.contacts[0]?.photo; + expect(typeof photo).toBe("string"); + expect((photo as string).startsWith(`${MEDIA}/`)).toBe(true); + const media = await contactsHandler( + new Request(photo as string), + harness, + ctx, + ); + expect(media.status).toBe(200); + }); + + it("rejects duplicate public URLs and malformed contact queries", async () => { + const creator = await mintToken("create"); + const create = async (name: string, url: string): Promise => + contactsHandler( + new Request(`${MICROPUB}?q=contact`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...(await authHeaders(creator, "POST", MICROPUB)), + }, + body: new URLSearchParams({ h: "card", name, url }), + }), + harness, + ctx, + ); + expect((await create("Grace Hopper", "https://grace.example")).status).toBe( + 201, + ); + expect( + (await create("Grace Duplicate", "https://GRACE.example:443/")).status, + ).toBe(409); + const malformed = await contactsHandler( + new Request(`${MICROPUB}?q=contact&limit=0`, { + headers: await authHeaders(creator, "GET", MICROPUB), + }), + harness, + ctx, + ); + expect(malformed.status).toBe(400); + }); +}); diff --git a/packages/micropub/src/index.ts b/packages/micropub/src/index.ts index 786f6d9f..5fe91e39 100644 --- a/packages/micropub/src/index.ts +++ b/packages/micropub/src/index.ts @@ -37,6 +37,7 @@ export type { ExtensionMaturity, ExtensionGroupsConfig, PostTypeConfig, + MicropubContactStoreProvider, AudienceConfig, } from "./config.js"; @@ -59,6 +60,23 @@ export { type SourceListQuery, } from "./store.js"; +export { + createMicropubContactStore, + canonicalContactUrl, + contactView, + contactWrite, + normalizeContactText, + ContactValidationError, + INTERNAL_CONTACT_URL, +} from "./contacts.js"; +export type { + ContactListQuery, + ContactRecord, + ContactWrite, + MicropubContactStore, + MicropubContactStoreEnv, +} from "./contacts.js"; + export { parseFormBody, parseJsonBody, diff --git a/spec/packages/micropub.md b/spec/packages/micropub.md index 4063e7a0..2eac3fd3 100644 --- a/spec/packages/micropub.md +++ b/spec/packages/micropub.md @@ -123,6 +123,36 @@ is a client's only way to browse its own drafts (#351). [mp-ext-list]: https://indieweb.org/Micropub-extensions#Query_for_Post_List +### Proposed Contacts (`q=contact`) + +Contacts are an opt-in proposed extension: it is advertised in `q=config` and +routed only when `extensions.proposed` is true and a `contacts` store/provider +is configured. It is private owner data, so every request uses the existing +IndieAuth subject binding and mandatory DPoP validation. Reads require an +authenticated token but no particular action scope (intentionally matching +`q=source`); create, update, and delete require their corresponding Micropub +scopes. + +`GET ?q=contact` returns `{ "contacts": [...] }`, with h-card value objects +and response-only `_internal_url` management handles. `filter` (or compatibility +alias `search`) is a case-insensitive literal substring match across strings in +known and unknown properties. Results have deterministic display-name ordering, +with `limit` (1โ€“100, default 100) and `offset` pagination. + +`POST ?q=contact` creates an `h-card` from JSON, form-encoded, or multipart +input; multipart files are streamed to R2 and folded into their matching h-card +properties. `action=update` uses standard JSON replace/add/delete operations +against `_internal_url`; and `action=delete` hard-deletes it. Contacts preserve +arbitrary property arrays and structured values. A non-empty `name`, +`nickname`, `url`, or `email` is required; the canonical first http(s) URL is +unique among contacts. To person-tag a post, a client copies the selected +h-card (not `_internal_url`) into `category` as an embedded h-card, preserving +a historical snapshot. + +The built-in `createMicropubContactStore` creates a separate strongly-consistent +D1 table with bound queries and indexes; custom stores implement the same +`MicropubContactStore` seam. KV is never an authoritative contact store. + ### Proposed Audience and Location Visibility The IndieWeb extensions reference reserves the `audience` and @@ -200,8 +230,8 @@ ordering, and is exclusive over the `(created_at, url)` tuple. `cursor` and property predicates remain safe exact JSON predicates rather than full-text search. -Proposed-group extensions (`q=geo` and `q=contact`) remain unimplemented and -are tracked separately. +The proposed-group `q=geo` extension remains unimplemented and is tracked +separately. ## Auth / security