From b1216a43f25a90cfe1d7e997b612dd5e7cbb2715 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 16 May 2024 15:49:59 -1000 Subject: [PATCH] refactor(api): :recycle: Change route names, improve API endpoints to be more consistent with Mastodon API --- packages/database-interface/main.ts | 3 +- packages/database-interface/oauth.ts | 270 ++++++++++++++++++ .../api/api/v1/accounts/:id/statuses.test.ts | 2 +- server/api/api/v1/frontend/config/index.ts | 21 ++ server/api/api/v1/instance/index.ts | 88 ++---- server/api/api/v1/notifications/index.test.ts | 2 +- server/api/api/v1/sso/:id/index.test.ts | 67 +++++ server/api/api/v1/sso/:id/index.ts | 104 +++++++ server/api/api/v1/sso/index.test.ts | 58 ++++ server/api/api/v1/sso/index.ts | 178 ++++++++++++ server/api/api/v1/statuses/index.test.ts | 36 +-- server/api/api/v1/statuses/index.ts | 11 +- server/api/api/v2/instance/index.test.ts | 1 + server/api/api/v2/instance/index.ts | 8 + server/api/oauth/link/index.ts | 111 ------- .../:issuer => sso/:issuer/callback}/index.ts | 184 ++---------- .../{authorize-external => sso}/index.ts | 10 +- tests/api/statuses.test.ts | 4 +- utils/constants.ts | 2 +- 19 files changed, 785 insertions(+), 375 deletions(-) create mode 100644 packages/database-interface/oauth.ts create mode 100644 server/api/api/v1/frontend/config/index.ts create mode 100644 server/api/api/v1/sso/:id/index.test.ts create mode 100644 server/api/api/v1/sso/:id/index.ts create mode 100644 server/api/api/v1/sso/index.test.ts create mode 100644 server/api/api/v1/sso/index.ts delete mode 100644 server/api/oauth/link/index.ts rename server/api/oauth/{callback/:issuer => sso/:issuer/callback}/index.ts (50%) rename server/api/oauth/{authorize-external => sso}/index.ts (93%) diff --git a/packages/database-interface/main.ts b/packages/database-interface/main.ts index 0aaaa2c5..7dc857d8 100644 --- a/packages/database-interface/main.ts +++ b/packages/database-interface/main.ts @@ -1,4 +1,5 @@ import { Note } from "./note"; +import { OAuthManager } from "./oauth"; import { Timeline } from "./timeline"; -export { Note, Timeline }; +export { Note, Timeline, OAuthManager }; diff --git a/packages/database-interface/oauth.ts b/packages/database-interface/oauth.ts new file mode 100644 index 00000000..6f5e4764 --- /dev/null +++ b/packages/database-interface/oauth.ts @@ -0,0 +1,270 @@ +import { oauthRedirectUri } from "@constants"; +import { errorResponse, response } from "@response"; +import type { InferInsertModel } from "drizzle-orm"; +import { + type AuthorizationServer, + authorizationCodeGrantRequest, + discoveryRequest, + expectNoState, + getValidatedIdTokenClaims, + isOAuth2Error, + processAuthorizationCodeOpenIDResponse, + processDiscoveryResponse, + processUserInfoResponse, + userInfoRequest, + validateAuthResponse, +} from "oauth4webapi"; +import type { Application } from "~database/entities/Application"; +import { db } from "~drizzle/db"; +import { type Applications, OpenIdAccounts } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; + +export class OAuthManager { + public issuer: (typeof config.oidc.providers)[0]; + + constructor(public issuer_id: string) { + const found = config.oidc.providers.find( + (provider) => provider.id === this.issuer_id, + ); + + if (!found) { + throw new Error(`Issuer ${this.issuer_id} not found`); + } + + this.issuer = found; + } + + async getFlow(flowId: string) { + return await db.query.OpenIdLoginFlows.findFirst({ + where: (flow, { eq }) => eq(flow.id, flowId), + with: { + application: true, + }, + }); + } + + async getAuthServer(issuerUrl: URL) { + return await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + } + + async getParameters( + authServer: AuthorizationServer, + issuer: (typeof config.oidc.providers)[0], + currentUrl: URL, + ) { + return validateAuthResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + currentUrl, + expectNoState, + ); + } + + async getOIDCResponse( + authServer: AuthorizationServer, + issuer: (typeof config.oidc.providers)[0], + redirectUri: string, + codeVerifier: string, + parameters: URLSearchParams, + ) { + return await authorizationCodeGrantRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + parameters, + redirectUri, + codeVerifier, + ); + } + + async processOIDCResponse( + authServer: AuthorizationServer, + issuer: (typeof config.oidc.providers)[0], + oidcResponse: Response, + ) { + return await processAuthorizationCodeOpenIDResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + oidcResponse, + ); + } + + async getUserInfo( + authServer: AuthorizationServer, + issuer: (typeof config.oidc.providers)[0], + access_token: string, + sub: string, + ) { + return await userInfoRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + access_token, + ).then( + async (res) => + await processUserInfoResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + sub, + res, + ), + ); + } + + async processOAuth2Error( + application: InferInsertModel | null, + ) { + return { + redirect_uri: application?.redirectUri, + client_id: application?.clientId, + response_type: "code", + scope: application?.scopes, + }; + } + + async linkUser( + userId: string, + // Return value of automaticOidcFlow + oidcFlowData: Exclude< + Awaited< + ReturnType + >, + Response + >, + ) { + const { flow, userInfo } = oidcFlowData; + + // Check if userId is equal to application.clientId + if ((flow.application?.clientId ?? "") !== userId) { + return response(null, 302, { + Location: `${config.http.base_url}?${new URLSearchParams({ + oidc_account_linking_error: "Account linking error", + oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`, + })}`, + }); + } + + // Check if account is already linked + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.serverId, userInfo.sub), + eq(account.issuerId, this.issuer.id), + ), + }); + + if (account) { + return response(null, 302, { + Location: `${config.http.base_url}?${new URLSearchParams({ + oidc_account_linking_error: "Account already linked", + oidc_account_linking_error_message: + "This account has already been linked to this OpenID Connect provider.", + })}`, + }); + } + + // Link the account + await db.insert(OpenIdAccounts).values({ + serverId: userInfo.sub, + issuerId: this.issuer.id, + userId: userId, + }); + + return response(null, 302, { + Location: `${config.http.base_url}?${new URLSearchParams({ + oidc_account_linked: "true", + })}`, + }); + } + + async automaticOidcFlow( + flowId: string, + currentUrl: URL, + errorFn: ( + error: string, + message: string, + app: Application | null, + ) => Response, + ) { + const flow = await this.getFlow(flowId); + + if (!flow) { + return errorFn("invalid_request", "Invalid flow", null); + } + + const issuerUrl = new URL(this.issuer.url); + + const authServer = await this.getAuthServer(issuerUrl); + + const parameters = await this.getParameters( + authServer, + this.issuer, + currentUrl, + ); + + if (isOAuth2Error(parameters)) { + return errorFn( + parameters.error, + parameters.error_description || "", + flow.application, + ); + } + + const oidcResponse = await this.getOIDCResponse( + authServer, + this.issuer, + `${oauthRedirectUri(this.issuer.id)}?flow=${flow.id}`, + flow.codeVerifier, + parameters, + ); + + const result = await this.processOIDCResponse( + authServer, + this.issuer, + oidcResponse, + ); + + if (isOAuth2Error(result)) { + return errorFn( + result.error, + result.error_description || "", + flow.application, + ); + } + + const { access_token } = result; + + const claims = getValidatedIdTokenClaims(result); + const { sub } = claims; + + // Validate `sub` + // Later, we'll use this to automatically set the user's data + const userInfo = await this.getUserInfo( + authServer, + this.issuer, + access_token, + sub, + ); + + return { + userInfo: userInfo, + flow: flow, + claims: claims, + }; + } +} diff --git a/server/api/api/v1/accounts/:id/statuses.test.ts b/server/api/api/v1/accounts/:id/statuses.test.ts index a796589f..41ba5882 100644 --- a/server/api/api/v1/accounts/:id/statuses.test.ts +++ b/server/api/api/v1/accounts/:id/statuses.test.ts @@ -102,7 +102,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Reply", in_reply_to_id: timeline[0].id, - federate: "false", + local_only: "true", }), }), ); diff --git a/server/api/api/v1/frontend/config/index.ts b/server/api/api/v1/frontend/config/index.ts new file mode 100644 index 00000000..9e52af8c --- /dev/null +++ b/server/api/api/v1/frontend/config/index.ts @@ -0,0 +1,21 @@ +import { applyConfig } from "@api"; +import { jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { config } from "~packages/config-manager"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 120, + }, + route: "/api/v1/frontend/config", +}); + +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return jsonResponse(config.frontend.settings); + }); diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index c8d9c292..1c1eaf49 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -80,7 +80,7 @@ export default (app: Hono) => user_count: userCount, }, thumbnail: proxyUrl(config.instance.logo), - banner: proxyUrl(config.instance.banner) ?? "", + banner: proxyUrl(config.instance.banner), title: config.instance.name, uri: config.http.base_url, urls: { @@ -88,79 +88,25 @@ export default (app: Hono) => }, version: "4.3.0-alpha.3+glitch", lysand_version: version, - pleroma: { - metadata: { - account_activation_required: false, - features: [ - "pleroma_api", - "akkoma_api", - "mastodon_api", - // "mastodon_api_streaming", - // "polls", - // "v2_suggestions", - // "pleroma_explicit_addressing", - // "shareable_emoji_packs", - // "multifetch", - // "pleroma:api/v1/notifications:include_types_filter", - "quote_posting", - "editing", - // "bubble_timeline", - // "relay", - // "pleroma_emoji_reactions", - // "exposable_reactions", - // "profile_directory", - "custom_emoji_reactions", - // "pleroma:get:main/ostatus", - ], - federation: { - enabled: true, - exclusions: false, - mrf_policies: [], - mrf_simple: { - accept: [], - avatar_removal: [], - background_removal: [], - banner_removal: [], - federated_timeline_removal: [], - followers_only: [], - media_nsfw: [], - media_removal: [], - reject: [], - reject_deletes: [], - report_removal: [], - }, - mrf_simple_info: { - media_nsfw: {}, - reject: {}, - }, - quarantined_instances: [], - quarantined_instances_info: { - quarantined_instances: {}, - }, - }, - fields_limits: { - max_fields: config.validation.max_field_count, - max_remote_fields: 9999, - name_length: config.validation.max_field_name_size, - value_length: config.validation.max_field_value_size, - }, - post_formats: [ - "text/plain", - "text/html", - "text/markdown", - "text/x.misskeymarkdown", - ], - privileged_staff: false, - }, - stats: { - mau: monthlyActiveUsers, - }, - vapid_public_key: "", + sso: { + forced: false, + providers: config.oidc.providers.map((p) => ({ + name: p.name, + icon: p.icon, + id: p.id, + })), }, contact_account: contactAccount?.toAPI() || undefined, } satisfies APIInstance & { - banner: string; + banner: string | null; lysand_version: string; - pleroma: object; + sso: { + forced: boolean; + providers: { + id: string; + name: string; + icon?: string; + }[]; + }; }); }); diff --git a/server/api/api/v1/notifications/index.test.ts b/server/api/api/v1/notifications/index.test.ts index 790f17f1..c4545834 100644 --- a/server/api/api/v1/notifications/index.test.ts +++ b/server/api/api/v1/notifications/index.test.ts @@ -86,7 +86,7 @@ beforeAll(async () => { body: new URLSearchParams({ status: `@${users[0].getUser().username} test mention`, visibility: "direct", - federate: "false", + local_only: "true", }), }), ); diff --git a/server/api/api/v1/sso/:id/index.test.ts b/server/api/api/v1/sso/:id/index.test.ts new file mode 100644 index 00000000..5938d249 --- /dev/null +++ b/server/api/api/v1/sso/:id/index.test.ts @@ -0,0 +1,67 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { config } from "~packages/config-manager"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { meta } from "./index"; + +await deleteOldTestUsers(); + +const { deleteUsers, tokens } = await getTestUsers(1); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v1/sso/:id +describe(meta.route, () => { + test("should not find unknown issuer", async () => { + const response = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", "unknown"), + config.http.base_url, + ), + { + method: "GET", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + }, + }, + ), + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + error: "Issuer not found", + }); + + const response2 = await sendTestRequest( + new Request( + new URL( + meta.route.replace(":id", "unknown"), + config.http.base_url, + ), + { + method: "DELETE", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + "Content-Type": "application/json", + }, + }, + ), + ); + + expect(response2.status).toBe(404); + expect(await response2.json()).toMatchObject({ + error: "Issuer not found", + }); + }); + + /* + Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider + setup in config, which we don't have in tests + */ +}); diff --git a/server/api/api/v1/sso/:id/index.ts b/server/api/api/v1/sso/:id/index.ts new file mode 100644 index 00000000..d2f2f6a8 --- /dev/null +++ b/server/api/api/v1/sso/:id/index.ts @@ -0,0 +1,104 @@ +import { applyConfig, auth, handleZodError, jsonOrForm } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse, response } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { OpenIdAccounts } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "DELETE"], + auth: { + required: true, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/api/v1/sso/:id", +}); + +export const schemas = { + param: z.object({ + id: z.string(), + }), +}; + +/** + * SSO Account Linking management endpoint + * A GET request allows the user to list all their linked accounts + * A POST request allows the user to link a new account + */ +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) { + return errorResponse("Unauthorized", 401); + } + + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return errorResponse("Issuer not found", 404); + } + + switch (context.req.method) { + case "GET": { + // Get all linked accounts + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, account.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return errorResponse( + "Account not found or is not linked to this issuer", + 404, + ); + } + + return jsonResponse({ + id: issuer.id, + name: issuer.name, + icon: issuer.icon, + }); + } + case "DELETE": { + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + return errorResponse( + "Account not found or is not linked to this issuer", + 404, + ); + } + + await db + .delete(OpenIdAccounts) + .where(eq(OpenIdAccounts.id, account.id)); + + return response(null, 204); + } + } + }, + ); diff --git a/server/api/api/v1/sso/index.test.ts b/server/api/api/v1/sso/index.test.ts new file mode 100644 index 00000000..2b44088e --- /dev/null +++ b/server/api/api/v1/sso/index.test.ts @@ -0,0 +1,58 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { config } from "~packages/config-manager"; +import { + deleteOldTestUsers, + getTestUsers, + sendTestRequest, +} from "~tests/utils"; +import { meta } from "./index"; + +await deleteOldTestUsers(); + +const { deleteUsers, tokens } = await getTestUsers(1); + +afterAll(async () => { + await deleteUsers(); +}); + +// /api/v1/sso +describe(meta.route, () => { + test("should return empty list", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "GET", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + }, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject([]); + }); + + test("should return an error if provider doesn't exist", async () => { + const response = await sendTestRequest( + new Request(new URL(meta.route, config.http.base_url), { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0]?.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + issuer: "unknown", + }), + }), + ); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + error: "Issuer unknown not found", + }); + }); + + /* + Unfortunately, we cannot test actual linking, as it requires a valid OpenID provider + setup in config, which we don't have in tests + */ +}); diff --git a/server/api/api/v1/sso/index.ts b/server/api/api/v1/sso/index.ts new file mode 100644 index 00000000..52147af5 --- /dev/null +++ b/server/api/api/v1/sso/index.ts @@ -0,0 +1,178 @@ +import { randomBytes } from "node:crypto"; +import { applyConfig, auth, handleZodError, jsonOrForm } from "@api"; +import { oauthRedirectUri } from "@constants"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { + calculatePKCECodeChallenge, + discoveryRequest, + generateRandomCodeVerifier, + processDiscoveryResponse, +} from "oauth4webapi"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Applications, OpenIdLoginFlows } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "POST"], + auth: { + required: true, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/api/v1/sso", +}); + +export const schemas = { + form: z + .object({ + issuer: z.string(), + }) + .partial(), +}; + +/** + * SSO Account Linking management endpoint + * A GET request allows the user to list all their linked accounts + * A POST request allows the user to link a new account, and returns a link + */ +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + jsonOrForm(), + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const form = context.req.valid("form"); + const { user } = context.req.valid("header"); + + if (!user) { + return errorResponse("Unauthorized", 401); + } + + switch (context.req.method) { + case "GET": { + // Get all linked accounts + const accounts = await db.query.OpenIdAccounts.findMany({ + where: (user, { eq }) => eq(user.userId, user.id), + }); + + return jsonResponse( + accounts + .map((account) => { + const issuer = config.oidc.providers.find( + (provider) => + provider.id === account.issuerId, + ); + + if (!issuer) { + return null; + } + + return { + id: issuer.id, + name: issuer.name, + icon: issuer.icon, + }; + }) + .filter(Boolean) as { + id: string; + name: string; + icon: string | undefined; + }[], + ); + } + case "POST": { + if (!form) { + return errorResponse( + "Missing issuer in form body", + 400, + ); + } + + const { issuer: issuerId } = form; + + if (!issuerId) { + return errorResponse( + "Missing issuer in form body", + 400, + ); + } + + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return errorResponse( + `Issuer ${issuerId} not found`, + 404, + ); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const codeVerifier = generateRandomCodeVerifier(); + + const application = ( + await db + .insert(Applications) + .values({ + clientId: + user.id + + randomBytes(32).toString("base64url"), + name: "Lysand", + redirectUri: `${oauthRedirectUri(issuerId)}`, + scopes: "openid profile email", + secret: "", + }) + .returning() + )[0]; + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + codeVerifier, + issuerId, + applicationId: application.id, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return jsonResponse({ + link: `${ + authServer.authorization_endpoint + }?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri( + issuerId, + )}?${new URLSearchParams({ + flow: newFlow.id, + link: "true", + user_id: user.id, + })}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + }); + } + } + }, + ); diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts index c7b037f3..f3f50a8f 100644 --- a/server/api/api/v1/statuses/index.test.ts +++ b/server/api/api/v1/statuses/index.test.ts @@ -64,7 +64,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "a".repeat(config.validation.max_note_size + 1), - federate: "false", + local_only: "true", }), }), ); @@ -82,7 +82,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world!", visibility: "invalid", - federate: "false", + local_only: "true", }), }), ); @@ -100,7 +100,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world!", scheduled_at: "invalid", - federate: "false", + local_only: "true", }), }), ); @@ -118,7 +118,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world!", in_reply_to_id: "invalid", - federate: "false", + local_only: "true", }), }), ); @@ -136,7 +136,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world!", quote_id: "invalid", - federate: "false", + local_only: "true", }), }), ); @@ -154,7 +154,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world!", "media_ids[]": "invalid", - federate: "false", + local_only: "true", }), }), ); @@ -171,7 +171,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "Hello, world!", - federate: "false", + local_only: "true", }), }), ); @@ -196,7 +196,7 @@ describe(meta.route, () => { body: JSON.stringify({ status: "Hello, world!", visibility: "unlisted", - federate: "false", + local_only: "true", }), }), ); @@ -219,7 +219,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "Hello, world!", - federate: "false", + local_only: "true", }), }), ); @@ -235,7 +235,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world again!", in_reply_to_id: object.id, - federate: "false", + local_only: "true", }), }), ); @@ -258,7 +258,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "Hello, world!", - federate: "false", + local_only: "true", }), }), ); @@ -274,7 +274,7 @@ describe(meta.route, () => { body: new URLSearchParams({ status: "Hello, world again!", quote_id: object.id, - federate: "false", + local_only: "true", }), }), ); @@ -300,7 +300,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "Hello, :test:!", - federate: "false", + local_only: "true", }), }), ); @@ -327,7 +327,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: `Hello, @${users[1].getUser().username}!`, - federate: "false", + local_only: "true", }), }), ); @@ -358,7 +358,7 @@ describe(meta.route, () => { status: `Hello, @${users[1].getUser().username}@${ new URL(config.http.base_url).host }!`, - federate: "false", + local_only: "true", }), }), ); @@ -389,7 +389,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "Hi! ", - federate: "false", + local_only: "true", }), }), ); @@ -417,7 +417,7 @@ describe(meta.route, () => { status: "Hello, world!", spoiler_text: "uwu ", - federate: "false", + local_only: "true", }), }), ); @@ -443,7 +443,7 @@ describe(meta.route, () => { }, body: new URLSearchParams({ status: "