Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mastodon-write-surface.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/activitypub/src/mastodon-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b> & c",
spoilerText: "cw <x> & y",
sensitive: true,
});
const object = (entry.activity as { object: Record<string, unknown> })
.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("<p>a &lt;b&gt; &amp; c</p>");
expect(object.summary).toBe("cw &lt;x&gt; &amp; 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`;
Expand Down
57 changes: 57 additions & 0 deletions packages/activitypub/src/mastodon-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type BackendEntry,
type BackendPage,
type BackendPageQuery,
type BackendPublishInput,
type MastodonApiConfig,
type MastodonApiEnv,
type MastodonBackend,
Expand Down Expand Up @@ -266,9 +267,65 @@ export function buildMastodonBackend(options: {
if (!response.ok) return null;
return (await response.json()) as BackendActorProfile;
},

async publishStatus(input: BackendPublishInput): Promise<BackendEntry> {
// 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<string, unknown> = {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

/**
* Escape a plain-text status and wrap it as the HTML an AS2 `Note` carries:
* `\n\n` splits paragraphs, a single `\n` becomes `<br>`. Mastodon clients
* submit plain text and expect the server to render markup.
*/
function plainTextToHtml(text: string): string {
return text
.split(/\n{2,}/)
.map(
(paragraph) => `<p>${escapeHtml(paragraph).replace(/\n/g, "<br>")}</p>`,
)
.join("");
}

/**
* Compose `@dwk/mastodon-api`'s router over this package's internal DO seam
* (mirrors `createSolidPodWebdav`'s *export* shape — a factory returning a
Expand Down
81 changes: 75 additions & 6 deletions packages/activitypub/src/object.ts
Comment thread
davidwkeith marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
buildPostActivity,
classifyActivity,
parsePostInput,
type PostInput,
} from "./objects.js";
import { INTERNAL_HEADERS, type ForwardedConfig } from "./config.js";
import {
Expand Down Expand Up @@ -382,6 +383,15 @@ export class ActivityPubObject extends DurableObject<ActivityPubEnv> {
}
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");
Expand Down Expand Up @@ -1237,7 +1247,6 @@ export class ActivityPubObject extends DurableObject<ActivityPubEnv> {
* authorized the request via the publish token.
*/
async #publishPost(request: Request): Promise<Response> {
const config = this.#config!;
if (request.headers.get(INTERNAL_HEADERS.publish) !== "1") {
return text(403, "Publishing is not enabled");
}
Expand All @@ -1250,17 +1259,39 @@ export class ActivityPubObject extends DurableObject<ActivityPubEnv> {
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<string, JsonValue>;
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);
Expand All @@ -1271,12 +1302,50 @@ export class ActivityPubObject extends DurableObject<ActivityPubEnv> {
.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<Response> {
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. */
Expand Down
32 changes: 32 additions & 0 deletions packages/mastodon-api/src/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions packages/mastodon-api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
20 changes: 20 additions & 0 deletions packages/mastodon-api/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ export interface BackendPage<T> {
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<BackendAccount>;
Expand All @@ -105,4 +118,11 @@ export interface MastodonBackend {
entry(id: string): Promise<BackendEntry | null>;
/** Cached remote actor profile, or null when it has not resolved yet. */
actorProfile?(actor: string): Promise<BackendActorProfile | null>;
/**
* 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<BackendEntry>;
}
9 changes: 9 additions & 0 deletions packages/mastodon-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
10 changes: 10 additions & 0 deletions packages/mastodon-api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions packages/mastodon-api/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -43,6 +44,7 @@ const ROUTES: ReadonlyMap<string, RouteHandler> = new Map<string, RouteHandler>(
["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],
Expand Down
1 change: 1 addition & 0 deletions packages/mastodon-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type {
BackendPageQuery,
BackendEntry,
BackendActorProfile,
BackendPublishInput,
} from "./backend.js";
export { mastodonError } from "./errors.js";
export {
Expand Down
Loading
Loading