Highly ambitious ATProtocol AppView service and sdks

update cli with new commands lexicon (pull, push), update oauth flows to use session id instead of only allowing one session at a time (whoops)

+765 -397
+40 -26
frontend/src/config.ts
··· 27 28 // OAuth setup 29 const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); 30 - const oauthClient = new OAuthClient( 31 - { 32 - clientId: OAUTH_CLIENT_ID, 33 - clientSecret: OAUTH_CLIENT_SECRET, 34 - authBaseUrl: OAUTH_AIP_BASE_URL, 35 - redirectUri: OAUTH_REDIRECT_URI, 36 - scopes: [ 37 - "openid", 38 - "email", 39 - "profile", 40 - "atproto", 41 - "transition:generic", 42 - "account:email", 43 - "blob:image/*", 44 - // "repo:network.slices.slice", 45 - // "repo:network.slices.lexicon", 46 - // "repo:network.slices.actor.profile", 47 - // "repo:network.slices.waiting", 48 - ], 49 - }, 50 - oauthStorage 51 - ); 52 53 // Session setup (shared database) 54 export const sessionStore = new SessionStore({ ··· 62 }); 63 64 // OAuth + Session integration 65 - export const oauthSessions = withOAuthSession(sessionStore, oauthClient, { 66 - autoRefresh: true, 67 - }); 68 69 - export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient); 70 71 // Public client for unauthenticated requests 72 export const publicClient = new AtProtoClient(API_URL, SLICE_URI);
··· 27 28 // OAuth setup 29 const oauthStorage = new SQLiteOAuthStorage(DATABASE_URL); 30 + const oauthConfig = { 31 + clientId: OAUTH_CLIENT_ID, 32 + clientSecret: OAUTH_CLIENT_SECRET, 33 + authBaseUrl: OAUTH_AIP_BASE_URL, 34 + redirectUri: OAUTH_REDIRECT_URI, 35 + scopes: [ 36 + "openid", 37 + "email", 38 + "profile", 39 + "atproto", 40 + "transition:generic", 41 + "account:email", 42 + "blob:image/*", 43 + // "repo:network.slices.slice", 44 + // "repo:network.slices.lexicon", 45 + // "repo:network.slices.actor.profile", 46 + // "repo:network.slices.waiting", 47 + ], 48 + }; 49 + 50 + // Export config and storage for creating session-scoped clients 51 + export { oauthConfig, oauthStorage }; 52 53 // Session setup (shared database) 54 export const sessionStore = new SessionStore({ ··· 62 }); 63 64 // OAuth + Session integration 65 + export const oauthSessions = withOAuthSession( 66 + sessionStore, 67 + oauthConfig, 68 + oauthStorage, 69 + { 70 + autoRefresh: true, 71 + } 72 + ); 73 74 + // Helper function to create session-scoped OAuth client 75 + export function createOAuthClient(sessionId: string): OAuthClient { 76 + return new OAuthClient(oauthConfig, oauthStorage, sessionId); 77 + } 78 + 79 + // Helper function to create authenticated AtProto client for a session 80 + export function createSessionClient(sessionId: string): AtProtoClient { 81 + const sessionOAuthClient = createOAuthClient(sessionId); 82 + return new AtProtoClient(API_URL!, SLICE_URI!, sessionOAuthClient); 83 + } 84 85 // Public client for unauthenticated requests 86 export const publicClient = new AtProtoClient(API_URL, SLICE_URI);
+59 -52
frontend/src/features/auth/handlers.tsx
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../routes/middleware.ts"; 3 - import { atprotoClient, oauthSessions, sessionStore } from "../../config.ts"; 4 import { renderHTML } from "../../utils/render.tsx"; 5 import { LoginPage } from "./templates/LoginPage.tsx"; 6 import { SLICE_URI } from "../../config.ts"; ··· 19 20 // Query for invites for this DID - using json field to query the record content 21 const invitesResult = 22 - await atprotoClient.network.slices.waitlist.invite.getRecords({ 23 where: { 24 slice: { eq: sliceUri }, 25 json: { contains: userDid }, ··· 45 46 // Check if user is already on the waitlist - requests are created by the user so record.did is correct 47 const requestsResult = 48 - await atprotoClient.network.slices.waitlist.request.getRecords({ 49 where: { 50 slice: { eq: sliceUri }, 51 json: { eq: userDid }, ··· 83 84 async function handleOAuthAuthorize(req: Request): Promise<Response> { 85 try { 86 - // Clear any existing auth state before new login attempt 87 - await atprotoClient.oauth?.logout(); 88 - 89 const formData = await req.formData(); 90 const loginHint = formData.get("loginHint") as string; 91 ··· 93 return new Response("Missing login hint", { status: 400 }); 94 } 95 96 - if (!atprotoClient.oauth) { 97 - return new Response("OAuth client not configured", { status: 500 }); 98 - } 99 100 - const authResult = await atprotoClient.oauth.authorize({ 101 loginHint, 102 }); 103 ··· 144 ); 145 } 146 147 - if (!atprotoClient.oauth) { 148 - return Response.redirect( 149 - new URL( 150 - "/login?error=" + encodeURIComponent("OAuth client not configured"), 151 - req.url 152 - ), 153 - 302 154 - ); 155 - } 156 157 - // Exchange code for tokens - the OAuth client handles this internally 158 - await atprotoClient.oauth.handleCallback({ 159 - code, 160 - state, 161 - }); 162 163 - // Create OAuth session with auto token management 164 - const sessionId = await oauthSessions.createOAuthSession(); 165 166 if (!sessionId) { 167 return Response.redirect( ··· 176 // Create session cookie 177 const sessionCookie = sessionStore.createSessionCookie(sessionId); 178 179 - // Get user info from OAuth session 180 let userInfo; 181 try { 182 - userInfo = await atprotoClient.oauth?.getUserInfo(); 183 } catch (error) { 184 console.error("Failed to get user info:", error); 185 } ··· 189 const { hasAccess, isOnWaitlist } = await checkUserAccess(userInfo.sub); 190 if (!hasAccess) { 191 // Clear OAuth session and redirect to waitlist page 192 - await atprotoClient.oauth?.logout(); 193 194 const errorCode = isOnWaitlist 195 ? "already_on_waitlist" ··· 204 // Sync external collections first to ensure actor records are populated 205 try { 206 if (userInfo?.sub) { 207 - await atprotoClient.network.slices.slice.syncUserCollections(); 208 } 209 } catch (error) { 210 console.error("Error during external collections sync:", error); ··· 215 try { 216 // Check if user already has a profile record in our slice 217 const existingProfile = 218 - await atprotoClient.network.slices.actor.profile.getRecords({ 219 where: { 220 did: { eq: userInfo.sub }, 221 }, ··· 233 try { 234 // Get their bsky profile data 235 const bskyProfile = 236 - await atprotoClient.app.bsky.actor.profile.getRecords({ 237 where: { 238 did: { eq: userInfo.sub }, 239 }, ··· 259 } 260 261 // Create the profile record with the data using "self" as the rkey 262 - await atprotoClient.network.slices.actor.profile.createRecord( 263 profileData, 264 true 265 ); ··· 329 return new Response("Missing handle", { status: 400 }); 330 } 331 332 - // Clear any existing auth state 333 - await atprotoClient.oauth?.logout(); 334 - 335 - if (!atprotoClient.oauth) { 336 - return new Response("OAuth client not configured", { status: 500 }); 337 - } 338 339 // Store waitlist flag in state parameter 340 const waitlistState = btoa( ··· 346 ); 347 348 // Initiate OAuth with minimal scope for waitlist, passing state directly 349 - const authResult = await atprotoClient.oauth.authorize({ 350 loginHint: handle, 351 scope: "atproto repo:network.slices.waitlist.request", 352 state: waitlistState, ··· 387 console.error("Failed to decode waitlist state"); 388 } 389 390 - if (!atprotoClient.oauth) { 391 - return Response.redirect( 392 - new URL("/waitlist?error=oauth_not_configured", req.url), 393 - 302 394 - ); 395 - } 396 397 // Exchange code for tokens 398 - await atprotoClient.oauth.handleCallback({ code, state }); 399 400 // Get user info 401 - const userInfo = await atprotoClient.oauth.getUserInfo(); 402 403 if (!userInfo) { 404 return Response.redirect( ··· 409 410 // Create waitlist record 411 try { 412 - await atprotoClient.network.slices.waitlist.request.createRecord( 413 { 414 slice: SLICE_URI!, 415 createdAt: new Date().toISOString(), ··· 419 420 // Sync user collections to populate their Bluesky profile data 421 try { 422 - await atprotoClient.network.slices.slice.syncUserCollections(); 423 } catch (syncError) { 424 console.error( 425 "Failed to sync user collections for waitlist user:", ··· 431 console.error("Failed to create waitlist record:", error); 432 } 433 434 - // Clear OAuth session since this is just for waitlist 435 - await atprotoClient.oauth?.logout(); 436 437 // Redirect back to waitlist page with success parameter 438 const handle = userInfo.name || waitlistData.handle || "user";
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../routes/middleware.ts"; 3 + import { 4 + oauthSessions, 5 + sessionStore, 6 + oauthConfig, 7 + oauthStorage, 8 + publicClient, 9 + createSessionClient, 10 + createOAuthClient, 11 + } from "../../config.ts"; 12 + import { OAuthClient } from "@slices/oauth"; 13 import { renderHTML } from "../../utils/render.tsx"; 14 import { LoginPage } from "./templates/LoginPage.tsx"; 15 import { SLICE_URI } from "../../config.ts"; ··· 28 29 // Query for invites for this DID - using json field to query the record content 30 const invitesResult = 31 + await publicClient.network.slices.waitlist.invite.getRecords({ 32 where: { 33 slice: { eq: sliceUri }, 34 json: { contains: userDid }, ··· 54 55 // Check if user is already on the waitlist - requests are created by the user so record.did is correct 56 const requestsResult = 57 + await publicClient.network.slices.waitlist.request.getRecords({ 58 where: { 59 slice: { eq: sliceUri }, 60 json: { eq: userDid }, ··· 92 93 async function handleOAuthAuthorize(req: Request): Promise<Response> { 94 try { 95 const formData = await req.formData(); 96 const loginHint = formData.get("loginHint") as string; 97 ··· 99 return new Response("Missing login hint", { status: 400 }); 100 } 101 102 + // Create a temporary OAuth client for the authorize flow 103 + // We don't have a userId yet since this is the start of OAuth 104 + const tempOAuthClient = new OAuthClient( 105 + oauthConfig, 106 + oauthStorage, 107 + loginHint 108 + ); 109 110 + const authResult = await tempOAuthClient.authorize({ 111 loginHint, 112 }); 113 ··· 154 ); 155 } 156 157 + // Create a temporary OAuth client for callback 158 + // We'll get the userId from the tokens 159 + const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, "temp"); 160 161 + // Exchange code for tokens 162 + const tokens = await tempOAuthClient.handleCallback({ code, state }); 163 164 + // Create OAuth session with tokens 165 + const sessionId = await oauthSessions.createOAuthSession(tokens); 166 167 if (!sessionId) { 168 return Response.redirect( ··· 177 // Create session cookie 178 const sessionCookie = sessionStore.createSessionCookie(sessionId); 179 180 + // Create session-scoped OAuth client 181 + const sessionOAuthClient = createOAuthClient(sessionId); 182 + const sessionClient = createSessionClient(sessionId); 183 + 184 + // Get user info from OAuth client 185 let userInfo; 186 try { 187 + userInfo = await sessionOAuthClient.getUserInfo(); 188 } catch (error) { 189 console.error("Failed to get user info:", error); 190 } ··· 194 const { hasAccess, isOnWaitlist } = await checkUserAccess(userInfo.sub); 195 if (!hasAccess) { 196 // Clear OAuth session and redirect to waitlist page 197 + await sessionOAuthClient.logout(); 198 199 const errorCode = isOnWaitlist 200 ? "already_on_waitlist" ··· 209 // Sync external collections first to ensure actor records are populated 210 try { 211 if (userInfo?.sub) { 212 + await sessionClient.network.slices.slice.syncUserCollections(); 213 } 214 } catch (error) { 215 console.error("Error during external collections sync:", error); ··· 220 try { 221 // Check if user already has a profile record in our slice 222 const existingProfile = 223 + await sessionClient.network.slices.actor.profile.getRecords({ 224 where: { 225 did: { eq: userInfo.sub }, 226 }, ··· 238 try { 239 // Get their bsky profile data 240 const bskyProfile = 241 + await sessionClient.app.bsky.actor.profile.getRecords({ 242 where: { 243 did: { eq: userInfo.sub }, 244 }, ··· 264 } 265 266 // Create the profile record with the data using "self" as the rkey 267 + await sessionClient.network.slices.actor.profile.createRecord( 268 profileData, 269 true 270 ); ··· 334 return new Response("Missing handle", { status: 400 }); 335 } 336 337 + // Create temporary OAuth client for waitlist flow 338 + const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, handle); 339 340 // Store waitlist flag in state parameter 341 const waitlistState = btoa( ··· 347 ); 348 349 // Initiate OAuth with minimal scope for waitlist, passing state directly 350 + const authResult = await tempOAuthClient.authorize({ 351 loginHint: handle, 352 scope: "atproto repo:network.slices.waitlist.request", 353 state: waitlistState, ··· 388 console.error("Failed to decode waitlist state"); 389 } 390 391 + // Create temp session-scoped client for waitlist (not creating actual session) 392 + const tempSessionId = "waitlist_" + Date.now(); 393 + const tempOAuthClient = new OAuthClient( 394 + oauthConfig, 395 + oauthStorage, 396 + tempSessionId 397 + ); 398 399 // Exchange code for tokens 400 + const tokens = await tempOAuthClient.handleCallback({ code, state }); 401 + 402 + // Store tokens so we can fetch user info 403 + await oauthStorage.setTokens(tokens, tempSessionId); 404 + 405 + const sessionClient = createSessionClient(tempSessionId); 406 407 // Get user info 408 + const userInfo = await tempOAuthClient.getUserInfo(); 409 410 if (!userInfo) { 411 return Response.redirect( ··· 416 417 // Create waitlist record 418 try { 419 + await sessionClient.network.slices.waitlist.request.createRecord( 420 { 421 slice: SLICE_URI!, 422 createdAt: new Date().toISOString(), ··· 426 427 // Sync user collections to populate their Bluesky profile data 428 try { 429 + await sessionClient.network.slices.slice.syncUserCollections(); 430 } catch (syncError) { 431 console.error( 432 "Failed to sync user collections for waitlist user:", ··· 438 console.error("Failed to create waitlist record:", error); 439 } 440 441 + // Clear temp OAuth session since this is just for waitlist 442 + await tempOAuthClient.logout(); 443 444 // Redirect back to waitlist page with success parameter 445 const handle = userInfo.name || waitlistData.handle || "user";
+7 -9
frontend/src/features/dashboard/handlers.tsx
··· 2 import { renderHTML } from "../../utils/render.tsx"; 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 - import { atprotoClient, publicClient } from "../../config.ts"; 6 import { DashboardPage } from "./templates/DashboardPage.tsx"; 7 import { CreateSliceDialog } from "./templates/fragments/CreateSliceDialog.tsx"; 8 import { getSliceActor, getSlicesForActor } from "../../lib/api.ts"; ··· 57 const authResponse = requireAuth(context); 58 if (authResponse) return authResponse; 59 60 - const authInfo = await atprotoClient.oauth?.getAuthenticationInfo(); 61 - if (!authInfo?.isAuthenticated) { 62 return renderHTML( 63 <CreateSliceDialog error="Session expired. Please log in again." /> 64 ); 65 } 66 67 try { 68 const formData = await req.formData(); ··· 90 } 91 92 try { 93 - const recordData = { 94 name: name.trim(), 95 domain: domain.trim(), 96 createdAt: new Date().toISOString(), 97 - }; 98 - 99 - const result = await atprotoClient.network.slices.slice.createRecord( 100 - recordData 101 - ); 102 103 const uriParts = result.uri.split("/"); 104 const sliceRkey = uriParts[uriParts.length - 1];
··· 2 import { renderHTML } from "../../utils/render.tsx"; 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 + import { publicClient, createSessionClient } from "../../config.ts"; 6 import { DashboardPage } from "./templates/DashboardPage.tsx"; 7 import { CreateSliceDialog } from "./templates/fragments/CreateSliceDialog.tsx"; 8 import { getSliceActor, getSlicesForActor } from "../../lib/api.ts"; ··· 57 const authResponse = requireAuth(context); 58 if (authResponse) return authResponse; 59 60 + if (!context.currentUser.sub) { 61 return renderHTML( 62 <CreateSliceDialog error="Session expired. Please log in again." /> 63 ); 64 } 65 + 66 + // Create session-scoped client 67 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 68 69 try { 70 const formData = await req.formData(); ··· 92 } 93 94 try { 95 + const result = await sessionClient.network.slices.slice.createRecord({ 96 name: name.trim(), 97 domain: domain.trim(), 98 createdAt: new Date().toISOString(), 99 + }); 100 101 const uriParts = result.uri.split("/"); 102 const sliceRkey = uriParts[uriParts.length - 1];
+9 -6
frontend/src/features/settings/handlers.tsx
··· 2 import { renderHTML } from "../../utils/render.tsx"; 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 - import { atprotoClient } from "../../config.ts"; 6 import { buildAtUri } from "../../utils/at-uri.ts"; 7 import type { SocialSlicesActorProfile } from "../../client.ts"; 8 import { SettingsPage } from "./templates/SettingsPage.tsx"; ··· 28 | undefined; 29 30 try { 31 - const profileRecord = await atprotoClient.network.slices.actor.profile 32 .getRecord({ 33 uri: buildAtUri({ 34 did: context.currentUser.sub!, ··· 80 createdAt: new Date().toISOString(), 81 }; 82 83 if (avatarFile && avatarFile.size > 0) { 84 try { 85 const arrayBuffer = await avatarFile.arrayBuffer(); 86 87 - const blobResult = await atprotoClient.uploadBlob({ 88 data: arrayBuffer, 89 mimeType: avatarFile.type, 90 }); ··· 100 throw new Error("User DID (sub) is required for profile operations"); 101 } 102 103 - const existingProfile = await atprotoClient.network.slices.actor.profile 104 .getRecord({ 105 uri: buildAtUri({ 106 did: context.currentUser.sub, ··· 121 updatedProfile.avatar = existingProfile.value.avatar; 122 } 123 124 - await atprotoClient.network.slices.actor.profile.updateRecord( 125 "self", 126 updatedProfile, 127 ); 128 } else { 129 - await atprotoClient.network.slices.actor.profile.createRecord( 130 profileData, 131 true, 132 );
··· 2 import { renderHTML } from "../../utils/render.tsx"; 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 + import { createSessionClient, publicClient } from "../../config.ts"; 6 import { buildAtUri } from "../../utils/at-uri.ts"; 7 import type { SocialSlicesActorProfile } from "../../client.ts"; 8 import { SettingsPage } from "./templates/SettingsPage.tsx"; ··· 28 | undefined; 29 30 try { 31 + const profileRecord = await publicClient.network.slices.actor.profile 32 .getRecord({ 33 uri: buildAtUri({ 34 did: context.currentUser.sub!, ··· 80 createdAt: new Date().toISOString(), 81 }; 82 83 + // Create user-scoped client for write operations 84 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 85 + 86 if (avatarFile && avatarFile.size > 0) { 87 try { 88 const arrayBuffer = await avatarFile.arrayBuffer(); 89 90 + const blobResult = await sessionClient.uploadBlob({ 91 data: arrayBuffer, 92 mimeType: avatarFile.type, 93 }); ··· 103 throw new Error("User DID (sub) is required for profile operations"); 104 } 105 106 + const existingProfile = await publicClient.network.slices.actor.profile 107 .getRecord({ 108 uri: buildAtUri({ 109 did: context.currentUser.sub, ··· 124 updatedProfile.avatar = existingProfile.value.avatar; 125 } 126 127 + await sessionClient.network.slices.actor.profile.updateRecord( 128 "self", 129 updatedProfile, 130 ); 131 } else { 132 + await sessionClient.network.slices.actor.profile.createRecord( 133 profileData, 134 true, 135 );
+10 -8
frontend/src/features/slices/api-docs/handlers.tsx
··· 6 withSliceAccess, 7 } from "../../../routes/slice-middleware.ts"; 8 import { extractSliceParams } from "../../../utils/slice-params.ts"; 9 - import { atprotoClient } from "../../../config.ts"; 10 import { SliceApiDocsPage } from "./templates/SliceApiDocsPage.tsx"; 11 12 async function handleSliceApiDocsPage( ··· 28 const accessError = requireSliceAccess(context); 29 if (accessError) return accessError; 30 31 - // Get OAuth access token directly from OAuth client (clean separation) 32 let accessToken: string | undefined; 33 - try { 34 - // Tokens are managed by @slices/oauth, not stored in sessions 35 - const tokens = await atprotoClient.oauth?.ensureValidToken(); 36 - accessToken = tokens?.accessToken; 37 - } catch (error) { 38 - console.log("Could not get OAuth token:", error); 39 } 40 41 return renderHTML(
··· 6 withSliceAccess, 7 } from "../../../routes/slice-middleware.ts"; 8 import { extractSliceParams } from "../../../utils/slice-params.ts"; 9 + import { createOAuthClient } from "../../../config.ts"; 10 import { SliceApiDocsPage } from "./templates/SliceApiDocsPage.tsx"; 11 12 async function handleSliceApiDocsPage( ··· 28 const accessError = requireSliceAccess(context); 29 if (accessError) return accessError; 30 31 + // Get OAuth access token for the current user 32 let accessToken: string | undefined; 33 + if (authContext.currentUser.sessionId) { 34 + try { 35 + const sessionOAuthClient = createOAuthClient(authContext.currentUser.sessionId); 36 + const tokens = await sessionOAuthClient.ensureValidToken(); 37 + accessToken = tokens?.accessToken; 38 + } catch (error) { 39 + console.log("Could not get OAuth token:", error); 40 + } 41 } 42 43 return renderHTML(
+3 -3
frontend/src/features/slices/jetstream/handlers.tsx
··· 5 withSliceAccess, 6 } from "../../../routes/slice-middleware.ts"; 7 import { getSliceClient } from "../../../utils/client.ts"; 8 - import { atprotoClient } from "../../../config.ts"; 9 import { renderHTML } from "../../../utils/render.tsx"; 10 import { Layout } from "../../../shared/fragments/Layout.tsx"; 11 import { extractSliceParams } from "../../../utils/slice-params.ts"; ··· 89 const handle = url.searchParams.get("handle"); 90 const isCompact = url.searchParams.get("compact") === "true"; 91 92 - // Fetch jetstream status using the atproto client 93 - const data = await atprotoClient.network.slices.slice.getJetstreamStatus(); 94 95 // Render compact version for logs page 96 if (isCompact) {
··· 5 withSliceAccess, 6 } from "../../../routes/slice-middleware.ts"; 7 import { getSliceClient } from "../../../utils/client.ts"; 8 + import { publicClient } from "../../../config.ts"; 9 import { renderHTML } from "../../../utils/render.tsx"; 10 import { Layout } from "../../../shared/fragments/Layout.tsx"; 11 import { extractSliceParams } from "../../../utils/slice-params.ts"; ··· 89 const handle = url.searchParams.get("handle"); 90 const isCompact = url.searchParams.get("compact") === "true"; 91 92 + // Fetch jetstream status using the public client 93 + const data = await publicClient.network.slices.slice.getJetstreamStatus(); 94 95 // Render compact version for logs page 96 if (isCompact) {
+11 -5
frontend/src/features/slices/settings/handlers.tsx
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../../routes/middleware.ts"; 3 - import { atprotoClient } from "../../../config.ts"; 4 import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 import { renderHTML } from "../../../utils/render.tsx"; 6 import { hxRedirect } from "../../../utils/htmx.ts"; ··· 77 // Construct the URI for this slice 78 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 79 80 - // Get the current record first 81 - const currentRecord = await atprotoClient.network.slices.slice.getRecord({ 82 uri: sliceUri, 83 }); 84 85 // Update the record with new name and domain 86 - await atprotoClient.network.slices.slice.updateRecord(sliceId, { 87 ...currentRecord.value, 88 name: name.trim(), 89 domain: domain.trim(), ··· 115 } 116 117 try { 118 // Delete the slice record from AT Protocol 119 - await atprotoClient.network.slices.slice.deleteRecord(sliceId); 120 121 return hxRedirect(`/profile/${context.currentUser.handle}`); 122 } catch (_error) {
··· 1 import type { Route } from "@std/http/unstable-route"; 2 import { withAuth } from "../../../routes/middleware.ts"; 3 + import { createSessionClient, publicClient } from "../../../config.ts"; 4 import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 import { renderHTML } from "../../../utils/render.tsx"; 6 import { hxRedirect } from "../../../utils/htmx.ts"; ··· 77 // Construct the URI for this slice 78 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 79 80 + // Get the current record first (read operation, use public client) 81 + const currentRecord = await publicClient.network.slices.slice.getRecord({ 82 uri: sliceUri, 83 }); 84 85 + // Create user-scoped client for write operation 86 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 87 + 88 // Update the record with new name and domain 89 + await sessionClient.network.slices.slice.updateRecord(sliceId, { 90 ...currentRecord.value, 91 name: name.trim(), 92 domain: domain.trim(), ··· 118 } 119 120 try { 121 + // Create user-scoped client for delete operation 122 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 123 + 124 // Delete the slice record from AT Protocol 125 + await sessionClient.network.slices.slice.deleteRecord(sliceId); 126 127 return hxRedirect(`/profile/${context.currentUser.handle}`); 128 } catch (_error) {
+2 -2
frontend/src/features/slices/sync/handlers.tsx
··· 3 import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 import { getSliceClient } from "../../../utils/client.ts"; 5 import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 - import { atprotoClient } from "../../../config.ts"; 7 import { 8 requireSliceAccess, 9 withSliceAccess, ··· 226 227 // Get slice info for domain comparison 228 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 229 - const sliceRecord = await atprotoClient.network.slices.slice.getRecord({ 230 uri: sliceUri, 231 }); 232 const sliceDomain = sliceRecord.value.domain;
··· 3 import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 import { getSliceClient } from "../../../utils/client.ts"; 5 import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 + import { publicClient } from "../../../config.ts"; 7 import { 8 requireSliceAccess, 9 withSliceAccess, ··· 226 227 // Get slice info for domain comparison 228 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 229 + const sliceRecord = await publicClient.network.slices.slice.getRecord({ 230 uri: sliceUri, 231 }); 232 const sliceDomain = sliceRecord.value.domain;
+8 -4
frontend/src/routes/middleware.ts
··· 1 - import { atprotoClient, sessionStore } from "../config.ts"; 2 import { recordBlobToCdnUrl } from "@slices/client"; 3 import { getSliceActor } from "../lib/api.ts"; 4 5 export interface AuthenticatedUser { 6 handle?: string; 7 sub?: string; 8 isAuthenticated: boolean; ··· 19 const currentUser = await sessionStore.getCurrentUser(req); 20 21 // If user is authenticated, try to fetch their profile data 22 - if (currentUser.isAuthenticated && currentUser.sub) { 23 try { 24 // Get the user's profile from network.slices.actor.profile 25 - const profile = await getSliceActor(atprotoClient, currentUser.sub); 26 if (profile) { 27 currentUser.displayName = profile.displayName; 28 currentUser.avatar = profile.avatar; ··· 35 // Fallback to Bluesky profile for avatar if not found in slices profile 36 if (!currentUser.avatar) { 37 try { 38 - const profileRecords = await atprotoClient.app.bsky.actor.profile 39 .getRecords({ 40 where: { 41 did: { eq: currentUser.sub },
··· 1 + import { sessionStore, createSessionClient } from "../config.ts"; 2 import { recordBlobToCdnUrl } from "@slices/client"; 3 import { getSliceActor } from "../lib/api.ts"; 4 5 export interface AuthenticatedUser { 6 + sessionId?: string; 7 handle?: string; 8 sub?: string; 9 isAuthenticated: boolean; ··· 20 const currentUser = await sessionStore.getCurrentUser(req); 21 22 // If user is authenticated, try to fetch their profile data 23 + if (currentUser.isAuthenticated && currentUser.sessionId && currentUser.sub) { 24 + // Create session-scoped client 25 + const sessionClient = createSessionClient(currentUser.sessionId); 26 + 27 try { 28 // Get the user's profile from network.slices.actor.profile 29 + const profile = await getSliceActor(sessionClient, currentUser.sub); 30 if (profile) { 31 currentUser.displayName = profile.displayName; 32 currentUser.avatar = profile.avatar; ··· 39 // Fallback to Bluesky profile for avatar if not found in slices profile 40 if (!currentUser.avatar) { 41 try { 42 + const profileRecords = await sessionClient.app.bsky.actor.profile 43 .getRecords({ 44 where: { 45 did: { eq: currentUser.sub },
+8 -4
frontend/src/utils/client.ts
··· 1 import { AtProtoClient } from "../client.ts"; 2 - import { atprotoClient } from "../config.ts"; 3 import { buildAtUri } from "./at-uri.ts"; 4 5 interface AuthContext { 6 currentUser: { 7 sub?: string; 8 }; 9 } ··· 30 }); 31 32 // Use authenticated client if user is authenticated, otherwise public client 33 - return context.currentUser.sub 34 - ? new AtProtoClient(API_URL, sliceUri, atprotoClient.oauth) 35 - : new AtProtoClient(API_URL, sliceUri); 36 }
··· 1 import { AtProtoClient } from "../client.ts"; 2 + import { createOAuthClient } from "../config.ts"; 3 import { buildAtUri } from "./at-uri.ts"; 4 5 interface AuthContext { 6 currentUser: { 7 + sessionId?: string; 8 sub?: string; 9 }; 10 } ··· 31 }); 32 33 // Use authenticated client if user is authenticated, otherwise public client 34 + if (context.currentUser.sessionId) { 35 + const sessionOAuthClient = createOAuthClient(context.currentUser.sessionId); 36 + return new AtProtoClient(API_URL, sliceUri, sessionOAuthClient); 37 + } else { 38 + return new AtProtoClient(API_URL, sliceUri); 39 + } 40 }
+1 -1
packages/cli/src/auth/config.ts
··· 62 63 async logout(): Promise<void> { 64 await this.save({ auth: undefined }); 65 - logger.info("Logged out successfully"); 66 } 67 68 getAuthHeaders(): Record<string, string> {
··· 62 63 async logout(): Promise<void> { 64 await this.save({ auth: undefined }); 65 + console.log("Logged out successfully"); 66 } 67 68 getAuthHeaders(): Record<string, string> {
+17 -34
packages/cli/src/auth/device_flow.ts
··· 15 const config = new ConfigManager(); 16 await config.load(); 17 18 - logger.step("🚀 Starting OAuth 2.0 Device Authorization Grant flow"); 19 - logger.info(`📡 AIP Server: ${aipBaseUrl}`); 20 - logger.info(`🆔 Client ID: ${clientId}`); 21 - logger.info(`📋 Scope: ${scope}`); 22 - 23 try { 24 - // Step 1: Request device authorization 25 - logger.step("📤 Requesting device authorization..."); 26 const authResponse = await deviceClient.requestDeviceAuthorization(scope); 27 28 - console.log("\n📱 Device Authorization Required"); 29 - console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); 30 - console.log(`📋 User Code: ${authResponse.user_code}`); 31 - console.log(`🌐 Verification URL: ${authResponse.verification_uri}`); 32 33 - if (authResponse.verification_uri_complete) { 34 - console.log(`🔗 Quick Link: ${authResponse.verification_uri_complete}`); 35 - console.log("\n💡 Open the quick link above to skip manual code entry!"); 36 } 37 38 - console.log(`⏰ Code expires in ${authResponse.expires_in} seconds`); 39 - console.log("\n🎯 Next Steps:"); 40 - console.log(` 1. Open ${authResponse.verification_uri} in your browser`); 41 - console.log(` 2. Enter the user code: ${authResponse.user_code}`); 42 - console.log(" 3. Complete the authentication process"); 43 - console.log(" 4. Return here - the CLI will automatically detect completion\n"); 44 - 45 - // Step 2: Poll for access token 46 - logger.step("🔄 Waiting for user authorization..."); 47 const tokenResponse = await deviceClient.pollForToken( 48 authResponse.device_code, 49 authResponse.interval || 5, 50 authResponse.expires_in, 51 ); 52 53 - logger.success("✅ Authentication Successful!"); 54 - logger.info(`🎫 Access Token: ${tokenResponse.access_token.slice(0, 8)}...${tokenResponse.access_token.slice(-8)}`); 55 - 56 - if (tokenResponse.expires_in) { 57 - logger.info(`⏰ Expires in: ${tokenResponse.expires_in} seconds`); 58 - } 59 - 60 - // Step 3: Get user info and save config 61 - logger.step("🔍 Getting user information..."); 62 const userInfo: OAuthUserInfo = await deviceClient.getUserInfo(tokenResponse.access_token); 63 64 const expiresAt = tokenResponse.expires_in ··· 75 }, 76 }); 77 78 - logger.success(`👤 Logged in as: ${userInfo.did}`); 79 - logger.success("🎉 Device flow complete! You can now use the CLI to manage lexicons."); 80 81 } catch (error) { 82 const err = error as Error;
··· 15 const config = new ConfigManager(); 16 await config.load(); 17 18 try { 19 const authResponse = await deviceClient.requestDeviceAuthorization(scope); 20 21 + console.log(`\n${authResponse.verification_uri_complete}`); 22 + console.log(`Code: ${authResponse.user_code}\n`); 23 24 + const openCommand = Deno.build.os === "darwin" 25 + ? `open "${authResponse.verification_uri_complete}"` 26 + : Deno.build.os === "windows" 27 + ? `start "${authResponse.verification_uri_complete}"` 28 + : `xdg-open "${authResponse.verification_uri_complete}"`; 29 + 30 + try { 31 + await new Deno.Command(Deno.build.os === "darwin" ? "open" : Deno.build.os === "windows" ? "cmd" : "xdg-open", { 32 + args: Deno.build.os === "windows" 33 + ? ["/c", "start", authResponse.verification_uri_complete] 34 + : [authResponse.verification_uri_complete], 35 + }).output(); 36 + } catch { 37 + console.log("Could not open browser automatically. Please open the link manually.\n"); 38 } 39 40 const tokenResponse = await deviceClient.pollForToken( 41 authResponse.device_code, 42 authResponse.interval || 5, 43 authResponse.expires_in, 44 ); 45 46 const userInfo: OAuthUserInfo = await deviceClient.getUserInfo(tokenResponse.access_token); 47 48 const expiresAt = tokenResponse.expires_in ··· 59 }, 60 }); 61 62 + console.log(`✓ Logged in as ${userInfo.did}`); 63 64 } catch (error) { 65 const err = error as Error;
+2 -2
packages/cli/src/commands/codegen.ts
··· 59 logger.error("--slice is required"); 60 if (!slicesConfig.slice) { 61 logger.info( 62 - "💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" 63 ); 64 } 65 console.log("\nRun 'slices codegen --help' for usage information."); ··· 109 logger.success(`Generated client: ${outputPath}`); 110 111 if (!excludeSlices) { 112 - logger.result("Includes network.slices XRPC client methods"); 113 } 114 } catch (error) { 115 const err = error as Error;
··· 59 logger.error("--slice is required"); 60 if (!slicesConfig.slice) { 61 logger.info( 62 + "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" 63 ); 64 } 65 console.log("\nRun 'slices codegen --help' for usage information."); ··· 109 logger.success(`Generated client: ${outputPath}`); 110 111 if (!excludeSlices) { 112 + logger.info("Includes network.slices XRPC client methods"); 113 } 114 } catch (error) { 115 const err = error as Error;
+13 -21
packages/cli/src/commands/init.ts
··· 22 const projectName = parsed.name || parsed._[0] as string; 23 24 if (!projectName) { 25 - console.error("Error: Project name is required"); 26 - console.error("Usage: slices init <project-name> or slices init --name <project-name>"); 27 Deno.exit(1); 28 } 29 30 // Validate project name 31 if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) { 32 - console.error("Error: Project name can only contain letters, numbers, hyphens, and underscores"); 33 Deno.exit(1); 34 } 35 ··· 39 try { 40 const stat = await Deno.stat(targetDir); 41 if (stat.isDirectory) { 42 - console.error(`Error: Directory '${projectName}' already exists`); 43 Deno.exit(1); 44 } 45 } catch { ··· 55 // Extract embedded templates 56 await extractEmbeddedTemplates(targetDir, projectName); 57 58 - logger.info("✅ Project created successfully!"); 59 console.log(` 60 - 📁 Created project: ${projectName} 61 - 62 - 🚀 Get started: 63 - cd ${projectName} 64 - cp .env.example .env 65 - # Edit .env with your OAuth configuration 66 - deno task dev 67 68 - 📖 Documentation: 69 - - README.md: Setup instructions 70 - - CLAUDE.md: Architecture guide for AI assistance 71 - - See /auth/login for OAuth implementation 72 - 73 - 🔧 Available commands: 74 - deno task dev # Start development server 75 - deno task start # Start production server 76 - deno fmt # Format code 77 `); 78 } catch (error) { 79 const err = error as Error;
··· 22 const projectName = parsed.name || parsed._[0] as string; 23 24 if (!projectName) { 25 + logger.error("Project name is required"); 26 + console.log("Usage: slices init <project-name> or slices init --name <project-name>"); 27 Deno.exit(1); 28 } 29 30 // Validate project name 31 if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) { 32 + logger.error("Project name can only contain letters, numbers, hyphens, and underscores"); 33 Deno.exit(1); 34 } 35 ··· 39 try { 40 const stat = await Deno.stat(targetDir); 41 if (stat.isDirectory) { 42 + logger.error(`Directory '${projectName}' already exists`); 43 Deno.exit(1); 44 } 45 } catch { ··· 55 // Extract embedded templates 56 await extractEmbeddedTemplates(targetDir, projectName); 57 58 + logger.success(`Created project: ${projectName}`); 59 console.log(` 60 + Get started: 61 + cd ${projectName} 62 + cp .env.example .env 63 + deno task dev 64 65 + Available commands: 66 + deno task dev Start development server 67 + deno task start Start production server 68 + deno fmt Format code 69 `); 70 } catch (error) { 71 const err = error as Error;
+11 -5
packages/cli/src/commands/lexicon.ts
··· 1 - import { importCommand } from "./lexicon/import.ts"; 2 import { listCommand } from "./lexicon/list.ts"; 3 4 function showLexiconHelp() { ··· 9 slices lexicon <SUBCOMMAND> [OPTIONS] 10 11 SUBCOMMANDS: 12 - import Import lexicon files to your slice 13 list List lexicons in your slice 14 help Show this help message 15 ··· 17 -h, --help Show help information 18 19 EXAMPLES: 20 - slices lexicon import --slice at://did:plc:example/slice 21 slices lexicon list --slice at://did:plc:example/slice 22 slices lexicon help 23 `); ··· 53 54 try { 55 switch (subcommand) { 56 - case "import": 57 - await importCommand(subcommandArgs, globalArgs); 58 break; 59 case "list": 60 await listCommand(subcommandArgs, globalArgs);
··· 1 + import { pushCommand } from "./lexicon/push.ts"; 2 + import { pullCommand } from "./lexicon/pull.ts"; 3 import { listCommand } from "./lexicon/list.ts"; 4 5 function showLexiconHelp() { ··· 10 slices lexicon <SUBCOMMAND> [OPTIONS] 11 12 SUBCOMMANDS: 13 + push Push lexicon files to your slice 14 + pull Pull lexicon files from your slice 15 list List lexicons in your slice 16 help Show this help message 17 ··· 19 -h, --help Show help information 20 21 EXAMPLES: 22 + slices lexicon push --slice at://did:plc:example/slice 23 + slices lexicon pull --slice at://did:plc:example/slice 24 slices lexicon list --slice at://did:plc:example/slice 25 slices lexicon help 26 `); ··· 56 57 try { 58 switch (subcommand) { 59 + case "push": 60 + await pushCommand(subcommandArgs, globalArgs); 61 + break; 62 + case "pull": 63 + await pullCommand(subcommandArgs, globalArgs); 64 break; 65 case "list": 66 await listCommand(subcommandArgs, globalArgs);
+24 -18
packages/cli/src/commands/lexicon/import.ts packages/cli/src/commands/lexicon/push.ts
··· 13 } from "../../utils/lexicon.ts"; 14 import type { LexiconDoc } from "@slices/lexicon"; 15 16 - function showImportHelp() { 17 console.log(` 18 - slices lexicon import - Import lexicon files to your slice 19 20 USAGE: 21 - slices lexicon import [OPTIONS] 22 23 OPTIONS: 24 --path <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json) 25 --slice <SLICE_URI> Target slice URI (required, or from slices.json) 26 --validate-only Only validate files, don't upload 27 --dry-run Show what would be imported without uploading 28 --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json) 29 -h, --help Show this help message 30 31 EXAMPLES: 32 - slices lexicon import --slice at://did:plc:example/slice 33 - slices lexicon import --path ./my-lexicons --slice at://did:plc:example/slice 34 - slices lexicon import --validate-only --path ./lexicons 35 - slices lexicon import --dry-run --slice at://did:plc:example/slice 36 - slices lexicon import # Uses config from slices.json 37 `); 38 } 39 ··· 50 validationResult: LexiconValidationResult, 51 sliceUri: string, 52 client: AtProtoClient, 53 - dryRun = false 54 ): Promise<ImportStats> { 55 const stats: ImportStats = { 56 attempted: 0, ··· 123 nsid: nsid, 124 definitions: JSON.stringify(lexicon.defs || lexicon.definitions), 125 slice: sliceUri, 126 }; 127 128 if (existingRecord) { ··· 175 } 176 177 178 - export async function importCommand(commandArgs: unknown[], _globalArgs: Record<string, unknown>): Promise<void> { 179 const args = parseArgs(commandArgs as string[], { 180 - boolean: ["help", "validate-only", "dry-run"], 181 string: ["path", "slice", "api-url"], 182 alias: { 183 h: "help", ··· 185 }); 186 187 if (args.help) { 188 - showImportHelp(); 189 return; 190 } 191 ··· 198 if (!args["validate-only"] && !mergedConfig.slice) { 199 logger.error("--slice is required unless using --validate-only"); 200 if (!slicesConfig.slice) { 201 - logger.info("💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"); 202 } 203 - console.log("\nRun 'slices lexicon import --help' for usage information."); 204 Deno.exit(1); 205 } 206 ··· 209 const apiUrl = mergedConfig.apiUrl!; 210 const validateOnly = args["validate-only"] as boolean; 211 const dryRun = args["dry-run"] as boolean; 212 213 const lexiconFiles = await findLexiconFiles(lexiconPath); 214 ··· 221 222 if (validationResult.invalidFiles > 0) { 223 printValidationSummary(validationResult); 224 - logger.error("Please fix validation errors before importing"); 225 Deno.exit(1); 226 } 227 ··· 231 } 232 233 if (validationResult.validFiles === 0) { 234 - logger.error("No valid lexicon files to import"); 235 Deno.exit(1); 236 } 237 ··· 253 validationResult, 254 sliceUri, 255 client, 256 - dryRun 257 ); 258 259 if (importStats.failed > 0) { ··· 266 } else { 267 const total = importStats.created + importStats.updated; 268 if (total > 0) { 269 - logger.success(`Imported ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`); 270 } else { 271 logger.success("All lexicons up to date"); 272 }
··· 13 } from "../../utils/lexicon.ts"; 14 import type { LexiconDoc } from "@slices/lexicon"; 15 16 + function showPushHelp() { 17 console.log(` 18 + slices lexicon push - Push lexicon files to your slice 19 20 USAGE: 21 + slices lexicon push [OPTIONS] 22 23 OPTIONS: 24 --path <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json) 25 --slice <SLICE_URI> Target slice URI (required, or from slices.json) 26 + --exclude-from-sync Exclude these lexicons from sync (sets excludedFromSync: true) 27 --validate-only Only validate files, don't upload 28 --dry-run Show what would be imported without uploading 29 --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json) 30 -h, --help Show this help message 31 32 EXAMPLES: 33 + slices lexicon push --slice at://did:plc:example/slice 34 + slices lexicon push --path ./my-lexicons --slice at://did:plc:example/slice 35 + slices lexicon push --exclude-from-sync --slice at://did:plc:example/slice 36 + slices lexicon push --validate-only --path ./lexicons 37 + slices lexicon push --dry-run --slice at://did:plc:example/slice 38 + slices lexicon push # Uses config from slices.json 39 `); 40 } 41 ··· 52 validationResult: LexiconValidationResult, 53 sliceUri: string, 54 client: AtProtoClient, 55 + dryRun = false, 56 + excludeFromSync = false 57 ): Promise<ImportStats> { 58 const stats: ImportStats = { 59 attempted: 0, ··· 126 nsid: nsid, 127 definitions: JSON.stringify(lexicon.defs || lexicon.definitions), 128 slice: sliceUri, 129 + excludedFromSync: excludeFromSync, 130 }; 131 132 if (existingRecord) { ··· 179 } 180 181 182 + export async function pushCommand(commandArgs: unknown[], _globalArgs: Record<string, unknown>): Promise<void> { 183 const args = parseArgs(commandArgs as string[], { 184 + boolean: ["help", "validate-only", "dry-run", "exclude-from-sync"], 185 string: ["path", "slice", "api-url"], 186 alias: { 187 h: "help", ··· 189 }); 190 191 if (args.help) { 192 + showPushHelp(); 193 return; 194 } 195 ··· 202 if (!args["validate-only"] && !mergedConfig.slice) { 203 logger.error("--slice is required unless using --validate-only"); 204 if (!slicesConfig.slice) { 205 + logger.info("Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"); 206 } 207 + console.log("\nRun 'slices lexicon push --help' for usage information."); 208 Deno.exit(1); 209 } 210 ··· 213 const apiUrl = mergedConfig.apiUrl!; 214 const validateOnly = args["validate-only"] as boolean; 215 const dryRun = args["dry-run"] as boolean; 216 + const excludeFromSync = args["exclude-from-sync"] as boolean; 217 218 const lexiconFiles = await findLexiconFiles(lexiconPath); 219 ··· 226 227 if (validationResult.invalidFiles > 0) { 228 printValidationSummary(validationResult); 229 + logger.error("Please fix validation errors before pushing"); 230 Deno.exit(1); 231 } 232 ··· 236 } 237 238 if (validationResult.validFiles === 0) { 239 + logger.error("No valid lexicon files to push"); 240 Deno.exit(1); 241 } 242 ··· 258 validationResult, 259 sliceUri, 260 client, 261 + dryRun, 262 + excludeFromSync 263 ); 264 265 if (importStats.failed > 0) { ··· 272 } else { 273 const total = importStats.created + importStats.updated; 274 if (total > 0) { 275 + logger.success(`Pushed ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`); 276 } else { 277 logger.success("All lexicons up to date"); 278 }
+192
packages/cli/src/commands/lexicon/pull.ts
···
··· 1 + import { parseArgs } from "@std/cli/parse-args"; 2 + import { join, dirname } from "@std/path"; 3 + import { ensureDir } from "@std/fs"; 4 + import type { AtProtoClient } from "../../generated_client.ts"; 5 + import { ConfigManager } from "../../auth/config.ts"; 6 + import { createAuthenticatedClient } from "../../utils/client.ts"; 7 + import { logger } from "../../utils/logger.ts"; 8 + import { SlicesConfigLoader, mergeConfig } from "../../utils/config.ts"; 9 + 10 + function showPullHelp() { 11 + console.log(` 12 + slices lexicon pull - Pull lexicon files from your slice 13 + 14 + USAGE: 15 + slices lexicon pull [OPTIONS] 16 + 17 + OPTIONS: 18 + --path <PATH> Directory to save lexicon files (default: ./lexicons or from slices.json) 19 + --slice <SLICE_URI> Source slice URI (required, or from slices.json) 20 + --nsid <PATTERN> Filter lexicons by NSID pattern (supports wildcards with *) 21 + --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json) 22 + -h, --help Show this help message 23 + 24 + EXAMPLES: 25 + slices lexicon pull --slice at://did:plc:example/slice 26 + slices lexicon pull --path ./my-lexicons --slice at://did:plc:example/slice 27 + slices lexicon pull --nsid "app.bsky.*" --slice at://did:plc:example/slice 28 + slices lexicon pull --nsid "app.bsky.actor.*" --slice at://did:plc:example/slice 29 + slices lexicon pull # Uses config from slices.json 30 + 31 + NOTE: 32 + When using wildcards (*), wrap the pattern in quotes to prevent shell expansion 33 + `); 34 + } 35 + 36 + interface PullStats { 37 + fetched: number; 38 + written: number; 39 + failed: number; 40 + errors: Array<{ nsid: string; error: string }>; 41 + } 42 + 43 + function nsidToPath(nsid: string, basePath: string): string { 44 + const parts = nsid.split("."); 45 + const dirParts = parts.slice(0, -1); 46 + const fileName = parts[parts.length - 1] + ".json"; 47 + 48 + return join(basePath, ...dirParts, fileName); 49 + } 50 + 51 + function matchesNsidPattern(nsid: string, pattern: string): boolean { 52 + const regexPattern = pattern.replace(/\./g, "\\.").replace(/\*/g, ".*"); 53 + const regex = new RegExp(`^${regexPattern}$`); 54 + return regex.test(nsid); 55 + } 56 + 57 + async function pullLexicons( 58 + sliceUri: string, 59 + lexiconPath: string, 60 + client: AtProtoClient, 61 + nsidPattern?: string 62 + ): Promise<PullStats> { 63 + const stats: PullStats = { 64 + fetched: 0, 65 + written: 0, 66 + failed: 0, 67 + errors: [], 68 + }; 69 + 70 + try { 71 + const response = await client.network.slices.lexicon.getRecords({ 72 + where: { slice: { eq: sliceUri } }, 73 + limit: 100, 74 + }); 75 + 76 + stats.fetched = response.records.length; 77 + 78 + for (const record of response.records) { 79 + try { 80 + const nsid = record.value.nsid; 81 + 82 + if (nsidPattern && !matchesNsidPattern(nsid, nsidPattern)) { 83 + continue; 84 + } 85 + const definitions = JSON.parse(record.value.definitions); 86 + 87 + const lexiconDoc = { 88 + lexicon: 1, 89 + id: nsid, 90 + defs: definitions, 91 + }; 92 + 93 + const filePath = nsidToPath(nsid, lexiconPath); 94 + 95 + await ensureDir(dirname(filePath)); 96 + 97 + await Deno.writeTextFile( 98 + filePath, 99 + JSON.stringify(lexiconDoc, null, 2) + "\n" 100 + ); 101 + 102 + logger.info(`Wrote: ${filePath}`); 103 + stats.written++; 104 + } catch (error) { 105 + const err = error as Error; 106 + stats.failed++; 107 + stats.errors.push({ 108 + nsid: record.value.nsid, 109 + error: err.message, 110 + }); 111 + } 112 + } 113 + } catch (error) { 114 + const err = error as Error; 115 + logger.error(`Failed to fetch lexicons: ${err.message}`); 116 + throw error; 117 + } 118 + 119 + return stats; 120 + } 121 + 122 + export async function pullCommand( 123 + commandArgs: unknown[], 124 + _globalArgs: Record<string, unknown> 125 + ): Promise<void> { 126 + const args = parseArgs(commandArgs as string[], { 127 + boolean: ["help"], 128 + string: ["path", "slice", "api-url", "nsid"], 129 + alias: { 130 + h: "help", 131 + }, 132 + }); 133 + 134 + if (args.help) { 135 + showPullHelp(); 136 + return; 137 + } 138 + 139 + const configLoader = new SlicesConfigLoader(); 140 + const slicesConfig = await configLoader.load(); 141 + const mergedConfig = mergeConfig(slicesConfig, args); 142 + 143 + if (!mergedConfig.slice) { 144 + logger.error("--slice is required"); 145 + if (!slicesConfig.slice) { 146 + logger.info( 147 + "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" 148 + ); 149 + } 150 + console.log("\nRun 'slices lexicon pull --help' for usage information."); 151 + Deno.exit(1); 152 + } 153 + 154 + const lexiconPath = mergedConfig.lexiconPath!; 155 + const sliceUri = mergedConfig.slice!; 156 + const apiUrl = mergedConfig.apiUrl!; 157 + const nsidPattern = args.nsid as string | undefined; 158 + 159 + const config = new ConfigManager(); 160 + await config.load(); 161 + 162 + if (!config.isAuthenticated()) { 163 + logger.error("Not authenticated. Run 'slices login' first."); 164 + Deno.exit(1); 165 + } 166 + 167 + const client = await createAuthenticatedClient(sliceUri, apiUrl); 168 + 169 + const pullStats = await pullLexicons( 170 + sliceUri, 171 + lexiconPath, 172 + client, 173 + nsidPattern 174 + ); 175 + 176 + if (pullStats.failed > 0) { 177 + logger.warn(`${pullStats.failed} lexicons failed to write`); 178 + for (const error of pullStats.errors) { 179 + logger.error(`${error.nsid}: ${error.error}`); 180 + } 181 + } 182 + 183 + if (pullStats.written > 0) { 184 + const filterMsg = nsidPattern ? ` matching '${nsidPattern}'` : ""; 185 + logger.success( 186 + `Pulled ${pullStats.written} lexicons${filterMsg} to ${lexiconPath}` 187 + ); 188 + } else { 189 + const filterMsg = nsidPattern ? ` matching '${nsidPattern}'` : ""; 190 + logger.info(`No lexicons found${filterMsg}`); 191 + } 192 + }
+2 -12
packages/cli/src/commands/login.ts
··· 47 // Check if already authenticated 48 if (!args.force && config.isAuthenticated()) { 49 const authConfig = config.get().auth!; 50 - logger.success("✅ Already authenticated!"); 51 - logger.info(`👤 User: ${authConfig.did}`); 52 - 53 - if (authConfig.expiresAt) { 54 - const expiresIn = Math.round((authConfig.expiresAt - Date.now()) / 1000); 55 - if (expiresIn > 0) { 56 - logger.info(`⏰ Token expires in: ${expiresIn} seconds`); 57 - } 58 - } 59 - 60 - logger.info("💡 Use --force to login again"); 61 return; 62 } 63 ··· 66 const scope = args.scope as string; 67 68 if (args.force && config.isAuthenticated()) { 69 - logger.info("🔄 Forcing re-authentication..."); 70 await config.logout(); 71 } 72
··· 47 // Check if already authenticated 48 if (!args.force && config.isAuthenticated()) { 49 const authConfig = config.get().auth!; 50 + console.log(`Already authenticated as ${authConfig.did}`); 51 + console.log("Use --force to login again"); 52 return; 53 } 54 ··· 57 const scope = args.scope as string; 58 59 if (args.force && config.isAuthenticated()) { 60 await config.logout(); 61 } 62
+7 -34
packages/cli/src/commands/status.ts
··· 33 const config = new ConfigManager(); 34 await config.load(); 35 36 - console.log("\n📊 Slices CLI Status"); 37 - console.log("━━━━━━━━━━━━━━━━━━━━"); 38 - 39 if (config.isAuthenticated()) { 40 const authConfig = config.get().auth!; 41 42 - logger.success("✅ Authentication Status: Authenticated"); 43 - logger.info(`👤 User DID: ${authConfig.did}`); 44 - logger.info(`📡 AIP Server: ${authConfig.aipBaseUrl}`); 45 46 if (authConfig.expiresAt) { 47 const expiresIn = Math.round((authConfig.expiresAt - Date.now()) / 1000); 48 if (expiresIn > 0) { 49 const hours = Math.floor(expiresIn / 3600); 50 const minutes = Math.floor((expiresIn % 3600) / 60); 51 - const seconds = expiresIn % 60; 52 53 if (hours > 0) { 54 - logger.info(`⏰ Token expires in: ${hours}h ${minutes}m ${seconds}s`); 55 } else if (minutes > 0) { 56 - logger.info(`⏰ Token expires in: ${minutes}m ${seconds}s`); 57 } else { 58 - logger.info(`⏰ Token expires in: ${seconds}s`); 59 } 60 } else { 61 - logger.warn("⚠️ Token has expired"); 62 - logger.info("💡 Run 'slices login' to re-authenticate"); 63 } 64 - } else { 65 - logger.info("⏰ Token expiration: Not specified"); 66 - } 67 - 68 - if (authConfig.refreshToken) { 69 - logger.info("🔄 Refresh token: Available"); 70 - } else { 71 - logger.info("🔄 Refresh token: Not available"); 72 } 73 } else { 74 - logger.warn("❌ Authentication Status: Not authenticated"); 75 - logger.info("💡 Run 'slices login' to authenticate"); 76 - } 77 - 78 - const fullConfig = config.get(); 79 - logger.info(`🌐 API Base URL: ${fullConfig.apiBaseUrl}`); 80 - 81 - if (fullConfig.defaultSliceUri) { 82 - logger.info(`📄 Default Slice: ${fullConfig.defaultSliceUri}`); 83 - } else { 84 - logger.info("📄 Default Slice: Not configured"); 85 } 86 - 87 - const configPath = `~/.config/slices/config.json`; 88 - logger.info(`⚙️ Config file: ${configPath}`); 89 }
··· 33 const config = new ConfigManager(); 34 await config.load(); 35 36 if (config.isAuthenticated()) { 37 const authConfig = config.get().auth!; 38 39 + console.log(`Authenticated as ${authConfig.did}`); 40 + console.log(`API: ${config.get().apiBaseUrl}`); 41 42 if (authConfig.expiresAt) { 43 const expiresIn = Math.round((authConfig.expiresAt - Date.now()) / 1000); 44 if (expiresIn > 0) { 45 const hours = Math.floor(expiresIn / 3600); 46 const minutes = Math.floor((expiresIn % 3600) / 60); 47 48 if (hours > 0) { 49 + console.log(`Token expires in ${hours}h ${minutes}m`); 50 } else if (minutes > 0) { 51 + console.log(`Token expires in ${minutes}m`); 52 } else { 53 + console.log(`Token expires in ${expiresIn}s`); 54 } 55 } else { 56 + console.log("Token has expired - run 'slices login' to re-authenticate"); 57 } 58 } 59 } else { 60 + console.log("Not authenticated - run 'slices login' to authenticate"); 61 } 62 }
+2 -2
packages/cli/src/mod.ts
··· 20 COMMANDS: 21 init Initialize a new Deno SSR project with OAuth 22 login Authenticate with Slices using device code flow 23 - lexicon Manage lexicons (import, list) 24 codegen Generate TypeScript client from lexicon files 25 status Show authentication and configuration status 26 help Show this help message ··· 33 EXAMPLES: 34 slices init my-app 35 slices login 36 - slices lexicon import --path ./lexicons --slice at://did:plc:example/slice 37 slices lexicon list --slice at://did:plc:example/slice 38 slices status 39 `);
··· 20 COMMANDS: 21 init Initialize a new Deno SSR project with OAuth 22 login Authenticate with Slices using device code flow 23 + lexicon Manage lexicons (push, pull, list) 24 codegen Generate TypeScript client from lexicon files 25 status Show authentication and configuration status 26 help Show this help message ··· 33 EXAMPLES: 34 slices init my-app 35 slices login 36 + slices lexicon push --path ./lexicons --slice at://did:plc:example/slice 37 slices lexicon list --slice at://did:plc:example/slice 38 slices status 39 `);
+6 -6
packages/cli/src/utils/logger.ts
··· 26 27 info(message: string, ...args: unknown[]) { 28 if (this.level <= LogLevel.INFO) { 29 - console.log(" ", message, ...args); 30 } 31 } 32 33 warn(message: string, ...args: unknown[]) { 34 if (this.level <= LogLevel.WARN) { 35 - console.warn(yellow(" warn"), message, ...args); 36 } 37 } 38 39 error(message: string, ...args: unknown[]) { 40 if (this.level <= LogLevel.ERROR) { 41 - console.error(red(" error"), message, ...args); 42 } 43 } 44 45 success(message: string, ...args: unknown[]) { 46 - console.log(green(" ✓"), message, ...args); 47 } 48 49 step(message: string, ...args: unknown[]) { 50 - console.log(cyan(" →"), message, ...args); 51 } 52 53 progress(message: string, current: number, total: number) { ··· 75 76 list(items: string[]) { 77 items.forEach(item => { 78 - console.log(` • ${item}`); 79 }); 80 } 81
··· 26 27 info(message: string, ...args: unknown[]) { 28 if (this.level <= LogLevel.INFO) { 29 + console.log("• ", message, ...args); 30 } 31 } 32 33 warn(message: string, ...args: unknown[]) { 34 if (this.level <= LogLevel.WARN) { 35 + console.warn(yellow("!"), message, ...args); 36 } 37 } 38 39 error(message: string, ...args: unknown[]) { 40 if (this.level <= LogLevel.ERROR) { 41 + console.error(red("✗"), message, ...args); 42 } 43 } 44 45 success(message: string, ...args: unknown[]) { 46 + console.log(green("✓"), message, ...args); 47 } 48 49 step(message: string, ...args: unknown[]) { 50 + console.log(cyan("→"), message, ...args); 51 } 52 53 progress(message: string, current: number, total: number) { ··· 75 76 list(items: string[]) { 77 items.forEach(item => { 78 + console.log(`• ${item}`); 79 }); 80 } 81
+23 -18
packages/client/src/mod.ts
··· 4 import type { OAuthClient } from "@slices/oauth"; 5 6 // Minimal auth interface that only requires what we actually use 7 export interface AuthProvider { 8 ensureValidToken(): Promise<{ accessToken: string; tokenType?: string }>; 9 } 10 11 // Base interfaces ··· 92 indexedAt: string; 93 } 94 95 - // Slice records parameters 96 - // These are internal interfaces not meant for export 97 - interface SliceRecordsParams<TSortField extends string = string> { 98 - slice: string; 99 - limit?: number; 100 - cursor?: string; 101 - where?: { [K in TSortField | IndexedRecordFields]?: WhereCondition }; 102 - orWhere?: { [K in TSortField | IndexedRecordFields]?: WhereCondition }; 103 - sortBy?: SortField<TSortField>[]; 104 - } 105 - 106 // Export these for use in generated clients 107 export interface SliceLevelRecordsParams<TRecord = Record<string, unknown>> { 108 slice: string; ··· 143 readonly sliceUri: string; // Make public so collection classes can access it 144 protected readonly authProvider?: OAuthClient | AuthProvider; 145 146 - constructor(baseUrl: string, sliceUri: string, authProvider?: OAuthClient | AuthProvider) { 147 this.baseUrl = baseUrl; 148 this.sliceUri = sliceUri; 149 this.authProvider = authProvider; ··· 187 "Authorization" 188 ] = `${tokens.tokenType} ${tokens.accessToken}`; 189 } 190 - } catch (tokenError) { 191 // For write operations, OAuth tokens are required (excluding read endpoints that use POST) 192 const isReadEndpoint = 193 endpoint.includes(".getRecords") || ··· 240 !isReadEndpoint 241 ) { 242 try { 243 - // Force token refresh by calling ensureValidToken again 244 - await this.authProvider.ensureValidToken(); 245 // Retry the request once with refreshed tokens 246 return this.makeRequestWithRetry(endpoint, method, params, true); 247 } catch (_refreshError) { ··· 261 } else if (errorBody?.error) { 262 errorMessage += ` - ${errorBody.error}`; 263 } 264 - 265 } catch { 266 // If we can't parse the response body, just use the status message 267 } ··· 303 ); 304 } 305 306 async uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> { 307 return this.uploadBlobWithRetry(request, false); 308 } ··· 335 // Handle 401 Unauthorized - attempt token refresh and retry once 336 if (response.status === 401 && !isRetry && this.authProvider) { 337 try { 338 - // Force token refresh by calling ensureValidToken again 339 - await this.authProvider.ensureValidToken(); 340 // Retry the request once with refreshed tokens 341 return this.uploadBlobWithRetry(request, true); 342 } catch (_refreshError) {
··· 4 import type { OAuthClient } from "@slices/oauth"; 5 6 // Minimal auth interface that only requires what we actually use 7 + // AuthProvider should be a user-scoped instance (e.g., OAuthClient created with userId) 8 export interface AuthProvider { 9 ensureValidToken(): Promise<{ accessToken: string; tokenType?: string }>; 10 + refreshAccessToken(): Promise<{ accessToken: string; tokenType?: string }>; 11 } 12 13 // Base interfaces ··· 94 indexedAt: string; 95 } 96 97 // Export these for use in generated clients 98 export interface SliceLevelRecordsParams<TRecord = Record<string, unknown>> { 99 slice: string; ··· 134 readonly sliceUri: string; // Make public so collection classes can access it 135 protected readonly authProvider?: OAuthClient | AuthProvider; 136 137 + constructor( 138 + baseUrl: string, 139 + sliceUri: string, 140 + authProvider?: OAuthClient | AuthProvider 141 + ) { 142 this.baseUrl = baseUrl; 143 this.sliceUri = sliceUri; 144 this.authProvider = authProvider; ··· 182 "Authorization" 183 ] = `${tokens.tokenType} ${tokens.accessToken}`; 184 } 185 + } catch (_tokenError) { 186 // For write operations, OAuth tokens are required (excluding read endpoints that use POST) 187 const isReadEndpoint = 188 endpoint.includes(".getRecords") || ··· 235 !isReadEndpoint 236 ) { 237 try { 238 + // Force token refresh by calling refreshAccessToken 239 + await this.authProvider.refreshAccessToken(); 240 // Retry the request once with refreshed tokens 241 return this.makeRequestWithRetry(endpoint, method, params, true); 242 } catch (_refreshError) { ··· 256 } else if (errorBody?.error) { 257 errorMessage += ` - ${errorBody.error}`; 258 } 259 } catch { 260 // If we can't parse the response body, just use the status message 261 } ··· 297 ); 298 } 299 300 + async syncUserCollections<T = any>(params?: { 301 + timeoutSeconds?: number; 302 + }): Promise<T> { 303 + const requestParams = { slice: this.sliceUri, ...params }; 304 + return await this.makeRequest<T>( 305 + "network.slices.slice.syncUserCollections", 306 + "POST", 307 + requestParams 308 + ); 309 + } 310 + 311 async uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> { 312 return this.uploadBlobWithRetry(request, false); 313 } ··· 340 // Handle 401 Unauthorized - attempt token refresh and retry once 341 if (response.status === 401 && !isRetry && this.authProvider) { 342 try { 343 + // Force token refresh by calling refreshAccessToken 344 + await this.authProvider.refreshAccessToken(); 345 // Retry the request once with refreshed tokens 346 return this.uploadBlobWithRetry(request, true); 347 } catch (_refreshError) {
packages/lexicon-intellisense/wasm/lexicon_validator_bg.wasm

This is a binary file and will not be displayed.

+60 -22
packages/oauth/README.md
··· 37 38 ### Standard OAuth Flow (Web Applications) 39 40 ```typescript 41 import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 42 43 // Set up storage 44 const storage = new SQLiteOAuthStorage("oauth.db"); 45 46 - // Create OAuth client 47 - const client = new OAuthClient({ 48 clientId: "your-client-id", 49 clientSecret: "your-client-secret", 50 authBaseUrl: "https://auth.example.com", 51 redirectUri: "http://localhost:8000/oauth/callback", 52 scopes: ["atproto"], 53 - }, storage); 54 55 - // Start authorization flow 56 - const result = await client.authorize({ loginHint: "user.bsky.social" }); 57 // Redirect user to result.authorizationUrl 58 59 // Handle callback 60 - const tokens = await client.handleCallback({ code, state }); 61 ``` 62 63 ### Device Authorization Grant (CLI Applications) ··· 158 try { 159 const tokens = await this.client.pollForTokens(deviceAuth); 160 console.log("✅ Authentication successful!"); 161 return tokens; 162 } catch (error) { 163 if (error.message.includes("expired")) { ··· 185 186 ### Token Management 187 188 - Both OAuth flows support automatic token refresh: 189 190 ```typescript 191 - // Check if token needs refresh 192 - if (client.needsRefresh(tokens)) { 193 - const newTokens = await client.refresh(tokens.refresh_token); 194 - // Store the new tokens 195 - } 196 197 - // Get user info 198 - const userInfo = await client.getUserInfo(tokens.access_token); 199 console.log("Authenticated as:", userInfo.sub); 200 ``` 201 202 ### Storage Backends 203 204 - The library provides pluggable storage backends: 205 206 ```typescript 207 // SQLite storage (persistent) ··· 210 211 // Deno KV storage (persistent) 212 import { DenoKVOAuthStorage } from "@slices/oauth"; 213 - const kvStorage = new DenoKVOAuthStorage(); 214 215 - // In-memory storage (for testing) 216 - import { MemoryOAuthStorage } from "@slices/oauth"; 217 - const memoryStorage = new MemoryOAuthStorage(); 218 219 - // Use with OAuthClient 220 - const client = new OAuthClient(config, storage); 221 - ```
··· 37 38 ### Standard OAuth Flow (Web Applications) 39 40 + OAuth clients are session-scoped. Each client instance is tied to a specific session ID, enabling proper multi-device support where each session has independent OAuth tokens. 41 + 42 ```typescript 43 import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 44 45 // Set up storage 46 const storage = new SQLiteOAuthStorage("oauth.db"); 47 48 + // OAuth configuration 49 + const config = { 50 clientId: "your-client-id", 51 clientSecret: "your-client-secret", 52 authBaseUrl: "https://auth.example.com", 53 redirectUri: "http://localhost:8000/oauth/callback", 54 scopes: ["atproto"], 55 + }; 56 57 + // Start authorization flow (before we have a session) 58 + const tempClient = new OAuthClient(config, storage, "temp"); 59 + const result = await tempClient.authorize({ loginHint: "user.bsky.social" }); 60 // Redirect user to result.authorizationUrl 61 62 // Handle callback 63 + const tokens = await tempClient.handleCallback({ code, state }); 64 + 65 + // Create session and store tokens by sessionId 66 + // (typically done via @slices/session - see Session Integration below) 67 + const sessionId = "user-session-id"; // from session store 68 + const sessionClient = new OAuthClient(config, storage, sessionId); 69 + await storage.setTokens(tokens, sessionId); 70 + 71 + // Use the session-scoped client for API calls 72 + const userInfo = await sessionClient.getUserInfo(); 73 + ``` 74 + 75 + ### Session-Scoped Pattern 76 + 77 + The key concept: **one OAuth client per session**. This enables: 78 + - Multiple active sessions per user (different devices) 79 + - Independent token management per session 80 + - Proper logout isolation (logging out one device doesn't affect others) 81 + 82 + ```typescript 83 + // Each session gets its own OAuth client 84 + function createSessionClient(sessionId: string) { 85 + return new OAuthClient(config, storage, sessionId); 86 + } 87 + 88 + // Session 1 (laptop) 89 + const laptopClient = createSessionClient("session-laptop-123"); 90 + 91 + // Session 2 (phone) - completely independent tokens 92 + const phoneClient = createSessionClient("session-phone-456"); 93 ``` 94 95 ### Device Authorization Grant (CLI Applications) ··· 190 try { 191 const tokens = await this.client.pollForTokens(deviceAuth); 192 console.log("✅ Authentication successful!"); 193 + 194 + // Get user info to find out who authenticated 195 + const userInfo = await this.getUserInfo(tokens); 196 + console.log("Authenticated as:", userInfo?.sub); 197 + 198 return tokens; 199 } catch (error) { 200 if (error.message.includes("expired")) { ··· 222 223 ### Token Management 224 225 + Both OAuth flows support automatic token refresh. The client handles token refresh automatically: 226 227 ```typescript 228 + // The client ensures tokens are valid before making requests 229 + const sessionClient = new OAuthClient(config, storage, sessionId); 230 231 + // Automatically refreshes if needed 232 + const tokens = await sessionClient.ensureValidToken(); 233 + 234 + // Get user info (automatically uses valid tokens) 235 + const userInfo = await sessionClient.getUserInfo(); 236 console.log("Authenticated as:", userInfo.sub); 237 + 238 + // Manual refresh if needed 239 + const refreshedTokens = await sessionClient.refreshAccessToken(); 240 ``` 241 242 ### Storage Backends 243 244 + The library provides pluggable storage backends. All storage backends store tokens by `sessionId`: 245 246 ```typescript 247 // SQLite storage (persistent) ··· 250 251 // Deno KV storage (persistent) 252 import { DenoKVOAuthStorage } from "@slices/oauth"; 253 + const kvStorage = new DenoKVOAuthStorage(await Deno.openKv()); 254 255 + // Use with OAuthClient (requires sessionId) 256 + const client = new OAuthClient(config, storage, sessionId); 257 + ``` 258 259 + **Note:** Tokens are stored with `sessionId` as the key, enabling multiple independent sessions per user.
+77 -31
packages/oauth/src/client.ts
··· 11 export class OAuthClient { 12 private config: OAuthConfig; 13 private storage: OAuthStorage; 14 private refreshPromise?: Promise<void>; 15 - private forceRefresh = false; // Flag to force refresh regardless of expiry 16 17 - constructor(config: OAuthConfig, storage: OAuthStorage) { 18 this.config = config; 19 this.storage = storage; 20 } 21 22 async authorize(params: { ··· 91 ); 92 93 const tokens = this.transformTokenResponse(tokenResponse); 94 - await this.storage.setTokens(tokens); 95 await this.storage.clearState(params.state); 96 97 return tokens; 98 } 99 100 async refreshAccessToken(): Promise<OAuthTokens> { 101 - const tokens = await this.storage.getTokens(); 102 if (!tokens?.refreshToken) { 103 throw new Error("No refresh token available"); 104 } ··· 117 ); 118 119 const newTokens = this.transformTokenResponse(tokenResponse); 120 - await this.storage.setTokens(newTokens); 121 return newTokens; 122 } catch (error) { 123 - await this.storage.clearTokens(); 124 throw new Error(`Failed to refresh token: ${error}`); 125 } 126 } 127 128 async ensureValidToken(): Promise<OAuthTokens> { 129 - const tokens = await this.storage.getTokens(); 130 131 if (!tokens) { 132 throw new Error("No access token available. Please authenticate first."); 133 } 134 135 - // Check if we need to refresh (either expired or force refresh flag is set) 136 - if (!this.forceRefresh && !this.isTokenExpired(tokens)) { 137 return tokens; 138 } 139 ··· 143 ); 144 } 145 146 if (this.refreshPromise) { 147 await this.refreshPromise; 148 - const refreshedTokens = await this.storage.getTokens(); 149 if (!refreshedTokens) { 150 throw new Error("Failed to refresh tokens"); 151 } 152 - this.forceRefresh = false; // Reset flag after successful refresh 153 return refreshedTokens; 154 } 155 156 this.refreshPromise = this.refreshAccessToken().then(() => undefined); 157 try { 158 await this.refreshPromise; 159 - const refreshedTokens = await this.storage.getTokens(); 160 if (!refreshedTokens) { 161 throw new Error("Failed to refresh tokens"); 162 } 163 - this.forceRefresh = false; // Reset flag after successful refresh 164 return refreshedTokens; 165 } finally { 166 this.refreshPromise = undefined; ··· 168 } 169 170 async getUserInfo(): Promise<OAuthUserInfo | null> { 171 - const isAuthenticated = await this.isAuthenticated(); 172 - if (!isAuthenticated) { 173 return null; 174 } 175 176 try { 177 - const userInfo = await this.makeRequest<OAuthUserInfo>( 178 "oauth/userinfo", 179 "GET", 180 - undefined, 181 - true 182 ); 183 return userInfo; 184 } catch (error) { ··· 188 } 189 190 async isAuthenticated(): Promise<boolean> { 191 - const tokens = await this.storage.getTokens(); 192 return !!tokens?.accessToken; 193 } 194 195 async logout(): Promise<void> { 196 - await this.storage.clearTokens(); 197 - } 198 - 199 - /** 200 - * Marks the current token as invalid, forcing the next ensureValidToken() 201 - * call to refresh regardless of expiry time. Use this when the server 202 - * rejects a token with 401 Unauthorized. 203 - */ 204 - invalidateCurrentToken(): void { 205 - this.forceRefresh = true; 206 } 207 208 async getAuthenticationInfo(): Promise<{ ··· 210 expiresAt?: number; 211 scope?: string; 212 }> { 213 - const tokens = await this.storage.getTokens(); 214 return { 215 isAuthenticated: !!tokens?.accessToken, 216 expiresAt: tokens?.expiresAt, ··· 241 }; 242 } 243 244 private async makeRequest<T = unknown>( 245 endpoint: string, 246 method: "GET" | "POST", ··· 303 } 304 305 async getTokens(): Promise<OAuthTokens | null> { 306 - return await this.storage.getTokens(); 307 } 308 }
··· 11 export class OAuthClient { 12 private config: OAuthConfig; 13 private storage: OAuthStorage; 14 + private sessionId: string; 15 private refreshPromise?: Promise<void>; 16 17 + constructor(config: OAuthConfig, storage: OAuthStorage, sessionId: string) { 18 this.config = config; 19 this.storage = storage; 20 + this.sessionId = sessionId; 21 } 22 23 async authorize(params: { ··· 92 ); 93 94 const tokens = this.transformTokenResponse(tokenResponse); 95 + 96 await this.storage.clearState(params.state); 97 98 return tokens; 99 } 100 101 async refreshAccessToken(): Promise<OAuthTokens> { 102 + const tokens = await this.storage.getTokens(this.sessionId); 103 if (!tokens?.refreshToken) { 104 throw new Error("No refresh token available"); 105 } ··· 118 ); 119 120 const newTokens = this.transformTokenResponse(tokenResponse); 121 + await this.storage.setTokens(newTokens, this.sessionId); 122 return newTokens; 123 } catch (error) { 124 + await this.storage.clearTokens(this.sessionId); 125 throw new Error(`Failed to refresh token: ${error}`); 126 } 127 } 128 129 async ensureValidToken(): Promise<OAuthTokens> { 130 + const tokens = await this.storage.getTokens(this.sessionId); 131 132 if (!tokens) { 133 throw new Error("No access token available. Please authenticate first."); 134 } 135 136 + // Check if token is still valid 137 + if (!this.isTokenExpired(tokens)) { 138 return tokens; 139 } 140 ··· 144 ); 145 } 146 147 + // Check if a refresh is already in progress 148 if (this.refreshPromise) { 149 await this.refreshPromise; 150 + const refreshedTokens = await this.storage.getTokens(this.sessionId); 151 if (!refreshedTokens) { 152 throw new Error("Failed to refresh tokens"); 153 } 154 return refreshedTokens; 155 } 156 157 + // Start a new refresh 158 this.refreshPromise = this.refreshAccessToken().then(() => undefined); 159 + 160 try { 161 await this.refreshPromise; 162 + const refreshedTokens = await this.storage.getTokens(this.sessionId); 163 if (!refreshedTokens) { 164 throw new Error("Failed to refresh tokens"); 165 } 166 return refreshedTokens; 167 } finally { 168 this.refreshPromise = undefined; ··· 170 } 171 172 async getUserInfo(): Promise<OAuthUserInfo | null> { 173 + const tokens = await this.storage.getTokens(this.sessionId); 174 + if (!tokens) { 175 return null; 176 } 177 178 try { 179 + const userInfo = await this.makeRequestWithTokens<OAuthUserInfo>( 180 "oauth/userinfo", 181 "GET", 182 + tokens, 183 + undefined 184 ); 185 return userInfo; 186 } catch (error) { ··· 190 } 191 192 async isAuthenticated(): Promise<boolean> { 193 + const tokens = await this.storage.getTokens(this.sessionId); 194 return !!tokens?.accessToken; 195 } 196 197 async logout(): Promise<void> { 198 + await this.storage.clearTokens(this.sessionId); 199 } 200 201 async getAuthenticationInfo(): Promise<{ ··· 203 expiresAt?: number; 204 scope?: string; 205 }> { 206 + const tokens = await this.storage.getTokens(this.sessionId); 207 return { 208 isAuthenticated: !!tokens?.accessToken, 209 expiresAt: tokens?.expiresAt, ··· 234 }; 235 } 236 237 + private async makeRequestWithTokens<T = unknown>( 238 + endpoint: string, 239 + method: "GET" | "POST", 240 + tokens: OAuthTokens, 241 + params?: Record<string, string | undefined> 242 + ): Promise<T> { 243 + const url = `${this.config.authBaseUrl}/${endpoint}`; 244 + 245 + const requestInit: RequestInit = { 246 + method, 247 + headers: { 248 + Authorization: `${tokens.tokenType} ${tokens.accessToken}`, 249 + }, 250 + }; 251 + 252 + if (method === "GET" && params) { 253 + const searchParams = new URLSearchParams(); 254 + Object.entries(params).forEach(([key, value]) => { 255 + if (value !== undefined && value !== null) { 256 + searchParams.append(key, String(value)); 257 + } 258 + }); 259 + const queryString = searchParams.toString(); 260 + if (queryString) { 261 + const urlWithParams = `${url}?${queryString}`; 262 + const response = await fetch(urlWithParams, requestInit); 263 + if (!response.ok) { 264 + throw new Error( 265 + `Request failed: ${response.status} ${response.statusText}` 266 + ); 267 + } 268 + return (await response.json()) as T; 269 + } 270 + } else if (method === "POST" && params) { 271 + (requestInit.headers as Record<string, string>)["Content-Type"] = 272 + "application/x-www-form-urlencoded"; 273 + requestInit.body = new URLSearchParams(params as Record<string, string>); 274 + } 275 + 276 + const response = await fetch(url, requestInit); 277 + if (!response.ok) { 278 + throw new Error( 279 + `Request failed: ${response.status} ${response.statusText}` 280 + ); 281 + } 282 + 283 + return (await response.json()) as T; 284 + } 285 + 286 private async makeRequest<T = unknown>( 287 endpoint: string, 288 method: "GET" | "POST", ··· 345 } 346 347 async getTokens(): Promise<OAuthTokens | null> { 348 + return await this.storage.getTokens(this.sessionId); 349 + } 350 + 351 + getSessionId(): string { 352 + return this.sessionId; 353 } 354 }
+6 -6
packages/oauth/src/storage/deno-kv.ts
··· 8 export class DenoKVOAuthStorage implements OAuthStorage { 9 constructor(private kv: Deno.Kv) {} 10 11 - async getTokens(): Promise<OAuthTokens | null> { 12 - const result = await this.kv.get<OAuthTokens>(["oauth_tokens"]); 13 return result.value; 14 } 15 16 - async setTokens(tokens: OAuthTokens): Promise<void> { 17 const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 18 - await this.kv.set(["oauth_tokens"], tokens, { expireIn: expirationMs }); 19 } 20 21 - async clearTokens(): Promise<void> { 22 - await this.kv.delete(["oauth_tokens"]); 23 } 24 25 async getState(state: string): Promise<string | null> {
··· 8 export class DenoKVOAuthStorage implements OAuthStorage { 9 constructor(private kv: Deno.Kv) {} 10 11 + async getTokens(sessionId: string): Promise<OAuthTokens | null> { 12 + const result = await this.kv.get<OAuthTokens>(["oauth_tokens", sessionId]); 13 return result.value; 14 } 15 16 + async setTokens(tokens: OAuthTokens, sessionId: string): Promise<void> { 17 const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 18 + await this.kv.set(["oauth_tokens", sessionId], tokens, { expireIn: expirationMs }); 19 } 20 21 + async clearTokens(sessionId: string): Promise<void> { 22 + await this.kv.delete(["oauth_tokens", sessionId]); 23 } 24 25 async getState(state: string): Promise<string | null> {
+16 -14
packages/oauth/src/storage/sqlite.ts
··· 14 this.db.exec(` 15 CREATE TABLE IF NOT EXISTS oauth_tokens ( 16 id INTEGER PRIMARY KEY, 17 access_token TEXT NOT NULL, 18 token_type TEXT NOT NULL, 19 expires_at INTEGER, 20 refresh_token TEXT, 21 scope TEXT, 22 - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) 23 ) 24 `); 25 ··· 38 `); 39 } 40 41 - getTokens(): Promise<OAuthTokens | null> { 42 const stmt = this.db.prepare(` 43 SELECT access_token, token_type, expires_at, refresh_token, scope 44 - FROM oauth_tokens 45 - ORDER BY created_at DESC 46 LIMIT 1 47 `); 48 - 49 - const row = stmt.get() as { 50 access_token: string; 51 token_type: string; 52 expires_at?: number; ··· 64 }); 65 } 66 67 - async setTokens(tokens: OAuthTokens): Promise<void> { 68 - // Clear existing tokens first 69 - await this.clearTokens(); 70 71 const stmt = this.db.prepare(` 72 - INSERT INTO oauth_tokens (access_token, token_type, expires_at, refresh_token, scope) 73 - VALUES (?, ?, ?, ?, ?) 74 `); 75 76 stmt.run( 77 tokens.accessToken, 78 tokens.tokenType, 79 tokens.expiresAt || null, ··· 82 ); 83 } 84 85 - clearTokens(): Promise<void> { 86 - const stmt = this.db.prepare("DELETE FROM oauth_tokens"); 87 - stmt.run(); 88 return Promise.resolve(); 89 } 90
··· 14 this.db.exec(` 15 CREATE TABLE IF NOT EXISTS oauth_tokens ( 16 id INTEGER PRIMARY KEY, 17 + session_id TEXT, 18 access_token TEXT NOT NULL, 19 token_type TEXT NOT NULL, 20 expires_at INTEGER, 21 refresh_token TEXT, 22 scope TEXT, 23 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), 24 + UNIQUE(session_id) 25 ) 26 `); 27 ··· 40 `); 41 } 42 43 + getTokens(sessionId: string): Promise<OAuthTokens | null> { 44 const stmt = this.db.prepare(` 45 SELECT access_token, token_type, expires_at, refresh_token, scope 46 + FROM oauth_tokens 47 + WHERE session_id = ? 48 LIMIT 1 49 `); 50 + 51 + const row = stmt.get(sessionId) as { 52 access_token: string; 53 token_type: string; 54 expires_at?: number; ··· 66 }); 67 } 68 69 + async setTokens(tokens: OAuthTokens, sessionId: string): Promise<void> { 70 + await this.clearTokens(sessionId); 71 72 const stmt = this.db.prepare(` 73 + INSERT INTO oauth_tokens (session_id, access_token, token_type, expires_at, refresh_token, scope) 74 + VALUES (?, ?, ?, ?, ?, ?) 75 `); 76 77 stmt.run( 78 + sessionId, 79 tokens.accessToken, 80 tokens.tokenType, 81 tokens.expiresAt || null, ··· 84 ); 85 } 86 87 + clearTokens(sessionId: string): Promise<void> { 88 + const stmt = this.db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?"); 89 + stmt.run(sessionId); 90 return Promise.resolve(); 91 } 92
+4 -4
packages/oauth/src/types.ts
··· 56 } 57 58 export interface OAuthStorage { 59 - getTokens(): Promise<OAuthTokens | null>; 60 - setTokens(tokens: OAuthTokens): Promise<void>; 61 - clearTokens(): Promise<void>; 62 - 63 getState(state: string): Promise<string | null>; 64 setState(state: string, codeVerifier: string): Promise<void>; 65 clearState(state: string): Promise<void>;
··· 56 } 57 58 export interface OAuthStorage { 59 + getTokens(sessionId: string): Promise<OAuthTokens | null>; 60 + setTokens(tokens: OAuthTokens, sessionId: string): Promise<void>; 61 + clearTokens(sessionId: string): Promise<void>; 62 + 63 getState(state: string): Promise<string | null>; 64 setState(state: string, codeVerifier: string): Promise<void>; 65 clearState(state: string): Promise<void>;
+69 -17
packages/session/README.md
··· 47 48 ```typescript 49 import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; 50 - import { OAuthClient } from "@slices/oauth"; 51 52 const sessionStore = new SessionStore({ 53 adapter: new SQLiteAdapter("./sessions.db") 54 }); 55 56 - const oauthClient = new OAuthClient({ 57 clientId: "your-client-id", 58 clientSecret: "your-client-secret", 59 - authBaseUrl: "https://auth.example.com" 60 - }); 61 62 - const oauthSessions = withOAuthSession(sessionStore, oauthClient); 63 64 - // Create OAuth session 65 - const sessionId = await oauthSessions.createOAuthSession(request); 66 67 // Get session with auto token refresh 68 const session = await oauthSessions.getOAuthSession(sessionId); 69 ``` 70 71 ### Storage Adapters ··· 130 131 ### OAuthSessionManager 132 133 - OAuth-enabled session management. 134 135 ```typescript 136 - const manager = withOAuthSession(sessionStore, oauthClient, { 137 - autoRefresh: true, // Auto-refresh expired tokens 138 - onTokenRefresh: async (sessionId, tokens) => { 139 - // Handle token refresh 140 - }, 141 - onLogout: async (sessionId) => { 142 - // Handle logout 143 } 144 - }); 145 ``` 146 147 #### Methods 148 149 - - `createOAuthSession(request)` - Create session with OAuth tokens 150 - `getOAuthSession(sessionId)` - Get session with token refresh 151 - `logout(sessionId)` - OAuth logout and session cleanup 152 - `hasValidOAuthTokens(sessionId)` - Check token validity 153 - `getAccessToken(sessionId)` - Get access token for API calls 154 155 ### Session Data Structure 156 157 ```typescript ··· 204 }); 205 ``` 206 207 ## Security 208 209 - Sessions are stored with secure, httpOnly cookies by default ··· 211 - CSRF protection through SameSite cookies 212 - Secure session ID generation using crypto.randomUUID() 213 - Optional token refresh to keep OAuth sessions valid 214 215 ## License 216
··· 47 48 ```typescript 49 import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; 50 + import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 51 52 const sessionStore = new SessionStore({ 53 adapter: new SQLiteAdapter("./sessions.db") 54 }); 55 56 + const oauthStorage = new SQLiteOAuthStorage("./oauth.db"); 57 + const oauthConfig = { 58 clientId: "your-client-id", 59 clientSecret: "your-client-secret", 60 + authBaseUrl: "https://auth.example.com", 61 + redirectUri: "http://localhost:8000/oauth/callback", 62 + scopes: ["atproto"], 63 + }; 64 65 + const oauthSessions = withOAuthSession( 66 + sessionStore, 67 + oauthConfig, 68 + oauthStorage, 69 + { 70 + autoRefresh: true 71 + } 72 + ); 73 74 + // OAuth callback flow 75 + const tempClient = new OAuthClient(oauthConfig, oauthStorage, "temp"); 76 + const tokens = await tempClient.handleCallback({ code, state }); 77 + 78 + // Create OAuth session (handles user info fetch and token storage) 79 + const sessionId = await oauthSessions.createOAuthSession(tokens); 80 81 // Get session with auto token refresh 82 const session = await oauthSessions.getOAuthSession(sessionId); 83 + 84 + // Create session-scoped OAuth client 85 + const sessionClient = new OAuthClient(oauthConfig, oauthStorage, sessionId); 86 + const userInfo = await sessionClient.getUserInfo(); 87 ``` 88 89 ### Storage Adapters ··· 148 149 ### OAuthSessionManager 150 151 + OAuth-enabled session management with session-scoped tokens. 152 153 ```typescript 154 + const manager = withOAuthSession( 155 + sessionStore, 156 + oauthConfig, 157 + oauthStorage, 158 + { 159 + autoRefresh: true, // Auto-refresh expired tokens 160 + onTokenRefresh: async (sessionId, tokens) => { 161 + // Handle token refresh 162 + }, 163 + onLogout: async (sessionId) => { 164 + // Handle logout 165 + } 166 } 167 + ); 168 ``` 169 170 #### Methods 171 172 + - `createOAuthSession(tokens)` - Create session with OAuth tokens (fetches user info, stores tokens by sessionId) 173 - `getOAuthSession(sessionId)` - Get session with token refresh 174 - `logout(sessionId)` - OAuth logout and session cleanup 175 - `hasValidOAuthTokens(sessionId)` - Check token validity 176 - `getAccessToken(sessionId)` - Get access token for API calls 177 178 + #### How it works 179 + 180 + 1. **OAuth callback** returns tokens without `sub` 181 + 2. `createOAuthSession(tokens)`: 182 + - Creates temp OAuth client 183 + - Fetches user info to get `sub` 184 + - Creates session with userId 185 + - Stores tokens by sessionId (not userId!) 186 + 3. All subsequent operations use sessionId for token lookup 187 + 188 ### Session Data Structure 189 190 ```typescript ··· 237 }); 238 ``` 239 240 + ## Multi-Device Support 241 + 242 + The session-scoped OAuth pattern enables proper multi-device support: 243 + 244 + ```typescript 245 + // User logs in on laptop 246 + const laptopSessionId = await oauthSessions.createOAuthSession(laptopTokens); 247 + const laptopClient = new OAuthClient(config, storage, laptopSessionId); 248 + 249 + // Same user logs in on phone 250 + const phoneSessionId = await oauthSessions.createOAuthSession(phoneTokens); 251 + const phoneClient = new OAuthClient(config, storage, phoneSessionId); 252 + 253 + // Each device has independent tokens 254 + // Logging out laptop doesn't affect phone session 255 + await oauthSessions.logout(laptopSessionId); // Only laptop session cleared 256 + ``` 257 + 258 ## Security 259 260 - Sessions are stored with secure, httpOnly cookies by default ··· 262 - CSRF protection through SameSite cookies 263 - Secure session ID generation using crypto.randomUUID() 264 - Optional token refresh to keep OAuth sessions valid 265 + - Session-scoped tokens prevent multi-device conflicts 266 267 ## License 268
+73 -30
packages/session/src/oauth-integration.ts
··· 1 import type { SessionStore } from "./store.ts"; 2 import type { SessionData } from "./types.ts"; 3 - import type { OAuthClient, OAuthTokens } from "@slices/oauth"; 4 5 export interface OAuthSessionOptions { 6 sessionStore: SessionStore; 7 - oauthClient: OAuthClient; 8 autoRefresh?: boolean; 9 onTokenRefresh?: (sessionId: string, tokens: OAuthTokens) => Promise<void>; 10 onLogout?: (sessionId: string) => Promise<void>; ··· 17 this.options = options; 18 } 19 20 - // Create a session linked to OAuth (no token duplication) 21 - async createOAuthSession(): Promise<string | null> { 22 try { 23 - // Verify OAuth tokens exist (but don't store them) 24 - const tokens = await this.options.oauthClient.ensureValidToken(); 25 - if (!tokens.accessToken) { 26 - return null; 27 - } 28 29 // Get user info from OAuth 30 - const userInfo = await this.options.oauthClient.getUserInfo(); 31 if (!userInfo) { 32 return null; 33 } 34 35 - // Create session with user data only (no token storage) 36 const sessionId = await this.options.sessionStore.createSession( 37 userInfo.sub, 38 userInfo.name, ··· 41 ...userInfo, 42 handle: userInfo.name, 43 }, 44 - // OAuth tokens are managed separately by @slices/oauth 45 - // No token duplication here 46 } 47 ); 48 49 return sessionId; 50 } catch (error) { ··· 53 } 54 } 55 56 - // Get session (tokens managed separately by OAuth client) 57 async getOAuthSession(sessionId: string): Promise<SessionData | null> { 58 const session = await this.options.sessionStore.getSession(sessionId); 59 if (!session) { ··· 63 // Auto-refresh is handled by OAuth client, not session storage 64 if (this.options.autoRefresh) { 65 try { 66 // This ensures tokens are fresh in OAuth storage 67 - await this.options.oauthClient.ensureValidToken(); 68 69 // Call refresh callback if provided 70 if (this.options.onTokenRefresh) { 71 - const tokens = await this.options.oauthClient.ensureValidToken(); 72 - await this.options.onTokenRefresh(sessionId, tokens); 73 } 74 } catch (error) { 75 console.error("Failed to refresh OAuth tokens:", error); ··· 80 return session; 81 } 82 83 - // Logout and cleanup OAuth session 84 async logout(sessionId: string): Promise<void> { 85 try { 86 - // Call OAuth logout 87 - await this.options.oauthClient.logout(); 88 89 // Delete session 90 await this.options.sessionStore.deleteSession(sessionId); ··· 100 } 101 } 102 103 - // Check if session has valid OAuth tokens (via OAuth client) 104 async hasValidOAuthTokens(sessionId: string): Promise<boolean> { 105 const session = await this.getOAuthSession(sessionId); 106 if (!session) return false; 107 108 try { 109 // Let OAuth client determine token validity 110 - const tokens = await this.options.oauthClient.ensureValidToken(); 111 return !!tokens.accessToken; 112 } catch (_error) { 113 return false; 114 } 115 } 116 117 - // Get OAuth access token for API calls (from OAuth client, not session) 118 async getAccessToken(sessionId: string): Promise<string | null> { 119 const session = await this.getOAuthSession(sessionId); 120 if (!session) return null; 121 122 try { 123 // Get fresh tokens from OAuth client 124 - const tokens = await this.options.oauthClient.ensureValidToken(); 125 return tokens.accessToken || null; 126 } catch (_error) { 127 return null; ··· 132 // Convenience function to create an OAuth-enabled session store 133 export function withOAuthSession( 134 sessionStore: SessionStore, 135 - oauthClient: OAuthClient, 136 - options: Partial<OAuthSessionOptions> = {} 137 ): OAuthSessionManager { 138 return new OAuthSessionManager({ 139 sessionStore, 140 - oauthClient, 141 autoRefresh: true, 142 ...options, 143 }); ··· 178 }; 179 }, 180 181 - // Login handler 182 - async login(): Promise<Response> { 183 - const sessionId = await manager.createOAuthSession(); 184 if (!sessionId) { 185 return new Response("Authentication failed", { status: 401 }); 186 }
··· 1 import type { SessionStore } from "./store.ts"; 2 import type { SessionData } from "./types.ts"; 3 + import { 4 + OAuthClient, 5 + type OAuthTokens, 6 + type OAuthConfig, 7 + type OAuthStorage, 8 + } from "@slices/oauth"; 9 10 export interface OAuthSessionOptions { 11 sessionStore: SessionStore; 12 + oauthConfig: OAuthConfig; 13 + oauthStorage: OAuthStorage; 14 autoRefresh?: boolean; 15 onTokenRefresh?: (sessionId: string, tokens: OAuthTokens) => Promise<void>; 16 onLogout?: (sessionId: string) => Promise<void>; ··· 23 this.options = options; 24 } 25 26 + async createOAuthSession(tokens: OAuthTokens): Promise<string | null> { 27 try { 28 + // Create temporary OAuth client to fetch user info 29 + const tempClient = new OAuthClient( 30 + this.options.oauthConfig, 31 + this.options.oauthStorage, 32 + "temp_" + Date.now() 33 + ); 34 + 35 + // Temporarily store tokens to fetch user info 36 + await this.options.oauthStorage.setTokens( 37 + tokens, 38 + tempClient.getSessionId() 39 + ); 40 41 // Get user info from OAuth 42 + const userInfo = await tempClient.getUserInfo(); 43 if (!userInfo) { 44 + await this.options.oauthStorage.clearTokens(tempClient.getSessionId()); 45 return null; 46 } 47 48 + // Clean up temp tokens 49 + await this.options.oauthStorage.clearTokens(tempClient.getSessionId()); 50 + 51 + // Create session FIRST to get sessionId 52 const sessionId = await this.options.sessionStore.createSession( 53 userInfo.sub, 54 userInfo.name, ··· 57 ...userInfo, 58 handle: userInfo.name, 59 }, 60 } 61 ); 62 + 63 + // NOW store tokens by sessionId 64 + await this.options.oauthStorage.setTokens(tokens, sessionId); 65 66 return sessionId; 67 } catch (error) { ··· 70 } 71 } 72 73 async getOAuthSession(sessionId: string): Promise<SessionData | null> { 74 const session = await this.options.sessionStore.getSession(sessionId); 75 if (!session) { ··· 79 // Auto-refresh is handled by OAuth client, not session storage 80 if (this.options.autoRefresh) { 81 try { 82 + // Create session-scoped OAuth client 83 + const sessionClient = new OAuthClient( 84 + this.options.oauthConfig, 85 + this.options.oauthStorage, 86 + sessionId 87 + ); 88 + 89 // This ensures tokens are fresh in OAuth storage 90 + await sessionClient.ensureValidToken(); 91 92 // Call refresh callback if provided 93 if (this.options.onTokenRefresh) { 94 + const tokens = await sessionClient.getTokens(); 95 + if (tokens) { 96 + await this.options.onTokenRefresh(sessionId, tokens); 97 + } 98 } 99 } catch (error) { 100 console.error("Failed to refresh OAuth tokens:", error); ··· 105 return session; 106 } 107 108 async logout(sessionId: string): Promise<void> { 109 try { 110 + // Create session-scoped OAuth client and logout 111 + const sessionClient = new OAuthClient( 112 + this.options.oauthConfig, 113 + this.options.oauthStorage, 114 + sessionId 115 + ); 116 + await sessionClient.logout(); 117 118 // Delete session 119 await this.options.sessionStore.deleteSession(sessionId); ··· 129 } 130 } 131 132 async hasValidOAuthTokens(sessionId: string): Promise<boolean> { 133 const session = await this.getOAuthSession(sessionId); 134 if (!session) return false; 135 136 try { 137 + // Create session-scoped OAuth client 138 + const sessionClient = new OAuthClient( 139 + this.options.oauthConfig, 140 + this.options.oauthStorage, 141 + sessionId 142 + ); 143 // Let OAuth client determine token validity 144 + const tokens = await sessionClient.ensureValidToken(); 145 return !!tokens.accessToken; 146 } catch (_error) { 147 return false; 148 } 149 } 150 151 async getAccessToken(sessionId: string): Promise<string | null> { 152 const session = await this.getOAuthSession(sessionId); 153 if (!session) return null; 154 155 try { 156 + // Create session-scoped OAuth client 157 + const sessionClient = new OAuthClient( 158 + this.options.oauthConfig, 159 + this.options.oauthStorage, 160 + sessionId 161 + ); 162 // Get fresh tokens from OAuth client 163 + const tokens = await sessionClient.ensureValidToken(); 164 return tokens.accessToken || null; 165 } catch (_error) { 166 return null; ··· 171 // Convenience function to create an OAuth-enabled session store 172 export function withOAuthSession( 173 sessionStore: SessionStore, 174 + oauthConfig: OAuthConfig, 175 + oauthStorage: OAuthStorage, 176 + options: Partial< 177 + Omit<OAuthSessionOptions, "sessionStore" | "oauthConfig" | "oauthStorage"> 178 + > = {} 179 ): OAuthSessionManager { 180 return new OAuthSessionManager({ 181 sessionStore, 182 + oauthConfig, 183 + oauthStorage, 184 autoRefresh: true, 185 ...options, 186 }); ··· 221 }; 222 }, 223 224 + // Login handler - expects tokens from OAuth callback 225 + async login(tokens: OAuthTokens): Promise<Response> { 226 + const sessionId = await manager.createOAuthSession(tokens); 227 if (!sessionId) { 228 return new Response("Authentication failed", { status: 401 }); 229 }
+2 -1
packages/session/src/store.ts
··· 117 // Get user info from session 118 async getCurrentUser(request: Request): Promise<SessionUser> { 119 const session = await this.getSessionFromRequest(request); 120 - 121 if (!session) { 122 return { 123 isAuthenticated: false, ··· 125 } 126 127 return { 128 sub: session.userId, 129 handle: session.handle, 130 isAuthenticated: session.isAuthenticated,
··· 117 // Get user info from session 118 async getCurrentUser(request: Request): Promise<SessionUser> { 119 const session = await this.getSessionFromRequest(request); 120 + 121 if (!session) { 122 return { 123 isAuthenticated: false, ··· 125 } 126 127 return { 128 + sessionId: session.sessionId, 129 sub: session.userId, 130 handle: session.handle, 131 isAuthenticated: session.isAuthenticated,
+1
packages/session/src/types.ts
··· 10 } 11 12 export interface SessionUser { 13 sub?: string; 14 handle?: string; 15 isAuthenticated: boolean;
··· 10 } 11 12 export interface SessionUser { 13 + sessionId?: string; 14 sub?: string; 15 handle?: string; 16 isAuthenticated: boolean;