Statusphere, but in atcute and SvelteKit
atproto svelte sveltekit drizzle atcute typescript

feat: statusphere

mary.my.id 72b10fed bb3b932f

verified
+2527 -41
+9
.env.example
··· 1 1 DATABASE_URL=file:local.db 2 + 3 + OAUTH_PUBLIC_URL= 4 + 5 + OAUTH_PRIVATE_KEY_JWK= 6 + 7 + COOKIE_SECRET= 8 + 9 + TAP_URL= 10 + TAP_ADMIN_PASSWORD=
+2 -1
AGENTS.md
··· 1 - atcute-statusphere-app is a repository reimplementing atproto's Statusphere demo with atcute and SvelteKit. 1 + atcute-statusphere-app is a repository reimplementing atproto's Statusphere demo with atcute and 2 + SvelteKit (with Svelte 5). 2 3 3 4 ## development notes 4 5
+1
CLAUDE.md
··· 1 + AGENTS.md
+51 -26
README.md
··· 1 - # sv 1 + # statusphere 2 + 3 + a reimplementation of Bluesky's 4 + [Statusphere example app](https://github.com/bluesky-social/statusphere-example-app), using 5 + [atcute](https://github.com/mary-ext/atcute) and [SvelteKit](https://svelte.dev). 6 + 7 + ![screenshot of the web interface](screenshot.png) 8 + 9 + ## setup 2 10 3 - Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). 11 + 1. install dependencies: 12 + 13 + ```sh 14 + pnpm install 15 + ``` 16 + 17 + 2. set up the environment variables: 18 + 19 + ```sh 20 + pnpm env:setup 21 + ``` 4 22 5 - ## Creating a project 23 + this copies the `.env.example` file to `.env` with the following values filled in: 24 + - `COOKIE_SECRET` - random secret for signing cookies 25 + - `OAUTH_PRIVATE_KEY_JWK` - ES256 keypair for OAuth 6 26 7 - If you're seeing this, you've probably already done this step. Congrats! 27 + 3. start a [Tap](https://github.com/bluesky-social/indigo/tree/main/cmd/tap) instance: 8 28 9 - ```sh 10 - # create a new project in the current directory 11 - npx sv create 29 + ```sh 30 + docker run -p 2480:2480 \ 31 + -e TAP_SIGNAL_COLLECTION=xyz.statusphere.status \ 32 + -e TAP_COLLECTION_FILTERS=xyz.statusphere.status,app.bsky.actor.profile \ 33 + ghcr.io/bluesky-social/indigo/tap:latest 34 + ``` 12 35 13 - # create a new project in my-app 14 - npx sv create my-app 15 - ``` 36 + Tap handles subscribing to the atproto firehose, backfilling repos, and filtering events. we set 37 + it up such that it'd backfill all repos that have posted a status, and only emits events for 38 + status and profile records. 16 39 17 - ## Developing 40 + then configure the Tap connection: 18 41 19 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or 20 - `yarn`), start a development server: 42 + ```sh 43 + TAP_URL=http://localhost:2480 21 44 22 - ```sh 23 - npm run dev 45 + # if configured with a password 46 + TAP_ADMIN_PASSWORD= 47 + ``` 24 48 25 - # or start the server and open the app in a new browser tab 26 - npm run dev -- --open 27 - ``` 49 + 4. configure the public-facing URL: 28 50 29 - ## Building 51 + ```sh 52 + OAUTH_PUBLIC_URL=https://insulation-famous-bluetooth-secret.trycloudflare.com 53 + ``` 30 54 31 - To create a production version of your app: 55 + 5. migrate the database: 32 56 33 - ```sh 34 - npm run build 35 - ``` 57 + ```sh 58 + pnpm db:migrate 59 + ``` 36 60 37 - You can preview the production build with `npm run preview`. 61 + 6. run it! 38 62 39 - > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for 40 - > your target environment. 63 + ```sh 64 + pnpm dev 65 + ```
+1 -1
drizzle.config.ts
··· 9 9 dialect: 'sqlite', 10 10 dbCredentials: { url: process.env.DATABASE_URL }, 11 11 verbose: true, 12 - strict: true 12 + strict: true, 13 13 });
+47
drizzle/0000_massive_morg.sql
··· 1 + CREATE TABLE `app_session` ( 2 + `id` text PRIMARY KEY NOT NULL, 3 + `did` text NOT NULL, 4 + `created_at` integer NOT NULL, 5 + `last_seen_at` integer NOT NULL 6 + ); 7 + --> statement-breakpoint 8 + CREATE INDEX `app_session_did_idx` ON `app_session` (`did`);--> statement-breakpoint 9 + CREATE TABLE `identity` ( 10 + `did` text PRIMARY KEY NOT NULL, 11 + `handle` text NOT NULL, 12 + `is_active` integer NOT NULL, 13 + `status` text NOT NULL, 14 + `updated_at` integer NOT NULL 15 + ); 16 + --> statement-breakpoint 17 + CREATE TABLE `oauth_session` ( 18 + `did` text PRIMARY KEY NOT NULL, 19 + `session_json` text NOT NULL, 20 + `updated_at` integer NOT NULL 21 + ); 22 + --> statement-breakpoint 23 + CREATE INDEX `oauth_session_updated_at_idx` ON `oauth_session` (`updated_at`);--> statement-breakpoint 24 + CREATE TABLE `oauth_state` ( 25 + `key` text PRIMARY KEY NOT NULL, 26 + `state_json` text NOT NULL, 27 + `expires_at` integer NOT NULL 28 + ); 29 + --> statement-breakpoint 30 + CREATE INDEX `oauth_state_expires_at_idx` ON `oauth_state` (`expires_at`);--> statement-breakpoint 31 + CREATE TABLE `profile` ( 32 + `did` text PRIMARY KEY NOT NULL, 33 + `display_name` text, 34 + `record_json` text NOT NULL, 35 + `indexed_at` integer NOT NULL 36 + ); 37 + --> statement-breakpoint 38 + CREATE TABLE `status` ( 39 + `uri` text PRIMARY KEY NOT NULL, 40 + `author_did` text NOT NULL, 41 + `rkey` text NOT NULL, 42 + `status` text NOT NULL, 43 + `created_at` text NOT NULL, 44 + `indexed_at` integer NOT NULL 45 + ); 46 + --> statement-breakpoint 47 + CREATE INDEX `status_indexed_at_idx` ON `status` (`indexed_at`);
+285
drizzle/meta/0000_snapshot.json
··· 1 + { 2 + "version": "6", 3 + "dialect": "sqlite", 4 + "id": "03f1c343-6be8-467e-a6c5-10bba6559e6a", 5 + "prevId": "00000000-0000-0000-0000-000000000000", 6 + "tables": { 7 + "app_session": { 8 + "name": "app_session", 9 + "columns": { 10 + "id": { 11 + "name": "id", 12 + "type": "text", 13 + "primaryKey": true, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "did": { 18 + "name": "did", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": true, 29 + "autoincrement": false 30 + }, 31 + "last_seen_at": { 32 + "name": "last_seen_at", 33 + "type": "integer", 34 + "primaryKey": false, 35 + "notNull": true, 36 + "autoincrement": false 37 + } 38 + }, 39 + "indexes": { 40 + "app_session_did_idx": { 41 + "name": "app_session_did_idx", 42 + "columns": [ 43 + "did" 44 + ], 45 + "isUnique": false 46 + } 47 + }, 48 + "foreignKeys": {}, 49 + "compositePrimaryKeys": {}, 50 + "uniqueConstraints": {}, 51 + "checkConstraints": {} 52 + }, 53 + "identity": { 54 + "name": "identity", 55 + "columns": { 56 + "did": { 57 + "name": "did", 58 + "type": "text", 59 + "primaryKey": true, 60 + "notNull": true, 61 + "autoincrement": false 62 + }, 63 + "handle": { 64 + "name": "handle", 65 + "type": "text", 66 + "primaryKey": false, 67 + "notNull": true, 68 + "autoincrement": false 69 + }, 70 + "is_active": { 71 + "name": "is_active", 72 + "type": "integer", 73 + "primaryKey": false, 74 + "notNull": true, 75 + "autoincrement": false 76 + }, 77 + "status": { 78 + "name": "status", 79 + "type": "text", 80 + "primaryKey": false, 81 + "notNull": true, 82 + "autoincrement": false 83 + }, 84 + "updated_at": { 85 + "name": "updated_at", 86 + "type": "integer", 87 + "primaryKey": false, 88 + "notNull": true, 89 + "autoincrement": false 90 + } 91 + }, 92 + "indexes": {}, 93 + "foreignKeys": {}, 94 + "compositePrimaryKeys": {}, 95 + "uniqueConstraints": {}, 96 + "checkConstraints": {} 97 + }, 98 + "oauth_session": { 99 + "name": "oauth_session", 100 + "columns": { 101 + "did": { 102 + "name": "did", 103 + "type": "text", 104 + "primaryKey": true, 105 + "notNull": true, 106 + "autoincrement": false 107 + }, 108 + "session_json": { 109 + "name": "session_json", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": true, 113 + "autoincrement": false 114 + }, 115 + "updated_at": { 116 + "name": "updated_at", 117 + "type": "integer", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "autoincrement": false 121 + } 122 + }, 123 + "indexes": { 124 + "oauth_session_updated_at_idx": { 125 + "name": "oauth_session_updated_at_idx", 126 + "columns": [ 127 + "updated_at" 128 + ], 129 + "isUnique": false 130 + } 131 + }, 132 + "foreignKeys": {}, 133 + "compositePrimaryKeys": {}, 134 + "uniqueConstraints": {}, 135 + "checkConstraints": {} 136 + }, 137 + "oauth_state": { 138 + "name": "oauth_state", 139 + "columns": { 140 + "key": { 141 + "name": "key", 142 + "type": "text", 143 + "primaryKey": true, 144 + "notNull": true, 145 + "autoincrement": false 146 + }, 147 + "state_json": { 148 + "name": "state_json", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": true, 152 + "autoincrement": false 153 + }, 154 + "expires_at": { 155 + "name": "expires_at", 156 + "type": "integer", 157 + "primaryKey": false, 158 + "notNull": true, 159 + "autoincrement": false 160 + } 161 + }, 162 + "indexes": { 163 + "oauth_state_expires_at_idx": { 164 + "name": "oauth_state_expires_at_idx", 165 + "columns": [ 166 + "expires_at" 167 + ], 168 + "isUnique": false 169 + } 170 + }, 171 + "foreignKeys": {}, 172 + "compositePrimaryKeys": {}, 173 + "uniqueConstraints": {}, 174 + "checkConstraints": {} 175 + }, 176 + "profile": { 177 + "name": "profile", 178 + "columns": { 179 + "did": { 180 + "name": "did", 181 + "type": "text", 182 + "primaryKey": true, 183 + "notNull": true, 184 + "autoincrement": false 185 + }, 186 + "display_name": { 187 + "name": "display_name", 188 + "type": "text", 189 + "primaryKey": false, 190 + "notNull": false, 191 + "autoincrement": false 192 + }, 193 + "record_json": { 194 + "name": "record_json", 195 + "type": "text", 196 + "primaryKey": false, 197 + "notNull": true, 198 + "autoincrement": false 199 + }, 200 + "indexed_at": { 201 + "name": "indexed_at", 202 + "type": "integer", 203 + "primaryKey": false, 204 + "notNull": true, 205 + "autoincrement": false 206 + } 207 + }, 208 + "indexes": {}, 209 + "foreignKeys": {}, 210 + "compositePrimaryKeys": {}, 211 + "uniqueConstraints": {}, 212 + "checkConstraints": {} 213 + }, 214 + "status": { 215 + "name": "status", 216 + "columns": { 217 + "uri": { 218 + "name": "uri", 219 + "type": "text", 220 + "primaryKey": true, 221 + "notNull": true, 222 + "autoincrement": false 223 + }, 224 + "author_did": { 225 + "name": "author_did", 226 + "type": "text", 227 + "primaryKey": false, 228 + "notNull": true, 229 + "autoincrement": false 230 + }, 231 + "rkey": { 232 + "name": "rkey", 233 + "type": "text", 234 + "primaryKey": false, 235 + "notNull": true, 236 + "autoincrement": false 237 + }, 238 + "status": { 239 + "name": "status", 240 + "type": "text", 241 + "primaryKey": false, 242 + "notNull": true, 243 + "autoincrement": false 244 + }, 245 + "created_at": { 246 + "name": "created_at", 247 + "type": "text", 248 + "primaryKey": false, 249 + "notNull": true, 250 + "autoincrement": false 251 + }, 252 + "indexed_at": { 253 + "name": "indexed_at", 254 + "type": "integer", 255 + "primaryKey": false, 256 + "notNull": true, 257 + "autoincrement": false 258 + } 259 + }, 260 + "indexes": { 261 + "status_indexed_at_idx": { 262 + "name": "status_indexed_at_idx", 263 + "columns": [ 264 + "indexed_at" 265 + ], 266 + "isUnique": false 267 + } 268 + }, 269 + "foreignKeys": {}, 270 + "compositePrimaryKeys": {}, 271 + "uniqueConstraints": {}, 272 + "checkConstraints": {} 273 + } 274 + }, 275 + "views": {}, 276 + "enums": {}, 277 + "_meta": { 278 + "schemas": {}, 279 + "tables": {}, 280 + "columns": {} 281 + }, 282 + "internal": { 283 + "indexes": {} 284 + } 285 + }
+13
drizzle/meta/_journal.json
··· 1 + { 2 + "version": "7", 3 + "dialect": "sqlite", 4 + "entries": [ 5 + { 6 + "idx": 0, 7 + "version": "6", 8 + "when": 1765701254575, 9 + "tag": "0000_massive_morg", 10 + "breakpoints": true 11 + } 12 + ] 13 + }
+6
lex.config.ts
··· 1 + import { defineLexiconConfig } from '@atcute/lex-cli'; 2 + 3 + export default defineLexiconConfig({ 4 + files: ['lexicons/**/*.json'], 5 + outdir: 'src/lib/lexicons/', 6 + });
+23
lexicons/xyz/statusphere/status.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "xyz.statusphere.status", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["status", "createdAt"], 11 + "properties": { 12 + "status": { 13 + "type": "string", 14 + "minLength": 1, 15 + "maxGraphemes": 1, 16 + "maxLength": 32 17 + }, 18 + "createdAt": { "type": "string", "format": "datetime" } 19 + } 20 + } 21 + } 22 + } 23 + }
+20
package.json
··· 8 8 "build": "vite build", 9 9 "preview": "vite preview", 10 10 "prepare": "svelte-kit sync || echo ''", 11 + "env:setup": "node scripts/setup-env.mjs", 12 + "lex:generate": "lex-cli generate", 11 13 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 14 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 15 "format": "prettier --write .", ··· 18 20 "db:studio": "drizzle-kit studio" 19 21 }, 20 22 "devDependencies": { 23 + "@atcute/lex-cli": "^2.5.2", 21 24 "@libsql/client": "^0.15.15", 22 25 "@sveltejs/adapter-auto": "^7.0.0", 23 26 "@sveltejs/kit": "^2.49.1", ··· 32 35 "svelte-check": "^4.3.4", 33 36 "typescript": "^5.9.3", 34 37 "vite": "^7.2.6" 38 + }, 39 + "dependencies": { 40 + "@atcute/atproto": "^3.1.9", 41 + "@atcute/bluesky": "^3.2.14", 42 + "@atcute/client": "^4.1.1", 43 + "@atcute/identity": "^1.1.3", 44 + "@atcute/identity-resolver": "^1.2.0", 45 + "@atcute/identity-resolver-node": "^1.0.3", 46 + "@atcute/lexicons": "^1.2.5", 47 + "@atcute/multibase": "^1.1.6", 48 + "@atcute/oauth-node-client": "^0.1.1", 49 + "@atcute/tap": "^0.1.0", 50 + "@atcute/tid": "^1.0.3", 51 + "@atcute/uint8array": "^1.0.6", 52 + "@badrap/valita": "^0.4.6", 53 + "nanoid": "^5.1.6", 54 + "valibot": "^1.2.0" 35 55 } 36 56 }
+358
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + '@atcute/atproto': 12 + specifier: ^3.1.9 13 + version: 3.1.9 14 + '@atcute/bluesky': 15 + specifier: ^3.2.14 16 + version: 3.2.14 17 + '@atcute/client': 18 + specifier: ^4.1.1 19 + version: 4.1.1 20 + '@atcute/identity': 21 + specifier: ^1.1.3 22 + version: 1.1.3 23 + '@atcute/identity-resolver': 24 + specifier: ^1.2.0 25 + version: 1.2.0(@atcute/identity@1.1.3) 26 + '@atcute/identity-resolver-node': 27 + specifier: ^1.0.3 28 + version: 1.0.3(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 29 + '@atcute/lexicons': 30 + specifier: ^1.2.5 31 + version: 1.2.5 32 + '@atcute/multibase': 33 + specifier: ^1.1.6 34 + version: 1.1.6 35 + '@atcute/oauth-node-client': 36 + specifier: ^0.1.1 37 + version: 0.1.1 38 + '@atcute/tap': 39 + specifier: ^0.1.0 40 + version: 0.1.0 41 + '@atcute/tid': 42 + specifier: ^1.0.3 43 + version: 1.0.3 44 + '@atcute/uint8array': 45 + specifier: ^1.0.6 46 + version: 1.0.6 47 + '@badrap/valita': 48 + specifier: ^0.4.6 49 + version: 0.4.6 50 + nanoid: 51 + specifier: ^5.1.6 52 + version: 5.1.6 53 + valibot: 54 + specifier: ^1.2.0 55 + version: 1.2.0(typescript@5.9.3) 10 56 devDependencies: 57 + '@atcute/lex-cli': 58 + specifier: ^2.5.2 59 + version: 2.5.2 11 60 '@libsql/client': 12 61 specifier: ^0.15.15 13 62 version: 0.15.15 ··· 53 102 54 103 packages: 55 104 105 + '@atcute/atproto@3.1.9': 106 + resolution: {integrity: sha512-DyWwHCTdR4hY2BPNbLXgVmm7lI+fceOwWbE4LXbGvbvVtSn+ejSVFaAv01Ra3kWDha0whsOmbJL8JP0QPpf1+w==} 107 + 108 + '@atcute/bluesky@3.2.14': 109 + resolution: {integrity: sha512-XlVuF55AYIyplmKvlGLlj+cUvk9ggxNRPczkTPIY991xJ4qDxDHpBJ39ekAV4dWcuBoRo2o9JynzpafPu2ljDA==} 110 + 111 + '@atcute/car@5.0.0': 112 + resolution: {integrity: sha512-OIY2xTXv8lSpZsDSn/UYQtJSMvDw5Hi4Q+uyvmiqSM+fht08QRAEq/nxa5YFciPZ3nfDFnZ3//EgJw7QhkSXLQ==} 113 + 114 + '@atcute/cbor@2.2.8': 115 + resolution: {integrity: sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==} 116 + 117 + '@atcute/cid@2.2.6': 118 + resolution: {integrity: sha512-bTAHHbJ24p+E//V4KCS4xdmd39o211jJswvqQOevj7vk+5IYcgDLx1ryZWZ1sEPOo9x875li/kj5gpKL14RDwQ==} 119 + 120 + '@atcute/client@4.1.1': 121 + resolution: {integrity: sha512-FROCbTTCeL5u4tO/n72jDEKyKqjdlXMB56Ehve3W/gnnLGCYWvN42sS7tvL1Mgu6sbO3yZwsXKDrmM2No4XpjA==} 122 + 123 + '@atcute/crypto@2.3.0': 124 + resolution: {integrity: sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==} 125 + 126 + '@atcute/identity-resolver-node@1.0.3': 127 + resolution: {integrity: sha512-RPH5M4ZRayKRcGnJWUOPVhN5WSYURXXZxKzgVT9lj/WZCH6ij2Vg3P3Eva7GGs0SG1ytnX1XVBTMoIk8nF/SLQ==} 128 + peerDependencies: 129 + '@atcute/identity': ^1.0.0 130 + '@atcute/identity-resolver': ^1.0.0 131 + 132 + '@atcute/identity-resolver@1.2.0': 133 + resolution: {integrity: sha512-5UbSJfdV3JIkF8ksXz7g4nKBWasf2wROvzM66cfvTIWydWFO6/oS1KZd+zo9Eokje5Scf5+jsY9ZfgVARLepXg==} 134 + peerDependencies: 135 + '@atcute/identity': ^1.0.0 136 + 137 + '@atcute/identity@1.1.3': 138 + resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 139 + 140 + '@atcute/lex-cli@2.5.2': 141 + resolution: {integrity: sha512-u3xeu7uF7mAgAErYpXvdUaH2bxpthGWLg+vUf20cejWZHBH/dAzL4ixLRjw/39WwoVmmCQDTde79WTPoBjuhpg==} 142 + hasBin: true 143 + 144 + '@atcute/lexicon-doc@2.0.5': 145 + resolution: {integrity: sha512-fNCp94ehGjWFZMIqP6pWD1F9MOJogNCyqsaMVZluPSIclZ+lDL528iXB56aW4u0eSiD6Y9WJB1OI/lElG39cSA==} 146 + 147 + '@atcute/lexicon-resolver@0.1.5': 148 + resolution: {integrity: sha512-0bx1/zdMQPuxvRcHW6ykAxRxktC2rEZLoAVSFoLSWDAA92Tf09F9QPK5wgXSF4MNODm1dvzMEdWSMIvlg8sr3A==} 149 + peerDependencies: 150 + '@atcute/identity': ^1.1.0 151 + '@atcute/identity-resolver': ^1.1.3 152 + 153 + '@atcute/lexicons@1.2.5': 154 + resolution: {integrity: sha512-9yO9WdgxW8jZ7SbzUycH710z+JmsQ9W9n5S6i6eghYju32kkluFmgBeS47r8e8p2+Dv4DemS7o/3SUGsX9FR5Q==} 155 + 156 + '@atcute/mst@0.1.0': 157 + resolution: {integrity: sha512-h+iDToKEnBpigk2DOHjSqY63vJtjYKUIztqu1CZ0P+I54wV2SrgoqAXAT1xrW6A1Iup8cjTv+U2H5WVG4KxPLw==} 158 + 159 + '@atcute/multibase@1.1.6': 160 + resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} 161 + 162 + '@atcute/oauth-node-client@0.1.1': 163 + resolution: {integrity: sha512-DmyG9vkaUY6rfzfLFDtsMCldU86O6gGLE/W6FVWCMUBWuDf7E6Qv03q5y43nuCkdkpr3uf4l1g2LSYHbVn5xLg==} 164 + 165 + '@atcute/repo@0.1.0': 166 + resolution: {integrity: sha512-INiYAuma8dydBu7cqd2WVpcXh3mzhIepYBUqFWAK5MqMulPRLTRCc/9GW3G9pxYrOdlvLCVamG2Jf8XK0nuFEw==} 167 + 168 + '@atcute/tap@0.1.0': 169 + resolution: {integrity: sha512-mVZUMZG4Rxl+FoUrsI5cdV7sWyYyd/ny3nMiy3sR0ZSP6oJdBciLS+bcHNfvCito6xyFjpW4eG5J+HWQn0AUnQ==} 170 + 171 + '@atcute/tid@1.0.3': 172 + resolution: {integrity: sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w==} 173 + 174 + '@atcute/uint8array@1.0.6': 175 + resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} 176 + 177 + '@atcute/util-fetch@1.0.4': 178 + resolution: {integrity: sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==} 179 + 180 + '@atcute/varint@1.0.3': 181 + resolution: {integrity: sha512-fdvMPyBB+McDT+Ai5e9RwEbwYV4yjZ60S2Dn5PTjGqUyxvoCH1z42viuheDZRUDkmfQehXJTZ5az7dSozVNtog==} 182 + 183 + '@badrap/valita@0.4.6': 184 + resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==} 185 + engines: {node: '>= 18'} 186 + 56 187 '@drizzle-team/brocli@0.10.2': 57 188 resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} 58 189 ··· 429 560 cpu: [x64] 430 561 os: [win32] 431 562 563 + '@mary-ext/event-iterator@1.0.0': 564 + resolution: {integrity: sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==} 565 + 566 + '@mary-ext/simple-event-emitter@1.0.0': 567 + resolution: {integrity: sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==} 568 + 432 569 '@neon-rs/load@0.0.4': 433 570 resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} 571 + 572 + '@noble/secp256k1@3.0.0': 573 + resolution: {integrity: sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==} 574 + 575 + '@optique/core@0.6.5': 576 + resolution: {integrity: sha512-H3O//t/qxq7GT+25oLi4mXyxB/PccTcj+0P4HcboDcTnAN7gcTgoxvAugaHExw9s7WrVOlRQRZuYXseKtU/cyw==} 577 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 578 + 579 + '@optique/run@0.6.5': 580 + resolution: {integrity: sha512-dJTfcNXRWM+dmGxbeTFNDt/cf3v92wNYcJVZUu+FqwNXD5lX/koWeMIGwT0eoJegGPWvzpCVRb0CVjc+b/AUbQ==} 581 + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} 434 582 435 583 '@polka/url@1.0.0-next.29': 436 584 resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} ··· 773 921 esrap@2.2.1: 774 922 resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==} 775 923 924 + event-target-polyfill@0.0.4: 925 + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} 926 + 776 927 fdir@6.5.0: 777 928 resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 778 929 engines: {node: '>=12.0.0'} ··· 800 951 801 952 is-reference@3.0.3: 802 953 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 954 + 955 + jose@6.1.3: 956 + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} 803 957 804 958 js-base64@3.7.8: 805 959 resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} ··· 835 989 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 836 990 hasBin: true 837 991 992 + nanoid@5.1.6: 993 + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 994 + engines: {node: ^18 || >=20} 995 + hasBin: true 996 + 838 997 node-domexception@1.0.0: 839 998 resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} 840 999 engines: {node: '>=10.5.0'} ··· 843 1002 node-fetch@3.3.2: 844 1003 resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} 845 1004 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 1005 + 1006 + partysocket@1.1.6: 1007 + resolution: {integrity: sha512-LkEk8N9hMDDsDT0iDK0zuwUDFVrVMUXFXCeN3850Ng8wtjPqPBeJlwdeY6ROlJSEh3tPoTTasXoSBYH76y118w==} 846 1008 847 1009 picocolors@1.1.1: 848 1010 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} ··· 941 1103 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 942 1104 engines: {node: '>=6'} 943 1105 1106 + type-fest@4.41.0: 1107 + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} 1108 + engines: {node: '>=16'} 1109 + 944 1110 typescript@5.9.3: 945 1111 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} 946 1112 engines: {node: '>=14.17'} ··· 948 1114 949 1115 undici-types@7.16.0: 950 1116 resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} 1117 + 1118 + valibot@1.2.0: 1119 + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} 1120 + peerDependencies: 1121 + typescript: '>=5' 1122 + peerDependenciesMeta: 1123 + typescript: 1124 + optional: true 951 1125 952 1126 vite@7.2.7: 953 1127 resolution: {integrity: sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==} ··· 1013 1187 utf-8-validate: 1014 1188 optional: true 1015 1189 1190 + yocto-queue@1.2.2: 1191 + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} 1192 + engines: {node: '>=12.20'} 1193 + 1016 1194 zimmerframe@1.1.4: 1017 1195 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 1018 1196 1019 1197 snapshots: 1020 1198 1199 + '@atcute/atproto@3.1.9': 1200 + dependencies: 1201 + '@atcute/lexicons': 1.2.5 1202 + 1203 + '@atcute/bluesky@3.2.14': 1204 + dependencies: 1205 + '@atcute/atproto': 3.1.9 1206 + '@atcute/lexicons': 1.2.5 1207 + 1208 + '@atcute/car@5.0.0': 1209 + dependencies: 1210 + '@atcute/cbor': 2.2.8 1211 + '@atcute/cid': 2.2.6 1212 + '@atcute/uint8array': 1.0.6 1213 + '@atcute/varint': 1.0.3 1214 + 1215 + '@atcute/cbor@2.2.8': 1216 + dependencies: 1217 + '@atcute/cid': 2.2.6 1218 + '@atcute/multibase': 1.1.6 1219 + '@atcute/uint8array': 1.0.6 1220 + 1221 + '@atcute/cid@2.2.6': 1222 + dependencies: 1223 + '@atcute/multibase': 1.1.6 1224 + '@atcute/uint8array': 1.0.6 1225 + 1226 + '@atcute/client@4.1.1': 1227 + dependencies: 1228 + '@atcute/identity': 1.1.3 1229 + '@atcute/lexicons': 1.2.5 1230 + 1231 + '@atcute/crypto@2.3.0': 1232 + dependencies: 1233 + '@atcute/multibase': 1.1.6 1234 + '@atcute/uint8array': 1.0.6 1235 + '@noble/secp256k1': 3.0.0 1236 + 1237 + '@atcute/identity-resolver-node@1.0.3(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)': 1238 + dependencies: 1239 + '@atcute/identity': 1.1.3 1240 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1241 + '@atcute/lexicons': 1.2.5 1242 + 1243 + '@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3)': 1244 + dependencies: 1245 + '@atcute/identity': 1.1.3 1246 + '@atcute/lexicons': 1.2.5 1247 + '@atcute/util-fetch': 1.0.4 1248 + '@badrap/valita': 0.4.6 1249 + 1250 + '@atcute/identity@1.1.3': 1251 + dependencies: 1252 + '@atcute/lexicons': 1.2.5 1253 + '@badrap/valita': 0.4.6 1254 + 1255 + '@atcute/lex-cli@2.5.2': 1256 + dependencies: 1257 + '@atcute/identity': 1.1.3 1258 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1259 + '@atcute/lexicon-doc': 2.0.5 1260 + '@atcute/lexicon-resolver': 0.1.5(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3) 1261 + '@atcute/lexicons': 1.2.5 1262 + '@badrap/valita': 0.4.6 1263 + '@optique/core': 0.6.5 1264 + '@optique/run': 0.6.5 1265 + picocolors: 1.1.1 1266 + prettier: 3.7.4 1267 + 1268 + '@atcute/lexicon-doc@2.0.5': 1269 + dependencies: 1270 + '@atcute/identity': 1.1.3 1271 + '@atcute/lexicons': 1.2.5 1272 + '@badrap/valita': 0.4.6 1273 + 1274 + '@atcute/lexicon-resolver@0.1.5(@atcute/identity-resolver@1.2.0(@atcute/identity@1.1.3))(@atcute/identity@1.1.3)': 1275 + dependencies: 1276 + '@atcute/crypto': 2.3.0 1277 + '@atcute/identity': 1.1.3 1278 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1279 + '@atcute/lexicon-doc': 2.0.5 1280 + '@atcute/lexicons': 1.2.5 1281 + '@atcute/repo': 0.1.0 1282 + '@atcute/util-fetch': 1.0.4 1283 + '@badrap/valita': 0.4.6 1284 + 1285 + '@atcute/lexicons@1.2.5': 1286 + dependencies: 1287 + '@standard-schema/spec': 1.0.0 1288 + esm-env: 1.2.2 1289 + 1290 + '@atcute/mst@0.1.0': 1291 + dependencies: 1292 + '@atcute/cbor': 2.2.8 1293 + '@atcute/cid': 2.2.6 1294 + '@atcute/uint8array': 1.0.6 1295 + 1296 + '@atcute/multibase@1.1.6': 1297 + dependencies: 1298 + '@atcute/uint8array': 1.0.6 1299 + 1300 + '@atcute/oauth-node-client@0.1.1': 1301 + dependencies: 1302 + '@atcute/client': 4.1.1 1303 + '@atcute/identity': 1.1.3 1304 + '@atcute/identity-resolver': 1.2.0(@atcute/identity@1.1.3) 1305 + '@atcute/lexicons': 1.2.5 1306 + '@atcute/multibase': 1.1.6 1307 + '@atcute/uint8array': 1.0.6 1308 + '@atcute/util-fetch': 1.0.4 1309 + '@badrap/valita': 0.4.6 1310 + jose: 6.1.3 1311 + nanoid: 5.1.6 1312 + 1313 + '@atcute/repo@0.1.0': 1314 + dependencies: 1315 + '@atcute/car': 5.0.0 1316 + '@atcute/cbor': 2.2.8 1317 + '@atcute/cid': 2.2.6 1318 + '@atcute/crypto': 2.3.0 1319 + '@atcute/lexicons': 1.2.5 1320 + '@atcute/mst': 0.1.0 1321 + '@atcute/uint8array': 1.0.6 1322 + 1323 + '@atcute/tap@0.1.0': 1324 + dependencies: 1325 + '@atcute/identity': 1.1.3 1326 + '@atcute/lexicons': 1.2.5 1327 + '@atcute/multibase': 1.1.6 1328 + '@atcute/uint8array': 1.0.6 1329 + '@badrap/valita': 0.4.6 1330 + '@mary-ext/event-iterator': 1.0.0 1331 + '@mary-ext/simple-event-emitter': 1.0.0 1332 + partysocket: 1.1.6 1333 + type-fest: 4.41.0 1334 + 1335 + '@atcute/tid@1.0.3': {} 1336 + 1337 + '@atcute/uint8array@1.0.6': {} 1338 + 1339 + '@atcute/util-fetch@1.0.4': 1340 + dependencies: 1341 + '@badrap/valita': 0.4.6 1342 + 1343 + '@atcute/varint@1.0.3': {} 1344 + 1345 + '@badrap/valita@0.4.6': {} 1346 + 1021 1347 '@drizzle-team/brocli@0.10.2': {} 1022 1348 1023 1349 '@esbuild-kit/core-utils@3.3.2': ··· 1255 1581 '@libsql/win32-x64-msvc@0.5.22': 1256 1582 optional: true 1257 1583 1584 + '@mary-ext/event-iterator@1.0.0': 1585 + dependencies: 1586 + yocto-queue: 1.2.2 1587 + 1588 + '@mary-ext/simple-event-emitter@1.0.0': {} 1589 + 1258 1590 '@neon-rs/load@0.0.4': {} 1259 1591 1592 + '@noble/secp256k1@3.0.0': {} 1593 + 1594 + '@optique/core@0.6.5': {} 1595 + 1596 + '@optique/run@0.6.5': 1597 + dependencies: 1598 + '@optique/core': 0.6.5 1599 + 1260 1600 '@polka/url@1.0.0-next.29': {} 1261 1601 1262 1602 '@rollup/rollup-android-arm-eabi@4.53.3': ··· 1499 1839 dependencies: 1500 1840 '@jridgewell/sourcemap-codec': 1.5.5 1501 1841 1842 + event-target-polyfill@0.0.4: {} 1843 + 1502 1844 fdir@6.5.0(picomatch@4.0.3): 1503 1845 optionalDependencies: 1504 1846 picomatch: 4.0.3 ··· 1522 1864 is-reference@3.0.3: 1523 1865 dependencies: 1524 1866 '@types/estree': 1.0.8 1867 + 1868 + jose@6.1.3: {} 1525 1869 1526 1870 js-base64@3.7.8: {} 1527 1871 ··· 1556 1900 1557 1901 nanoid@3.3.11: {} 1558 1902 1903 + nanoid@5.1.6: {} 1904 + 1559 1905 node-domexception@1.0.0: {} 1560 1906 1561 1907 node-fetch@3.3.2: ··· 1563 1909 data-uri-to-buffer: 4.0.1 1564 1910 fetch-blob: 3.2.0 1565 1911 formdata-polyfill: 4.0.10 1912 + 1913 + partysocket@1.1.6: 1914 + dependencies: 1915 + event-target-polyfill: 0.0.4 1566 1916 1567 1917 picocolors@1.1.1: {} 1568 1918 ··· 1690 2040 1691 2041 totalist@3.0.1: {} 1692 2042 2043 + type-fest@4.41.0: {} 2044 + 1693 2045 typescript@5.9.3: {} 1694 2046 1695 2047 undici-types@7.16.0: {} 2048 + 2049 + valibot@1.2.0(typescript@5.9.3): 2050 + optionalDependencies: 2051 + typescript: 5.9.3 1696 2052 1697 2053 vite@7.2.7(@types/node@24.10.4): 1698 2054 dependencies: ··· 1713 2069 web-streams-polyfill@3.3.3: {} 1714 2070 1715 2071 ws@8.18.3: {} 2072 + 2073 + yocto-queue@1.2.2: {} 1716 2074 1717 2075 zimmerframe@1.1.4: {}
screenshot.png

This is a binary file and will not be displayed.

+81
scripts/setup-env.mjs
··· 1 + import { existsSync } from 'node:fs'; 2 + import { copyFile, readFile, writeFile } from 'node:fs/promises'; 3 + import { resolve } from 'node:path'; 4 + 5 + import { exportJwkKey, generatePrivateKey, importJwkKey } from '@atcute/oauth-node-client'; 6 + 7 + import { nanoid } from 'nanoid'; 8 + 9 + const ensureEnv = async () => { 10 + const cwd = process.cwd(); 11 + 12 + const examplePath = resolve(cwd, '.env.example'); 13 + const envPath = resolve(cwd, '.env'); 14 + 15 + if (!existsSync(envPath)) { 16 + if (!existsSync(examplePath)) { 17 + throw new Error(`missing .env.example (expected at ${examplePath})`); 18 + } 19 + 20 + await copyFile(examplePath, envPath); 21 + console.log(`created ${envPath}`); 22 + } 23 + 24 + return envPath; 25 + }; 26 + 27 + const normalizeCurrentValue = (value) => { 28 + const trimmed = value.trim(); 29 + 30 + if (trimmed === '' || trimmed === `''` || trimmed === `""`) { 31 + return ''; 32 + } 33 + 34 + return trimmed; 35 + }; 36 + 37 + const upsertEnvVar = (input, key, value) => { 38 + const line = `${key}=${value}`; 39 + const re = new RegExp(`^${key}=.*$`, 'm'); 40 + 41 + if (re.test(input)) { 42 + const match = input.match(re); 43 + const current = match ? match[0].slice(key.length + 1) : ''; 44 + 45 + if (normalizeCurrentValue(current) === '') { 46 + return input.replace(re, line); 47 + } 48 + 49 + return input; 50 + } 51 + 52 + const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n'; 53 + return `${input}${suffix}${line}\n`; 54 + }; 55 + 56 + const envPath = await ensureEnv(); 57 + const env = await readFile(envPath, 'utf8'); 58 + 59 + let updated = env; 60 + 61 + { 62 + const cookieSecret = nanoid(32); 63 + updated = upsertEnvVar(updated, 'COOKIE_SECRET', `'${cookieSecret}'`); 64 + } 65 + 66 + { 67 + const privateKey = await generatePrivateKey('main', 'ES256'); 68 + const jwk = await exportJwkKey(privateKey); 69 + 70 + // sanity-check that the key parses before writing 71 + await importJwkKey(jwk); 72 + 73 + updated = upsertEnvVar(updated, 'OAUTH_PRIVATE_KEY_JWK', `'${JSON.stringify(jwk)}'`); 74 + } 75 + 76 + if (updated !== env) { 77 + await writeFile(envPath, updated); 78 + console.log(`updated ${envPath}`); 79 + } else { 80 + console.log(`no changes to ${envPath}`); 81 + }
+93
src/app.css
··· 1 + /* css reset - based on josh comeau's custom css reset */ 2 + *, 3 + *::before, 4 + *::after { 5 + box-sizing: border-box; 6 + } 7 + 8 + * { 9 + margin: 0; 10 + } 11 + 12 + body { 13 + line-height: 1.5; 14 + -webkit-font-smoothing: antialiased; 15 + } 16 + 17 + img, 18 + picture, 19 + video, 20 + canvas, 21 + svg { 22 + display: block; 23 + max-width: 100%; 24 + } 25 + 26 + input, 27 + button, 28 + textarea, 29 + select { 30 + font: inherit; 31 + } 32 + 33 + p, 34 + h1, 35 + h2, 36 + h3, 37 + h4, 38 + h5, 39 + h6 { 40 + overflow-wrap: break-word; 41 + } 42 + 43 + /* css variables */ 44 + :root { 45 + --color-bg: #fafafa; 46 + --color-bg-elevated: #ffffff; 47 + --color-text: #1a1a1a; 48 + --color-text-muted: #6b7280; 49 + --color-border: #e5e7eb; 50 + --color-accent: #3b82f6; 51 + --color-accent-hover: #2563eb; 52 + --color-accent-bg: #eff6ff; 53 + --color-error: #dc2626; 54 + --color-error-bg: #fef2f2; 55 + 56 + --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; 57 + 58 + --radius-sm: 0.375rem; 59 + --radius-md: 0.5rem; 60 + --radius-lg: 0.75rem; 61 + --radius-full: 9999px; 62 + } 63 + 64 + @media (prefers-color-scheme: dark) { 65 + :root { 66 + --color-bg: #0a0a0a; 67 + --color-bg-elevated: #171717; 68 + --color-text: #fafafa; 69 + --color-text-muted: #a1a1aa; 70 + --color-border: #27272a; 71 + --color-accent: #60a5fa; 72 + --color-accent-hover: #93c5fd; 73 + --color-accent-bg: #1e3a5f; 74 + --color-error: #f87171; 75 + --color-error-bg: #450a0a; 76 + } 77 + } 78 + 79 + /* base styles */ 80 + html { 81 + background-color: var(--color-bg); 82 + color: var(--color-text); 83 + font-family: var(--font-sans); 84 + } 85 + 86 + a { 87 + color: var(--color-accent); 88 + text-decoration: none; 89 + } 90 + 91 + a:hover { 92 + text-decoration: underline; 93 + }
+6 -1
src/app.d.ts
··· 1 1 // See https://svelte.dev/docs/kit/types#app.d.ts 2 + 3 + import type { AppSession } from '$lib/server/auth/app-session'; 4 + 2 5 // for information about these interfaces 3 6 declare global { 4 7 namespace App { 5 8 // interface Error {} 6 - // interface Locals {} 9 + interface Locals { 10 + session?: AppSession | null; 11 + } 7 12 // interface PageData {} 8 13 // interface PageState {} 9 14 // interface Platform {}
+40
src/hooks.server.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import type { Handle } from '@sveltejs/kit'; 3 + 4 + import { TapClient } from '@atcute/tap'; 5 + 6 + import { APP_SESSION_COOKIE, getAppSession } from '$lib/server/auth/app-session'; 7 + import { getSignedCookie } from '$lib/server/auth/signed-cookie'; 8 + import { runTapSubscription } from '$lib/server/tap'; 9 + 10 + if (!env.TAP_URL) { 11 + throw new Error(`TAP_URL is not set`); 12 + } 13 + 14 + { 15 + const tap = new TapClient({ 16 + url: env.TAP_URL, 17 + adminPassword: env.TAP_ADMIN_PASSWORD || undefined, 18 + }); 19 + 20 + void runTapSubscription(tap).catch((err) => { 21 + console.error(err); 22 + }); 23 + } 24 + 25 + export const handle: Handle = async ({ event, resolve }) => { 26 + const { locals, cookies } = event; 27 + 28 + const sessionId = getSignedCookie(cookies, APP_SESSION_COOKIE); 29 + if (sessionId) { 30 + const session = await getAppSession(sessionId); 31 + 32 + if (session) { 33 + locals.session = session; 34 + } else { 35 + cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 36 + } 37 + } 38 + 39 + return resolve(event); 40 + };
+56
src/lib/auth.remote.ts
··· 1 + import { invalid, redirect } from '@sveltejs/kit'; 2 + 3 + import * as v from 'valibot'; 4 + 5 + import { isActorIdentifier, type ActorIdentifier } from '@atcute/lexicons/syntax'; 6 + import { OAuthResolverError } from '@atcute/oauth-node-client'; 7 + 8 + import { form, getRequestEvent } from '$app/server'; 9 + 10 + import { APP_SESSION_COOKIE, deleteAppSession } from './server/auth/app-session'; 11 + import { oauth } from './server/oauth'; 12 + 13 + const actorIdentifierString = v.custom<ActorIdentifier>( 14 + (input) => isActorIdentifier(input), 15 + `please enter a valid handle (e.g. alice.bsky.social) or DID`, 16 + ); 17 + 18 + export const doLogin = form( 19 + v.object({ 20 + identifier: actorIdentifierString, 21 + }), 22 + async ({ identifier }) => { 23 + let url: URL; 24 + 25 + try { 26 + const result = await oauth.authorize({ 27 + target: { 28 + type: 'account', 29 + identifier: identifier, 30 + }, 31 + }); 32 + 33 + url = result.url; 34 + } catch (err) { 35 + console.error(`failed to authenticate ${identifier}:`, err); 36 + 37 + invalid(`could not initiate login`); 38 + } 39 + 40 + redirect(303, url.href); 41 + }, 42 + ); 43 + 44 + export const doLogout = form(async () => { 45 + const { 46 + locals: { session }, 47 + cookies, 48 + } = getRequestEvent(); 49 + 50 + if (session) { 51 + await deleteAppSession(session.id); 52 + } 53 + 54 + cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 55 + redirect(303, '/'); 56 + });
+94
src/lib/components/header.svelte
··· 1 + <script lang="ts"> 2 + import { doLogout } from '$lib/auth.remote'; 3 + import { getCurrentUser } from '$lib/status.remote'; 4 + 5 + const user = await getCurrentUser(); 6 + </script> 7 + 8 + <header class="header"> 9 + <h1 class="title">statusphere</h1> 10 + 11 + <div class="actions"> 12 + {#if user} 13 + <span class="handle">@{user.handle}</span> 14 + <form 15 + {...doLogout.enhance(async ({ submit }) => { 16 + await submit(); 17 + window.location.reload(); 18 + })} 19 + > 20 + <button type="submit" class="btn btn-ghost">sign out</button> 21 + </form> 22 + {:else} 23 + <a href="/login" class="btn btn-primary">sign in</a> 24 + {/if} 25 + </div> 26 + </header> 27 + 28 + <style> 29 + .header { 30 + display: flex; 31 + justify-content: space-between; 32 + align-items: center; 33 + margin-bottom: 1.5rem; 34 + border-bottom: 1px solid var(--color-border); 35 + padding-bottom: 1.5rem; 36 + } 37 + 38 + .title { 39 + font-weight: 700; 40 + font-size: 1.5rem; 41 + } 42 + 43 + .actions { 44 + display: flex; 45 + align-items: center; 46 + gap: 0.75rem; 47 + } 48 + 49 + .handle { 50 + color: var(--color-text-muted); 51 + font-size: 0.875rem; 52 + } 53 + 54 + .btn { 55 + display: inline-flex; 56 + justify-content: center; 57 + align-items: center; 58 + transition: 59 + background-color 0.15s, 60 + border-color 0.15s; 61 + cursor: pointer; 62 + border: 1px solid transparent; 63 + border-radius: var(--radius-md); 64 + padding: 0.5rem 1rem; 65 + font-weight: 500; 66 + font-size: 0.875rem; 67 + } 68 + 69 + .btn:disabled { 70 + opacity: 0.5; 71 + cursor: not-allowed; 72 + } 73 + 74 + .btn-primary { 75 + background-color: var(--color-accent); 76 + color: white; 77 + text-decoration: none; 78 + } 79 + 80 + .btn-primary:hover { 81 + background-color: var(--color-accent-hover); 82 + text-decoration: none; 83 + } 84 + 85 + .btn-ghost { 86 + background-color: transparent; 87 + color: var(--color-text-muted); 88 + } 89 + 90 + .btn-ghost:hover:not(:disabled) { 91 + background-color: var(--color-bg-elevated); 92 + color: var(--color-text); 93 + } 94 + </style>
+112
src/lib/components/status-picker.svelte
··· 1 + <script lang="ts"> 2 + import { statusOptions } from '$lib/status-options'; 3 + import { getTimeline, postStatus, type CurrentUser } from '$lib/status.remote'; 4 + 5 + interface Props { 6 + user: CurrentUser; 7 + } 8 + 9 + let { user }: Props = $props(); 10 + </script> 11 + 12 + <section class="picker"> 13 + <p class="label">what's your status?</p> 14 + 15 + <form 16 + class="grid" 17 + {...postStatus.enhance(async ({ data, submit }) => { 18 + await submit().updates( 19 + getTimeline({}).withOverride((current) => { 20 + return { 21 + ...current, 22 + statuses: [ 23 + { 24 + author: { 25 + did: user.did, 26 + handle: user.handle, 27 + displayName: user.displayName, 28 + }, 29 + status: data.status, 30 + indexedAt: new Date().toISOString(), 31 + }, 32 + ...current.statuses, 33 + ], 34 + }; 35 + }), 36 + ); 37 + })} 38 + > 39 + {#each statusOptions as status} 40 + <button 41 + type="submit" 42 + name="status" 43 + value={status} 44 + class="status-btn" 45 + aria-label={`set status to ${status}`} 46 + disabled={!!postStatus.pending} 47 + > 48 + {status} 49 + </button> 50 + {/each} 51 + </form> 52 + </section> 53 + 54 + <style> 55 + .picker { 56 + margin-bottom: 1.5rem; 57 + border: 1px solid var(--color-border); 58 + border-radius: var(--radius-lg); 59 + background-color: var(--color-bg-elevated); 60 + padding: 1rem; 61 + } 62 + 63 + .label { 64 + margin-bottom: 0.75rem; 65 + color: var(--color-text-muted); 66 + font-weight: 500; 67 + font-size: 0.875rem; 68 + } 69 + 70 + .grid { 71 + display: flex; 72 + flex-wrap: wrap; 73 + gap: 0.5rem; 74 + } 75 + 76 + .status-btn { 77 + display: flex; 78 + justify-content: center; 79 + align-items: center; 80 + transition: 81 + transform 0.1s, 82 + border-color 0.15s, 83 + background-color 0.15s; 84 + cursor: pointer; 85 + border: 2px solid var(--color-border); 86 + border-radius: var(--radius-md); 87 + background-color: var(--color-bg); 88 + width: 3rem; 89 + height: 3rem; 90 + font-size: 1.5rem; 91 + } 92 + 93 + .status-btn:hover:not(:disabled) { 94 + transform: scale(1.1); 95 + border-color: var(--color-accent); 96 + background-color: var(--color-accent-bg); 97 + } 98 + 99 + .status-btn:active:not(:disabled) { 100 + transform: scale(0.95); 101 + } 102 + 103 + .status-btn:disabled { 104 + opacity: 0.5; 105 + cursor: not-allowed; 106 + } 107 + 108 + .status-btn[aria-pressed='true'] { 109 + border-color: var(--color-accent); 110 + background-color: var(--color-accent-bg); 111 + } 112 + </style>
+143
src/lib/components/timeline.svelte
··· 1 + <script lang="ts"> 2 + import type { StatusView } from '$lib/status.remote'; 3 + 4 + interface Props { 5 + statuses: StatusView[]; 6 + } 7 + 8 + let { statuses }: Props = $props(); 9 + 10 + const getBskyProfileUrl = (handle: string): string => { 11 + return `https://bsky.app/profile/${handle}`; 12 + }; 13 + 14 + const formatTime = (isoString: string): string => { 15 + const date = new Date(isoString); 16 + const now = new Date(); 17 + const diffMs = now.getTime() - date.getTime(); 18 + const diffMins = Math.floor(diffMs / 60000); 19 + const diffHours = Math.floor(diffMs / 3600000); 20 + 21 + if (diffMins < 1) { 22 + return 'just now'; 23 + } 24 + if (diffMins < 60) { 25 + return `${diffMins}m ago`; 26 + } 27 + if (diffHours < 24) { 28 + return `${diffHours}h ago`; 29 + } 30 + 31 + const isToday = date.toDateString() === now.toDateString(); 32 + if (isToday) { 33 + return 'today'; 34 + } 35 + 36 + const yesterday = new Date(now); 37 + yesterday.setDate(yesterday.getDate() - 1); 38 + if (date.toDateString() === yesterday.toDateString()) { 39 + return 'yesterday'; 40 + } 41 + 42 + return date.toLocaleDateString(); 43 + }; 44 + </script> 45 + 46 + {#if statuses.length === 0} 47 + <div class="empty"> 48 + <p class="empty-emoji">🦋</p> 49 + <p class="empty-text">no statuses yet</p> 50 + </div> 51 + {:else} 52 + <div class="timeline"> 53 + {#each statuses as item} 54 + <div class="item"> 55 + <div class="emoji">{item.status}</div> 56 + <div class="content"> 57 + <a 58 + href={getBskyProfileUrl(item.author.handle)} 59 + target="_blank" 60 + rel="noopener noreferrer" 61 + class="author" 62 + > 63 + {item.author.displayName ?? `@${item.author.handle}`} 64 + </a> 65 + <span class="meta"> · {formatTime(item.indexedAt)}</span> 66 + </div> 67 + </div> 68 + {/each} 69 + </div> 70 + {/if} 71 + 72 + <style> 73 + .empty { 74 + padding: 3rem 1rem; 75 + color: var(--color-text-muted); 76 + text-align: center; 77 + } 78 + 79 + .empty-emoji { 80 + margin-bottom: 1rem; 81 + font-size: 3rem; 82 + } 83 + 84 + .empty-text { 85 + font-size: 1rem; 86 + } 87 + 88 + .timeline { 89 + position: relative; 90 + } 91 + 92 + .timeline::before { 93 + position: absolute; 94 + top: 0; 95 + bottom: 0; 96 + left: 1.5rem; 97 + transform: translateX(-50%); 98 + background-color: var(--color-border); 99 + width: 2px; 100 + content: ''; 101 + } 102 + 103 + .item { 104 + display: flex; 105 + position: relative; 106 + align-items: flex-start; 107 + gap: 1rem; 108 + padding-bottom: 1.25rem; 109 + } 110 + 111 + .item:last-child { 112 + padding-bottom: 0; 113 + } 114 + 115 + .emoji { 116 + display: flex; 117 + position: relative; 118 + flex-shrink: 0; 119 + justify-content: center; 120 + align-items: center; 121 + z-index: 1; 122 + border: 2px solid var(--color-border); 123 + border-radius: var(--radius-full); 124 + background-color: var(--color-bg-elevated); 125 + width: 3rem; 126 + height: 3rem; 127 + font-size: 1.5rem; 128 + } 129 + 130 + .content { 131 + flex: 1; 132 + padding-top: 0.75rem; 133 + } 134 + 135 + .author { 136 + font-weight: 500; 137 + } 138 + 139 + .meta { 140 + color: var(--color-text-muted); 141 + font-size: 0.875rem; 142 + } 143 + </style>
-1
src/lib/index.ts
··· 1 - // place files you want to import through the `$lib` alias in this folder.
+1
src/lib/lexicons/index.ts
··· 1 + export * as XyzStatusphereStatus from './types/xyz/statusphere/status.js';
+34
src/lib/lexicons/types/xyz/statusphere/status.ts
··· 1 + import type {} from '@atcute/lexicons'; 2 + import * as v from '@atcute/lexicons/validations'; 3 + import type {} from '@atcute/lexicons/ambient'; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.record( 6 + /*#__PURE__*/ v.tidString(), 7 + /*#__PURE__*/ v.object({ 8 + $type: /*#__PURE__*/ v.literal('xyz.statusphere.status'), 9 + createdAt: /*#__PURE__*/ v.datetimeString(), 10 + /** 11 + * @minLength 1 12 + * @maxLength 32 13 + * @maxGraphemes 1 14 + */ 15 + status: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 16 + /*#__PURE__*/ v.stringLength(1, 32), 17 + /*#__PURE__*/ v.stringGraphemes(0, 1), 18 + ]), 19 + }), 20 + ); 21 + 22 + type main$schematype = typeof _mainSchema; 23 + 24 + export interface mainSchema extends main$schematype {} 25 + 26 + export const mainSchema = _mainSchema as mainSchema; 27 + 28 + export interface Main extends v.InferInput<typeof mainSchema> {} 29 + 30 + declare module '@atcute/lexicons/ambient' { 31 + interface Records { 32 + 'xyz.statusphere.status': mainSchema; 33 + } 34 + }
+53
src/lib/server/auth/app-session.ts
··· 1 + import { eq } from 'drizzle-orm'; 2 + import { nanoid } from 'nanoid'; 3 + 4 + import type { Did } from '@atcute/lexicons/syntax'; 5 + 6 + import { db } from '$lib/server/db'; 7 + import { appSession } from '$lib/server/db/schema'; 8 + 9 + export const APP_SESSION_COOKIE = 'statusphere_session'; 10 + 11 + export type AppSession = { 12 + id: string; 13 + did: Did; 14 + }; 15 + 16 + export const createAppSession = async (did: Did): Promise<AppSession> => { 17 + const id = nanoid(32); 18 + const ts = Date.now(); 19 + 20 + await db 21 + .insert(appSession) 22 + .values({ 23 + id, 24 + did, 25 + createdAt: ts, 26 + lastSeenAt: ts, 27 + }) 28 + .run(); 29 + 30 + return { id, did }; 31 + }; 32 + 33 + export const deleteAppSession = async (id: string): Promise<void> => { 34 + await db.delete(appSession).where(eq(appSession.id, id)).run(); 35 + }; 36 + 37 + export const getAppSession = async (id: string): Promise<AppSession | null> => { 38 + const row = await db.select().from(appSession).where(eq(appSession.id, id)).get(); 39 + if (!row) { 40 + return null; 41 + } 42 + 43 + const now = Date.now(); 44 + if (row.lastSeenAt && now - row.lastSeenAt > 15 * 60 * 1000) { 45 + touchAppSession(id); 46 + } 47 + 48 + return { id: row.id, did: row.did as Did }; 49 + }; 50 + 51 + const touchAppSession = async (id: string): Promise<void> => { 52 + await db.update(appSession).set({ lastSeenAt: Date.now() }).where(eq(appSession.id, id)).run(); 53 + };
+46
src/lib/server/auth/index.ts
··· 1 + import { Client } from '@atcute/client'; 2 + import { 3 + AuthMethodUnsatisfiableError, 4 + TokenInvalidError, 5 + TokenRefreshError, 6 + TokenRevokedError, 7 + } from '@atcute/oauth-node-client'; 8 + 9 + import { getRequestEvent } from '$app/server'; 10 + 11 + import { APP_SESSION_COOKIE, deleteAppSession } from '$lib/server/auth/app-session'; 12 + import { oauth } from '$lib/server/oauth'; 13 + 14 + const isSessionInvalidError = (err: unknown): boolean => { 15 + return ( 16 + err instanceof TokenRefreshError || 17 + err instanceof TokenInvalidError || 18 + err instanceof TokenRevokedError || 19 + err instanceof AuthMethodUnsatisfiableError 20 + ); 21 + }; 22 + 23 + export const getAuthedClient = async (): Promise<Client> => { 24 + const { 25 + locals: { session: sessionInfo }, 26 + cookies, 27 + } = getRequestEvent(); 28 + 29 + if (!sessionInfo) { 30 + throw new Error(`not signed in`); 31 + } 32 + 33 + try { 34 + const session = await oauth.restore(sessionInfo.did); 35 + const client = new Client({ handler: session }); 36 + 37 + return client; 38 + } catch (err) { 39 + if (isSessionInvalidError(err)) { 40 + await deleteAppSession(sessionInfo.id); 41 + cookies.delete(APP_SESSION_COOKIE, { path: '/' }); 42 + } 43 + 44 + throw err; 45 + } 46 + };
+59
src/lib/server/auth/signed-cookie.ts
··· 1 + import { createHmac, timingSafeEqual } from 'node:crypto'; 2 + 3 + import type { Cookies } from '@sveltejs/kit'; 4 + 5 + import { env } from '$env/dynamic/private'; 6 + 7 + import { fromBase64Url, toBase64Url } from '@atcute/multibase'; 8 + 9 + if (!env.COOKIE_SECRET) { 10 + throw new Error(`COOKIE_SECRET is not set`); 11 + } 12 + 13 + const SEPARATOR = '.'; 14 + 15 + const hmacSha256 = (data: string): Uint8Array => { 16 + return createHmac('sha256', env.COOKIE_SECRET).update(data).digest(); 17 + }; 18 + 19 + export const getSignedCookie = (cookies: Cookies, name: string): string | null => { 20 + const signed = cookies.get(name); 21 + if (!signed) { 22 + return null; 23 + } 24 + 25 + const idx = signed.lastIndexOf(SEPARATOR); 26 + if (idx === -1) { 27 + return null; 28 + } 29 + 30 + const value = signed.slice(0, idx); 31 + const sig = signed.slice(idx + 1); 32 + 33 + let expected: Uint8Array; 34 + let got: Uint8Array; 35 + try { 36 + expected = hmacSha256(value); 37 + got = fromBase64Url(sig); 38 + } catch { 39 + return null; 40 + } 41 + 42 + if (!timingSafeEqual(got, expected)) { 43 + return null; 44 + } 45 + 46 + return value; 47 + }; 48 + 49 + export const setSignedCookie = ( 50 + cookies: Cookies, 51 + name: string, 52 + value: string, 53 + options: Parameters<Cookies['set']>[2], 54 + ): void => { 55 + const sig = toBase64Url(hmacSha256(value)); 56 + const signed = `${value}${SEPARATOR}${sig}`; 57 + 58 + cookies.set(name, signed, options); 59 + };
+7 -3
src/lib/server/db/index.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { createClient } from '@libsql/client'; 1 3 import { drizzle } from 'drizzle-orm/libsql'; 2 - import { createClient } from '@libsql/client'; 4 + 3 5 import * as schema from './schema'; 4 - import { env } from '$env/dynamic/private'; 5 6 6 - if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set'); 7 + if (!env.DATABASE_URL) { 8 + throw new Error('DATABASE_URL is not set'); 9 + } 7 10 8 11 const client = createClient({ url: env.DATABASE_URL }); 9 12 10 13 export const db = drizzle(client, { schema }); 14 + export { schema };
+58 -6
src/lib/server/db/schema.ts
··· 1 - import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 1 + import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 2 2 3 - export const user = sqliteTable('user', { 4 - id: text('id') 5 - .primaryKey() 6 - .$defaultFn(() => crypto.randomUUID()), 7 - age: integer('age'), 3 + export const oauthState = sqliteTable( 4 + 'oauth_state', 5 + { 6 + key: text('key').primaryKey(), 7 + stateJson: text('state_json').notNull(), 8 + expiresAt: integer('expires_at').notNull(), 9 + }, 10 + (table) => [index('oauth_state_expires_at_idx').on(table.expiresAt)], 11 + ); 12 + 13 + export const oauthSession = sqliteTable( 14 + 'oauth_session', 15 + { 16 + did: text('did').primaryKey(), 17 + sessionJson: text('session_json').notNull(), 18 + updatedAt: integer('updated_at').notNull(), 19 + }, 20 + (table) => [index('oauth_session_updated_at_idx').on(table.updatedAt)], 21 + ); 22 + 23 + export const appSession = sqliteTable( 24 + 'app_session', 25 + { 26 + id: text('id').primaryKey(), 27 + did: text('did').notNull(), 28 + createdAt: integer('created_at').notNull(), 29 + lastSeenAt: integer('last_seen_at').notNull(), 30 + }, 31 + (table) => [index('app_session_did_idx').on(table.did)], 32 + ); 33 + 34 + export const identity = sqliteTable('identity', { 35 + did: text('did').primaryKey(), 36 + handle: text('handle').notNull(), 37 + isActive: integer('is_active', { mode: 'boolean' }).notNull(), 38 + status: text('status').notNull(), 39 + updatedAt: integer('updated_at').notNull(), 8 40 }); 41 + 42 + export const profile = sqliteTable('profile', { 43 + did: text('did').primaryKey(), 44 + displayName: text('display_name'), 45 + recordJson: text('record_json').notNull(), 46 + indexedAt: integer('indexed_at').notNull(), 47 + }); 48 + 49 + export const status = sqliteTable( 50 + 'status', 51 + { 52 + uri: text('uri').primaryKey(), 53 + authorDid: text('author_did').notNull(), 54 + rkey: text('rkey').notNull(), 55 + status: text('status').notNull(), 56 + createdAt: text('created_at').notNull(), 57 + indexedAt: integer('indexed_at').notNull(), 58 + }, 59 + (table) => [index('status_indexed_at_idx').on(table.indexedAt)], 60 + );
+52
src/lib/server/oauth/index.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { 3 + CompositeDidDocumentResolver, 4 + CompositeHandleResolver, 5 + LocalActorResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + WellKnownHandleResolver, 9 + } from '@atcute/identity-resolver'; 10 + import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; 11 + import { OAuthClient, importJwkKey } from '@atcute/oauth-node-client'; 12 + 13 + import { stores } from './stores'; 14 + 15 + if (!env.OAUTH_PUBLIC_URL) { 16 + throw new Error(`OAUTH_PUBLIC_URL is not set`); 17 + } 18 + 19 + if (!env.OAUTH_PRIVATE_KEY_JWK) { 20 + throw new Error(`OAUTH_PRIVATE_KEY_JWK is not set`); 21 + } 22 + 23 + const publicUrl = new URL(env.OAUTH_PUBLIC_URL); 24 + 25 + export const oauth = new OAuthClient({ 26 + metadata: { 27 + client_id: new URL('/oauth-client-metadata.json', publicUrl).href, 28 + client_name: 'statusphere', 29 + redirect_uris: [new URL('/oauth/callback', publicUrl).href], 30 + scope: 'atproto transition:generic', 31 + jwks_uri: new URL('/jwks.json', publicUrl).href, 32 + }, 33 + 34 + keyset: await Promise.all([importJwkKey(env.OAUTH_PRIVATE_KEY_JWK)]), 35 + 36 + actorResolver: new LocalActorResolver({ 37 + handleResolver: new CompositeHandleResolver({ 38 + methods: { 39 + dns: new NodeDnsHandleResolver(), 40 + http: new WellKnownHandleResolver(), 41 + }, 42 + }), 43 + didDocumentResolver: new CompositeDidDocumentResolver({ 44 + methods: { 45 + plc: new PlcDidDocumentResolver(), 46 + web: new WebDidDocumentResolver(), 47 + }, 48 + }), 49 + }), 50 + 51 + stores, 52 + });
+78
src/lib/server/oauth/stores.ts
··· 1 + import { eq, lte } from 'drizzle-orm'; 2 + 3 + import type { Did } from '@atcute/lexicons/syntax'; 4 + import type { OAuthClientStores, StoredSession, StoredState } from '@atcute/oauth-node-client'; 5 + 6 + import { db } from '$lib/server/db'; 7 + import { oauthSession, oauthState } from '$lib/server/db/schema'; 8 + 9 + export const stores: OAuthClientStores = { 10 + sessions: { 11 + async get(did: Did) { 12 + const row = await db.select().from(oauthSession).where(eq(oauthSession.did, did)).get(); 13 + if (!row) { 14 + return; 15 + } 16 + 17 + return JSON.parse(row.sessionJson) as StoredSession; 18 + }, 19 + async set(did: Did, value: StoredSession) { 20 + const sessionJson = JSON.stringify(value); 21 + const updatedAt = Date.now(); 22 + 23 + await db 24 + .insert(oauthSession) 25 + .values({ did, sessionJson, updatedAt }) 26 + .onConflictDoUpdate({ 27 + target: oauthSession.did, 28 + set: { sessionJson, updatedAt }, 29 + }) 30 + .run(); 31 + }, 32 + async delete(did: Did) { 33 + await db.delete(oauthSession).where(eq(oauthSession.did, did)).run(); 34 + }, 35 + async clear() { 36 + await db.delete(oauthSession).run(); 37 + }, 38 + }, 39 + 40 + states: { 41 + async get(key: string) { 42 + const row = await db.select().from(oauthState).where(eq(oauthState.key, key)).get(); 43 + if (!row) { 44 + return; 45 + } 46 + 47 + if (row.expiresAt <= Date.now()) { 48 + await db.delete(oauthState).where(eq(oauthState.key, key)).run(); 49 + return; 50 + } 51 + 52 + return JSON.parse(row.stateJson) as StoredState; 53 + }, 54 + async set(key: string, value: StoredState) { 55 + const stateJson = JSON.stringify(value); 56 + const expiresAt = value.expiresAt; 57 + 58 + await db 59 + .insert(oauthState) 60 + .values({ key, stateJson, expiresAt }) 61 + .onConflictDoUpdate({ 62 + target: oauthState.key, 63 + set: { stateJson, expiresAt }, 64 + }) 65 + .run(); 66 + }, 67 + async delete(key: string) { 68 + await db.delete(oauthState).where(eq(oauthState.key, key)).run(); 69 + }, 70 + async clear() { 71 + await db.delete(oauthState).run(); 72 + }, 73 + }, 74 + }; 75 + 76 + export const pruneExpiredStates = async () => { 77 + await db.delete(oauthState).where(lte(oauthState.expiresAt, Date.now())).run(); 78 + };
+15
src/lib/server/tap/index.ts
··· 1 + import type { TapClient } from '@atcute/tap'; 2 + 3 + import { ingestTapEvent } from './ingest'; 4 + 5 + /** 6 + * runs a tap subscription loop until the process exits. 7 + * 8 + * @param tap configured tap client 9 + */ 10 + export const runTapSubscription = async (tap: TapClient): Promise<void> => { 11 + for await (const { event, ack } of tap.subscribe()) { 12 + await ingestTapEvent(event); 13 + await ack(); 14 + } 15 + };
+129
src/lib/server/tap/ingest.ts
··· 1 + import { AppBskyActorProfile } from '@atcute/bluesky'; 2 + import { safeParse } from '@atcute/lexicons/validations'; 3 + import type { TapEvent } from '@atcute/tap'; 4 + import { eq } from 'drizzle-orm'; 5 + 6 + import { XyzStatusphereStatus } from '$lib/lexicons'; 7 + import { db } from '$lib/server/db'; 8 + import { identity, profile, status } from '$lib/server/db/schema'; 9 + 10 + const now = () => Date.now(); 11 + 12 + const toAtUri = (did: string, collection: string, rkey: string): string => { 13 + return `at://${did}/${collection}/${rkey}`; 14 + }; 15 + 16 + /** 17 + * ingests a single tap event into the local database. 18 + * 19 + * @param event tap event 20 + */ 21 + export const ingestTapEvent = async (event: TapEvent): Promise<void> => { 22 + if (event.type === 'identity') { 23 + await db 24 + .insert(identity) 25 + .values({ 26 + did: event.did, 27 + handle: event.handle, 28 + isActive: event.isActive, 29 + status: event.status, 30 + updatedAt: now(), 31 + }) 32 + .onConflictDoUpdate({ 33 + target: identity.did, 34 + set: { 35 + handle: event.handle, 36 + isActive: event.isActive, 37 + status: event.status, 38 + updatedAt: now(), 39 + }, 40 + }) 41 + .run(); 42 + 43 + return; 44 + } 45 + 46 + if (event.collection === 'app.bsky.actor.profile') { 47 + if (event.rkey !== 'self') { 48 + return; 49 + } 50 + 51 + if (event.action === 'delete') { 52 + await db.delete(profile).where(eq(profile.did, event.did)).run(); 53 + return; 54 + } 55 + 56 + const record = event.record; 57 + if (!record) { 58 + return; 59 + } 60 + 61 + const parsed = safeParse(AppBskyActorProfile.mainSchema, record); 62 + if (!parsed.ok) { 63 + return; 64 + } 65 + 66 + const indexedAt = now(); 67 + const recordJson = JSON.stringify(parsed.value); 68 + 69 + await db 70 + .insert(profile) 71 + .values({ 72 + did: event.did, 73 + displayName: parsed.value.displayName ?? null, 74 + recordJson, 75 + indexedAt, 76 + }) 77 + .onConflictDoUpdate({ 78 + target: profile.did, 79 + set: { 80 + displayName: parsed.value.displayName ?? null, 81 + recordJson, 82 + indexedAt, 83 + }, 84 + }) 85 + .run(); 86 + 87 + return; 88 + } 89 + 90 + if (event.collection === 'xyz.statusphere.status') { 91 + const uri = toAtUri(event.did, event.collection, event.rkey); 92 + 93 + if (event.action === 'delete') { 94 + await db.delete(status).where(eq(status.uri, uri)).run(); 95 + return; 96 + } 97 + 98 + const record = event.record; 99 + if (!record) { 100 + return; 101 + } 102 + 103 + const parsed = safeParse(XyzStatusphereStatus.mainSchema, record); 104 + if (!parsed.ok) { 105 + return; 106 + } 107 + 108 + const indexedAt = now(); 109 + 110 + await db 111 + .insert(status) 112 + .values({ 113 + uri, 114 + authorDid: event.did, 115 + rkey: event.rkey, 116 + status: parsed.value.status, 117 + createdAt: parsed.value.createdAt, 118 + indexedAt, 119 + }) 120 + .onConflictDoUpdate({ 121 + target: status.uri, 122 + set: { 123 + status: parsed.value.status, 124 + indexedAt, 125 + }, 126 + }) 127 + .run(); 128 + } 129 + };
+28
src/lib/status-options.ts
··· 1 + export const statusOptions = [ 2 + '👍', 3 + '👎', 4 + '💙', 5 + '🥹', 6 + '😧', 7 + '🙃', 8 + '😉', 9 + '😎', 10 + '🤓', 11 + '🤨', 12 + '🥳', 13 + '😭', 14 + '😤', 15 + '🤯', 16 + '🫡', 17 + '💀', 18 + '✊', 19 + '🤘', 20 + '👀', 21 + '🧠', 22 + '👩‍💻', 23 + '🧑‍💻', 24 + '🥷', 25 + '🧌', 26 + '🦋', 27 + '🚀', 28 + ];
+213
src/lib/status.remote.ts
··· 1 + import { invalid } from '@sveltejs/kit'; 2 + 3 + import * as v from 'valibot'; 4 + 5 + import { ComAtprotoRepoCreateRecord } from '@atcute/atproto'; 6 + import { ok } from '@atcute/client'; 7 + import type { CanonicalResourceUri, Did, Handle } from '@atcute/lexicons'; 8 + import * as TID from '@atcute/tid'; 9 + import { and, desc, eq, inArray, lt, or } from 'drizzle-orm'; 10 + 11 + import { form, getRequestEvent, query } from '$app/server'; 12 + 13 + import type { XyzStatusphereStatus } from '$lib/lexicons'; 14 + import { getAuthedClient } from '$lib/server/auth'; 15 + import { db, schema } from '$lib/server/db'; 16 + import { statusOptions } from '$lib/status-options'; 17 + 18 + export interface CurrentUser { 19 + did: Did; 20 + handle: Handle; 21 + displayName?: string; 22 + } 23 + 24 + /** returns the current user's profile, or null if not signed in */ 25 + export const getCurrentUser = query(async (): Promise<CurrentUser | null> => { 26 + const { 27 + locals: { session }, 28 + } = getRequestEvent(); 29 + 30 + if (!session) { 31 + return null; 32 + } 33 + 34 + const [identity, profile] = await Promise.all([ 35 + db.select().from(schema.identity).where(eq(schema.identity.did, session.did)).get(), 36 + db.select().from(schema.profile).where(eq(schema.profile.did, session.did)).get(), 37 + ]); 38 + 39 + return { 40 + did: session.did, 41 + handle: (identity?.handle ?? 'handle.invalid') as Handle, 42 + displayName: profile?.displayName ?? undefined, 43 + }; 44 + }); 45 + 46 + const encodeCursor = (indexedAt: number, uri: string): string => { 47 + return `${indexedAt}:${uri}`; 48 + }; 49 + 50 + const cursorSchema = v.pipe( 51 + v.string(), 52 + v.rawTransform(({ dataset, addIssue, NEVER }) => { 53 + const input = dataset.value; 54 + 55 + const idx = input.indexOf(':'); 56 + if (idx === -1) { 57 + addIssue({ message: 'invalid cursor format' }); 58 + return NEVER; 59 + } 60 + 61 + const indexedAt = parseInt(input.slice(0, idx), 10); 62 + const uri = input.slice(idx + 1); 63 + 64 + if (Number.isNaN(indexedAt) || !uri) { 65 + addIssue({ message: 'invalid cursor format' }); 66 + return NEVER; 67 + } 68 + 69 + return { indexedAt, uri }; 70 + }), 71 + ); 72 + 73 + export const postStatus = form( 74 + v.object({ 75 + status: v.pipe(v.string(), v.minLength(1), v.maxLength(32), v.maxGraphemes(1)), 76 + }), 77 + async ({ status }, issue) => { 78 + const { 79 + locals: { session }, 80 + } = getRequestEvent(); 81 + 82 + if (!session) { 83 + invalid(`not signed in`); 84 + } 85 + 86 + if (!statusOptions.includes(status)) { 87 + invalid(issue.status(`invalid status`)); 88 + } 89 + 90 + const client = await getAuthedClient(); 91 + 92 + const rkey = TID.now(); 93 + const createdAt = new Date().toISOString(); 94 + 95 + const record: XyzStatusphereStatus.Main = { 96 + $type: 'xyz.statusphere.status', 97 + createdAt: createdAt, 98 + status: status, 99 + }; 100 + 101 + try { 102 + await ok( 103 + client.call(ComAtprotoRepoCreateRecord, { 104 + input: { 105 + repo: session.did, 106 + collection: 'xyz.statusphere.status', 107 + rkey: rkey, 108 + record, 109 + }, 110 + }), 111 + ); 112 + } catch (err) { 113 + console.error(`failed to post status:`, err); 114 + 115 + invalid(`could not post status - please try again`); 116 + } 117 + 118 + // insert locally so we don't have to wait for ingester 119 + { 120 + const uri: CanonicalResourceUri = `at://${session.did}/xyz.statusphere.status/${rkey}`; 121 + await db 122 + .insert(schema.status) 123 + .values({ 124 + uri, 125 + authorDid: session.did, 126 + rkey, 127 + status, 128 + createdAt, 129 + indexedAt: Date.now(), 130 + }) 131 + .onConflictDoNothing() 132 + .run(); 133 + } 134 + }, 135 + ); 136 + 137 + export interface AuthorView { 138 + did: Did; 139 + handle: Handle; 140 + displayName?: string; 141 + avatar?: string; 142 + } 143 + 144 + export interface StatusView { 145 + author: AuthorView; 146 + status: string; 147 + indexedAt: string; 148 + } 149 + 150 + export interface TimelineResponse { 151 + cursor: string | undefined; 152 + statuses: StatusView[]; 153 + } 154 + 155 + export const getTimeline = query( 156 + v.object({ 157 + cursor: v.optional(cursorSchema), 158 + }), 159 + async ({ cursor }): Promise<TimelineResponse> => { 160 + const limit = 20; 161 + 162 + const statusRows = await db 163 + .select() 164 + .from(schema.status) 165 + .where( 166 + cursor 167 + ? or( 168 + lt(schema.status.indexedAt, cursor.indexedAt), 169 + and(eq(schema.status.indexedAt, cursor.indexedAt), lt(schema.status.uri, cursor.uri)), 170 + ) 171 + : undefined, 172 + ) 173 + .orderBy(desc(schema.status.indexedAt), desc(schema.status.uri)) 174 + .limit(limit + 1) 175 + .all(); 176 + 177 + const hasMore = statusRows.length > limit; 178 + const items = hasMore ? statusRows.slice(0, limit) : statusRows; 179 + 180 + const dids = [...new Set(items.map((s) => s.authorDid))]; 181 + 182 + const [identities, profiles] = await Promise.all([ 183 + db.select().from(schema.identity).where(inArray(schema.identity.did, dids)).all(), 184 + db.select().from(schema.profile).where(inArray(schema.profile.did, dids)).all(), 185 + ]); 186 + 187 + const identityMap = new Map(identities.map((i) => [i.did, i])); 188 + const profileMap = new Map(profiles.map((p) => [p.did, p])); 189 + 190 + const statuses = items.map((s): StatusView => { 191 + const identity = identityMap.get(s.authorDid); 192 + const profile = profileMap.get(s.authorDid); 193 + const indexedAt = Math.min(Date.parse(s.createdAt), s.indexedAt); 194 + 195 + return { 196 + author: { 197 + did: s.authorDid as Did, 198 + handle: (identity?.handle ?? 'handle.invalid') as Handle, 199 + displayName: profile?.displayName ?? undefined, 200 + }, 201 + status: s.status, 202 + indexedAt: new Date(indexedAt).toISOString(), 203 + }; 204 + }); 205 + 206 + const last = items[items.length - 1]; 207 + 208 + return { 209 + cursor: hasMore && last ? encodeCursor(last.indexedAt, last.uri) : undefined, 210 + statuses, 211 + }; 212 + }, 213 + );
+1
src/routes/+layout.svelte
··· 1 1 <script lang="ts"> 2 2 import favicon from '$lib/assets/favicon.svg'; 3 + import '../app.css'; 3 4 4 5 let { children } = $props(); 5 6 </script>
+94 -2
src/routes/+page.svelte
··· 1 - <h1>Welcome to SvelteKit</h1> 2 - <p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> 1 + <script lang="ts"> 2 + import Header from '$lib/components/header.svelte'; 3 + import StatusPicker from '$lib/components/status-picker.svelte'; 4 + import Timeline from '$lib/components/timeline.svelte'; 5 + 6 + import { getCurrentUser, getTimeline } from '$lib/status.remote'; 7 + 8 + const user = await getCurrentUser(); 9 + </script> 10 + 11 + <div class="container"> 12 + <Header /> 13 + 14 + {#if user} 15 + <StatusPicker {user} /> 16 + {:else} 17 + <div class="card"> 18 + <p class="card-title">welcome to statusphere</p> 19 + <p class="card-text">sign in to share your status with the world</p> 20 + </div> 21 + {/if} 22 + 23 + <svelte:boundary> 24 + <Timeline statuses={(await getTimeline({})).statuses} /> 25 + 26 + {#snippet pending()} 27 + <div class="loading"> 28 + <div class="spinner"></div> 29 + </div> 30 + {/snippet} 31 + 32 + {#snippet failed(_error)} 33 + <div class="error"> 34 + <p>failed to load timeline</p> 35 + </div> 36 + {/snippet} 37 + </svelte:boundary> 38 + </div> 39 + 40 + <style> 41 + .container { 42 + margin: 0 auto; 43 + padding: 1.5rem 1rem; 44 + max-width: 600px; 45 + } 46 + 47 + .card { 48 + margin-bottom: 1.5rem; 49 + border: 1px solid var(--color-border); 50 + border-radius: var(--radius-lg); 51 + background-color: var(--color-bg-elevated); 52 + padding: 1rem; 53 + } 54 + 55 + .card-title { 56 + margin-bottom: 0.5rem; 57 + font-weight: 500; 58 + } 59 + 60 + .card-text { 61 + color: var(--color-text-muted); 62 + font-size: 0.875rem; 63 + } 64 + 65 + .loading { 66 + display: flex; 67 + justify-content: center; 68 + align-items: center; 69 + padding: 2rem; 70 + } 71 + 72 + .spinner { 73 + animation: spin 0.8s linear infinite; 74 + border: 2px solid var(--color-border); 75 + border-top-color: var(--color-accent); 76 + border-radius: 50%; 77 + width: 1.5rem; 78 + height: 1.5rem; 79 + } 80 + 81 + @keyframes spin { 82 + to { 83 + transform: rotate(360deg); 84 + } 85 + } 86 + 87 + .error { 88 + border-radius: var(--radius-md); 89 + background-color: var(--color-error-bg); 90 + padding: 0.75rem 1rem; 91 + color: var(--color-error); 92 + font-size: 0.875rem; 93 + } 94 + </style>
+11
src/routes/jwks.json/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + 3 + import { oauth } from '$lib/server/oauth'; 4 + 5 + export const GET = () => { 6 + return json(oauth.jwks, { 7 + headers: { 8 + 'cache-control': 'public, max-age=60', 9 + }, 10 + }); 11 + };
+155
src/routes/login/+page.svelte
··· 1 + <script lang="ts"> 2 + import { doLogin } from '$lib/auth.remote'; 3 + 4 + const identifierIssues = $derived(doLogin.fields.identifier.issues() ?? []); 5 + const allIssues = $derived(doLogin.fields.issues() ?? []); 6 + const formIssue = $derived(allIssues.find((i) => i.path === undefined)); 7 + </script> 8 + 9 + <div class="page"> 10 + <h1 class="title">sign in to statusphere</h1> 11 + <p class="subtitle">enter your Bluesky handle or DID</p> 12 + 13 + <form class="form" {...doLogin}> 14 + <div class="field"> 15 + <label for="identifier" class="label">handle</label> 16 + <input 17 + {...doLogin.fields.identifier.as('text')} 18 + id="identifier" 19 + class="input" 20 + placeholder="alice.bsky.social" 21 + required 22 + aria-invalid={identifierIssues.length > 0 ? 'true' : undefined} 23 + /> 24 + {#if identifierIssues.length > 0} 25 + <p class="error">{identifierIssues[0].message}</p> 26 + {/if} 27 + </div> 28 + 29 + {#if formIssue} 30 + <div class="form-error"> 31 + <p>{formIssue.message}</p> 32 + </div> 33 + {/if} 34 + 35 + <button type="submit" class="btn" disabled={!!doLogin.pending}> 36 + {doLogin.pending ? 'signing in...' : 'sign in'} 37 + </button> 38 + </form> 39 + 40 + <p class="back"> 41 + <a href="/">← back to home</a> 42 + </p> 43 + </div> 44 + 45 + <style> 46 + .page { 47 + margin: 0 auto; 48 + padding: 3rem 1rem; 49 + max-width: 400px; 50 + } 51 + 52 + .title { 53 + margin-bottom: 0.5rem; 54 + font-weight: 700; 55 + font-size: 1.5rem; 56 + text-align: center; 57 + } 58 + 59 + .subtitle { 60 + margin-bottom: 2rem; 61 + color: var(--color-text-muted); 62 + text-align: center; 63 + } 64 + 65 + .form { 66 + border: 1px solid var(--color-border); 67 + border-radius: var(--radius-lg); 68 + background-color: var(--color-bg-elevated); 69 + padding: 1.5rem; 70 + } 71 + 72 + .field { 73 + margin-bottom: 1rem; 74 + } 75 + 76 + .label { 77 + display: block; 78 + margin-bottom: 0.375rem; 79 + font-weight: 500; 80 + font-size: 0.875rem; 81 + } 82 + 83 + .input { 84 + transition: 85 + border-color 0.15s, 86 + box-shadow 0.15s; 87 + border: 1px solid var(--color-border); 88 + border-radius: var(--radius-md); 89 + background-color: var(--color-bg); 90 + padding: 0.625rem 0.75rem; 91 + width: 100%; 92 + color: var(--color-text); 93 + font-size: 1rem; 94 + } 95 + 96 + .input::placeholder { 97 + color: var(--color-text-muted); 98 + } 99 + 100 + .input:focus { 101 + outline: none; 102 + box-shadow: 0 0 0 3px var(--color-accent-bg); 103 + border-color: var(--color-accent); 104 + } 105 + 106 + .input[aria-invalid='true'] { 107 + border-color: var(--color-error); 108 + } 109 + 110 + .error { 111 + margin-top: 0.375rem; 112 + color: var(--color-error); 113 + font-size: 0.875rem; 114 + } 115 + 116 + .form-error { 117 + margin-bottom: 1rem; 118 + border-radius: var(--radius-md); 119 + background-color: var(--color-error-bg); 120 + padding: 0.75rem 1rem; 121 + color: var(--color-error); 122 + font-size: 0.875rem; 123 + } 124 + 125 + .btn { 126 + display: inline-flex; 127 + justify-content: center; 128 + align-items: center; 129 + transition: background-color 0.15s; 130 + cursor: pointer; 131 + border: none; 132 + border-radius: var(--radius-md); 133 + background-color: var(--color-accent); 134 + padding: 0.625rem 1rem; 135 + width: 100%; 136 + color: white; 137 + font-weight: 500; 138 + font-size: 1rem; 139 + } 140 + 141 + .btn:hover:not(:disabled) { 142 + background-color: var(--color-accent-hover); 143 + } 144 + 145 + .btn:disabled { 146 + opacity: 0.5; 147 + cursor: not-allowed; 148 + } 149 + 150 + .back { 151 + margin-top: 1.5rem; 152 + font-size: 0.875rem; 153 + text-align: center; 154 + } 155 + </style>
+11
src/routes/oauth-client-metadata.json/+server.ts
··· 1 + import { json } from '@sveltejs/kit'; 2 + 3 + import { oauth } from '$lib/server/oauth'; 4 + 5 + export const GET = () => { 6 + return json(oauth.metadata, { 7 + headers: { 8 + 'cache-control': 'public, max-age=60', 9 + }, 10 + }); 11 + };
+29
src/routes/oauth/callback/+server.ts
··· 1 + import { redirect } from '@sveltejs/kit'; 2 + 3 + import { APP_SESSION_COOKIE, createAppSession, deleteAppSession } from '$lib/server/auth/app-session'; 4 + import { getSignedCookie, setSignedCookie } from '$lib/server/auth/signed-cookie'; 5 + import { oauth } from '$lib/server/oauth'; 6 + 7 + export const GET = async ({ url, cookies }) => { 8 + { 9 + const existingSessionId = getSignedCookie(cookies, APP_SESSION_COOKIE); 10 + if (existingSessionId) { 11 + await deleteAppSession(existingSessionId); 12 + } 13 + } 14 + 15 + const { session } = await oauth.callback(url.searchParams); 16 + 17 + const appSession = await createAppSession(session.did); 18 + const secure = url.protocol === 'https:'; 19 + 20 + setSignedCookie(cookies, APP_SESSION_COOKIE, appSession.id, { 21 + httpOnly: true, 22 + secure: secure, 23 + sameSite: 'lax' as const, 24 + path: '/', 25 + maxAge: 60 * 60 * 24 * 30, 26 + }); 27 + 28 + redirect(303, '/'); 29 + };
+9
svelte.config.js
··· 7 7 // for more information about preprocessors 8 8 preprocess: vitePreprocess(), 9 9 10 + compilerOptions: { 11 + experimental: { 12 + async: true, 13 + }, 14 + }, 15 + 10 16 kit: { 11 17 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 12 18 // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 19 // See https://svelte.dev/docs/kit/adapters for more information about adapters. 14 20 adapter: adapter(), 21 + experimental: { 22 + remoteFunctions: true, 23 + }, 15 24 }, 16 25 }; 17 26
+3
vite.config.ts
··· 3 3 4 4 export default defineConfig({ 5 5 plugins: [sveltekit()], 6 + server: { 7 + allowedHosts: ['.trycloudflare.com'], 8 + }, 6 9 });