From 03925c9fc5915e762c4279353eed8148dbcbc74e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 04:49:42 +0000 Subject: [PATCH 1/3] feat(mastodon-api,activitypub): opt-in owner-scoped status write surface Add POST /api/v1/statuses behind config.allowWrites (default off, so every write route stays 404 and the plain-bearer exception remains strictly read-only). When enabled, the exception widens to owner-scoped write: the route requires an owner-bound bearer (422 for app-level tokens) carrying write / write:statuses scope (403 otherwise), enforces a 500-char ceiling, renders the plain-text status to Note HTML, and publishes through @dwk/activitypub's outbox path via a new internal __client/publish DO route that shares #storePost with the AS2 /publish endpoint. New MastodonBackend.publishStatus seam and tokenHasScope helper. Delete, interaction verbs, follow, and reply-on-create are follow-ups. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- .changeset/mastodon-write-surface.md | 6 + packages/activitypub/src/mastodon-api.ts | 44 ++++ packages/activitypub/src/object.ts | 76 ++++++- packages/mastodon-api/src/auth.test.ts | 32 +++ packages/mastodon-api/src/auth.ts | 17 ++ packages/mastodon-api/src/backend.ts | 20 ++ packages/mastodon-api/src/config.ts | 9 + packages/mastodon-api/src/errors.ts | 10 + packages/mastodon-api/src/handler.ts | 2 + packages/mastodon-api/src/index.ts | 1 + .../mastodon-api/src/statuses-write.test.ts | 197 ++++++++++++++++++ packages/mastodon-api/src/statuses-write.ts | 116 +++++++++++ spec/non-functional-requirements.md | 11 +- spec/packages/mastodon-api.md | 46 +++- 14 files changed, 574 insertions(+), 13 deletions(-) create mode 100644 .changeset/mastodon-write-surface.md create mode 100644 packages/mastodon-api/src/auth.test.ts create mode 100644 packages/mastodon-api/src/statuses-write.test.ts create mode 100644 packages/mastodon-api/src/statuses-write.ts diff --git a/.changeset/mastodon-write-surface.md b/.changeset/mastodon-write-surface.md new file mode 100644 index 00000000..83123e59 --- /dev/null +++ b/.changeset/mastodon-write-surface.md @@ -0,0 +1,6 @@ +--- +"@dwk/mastodon-api": minor +"@dwk/activitypub": minor +--- + +Add an opt-in owner-scoped write surface to the Mastodon client API (`config.allowWrites`, default off). When enabled, `POST /api/v1/statuses` lets the single owner account author a status through a `write`-scoped bearer: the plain-text `status` is rendered to `Note` HTML (with `spoiler_text`/`sensitive` carried through), published via `@dwk/activitypub`'s existing outbox/fan-out path over a new internal `__client/publish` DO route, and returned as the owner-attributed `Status`. This deliberately widens the documented plain-bearer DPoP-everywhere exception from read-only to owner-scoped write — but only when opted in; the default keeps every write route `404`, so the exception stays strictly read-only. Enforcement: owner account required (`422` for app-level tokens), `write`/`write:statuses` scope required (`403` otherwise), 500-char ceiling. New seam `MastodonBackend.publishStatus?` and `tokenHasScope` helper. Delete, interaction verbs, follow, and reply-on-create are follow-up increments. diff --git a/packages/activitypub/src/mastodon-api.ts b/packages/activitypub/src/mastodon-api.ts index 07753056..98bce1d7 100644 --- a/packages/activitypub/src/mastodon-api.ts +++ b/packages/activitypub/src/mastodon-api.ts @@ -20,6 +20,7 @@ import { type BackendEntry, type BackendPage, type BackendPageQuery, + type BackendPublishInput, type MastodonApiConfig, type MastodonApiEnv, type MastodonBackend, @@ -222,9 +223,52 @@ export function buildMastodonBackend(options: { if (!response.ok) return null; return (await response.json()) as BackendActorProfile; }, + + async publishStatus(input: BackendPublishInput): Promise { + // Render the client's plain-text status into the HTML an AS2 Note + // carries. The DO's outbox stores it verbatim and the read path + // re-sanitizes on the way out, so escaping here is belt-and-suspenders. + const headers = internalHeaders(); + headers.set(INTERNAL_HEADERS.publish, "1"); + headers.set("content-type", "application/json"); + const body: Record = { + kind: "note", + content: plainTextToHtml(input.status), + }; + if (input.spoilerText !== undefined) body.summary = input.spoilerText; + if (input.sensitive !== undefined) body.sensitive = input.sensitive; + const response = await stub().fetch( + new Request(`${config.iris.id}/__client/publish`, { + method: "POST", + headers, + body: JSON.stringify(body), + }), + ); + if (!response.ok) { + throw new Error( + `__client/publish failed (${response.status}): ${await response.text()}`, + ); + } + const row = (await response.json()) as ClientEntryRow; + return toBackendEntry(row); + }, }; } +/** + * Escape a plain-text status and wrap it as the HTML an AS2 `Note` carries: + * `\n\n` splits paragraphs, a single `\n` becomes `
`. Mastodon clients + * submit plain text and expect the server to render markup. + */ +function plainTextToHtml(text: string): string { + const escape = (value: string): string => + value.replace(/&/g, "&").replace(//g, ">"); + return text + .split(/\n{2,}/) + .map((paragraph) => `

${escape(paragraph).replace(/\n/g, "
")}

`) + .join(""); +} + /** * Compose `@dwk/mastodon-api`'s router over this package's internal DO seam * (mirrors `createSolidPodWebdav`'s *export* shape — a factory returning a diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 905ac910..98d1d4f4 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -43,6 +43,7 @@ import { buildPostActivity, classifyActivity, parsePostInput, + type PostInput, } from "./objects.js"; import { INTERNAL_HEADERS, type ForwardedConfig } from "./config.js"; import { @@ -382,6 +383,15 @@ export class ActivityPubObject extends DurableObject { } return this.#clientActor(request); } + // Owner-write path for the Mastodon client API (`POST /api/v1/statuses`). + // Internal + publish markers required, exactly like `/publish`; the + // mastodon-api layer enforces the owner bearer + `write` scope upstream. + if (path === `${pathOf(iris.id)}/__client/publish`) { + if (request.headers.get(INTERNAL_HEADERS.internal) !== "1") { + return text(404, "not found"); + } + return this.#clientPublish(request); + } if (path === pathOf(iris.followers)) { return this.#serveCollection(request, iris.followers, "followers"); @@ -1237,7 +1247,6 @@ export class ActivityPubObject extends DurableObject { * authorized the request via the publish token. */ async #publishPost(request: Request): Promise { - const config = this.#config!; if (request.headers.get(INTERNAL_HEADERS.publish) !== "1") { return text(403, "Publishing is not enabled"); } @@ -1250,17 +1259,39 @@ export class ActivityPubObject extends DurableObject { const parsed = parsePostInput(body); if (!parsed.ok) return text(400, parsed.error); + const stored = await this.#storePost(parsed.input); + return json(201, stored.activity as JsonValue, { + location: stored.activityId, + }); + } + + /** + * Build a `Create(Note|Article|Page)` from a parsed {@link PostInput}, store + * it to the outbox, and fan it out to followers (and any community + * `audience`) exactly like the AS2 publish path — returning the stored row's + * outbox coordinates so a caller that needs the Mastodon-shaped snowflake + * (the `__client/publish` write path) can build it. Shared by `#publishPost` + * and `#clientPublish`. + */ + async #storePost(input: PostInput): Promise<{ + activityId: string; + activity: Record; + seq: number; + publishedAt: number; + }> { + const config = this.#config!; const activityId = `${config.iris.outbox}/${crypto.randomUUID()}`; - const activity = buildPostActivity(parsed.input, config.iris, { + const activity = buildPostActivity(input, config.iris, { activityId, objectId: `${activityId}/object`, published: new Date().toISOString(), }); + const publishedAt = Date.now(); this.#sql.exec( `INSERT OR IGNORE INTO outbox (id, json, published_at) VALUES (?, ?, ?)`, activityId, JSON.stringify(activity), - Date.now(), + publishedAt, ); const json_ = JSON.stringify(activity); @@ -1271,12 +1302,45 @@ export class ActivityPubObject extends DurableObject { .toArray()) { if (row.inbox) this.#enqueueDelivery(row.inbox, json_); } - if (parsed.input.audience) { - this.#deliverToAudience(parsed.input.audience, json_); + if (input.audience) { + this.#deliverToAudience(input.audience, json_); } await this.#armAlarm(); - return json(201, activity as JsonValue, { location: activityId }); + const seq = this.#sql + .exec<{ seq: number }>(`SELECT seq FROM outbox WHERE id = ?`, activityId) + .one().seq; + return { activityId, activity, seq, publishedAt }; + } + + /** + * Internal owner-write path for the Mastodon client API (`POST + * /api/v1/statuses`, #349 phase-4 writes): publish a status and return it in + * the `__client/*` row shape (`{seq, receivedAt, activity, source: 1}`) so + * the adapter renders it with the same `statusEntity` mapper the read path + * uses. The mastodon-api layer has already authenticated the owner and + * enforced the `write` scope before setting the publish marker; this route + * requires both the internal and publish markers, exactly like `/publish`. + */ + async #clientPublish(request: Request): Promise { + if (request.headers.get(INTERNAL_HEADERS.publish) !== "1") { + return text(403, "Publishing is not enabled"); + } + let body: unknown; + try { + body = await request.json(); + } catch { + return text(400, "Malformed post JSON"); + } + const parsed = parsePostInput(body); + if (!parsed.ok) return text(400, parsed.error); + const stored = await this.#storePost(parsed.input); + return json(200, { + seq: stored.seq, + receivedAt: stored.publishedAt, + activity: stored.activity, + source: 1, + } as unknown as JsonValue); } /** Wrap a bare object in a `Create`, assign ids/audience, and timestamp it. */ diff --git a/packages/mastodon-api/src/auth.test.ts b/packages/mastodon-api/src/auth.test.ts new file mode 100644 index 00000000..afe90b16 --- /dev/null +++ b/packages/mastodon-api/src/auth.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { tokenHasScope } from "./auth.js"; + +describe("tokenHasScope", () => { + it("matches an exact granted scope", () => { + expect(tokenHasScope("read write", "write")).toBe(true); + }); + + it("a broad granted scope covers its granular child", () => { + expect(tokenHasScope("read write", "write:statuses")).toBe(true); + }); + + it("a granular granted scope satisfies itself", () => { + expect(tokenHasScope("read write:statuses", "write:statuses")).toBe(true); + }); + + it("a granular granted scope does not grant the broad required scope", () => { + // write:statuses must not satisfy a required plain `write`. + expect(tokenHasScope("read write:statuses", "write")).toBe(false); + }); + + it("read does not grant write", () => { + expect(tokenHasScope("read follow push", "write:statuses")).toBe(false); + expect(tokenHasScope("read", "write")).toBe(false); + }); + + it("tolerates arbitrary whitespace and empty segments", () => { + expect(tokenHasScope(" read write ", "write")).toBe(true); + expect(tokenHasScope("", "write")).toBe(false); + }); +}); diff --git a/packages/mastodon-api/src/auth.ts b/packages/mastodon-api/src/auth.ts index b4267010..48d97bfa 100644 --- a/packages/mastodon-api/src/auth.ts +++ b/packages/mastodon-api/src/auth.ts @@ -59,3 +59,20 @@ export async function authenticateBearer( const record = await store.getToken(await sha256Hex(token)); return record && !record.revoked ? record : null; } + +/** + * Whether a granted token scope string satisfies a required Mastodon scope. + * A granted broad scope (`write`) covers its granular children + * (`write:statuses`), matching Mastodon's hierarchy — but not the reverse + * (`write:statuses` does not grant `write`, nor does `read` grant `write`). + */ +export function tokenHasScope( + granted: string, + required: `${"read" | "write"}${"" | `:${string}`}`, +): boolean { + const [requiredTop] = required.split(":"); + return granted + .split(/\s+/) + .filter(Boolean) + .some((scope) => scope === required || scope === requiredTop); +} diff --git a/packages/mastodon-api/src/backend.ts b/packages/mastodon-api/src/backend.ts index c5def382..0bf84b5b 100644 --- a/packages/mastodon-api/src/backend.ts +++ b/packages/mastodon-api/src/backend.ts @@ -62,6 +62,19 @@ export interface BackendPage { readonly entries: readonly T[]; } +/** + * An owner-authored status to publish (`POST /api/v1/statuses`). `status` is + * the plain-text body the client typed; the backend renders it to the HTML an + * AS2 `Note` carries. + */ +export interface BackendPublishInput { + readonly status: string; + /** Content warning (Mastodon `spoiler_text` → AS2 `summary`). */ + readonly spoilerText?: string; + /** Mark the status sensitive (`as:sensitive`). */ + readonly sensitive?: boolean; +} + export interface MastodonBackend { /** Actor profile + live counts (followers/following/statuses). */ account(): Promise; @@ -79,4 +92,11 @@ export interface MastodonBackend { entry(id: string): Promise; /** Cached remote actor profile, or null when it has not resolved yet. */ actorProfile?(actor: string): Promise; + /** + * Publish an owner status and return the stored entry (source-1). Optional: + * a backend without it leaves the write route unsupported even where the + * deployment opted into writes. The owner bearer + `write` scope are + * enforced by the route before this is called. + */ + publishStatus?(input: BackendPublishInput): Promise; } diff --git a/packages/mastodon-api/src/config.ts b/packages/mastodon-api/src/config.ts index 2a549f62..6c5705d6 100644 --- a/packages/mastodon-api/src/config.ts +++ b/packages/mastodon-api/src/config.ts @@ -93,6 +93,15 @@ export interface MastodonApiConfig { readonly pageSize?: { readonly default: number; readonly max: number }; /** Live-count + timeline backend; absent in phase 1 (counts render as 0). */ readonly backend?: MastodonBackend; + /** + * Opt-in owner write surface. Default (absent / `false`) keeps the API + * read-only — every write route answers `404`, so the plain-bearer token + * exception stays strictly read-only as documented. Setting it `true` + * extends that documented DPoP-everywhere exception to **owner-scoped + * writes**: a `write`-scoped bearer for the single owner account may author + * on this deployment. See `spec/packages/mastodon-api.md` § Write surface. + */ + readonly allowWrites?: boolean; } /** The one local account id this deployment ever mints (single-owner). */ diff --git a/packages/mastodon-api/src/errors.ts b/packages/mastodon-api/src/errors.ts index 7af767ea..17dfe8d4 100644 --- a/packages/mastodon-api/src/errors.ts +++ b/packages/mastodon-api/src/errors.ts @@ -24,3 +24,13 @@ export function recordNotFound(): Response { export function accountRequired(): Response { return mastodonError(422, "This method requires an authenticated user."); } + +/** `403` — the token's granted scopes do not cover this write. */ +export function insufficientScope(): Response { + return mastodonError(403, "This action is outside the authorized scopes."); +} + +/** `422` — a syntactically valid request that fails validation. */ +export function unprocessable(message: string): Response { + return mastodonError(422, message); +} diff --git a/packages/mastodon-api/src/handler.ts b/packages/mastodon-api/src/handler.ts index 3b03264f..9297773b 100644 --- a/packages/mastodon-api/src/handler.ts +++ b/packages/mastodon-api/src/handler.ts @@ -19,6 +19,7 @@ import { handleGetMarkers, handleSaveMarkers } from "./markers.js"; import { handleNotifications } from "./notifications.js"; import { handleAuthorize, handleRevoke, handleToken } from "./oauth-flow.js"; import { handleGetStatus } from "./statuses.js"; +import { handleCreateStatus } from "./statuses-write.js"; import { stubRouteEntries } from "./stubs.js"; import { handleHomeTimeline } from "./timelines.js"; @@ -43,6 +44,7 @@ const ROUTES: ReadonlyMap = new Map( ["GET /api/v1/markers", handleGetMarkers], ["POST /api/v1/markers", handleSaveMarkers], ["GET /api/v1/timelines/home", handleHomeTimeline], + ["POST /api/v1/statuses", handleCreateStatus], ["GET /api/v1/notifications", handleNotifications], ["GET /oauth/authorize", handleAuthorize], ["POST /oauth/token", handleToken], diff --git a/packages/mastodon-api/src/index.ts b/packages/mastodon-api/src/index.ts index 71b51ae4..dde7349c 100644 --- a/packages/mastodon-api/src/index.ts +++ b/packages/mastodon-api/src/index.ts @@ -36,6 +36,7 @@ export type { BackendPageQuery, BackendEntry, BackendActorProfile, + BackendPublishInput, } from "./backend.js"; export { mastodonError } from "./errors.js"; export { diff --git a/packages/mastodon-api/src/statuses-write.test.ts b/packages/mastodon-api/src/statuses-write.test.ts new file mode 100644 index 00000000..83c9a98d --- /dev/null +++ b/packages/mastodon-api/src/statuses-write.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; + +import { api, registerApp, resetDb, testConfig } from "./test-harness.js"; +import type { + BackendEntry, + BackendPublishInput, + MastodonBackend, +} from "./backend.js"; +import { encodeSnowflake } from "./snowflake.js"; + +/** A backend that records the publish input and echoes back a source-1 entry. */ +function writeBackend(): MastodonBackend & { + readonly published: BackendPublishInput[]; +} { + const published: BackendPublishInput[] = []; + return { + published, + account: async () => ({ + counts: { followers: 1, following: 2, statuses: 3 }, + }), + timeline: async () => ({ entries: [] }), + notifications: async () => ({ entries: [] }), + entry: async () => null, + publishStatus: async (input): Promise => { + published.push(input); + return { + id: encodeSnowflake(1_753_000_000_100, 1, 1), + receivedAt: 1_753_000_000_100, + objectType: "Note", + relayedBy: null, + source: 1, + activity: { + type: "Create", + actor: "https://owner.example/users/owner", + object: { + id: "https://owner.example/users/owner/outbox/x/object", + type: "Note", + content: `

${input.status}

`, + ...(input.spoilerText ? { summary: input.spoilerText } : {}), + ...(input.sensitive ? { sensitive: input.sensitive } : {}), + }, + }, + }; + }, + }; +} + +/** Mint a bearer token whose grant carries exactly `scopes`. */ +async function tokenWithScopes(scopes: string): Promise { + const app = await registerApp({ scopes }); + const authorize = new URL("https://owner.example/oauth/authorize"); + authorize.searchParams.set("client_id", app.client_id); + authorize.searchParams.set("redirect_uri", "app://oauth-callback"); + authorize.searchParams.set("response_type", "code"); + const redirect = await api()(new Request(authorize.toString())); + const code = new URL(redirect.headers.get("location") ?? "").searchParams.get( + "code", + ); + const res = await api()( + new Request("https://owner.example/oauth/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: "app://oauth-callback", + code: code ?? "", + }), + }), + ); + return ((await res.json()) as { access_token: string }).access_token; +} + +function post(token: string, body: string): Request { + return new Request("https://owner.example/api/v1/statuses", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/x-www-form-urlencoded", + }, + body, + }); +} + +describe("POST /api/v1/statuses", () => { + it("404s when writes are not enabled (default keeps the API read-only)", async () => { + await resetDb(); + const token = await tokenWithScopes("read write"); + // allowWrites defaults to false on testConfig. + const cfg = { ...testConfig, backend: writeBackend() }; + const response = await api(cfg)(post(token, "status=hello")); + expect(response.status).toBe(404); + }); + + it("404s when writes are enabled but the backend cannot publish", async () => { + await resetDb(); + const token = await tokenWithScopes("read write"); + const backend: MastodonBackend = { + account: async () => ({ + counts: { followers: 0, following: 0, statuses: 0 }, + }), + timeline: async () => ({ entries: [] }), + notifications: async () => ({ entries: [] }), + entry: async () => null, + }; + const cfg = { ...testConfig, allowWrites: true, backend }; + const response = await api(cfg)(post(token, "status=hello")); + expect(response.status).toBe(404); + }); + + it("401s without a bearer token", async () => { + await resetDb(); + const cfg = { ...testConfig, allowWrites: true, backend: writeBackend() }; + const response = await api(cfg)( + new Request("https://owner.example/api/v1/statuses", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "status=hi", + }), + ); + expect(response.status).toBe(401); + }); + + it("403s a read-only token (insufficient scope)", async () => { + await resetDb(); + const token = await tokenWithScopes("read"); + const cfg = { ...testConfig, allowWrites: true, backend: writeBackend() }; + const response = await api(cfg)(post(token, "status=hi")); + expect(response.status).toBe(403); + }); + + it("422s a blank status", async () => { + await resetDb(); + const token = await tokenWithScopes("read write"); + const cfg = { ...testConfig, allowWrites: true, backend: writeBackend() }; + const response = await api(cfg)(post(token, "status=%20%20")); + expect(response.status).toBe(422); + }); + + it("422s a status over the 500-character ceiling", async () => { + await resetDb(); + const token = await tokenWithScopes("read write"); + const cfg = { ...testConfig, allowWrites: true, backend: writeBackend() }; + const long = "x".repeat(501); + const response = await api(cfg)(post(token, `status=${long}`)); + expect(response.status).toBe(422); + }); + + it("publishes a status and returns the owner-attributed Status", async () => { + await resetDb(); + const token = await tokenWithScopes("read write"); + const backend = writeBackend(); + const cfg = { ...testConfig, allowWrites: true, backend }; + const response = await api(cfg)( + post(token, "status=hello%20world&spoiler_text=cw&sensitive=true"), + ); + expect(response.status).toBe(200); + const status = (await response.json()) as { + content: string; + spoiler_text: string; + sensitive: boolean; + account: { id: string }; + }; + expect(backend.published).toEqual([ + { status: "hello world", spoilerText: "cw", sensitive: true }, + ]); + expect(status.content).toContain("hello world"); + expect(status.spoiler_text).toBe("cw"); + expect(status.sensitive).toBe(true); + // Owner-authored (source 1) → the real owner account id, not a remote one. + expect(status.account.id).toBe("1"); + }); + + it("accepts a client_credentials (app-level) token only up to the account gate", async () => { + await resetDb(); + // An app-level token has no account, so writes are 422 (account required) + // even with write scope — matching the read account endpoints. + const app = await registerApp({ scopes: "read write" }); + const tokenRes = await api()( + new Request("https://owner.example/oauth/token", { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: app.client_id, + client_secret: app.client_secret, + }), + }), + ); + const token = ((await tokenRes.json()) as { access_token: string }) + .access_token; + const cfg = { ...testConfig, allowWrites: true, backend: writeBackend() }; + const response = await api(cfg)(post(token, "status=hi")); + expect(response.status).toBe(422); + }); +}); diff --git a/packages/mastodon-api/src/statuses-write.ts b/packages/mastodon-api/src/statuses-write.ts new file mode 100644 index 00000000..053221ab --- /dev/null +++ b/packages/mastodon-api/src/statuses-write.ts @@ -0,0 +1,116 @@ +/** + * `POST /api/v1/statuses` — owner-authored status creation. + * + * This is the opt-in write surface (`config.allowWrites`). It extends the + * documented plain-bearer DPoP-everywhere exception from read-only to + * owner-scoped write: the route requires a `write`-scoped bearer for the + * single owner account, and every other mitigation is unchanged (tokens are + * opaque, hashed at rest, isolated to this package, RFC 7009 revocable). When + * writes are not enabled — the default — the route answers `404`, so the + * exception stays strictly read-only exactly as before. + * + * @see spec/packages/mastodon-api.md § Write surface + */ + +import { authenticateBearer, tokenHasScope } from "./auth.js"; +import { statusEntity } from "./entities.js"; +import { + accountRequired, + insufficientScope, + invalidToken, + recordNotFound, + unprocessable, +} from "./errors.js"; +import type { RouteContext } from "./handler.js"; +import { credentialAccountEntity } from "./entities.js"; +import { createMastodonStore } from "./store.js"; +import type { BackendPublishInput } from "./backend.js"; + +/** Default Mastodon status character ceiling; clients read it from `Instance`. */ +const MAX_STATUS_CHARS = 500; + +interface StatusForm { + readonly status: string; + readonly spoilerText?: string; + readonly sensitive?: boolean; +} + +async function readStatusForm(request: Request): Promise { + let raw: Record = {}; + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + const parsed = (await request.json()) as unknown; + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + raw = parsed as Record; + } + } catch { + // Malformed JSON → empty fields; validation reports the 422 below. + } + } else { + try { + const form = await request.formData(); + for (const [key, value] of form) { + if (typeof value === "string") raw[key] = value; + } + } catch { + // Malformed body → empty fields, as above. + } + } + const truthy = (value: unknown): boolean => + value === true || value === "true" || value === "1"; + return { + status: typeof raw["status"] === "string" ? raw["status"] : "", + ...(typeof raw["spoiler_text"] === "string" && raw["spoiler_text"] !== "" + ? { spoilerText: raw["spoiler_text"] } + : {}), + ...(raw["sensitive"] !== undefined + ? { sensitive: truthy(raw["sensitive"]) } + : {}), + }; +} + +/** `POST /api/v1/statuses`. */ +export async function handleCreateStatus(ctx: RouteContext): Promise { + // Writes are opt-in and require a backend that can publish. When either is + // absent the route does not exist — `404`, keeping the read-only default. + if (!ctx.config.allowWrites || !ctx.config.backend?.publishStatus) { + return recordNotFound(); + } + const token = await authenticateBearer( + ctx.request, + createMastodonStore(ctx.env), + ); + if (!token) return invalidToken(); + if (token.accountId === null) return accountRequired(); + if (!tokenHasScope(token.scope, "write:statuses")) { + return insufficientScope(); + } + + const form = await readStatusForm(ctx.request); + const status = form.status.trim(); + if (status.length === 0) { + return unprocessable("Validation failed: Text can't be blank"); + } + if (status.length > MAX_STATUS_CHARS) { + return unprocessable( + `Validation failed: Text is too long (maximum is ${MAX_STATUS_CHARS} characters)`, + ); + } + + const input: BackendPublishInput = { + status, + ...(form.spoilerText !== undefined + ? { spoilerText: form.spoilerText } + : {}), + ...(form.sensitive !== undefined ? { sensitive: form.sensitive } : {}), + }; + const entry = await ctx.config.backend.publishStatus(input); + const ownerAccount = credentialAccountEntity( + ctx.config, + (await ctx.config.backend.account()).counts, + ); + return Response.json( + statusEntity(entry, { baseUrl: ctx.config.baseUrl, ownerAccount }), + ); +} diff --git a/spec/non-functional-requirements.md b/spec/non-functional-requirements.md index 642501ff..de77ddfd 100644 --- a/spec/non-functional-requirements.md +++ b/spec/non-functional-requirements.md @@ -41,10 +41,15 @@ Implications: ## Security - **DPoP everywhere** tokens are used. One designed exception: - `@dwk/mastodon-api`'s read-only client-API tokens are plain bearer (real - Mastodon apps cannot do DPoP) — scoped, isolated, and mitigated per + `@dwk/mastodon-api`'s client-API tokens are plain bearer (real Mastodon apps + cannot do DPoP) — scoped, isolated, and mitigated per [mastodon-client-api.md](mastodon-client-api.md) Decision 2; no DPoP-bound - surface accepts them. + surface accepts them. The exception is **read-only by default**. A deployment + MAY opt into an **owner-scoped write** surface (`config.allowWrites`), which + widens the exception to writes authored by the single owner account under a + `write`-scoped bearer; the other mitigations (opaque, hashed at rest, + isolated audience, RFC 7009 revocable) are unchanged. See + [packages/mastodon-api.md](packages/mastodon-api.md) § Write surface. - **No ACL / decision caching outside strongly-consistent layers.** - **Least-privilege bindings** — a package gets only the bindings it declares. - **Outbound SSRF posture is deny-by-default** — every fetch of an attacker- diff --git a/spec/packages/mastodon-api.md b/spec/packages/mastodon-api.md index cd37012d..eb7f36c3 100644 --- a/spec/packages/mastodon-api.md +++ b/spec/packages/mastodon-api.md @@ -80,10 +80,48 @@ object). Everything else under `/api/` (including `/api/v1/push/subscription` `scope`, `client_id`, `account_id`, `created_at`, `revoked` — the repo's documented, mitigated **exception to DPoP-everywhere** ([non-functional-requirements.md](../non-functional-requirements.md)): - read-only surface, isolated audience (no other package accepts them), - hashed at rest, RFC 7009 revocable. Scopes are recorded as requested and - **echoed as granted, never narrowed**; enforcement is that no write - endpoint exists. + isolated audience (no other package accepts them), hashed at rest, RFC 7009 + revocable. Scopes are recorded as requested and **echoed as granted, never + narrowed**; enforcement of a scope is at the endpoint, not by narrowing the + grant. The exception's blast radius is **read-only by default** — see the + Write surface below for how it widens when a deployment opts in. + +## Write surface (opt-in; `config.allowWrites`) + +Off-the-shelf Mastodon clients cannot do DPoP, so any write route they can use +inherently extends the plain-bearer exception above from read-only to writes. +That extension is therefore **opt-in and owner-scoped**, not on by default: + +- **Default is read-only.** With `allowWrites` absent/`false`, every write + route answers `404` (as if it does not exist), so the token exception stays + strictly read-only exactly as originally documented. `enforcement is that no + write endpoint exists` still holds for the default configuration. +- **When enabled**, the exception widens to **owner-scoped write**: a write + route requires a bearer that (a) is bound to the single owner account + (`account_id` non-null — an app-level `client_credentials` token is `422`, + as on the read account endpoints) and (b) carries the `write` scope (a broad + `write` grant, or the granular `write:statuses`; `read` alone is `403` + `insufficient_scope`). Every other mitigation is unchanged — tokens stay + opaque, hashed at rest, isolated to this package's routes, and RFC 7009 + revocable. The accepted blast radius: a leaked write token can author as the + owner until revoked, matching how any Mastodon instance treats an access + token. +- **v1 endpoint:** `POST /api/v1/statuses` (create). The plain-text `status` + is rendered to the HTML an AS2 `Note` carries (`\n\n`→paragraph, `\n`→`
`, + escaped), with `spoiler_text`→`summary` and `sensitive` carried through; it + is published through the actor DO's existing outbox/fan-out path and returned + as the owner-attributed `Status`. Over the 500-character ceiling or a blank + body is `422`. **Not yet in v1** (a follow-up increment): delete + (`DELETE /api/v1/statuses/:id`), the interaction verbs + (`favourite`/`reblog`/`bookmark` and their undos), `follow`/`unfollow`, and + `in_reply_to_id` on create (the write path does not yet resolve a reply + target snowflake back to its object IRI). +- **Backend seam:** `MastodonBackend.publishStatus?` (optional — a backend + without it leaves the route `404` even when `allowWrites` is set). + `@dwk/activitypub`'s adapter implements it over a new internal + `POST /__client/publish` DO route that shares the outbox-write path + with the AS2 `/publish` endpoint and returns the stored row's snowflake + coordinates. ## Entity fields emitted (phase 1) From 1c3c86d4c1ecba0851c2dcb6d681035a034cf2a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 04:59:32 +0000 Subject: [PATCH 2/3] fix(activitypub,mastodon-api): set relayedBy on the write row; dedupe import Address review on #426: - #clientPublish's response omitted relayedBy, so toBackendEntry read it as undefined against the declared string | null. Set relayedBy: null explicitly, matching every other __client/* row producer. - Merge the duplicate ./entities.js import in statuses-write.ts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- packages/activitypub/src/object.ts | 5 +++++ packages/mastodon-api/src/statuses-write.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/activitypub/src/object.ts b/packages/activitypub/src/object.ts index 98d1d4f4..2bd52590 100644 --- a/packages/activitypub/src/object.ts +++ b/packages/activitypub/src/object.ts @@ -1339,6 +1339,11 @@ export class ActivityPubObject extends DurableObject { seq: stored.seq, receivedAt: stored.publishedAt, activity: stored.activity, + // Owner-authored, never group-relayed — set explicitly so the row's + // declared `relayedBy: string | null` holds, matching every other + // `__client/*` producer (an omitted field would reach `toBackendEntry` + // as `undefined`). + relayedBy: null, source: 1, } as unknown as JsonValue); } diff --git a/packages/mastodon-api/src/statuses-write.ts b/packages/mastodon-api/src/statuses-write.ts index 053221ab..fb8f93fc 100644 --- a/packages/mastodon-api/src/statuses-write.ts +++ b/packages/mastodon-api/src/statuses-write.ts @@ -13,7 +13,7 @@ */ import { authenticateBearer, tokenHasScope } from "./auth.js"; -import { statusEntity } from "./entities.js"; +import { credentialAccountEntity, statusEntity } from "./entities.js"; import { accountRequired, insufficientScope, @@ -22,7 +22,6 @@ import { unprocessable, } from "./errors.js"; import type { RouteContext } from "./handler.js"; -import { credentialAccountEntity } from "./entities.js"; import { createMastodonStore } from "./store.js"; import type { BackendPublishInput } from "./backend.js"; From 89b3b35dcfe2891819d77b88e38e2ced0326e894 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 05:14:54 +0000 Subject: [PATCH 3/3] fix(activitypub): escape the content warning before federating it as summary publishStatus escaped the status body via plainTextToHtml but assigned spoilerText to the Note's summary raw. Since the Note is fanned out to followers, an unescaped in the owner's CW became literal markup on receiving instances. Extract escapeHtml and apply it to the summary too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XT45PcE3pgXgsQffQDWdhW --- packages/activitypub/src/mastodon-api.test.ts | 17 +++++++++++++++ packages/activitypub/src/mastodon-api.ts | 21 +++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/activitypub/src/mastodon-api.test.ts b/packages/activitypub/src/mastodon-api.test.ts index 0a65fdb2..ab7020fe 100644 --- a/packages/activitypub/src/mastodon-api.test.ts +++ b/packages/activitypub/src/mastodon-api.test.ts @@ -844,6 +844,23 @@ describe("buildMastodonBackend", () => { expect(fetched!.receivedAt).toBe(wanted!.receivedAt); }); + it("publishStatus() escapes HTML metacharacters in both content and the CW summary", async () => { + const config = freshConfig(); + const backend = buildMastodonBackend({ config, actor: testEnv.ACTOR }); + const entry = await backend.publishStatus!({ + status: "a & c", + spoilerText: "cw & y", + sensitive: true, + }); + const object = (entry.activity as { object: Record }) + .object; + // The federated Note carries escaped HTML in content and summary alike, so + // a `<`/`&` never becomes literal markup on a receiving instance. + expect(object.content).toBe("

a <b> & c

"); + expect(object.summary).toBe("cw <x> & y"); + expect(entry.source).toBe(1); + }); + it("entry() returns null for an unparseable id", async () => { const config = freshConfig(); const backend = buildMastodonBackend({ config, actor: testEnv.ACTOR }); diff --git a/packages/activitypub/src/mastodon-api.ts b/packages/activitypub/src/mastodon-api.ts index 98bce1d7..e0182a0c 100644 --- a/packages/activitypub/src/mastodon-api.ts +++ b/packages/activitypub/src/mastodon-api.ts @@ -235,7 +235,12 @@ export function buildMastodonBackend(options: { kind: "note", content: plainTextToHtml(input.status), }; - if (input.spoilerText !== undefined) body.summary = input.spoilerText; + // The content warning federates as the Note's `summary`, which AP peers + // treat as HTML on the wire — escape it like `content` so a `<`/`&` typed + // into a CW never becomes literal markup on a receiving instance. + if (input.spoilerText !== undefined) { + body.summary = escapeHtml(input.spoilerText); + } if (input.sensitive !== undefined) body.sensitive = input.sensitive; const response = await stub().fetch( new Request(`${config.iris.id}/__client/publish`, { @@ -255,17 +260,25 @@ export function buildMastodonBackend(options: { }; } +/** Escape the HTML metacharacters so trusted-owner plain text federates cleanly. */ +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">"); +} + /** * Escape a plain-text status and wrap it as the HTML an AS2 `Note` carries: * `\n\n` splits paragraphs, a single `\n` becomes `
`. Mastodon clients * submit plain text and expect the server to render markup. */ function plainTextToHtml(text: string): string { - const escape = (value: string): string => - value.replace(/&/g, "&").replace(//g, ">"); return text .split(/\n{2,}/) - .map((paragraph) => `

${escape(paragraph).replace(/\n/g, "
")}

`) + .map( + (paragraph) => `

${escapeHtml(paragraph).replace(/\n/g, "
")}

`, + ) .join(""); }