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.test.ts b/packages/activitypub/src/mastodon-api.test.ts index d5f42034..e0883ab2 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("resolves inReplyTo to the owner's outbox post (in_reply_to snowflake + owner author)", async () => { const config = freshConfig(); const ownerPost = `${config.iris.outbox}/reply-target/object`; diff --git a/packages/activitypub/src/mastodon-api.ts b/packages/activitypub/src/mastodon-api.ts index b53f7ce1..a7fbc163 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, @@ -266,9 +267,65 @@ 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), + }; + // 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`, { + 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 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 { + return text + .split(/\n{2,}/) + .map( + (paragraph) => `

${escapeHtml(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 ff78944f..8a423d4a 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,50 @@ 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, + // 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); } /** 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 cc064e81..b9e42a34 100644 --- a/packages/mastodon-api/src/backend.ts +++ b/packages/mastodon-api/src/backend.ts @@ -88,6 +88,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; @@ -105,4 +118,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..fb8f93fc --- /dev/null +++ b/packages/mastodon-api/src/statuses-write.ts @@ -0,0 +1,115 @@ +/** + * `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 { credentialAccountEntity, statusEntity } from "./entities.js"; +import { + accountRequired, + insufficientScope, + invalidToken, + recordNotFound, + unprocessable, +} from "./errors.js"; +import type { RouteContext } from "./handler.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 10257a9c..a3eb61a4 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)