···11+#Required, your sqlite db location
22+DATABASE_URL=local.db
33+44+#OAuth Setup
55+66+#Most likely 127.0.01:5173 if it's dev (empty is also that), or if you have a domain on prod like pdsmoover.com
77+#OAUTH_DOMAIN=127.0.01:5173
88+#Optional
99+#OAUTH_CLIENT_NAME="SvelteKit template"
1010+#OAUTH_LOGO_URI="url to a picture"
1111+#OAUTH_SCOPES="atproto transition:generic"
1212+#node ./bin/gen-jwk.js and only if you want the confendenial client which gets you longer logins
1313+#OAUTH_JWK=
1414+1515+#DEV set means it will always use the local loop back release
1616+DEV=true
1717+1818+#Optional microcosm settings if you'd like to have a differnt instance. Defaults to these if not set
1919+#PUBLIC_SLINGSHOT_ENDPOINT=https://slingshot.microcosm.blue
2020+#PUBLIC_CONSTELLATION_ENDPOINT=https://constellation.microcosm.blue
2121+
···11+FROM node:24-slim AS builder
22+WORKDIR /app
33+44+COPY package.json ./
55+RUN npm install
66+77+COPY . .
88+#Needs a place holder during build
99+ENV DATABASE_URL="placeholder"
1010+RUN npm run build
1111+1212+FROM node:24-alpine3.22 AS web-app
1313+WORKDIR /app
1414+1515+COPY --from=builder /app/build /app/dist
1616+COPY --from=builder /app/package.json /app/
1717+COPY ./drizzle /app/drizzle
1818+ENV MIGRATIONS_FOLDER="/app/drizzle"
1919+2020+RUN npm install --prod
2121+2222+#This is saying run what's in the dist folder
2323+CMD ["node", "/app/dist/index.js"]
+54
README.md
···11+# ATProto SvelteKit Template
22+33+44+OAuth already figured out for you, a minimal template with ease of use oauth confidential client and local development. Along with a demo how to use the logged-in client. This is just a starting point, it's up to you to build the application.
55+66+## Features
77+- OAuth client configured from `.env` variables
88+ - `DEV=true` allows local development, do not need a public url
99+ - removing `DEV=true` and setting `OAUTH_DOMAIN` is production and requires a public url like [demo.atpoke.xyz](https://demo.atpoke.xyz)
1010+ - Setting `OAUTH_JWK` allows for the confidential client that lasts much longer (forever if you refresh the tokens, 180 for indivudal refresh tokens). Can get a value for it from running `node ./bin/gen-jwk.js`
1111+- Server side sessions and automatic loading of the atproto client from `event.locals.atpAgent` on server side components.
1212+- Examples on how to create atproto records with [pokes or making a Bluesky post](./src/routes/demo/+page.server.ts)
1313+- Examples showing how to use [microcosm.blue](https://microcosm.blue/) tooling for an appview like experince without the appview.
1414+ - See how many people and who [have poked](./src/routes/demo/+page.svelte) you with [constellation](https://constellation.microcosm.blue/)
1515+ - Find the [handles from the did](./src/routes/demo/+page.svelte) easily with [slingshot](https://slingshot.microcosm.blue/)
1616+1717+## Dev Setup
1818+1. Copy [.env.example](.env.example) to [.env](.env), .env.example is dev settings
1919+2. `pnpm install`
2020+3. May need to run `pnpm approve-builds` for the build scripts for sqlite
2121+4. `pnpm run dev` or `pnpm run dev:logging` with [pino-pretty](https://github.com/pinojs/pino-pretty) for pretty logging
2222+2323+> If you are running locally on a different port than `5173` or something else odd can set `OAUTH_DOMAIN` .env to the domain and port. Just make sure to use either `127.0.0.1` or `[::1]`(ipv6) for oauth to work for local development.
2424+2525+## Production
2626+2727+> Sign up for Railway with my referral code [z49xDi](https://railway.com?referralCode=z49xDi) to get $20 in credits, and if you spend anything, I get 15% in credits.
2828+2929+### Railway
3030+1. Install the railway cli ([directions here](https://docs.railway.com/guides/cli#installing-the-cli))
3131+2. Login with `railway login`
3232+3. Create a new project with `railway init`, set your project name
3333+4. Deploy your webapp with `railway up`, this will create a new deployment. This will crash on the first run since we still have some changes to make. This is what uploads your code to railway. That is expected since we don't have a volume and our variables yet.
3434+
3535+5. `railway service` select the service you deployed earlier, name is most likely the same as the project name.
3636+6. Run `railway volume add -m /app_data` to create a persistent volume for the sqlite database.
3737+7. If you do not already have your project dashboard open you can open it with `railway open`, this opens it in a web browser.
3838+8. Click on your service, then Variables. Add the following variables:
3939+ * `OAUTH_DOMAIN` - your domain name
4040+ * `OAUTH_JWK` - the value from `node ./bin/gen-jwk.js`
4141+ * `DATABASE_URL` - `/app_data/local.db`
4242+
4343+9. Go to settings and select "Custom Domain" to add your domain name. Follow the directions there
4444+
4545+4646+And then to update your project going forward you can run `railway up` again.
4747+4848+### Local server like a VPS
4949+1. [Install docker](https://docs.docker.com/engine/install/)
5050+2. Copy [.env.example](.env.example) to [.env](.env) and fill in the variables. Make sure to remove `DEV=true`
5151+3. `docker-compose up`
5252+5353+> The docker compose comes with Caddy, if you have another reverse proxy you can remove it from the docker compose and just reverse proxy to port 3000.
5454+> You may also have to play around with the Caddyfile depending on your setup.
···11+// This wrappers around https://tangled.org/mary.my.id/atcute packages that call https://constellation.microcosm.blue
22+// Constellation is a great way to get how records are connected without an appview, like seeing who and how many people has poked you!
33+import { Client, ok, simpleFetchHandler } from '@atcute/client';
44+import { env } from '$env/dynamic/public';
55+import type {} from '@atcute/microcosm';
66+import type { Did } from '@atproto/api';
77+88+//Loads who has poked you using https://constellation.microcosm.blue
99+export const getPokes = async (did: string, count: number = 10, cursor: string | undefined = undefined) => {
1010+ const constellation = new Client({
1111+ handler: simpleFetchHandler({ service: env.PUBLIC_CONSTELLATION_ENDPOINT ?? 'https://constellation.microcosm.blue' }),
1212+ });
1313+ const backlinks = await ok(
1414+ constellation.get('blue.microcosm.links.getBacklinks', {
1515+ params: {
1616+ subject: did as Did,
1717+ source: 'xyz.atpoke.graph.poke:subject',
1818+ limit: count,
1919+ cursor
2020+ },
2121+ }),
2222+ );
2323+2424+ return backlinks;
2525+};
2626+2727+//Gets the users handle via https://slingshot.microcosm.blue
2828+//You will want to somewhat cache the results from this. I am on the front end so if someone pokes someone 10 times it only resolves the handle once
2929+export const getHandle = async (did: string) => {
3030+ const slingshot = new Client({
3131+ handler: simpleFetchHandler({ service: env.PUBLIC_SLINGSHOT_ENDPOINT ?? 'https://slingshot.microcosm.blue' }),
3232+ });
3333+3434+ const resolved = await ok(
3535+ slingshot.get('com.bad-example.identity.resolveMiniDoc', {
3636+ params: {
3737+ identifier: did as Did,
3838+ },
3939+ }),
4040+ );
4141+4242+ return resolved.handle;
4343+};
+1
src/lib/index.ts
···11+// place files you want to import through the `$lib` alias in this folder.
+79
src/lib/server/atproto/client.ts
···11+// Loads all your OAuth settings from here and then uses the client everywhere else. metadata endpoint, jwks, etc
22+33+import { atprotoLoopbackClientMetadata, Keyset, NodeOAuthClient } from '@atproto/oauth-client-node';
44+import { JoseKey } from '@atproto/jwk-jose';
55+import { db } from '$lib/server/db';
66+import { SessionStore, StateStore } from '$lib/server/atproto/storage';
77+import { env } from '$env/dynamic/private';
88+import type { OAuthClientMetadataInput } from '@atproto/oauth-types';
99+1010+//You will need to change these if you are using another collection, can also change by setting the env OAUTH_SCOPES
1111+//For permission to all you can uncomment below
1212+// const DEFAULT_SCOPES = 'atproto transition:generic';
1313+const DEFAULT_SCOPES = 'atproto repo:app.bsky.feed.post?action=create repo:xyz.atpoke.graph.poke';
1414+const loadJwk = async () => {
1515+ const raw = env.OAUTH_JWK;
1616+ if (!raw) return undefined;
1717+ const json = JSON.parse(raw);
1818+ if (!json) return undefined;
1919+ const keys = await Promise.all(
2020+ json.map((jwk: string | Record<string, unknown>) => JoseKey.fromJWK(jwk)),
2121+ );
2222+ return new Keyset(keys);
2323+};
2424+2525+2626+let client: Promise<NodeOAuthClient> | null = null;
2727+2828+export const atpOAuthClient = async () => {
2929+ if (!client) {
3030+ client = (async () => {
3131+ const rootDomain = env.OAUTH_DOMAIN ?? '127.0.0.1:5173';
3232+ const dev = env.DEV !== undefined;
3333+ const isConfidential = env.OAUTH_JWK !== undefined;
3434+3535+ if(!dev && env.OAUTH_DOMAIN === undefined){
3636+ throw new Error('OAUTH_DOMAIN must be set in production');
3737+ }
3838+3939+ const keyset = env.OAUTH_JWK && env.OAUTH_DOMAIN
4040+ ? await loadJwk()
4141+ : undefined;
4242+ // @ts-expect-error I have no idea why it doesn't like use
4343+ const pk = keyset?.findPrivateKey({ use: 'sig' });
4444+ const rootUrl = `https://${rootDomain}`;
4545+ const clientMetadata: OAuthClientMetadataInput = dev
4646+ ? atprotoLoopbackClientMetadata(
4747+ `http://localhost?${new URLSearchParams([
4848+ ['redirect_uri', `http://${rootDomain}/oauth/callback`],
4949+ ['scope', env.OAUTH_SCOPES ?? DEFAULT_SCOPES],
5050+ ])}`,
5151+ ) :
5252+ {
5353+ client_name: env.OAUTH_CLIENT_NAME,
5454+ logo_uri: env.OAUTH_LOGO_URI,
5555+ client_id: `${rootUrl}/oauth-client-metadata.json`,
5656+ client_uri: rootUrl,
5757+ redirect_uris: [`${rootUrl}/oauth/callback`],
5858+ scope: env.OAUTH_SCOPES ?? DEFAULT_SCOPES,
5959+ grant_types: ['authorization_code', 'refresh_token'],
6060+ application_type: 'web',
6161+ token_endpoint_auth_method: isConfidential ? 'private_key_jwt' : 'none',
6262+ dpop_bound_access_tokens: true,
6363+ jwks_uri: isConfidential ? `${rootUrl}/.well-known/jwks.json` : undefined,
6464+ token_endpoint_auth_signing_alg: isConfidential ? pk?.alg : undefined
6565+ };
6666+6767+ return new NodeOAuthClient({
6868+ stateStore: new StateStore(db),
6969+ sessionStore: new SessionStore(db),
7070+ keyset,
7171+ clientMetadata,
7272+ // Not needed since this all runs locally to one machine I believe. But if you do run multiple instances and change out the DB from sqlite may need this
7373+ // https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client-node#requestlock
7474+ requestLock: undefined,
7575+ });
7676+ })();
7777+ }
7878+ return client;
7979+};
+56
src/lib/server/atproto/storage.ts
···11+//Inter faces required by oquth-client-node for storing sessions and state
22+33+import type {
44+ NodeSavedSession,
55+ NodeSavedSessionStore,
66+ NodeSavedState,
77+ NodeSavedStateStore
88+} from '@atproto/oauth-client-node';
99+import { Cache, SESSION_STORE, STATE_STORE } from '$lib/server/cache';
1010+import { db } from '$lib/server/db';
1111+1212+export class StateStore implements NodeSavedStateStore{
1313+1414+ cache: Cache;
1515+1616+ constructor(database: typeof db) {
1717+ this.cache = new Cache(database, STATE_STORE);
1818+ }
1919+2020+ async del(key: string) {
2121+ await this.cache.delete(key);
2222+ }
2323+2424+ async get(key: string){
2525+ const value = await this.cache.get(key);
2626+ return value ? JSON.parse(value) as NodeSavedState : undefined;
2727+ }
2828+2929+ async set(key: string, value: NodeSavedState) {
3030+ const json = JSON.stringify(value);
3131+ await this.cache.set(key, json);
3232+ }
3333+}
3434+3535+export class SessionStore implements NodeSavedSessionStore{
3636+3737+ cache: Cache;
3838+3939+ constructor(database: typeof db) {
4040+ this.cache = new Cache(database, SESSION_STORE);
4141+ }
4242+4343+ async del(key: string) {
4444+ await this.cache.delete(key);
4545+ }
4646+4747+ async get(key: string){
4848+ const value = await this.cache.get(key);
4949+ return value ? JSON.parse(value) as NodeSavedSession : undefined;
5050+ }
5151+5252+ async set(key: string, value: NodeSavedSession) {
5353+ const json = JSON.stringify(value);
5454+ await this.cache.set(key, json);
5555+ }
5656+}
+45
src/lib/server/cache.ts
···11+// A key value key to the database that is mostly used for atproto session storage and state storage during the oauth session creation
22+// The "stores" are divided up by a where on the store type so it can share the same interface just with that
33+44+import { db } from './db';
55+import { keyValueStore } from './db/schema';
66+import { and, eq } from 'drizzle-orm';
77+88+export const SESSION_STORE = 'sessions';
99+export const STATE_STORE = 'states';
1010+1111+export class Cache {
1212+1313+ db: typeof db;
1414+ cacheName: string;
1515+1616+ constructor(database: typeof db, cacheName: string) {
1717+ this.db = database;
1818+ this.cacheName = cacheName;
1919+ }
2020+2121+ async get(key: string) {
2222+ const result = await this.db.select().from(keyValueStore).where(and(
2323+ eq(keyValueStore.key, key),
2424+ eq(keyValueStore.storeName, this.cacheName)
2525+ )).limit(1);
2626+ if(result.length > 0){
2727+ return result[0].value;
2828+ }
2929+ return null;
3030+ }
3131+3232+ async set(key: string, value: string) {
3333+ return this.db.insert(keyValueStore)
3434+ .values({ key, value, storeName: this.cacheName, createdAt: new Date() })
3535+ .onConflictDoUpdate({
3636+ target: keyValueStore.key,
3737+ set: { value, createdAt: new Date() }
3838+ });
3939+ }
4040+4141+ async delete(key: string) {
4242+ return this.db.delete(keyValueStore).where(eq(keyValueStore.key, key));
4343+ }
4444+4545+}
+11
src/lib/server/db/index.ts
···11+import { drizzle } from 'drizzle-orm/better-sqlite3';
22+import Database from 'better-sqlite3';
33+import * as schema from './schema';
44+import { env } from '$env/dynamic/private';
55+import { logger } from '$lib/server/logger';
66+77+if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
88+99+const client = new Database(env.DATABASE_URL);
1010+logger.info('Connected to database');
1111+export const db = drizzle(client, { schema });
+19
src/lib/server/db/schema.ts
···11+import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
22+33+export const keyValueStore = sqliteTable('key_value_store', {
44+ key: text('key').primaryKey(),
55+ value: text('value'),
66+ storeName: text('storeName'),
77+ createdAt: integer({ mode: 'timestamp' }) // Date
88+});
99+1010+1111+export const sessionStore = sqliteTable('session_store', {
1212+ id: text('id').primaryKey(),
1313+ //Not leaving unique since it could be multiple logins from the same user across browsers
1414+ did: text('did').notNull(),
1515+ handle: text('handle').notNull(),
1616+ createdAt: integer({ mode: 'timestamp' }).notNull(), // Date
1717+ expiresAt: integer({ mode: 'timestamp' }).notNull() // Date
1818+1919+});
+3
src/lib/server/logger.ts
···11+import pino from 'pino';
22+33+export const logger = pino();
+137
src/lib/server/session.ts
···11+// A cookie session store based on https://lucia-auth.com/ examples which is recommended from Svelte's docs
22+// Creates a cookie that links to a session store inside the database allowing the atproto oauth session to be loaded
33+44+import { db } from './db';
55+import { atpOAuthClient } from './atproto/client';
66+import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
77+import { sha256 } from '@oslojs/crypto/sha2';
88+import { DAY } from '@atproto/common';
99+import { sessionStore } from '$lib/server/db/schema';
1010+import type { RequestEvent } from '@sveltejs/kit';
1111+import { eq } from 'drizzle-orm';
1212+import { Agent } from '@atproto/api';
1313+import type { NodeOAuthClient } from '@atproto/oauth-client-node';
1414+import { logger } from '$lib/server/logger';
1515+1616+1717+// This is a sliding expiration for the cookie session. Can change it if you want it to be less or more.
1818+// The actual atproto session goes for a while if it's a confidential client as long as it's refreshed
1919+// https://atproto.com/specs/oauth#tokens-and-session-lifetime
2020+const DEFAULT_EXPIRY = 30 * DAY;
2121+2222+const NULL_SESSION_RESPONSE = { atpAgent: null, did: null, handle: null };
2323+2424+2525+export class Session {
2626+ db: typeof db;
2727+ atpOAuthClient: NodeOAuthClient;
2828+2929+ constructor(database: typeof db, oauthClient: NodeOAuthClient) {
3030+ this.db = database;
3131+ this.atpOAuthClient = oauthClient;
3232+ }
3333+3434+3535+ async validateSessionToken(token: string): Promise<SessionValidationResult> {
3636+ const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
3737+ const result = await this.db.select().from(sessionStore).where(eq(sessionStore.id, sessionId)).limit(1);
3838+ if(result.length > 1){
3939+ throw new Error('Multiple sessions found for token. Should not happen');
4040+ }
4141+ if(result.length === 0){
4242+ return NULL_SESSION_RESPONSE;
4343+ }
4444+ const session = result[0];
4545+4646+ if (Date.now() >= session.expiresAt.getTime()) {
4747+ await this.invalidateSession(session.id);
4848+ logger.warn(`Session expired for the did: ${session.did}`);
4949+ return NULL_SESSION_RESPONSE;
5050+ }
5151+ if (Date.now() >= session.expiresAt.getTime() - 1000 * 60 * 60 * 24 * 15) {
5252+ session.expiresAt = new Date(Date.now() + DEFAULT_EXPIRY);
5353+ await this.db.update(sessionStore).set(session).where(eq(sessionStore.id, sessionId));
5454+ }
5555+5656+ const oAuthSession = await this.atpOAuthClient.restore(session.did);
5757+ const agent = new Agent(oAuthSession);
5858+ return { atpAgent: agent, did: session.did, handle: session.handle };
5959+ }
6060+6161+ private setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date): void {
6262+ event.cookies.set('session', token, {
6363+ httpOnly: true,
6464+ path: '/',
6565+ secure: import.meta.env.PROD,
6666+ sameSite: 'lax',
6767+ expires: expiresAt
6868+ });
6969+ }
7070+7171+ deleteSessionTokenCookie(event: RequestEvent): void {
7272+ event.cookies.set('session', '', {
7373+ httpOnly: true,
7474+ path: '/',
7575+ secure: import.meta.env.PROD,
7676+ sameSite: 'lax',
7777+ maxAge: 0
7878+ });
7979+ }
8080+8181+ async invalidateSession(sessionId: string) {
8282+ await this.db.delete(sessionStore).where(eq(sessionStore.id, sessionId));
8383+ }
8484+8585+ async invalidateSessionByToken(token: string) {
8686+ const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
8787+ await this.invalidateSession(sessionId);
8888+ }
8989+9090+ async invalidateUserSessions(did: string) {
9191+ await this.db.delete(sessionStore).where(eq(sessionStore.did, did));
9292+ }
9393+9494+ private async createSession(token: string, did: string, handle: string) {
9595+ const sessionId = encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
9696+ const expiresAt = new Date(Date.now() + DEFAULT_EXPIRY);
9797+ const session = { id: sessionId, did, handle, expiresAt, createdAt: new Date() };
9898+ await this.db.insert(sessionStore).values(session);
9999+ return session;
100100+ }
101101+102102+ private generateSessionToken(): string {
103103+ const tokenBytes = new Uint8Array(20);
104104+ crypto.getRandomValues(tokenBytes);
105105+ return encodeBase32LowerCaseNoPadding(tokenBytes);
106106+ }
107107+108108+ async createAndSetSession(event: RequestEvent, did: string, handle: string) {
109109+ const token = this.generateSessionToken();
110110+ const session = await this.createSession(token, did, handle);
111111+ this.setSessionTokenCookie(event, token, session.expiresAt);
112112+ return session;
113113+ }
114114+115115+ async getSessionFromRequest(event: RequestEvent): Promise<SessionValidationResult> {
116116+ const token = event.cookies.get('session');
117117+ if (!token) {
118118+ return NULL_SESSION_RESPONSE;
119119+ }
120120+ return this.validateSessionToken(token);
121121+ }
122122+123123+}
124124+125125+type SessionValidationResult = { atpAgent: Agent, did: string, handle: string } | { atpAgent: null, did: null, handle: null };
126126+127127+let sessionManager: Promise<Session> | null = null;
128128+129129+export const getSessionManager = async (): Promise<Session> => {
130130+ if (!sessionManager) {
131131+ sessionManager = (async () => {
132132+ const client = await atpOAuthClient();
133133+ return new Session(db, client);
134134+ })();
135135+ }
136136+ return sessionManager;
137137+};
···11+<script lang="ts">
22+ import type { PageProps } from './$types';
33+ let { data }: PageProps = $props();
44+</script>
55+66+<style>
77+ a {
88+ text-decoration: underline darkblue;
99+ }
1010+</style>
1111+1212+<div>
1313+ <h1>SvelteKit ATProtocol OAuth template</h1>
1414+ <h3>A build your own ATProto adventure</h3>
1515+ <p> It's up to you on what this project should be and what it looks like. This is mostly just a technical preview showing a demo of what the application looks like and how it works. But if you <a href="/login">Login</a> you can poke people or see who poked you, so there's that at least.</p>
1616+ <h3>A bare minimal SvelteKit demo that...</h3>
1717+ <ul>
1818+ <li>Can have local dev oauth with setting the <code>.env</code> variable <code>DEV=true</code></li>
1919+ <li>Can switch to production oauth when you set the <code>OAUTH_DOMAIN</code>
2020+ variable to a domain like <code>mycoolwebsite.xyz</code>
2121+ and making sure the website is server to the internet</li>
2222+ <li>A persistent session store using <a href="https://orm.drizzle.team/">drizzle</a> with sqlite</li>
2323+ <li>A docker compose and documentation on how to deploy to <a href="https://railway.com/">railway</a></li>
2424+ <li><a href="/demo">An example page using the atproto Agent where you can make a post or poke someone</a> </li>
2525+ <li>Source code can be found on <a href="https://tangled.org/baileytownsend.dev/atproto-sveltekit-template">tangled.org</a> </li>
2626+ </ul>
2727+ <br/>
2828+ {#if !data.session}
2929+ <a href="/login">Login</a>
3030+ {/if}
3131+</div>
+14
src/routes/.well-known/jwks.json/+server.ts
···11+//Loads the public jwk's which are needed for a confidential client
22+import type { RequestHandler } from './$types';
33+import { json } from '@sveltejs/kit';
44+import { atpOAuthClient } from '$lib/server/atproto/client';
55+66+export const GET: RequestHandler = async () => {
77+ const client = await atpOAuthClient();
88+99+ return json(client.jwks, {
1010+ headers: {
1111+ 'Cache-Control': 'no-store'
1212+ }
1313+ });
1414+};
+93
src/routes/demo/+page.server.ts
···11+import type { PageServerLoad } from './$types';
22+import { type Actions, fail, redirect } from '@sveltejs/kit';
33+import { RichText } from '@atproto/api';
44+import { logger } from '$lib/server/logger';
55+66+export const load: PageServerLoad = async (event) => {
77+ if(!event.locals.session) {
88+ return redirect(302, '/login');
99+ }
1010+ return { usersDid: event.locals.session.did };
1111+};
1212+1313+1414+export const actions = {
1515+ makeAPost: async (event) => {
1616+ try{
1717+ const agent = event.locals.atpAgent;
1818+ if (!agent) {
1919+ return fail(401, { error: 'Not authenticated' });
2020+ }
2121+2222+ const form = await event.request.formData();
2323+ const text = String(form.get('post_text') ?? '').trim();
2424+ const rt = new RichText({ text });
2525+ // Automatically detect mentions, links, hashtags
2626+ await rt.detectFacets(agent);
2727+2828+ const record = {
2929+ $type: 'app.bsky.feed.post',
3030+ text: rt.text,
3131+ facets: rt.facets,
3232+ createdAt: new Date().toISOString(),
3333+ };
3434+3535+ const result = await agent.com.atproto.repo.createRecord({
3636+ repo: event.locals.session!.did,
3737+ collection: 'app.bsky.feed.post',
3838+ record: record,
3939+ //Since this is a known lexicon to the PDS we can validate it on creation
4040+ validate: true
4141+ });
4242+4343+4444+ return { success: true, result: result.data };
4545+4646+ }
4747+ catch (err) {
4848+ const errorMessage = (err as Error).message;
4949+ logger.error(errorMessage);
5050+ return fail(400, { error: errorMessage });
5151+ }
5252+ },
5353+ poke: async (event) => {
5454+ try{
5555+ const agent = event.locals.atpAgent;
5656+ if (!agent) {
5757+ return fail(401, { error: 'Not authenticated' });
5858+ }
5959+6060+ const form = await event.request.formData();
6161+ const handle = String(form.get('handle') ?? '').trim();
6262+6363+6464+ const toPokesDid = await agent.com.atproto.identity.resolveHandle({ handle });
6565+6666+ // Can view the lexicon schema at the below url. just doing plain json for now instead of a strong type
6767+ // https://selfhosted.social/xrpc/com.atproto.repo.getRecord?repo=did:plc:rnpkyqnmsw4ipey6eotbdnnf&collection=com.atproto.lexicon.schema&rkey=xyz.atpoke.graph.poke
6868+ // Ideally you should type it and pull it in and all that good stuff, but yeah you can just send json as well
6969+ // The correct way would be using something like this https://github.com/bluesky-social/atproto/tree/main/packages/lex/lex
7070+7171+ const record = {
7272+ $type: 'xyz.atpoke.graph.poke',
7373+ subject: toPokesDid.data.did,
7474+ createdAt: new Date().toISOString(),
7575+ };
7676+7777+ await agent.com.atproto.repo.createRecord({
7878+ repo: event.locals.session!.did,
7979+ collection: 'xyz.atpoke.graph.poke',
8080+ record: record,
8181+ });
8282+8383+ return { pokeResult: `You just poked ${handle}!`, pokedHandle: handle };
8484+8585+ }
8686+ catch (err) {
8787+ const errorMessage = (err as Error).message;
8888+ logger.error(errorMessage);
8989+ return fail(400, { error: errorMessage });
9090+ }
9191+ }
9292+} satisfies Actions;
9393+
+120
src/routes/demo/+page.svelte
···11+<script lang="ts">
22+ import type { PageProps } from './$types';
33+ import { enhance } from '$app/forms';
44+ import HandleInput from '$lib/components/HandleInput.svelte';
55+ import { getHandle, getPokes } from '$lib/constellation';
66+77+ type Poker = { handle: string, did: string, pokes: number };
88+ let pokers = $state<Poker[]>([]);
99+ let { form, data }: PageProps = $props();
1010+ let handleQuery = $state('');
1111+ let constellationCursor = $state<string | undefined>(undefined);
1212+1313+ const bskyPostText = (handle: string) => encodeURIComponent(`I just poked @${handle} with demo.atpoke.xyz`);
1414+1515+ const didWeAlreadyFindTheHandle = (did: string) => pokers.find((p) => p.did === did);
1616+1717+ //Shows how you can load backlinks from constellation to see who has poked you.
1818+ const loadWhoPoked = async () => {
1919+ //Gets a list of who all has poked you from constellation
2020+ const backLinks = await getPokes(data.usersDid, 25, constellationCursor);
2121+ constellationCursor = backLinks.cursor;
2222+ if(backLinks.records.length > 0 ){
2323+ for (const record of backLinks.records) {
2424+ //No need to do a second call if we already found the handle
2525+ const alreadyFoundHandle = didWeAlreadyFindTheHandle(record.did);
2626+ if(alreadyFoundHandle){
2727+ alreadyFoundHandle.pokes++;
2828+ }else{
2929+ pokers.push({
3030+ //Example showing you how you can easily get a handle from a did with slingshot
3131+ handle: await getHandle(record.did),
3232+ did: record.did,
3333+ pokes: 1
3434+ });
3535+ }
3636+ }
3737+ }
3838+ };
3939+4040+4141+</script>
4242+4343+<style>
4444+ .error {
4545+ color: red;
4646+ }
4747+4848+ textarea {
4949+ width: 75%;
5050+ height: 100px;
5151+ }
5252+5353+ .poke-form {
5454+ display: flex;
5555+ flex-direction: row;
5656+ align-items: center;
5757+ }
5858+5959+ .poke-button {
6060+ align-self: start;
6161+ }
6262+6363+ .give-me-some-space {
6464+ margin-bottom: 10px
6565+ }
6666+</style>
6767+6868+{#if form?.error}
6969+ <p class="error">{form.error}</p>
7070+{/if}
7171+7272+<!--Make a bluesky post demo-->
7373+7474+<form method="POST" action="/demo?/makeAPost" use:enhance>
7575+ <h1>Make a Bluesky post</h1>
7676+ <textarea name="post_text" rows="5" cols="60">
7777+I am trying out @baileytownsend.dev's new atproto SvelteKit template.
7878+7979+https://tangled.org/baileytownsend.dev/atproto-sveltekit-template
8080+ </textarea>
8181+ <br />
8282+ <button type="submit">Post</button>
8383+ {#if form?.success}
8484+ <p>Success! The post has been made</p>
8585+ {/if}
8686+ {#if form?.result}
8787+ <pre>{JSON.stringify(form.result, null, 2)}</pre>
8888+ {/if}
8989+</form>
9090+9191+<!--Poke demo-->
9292+<h1>Poke someone</h1>
9393+<h3>You have been poked {data.totalPoked} times</h3>
9494+<div>
9595+ <a href="https://ufos.microcosm.blue/collection/?nsid=xyz.atpoke.graph.poke">View xyz.atproto.graph.pokes on ufos</a>
9696+</div>
9797+9898+<br/>
9999+{#if pokers.length > 0}
100100+ <ul>
101101+ {#each pokers as poker, index (index)}
102102+ <li> <a href="{`https://bsky.app/profile/${poker.did}`}">{poker.handle}</a> {poker.pokes} times </li>
103103+ {/each}
104104+ </ul>
105105+{/if}
106106+{#if data.totalPoked > 0 && constellationCursor !== null}
107107+ <button class="give-me-some-space" type="submit" onclick={loadWhoPoked}>{constellationCursor === undefined ? 'Load who poked you': 'Load more'}</button>
108108+{/if}
109109+<br/>
110110+111111+<form method="POST" action="/demo?/poke" use:enhance>
112112+ <div class="poke-form">
113113+ <HandleInput name="handle" bind:value={handleQuery} placeholder="Handle to poke" required showDisplayName/>
114114+ <button class="poke-button" type="submit">Poke</button>
115115+ </div>
116116+ {#if form?.pokeResult}
117117+ <span>{form.pokeResult}</span>
118118+ <a href={`https://bsky.app/intent/compose?text=${bskyPostText(form?.pokedHandle)}`}>Tell them about it on Bluesky!</a>
119119+ {/if}
120120+</form>
···11+import type { RequestHandler } from './$types';
22+import { isRedirect, type RequestEvent } from '@sveltejs/kit';
33+import { atpOAuthClient } from '$lib/server/atproto/client';
44+import { getSessionManager } from '$lib/server/session';
55+import { error, redirect } from '@sveltejs/kit';
66+import { logger } from '$lib/server/logger';
77+import { Agent } from '@atproto/api';
88+99+export const GET: RequestHandler = async (event: RequestEvent) => {
1010+ try{
1111+ const params = new URLSearchParams(event.request.url.split('?')[1]);
1212+ const client = await atpOAuthClient();
1313+ const { session } = await client.callback(params);
1414+1515+ //Should error out if we can't make a successful getSession call
1616+ const agent = new Agent(session);
1717+ const atpSession = await agent.com.atproto.server.getSession();
1818+ const sessionManager = await getSessionManager();
1919+ await sessionManager.createAndSetSession(event, session.did, atpSession.data.handle);
2020+2121+ // const agent = new Agent(session);
2222+ // const dateStamp = new Date().toISOString();
2323+ // let token
2424+ // await agent.com.atproto.repo.createRecord({
2525+ // collection: 'app.bsky.feed.post',
2626+ // record: {
2727+ // $type: 'app.bsky.feed.post',
2828+ // text: 'I found Bailey\'s new SvelteKit template before he finished it and wrote documentation for it. I probably should of checked what it did first.',
2929+ // langs: [
3030+ // 'en'
3131+ // ],
3232+ // reply: {
3333+ // root: {
3434+ // cid: 'bafyreigxcmxkn6egt5ykybaaztnbbkq74facddwva65bwxfyfqrevtpk64',
3535+ // uri: 'at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3m7mi36bsp22u'
3636+ // },
3737+ // parent: {
3838+ // cid: 'bafyreigxcmxkn6egt5ykybaaztnbbkq74facddwva65bwxfyfqrevtpk64',
3939+ // uri: 'at://did:plc:rnpkyqnmsw4ipey6eotbdnnf/app.bsky.feed.post/3m7mi36bsp22u'
4040+ // }
4141+ // },
4242+ // createdAt: dateStamp,
4343+ // }, repo: session.did, validate: true
4444+ //
4545+ // }
4646+4747+ return redirect(302, '/demo');
4848+4949+ }catch (err){
5050+ //redirects are errors, so this passes it along
5151+ if (isRedirect(err)) throw err;
5252+5353+ const errorMessage = (err as Error).message;
5454+ logger.error(`Error on oauth callback: ${errorMessage}`);
5555+ return error(500, { message: (err as Error).message });
5656+ }
5757+};
+3
static/robots.txt
···11+# allow crawling everything by default
22+User-agent: *
33+Disallow:
+18
svelte.config.js
···11+import adapter from '@sveltejs/adapter-node';
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;
+20
tsconfig.json
···11+{
22+ "extends": "./.svelte-kit/tsconfig.json",
33+ "compilerOptions": {
44+ "rewriteRelativeImportExtensions": true,
55+ "allowJs": true,
66+ "checkJs": true,
77+ "esModuleInterop": true,
88+ "forceConsistentCasingInFileNames": true,
99+ "resolveJsonModule": true,
1010+ "skipLibCheck": true,
1111+ "sourceMap": true,
1212+ "strict": true,
1313+ "moduleResolution": "bundler"
1414+ }
1515+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1616+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1717+ //
1818+ // To make changes to top-level options such as include and exclude, we recommend extending
1919+ // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
2020+}