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)

Changed files
+765 -397
frontend
src
features
auth
dashboard
settings
slices
api-docs
jetstream
settings
sync
routes
utils
packages
+40 -26
frontend/src/config.ts
··· 27 27 28 28 // OAuth setup 29 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 - ); 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 52 53 53 // Session setup (shared database) 54 54 export const sessionStore = new SessionStore({ ··· 62 62 }); 63 63 64 64 // OAuth + Session integration 65 - export const oauthSessions = withOAuthSession(sessionStore, oauthClient, { 66 - autoRefresh: true, 67 - }); 65 + export const oauthSessions = withOAuthSession( 66 + sessionStore, 67 + oauthConfig, 68 + oauthStorage, 69 + { 70 + autoRefresh: true, 71 + } 72 + ); 68 73 69 - export const atprotoClient = new AtProtoClient(API_URL, SLICE_URI, oauthClient); 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 + } 70 84 71 85 // Public client for unauthenticated requests 72 86 export const publicClient = new AtProtoClient(API_URL, SLICE_URI);
+59 -52
frontend/src/features/auth/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { withAuth } from "../../routes/middleware.ts"; 3 - import { atprotoClient, oauthSessions, sessionStore } from "../../config.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"; 4 13 import { renderHTML } from "../../utils/render.tsx"; 5 14 import { LoginPage } from "./templates/LoginPage.tsx"; 6 15 import { SLICE_URI } from "../../config.ts"; ··· 19 28 20 29 // Query for invites for this DID - using json field to query the record content 21 30 const invitesResult = 22 - await atprotoClient.network.slices.waitlist.invite.getRecords({ 31 + await publicClient.network.slices.waitlist.invite.getRecords({ 23 32 where: { 24 33 slice: { eq: sliceUri }, 25 34 json: { contains: userDid }, ··· 45 54 46 55 // Check if user is already on the waitlist - requests are created by the user so record.did is correct 47 56 const requestsResult = 48 - await atprotoClient.network.slices.waitlist.request.getRecords({ 57 + await publicClient.network.slices.waitlist.request.getRecords({ 49 58 where: { 50 59 slice: { eq: sliceUri }, 51 60 json: { eq: userDid }, ··· 83 92 84 93 async function handleOAuthAuthorize(req: Request): Promise<Response> { 85 94 try { 86 - // Clear any existing auth state before new login attempt 87 - await atprotoClient.oauth?.logout(); 88 - 89 95 const formData = await req.formData(); 90 96 const loginHint = formData.get("loginHint") as string; 91 97 ··· 93 99 return new Response("Missing login hint", { status: 400 }); 94 100 } 95 101 96 - if (!atprotoClient.oauth) { 97 - return new Response("OAuth client not configured", { status: 500 }); 98 - } 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 + ); 99 109 100 - const authResult = await atprotoClient.oauth.authorize({ 110 + const authResult = await tempOAuthClient.authorize({ 101 111 loginHint, 102 112 }); 103 113 ··· 144 154 ); 145 155 } 146 156 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 - } 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"); 156 160 157 - // Exchange code for tokens - the OAuth client handles this internally 158 - await atprotoClient.oauth.handleCallback({ 159 - code, 160 - state, 161 - }); 161 + // Exchange code for tokens 162 + const tokens = await tempOAuthClient.handleCallback({ code, state }); 162 163 163 - // Create OAuth session with auto token management 164 - const sessionId = await oauthSessions.createOAuthSession(); 164 + // Create OAuth session with tokens 165 + const sessionId = await oauthSessions.createOAuthSession(tokens); 165 166 166 167 if (!sessionId) { 167 168 return Response.redirect( ··· 176 177 // Create session cookie 177 178 const sessionCookie = sessionStore.createSessionCookie(sessionId); 178 179 179 - // Get user info from OAuth session 180 + // Create session-scoped OAuth client 181 + const sessionOAuthClient = createOAuthClient(sessionId); 182 + const sessionClient = createSessionClient(sessionId); 183 + 184 + // Get user info from OAuth client 180 185 let userInfo; 181 186 try { 182 - userInfo = await atprotoClient.oauth?.getUserInfo(); 187 + userInfo = await sessionOAuthClient.getUserInfo(); 183 188 } catch (error) { 184 189 console.error("Failed to get user info:", error); 185 190 } ··· 189 194 const { hasAccess, isOnWaitlist } = await checkUserAccess(userInfo.sub); 190 195 if (!hasAccess) { 191 196 // Clear OAuth session and redirect to waitlist page 192 - await atprotoClient.oauth?.logout(); 197 + await sessionOAuthClient.logout(); 193 198 194 199 const errorCode = isOnWaitlist 195 200 ? "already_on_waitlist" ··· 204 209 // Sync external collections first to ensure actor records are populated 205 210 try { 206 211 if (userInfo?.sub) { 207 - await atprotoClient.network.slices.slice.syncUserCollections(); 212 + await sessionClient.network.slices.slice.syncUserCollections(); 208 213 } 209 214 } catch (error) { 210 215 console.error("Error during external collections sync:", error); ··· 215 220 try { 216 221 // Check if user already has a profile record in our slice 217 222 const existingProfile = 218 - await atprotoClient.network.slices.actor.profile.getRecords({ 223 + await sessionClient.network.slices.actor.profile.getRecords({ 219 224 where: { 220 225 did: { eq: userInfo.sub }, 221 226 }, ··· 233 238 try { 234 239 // Get their bsky profile data 235 240 const bskyProfile = 236 - await atprotoClient.app.bsky.actor.profile.getRecords({ 241 + await sessionClient.app.bsky.actor.profile.getRecords({ 237 242 where: { 238 243 did: { eq: userInfo.sub }, 239 244 }, ··· 259 264 } 260 265 261 266 // Create the profile record with the data using "self" as the rkey 262 - await atprotoClient.network.slices.actor.profile.createRecord( 267 + await sessionClient.network.slices.actor.profile.createRecord( 263 268 profileData, 264 269 true 265 270 ); ··· 329 334 return new Response("Missing handle", { status: 400 }); 330 335 } 331 336 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 - } 337 + // Create temporary OAuth client for waitlist flow 338 + const tempOAuthClient = new OAuthClient(oauthConfig, oauthStorage, handle); 338 339 339 340 // Store waitlist flag in state parameter 340 341 const waitlistState = btoa( ··· 346 347 ); 347 348 348 349 // Initiate OAuth with minimal scope for waitlist, passing state directly 349 - const authResult = await atprotoClient.oauth.authorize({ 350 + const authResult = await tempOAuthClient.authorize({ 350 351 loginHint: handle, 351 352 scope: "atproto repo:network.slices.waitlist.request", 352 353 state: waitlistState, ··· 387 388 console.error("Failed to decode waitlist state"); 388 389 } 389 390 390 - if (!atprotoClient.oauth) { 391 - return Response.redirect( 392 - new URL("/waitlist?error=oauth_not_configured", req.url), 393 - 302 394 - ); 395 - } 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 + ); 396 398 397 399 // Exchange code for tokens 398 - await atprotoClient.oauth.handleCallback({ code, state }); 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); 399 406 400 407 // Get user info 401 - const userInfo = await atprotoClient.oauth.getUserInfo(); 408 + const userInfo = await tempOAuthClient.getUserInfo(); 402 409 403 410 if (!userInfo) { 404 411 return Response.redirect( ··· 409 416 410 417 // Create waitlist record 411 418 try { 412 - await atprotoClient.network.slices.waitlist.request.createRecord( 419 + await sessionClient.network.slices.waitlist.request.createRecord( 413 420 { 414 421 slice: SLICE_URI!, 415 422 createdAt: new Date().toISOString(), ··· 419 426 420 427 // Sync user collections to populate their Bluesky profile data 421 428 try { 422 - await atprotoClient.network.slices.slice.syncUserCollections(); 429 + await sessionClient.network.slices.slice.syncUserCollections(); 423 430 } catch (syncError) { 424 431 console.error( 425 432 "Failed to sync user collections for waitlist user:", ··· 431 438 console.error("Failed to create waitlist record:", error); 432 439 } 433 440 434 - // Clear OAuth session since this is just for waitlist 435 - await atprotoClient.oauth?.logout(); 441 + // Clear temp OAuth session since this is just for waitlist 442 + await tempOAuthClient.logout(); 436 443 437 444 // Redirect back to waitlist page with success parameter 438 445 const handle = userInfo.name || waitlistData.handle || "user";
+7 -9
frontend/src/features/dashboard/handlers.tsx
··· 2 2 import { renderHTML } from "../../utils/render.tsx"; 3 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 - import { atprotoClient, publicClient } from "../../config.ts"; 5 + import { publicClient, createSessionClient } from "../../config.ts"; 6 6 import { DashboardPage } from "./templates/DashboardPage.tsx"; 7 7 import { CreateSliceDialog } from "./templates/fragments/CreateSliceDialog.tsx"; 8 8 import { getSliceActor, getSlicesForActor } from "../../lib/api.ts"; ··· 57 57 const authResponse = requireAuth(context); 58 58 if (authResponse) return authResponse; 59 59 60 - const authInfo = await atprotoClient.oauth?.getAuthenticationInfo(); 61 - if (!authInfo?.isAuthenticated) { 60 + if (!context.currentUser.sub) { 62 61 return renderHTML( 63 62 <CreateSliceDialog error="Session expired. Please log in again." /> 64 63 ); 65 64 } 65 + 66 + // Create session-scoped client 67 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 66 68 67 69 try { 68 70 const formData = await req.formData(); ··· 90 92 } 91 93 92 94 try { 93 - const recordData = { 95 + const result = await sessionClient.network.slices.slice.createRecord({ 94 96 name: name.trim(), 95 97 domain: domain.trim(), 96 98 createdAt: new Date().toISOString(), 97 - }; 98 - 99 - const result = await atprotoClient.network.slices.slice.createRecord( 100 - recordData 101 - ); 99 + }); 102 100 103 101 const uriParts = result.uri.split("/"); 104 102 const sliceRkey = uriParts[uriParts.length - 1];
+9 -6
frontend/src/features/settings/handlers.tsx
··· 2 2 import { renderHTML } from "../../utils/render.tsx"; 3 3 import { hxRedirect } from "../../utils/htmx.ts"; 4 4 import { requireAuth, withAuth } from "../../routes/middleware.ts"; 5 - import { atprotoClient } from "../../config.ts"; 5 + import { createSessionClient, publicClient } from "../../config.ts"; 6 6 import { buildAtUri } from "../../utils/at-uri.ts"; 7 7 import type { SocialSlicesActorProfile } from "../../client.ts"; 8 8 import { SettingsPage } from "./templates/SettingsPage.tsx"; ··· 28 28 | undefined; 29 29 30 30 try { 31 - const profileRecord = await atprotoClient.network.slices.actor.profile 31 + const profileRecord = await publicClient.network.slices.actor.profile 32 32 .getRecord({ 33 33 uri: buildAtUri({ 34 34 did: context.currentUser.sub!, ··· 80 80 createdAt: new Date().toISOString(), 81 81 }; 82 82 83 + // Create user-scoped client for write operations 84 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 85 + 83 86 if (avatarFile && avatarFile.size > 0) { 84 87 try { 85 88 const arrayBuffer = await avatarFile.arrayBuffer(); 86 89 87 - const blobResult = await atprotoClient.uploadBlob({ 90 + const blobResult = await sessionClient.uploadBlob({ 88 91 data: arrayBuffer, 89 92 mimeType: avatarFile.type, 90 93 }); ··· 100 103 throw new Error("User DID (sub) is required for profile operations"); 101 104 } 102 105 103 - const existingProfile = await atprotoClient.network.slices.actor.profile 106 + const existingProfile = await publicClient.network.slices.actor.profile 104 107 .getRecord({ 105 108 uri: buildAtUri({ 106 109 did: context.currentUser.sub, ··· 121 124 updatedProfile.avatar = existingProfile.value.avatar; 122 125 } 123 126 124 - await atprotoClient.network.slices.actor.profile.updateRecord( 127 + await sessionClient.network.slices.actor.profile.updateRecord( 125 128 "self", 126 129 updatedProfile, 127 130 ); 128 131 } else { 129 - await atprotoClient.network.slices.actor.profile.createRecord( 132 + await sessionClient.network.slices.actor.profile.createRecord( 130 133 profileData, 131 134 true, 132 135 );
+10 -8
frontend/src/features/slices/api-docs/handlers.tsx
··· 6 6 withSliceAccess, 7 7 } from "../../../routes/slice-middleware.ts"; 8 8 import { extractSliceParams } from "../../../utils/slice-params.ts"; 9 - import { atprotoClient } from "../../../config.ts"; 9 + import { createOAuthClient } from "../../../config.ts"; 10 10 import { SliceApiDocsPage } from "./templates/SliceApiDocsPage.tsx"; 11 11 12 12 async function handleSliceApiDocsPage( ··· 28 28 const accessError = requireSliceAccess(context); 29 29 if (accessError) return accessError; 30 30 31 - // Get OAuth access token directly from OAuth client (clean separation) 31 + // Get OAuth access token for the current user 32 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); 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 + } 39 41 } 40 42 41 43 return renderHTML(
+3 -3
frontend/src/features/slices/jetstream/handlers.tsx
··· 5 5 withSliceAccess, 6 6 } from "../../../routes/slice-middleware.ts"; 7 7 import { getSliceClient } from "../../../utils/client.ts"; 8 - import { atprotoClient } from "../../../config.ts"; 8 + import { publicClient } from "../../../config.ts"; 9 9 import { renderHTML } from "../../../utils/render.tsx"; 10 10 import { Layout } from "../../../shared/fragments/Layout.tsx"; 11 11 import { extractSliceParams } from "../../../utils/slice-params.ts"; ··· 89 89 const handle = url.searchParams.get("handle"); 90 90 const isCompact = url.searchParams.get("compact") === "true"; 91 91 92 - // Fetch jetstream status using the atproto client 93 - const data = await atprotoClient.network.slices.slice.getJetstreamStatus(); 92 + // Fetch jetstream status using the public client 93 + const data = await publicClient.network.slices.slice.getJetstreamStatus(); 94 94 95 95 // Render compact version for logs page 96 96 if (isCompact) {
+11 -5
frontend/src/features/slices/settings/handlers.tsx
··· 1 1 import type { Route } from "@std/http/unstable-route"; 2 2 import { withAuth } from "../../../routes/middleware.ts"; 3 - import { atprotoClient } from "../../../config.ts"; 3 + import { createSessionClient, publicClient } from "../../../config.ts"; 4 4 import { buildSliceUri } from "../../../utils/at-uri.ts"; 5 5 import { renderHTML } from "../../../utils/render.tsx"; 6 6 import { hxRedirect } from "../../../utils/htmx.ts"; ··· 77 77 // Construct the URI for this slice 78 78 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 79 79 80 - // Get the current record first 81 - const currentRecord = await atprotoClient.network.slices.slice.getRecord({ 80 + // Get the current record first (read operation, use public client) 81 + const currentRecord = await publicClient.network.slices.slice.getRecord({ 82 82 uri: sliceUri, 83 83 }); 84 84 85 + // Create user-scoped client for write operation 86 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 87 + 85 88 // Update the record with new name and domain 86 - await atprotoClient.network.slices.slice.updateRecord(sliceId, { 89 + await sessionClient.network.slices.slice.updateRecord(sliceId, { 87 90 ...currentRecord.value, 88 91 name: name.trim(), 89 92 domain: domain.trim(), ··· 115 118 } 116 119 117 120 try { 121 + // Create user-scoped client for delete operation 122 + const sessionClient = createSessionClient(context.currentUser.sessionId!); 123 + 118 124 // Delete the slice record from AT Protocol 119 - await atprotoClient.network.slices.slice.deleteRecord(sliceId); 125 + await sessionClient.network.slices.slice.deleteRecord(sliceId); 120 126 121 127 return hxRedirect(`/profile/${context.currentUser.handle}`); 122 128 } catch (_error) {
+2 -2
frontend/src/features/slices/sync/handlers.tsx
··· 3 3 import { requireAuth, withAuth } from "../../../routes/middleware.ts"; 4 4 import { getSliceClient } from "../../../utils/client.ts"; 5 5 import { buildSliceUri } from "../../../utils/at-uri.ts"; 6 - import { atprotoClient } from "../../../config.ts"; 6 + import { publicClient } from "../../../config.ts"; 7 7 import { 8 8 requireSliceAccess, 9 9 withSliceAccess, ··· 226 226 227 227 // Get slice info for domain comparison 228 228 const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId); 229 - const sliceRecord = await atprotoClient.network.slices.slice.getRecord({ 229 + const sliceRecord = await publicClient.network.slices.slice.getRecord({ 230 230 uri: sliceUri, 231 231 }); 232 232 const sliceDomain = sliceRecord.value.domain;
+8 -4
frontend/src/routes/middleware.ts
··· 1 - import { atprotoClient, sessionStore } from "../config.ts"; 1 + import { sessionStore, createSessionClient } from "../config.ts"; 2 2 import { recordBlobToCdnUrl } from "@slices/client"; 3 3 import { getSliceActor } from "../lib/api.ts"; 4 4 5 5 export interface AuthenticatedUser { 6 + sessionId?: string; 6 7 handle?: string; 7 8 sub?: string; 8 9 isAuthenticated: boolean; ··· 19 20 const currentUser = await sessionStore.getCurrentUser(req); 20 21 21 22 // If user is authenticated, try to fetch their profile data 22 - if (currentUser.isAuthenticated && currentUser.sub) { 23 + if (currentUser.isAuthenticated && currentUser.sessionId && currentUser.sub) { 24 + // Create session-scoped client 25 + const sessionClient = createSessionClient(currentUser.sessionId); 26 + 23 27 try { 24 28 // Get the user's profile from network.slices.actor.profile 25 - const profile = await getSliceActor(atprotoClient, currentUser.sub); 29 + const profile = await getSliceActor(sessionClient, currentUser.sub); 26 30 if (profile) { 27 31 currentUser.displayName = profile.displayName; 28 32 currentUser.avatar = profile.avatar; ··· 35 39 // Fallback to Bluesky profile for avatar if not found in slices profile 36 40 if (!currentUser.avatar) { 37 41 try { 38 - const profileRecords = await atprotoClient.app.bsky.actor.profile 42 + const profileRecords = await sessionClient.app.bsky.actor.profile 39 43 .getRecords({ 40 44 where: { 41 45 did: { eq: currentUser.sub },
+8 -4
frontend/src/utils/client.ts
··· 1 1 import { AtProtoClient } from "../client.ts"; 2 - import { atprotoClient } from "../config.ts"; 2 + import { createOAuthClient } from "../config.ts"; 3 3 import { buildAtUri } from "./at-uri.ts"; 4 4 5 5 interface AuthContext { 6 6 currentUser: { 7 + sessionId?: string; 7 8 sub?: string; 8 9 }; 9 10 } ··· 30 31 }); 31 32 32 33 // 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); 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 + } 36 40 }
+1 -1
packages/cli/src/auth/config.ts
··· 62 62 63 63 async logout(): Promise<void> { 64 64 await this.save({ auth: undefined }); 65 - logger.info("Logged out successfully"); 65 + console.log("Logged out successfully"); 66 66 } 67 67 68 68 getAuthHeaders(): Record<string, string> {
+17 -34
packages/cli/src/auth/device_flow.ts
··· 15 15 const config = new ConfigManager(); 16 16 await config.load(); 17 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 18 try { 24 - // Step 1: Request device authorization 25 - logger.step("📤 Requesting device authorization..."); 26 19 const authResponse = await deviceClient.requestDeviceAuthorization(scope); 27 20 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}`); 21 + console.log(`\n${authResponse.verification_uri_complete}`); 22 + console.log(`Code: ${authResponse.user_code}\n`); 32 23 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!"); 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"); 36 38 } 37 39 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 40 const tokenResponse = await deviceClient.pollForToken( 48 41 authResponse.device_code, 49 42 authResponse.interval || 5, 50 43 authResponse.expires_in, 51 44 ); 52 45 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 46 const userInfo: OAuthUserInfo = await deviceClient.getUserInfo(tokenResponse.access_token); 63 47 64 48 const expiresAt = tokenResponse.expires_in ··· 75 59 }, 76 60 }); 77 61 78 - logger.success(`👤 Logged in as: ${userInfo.did}`); 79 - logger.success("🎉 Device flow complete! You can now use the CLI to manage lexicons."); 62 + console.log(`✓ Logged in as ${userInfo.did}`); 80 63 81 64 } catch (error) { 82 65 const err = error as Error;
+2 -2
packages/cli/src/commands/codegen.ts
··· 59 59 logger.error("--slice is required"); 60 60 if (!slicesConfig.slice) { 61 61 logger.info( 62 - "💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" 62 + "Tip: Create a slices.json file with your slice URI to avoid passing --slice every time" 63 63 ); 64 64 } 65 65 console.log("\nRun 'slices codegen --help' for usage information."); ··· 109 109 logger.success(`Generated client: ${outputPath}`); 110 110 111 111 if (!excludeSlices) { 112 - logger.result("Includes network.slices XRPC client methods"); 112 + logger.info("Includes network.slices XRPC client methods"); 113 113 } 114 114 } catch (error) { 115 115 const err = error as Error;
+13 -21
packages/cli/src/commands/init.ts
··· 22 22 const projectName = parsed.name || parsed._[0] as string; 23 23 24 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>"); 25 + logger.error("Project name is required"); 26 + console.log("Usage: slices init <project-name> or slices init --name <project-name>"); 27 27 Deno.exit(1); 28 28 } 29 29 30 30 // Validate project name 31 31 if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) { 32 - console.error("Error: Project name can only contain letters, numbers, hyphens, and underscores"); 32 + logger.error("Project name can only contain letters, numbers, hyphens, and underscores"); 33 33 Deno.exit(1); 34 34 } 35 35 ··· 39 39 try { 40 40 const stat = await Deno.stat(targetDir); 41 41 if (stat.isDirectory) { 42 - console.error(`Error: Directory '${projectName}' already exists`); 42 + logger.error(`Directory '${projectName}' already exists`); 43 43 Deno.exit(1); 44 44 } 45 45 } catch { ··· 55 55 // Extract embedded templates 56 56 await extractEmbeddedTemplates(targetDir, projectName); 57 57 58 - logger.info("✅ Project created successfully!"); 58 + logger.success(`Created project: ${projectName}`); 59 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 60 + Get started: 61 + cd ${projectName} 62 + cp .env.example .env 63 + deno task dev 67 64 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 65 + Available commands: 66 + deno task dev Start development server 67 + deno task start Start production server 68 + deno fmt Format code 77 69 `); 78 70 } catch (error) { 79 71 const err = error as Error;
+11 -5
packages/cli/src/commands/lexicon.ts
··· 1 - import { importCommand } from "./lexicon/import.ts"; 1 + import { pushCommand } from "./lexicon/push.ts"; 2 + import { pullCommand } from "./lexicon/pull.ts"; 2 3 import { listCommand } from "./lexicon/list.ts"; 3 4 4 5 function showLexiconHelp() { ··· 9 10 slices lexicon <SUBCOMMAND> [OPTIONS] 10 11 11 12 SUBCOMMANDS: 12 - import Import lexicon files to your slice 13 + push Push lexicon files to your slice 14 + pull Pull lexicon files from your slice 13 15 list List lexicons in your slice 14 16 help Show this help message 15 17 ··· 17 19 -h, --help Show help information 18 20 19 21 EXAMPLES: 20 - slices lexicon import --slice at://did:plc:example/slice 22 + slices lexicon push --slice at://did:plc:example/slice 23 + slices lexicon pull --slice at://did:plc:example/slice 21 24 slices lexicon list --slice at://did:plc:example/slice 22 25 slices lexicon help 23 26 `); ··· 53 56 54 57 try { 55 58 switch (subcommand) { 56 - case "import": 57 - await importCommand(subcommandArgs, globalArgs); 59 + case "push": 60 + await pushCommand(subcommandArgs, globalArgs); 61 + break; 62 + case "pull": 63 + await pullCommand(subcommandArgs, globalArgs); 58 64 break; 59 65 case "list": 60 66 await listCommand(subcommandArgs, globalArgs);
+24 -18
packages/cli/src/commands/lexicon/import.ts packages/cli/src/commands/lexicon/push.ts
··· 13 13 } from "../../utils/lexicon.ts"; 14 14 import type { LexiconDoc } from "@slices/lexicon"; 15 15 16 - function showImportHelp() { 16 + function showPushHelp() { 17 17 console.log(` 18 - slices lexicon import - Import lexicon files to your slice 18 + slices lexicon push - Push lexicon files to your slice 19 19 20 20 USAGE: 21 - slices lexicon import [OPTIONS] 21 + slices lexicon push [OPTIONS] 22 22 23 23 OPTIONS: 24 24 --path <PATH> Directory containing lexicon files (default: ./lexicons or from slices.json) 25 25 --slice <SLICE_URI> Target slice URI (required, or from slices.json) 26 + --exclude-from-sync Exclude these lexicons from sync (sets excludedFromSync: true) 26 27 --validate-only Only validate files, don't upload 27 28 --dry-run Show what would be imported without uploading 28 29 --api-url <URL> Slices API base URL (default: https://api.slices.network or from slices.json) 29 30 -h, --help Show this help message 30 31 31 32 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 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 37 39 `); 38 40 } 39 41 ··· 50 52 validationResult: LexiconValidationResult, 51 53 sliceUri: string, 52 54 client: AtProtoClient, 53 - dryRun = false 55 + dryRun = false, 56 + excludeFromSync = false 54 57 ): Promise<ImportStats> { 55 58 const stats: ImportStats = { 56 59 attempted: 0, ··· 123 126 nsid: nsid, 124 127 definitions: JSON.stringify(lexicon.defs || lexicon.definitions), 125 128 slice: sliceUri, 129 + excludedFromSync: excludeFromSync, 126 130 }; 127 131 128 132 if (existingRecord) { ··· 175 179 } 176 180 177 181 178 - export async function importCommand(commandArgs: unknown[], _globalArgs: Record<string, unknown>): Promise<void> { 182 + export async function pushCommand(commandArgs: unknown[], _globalArgs: Record<string, unknown>): Promise<void> { 179 183 const args = parseArgs(commandArgs as string[], { 180 - boolean: ["help", "validate-only", "dry-run"], 184 + boolean: ["help", "validate-only", "dry-run", "exclude-from-sync"], 181 185 string: ["path", "slice", "api-url"], 182 186 alias: { 183 187 h: "help", ··· 185 189 }); 186 190 187 191 if (args.help) { 188 - showImportHelp(); 192 + showPushHelp(); 189 193 return; 190 194 } 191 195 ··· 198 202 if (!args["validate-only"] && !mergedConfig.slice) { 199 203 logger.error("--slice is required unless using --validate-only"); 200 204 if (!slicesConfig.slice) { 201 - logger.info("💡 Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"); 205 + logger.info("Tip: Create a slices.json file with your slice URI to avoid passing --slice every time"); 202 206 } 203 - console.log("\nRun 'slices lexicon import --help' for usage information."); 207 + console.log("\nRun 'slices lexicon push --help' for usage information."); 204 208 Deno.exit(1); 205 209 } 206 210 ··· 209 213 const apiUrl = mergedConfig.apiUrl!; 210 214 const validateOnly = args["validate-only"] as boolean; 211 215 const dryRun = args["dry-run"] as boolean; 216 + const excludeFromSync = args["exclude-from-sync"] as boolean; 212 217 213 218 const lexiconFiles = await findLexiconFiles(lexiconPath); 214 219 ··· 221 226 222 227 if (validationResult.invalidFiles > 0) { 223 228 printValidationSummary(validationResult); 224 - logger.error("Please fix validation errors before importing"); 229 + logger.error("Please fix validation errors before pushing"); 225 230 Deno.exit(1); 226 231 } 227 232 ··· 231 236 } 232 237 233 238 if (validationResult.validFiles === 0) { 234 - logger.error("No valid lexicon files to import"); 239 + logger.error("No valid lexicon files to push"); 235 240 Deno.exit(1); 236 241 } 237 242 ··· 253 258 validationResult, 254 259 sliceUri, 255 260 client, 256 - dryRun 261 + dryRun, 262 + excludeFromSync 257 263 ); 258 264 259 265 if (importStats.failed > 0) { ··· 266 272 } else { 267 273 const total = importStats.created + importStats.updated; 268 274 if (total > 0) { 269 - logger.success(`Imported ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`); 275 + logger.success(`Pushed ${total} lexicons (${importStats.created} created, ${importStats.updated} updated)`); 270 276 } else { 271 277 logger.success("All lexicons up to date"); 272 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 47 // Check if already authenticated 48 48 if (!args.force && config.isAuthenticated()) { 49 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"); 50 + console.log(`Already authenticated as ${authConfig.did}`); 51 + console.log("Use --force to login again"); 61 52 return; 62 53 } 63 54 ··· 66 57 const scope = args.scope as string; 67 58 68 59 if (args.force && config.isAuthenticated()) { 69 - logger.info("🔄 Forcing re-authentication..."); 70 60 await config.logout(); 71 61 } 72 62
+7 -34
packages/cli/src/commands/status.ts
··· 33 33 const config = new ConfigManager(); 34 34 await config.load(); 35 35 36 - console.log("\n📊 Slices CLI Status"); 37 - console.log("━━━━━━━━━━━━━━━━━━━━"); 38 - 39 36 if (config.isAuthenticated()) { 40 37 const authConfig = config.get().auth!; 41 38 42 - logger.success("✅ Authentication Status: Authenticated"); 43 - logger.info(`👤 User DID: ${authConfig.did}`); 44 - logger.info(`📡 AIP Server: ${authConfig.aipBaseUrl}`); 39 + console.log(`Authenticated as ${authConfig.did}`); 40 + console.log(`API: ${config.get().apiBaseUrl}`); 45 41 46 42 if (authConfig.expiresAt) { 47 43 const expiresIn = Math.round((authConfig.expiresAt - Date.now()) / 1000); 48 44 if (expiresIn > 0) { 49 45 const hours = Math.floor(expiresIn / 3600); 50 46 const minutes = Math.floor((expiresIn % 3600) / 60); 51 - const seconds = expiresIn % 60; 52 47 53 48 if (hours > 0) { 54 - logger.info(`⏰ Token expires in: ${hours}h ${minutes}m ${seconds}s`); 49 + console.log(`Token expires in ${hours}h ${minutes}m`); 55 50 } else if (minutes > 0) { 56 - logger.info(`⏰ Token expires in: ${minutes}m ${seconds}s`); 51 + console.log(`Token expires in ${minutes}m`); 57 52 } else { 58 - logger.info(`⏰ Token expires in: ${seconds}s`); 53 + console.log(`Token expires in ${expiresIn}s`); 59 54 } 60 55 } else { 61 - logger.warn("⚠️ Token has expired"); 62 - logger.info("💡 Run 'slices login' to re-authenticate"); 56 + console.log("Token has expired - run 'slices login' to re-authenticate"); 63 57 } 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 58 } 73 59 } 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"); 60 + console.log("Not authenticated - run 'slices login' to authenticate"); 85 61 } 86 - 87 - const configPath = `~/.config/slices/config.json`; 88 - logger.info(`⚙️ Config file: ${configPath}`); 89 62 }
+2 -2
packages/cli/src/mod.ts
··· 20 20 COMMANDS: 21 21 init Initialize a new Deno SSR project with OAuth 22 22 login Authenticate with Slices using device code flow 23 - lexicon Manage lexicons (import, list) 23 + lexicon Manage lexicons (push, pull, list) 24 24 codegen Generate TypeScript client from lexicon files 25 25 status Show authentication and configuration status 26 26 help Show this help message ··· 33 33 EXAMPLES: 34 34 slices init my-app 35 35 slices login 36 - slices lexicon import --path ./lexicons --slice at://did:plc:example/slice 36 + slices lexicon push --path ./lexicons --slice at://did:plc:example/slice 37 37 slices lexicon list --slice at://did:plc:example/slice 38 38 slices status 39 39 `);
+6 -6
packages/cli/src/utils/logger.ts
··· 26 26 27 27 info(message: string, ...args: unknown[]) { 28 28 if (this.level <= LogLevel.INFO) { 29 - console.log(" ", message, ...args); 29 + console.log("• ", message, ...args); 30 30 } 31 31 } 32 32 33 33 warn(message: string, ...args: unknown[]) { 34 34 if (this.level <= LogLevel.WARN) { 35 - console.warn(yellow(" warn"), message, ...args); 35 + console.warn(yellow("!"), message, ...args); 36 36 } 37 37 } 38 38 39 39 error(message: string, ...args: unknown[]) { 40 40 if (this.level <= LogLevel.ERROR) { 41 - console.error(red(" error"), message, ...args); 41 + console.error(red("✗"), message, ...args); 42 42 } 43 43 } 44 44 45 45 success(message: string, ...args: unknown[]) { 46 - console.log(green(" ✓"), message, ...args); 46 + console.log(green("✓"), message, ...args); 47 47 } 48 48 49 49 step(message: string, ...args: unknown[]) { 50 - console.log(cyan(" →"), message, ...args); 50 + console.log(cyan("→"), message, ...args); 51 51 } 52 52 53 53 progress(message: string, current: number, total: number) { ··· 75 75 76 76 list(items: string[]) { 77 77 items.forEach(item => { 78 - console.log(` • ${item}`); 78 + console.log(`• ${item}`); 79 79 }); 80 80 } 81 81
+23 -18
packages/client/src/mod.ts
··· 4 4 import type { OAuthClient } from "@slices/oauth"; 5 5 6 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) 7 8 export interface AuthProvider { 8 9 ensureValidToken(): Promise<{ accessToken: string; tokenType?: string }>; 10 + refreshAccessToken(): Promise<{ accessToken: string; tokenType?: string }>; 9 11 } 10 12 11 13 // Base interfaces ··· 92 94 indexedAt: string; 93 95 } 94 96 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 97 // Export these for use in generated clients 107 98 export interface SliceLevelRecordsParams<TRecord = Record<string, unknown>> { 108 99 slice: string; ··· 143 134 readonly sliceUri: string; // Make public so collection classes can access it 144 135 protected readonly authProvider?: OAuthClient | AuthProvider; 145 136 146 - constructor(baseUrl: string, sliceUri: string, authProvider?: OAuthClient | AuthProvider) { 137 + constructor( 138 + baseUrl: string, 139 + sliceUri: string, 140 + authProvider?: OAuthClient | AuthProvider 141 + ) { 147 142 this.baseUrl = baseUrl; 148 143 this.sliceUri = sliceUri; 149 144 this.authProvider = authProvider; ··· 187 182 "Authorization" 188 183 ] = `${tokens.tokenType} ${tokens.accessToken}`; 189 184 } 190 - } catch (tokenError) { 185 + } catch (_tokenError) { 191 186 // For write operations, OAuth tokens are required (excluding read endpoints that use POST) 192 187 const isReadEndpoint = 193 188 endpoint.includes(".getRecords") || ··· 240 235 !isReadEndpoint 241 236 ) { 242 237 try { 243 - // Force token refresh by calling ensureValidToken again 244 - await this.authProvider.ensureValidToken(); 238 + // Force token refresh by calling refreshAccessToken 239 + await this.authProvider.refreshAccessToken(); 245 240 // Retry the request once with refreshed tokens 246 241 return this.makeRequestWithRetry(endpoint, method, params, true); 247 242 } catch (_refreshError) { ··· 261 256 } else if (errorBody?.error) { 262 257 errorMessage += ` - ${errorBody.error}`; 263 258 } 264 - 265 259 } catch { 266 260 // If we can't parse the response body, just use the status message 267 261 } ··· 303 297 ); 304 298 } 305 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 + 306 311 async uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> { 307 312 return this.uploadBlobWithRetry(request, false); 308 313 } ··· 335 340 // Handle 401 Unauthorized - attempt token refresh and retry once 336 341 if (response.status === 401 && !isRetry && this.authProvider) { 337 342 try { 338 - // Force token refresh by calling ensureValidToken again 339 - await this.authProvider.ensureValidToken(); 343 + // Force token refresh by calling refreshAccessToken 344 + await this.authProvider.refreshAccessToken(); 340 345 // Retry the request once with refreshed tokens 341 346 return this.uploadBlobWithRetry(request, true); 342 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 37 38 38 ### Standard OAuth Flow (Web Applications) 39 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 + 40 42 ```typescript 41 43 import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 42 44 43 45 // Set up storage 44 46 const storage = new SQLiteOAuthStorage("oauth.db"); 45 47 46 - // Create OAuth client 47 - const client = new OAuthClient({ 48 + // OAuth configuration 49 + const config = { 48 50 clientId: "your-client-id", 49 51 clientSecret: "your-client-secret", 50 52 authBaseUrl: "https://auth.example.com", 51 53 redirectUri: "http://localhost:8000/oauth/callback", 52 54 scopes: ["atproto"], 53 - }, storage); 55 + }; 54 56 55 - // Start authorization flow 56 - const result = await client.authorize({ loginHint: "user.bsky.social" }); 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" }); 57 60 // Redirect user to result.authorizationUrl 58 61 59 62 // Handle callback 60 - const tokens = await client.handleCallback({ code, state }); 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"); 61 93 ``` 62 94 63 95 ### Device Authorization Grant (CLI Applications) ··· 158 190 try { 159 191 const tokens = await this.client.pollForTokens(deviceAuth); 160 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 + 161 198 return tokens; 162 199 } catch (error) { 163 200 if (error.message.includes("expired")) { ··· 185 222 186 223 ### Token Management 187 224 188 - Both OAuth flows support automatic token refresh: 225 + Both OAuth flows support automatic token refresh. The client handles token refresh automatically: 189 226 190 227 ```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 - } 228 + // The client ensures tokens are valid before making requests 229 + const sessionClient = new OAuthClient(config, storage, sessionId); 196 230 197 - // Get user info 198 - const userInfo = await client.getUserInfo(tokens.access_token); 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(); 199 236 console.log("Authenticated as:", userInfo.sub); 237 + 238 + // Manual refresh if needed 239 + const refreshedTokens = await sessionClient.refreshAccessToken(); 200 240 ``` 201 241 202 242 ### Storage Backends 203 243 204 - The library provides pluggable storage backends: 244 + The library provides pluggable storage backends. All storage backends store tokens by `sessionId`: 205 245 206 246 ```typescript 207 247 // SQLite storage (persistent) ··· 210 250 211 251 // Deno KV storage (persistent) 212 252 import { DenoKVOAuthStorage } from "@slices/oauth"; 213 - const kvStorage = new DenoKVOAuthStorage(); 253 + const kvStorage = new DenoKVOAuthStorage(await Deno.openKv()); 214 254 215 - // In-memory storage (for testing) 216 - import { MemoryOAuthStorage } from "@slices/oauth"; 217 - const memoryStorage = new MemoryOAuthStorage(); 255 + // Use with OAuthClient (requires sessionId) 256 + const client = new OAuthClient(config, storage, sessionId); 257 + ``` 218 258 219 - // Use with OAuthClient 220 - const client = new OAuthClient(config, storage); 221 - ``` 259 + **Note:** Tokens are stored with `sessionId` as the key, enabling multiple independent sessions per user.
+77 -31
packages/oauth/src/client.ts
··· 11 11 export class OAuthClient { 12 12 private config: OAuthConfig; 13 13 private storage: OAuthStorage; 14 + private sessionId: string; 14 15 private refreshPromise?: Promise<void>; 15 - private forceRefresh = false; // Flag to force refresh regardless of expiry 16 16 17 - constructor(config: OAuthConfig, storage: OAuthStorage) { 17 + constructor(config: OAuthConfig, storage: OAuthStorage, sessionId: string) { 18 18 this.config = config; 19 19 this.storage = storage; 20 + this.sessionId = sessionId; 20 21 } 21 22 22 23 async authorize(params: { ··· 91 92 ); 92 93 93 94 const tokens = this.transformTokenResponse(tokenResponse); 94 - await this.storage.setTokens(tokens); 95 + 95 96 await this.storage.clearState(params.state); 96 97 97 98 return tokens; 98 99 } 99 100 100 101 async refreshAccessToken(): Promise<OAuthTokens> { 101 - const tokens = await this.storage.getTokens(); 102 + const tokens = await this.storage.getTokens(this.sessionId); 102 103 if (!tokens?.refreshToken) { 103 104 throw new Error("No refresh token available"); 104 105 } ··· 117 118 ); 118 119 119 120 const newTokens = this.transformTokenResponse(tokenResponse); 120 - await this.storage.setTokens(newTokens); 121 + await this.storage.setTokens(newTokens, this.sessionId); 121 122 return newTokens; 122 123 } catch (error) { 123 - await this.storage.clearTokens(); 124 + await this.storage.clearTokens(this.sessionId); 124 125 throw new Error(`Failed to refresh token: ${error}`); 125 126 } 126 127 } 127 128 128 129 async ensureValidToken(): Promise<OAuthTokens> { 129 - const tokens = await this.storage.getTokens(); 130 + const tokens = await this.storage.getTokens(this.sessionId); 130 131 131 132 if (!tokens) { 132 133 throw new Error("No access token available. Please authenticate first."); 133 134 } 134 135 135 - // Check if we need to refresh (either expired or force refresh flag is set) 136 - if (!this.forceRefresh && !this.isTokenExpired(tokens)) { 136 + // Check if token is still valid 137 + if (!this.isTokenExpired(tokens)) { 137 138 return tokens; 138 139 } 139 140 ··· 143 144 ); 144 145 } 145 146 147 + // Check if a refresh is already in progress 146 148 if (this.refreshPromise) { 147 149 await this.refreshPromise; 148 - const refreshedTokens = await this.storage.getTokens(); 150 + const refreshedTokens = await this.storage.getTokens(this.sessionId); 149 151 if (!refreshedTokens) { 150 152 throw new Error("Failed to refresh tokens"); 151 153 } 152 - this.forceRefresh = false; // Reset flag after successful refresh 153 154 return refreshedTokens; 154 155 } 155 156 157 + // Start a new refresh 156 158 this.refreshPromise = this.refreshAccessToken().then(() => undefined); 159 + 157 160 try { 158 161 await this.refreshPromise; 159 - const refreshedTokens = await this.storage.getTokens(); 162 + const refreshedTokens = await this.storage.getTokens(this.sessionId); 160 163 if (!refreshedTokens) { 161 164 throw new Error("Failed to refresh tokens"); 162 165 } 163 - this.forceRefresh = false; // Reset flag after successful refresh 164 166 return refreshedTokens; 165 167 } finally { 166 168 this.refreshPromise = undefined; ··· 168 170 } 169 171 170 172 async getUserInfo(): Promise<OAuthUserInfo | null> { 171 - const isAuthenticated = await this.isAuthenticated(); 172 - if (!isAuthenticated) { 173 + const tokens = await this.storage.getTokens(this.sessionId); 174 + if (!tokens) { 173 175 return null; 174 176 } 175 177 176 178 try { 177 - const userInfo = await this.makeRequest<OAuthUserInfo>( 179 + const userInfo = await this.makeRequestWithTokens<OAuthUserInfo>( 178 180 "oauth/userinfo", 179 181 "GET", 180 - undefined, 181 - true 182 + tokens, 183 + undefined 182 184 ); 183 185 return userInfo; 184 186 } catch (error) { ··· 188 190 } 189 191 190 192 async isAuthenticated(): Promise<boolean> { 191 - const tokens = await this.storage.getTokens(); 193 + const tokens = await this.storage.getTokens(this.sessionId); 192 194 return !!tokens?.accessToken; 193 195 } 194 196 195 197 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; 198 + await this.storage.clearTokens(this.sessionId); 206 199 } 207 200 208 201 async getAuthenticationInfo(): Promise<{ ··· 210 203 expiresAt?: number; 211 204 scope?: string; 212 205 }> { 213 - const tokens = await this.storage.getTokens(); 206 + const tokens = await this.storage.getTokens(this.sessionId); 214 207 return { 215 208 isAuthenticated: !!tokens?.accessToken, 216 209 expiresAt: tokens?.expiresAt, ··· 241 234 }; 242 235 } 243 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 + 244 286 private async makeRequest<T = unknown>( 245 287 endpoint: string, 246 288 method: "GET" | "POST", ··· 303 345 } 304 346 305 347 async getTokens(): Promise<OAuthTokens | null> { 306 - return await this.storage.getTokens(); 348 + return await this.storage.getTokens(this.sessionId); 349 + } 350 + 351 + getSessionId(): string { 352 + return this.sessionId; 307 353 } 308 354 }
+6 -6
packages/oauth/src/storage/deno-kv.ts
··· 8 8 export class DenoKVOAuthStorage implements OAuthStorage { 9 9 constructor(private kv: Deno.Kv) {} 10 10 11 - async getTokens(): Promise<OAuthTokens | null> { 12 - const result = await this.kv.get<OAuthTokens>(["oauth_tokens"]); 11 + async getTokens(sessionId: string): Promise<OAuthTokens | null> { 12 + const result = await this.kv.get<OAuthTokens>(["oauth_tokens", sessionId]); 13 13 return result.value; 14 14 } 15 15 16 - async setTokens(tokens: OAuthTokens): Promise<void> { 16 + async setTokens(tokens: OAuthTokens, sessionId: string): Promise<void> { 17 17 const expirationMs = 30 * 24 * 60 * 60 * 1000; // 30 days 18 - await this.kv.set(["oauth_tokens"], tokens, { expireIn: expirationMs }); 18 + await this.kv.set(["oauth_tokens", sessionId], tokens, { expireIn: expirationMs }); 19 19 } 20 20 21 - async clearTokens(): Promise<void> { 22 - await this.kv.delete(["oauth_tokens"]); 21 + async clearTokens(sessionId: string): Promise<void> { 22 + await this.kv.delete(["oauth_tokens", sessionId]); 23 23 } 24 24 25 25 async getState(state: string): Promise<string | null> {
+16 -14
packages/oauth/src/storage/sqlite.ts
··· 14 14 this.db.exec(` 15 15 CREATE TABLE IF NOT EXISTS oauth_tokens ( 16 16 id INTEGER PRIMARY KEY, 17 + session_id TEXT, 17 18 access_token TEXT NOT NULL, 18 19 token_type TEXT NOT NULL, 19 20 expires_at INTEGER, 20 21 refresh_token TEXT, 21 22 scope TEXT, 22 - created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000) 23 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000), 24 + UNIQUE(session_id) 23 25 ) 24 26 `); 25 27 ··· 38 40 `); 39 41 } 40 42 41 - getTokens(): Promise<OAuthTokens | null> { 43 + getTokens(sessionId: string): Promise<OAuthTokens | null> { 42 44 const stmt = this.db.prepare(` 43 45 SELECT access_token, token_type, expires_at, refresh_token, scope 44 - FROM oauth_tokens 45 - ORDER BY created_at DESC 46 + FROM oauth_tokens 47 + WHERE session_id = ? 46 48 LIMIT 1 47 49 `); 48 - 49 - const row = stmt.get() as { 50 + 51 + const row = stmt.get(sessionId) as { 50 52 access_token: string; 51 53 token_type: string; 52 54 expires_at?: number; ··· 64 66 }); 65 67 } 66 68 67 - async setTokens(tokens: OAuthTokens): Promise<void> { 68 - // Clear existing tokens first 69 - await this.clearTokens(); 69 + async setTokens(tokens: OAuthTokens, sessionId: string): Promise<void> { 70 + await this.clearTokens(sessionId); 70 71 71 72 const stmt = this.db.prepare(` 72 - INSERT INTO oauth_tokens (access_token, token_type, expires_at, refresh_token, scope) 73 - VALUES (?, ?, ?, ?, ?) 73 + INSERT INTO oauth_tokens (session_id, access_token, token_type, expires_at, refresh_token, scope) 74 + VALUES (?, ?, ?, ?, ?, ?) 74 75 `); 75 76 76 77 stmt.run( 78 + sessionId, 77 79 tokens.accessToken, 78 80 tokens.tokenType, 79 81 tokens.expiresAt || null, ··· 82 84 ); 83 85 } 84 86 85 - clearTokens(): Promise<void> { 86 - const stmt = this.db.prepare("DELETE FROM oauth_tokens"); 87 - stmt.run(); 87 + clearTokens(sessionId: string): Promise<void> { 88 + const stmt = this.db.prepare("DELETE FROM oauth_tokens WHERE session_id = ?"); 89 + stmt.run(sessionId); 88 90 return Promise.resolve(); 89 91 } 90 92
+4 -4
packages/oauth/src/types.ts
··· 56 56 } 57 57 58 58 export interface OAuthStorage { 59 - getTokens(): Promise<OAuthTokens | null>; 60 - setTokens(tokens: OAuthTokens): Promise<void>; 61 - clearTokens(): Promise<void>; 62 - 59 + getTokens(sessionId: string): Promise<OAuthTokens | null>; 60 + setTokens(tokens: OAuthTokens, sessionId: string): Promise<void>; 61 + clearTokens(sessionId: string): Promise<void>; 62 + 63 63 getState(state: string): Promise<string | null>; 64 64 setState(state: string, codeVerifier: string): Promise<void>; 65 65 clearState(state: string): Promise<void>;
+69 -17
packages/session/README.md
··· 47 47 48 48 ```typescript 49 49 import { SessionStore, SQLiteAdapter, withOAuthSession } from "@slices/session"; 50 - import { OAuthClient } from "@slices/oauth"; 50 + import { OAuthClient, SQLiteOAuthStorage } from "@slices/oauth"; 51 51 52 52 const sessionStore = new SessionStore({ 53 53 adapter: new SQLiteAdapter("./sessions.db") 54 54 }); 55 55 56 - const oauthClient = new OAuthClient({ 56 + const oauthStorage = new SQLiteOAuthStorage("./oauth.db"); 57 + const oauthConfig = { 57 58 clientId: "your-client-id", 58 59 clientSecret: "your-client-secret", 59 - authBaseUrl: "https://auth.example.com" 60 - }); 60 + authBaseUrl: "https://auth.example.com", 61 + redirectUri: "http://localhost:8000/oauth/callback", 62 + scopes: ["atproto"], 63 + }; 61 64 62 - const oauthSessions = withOAuthSession(sessionStore, oauthClient); 65 + const oauthSessions = withOAuthSession( 66 + sessionStore, 67 + oauthConfig, 68 + oauthStorage, 69 + { 70 + autoRefresh: true 71 + } 72 + ); 63 73 64 - // Create OAuth session 65 - const sessionId = await oauthSessions.createOAuthSession(request); 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); 66 80 67 81 // Get session with auto token refresh 68 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(); 69 87 ``` 70 88 71 89 ### Storage Adapters ··· 130 148 131 149 ### OAuthSessionManager 132 150 133 - OAuth-enabled session management. 151 + OAuth-enabled session management with session-scoped tokens. 134 152 135 153 ```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 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 + } 143 166 } 144 - }); 167 + ); 145 168 ``` 146 169 147 170 #### Methods 148 171 149 - - `createOAuthSession(request)` - Create session with OAuth tokens 172 + - `createOAuthSession(tokens)` - Create session with OAuth tokens (fetches user info, stores tokens by sessionId) 150 173 - `getOAuthSession(sessionId)` - Get session with token refresh 151 174 - `logout(sessionId)` - OAuth logout and session cleanup 152 175 - `hasValidOAuthTokens(sessionId)` - Check token validity 153 176 - `getAccessToken(sessionId)` - Get access token for API calls 154 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 + 155 188 ### Session Data Structure 156 189 157 190 ```typescript ··· 204 237 }); 205 238 ``` 206 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 + 207 258 ## Security 208 259 209 260 - Sessions are stored with secure, httpOnly cookies by default ··· 211 262 - CSRF protection through SameSite cookies 212 263 - Secure session ID generation using crypto.randomUUID() 213 264 - Optional token refresh to keep OAuth sessions valid 265 + - Session-scoped tokens prevent multi-device conflicts 214 266 215 267 ## License 216 268
+73 -30
packages/session/src/oauth-integration.ts
··· 1 1 import type { SessionStore } from "./store.ts"; 2 2 import type { SessionData } from "./types.ts"; 3 - import type { OAuthClient, OAuthTokens } from "@slices/oauth"; 3 + import { 4 + OAuthClient, 5 + type OAuthTokens, 6 + type OAuthConfig, 7 + type OAuthStorage, 8 + } from "@slices/oauth"; 4 9 5 10 export interface OAuthSessionOptions { 6 11 sessionStore: SessionStore; 7 - oauthClient: OAuthClient; 12 + oauthConfig: OAuthConfig; 13 + oauthStorage: OAuthStorage; 8 14 autoRefresh?: boolean; 9 15 onTokenRefresh?: (sessionId: string, tokens: OAuthTokens) => Promise<void>; 10 16 onLogout?: (sessionId: string) => Promise<void>; ··· 17 23 this.options = options; 18 24 } 19 25 20 - // Create a session linked to OAuth (no token duplication) 21 - async createOAuthSession(): Promise<string | null> { 26 + async createOAuthSession(tokens: OAuthTokens): Promise<string | null> { 22 27 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 + // 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 + ); 28 40 29 41 // Get user info from OAuth 30 - const userInfo = await this.options.oauthClient.getUserInfo(); 42 + const userInfo = await tempClient.getUserInfo(); 31 43 if (!userInfo) { 44 + await this.options.oauthStorage.clearTokens(tempClient.getSessionId()); 32 45 return null; 33 46 } 34 47 35 - // Create session with user data only (no token storage) 48 + // Clean up temp tokens 49 + await this.options.oauthStorage.clearTokens(tempClient.getSessionId()); 50 + 51 + // Create session FIRST to get sessionId 36 52 const sessionId = await this.options.sessionStore.createSession( 37 53 userInfo.sub, 38 54 userInfo.name, ··· 41 57 ...userInfo, 42 58 handle: userInfo.name, 43 59 }, 44 - // OAuth tokens are managed separately by @slices/oauth 45 - // No token duplication here 46 60 } 47 61 ); 62 + 63 + // NOW store tokens by sessionId 64 + await this.options.oauthStorage.setTokens(tokens, sessionId); 48 65 49 66 return sessionId; 50 67 } catch (error) { ··· 53 70 } 54 71 } 55 72 56 - // Get session (tokens managed separately by OAuth client) 57 73 async getOAuthSession(sessionId: string): Promise<SessionData | null> { 58 74 const session = await this.options.sessionStore.getSession(sessionId); 59 75 if (!session) { ··· 63 79 // Auto-refresh is handled by OAuth client, not session storage 64 80 if (this.options.autoRefresh) { 65 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 + 66 89 // This ensures tokens are fresh in OAuth storage 67 - await this.options.oauthClient.ensureValidToken(); 90 + await sessionClient.ensureValidToken(); 68 91 69 92 // Call refresh callback if provided 70 93 if (this.options.onTokenRefresh) { 71 - const tokens = await this.options.oauthClient.ensureValidToken(); 72 - await this.options.onTokenRefresh(sessionId, tokens); 94 + const tokens = await sessionClient.getTokens(); 95 + if (tokens) { 96 + await this.options.onTokenRefresh(sessionId, tokens); 97 + } 73 98 } 74 99 } catch (error) { 75 100 console.error("Failed to refresh OAuth tokens:", error); ··· 80 105 return session; 81 106 } 82 107 83 - // Logout and cleanup OAuth session 84 108 async logout(sessionId: string): Promise<void> { 85 109 try { 86 - // Call OAuth logout 87 - await this.options.oauthClient.logout(); 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(); 88 117 89 118 // Delete session 90 119 await this.options.sessionStore.deleteSession(sessionId); ··· 100 129 } 101 130 } 102 131 103 - // Check if session has valid OAuth tokens (via OAuth client) 104 132 async hasValidOAuthTokens(sessionId: string): Promise<boolean> { 105 133 const session = await this.getOAuthSession(sessionId); 106 134 if (!session) return false; 107 135 108 136 try { 137 + // Create session-scoped OAuth client 138 + const sessionClient = new OAuthClient( 139 + this.options.oauthConfig, 140 + this.options.oauthStorage, 141 + sessionId 142 + ); 109 143 // Let OAuth client determine token validity 110 - const tokens = await this.options.oauthClient.ensureValidToken(); 144 + const tokens = await sessionClient.ensureValidToken(); 111 145 return !!tokens.accessToken; 112 146 } catch (_error) { 113 147 return false; 114 148 } 115 149 } 116 150 117 - // Get OAuth access token for API calls (from OAuth client, not session) 118 151 async getAccessToken(sessionId: string): Promise<string | null> { 119 152 const session = await this.getOAuthSession(sessionId); 120 153 if (!session) return null; 121 154 122 155 try { 156 + // Create session-scoped OAuth client 157 + const sessionClient = new OAuthClient( 158 + this.options.oauthConfig, 159 + this.options.oauthStorage, 160 + sessionId 161 + ); 123 162 // Get fresh tokens from OAuth client 124 - const tokens = await this.options.oauthClient.ensureValidToken(); 163 + const tokens = await sessionClient.ensureValidToken(); 125 164 return tokens.accessToken || null; 126 165 } catch (_error) { 127 166 return null; ··· 132 171 // Convenience function to create an OAuth-enabled session store 133 172 export function withOAuthSession( 134 173 sessionStore: SessionStore, 135 - oauthClient: OAuthClient, 136 - options: Partial<OAuthSessionOptions> = {} 174 + oauthConfig: OAuthConfig, 175 + oauthStorage: OAuthStorage, 176 + options: Partial< 177 + Omit<OAuthSessionOptions, "sessionStore" | "oauthConfig" | "oauthStorage"> 178 + > = {} 137 179 ): OAuthSessionManager { 138 180 return new OAuthSessionManager({ 139 181 sessionStore, 140 - oauthClient, 182 + oauthConfig, 183 + oauthStorage, 141 184 autoRefresh: true, 142 185 ...options, 143 186 }); ··· 178 221 }; 179 222 }, 180 223 181 - // Login handler 182 - async login(): Promise<Response> { 183 - const sessionId = await manager.createOAuthSession(); 224 + // Login handler - expects tokens from OAuth callback 225 + async login(tokens: OAuthTokens): Promise<Response> { 226 + const sessionId = await manager.createOAuthSession(tokens); 184 227 if (!sessionId) { 185 228 return new Response("Authentication failed", { status: 401 }); 186 229 }
+2 -1
packages/session/src/store.ts
··· 117 117 // Get user info from session 118 118 async getCurrentUser(request: Request): Promise<SessionUser> { 119 119 const session = await this.getSessionFromRequest(request); 120 - 120 + 121 121 if (!session) { 122 122 return { 123 123 isAuthenticated: false, ··· 125 125 } 126 126 127 127 return { 128 + sessionId: session.sessionId, 128 129 sub: session.userId, 129 130 handle: session.handle, 130 131 isAuthenticated: session.isAuthenticated,
+1
packages/session/src/types.ts
··· 10 10 } 11 11 12 12 export interface SessionUser { 13 + sessionId?: string; 13 14 sub?: string; 14 15 handle?: string; 15 16 isAuthenticated: boolean;