An atproto based writing game loosely inspired by Fiasco!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

basic login

+932 -111
+41
claude/auth-implementation-plan.md
··· 1 + # AT Proto OAuth Authentication Implementation Plan 2 + 3 + ## Architecture Overview 4 + Combine patterns from both reference projects: 5 + - **statusphere**: AT Proto OAuth client with DB-backed session/state storage 6 + - **solid-test**: Cookie-based session management with vinxi/http useSession 7 + - **Our approach**: AT Proto OAuth + iron-session cookies storing only session ID + full session data in DB 8 + 9 + ## Database Schema Changes (`src/db/schema.ts`) 10 + 1. Add `auth_session` table (key: string PK, session: JSON/text) 11 + 2. Add `auth_state` table (key: string PK, state: JSON/text) 12 + 3. Add `user` table (did: string PK, handle: varchar, createdAt: timestamp) 13 + 4. Add `user_session` table (sessionId: string PK, did: string FK, createdAt: timestamp, expiresAt: timestamp) 14 + 15 + ## New Files to Create 16 + 1. `src/auth/client.ts` - NodeOAuthClient setup 17 + 2. `src/auth/storage.ts` - StateStore & SessionStore classes (DB-backed) 18 + 3. `src/auth/session.ts` - iron-session helper for cookie management 19 + 4. `src/auth/index.ts` - Export auth utilities 20 + 21 + ## Server Actions (`src/api/server.ts`) 22 + 1. `initiateLogin(handle: string)` - Start OAuth flow 23 + 2. `handleOAuthCallback(params: URLSearchParams)` - Complete OAuth, create user session 24 + 3. `logout()` - Destroy session 25 + 4. `getUser()` - Retrieve user from session 26 + 27 + ## Routes 28 + 1. Update `src/routes/login.tsx` - AT Proto handle input 29 + 2. Add OAuth callback route/handler 30 + 3. Protect routes requiring auth 31 + 32 + ## Environment Variables 33 + Add to `.env`: 34 + - `COOKIE_SECRET` - for iron-session 35 + - `PUBLIC_URL` - for OAuth client metadata (optional, dev uses localhost) 36 + 37 + ## Flow 38 + 1. User enters handle → `initiateLogin` → OAuth authorize URL 39 + 2. Redirect to PDS → User approves 40 + 3. Callback → `handleOAuthCallback` → Store OAuth session in DB, create user_session, set cookie with sessionId 41 + 4. Cookie contains only sessionId → Server reads sessionId → Looks up user_session → Gets DID → Restores OAuth session from DB → Gets Agent
+24
drizzle/0002_loose_meltdown.sql
··· 1 + CREATE TABLE "auth_session" ( 2 + "key" varchar(255) PRIMARY KEY NOT NULL, 3 + "session" text NOT NULL 4 + ); 5 + --> statement-breakpoint 6 + CREATE TABLE "auth_state" ( 7 + "key" varchar(255) PRIMARY KEY NOT NULL, 8 + "state" text NOT NULL 9 + ); 10 + --> statement-breakpoint 11 + CREATE TABLE "user_session" ( 12 + "sessionId" varchar(255) PRIMARY KEY NOT NULL, 13 + "did" varchar(255) NOT NULL, 14 + "createdAt" timestamp DEFAULT now() NOT NULL, 15 + "expiresAt" timestamp NOT NULL 16 + ); 17 + --> statement-breakpoint 18 + CREATE TABLE "user" ( 19 + "did" varchar(255) PRIMARY KEY NOT NULL, 20 + "handle" varchar(255) NOT NULL, 21 + "createdAt" timestamp DEFAULT now() NOT NULL 22 + ); 23 + --> statement-breakpoint 24 + ALTER TABLE "user_session" ADD CONSTRAINT "user_session_did_user_did_fk" FOREIGN KEY ("did") REFERENCES "public"."user"("did") ON DELETE cascade ON UPDATE no action;
+195
drizzle/meta/0002_snapshot.json
··· 1 + { 2 + "id": "ed4e5fa6-c644-4498-81f2-2ab6e82937ce", 3 + "prevId": "88443dfe-9235-4d7e-a3a9-9407e5d33065", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.auth_session": { 8 + "name": "auth_session", 9 + "schema": "", 10 + "columns": { 11 + "key": { 12 + "name": "key", 13 + "type": "varchar(255)", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "session": { 18 + "name": "session", 19 + "type": "text", 20 + "primaryKey": false, 21 + "notNull": true 22 + } 23 + }, 24 + "indexes": {}, 25 + "foreignKeys": {}, 26 + "compositePrimaryKeys": {}, 27 + "uniqueConstraints": {}, 28 + "policies": {}, 29 + "checkConstraints": {}, 30 + "isRLSEnabled": false 31 + }, 32 + "public.auth_state": { 33 + "name": "auth_state", 34 + "schema": "", 35 + "columns": { 36 + "key": { 37 + "name": "key", 38 + "type": "varchar(255)", 39 + "primaryKey": true, 40 + "notNull": true 41 + }, 42 + "state": { 43 + "name": "state", 44 + "type": "text", 45 + "primaryKey": false, 46 + "notNull": true 47 + } 48 + }, 49 + "indexes": {}, 50 + "foreignKeys": {}, 51 + "compositePrimaryKeys": {}, 52 + "uniqueConstraints": {}, 53 + "policies": {}, 54 + "checkConstraints": {}, 55 + "isRLSEnabled": false 56 + }, 57 + "public.post": { 58 + "name": "post", 59 + "schema": "", 60 + "columns": { 61 + "id": { 62 + "name": "id", 63 + "type": "integer", 64 + "primaryKey": true, 65 + "notNull": true, 66 + "identity": { 67 + "type": "always", 68 + "name": "post_id_seq", 69 + "schema": "public", 70 + "increment": "1", 71 + "startWith": "1", 72 + "minValue": "1", 73 + "maxValue": "2147483647", 74 + "cache": "1", 75 + "cycle": false 76 + } 77 + }, 78 + "message": { 79 + "name": "message", 80 + "type": "varchar(255)", 81 + "primaryKey": false, 82 + "notNull": true 83 + }, 84 + "author": { 85 + "name": "author", 86 + "type": "varchar(255)", 87 + "primaryKey": false, 88 + "notNull": true 89 + } 90 + }, 91 + "indexes": {}, 92 + "foreignKeys": {}, 93 + "compositePrimaryKeys": {}, 94 + "uniqueConstraints": {}, 95 + "policies": {}, 96 + "checkConstraints": {}, 97 + "isRLSEnabled": false 98 + }, 99 + "public.user_session": { 100 + "name": "user_session", 101 + "schema": "", 102 + "columns": { 103 + "sessionId": { 104 + "name": "sessionId", 105 + "type": "varchar(255)", 106 + "primaryKey": true, 107 + "notNull": true 108 + }, 109 + "did": { 110 + "name": "did", 111 + "type": "varchar(255)", 112 + "primaryKey": false, 113 + "notNull": true 114 + }, 115 + "createdAt": { 116 + "name": "createdAt", 117 + "type": "timestamp", 118 + "primaryKey": false, 119 + "notNull": true, 120 + "default": "now()" 121 + }, 122 + "expiresAt": { 123 + "name": "expiresAt", 124 + "type": "timestamp", 125 + "primaryKey": false, 126 + "notNull": true 127 + } 128 + }, 129 + "indexes": {}, 130 + "foreignKeys": { 131 + "user_session_did_user_did_fk": { 132 + "name": "user_session_did_user_did_fk", 133 + "tableFrom": "user_session", 134 + "tableTo": "user", 135 + "columnsFrom": [ 136 + "did" 137 + ], 138 + "columnsTo": [ 139 + "did" 140 + ], 141 + "onDelete": "cascade", 142 + "onUpdate": "no action" 143 + } 144 + }, 145 + "compositePrimaryKeys": {}, 146 + "uniqueConstraints": {}, 147 + "policies": {}, 148 + "checkConstraints": {}, 149 + "isRLSEnabled": false 150 + }, 151 + "public.user": { 152 + "name": "user", 153 + "schema": "", 154 + "columns": { 155 + "did": { 156 + "name": "did", 157 + "type": "varchar(255)", 158 + "primaryKey": true, 159 + "notNull": true 160 + }, 161 + "handle": { 162 + "name": "handle", 163 + "type": "varchar(255)", 164 + "primaryKey": false, 165 + "notNull": true 166 + }, 167 + "createdAt": { 168 + "name": "createdAt", 169 + "type": "timestamp", 170 + "primaryKey": false, 171 + "notNull": true, 172 + "default": "now()" 173 + } 174 + }, 175 + "indexes": {}, 176 + "foreignKeys": {}, 177 + "compositePrimaryKeys": {}, 178 + "uniqueConstraints": {}, 179 + "policies": {}, 180 + "checkConstraints": {}, 181 + "isRLSEnabled": false 182 + } 183 + }, 184 + "enums": {}, 185 + "schemas": {}, 186 + "sequences": {}, 187 + "roles": {}, 188 + "policies": {}, 189 + "views": {}, 190 + "_meta": { 191 + "columns": {}, 192 + "schemas": {}, 193 + "tables": {} 194 + } 195 + }
+7
drizzle/meta/_journal.json
··· 15 15 "when": 1759350317765, 16 16 "tag": "0001_brown_wraith", 17 17 "breakpoints": true 18 + }, 19 + { 20 + "idx": 2, 21 + "version": "7", 22 + "when": 1759612340624, 23 + "tag": "0002_loose_meltdown", 24 + "breakpoints": true 18 25 } 19 26 ] 20 27 }
+3
package.json
··· 14 14 "tsx": "^4.20.6" 15 15 }, 16 16 "dependencies": { 17 + "@atproto/api": "^0.17.0", 18 + "@atproto/oauth-client-node": "^0.3.8", 19 + "@atproto/syntax": "^0.4.1", 17 20 "@solidjs/router": "^0.15.0", 18 21 "@solidjs/start": "^1.1.0", 19 22 "better-sqlite3": "^11.0.0",
+257
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/api': 12 + specifier: ^0.17.0 13 + version: 0.17.0 14 + '@atproto/oauth-client-node': 15 + specifier: ^0.3.8 16 + version: 0.3.8 17 + '@atproto/syntax': 18 + specifier: ^0.4.1 19 + version: 0.4.1 11 20 '@solidjs/router': 12 21 specifier: ^0.15.0 13 22 version: 0.15.3(solid-js@1.9.9) ··· 51 60 52 61 packages: 53 62 63 + '@atproto-labs/did-resolver@0.2.1': 64 + resolution: {integrity: sha512-zSoHyqwwRYUtMNLW+RrWsImt1U5S47nJv5FfmAXTmon6wVKjxKD/PFrD1pg/4G6THqJmQHTs1Hj+54XVupYnvQ==} 65 + 66 + '@atproto-labs/fetch-node@0.1.10': 67 + resolution: {integrity: sha512-o7hGaonA71A6p7O107VhM6UBUN/g9tTyYohMp1q0Kf6xQ4npnuZYRSHSf2g6reSfGQJ1GoFNjBObETTT1ge/jQ==} 68 + engines: {node: '>=18.7.0'} 69 + 70 + '@atproto-labs/fetch@0.2.3': 71 + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} 72 + 73 + '@atproto-labs/handle-resolver-node@0.1.19': 74 + resolution: {integrity: sha512-nNVCfiKudvMYfDcWCa9koOMOpCYaC0wG4Uys5dZev99s/Nka7tRlIZIV+u+GWivnG9lqCupKATkoyCd6Per8Gw==} 75 + engines: {node: '>=18.7.0'} 76 + 77 + '@atproto-labs/handle-resolver@0.3.1': 78 + resolution: {integrity: sha512-mLZdMNvwomgnn9sffKO1/xr02ctgeiT0FUVw7JekbchTckub2RM7qMu8Rw1mC4bpCpW+i7DXDiOxpoajkppwYQ==} 79 + 80 + '@atproto-labs/identity-resolver@0.3.1': 81 + resolution: {integrity: sha512-jCgotRRqPykPwh4gh0FBLOqeofv1G8OH/DZ5s88HWm7biUZeksZwDrEvL5TnqEFUpXT3O9Hcyp/XEpfCAplRoQ==} 82 + 83 + '@atproto-labs/pipe@0.1.1': 84 + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} 85 + 86 + '@atproto-labs/simple-store-memory@0.1.4': 87 + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} 88 + 89 + '@atproto-labs/simple-store@0.3.0': 90 + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 91 + 92 + '@atproto/api@0.17.0': 93 + resolution: {integrity: sha512-FNS9SW7/3kslAnJH7F4fO9/jPjXzC0NMD6u9NjJ/h4EnaIEpWHZQPkmD9Q2hvAwD6+Uo2boYZEPKkOa55Lr5Dg==} 94 + 95 + '@atproto/common-web@0.4.3': 96 + resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==} 97 + 98 + '@atproto/did@0.2.0': 99 + resolution: {integrity: sha512-BskT39KYbwY1DUsWekkHh47xS+wvJpFq5F9acsicNfYniinyAMnNTzGKQEhnjQuG7K0qQItg/SnmC+y0tJXV7Q==} 100 + 101 + '@atproto/jwk-jose@0.1.10': 102 + resolution: {integrity: sha512-Eiu/u4tZHz3IIhHZt0zneYEffSAO3Oqk/ToKwlu1TqKte6sjtPs/4uquSiAAGFYozqgo92JC/AQclWzzkHI5QQ==} 103 + 104 + '@atproto/jwk-webcrypto@0.1.10': 105 + resolution: {integrity: sha512-JZsavs6JiSmw5rgcjkGDwzr1aCJGdybZOjVfYH+m9sXRU1BrUCA30uwNfZY7eFyWXyRAnCFiYiGVZgypXyKotw==} 106 + 107 + '@atproto/jwk@0.5.0': 108 + resolution: {integrity: sha512-Qi2NtEqhkG+uz3CKia4+H05WMV/z//dz3ESo5+cyBKrOnxVTJ5ZubMyltWjoYvy6v/jLhorXdDWcjn07yky7MQ==} 109 + 110 + '@atproto/lexicon@0.5.1': 111 + resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==} 112 + 113 + '@atproto/oauth-client-node@0.3.8': 114 + resolution: {integrity: sha512-HIBiYQERj04Xa0l8cJkqcZC0BbHH5uqDEvhqHWnJ5umSq/ms0+HZi3JKJXGv1XfYOvxUxx6NKgXJ8VhhYoQa5A==} 115 + engines: {node: '>=18.7.0'} 116 + 117 + '@atproto/oauth-client@0.5.6': 118 + resolution: {integrity: sha512-O1S9lPptJxWPcNd2kODaLgWntz+A7PzskU2hP4IFa7hVLs4aEnEt9dKq5wJE97tDli8mgyh/ndPQhxUaCVQ5iQ==} 119 + 120 + '@atproto/oauth-types@0.4.1': 121 + resolution: {integrity: sha512-c5ixf2ZOzcltOu1fDBnO/tok6Wj7JDDK66+Z0q/+bAr8LXgOnxP7zQfJ+DD4gTkB+saTqsqWtVv8qvx/IEtm1g==} 122 + 123 + '@atproto/syntax@0.4.1': 124 + resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} 125 + 126 + '@atproto/xrpc@0.7.5': 127 + resolution: {integrity: sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==} 128 + 54 129 '@babel/code-frame@7.26.2': 55 130 resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} 56 131 engines: {node: '>=6.9.0'} ··· 1038 1113 async@3.2.6: 1039 1114 resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} 1040 1115 1116 + await-lock@2.2.2: 1117 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 1118 + 1041 1119 b4a@1.7.3: 1042 1120 resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} 1043 1121 peerDependencies: ··· 1660 1738 1661 1739 graceful-fs@4.2.11: 1662 1740 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 1741 + 1742 + graphemer@1.4.0: 1743 + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} 1663 1744 1664 1745 gzip-size@7.0.0: 1665 1746 resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} ··· 1733 1814 resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} 1734 1815 engines: {node: '>=12.22.0'} 1735 1816 1817 + ipaddr.js@2.2.0: 1818 + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} 1819 + engines: {node: '>= 10'} 1820 + 1736 1821 iron-webcrypto@1.2.1: 1737 1822 resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} 1738 1823 ··· 1811 1896 resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} 1812 1897 engines: {node: '>=16'} 1813 1898 1899 + iso-datestring-validator@2.2.2: 1900 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 1901 + 1814 1902 jackspeak@3.4.3: 1815 1903 resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} 1816 1904 ··· 1821 1909 jiti@2.6.0: 1822 1910 resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==} 1823 1911 hasBin: true 1912 + 1913 + jose@5.10.0: 1914 + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 1824 1915 1825 1916 js-tokens@4.0.0: 1826 1917 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} ··· 1979 2070 1980 2071 ms@2.1.3: 1981 2072 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 2073 + 2074 + multiformats@9.9.0: 2075 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 1982 2076 1983 2077 nanoid@3.3.11: 1984 2078 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} ··· 2540 2634 resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} 2541 2635 engines: {node: '>=12.0.0'} 2542 2636 2637 + tlds@1.260.0: 2638 + resolution: {integrity: sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==} 2639 + hasBin: true 2640 + 2543 2641 to-regex-range@5.0.1: 2544 2642 resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 2545 2643 engines: {node: '>=8.0'} ··· 2572 2670 ufo@1.6.1: 2573 2671 resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} 2574 2672 2673 + uint8arrays@3.0.0: 2674 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 2675 + 2575 2676 ultrahtml@1.6.0: 2576 2677 resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} 2577 2678 ··· 2586 2687 2587 2688 undici-types@6.21.0: 2588 2689 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 2690 + 2691 + undici@6.22.0: 2692 + resolution: {integrity: sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw==} 2693 + engines: {node: '>=18.17'} 2589 2694 2590 2695 unenv@1.10.0: 2591 2696 resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} ··· 2859 2964 2860 2965 snapshots: 2861 2966 2967 + '@atproto-labs/did-resolver@0.2.1': 2968 + dependencies: 2969 + '@atproto-labs/fetch': 0.2.3 2970 + '@atproto-labs/pipe': 0.1.1 2971 + '@atproto-labs/simple-store': 0.3.0 2972 + '@atproto-labs/simple-store-memory': 0.1.4 2973 + '@atproto/did': 0.2.0 2974 + zod: 3.25.76 2975 + 2976 + '@atproto-labs/fetch-node@0.1.10': 2977 + dependencies: 2978 + '@atproto-labs/fetch': 0.2.3 2979 + '@atproto-labs/pipe': 0.1.1 2980 + ipaddr.js: 2.2.0 2981 + undici: 6.22.0 2982 + 2983 + '@atproto-labs/fetch@0.2.3': 2984 + dependencies: 2985 + '@atproto-labs/pipe': 0.1.1 2986 + 2987 + '@atproto-labs/handle-resolver-node@0.1.19': 2988 + dependencies: 2989 + '@atproto-labs/fetch-node': 0.1.10 2990 + '@atproto-labs/handle-resolver': 0.3.1 2991 + '@atproto/did': 0.2.0 2992 + 2993 + '@atproto-labs/handle-resolver@0.3.1': 2994 + dependencies: 2995 + '@atproto-labs/simple-store': 0.3.0 2996 + '@atproto-labs/simple-store-memory': 0.1.4 2997 + '@atproto/did': 0.2.0 2998 + zod: 3.25.76 2999 + 3000 + '@atproto-labs/identity-resolver@0.3.1': 3001 + dependencies: 3002 + '@atproto-labs/did-resolver': 0.2.1 3003 + '@atproto-labs/handle-resolver': 0.3.1 3004 + 3005 + '@atproto-labs/pipe@0.1.1': {} 3006 + 3007 + '@atproto-labs/simple-store-memory@0.1.4': 3008 + dependencies: 3009 + '@atproto-labs/simple-store': 0.3.0 3010 + lru-cache: 10.4.3 3011 + 3012 + '@atproto-labs/simple-store@0.3.0': {} 3013 + 3014 + '@atproto/api@0.17.0': 3015 + dependencies: 3016 + '@atproto/common-web': 0.4.3 3017 + '@atproto/lexicon': 0.5.1 3018 + '@atproto/syntax': 0.4.1 3019 + '@atproto/xrpc': 0.7.5 3020 + await-lock: 2.2.2 3021 + multiformats: 9.9.0 3022 + tlds: 1.260.0 3023 + zod: 3.25.76 3024 + 3025 + '@atproto/common-web@0.4.3': 3026 + dependencies: 3027 + graphemer: 1.4.0 3028 + multiformats: 9.9.0 3029 + uint8arrays: 3.0.0 3030 + zod: 3.25.76 3031 + 3032 + '@atproto/did@0.2.0': 3033 + dependencies: 3034 + zod: 3.25.76 3035 + 3036 + '@atproto/jwk-jose@0.1.10': 3037 + dependencies: 3038 + '@atproto/jwk': 0.5.0 3039 + jose: 5.10.0 3040 + 3041 + '@atproto/jwk-webcrypto@0.1.10': 3042 + dependencies: 3043 + '@atproto/jwk': 0.5.0 3044 + '@atproto/jwk-jose': 0.1.10 3045 + zod: 3.25.76 3046 + 3047 + '@atproto/jwk@0.5.0': 3048 + dependencies: 3049 + multiformats: 9.9.0 3050 + zod: 3.25.76 3051 + 3052 + '@atproto/lexicon@0.5.1': 3053 + dependencies: 3054 + '@atproto/common-web': 0.4.3 3055 + '@atproto/syntax': 0.4.1 3056 + iso-datestring-validator: 2.2.2 3057 + multiformats: 9.9.0 3058 + zod: 3.25.76 3059 + 3060 + '@atproto/oauth-client-node@0.3.8': 3061 + dependencies: 3062 + '@atproto-labs/did-resolver': 0.2.1 3063 + '@atproto-labs/handle-resolver-node': 0.1.19 3064 + '@atproto-labs/simple-store': 0.3.0 3065 + '@atproto/did': 0.2.0 3066 + '@atproto/jwk': 0.5.0 3067 + '@atproto/jwk-jose': 0.1.10 3068 + '@atproto/jwk-webcrypto': 0.1.10 3069 + '@atproto/oauth-client': 0.5.6 3070 + '@atproto/oauth-types': 0.4.1 3071 + 3072 + '@atproto/oauth-client@0.5.6': 3073 + dependencies: 3074 + '@atproto-labs/did-resolver': 0.2.1 3075 + '@atproto-labs/fetch': 0.2.3 3076 + '@atproto-labs/handle-resolver': 0.3.1 3077 + '@atproto-labs/identity-resolver': 0.3.1 3078 + '@atproto-labs/simple-store': 0.3.0 3079 + '@atproto-labs/simple-store-memory': 0.1.4 3080 + '@atproto/did': 0.2.0 3081 + '@atproto/jwk': 0.5.0 3082 + '@atproto/oauth-types': 0.4.1 3083 + '@atproto/xrpc': 0.7.5 3084 + multiformats: 9.9.0 3085 + zod: 3.25.76 3086 + 3087 + '@atproto/oauth-types@0.4.1': 3088 + dependencies: 3089 + '@atproto/jwk': 0.5.0 3090 + zod: 3.25.76 3091 + 3092 + '@atproto/syntax@0.4.1': {} 3093 + 3094 + '@atproto/xrpc@0.7.5': 3095 + dependencies: 3096 + '@atproto/lexicon': 0.5.1 3097 + zod: 3.25.76 3098 + 2862 3099 '@babel/code-frame@7.26.2': 2863 3100 dependencies: 2864 3101 '@babel/helper-validator-identifier': 7.27.1 ··· 3810 4047 3811 4048 async@3.2.6: {} 3812 4049 4050 + await-lock@2.2.2: {} 4051 + 3813 4052 b4a@1.7.3: {} 3814 4053 3815 4054 babel-dead-code-elimination@1.0.10: ··· 4317 4556 unicorn-magic: 0.3.0 4318 4557 4319 4558 graceful-fs@4.2.11: {} 4559 + 4560 + graphemer@1.4.0: {} 4320 4561 4321 4562 gzip-size@7.0.0: 4322 4563 dependencies: ··· 4427 4668 transitivePeerDependencies: 4428 4669 - supports-color 4429 4670 4671 + ipaddr.js@2.2.0: {} 4672 + 4430 4673 iron-webcrypto@1.2.1: {} 4431 4674 4432 4675 is-core-module@2.16.1: ··· 4481 4724 4482 4725 isexe@3.1.1: {} 4483 4726 4727 + iso-datestring-validator@2.2.2: {} 4728 + 4484 4729 jackspeak@3.4.3: 4485 4730 dependencies: 4486 4731 '@isaacs/cliui': 8.0.2 ··· 4490 4735 jiti@1.21.7: {} 4491 4736 4492 4737 jiti@2.6.0: {} 4738 + 4739 + jose@5.10.0: {} 4493 4740 4494 4741 js-tokens@4.0.0: {} 4495 4742 ··· 4650 4897 ms@2.0.0: {} 4651 4898 4652 4899 ms@2.1.3: {} 4900 + 4901 + multiformats@9.9.0: {} 4653 4902 4654 4903 nanoid@3.3.11: {} 4655 4904 ··· 5360 5609 fdir: 6.5.0(picomatch@4.0.3) 5361 5610 picomatch: 4.0.3 5362 5611 5612 + tlds@1.260.0: {} 5613 + 5363 5614 to-regex-range@5.0.1: 5364 5615 dependencies: 5365 5616 is-number: 7.0.0 ··· 5387 5638 5388 5639 ufo@1.6.1: {} 5389 5640 5641 + uint8arrays@3.0.0: 5642 + dependencies: 5643 + multiformats: 9.9.0 5644 + 5390 5645 ultrahtml@1.6.0: {} 5391 5646 5392 5647 uncrypto@0.1.3: {} ··· 5401 5656 undici-types@5.28.4: {} 5402 5657 5403 5658 undici-types@6.21.0: {} 5659 + 5660 + undici@6.22.0: {} 5404 5661 5405 5662 unenv@1.10.0: 5406 5663 dependencies:
+4 -3
src/api/index.ts
··· 1 1 import { action, query } from "@solidjs/router"; 2 - import { getUser as gU, logout as l, loginOrRegister as lOR } from "./server"; 2 + import { getUser as gU, logout as l, initiateLogin as iL, getAgent as gA } from "./server"; 3 3 4 4 export const getUser = query(gU, "user"); 5 - export const loginOrRegister = action(lOR, "loginOrRegister"); 6 - export const logout = action(l, "logout"); 5 + export const initiateLogin = action(iL, "initiateLogin"); 6 + export const logout = action(l, "logout"); 7 + export const getAgent = query(gA, "agent");
+159 -67
src/api/server.ts
··· 1 - // "use server"; 2 - // import { redirect } from "@solidjs/router"; 3 - // import { useSession } from "vinxi/http"; 4 - // import { eq } from "drizzle-orm"; 5 - // import { db } from "./db"; 6 - // import { Users } from "../../drizzle/schema"; 1 + "use server" 7 2 8 - // function validateUsername(username: unknown) { 9 - // if (typeof username !== "string" || username.length < 3) { 10 - // return `Usernames must be at least 3 characters long`; 11 - // } 12 - // } 3 + import { redirect } from "@solidjs/router" 4 + import { isValidHandle } from '@atproto/syntax' 5 + import { Agent } from '@atproto/api' 6 + import { drizzle } from 'drizzle-orm/node-postgres' 7 + import { eq } from 'drizzle-orm' 8 + import { getOAuthClient } from '~/auth' 9 + import { userTable, userSessionTable } from '~/db/schema' 10 + import { getRequestEvent } from 'solid-js/web' 13 11 14 - // function validatePassword(password: unknown) { 15 - // if (typeof password !== "string" || password.length < 6) { 16 - // return `Passwords must be at least 6 characters long`; 17 - // } 18 - // } 12 + const db = drizzle(process.env.DATABASE_URL!) 19 13 20 - // async function login(username: string, password: string) { 21 - // const user = db.select().from(Users).where(eq(Users.username, username)).get(); 22 - // if (!user || password !== user.password) throw new Error("Invalid login"); 23 - // return user; 24 - // } 14 + export async function initiateLogin(formData: FormData) { 15 + const handle = String(formData.get('handle')) 25 16 26 - // async function register(username: string, password: string) { 27 - // const existingUser = db.select().from(Users).where(eq(Users.username, username)).get(); 28 - // if (existingUser) throw new Error("User already exists"); 29 - // return db.insert(Users).values({ username, password }).returning().get(); 30 - // } 17 + if (!isValidHandle(handle)) { 18 + return { error: 'Invalid handle' } 19 + } 31 20 32 - // function getSession() { 33 - // return useSession({ 34 - // password: process.env.SESSION_SECRET ?? "areallylongsecretthatyoushouldreplace" 35 - // }); 36 - // } 21 + try { 22 + const oauthClient = await getOAuthClient() 23 + const url = await oauthClient.authorize(handle, { 24 + scope: 'atproto transition:generic', 25 + }) 26 + throw redirect(url.toString()) 27 + } catch (err) { 28 + if (err instanceof Response) throw err 29 + console.error('OAuth authorize failed:', err) 30 + return { error: 'Could not initiate login' } 31 + } 32 + } 37 33 38 - // export async function loginOrRegister(formData: FormData) { 39 - // const username = String(formData.get("username")); 40 - // const password = String(formData.get("password")); 41 - // const loginType = String(formData.get("loginType")); 42 - // let error = validateUsername(username) || validatePassword(password); 43 - // if (error) return new Error(error); 34 + export async function handleOAuthCallback(params: URLSearchParams) { 35 + try { 36 + const oauthClient = await getOAuthClient() 37 + const { session: oauthSession } = await oauthClient.callback(params) 44 38 45 - // try { 46 - // const user = await (loginType !== "login" 47 - // ? register(username, password) 48 - // : login(username, password)); 49 - // const session = await getSession(); 50 - // await session.update(d => { 51 - // d.userId = user.id; 52 - // }); 53 - // } catch (err) { 54 - // return err as Error; 55 - // } 56 - // throw redirect("/"); 57 - // } 39 + // Get or create user 40 + const existingUser = await db 41 + .select() 42 + .from(userTable) 43 + .where(eq(userTable.did, oauthSession.did)) 44 + .limit(1) 58 45 59 - // export async function logout() { 60 - // const session = await getSession(); 61 - // await session.update(d => (d.userId = undefined)); 62 - // throw redirect("/login"); 63 - // } 46 + if (existingUser.length === 0) { 47 + // Fetch handle from the agent 48 + const agent = new Agent(oauthSession) 49 + const profile = await agent.getProfile({ actor: oauthSession.did }) 64 50 65 - // export async function getUser() { 66 - // const session = await getSession(); 67 - // const userId = session.data.userId; 68 - // if (userId === undefined) throw redirect("/login"); 51 + await db.insert(userTable).values({ 52 + did: oauthSession.did, 53 + handle: profile.data.handle, 54 + }) 55 + } 69 56 70 - // try { 71 - // const user = db.select().from(Users).where(eq(Users.id, userId)).get(); 72 - // if (!user) throw redirect("/login"); 73 - // return { id: user.id, username: user.username }; 74 - // } catch { 75 - // throw logout(); 76 - // } 77 - // } 57 + // Create user session 58 + const sessionId = crypto.randomUUID() 59 + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days 60 + 61 + await db.insert(userSessionTable).values({ 62 + sessionId, 63 + did: oauthSession.did, 64 + expiresAt, 65 + }) 66 + 67 + return { sessionId, did: oauthSession.did } 68 + } catch (err) { 69 + console.error('OAuth callback failed:', err) 70 + throw redirect('/?error=auth_failed') 71 + } 72 + } 73 + 74 + export async function logout() { 75 + const { getSession } = await import('~/auth') 76 + const session = await getSession() 77 + 78 + if (session.data.sessionId) { 79 + await db 80 + .delete(userSessionTable) 81 + .where(eq(userSessionTable.sessionId, session.data.sessionId)) 82 + 83 + await session.update((d) => { 84 + d.sessionId = undefined 85 + }) 86 + } 87 + 88 + throw redirect('/login') 89 + } 90 + 91 + export async function getUser() { 92 + const { getSession } = await import('~/auth') 93 + const session = await getSession() 94 + 95 + if (!session.data.sessionId) return null 96 + 97 + try { 98 + const userSession = await db 99 + .select() 100 + .from(userSessionTable) 101 + .where(eq(userSessionTable.sessionId, session.data.sessionId)) 102 + .limit(1) 103 + 104 + if (!userSession || userSession.length === 0) { 105 + await session.update((d) => { 106 + d.sessionId = undefined 107 + }) 108 + return null 109 + } 110 + 111 + const sessionData = userSession[0] 112 + 113 + // Check if session expired 114 + if (new Date() > sessionData.expiresAt) { 115 + await db 116 + .delete(userSessionTable) 117 + .where(eq(userSessionTable.sessionId, session.data.sessionId)) 118 + await session.update((d) => { 119 + d.sessionId = undefined 120 + }) 121 + return null 122 + } 123 + 124 + const user = await db 125 + .select() 126 + .from(userTable) 127 + .where(eq(userTable.did, sessionData.did)) 128 + .limit(1) 129 + 130 + if (!user || user.length === 0) { 131 + await session.update((d) => { 132 + d.sessionId = undefined 133 + }) 134 + return null 135 + } 136 + 137 + return { did: user[0].did, handle: user[0].handle } 138 + } catch (err) { 139 + console.error('Failed to get user:', err) 140 + return null 141 + } 142 + } 143 + 144 + export async function getAgent() { 145 + const { getSession } = await import('~/auth') 146 + const session = await getSession() 147 + 148 + if (!session.data.sessionId) return null 149 + 150 + try { 151 + const userSession = await db 152 + .select() 153 + .from(userSessionTable) 154 + .where(eq(userSessionTable.sessionId, session.data.sessionId)) 155 + .limit(1) 156 + 157 + if (!userSession || userSession.length === 0) return null 158 + 159 + const oauthClient = await getOAuthClient() 160 + const oauthSession = await oauthClient.restore(userSession[0].did) 161 + 162 + if (!oauthSession) return null 163 + 164 + return new Agent(oauthSession) 165 + } catch (err) { 166 + console.error('Failed to restore agent:', err) 167 + return null 168 + } 169 + }
+34
src/auth/client.ts
··· 1 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 2 + import { SessionStore, StateStore } from './storage' 3 + 4 + let oauthClient: NodeOAuthClient | null = null 5 + 6 + export const getOAuthClient = async () => { 7 + if (oauthClient) return oauthClient 8 + 9 + const publicUrl = process.env.PUBLIC_URL 10 + const port = process.env.PORT || 3000 11 + const url = publicUrl || `http://localhost:${port}` 12 + const enc = encodeURIComponent 13 + 14 + oauthClient = new NodeOAuthClient({ 15 + clientMetadata: { 16 + client_name: 'Protoshamble', 17 + client_id: publicUrl 18 + ? `${url}/client-metadata.json` 19 + : `http://localhost?redirect_uri=${enc(`${url}/oauth/callback`)}&scope=${enc('atproto transition:generic')}`, 20 + client_uri: url, 21 + redirect_uris: [`${url}/oauth/callback`], 22 + scope: 'atproto transition:generic', 23 + grant_types: ['authorization_code', 'refresh_token'], 24 + response_types: ['code'], 25 + application_type: 'web', 26 + token_endpoint_auth_method: 'none', 27 + dpop_bound_access_tokens: true, 28 + }, 29 + stateStore: new StateStore(), 30 + sessionStore: new SessionStore(), 31 + }) 32 + 33 + return oauthClient 34 + }
+3
src/auth/index.ts
··· 1 + export { getOAuthClient } from './client' 2 + export { getSession } from './session' 3 + export type { SessionData } from './session'
+11
src/auth/session.ts
··· 1 + import { useSession } from 'vinxi/http' 2 + 3 + export type SessionData = { 4 + sessionId?: string 5 + } 6 + 7 + export const getSession = () => { 8 + return useSession<SessionData>({ 9 + password: process.env.COOKIE_SECRET || 'complex_password_at_least_32_characters_long', 10 + }) 11 + }
+71
src/auth/storage.ts
··· 1 + import type { 2 + NodeSavedSession, 3 + NodeSavedSessionStore, 4 + NodeSavedState, 5 + NodeSavedStateStore, 6 + } from '@atproto/oauth-client-node' 7 + import { drizzle } from 'drizzle-orm/node-postgres' 8 + import { eq } from 'drizzle-orm' 9 + import { authSessionTable, authStateTable } from '~/db/schema' 10 + 11 + const db = drizzle(process.env.DATABASE_URL!) 12 + 13 + export class StateStore implements NodeSavedStateStore { 14 + async get(key: string): Promise<NodeSavedState | undefined> { 15 + const result = await db 16 + .select() 17 + .from(authStateTable) 18 + .where(eq(authStateTable.key, key)) 19 + .limit(1) 20 + 21 + if (!result || result.length === 0) return undefined 22 + return JSON.parse(result[0].state) as NodeSavedState 23 + } 24 + 25 + async set(key: string, val: NodeSavedState) { 26 + const state = JSON.stringify(val) 27 + await db 28 + .insert(authStateTable) 29 + .values({ key, state }) 30 + .onConflictDoUpdate({ 31 + target: authStateTable.key, 32 + set: { state } 33 + }) 34 + } 35 + 36 + async del(key: string) { 37 + await db 38 + .delete(authStateTable) 39 + .where(eq(authStateTable.key, key)) 40 + } 41 + } 42 + 43 + export class SessionStore implements NodeSavedSessionStore { 44 + async get(key: string): Promise<NodeSavedSession | undefined> { 45 + const result = await db 46 + .select() 47 + .from(authSessionTable) 48 + .where(eq(authSessionTable.key, key)) 49 + .limit(1) 50 + 51 + if (!result || result.length === 0) return undefined 52 + return JSON.parse(result[0].session) as NodeSavedSession 53 + } 54 + 55 + async set(key: string, val: NodeSavedSession) { 56 + const session = JSON.stringify(val) 57 + await db 58 + .insert(authSessionTable) 59 + .values({ key, session }) 60 + .onConflictDoUpdate({ 61 + target: authSessionTable.key, 62 + set: { session } 63 + }) 64 + } 65 + 66 + async del(key: string) { 67 + await db 68 + .delete(authSessionTable) 69 + .where(eq(authSessionTable.key, key)) 70 + } 71 + }
+24 -1
src/db/schema.ts
··· 1 - import { integer, pgTable, varchar } from "drizzle-orm/pg-core"; 1 + import { integer, pgTable, varchar, text, timestamp } from "drizzle-orm/pg-core"; 2 2 3 3 export const postTable = pgTable("post", { 4 4 id: integer().primaryKey().generatedAlwaysAsIdentity(), 5 5 message: varchar({ length: 255 }).notNull(), 6 6 author: varchar({ length: 255 }).notNull() 7 7 }); 8 + 9 + export const authSessionTable = pgTable("auth_session", { 10 + key: varchar({ length: 255 }).primaryKey(), 11 + session: text().notNull() 12 + }); 13 + 14 + export const authStateTable = pgTable("auth_state", { 15 + key: varchar({ length: 255 }).primaryKey(), 16 + state: text().notNull() 17 + }); 18 + 19 + export const userTable = pgTable("user", { 20 + did: varchar({ length: 255 }).primaryKey(), 21 + handle: varchar({ length: 255 }).notNull(), 22 + createdAt: timestamp().notNull().defaultNow() 23 + }); 24 + 25 + export const userSessionTable = pgTable("user_session", { 26 + sessionId: varchar({ length: 255 }).primaryKey(), 27 + did: varchar({ length: 255 }).notNull().references(() => userTable.did, { onDelete: 'cascade' }), 28 + createdAt: timestamp().notNull().defaultNow(), 29 + expiresAt: timestamp().notNull() 30 + });
+44 -10
src/routes/index.tsx
··· 1 1 import { createAsync, type RouteDefinition } from "@solidjs/router"; 2 - import { For } from "solid-js"; 2 + import { For, Show } from "solid-js"; 3 3 import { action } from "@solidjs/router"; 4 4 import { getPosts, insertPost } from "~/db"; 5 + import { getUser, logout } from "~/api"; 5 6 6 7 export const route = { 7 8 preload() { 8 9 const post = getPosts(); 10 + const user = getUser(); 9 11 console.log(post) 10 - return post 12 + return { post, user } 11 13 } 12 14 } satisfies RouteDefinition; 13 15 ··· 19 21 20 22 export default function Home() { 21 23 const posts = createAsync(async () => (await getPosts()).map(post => post.message || ''), { deferStream: true }); 24 + const user = createAsync(() => getUser()); 22 25 23 26 return ( 24 - <main class="w-full p-4 space-y-2"> 25 - <For each={posts()}> 26 - {(post) => (<span>{post}</span>)} 27 - </For> 28 - {/* <h2 class="font-bold text-3xl">Hello {user()?.username}</h2> */} 27 + <main class="w-full p-4 space-y-4"> 28 + <div class="flex items-center justify-between"> 29 + <Show 30 + when={user()} 31 + fallback={ 32 + <a href="/login" class="text-blue-500 underline">Login</a> 33 + } 34 + > 35 + <div class="flex items-center gap-4"> 36 + <span>Logged in as: <strong>{user()?.handle}</strong></span> 37 + <form action={logout} method="post"> 38 + <button type="submit" class="px-3 py-1 bg-red-500 text-white rounded"> 39 + Logout 40 + </button> 41 + </form> 42 + </div> 43 + </Show> 44 + </div> 45 + 29 46 <h3 class="font-bold text-xl">Message board</h3> 30 - <form action={post} method="post"> 31 - <input type="text" name="message" id="message" /> 32 - <button type="submit"> 47 + 48 + <div class="space-y-2"> 49 + <For each={posts()}> 50 + {(post) => ( 51 + <div class="p-2 border rounded"> 52 + {post} 53 + </div> 54 + )} 55 + </For> 56 + </div> 57 + 58 + <form action={post} method="post" class="space-y-2"> 59 + <input 60 + type="text" 61 + name="message" 62 + id="message" 63 + placeholder="Enter your message" 64 + class="w-full px-3 py-2 border rounded" 65 + /> 66 + <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded"> 33 67 Post 34 68 </button> 35 69 </form>
+26 -30
src/routes/login.tsx
··· 1 - import { 2 - useSubmission, 3 - type RouteSectionProps 4 - } from "@solidjs/router"; 1 + import { useSubmission } from "@solidjs/router"; 5 2 import { Show } from "solid-js"; 6 - import { loginOrRegister } from "~/api"; 3 + import { initiateLogin } from "~/api"; 7 4 8 - export default function Login(props: RouteSectionProps) { 9 - const loggingIn = useSubmission(loginOrRegister); 5 + export default function Login() { 6 + const loggingIn = useSubmission(initiateLogin); 10 7 11 8 return ( 12 - <main> 13 - <h1>Login</h1> 14 - <form action={loginOrRegister} method="post"> 15 - <input type="hidden" name="redirectTo" value={props.params.redirectTo ?? "/"} /> 16 - <fieldset> 17 - <legend>Login or Register?</legend> 18 - <label> 19 - <input type="radio" name="loginType" value="login" checked={true} /> Login 20 - </label> 21 - <label> 22 - <input type="radio" name="loginType" value="register" /> Register 23 - </label> 24 - </fieldset> 25 - <div> 26 - <label for="username-input">Username</label> 27 - <input name="username" placeholder="kody" autocomplete="username" /> 28 - </div> 9 + <main class="w-full p-4 space-y-4"> 10 + <h1 class="font-bold text-3xl">Login with AT Protocol</h1> 11 + <form action={initiateLogin} method="post" class="space-y-4"> 29 12 <div> 30 - <label for="password-input">Password</label> 31 - <input name="password" type="password" placeholder="twixrox" autocomplete="current-password" /> 13 + <label for="handle-input">Handle</label> 14 + <input 15 + id="handle-input" 16 + name="handle" 17 + type="text" 18 + placeholder="alice.bsky.social" 19 + autocomplete="username" 20 + required 21 + class="w-full px-3 py-2 border rounded" 22 + /> 32 23 </div> 33 - <button type="submit">Login</button> 34 - <Show when={loggingIn.result}> 35 - <p style={{color: "red"}} role="alert" id="error-message"> 36 - {loggingIn.result!.message} 24 + <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded"> 25 + Login 26 + </button> 27 + <Show when={loggingIn.result?.error}> 28 + <p style={{color: "red"}} role="alert"> 29 + {loggingIn.result!.error} 37 30 </p> 38 31 </Show> 39 32 </form> 33 + <div class="text-sm text-gray-600"> 34 + Don't have an account? <a href="https://bsky.app" target="_blank" class="text-blue-500 underline">Sign up for Bluesky</a> 35 + </div> 40 36 </main> 41 37 ); 42 38 }
+29
src/routes/oauth/callback/index.ts
··· 1 + import { type APIEvent } from "@solidjs/start/server"; 2 + import { handleOAuthCallback } from "~/api/server"; 3 + import { getSession } from "~/auth"; 4 + 5 + export async function GET(event: APIEvent) { 6 + const url = new URL(event.request.url); 7 + const params = new URLSearchParams(url.search); 8 + 9 + try { 10 + const { sessionId } = await handleOAuthCallback(params); 11 + 12 + // Store session ID in cookie 13 + const session = await getSession(); 14 + await session.update((d) => { 15 + d.sessionId = sessionId; 16 + }); 17 + 18 + return new Response(null, { 19 + status: 302, 20 + headers: { Location: "/" } 21 + }); 22 + } catch (err) { 23 + console.error("OAuth callback error:", err); 24 + return new Response(null, { 25 + status: 302, 26 + headers: { Location: "/?error=auth_failed" } 27 + }); 28 + } 29 + }