···11+# Replace with your DB credentials!
22+DATABASE_URL="postgres://user:password@host:port/db-name"
33+44+# Generated using `openssl rand -base64 32`
55+# Used to encrypt the DID stored in the cookies
66+ENCRYPTION_PASSWORD=<generated base 64 string here>
+18
.gitignore
···11+node_modules
22+33+# Output
44+.output
55+.vercel
66+/.svelte-kit
77+/build
88+99+# OS
1010+.DS_Store
1111+Thumbs.db
1212+1313+# Env
1414+.env
1515+1616+# Vite
1717+vite.config.js.timestamp-*
1818+vite.config.ts.timestamp-*
···11+# potatonet
22+33+Get started at [potatonet.app](https://potatonet.app) 🥔
44+55+> Special thanks to [pilcrowonpaper](https://pilcrowonpaper.com) for `@oslojs/encoding` library and the
66+[encryption gist](https://gist.github.com/pilcrowonpaper/353318556029221c8e25f451b91e5f76) that the `encryption.ts` file is based on.
bun.lockb
This is a binary file and will not be displayed.
+14
drizzle.config.ts
···11+import { defineConfig } from 'drizzle-kit';
22+if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
33+44+export default defineConfig({
55+ schema: './src/lib/server/db/schema.ts',
66+77+ dbCredentials: {
88+ url: process.env.DATABASE_URL
99+ },
1010+1111+ verbose: true,
1212+ strict: true,
1313+ dialect: 'postgresql'
1414+});
+11
drizzle/0000_slippery_sleepwalker.sql
···11+CREATE TABLE "auth_session" (
22+ "key" text PRIMARY KEY NOT NULL,
33+ "session" json NOT NULL,
44+ CONSTRAINT "auth_session_key_unique" UNIQUE("key")
55+);
66+--> statement-breakpoint
77+CREATE TABLE "auth_state" (
88+ "key" text PRIMARY KEY NOT NULL,
99+ "state" json NOT NULL,
1010+ CONSTRAINT "auth_state_key_unique" UNIQUE("key")
1111+);
···11+import { atclient } from "$lib/atproto";
22+import { AtpBaseClient, Agent } from "@atproto/api";
33+44+import { decryptToString } from "$lib/server/encryption";
55+import { decodeBase64, decodeBase64urlIgnorePadding } from "@oslojs/encoding";
66+77+import type { Handle } from "@sveltejs/kit";
88+import { ENCRYPTION_PASSWORD } from "$env/static/private";
99+1010+// runs everytime there's a new request
1111+export const handle: Handle = async ({ event, resolve }) => {
1212+ const sid = event.cookies.get("sid");
1313+1414+ // if there is a session cookie
1515+ if (sid) {
1616+ // if a user is already authed, skip reauthing
1717+ if (event.locals.user) { return resolve(event); }
1818+1919+ // decrypt session cookie
2020+ const decoded = decodeBase64urlIgnorePadding(sid);
2121+ const key = decodeBase64(ENCRYPTION_PASSWORD);
2222+ const decrypted = await decryptToString(key, decoded);
2323+2424+ // get oauth session from client using decrypted cookie
2525+ const oauthSession = await atclient.restore(decrypted);
2626+2727+ // set the authed agent
2828+ const agent = new Agent(oauthSession);
2929+ event.locals.agent = agent;
3030+3131+ // set the authed user with decrypted session DID
3232+ const user = await agent.getProfile({ actor: decrypted });
3333+ event.locals.user = user.data;
3434+ }
3535+ else {
3636+ // set public API agent
3737+ const agent = new AtpBaseClient({
3838+ service: "https://slingshot.microcosm.blue"
3939+ });
4040+ event.locals.agent = agent;
4141+ }
4242+4343+ return resolve(event);
4444+}
+30
src/lib/atproto.ts
···11+import { db } from "./server/db";
22+import { NodeOAuthClient } from "@atproto/oauth-client-node";
33+import { AuthSessionStore, AuthStateStore } from "./stores";
44+55+import { dev } from "$app/environment";
66+77+const publicUrl = "https://potatonet.app"
88+// localhost resolves to either 127.0.0.1 or [::1] (if ipv6)
99+const url = dev ? "http://[::1]:5173" : publicUrl;
1010+1111+export const atclient = new NodeOAuthClient({
1212+ stateStore: new AuthStateStore(db),
1313+ sessionStore: new AuthSessionStore(db),
1414+ clientMetadata: {
1515+ client_name: "potatonet-app",
1616+ client_id: !dev ? `${publicUrl}/client-metadata.json`
1717+ : `http://localhost?redirect_uri=${
1818+ encodeURIComponent(`${url}/oauth/callback`)
1919+ }&scope=${
2020+ encodeURIComponent(`atproto transition:generic`)
2121+ }`,
2222+ client_uri: url,
2323+ redirect_uris: [`${url}/oauth/callback`],
2424+ scope: "atproto transition:generic",
2525+ grant_types: ["authorization_code", "refresh_token"],
2626+ application_type: "web",
2727+ token_endpoint_auth_method: "none",
2828+ dpop_bound_access_tokens: true
2929+ }
3030+});
+10
src/lib/server/db/index.ts
···11+import { drizzle } from 'drizzle-orm/postgres-js';
22+import postgres from 'postgres';
33+import { env } from '$env/dynamic/private';
44+import * as schema from "./schema";
55+66+if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
77+const client = postgres(env.DATABASE_URL);
88+99+// add schema
1010+export const db = drizzle(client, { schema });
···11+import { eq } from "drizzle-orm";
22+import { db as database } from "./server/db";
33+import * as schema from "./server/db/schema";
44+import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from "@atproto/oauth-client-node";
55+66+// can be implemented with your preferred DB and ORM
77+// both stores are the same, only different is 'state' and 'session'
88+99+export class AuthStateStore implements NodeSavedStateStore {
1010+ constructor(private db: typeof database) {}
1111+1212+ async get(key: string): Promise<NodeSavedState | undefined> {
1313+ const result = await this.db.query.AuthState.findFirst({
1414+ where: eq(schema.AuthState.key, key)
1515+ });
1616+1717+ if (!result) return;
1818+1919+ return result.state as NodeSavedState;
2020+ }
2121+2222+ async set(key: string, val: NodeSavedState) {
2323+ await this.db.insert(schema.AuthState)
2424+ .values({ key, state: val })
2525+ .onConflictDoUpdate({
2626+ target: schema.AuthState.key,
2727+ set: { state: val }
2828+ });
2929+ }
3030+3131+ async del(key: string) {
3232+ await this.db.delete(schema.AuthState)
3333+ .where(eq(schema.AuthState.key, key));
3434+ }
3535+}
3636+3737+export class AuthSessionStore implements NodeSavedSessionStore {
3838+ constructor(private db: typeof database) {}
3939+4040+ async get(key: string): Promise<NodeSavedSession | undefined> {
4141+ const result = await this.db.query.AuthSession.findFirst({
4242+ where: eq(schema.AuthSession.key, key)
4343+ });
4444+4545+ if (!result) return;
4646+ return result.session as NodeSavedSession;
4747+ }
4848+4949+ async set(key: string, val: NodeSavedSession) {
5050+ await this.db.insert(schema.AuthSession)
5151+ .values({ key, session: val })
5252+ .onConflictDoUpdate({
5353+ target: schema.AuthSession.key,
5454+ set: { session: val }
5555+ });
5656+ }
5757+5858+ async del(key: string) {
5959+ await this.db.delete(schema.AuthSession)
6060+ .where(eq(schema.AuthSession.key, key));
6161+ }
6262+}
6363+
+6
src/routes/+layout.server.ts
···11+import type { ServerLoadEvent } from "@sveltejs/kit";
22+33+export async function load({ locals }: ServerLoadEvent) {
44+ // have user available throughout the app via LayoutData
55+ return { user: locals.user };
66+}
+6
src/routes/+layout.svelte
···11+<script lang="ts">
22+ import '../app.css';
33+ let { children } = $props();
44+</script>
55+66+{@render children()}
+32
src/routes/+page.server.ts
···11+import { atclient } from "$lib/atproto";
22+import { isValidHandle } from "@atproto/syntax";
33+import { error, redirect, type Actions } from "@sveltejs/kit";
44+55+export const actions: Actions = {
66+ login: async ({ request }) => {
77+ // get handle from form
88+ const formData = await request.formData();
99+ const handle = formData.get("handle") as string;
1010+1111+ // validate handle using ATProto SDK
1212+ if (!isValidHandle(handle)) {
1313+ error(400, { message: "Invalid handle" });
1414+ }
1515+1616+ // get oauth authorizing url to redirect to
1717+ const redirectUrl = await atclient.authorize(handle, {
1818+ scope: "atproto transition:generic"
1919+ });
2020+2121+ if (!redirectUrl) {
2222+ error(500, { message: "Unable to authorize" });
2323+ }
2424+2525+ // redirect for user to authorize
2626+ redirect(301, redirectUrl.toString());
2727+ },
2828+ logout: async ({ cookies }) => {
2929+ cookies.delete("sid", { path: "/" });
3030+ redirect(301, "/");
3131+ }
3232+};
···11+import { atclient } from "$lib/atproto";
22+import { json } from "@sveltejs/kit";
33+44+export async function GET() {
55+ return json(atclient.clientMetadata);
66+}
+34
src/routes/oauth/callback/+server.ts
···11+import { atclient } from "$lib/atproto";
22+import { encryptString } from "$lib/server/encryption";
33+import { decodeBase64, encodeBase64urlNoPadding } from "@oslojs/encoding";
44+55+import { error, redirect } from "@sveltejs/kit";
66+import type { RequestEvent } from "@sveltejs/kit";
77+import { ENCRYPTION_PASSWORD } from "$env/static/private";
88+99+// called on after authorizing OAuth
1010+export async function GET({ request, cookies }: RequestEvent) {
1111+ // get parameters set by the callback
1212+ const params = new URLSearchParams(request.url.split("?")[1]);
1313+1414+ try {
1515+ const { session } = await atclient.callback(params);
1616+ const key = decodeBase64(ENCRYPTION_PASSWORD);
1717+1818+ // encrypt the user DID
1919+ const encrypted = await encryptString(key, session.did);
2020+ const encoded = encodeBase64urlNoPadding(encrypted);
2121+2222+ // set encoded session DID as cookies for auth
2323+ cookies.set("sid", encoded, {
2424+ path: "/",
2525+ maxAge: 60 * 60,
2626+ httpOnly: true,
2727+ sameSite: "lax"
2828+ });
2929+ } catch (err) {
3030+ error(500, { message: (err as Error).message });
3131+ }
3232+3333+ redirect(301, "/");
3434+}
static/favicon.png
This is a binary file and will not be displayed.
+18
svelte.config.js
···11+import adapter from '@sveltejs/adapter-auto';
22+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
33+44+/** @type {import('@sveltejs/kit').Config} */
55+const config = {
66+ // Consult https://svelte.dev/docs/kit/integrations
77+ // for more information about preprocessors
88+ preprocess: vitePreprocess(),
99+1010+ kit: {
1111+ // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
1212+ // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
1313+ // See https://svelte.dev/docs/kit/adapters for more information about adapters.
1414+ adapter: adapter()
1515+ }
1616+};
1717+1818+export default config;
···11+{
22+ "extends": "./.svelte-kit/tsconfig.json",
33+ "compilerOptions": {
44+ "allowJs": true,
55+ "checkJs": true,
66+ "esModuleInterop": true,
77+ "forceConsistentCasingInFileNames": true,
88+ "resolveJsonModule": true,
99+ "skipLibCheck": true,
1010+ "sourceMap": true,
1111+ "strict": true,
1212+ "moduleResolution": "bundler"
1313+ }
1414+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1515+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1616+ //
1717+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
1818+ // from the referenced tsconfig.json - TypeScript does not merge them in
1919+}
+7
vite.config.ts
···11+import { sveltekit } from '@sveltejs/kit/vite';
22+import tailwindcss from '@tailwindcss/vite';
33+import { defineConfig } from 'vite';
44+55+export default defineConfig({
66+ plugins: [sveltekit(), tailwindcss()]
77+});