···11-MIT License
11+Copyright 2025 Spark Social PBC
2233Permission is hereby granted, free of charge, to any person obtaining a copy
44of this software and associated documentation files (the "Software"), to deal
+6-11
README.md
···21212222```
2323# Database
2424-DB_HOST=localhost
2525-DB_PORT=27017
2626-DB_NAME=dev
2727-DB_USER=mongo
2828-DB_PASSWORD=mongo
2424+SPRK_DB_URI=mongodb://mongo:mongo@localhost:27017
29253026# Server
3131-HOST=0.0.0.0
3227NODE_ENV=development
3333-PORT=4000
3434-PUBLIC_URL=http://localhost:3000
3535-SERVICE_DID=did:web:localhost
2828+SPRK_PORT=4000
2929+SPRK_PUBLIC_URL=http://localhost:3000
3030+SPRK_SERVER_DID=did:web:localhost
36313732# Keys, generate these with openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
3833# On Mac: openssl ecparam -name secp256k1 -genkey -noout -outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32
3939-APPVIEW_K256_PRIVATE_KEY_HEX=keyhex
4040-ADMIN_PASSWORD=password
3434+SPRK_PRIVATE_KEY=keyhex
3535+SPRK_ADMIN_PASSWORDS=password1,password2
4136```
···11-import { HTTPException } from "hono/http-exception";
22-import { AppContext } from "../../../../main.ts";
11+import { AuthRequiredError, InvalidRequestError } from "@atp/xrpc-server";
22+import { AppContext } from "../../../../context.ts";
33import { Server } from "../../../../lex/index.ts";
44-import type * as ComAtprotoAdminDefs from "../../../../lex/types/com/atproto/admin/defs.ts";
55-import type * as ComAtprotoRepoStrongRef from "../../../../lex/types/com/atproto/repo/strongRef.ts";
66-import { AuthRequiredError } from "@atp/xrpc-server";
44+import {
55+ isRepoBlobRef,
66+ isRepoRef,
77+} from "../../../../lex/types/com/atproto/admin/defs.ts";
88+import { isMain as isStrongRef } from "../../../../lex/types/com/atproto/repo/strongRef.ts";
79810export default function (server: Server, ctx: AppContext) {
911 server.com.atproto.admin.updateSubjectStatus({
1010- auth: ctx.authVerifier.optionalStandardOrRole,
1212+ auth: ctx.authVerifier.roleOrModService,
1113 handler: async ({ input, auth }) => {
1212- const { subject, takedown } = input.body;
1313- if (!takedown || typeof takedown.applied !== "boolean") {
1414- throw new HTTPException(400, { message: "Invalid takedown status" });
1515- }
1616-1714 const { canPerformTakedown } = ctx.authVerifier.parseCreds(auth);
1815 if (!canPerformTakedown) {
1919- throw new AuthRequiredError("Requires admin privileges");
1616+ throw new AuthRequiredError(
1717+ "Must be a full moderator to update subject state",
1818+ );
2019 }
2121-2222- try {
2323- if (subject.$type === "com.atproto.admin.defs#repoRef") {
2424- const repoRef = subject as ComAtprotoAdminDefs.RepoRef;
2525- if (!repoRef.did) {
2626- throw new HTTPException(400, {
2727- message: "DID is required for repo takedowns",
2828- });
2929- }
3030-2020+ const { subject, takedown } = input.body;
2121+ if (takedown) {
2222+ if (isRepoRef(subject)) {
3123 if (takedown.applied) {
3232- await ctx.takedownService.takedownRepo({
3333- did: repoRef.did,
3434- reason: "Moderation action",
3535- adminDid: auth.credentials.type === "standard"
3636- ? auth.credentials.iss
3737- : "admin",
3838- ref: takedown.ref,
3939- });
4040- await ctx.takedownService.updateRepoTakedownApplied(
4141- repoRef.did,
4242- true,
2424+ await ctx.dataplane.moderation.takedownActor(
2525+ subject.did,
2626+ takedown.ref,
4327 );
4428 } else {
4545- await ctx.takedownService.removeRepoTakedown(repoRef.did);
4646- }
4747- } else if (subject.$type === "com.atproto.admin.defs#recordRef") {
4848- const recordRef = subject as ComAtprotoRepoStrongRef.Main;
4949- if (!recordRef.uri || !recordRef.cid) {
5050- throw new HTTPException(400, {
5151- message: "URI and CID are required for record takedowns",
5252- });
2929+ await ctx.dataplane.moderation.untakedownActor(
3030+ subject.did,
3131+ );
5332 }
5454-3333+ } else if (isStrongRef(subject)) {
5534 if (takedown.applied) {
5656- await ctx.takedownService.takedownContent({
5757- targetUri: recordRef.uri,
5858- targetCid: recordRef.cid,
5959- reason: "Moderation action",
6060- adminDid: auth.credentials.type === "standard"
6161- ? auth.credentials.iss
6262- : "admin",
6363- ref: takedown.ref,
6464- });
6565- await ctx.takedownService.updateTakedownApplied(
6666- recordRef.uri,
6767- true,
3535+ await ctx.dataplane.moderation.takedownRecord(
3636+ subject.uri,
3737+ takedown.ref,
6838 );
6939 } else {
7070- await ctx.takedownService.removeTakedown(recordRef.uri);
7171- }
7272- } else if (subject.$type === "com.atproto.admin.defs#repoBlobRef") {
7373- const repoBlobRef = subject as ComAtprotoAdminDefs.RepoBlobRef;
7474- if (!repoBlobRef.did || !repoBlobRef.cid) {
7575- throw new HTTPException(400, {
7676- message: "DID and CID are required for blob takedowns",
7777- });
4040+ await ctx.dataplane.moderation.untakedownRecord(
4141+ subject.uri,
4242+ );
7843 }
7979-4444+ } else if (isRepoBlobRef(subject)) {
8045 if (takedown.applied) {
8181- await ctx.takedownService.takedownBlob({
8282- did: repoBlobRef.did,
8383- cid: repoBlobRef.cid,
8484- reason: "Moderation action",
8585- adminDid: auth.credentials.type === "standard"
8686- ? auth.credentials.iss
8787- : "admin",
8888- ref: takedown.ref,
8989- });
9090- await ctx.takedownService.updateBlobTakedownApplied(
9191- repoBlobRef.did,
9292- repoBlobRef.cid,
9393- true,
4646+ await ctx.dataplane.moderation.takedownBlob(
4747+ subject.did,
4848+ subject.cid,
4949+ takedown.ref,
9450 );
9551 } else {
9696- await ctx.takedownService.removeBlobTakedown(
9797- repoBlobRef.did,
9898- repoBlobRef.cid,
5252+ await ctx.dataplane.moderation.untakedownBlob(
5353+ subject.did,
5454+ subject.cid,
9955 );
10056 }
10157 } else {
102102- throw new HTTPException(400, {
103103- message: `Unsupported subject type: ${subject.$type}`,
104104- });
5858+ throw new InvalidRequestError("Invalid subject");
10559 }
106106-107107- return {
108108- encoding: "application/json",
109109- body: {
110110- subject,
111111- takedown: takedown.applied ? takedown : undefined,
112112- },
113113- };
114114- } catch (err) {
115115- if (err instanceof HTTPException) throw err;
116116- throw new HTTPException(500, { message: "Internal server error" });
11760 }
6161+6262+ return {
6363+ encoding: "application/json",
6464+ body: {
6565+ subject,
6666+ takedown,
6767+ },
6868+ };
11869 },
11970 });
12071}
+1-1
api/com/atproto/identity/resolveHandle.ts
···11import * as ident from "@atp/syntax";
22import { InvalidRequestError } from "@atp/xrpc-server";
33import { Server } from "../../../../lex/index.ts";
44-import { AppContext } from "../../../../main.ts";
44+import { AppContext } from "../../../../context.ts";
5566export default function (server: Server, ctx: AppContext) {
77 server.com.atproto.identity.resolveHandle({
+1-1
api/com/atproto/repo/getRecord.ts
···11import { AtUri } from "@atp/syntax";
22import { InvalidRequestError } from "@atp/xrpc-server";
33-import { AppContext } from "../../../../main.ts";
33+import { AppContext } from "../../../../context.ts";
44import { Server } from "../../../../lex/index.ts";
5566export default function (server: Server, ctx: AppContext) {
+35
api/health.ts
···11+import { Hono } from "hono";
22+33+const app = new Hono();
44+55+app.get("/", (c) => {
66+ return c.text(
77+ `
88+.------..------..------..------..------.
99+|S.--. ||P.--. ||A.--. ||R.--. ||K.--. |
1010+| :/\\: || :/\\: || (\\/) || :(): || :/\\: |
1111+| :\\/: || (__) || :\\/: || ()() || :\\/: |
1212+| '--'S|| '--'P|| '--'A|| '--'R|| '--'K|
1313+${"`"}------'${"`"}------'${"`"}------'${"`"}------'${"`"}------'
1414+1515+1616+This is an AT Protocol Application View (AppView) for the "sprk.so" application.
1717+1818+Most API routes are under /xrpc/
1919+2020+ `,
2121+ );
2222+});
2323+2424+app.get("/robots.txt", (c) => {
2525+ return c.text(
2626+ '# Hello Friends!\n\n# Crawling the public parts of the API is allowed. HTTP 429 ("backoff") status codes are used for rate-limiting. Up to a handful concurrent requests should be ok.\nUser-agent: *\nAllow: /',
2727+ );
2828+});
2929+3030+app.get("/xrpc/_health", (c) => {
3131+ const version = Deno.env.get("COMMIT_SHA") ?? "unknown";
3232+ return c.json({ version });
3333+});
3434+3535+export default app;
+1-1
api/index.ts
···11import { Server } from "../lex/index.ts";
22-import { AppContext } from "../main.ts";
22+import { AppContext } from "../context.ts";
33import getAccountInfos from "./com/atproto/admin/getAccountInfos.ts";
44import getSubjectStatus from "./com/atproto/admin/getSubjectStatus.ts";
55import updateSubjectStatus from "./com/atproto/admin/updateSubjectStatus.ts";
+1-1
api/so/sprk/actor/getPreferences.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import { Preferences } from "../../../../lex/types/so/sprk/actor/defs.ts";
4455export default function (server: Server, ctx: AppContext) {
+1-1
api/so/sprk/actor/getProfile.ts
···11import { InvalidRequestError } from "@atp/xrpc-server";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import {
44 HydrateCtx,
55 HydrationState,
+1-1
api/so/sprk/actor/getProfiles.ts
···11import { mapDefined } from "@atp/common";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import {
44 HydrateCtx,
55 HydrationState,
+1-1
api/so/sprk/actor/putPreferences.ts
···11import { Server } from "../../../../lex/index.ts";
22import { SavedFeedsPref } from "../../../../lex/types/so/sprk/actor/defs.ts";
33-import { AppContext } from "../../../../main.ts";
33+import { AppContext } from "../../../../context.ts";
4455export default function (server: Server, ctx: AppContext) {
66 server.so.sprk.actor.putPreferences({
+1-1
api/so/sprk/actor/searchActors.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import type * as SoSprkActorSearch from "../../../../lex/types/so/sprk/actor/searchActors.ts";
44import { getProfileViews } from "../../../../utils/profile-helper.ts";
55
+1-1
api/so/sprk/feed/getAuthorFeed.ts
···11import { mapDefined } from "@atp/common";
22import { InvalidRequestError } from "@atp/xrpc-server";
33-import { AppContext } from "../../../../main.ts";
33+import { AppContext } from "../../../../context.ts";
44import { DataPlane } from "../../../../data-plane/index.ts";
55import { Actor } from "../../../../hydration/actor.ts";
66import { FeedItem, Post } from "../../../../hydration/feed.ts";
+1-1
api/so/sprk/feed/getPostThread.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import { OutputSchema } from "../../../../lex/types/so/sprk/feed/getPostThread.ts";
44import type * as SoSprkFeedDefs from "../../../../lex/types/so/sprk/feed/defs.ts";
55import { transformPostsToPostViews } from "../../../../utils/post-transformer.ts";
+1-1
api/so/sprk/feed/getPosts.ts
···11import { dedupeStrs, mapDefined } from "@atp/common";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import {
44 HydrateCtx,
55 HydrationState,
+1-1
api/so/sprk/feed/getStories.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import { OutputSchema } from "../../../../lex/types/so/sprk/feed/getStories.ts";
44import { transformStoriesToStoryViews } from "../../../../utils/story-transformer.ts";
55import { StoryDocument } from "../../../../data-plane/db/models.ts";
+1-1
api/so/sprk/feed/getStoriesTimeline.ts
···11import { InvalidRequestError } from "@atp/xrpc-server";
22import { Server } from "../../../../lex/index.ts";
33-import { AppContext } from "../../../../main.ts";
33+import { AppContext } from "../../../../context.ts";
44import { transformStoriesToStoryViews } from "../../../../utils/story-transformer.ts";
55import { decodeBase64, encodeBase64 } from "@std/encoding";
66import type { ProfileViewBasic } from "../../../../lex/types/so/sprk/actor/defs.ts";
+1-1
api/so/sprk/feed/getSuggestedFeeds.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import {
44 BskyGeneratorDocument,
55 SprkGeneratorDocument,
+1-1
api/so/sprk/feed/getTimeline.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import { transformPostsToPostViews } from "../../../../utils/post-transformer.ts";
44import { decodeBase64, encodeBase64 } from "@std/encoding";
55import { OutputSchema } from "../../../../lex/types/so/sprk/feed/getTimeline.ts";
+1-1
api/so/sprk/feed/searchPosts.ts
···11import { Server } from "../../../../lex/index.ts";
22-import { AppContext } from "../../../../main.ts";
22+import { AppContext } from "../../../../context.ts";
33import { transformPostsToPostViews } from "../../../../utils/post-transformer.ts";
44import * as SoSprkFeedDefs from "../../../../lex/types/so/sprk/feed/defs.ts";
55import { OutputSchema } from "../../../../lex/types/so/sprk/feed/searchPosts.ts";
···11import type * as SoSprkSoundDefs from "../lex/types/so/sprk/sound/defs.ts";
22import { AudioDocument } from "../data-plane/db/models.ts";
33-import { AppContext } from "../main.ts";
33+import { AppContext } from "../context.ts";
44import { createProfileViewBasic } from "./profile-helper.ts";
55import type { Label } from "../lex/types/com/atproto/label/defs.ts";
66