+40
-26
frontend/src/config.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
-1
packages/cli/src/auth/config.ts
+17
-34
packages/cli/src/auth/device_flow.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
packages/lexicon-intellisense/wasm/lexicon_validator_bg.wasm
This is a binary file and will not be displayed.
+60
-22
packages/oauth/README.md
+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
+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
+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
+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
+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
+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
+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
+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,