.claude
scripts
src
lib
routes
(oauth)
oauth
oauth-client-metadata.json
oauth
···
5
5
"WebFetch(domain:raw.githubusercontent.com)",
6
6
"WebFetch(domain:github.com)",
7
7
"mcp__plugin_svelte_svelte__svelte-autofixer",
8
8
-
"mcp__plugin_svelte_svelte__get-documentation"
8
8
+
"mcp__plugin_svelte_svelte__get-documentation",
9
9
+
"Bash(pnpm check:*)",
10
10
+
"Bash(pnpm env:generate-secret:*)"
9
11
]
10
12
}
11
13
}
···
1
1
-
CLIENT_ASSERTION_KEY={"kty":"EC","crv":"P-256","x":"...","y":"...","d":"...","kid":"main-key","alg":"ES256"}
···
1
1
+
# Generate both with: pnpm env:setup-dev
2
2
+
CLIENT_ASSERTION_KEY=
3
3
+
COOKIE_SECRET=
4
4
+
5
5
+
# Set to your tunnel URL to use a confidential client in dev
6
6
+
# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com
···
1
1
+
# Add AT Protocol OAuth to SvelteKit + Cloudflare Workers
2
2
+
3
3
+
You are adding AT Protocol OAuth authentication to an existing SvelteKit project deployed on Cloudflare Workers. This uses server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session storage, and SvelteKit remote functions.
4
4
+
5
5
+
## Prerequisites
6
6
+
7
7
+
The project must already use:
8
8
+
- SvelteKit with `@sveltejs/adapter-cloudflare`
9
9
+
- A `wrangler.jsonc` (or `wrangler.toml`) config
10
10
+
11
11
+
## Step 0: Ask the user
12
12
+
13
13
+
Before making any changes, ask the user these questions:
14
14
+
15
15
+
1. **UI**: Should I add a login UI?
16
16
+
- **`foxui`** — Use `@foxui/social` login modal (polished, recommended)
17
17
+
- **`basic`** — Simple login/logout page at `/user` route (uses Tailwind if available)
18
18
+
- **`none`** — Backend only, no UI (you'll build your own)
19
19
+
20
20
+
2. **Collections**: What AT Protocol collections should your app write to? (e.g. `xyz.statusphere.status`, `app.bsky.feed.like`). Leave empty for read-only.
21
21
+
22
22
+
3. **Blobs**: Does the app need to upload blobs (images, video)? If yes, what types? (e.g. `image/*`, `video/*`)
23
23
+
24
24
+
Use the answers to customize `settings.ts` (marked with `CUSTOMIZE` below) and choose which UI dependencies/files to create.
25
25
+
26
26
+
## Step 1: Install dependencies
27
27
+
28
28
+
Always install:
29
29
+
30
30
+
```sh
31
31
+
pnpm add valibot
32
32
+
pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky
33
33
+
```
34
34
+
35
35
+
If UI choice is `foxui`:
36
36
+
37
37
+
```sh
38
38
+
pnpm add @foxui/social @foxui/core
39
39
+
```
40
40
+
41
41
+
## Step 2: Create files
42
42
+
43
43
+
Create all of the following files. These go into `src/lib/atproto/` and `src/routes/(oauth)/`.
44
44
+
45
45
+
### `src/lib/atproto/settings.ts`
46
46
+
47
47
+
Fill in `collections` and `blobs` from the user's answers. If no collections were specified, use an empty array.
48
48
+
49
49
+
```ts
50
50
+
import { dev } from '$app/environment';
51
51
+
52
52
+
type Permissions = {
53
53
+
collections: readonly string[];
54
54
+
rpc: Record<string, string | string[]>;
55
55
+
blobs: readonly string[];
56
56
+
};
57
57
+
58
58
+
export const permissions = {
59
59
+
// CUSTOMIZE: add the user's collections
60
60
+
collections: [],
61
61
+
62
62
+
// CUSTOMIZE: add any authenticated RPC requests needed
63
63
+
rpc: {},
64
64
+
65
65
+
// CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*'])
66
66
+
blobs: []
67
67
+
} as const satisfies Permissions;
68
68
+
69
69
+
type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
70
70
+
71
71
+
export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
72
72
+
73
73
+
// PDS to use for signup (change to preferred PDS)
74
74
+
const devPDS = 'https://bsky.social/';
75
75
+
const prodPDS = 'https://bsky.social/';
76
76
+
export const signUpPDS = dev ? devPDS : prodPDS;
77
77
+
78
78
+
export const REDIRECT_PATH = '/oauth/callback';
79
79
+
80
80
+
export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
81
81
+
```
82
82
+
83
83
+
### `src/lib/atproto/metadata.ts`
84
84
+
85
85
+
```ts
86
86
+
import { permissions } from './settings';
87
87
+
88
88
+
function constructScope() {
89
89
+
const parts: string[] = ['atproto'];
90
90
+
91
91
+
for (const collection of permissions.collections) {
92
92
+
parts.push('repo:' + collection);
93
93
+
}
94
94
+
95
95
+
for (const [key, value] of Object.entries(permissions.rpc ?? {})) {
96
96
+
const lxms = Array.isArray(value) ? value : [value];
97
97
+
for (const lxm of lxms) {
98
98
+
parts.push('rpc?lxm=' + lxm + '&aud=' + key);
99
99
+
}
100
100
+
}
101
101
+
102
102
+
if (permissions.blobs.length > 0) {
103
103
+
parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'));
104
104
+
}
105
105
+
106
106
+
return parts.join(' ');
107
107
+
}
108
108
+
109
109
+
export const scope = constructScope();
110
110
+
```
111
111
+
112
112
+
### `src/lib/atproto/auth.svelte.ts`
113
113
+
114
114
+
```ts
115
115
+
import { AppBskyActorDefs } from '@atcute/bluesky';
116
116
+
import type { ActorIdentifier, Did } from '@atcute/lexicons';
117
117
+
import { page } from '$app/state';
118
118
+
119
119
+
export const user = {
120
120
+
get profile() {
121
121
+
return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null;
122
122
+
},
123
123
+
get isLoggedIn() {
124
124
+
return !!page.data?.did;
125
125
+
},
126
126
+
get did() {
127
127
+
return (page.data?.did as Did | null) ?? null;
128
128
+
}
129
129
+
};
130
130
+
131
131
+
export async function login(handle: string) {
132
132
+
if (handle.startsWith('did:')) {
133
133
+
if (handle.length < 6) throw new Error('DID must be at least 6 characters');
134
134
+
} else if (handle.includes('.') && handle.length > 3) {
135
135
+
handle = (handle.startsWith('@') ? handle.slice(1) : handle) as ActorIdentifier;
136
136
+
if (handle.length < 4) throw new Error('Handle must be at least 4 characters');
137
137
+
} else if (handle.length > 3) {
138
138
+
handle = ((handle.startsWith('@') ? handle.slice(1) : handle) +
139
139
+
'.bsky.social') as ActorIdentifier;
140
140
+
} else {
141
141
+
throw new Error('Please provide a valid handle or DID.');
142
142
+
}
143
143
+
144
144
+
const { oauthLogin } = await import('./server/oauth.remote');
145
145
+
const { url } = await oauthLogin({ handle });
146
146
+
window.location.assign(url);
147
147
+
148
148
+
await new Promise((_resolve, reject) => {
149
149
+
window.addEventListener('pageshow', () => reject(new Error('user aborted the login request')), {
150
150
+
once: true
151
151
+
});
152
152
+
});
153
153
+
}
154
154
+
155
155
+
export async function signup() {
156
156
+
const { oauthLogin } = await import('./server/oauth.remote');
157
157
+
const { url } = await oauthLogin({ signup: true });
158
158
+
window.location.assign(url);
159
159
+
160
160
+
await new Promise((_resolve, reject) => {
161
161
+
window.addEventListener('pageshow', () => reject(new Error('user aborted the signup request')), {
162
162
+
once: true
163
163
+
});
164
164
+
});
165
165
+
}
166
166
+
167
167
+
export async function logout() {
168
168
+
try {
169
169
+
const { oauthLogout } = await import('./server/oauth.remote');
170
170
+
await oauthLogout();
171
171
+
} catch (e) {
172
172
+
console.error('Error logging out:', e);
173
173
+
}
174
174
+
175
175
+
window.location.href = '/';
176
176
+
}
177
177
+
```
178
178
+
179
179
+
### `src/lib/atproto/methods.ts`
180
180
+
181
181
+
```ts
182
182
+
import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons';
183
183
+
import { user } from './auth.svelte';
184
184
+
import { DOH_RESOLVER, type AllowedCollection } from './settings';
185
185
+
import {
186
186
+
CompositeDidDocumentResolver,
187
187
+
CompositeHandleResolver,
188
188
+
DohJsonHandleResolver,
189
189
+
PlcDidDocumentResolver,
190
190
+
WebDidDocumentResolver,
191
191
+
WellKnownHandleResolver
192
192
+
} from '@atcute/identity-resolver';
193
193
+
import { Client, simpleFetchHandler } from '@atcute/client';
194
194
+
import { type AppBskyActorDefs } from '@atcute/bluesky';
195
195
+
196
196
+
export type Collection = `${string}.${string}.${string}`;
197
197
+
import * as TID from '@atcute/tid';
198
198
+
199
199
+
export function parseUri(uri: string) {
200
200
+
const parts = parseResourceUri(uri);
201
201
+
if (!parts.ok) return;
202
202
+
return parts.value;
203
203
+
}
204
204
+
205
205
+
export async function resolveHandle({ handle }: { handle: Handle }) {
206
206
+
const handleResolver = new CompositeHandleResolver({
207
207
+
methods: {
208
208
+
dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }),
209
209
+
http: new WellKnownHandleResolver()
210
210
+
}
211
211
+
});
212
212
+
213
213
+
const data = await handleResolver.resolve(handle);
214
214
+
return data;
215
215
+
}
216
216
+
217
217
+
const didResolver = new CompositeDidDocumentResolver({
218
218
+
methods: {
219
219
+
plc: new PlcDidDocumentResolver(),
220
220
+
web: new WebDidDocumentResolver()
221
221
+
}
222
222
+
});
223
223
+
224
224
+
export async function getPDS(did: Did) {
225
225
+
const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
226
226
+
if (!doc.service) throw new Error('No PDS found');
227
227
+
for (const service of doc.service) {
228
228
+
if (service.id === '#atproto_pds') {
229
229
+
return service.serviceEndpoint.toString();
230
230
+
}
231
231
+
}
232
232
+
}
233
233
+
234
234
+
export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
235
235
+
data ??= {};
236
236
+
data.did ??= user.did ?? undefined;
237
237
+
238
238
+
if (!data.did) throw new Error('Error getting detailed profile: no did');
239
239
+
240
240
+
data.client ??= new Client({
241
241
+
handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
242
242
+
});
243
243
+
244
244
+
const response = await data.client.get('app.bsky.actor.getProfile', {
245
245
+
params: { actor: data.did }
246
246
+
});
247
247
+
248
248
+
if (!response.ok) return;
249
249
+
250
250
+
return response.data;
251
251
+
}
252
252
+
253
253
+
export async function getClient({ did }: { did: Did }) {
254
254
+
const pds = await getPDS(did);
255
255
+
if (!pds) throw new Error('PDS not found');
256
256
+
257
257
+
const client = new Client({
258
258
+
handler: simpleFetchHandler({ service: pds })
259
259
+
});
260
260
+
261
261
+
return client;
262
262
+
}
263
263
+
264
264
+
export async function listRecords({
265
265
+
did,
266
266
+
collection,
267
267
+
cursor,
268
268
+
limit = 100,
269
269
+
client
270
270
+
}: {
271
271
+
did?: Did;
272
272
+
collection: `${string}.${string}.${string}`;
273
273
+
cursor?: string;
274
274
+
limit?: number;
275
275
+
client?: Client;
276
276
+
}) {
277
277
+
did ??= user.did ?? undefined;
278
278
+
if (!collection) {
279
279
+
throw new Error('Missing parameters for listRecords');
280
280
+
}
281
281
+
if (!did) {
282
282
+
throw new Error('Missing did for listRecords');
283
283
+
}
284
284
+
285
285
+
client ??= await getClient({ did });
286
286
+
287
287
+
const allRecords = [];
288
288
+
289
289
+
let currentCursor = cursor;
290
290
+
do {
291
291
+
const response = await client.get('com.atproto.repo.listRecords', {
292
292
+
params: {
293
293
+
repo: did,
294
294
+
collection,
295
295
+
limit: !limit || limit > 100 ? 100 : limit,
296
296
+
cursor: currentCursor
297
297
+
}
298
298
+
});
299
299
+
300
300
+
if (!response.ok) {
301
301
+
return allRecords;
302
302
+
}
303
303
+
304
304
+
allRecords.push(...response.data.records);
305
305
+
currentCursor = response.data.cursor;
306
306
+
} while (currentCursor && (!limit || allRecords.length < limit));
307
307
+
308
308
+
return allRecords;
309
309
+
}
310
310
+
311
311
+
export async function getRecord({
312
312
+
did,
313
313
+
collection,
314
314
+
rkey = 'self',
315
315
+
client
316
316
+
}: {
317
317
+
did?: Did;
318
318
+
collection: Collection;
319
319
+
rkey?: string;
320
320
+
client?: Client;
321
321
+
}) {
322
322
+
did ??= user.did ?? undefined;
323
323
+
324
324
+
if (!collection) {
325
325
+
throw new Error('Missing parameters for getRecord');
326
326
+
}
327
327
+
if (!did) {
328
328
+
throw new Error('Missing did for getRecord');
329
329
+
}
330
330
+
331
331
+
client ??= await getClient({ did });
332
332
+
333
333
+
const record = await client.get('com.atproto.repo.getRecord', {
334
334
+
params: {
335
335
+
repo: did,
336
336
+
collection,
337
337
+
rkey
338
338
+
}
339
339
+
});
340
340
+
341
341
+
return JSON.parse(JSON.stringify(record.data));
342
342
+
}
343
343
+
344
344
+
export async function putRecord({
345
345
+
collection,
346
346
+
rkey = 'self',
347
347
+
record
348
348
+
}: {
349
349
+
collection: AllowedCollection;
350
350
+
rkey?: string;
351
351
+
record: Record<string, unknown>;
352
352
+
}) {
353
353
+
if (!user.did) throw new Error('Not logged in');
354
354
+
355
355
+
const { putRecord: putRecordRemote } = await import('./server/repo.remote');
356
356
+
const data = await putRecordRemote({ collection, rkey, record });
357
357
+
return { ok: true, data };
358
358
+
}
359
359
+
360
360
+
export async function deleteRecord({
361
361
+
collection,
362
362
+
rkey = 'self'
363
363
+
}: {
364
364
+
collection: AllowedCollection;
365
365
+
rkey: string;
366
366
+
}) {
367
367
+
if (!user.did) throw new Error('Not logged in');
368
368
+
369
369
+
const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote');
370
370
+
const data = await deleteRecordRemote({ collection, rkey });
371
371
+
return data.ok;
372
372
+
}
373
373
+
374
374
+
export async function uploadBlob({ blob }: { blob: Blob }) {
375
375
+
if (!user.did) throw new Error("Can't upload blob: Not logged in");
376
376
+
377
377
+
const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote');
378
378
+
return await uploadBlobRemote({ blob });
379
379
+
}
380
380
+
381
381
+
export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
382
382
+
did ??= user.did ?? undefined;
383
383
+
if (!did) {
384
384
+
throw new Error('Error describeRepo: No did');
385
385
+
}
386
386
+
client ??= await getClient({ did });
387
387
+
388
388
+
const repo = await client.get('com.atproto.repo.describeRepo', {
389
389
+
params: {
390
390
+
repo: did
391
391
+
}
392
392
+
});
393
393
+
if (!repo.ok) return;
394
394
+
395
395
+
return repo.data;
396
396
+
}
397
397
+
398
398
+
export async function getBlobURL({
399
399
+
did,
400
400
+
blob
401
401
+
}: {
402
402
+
did: Did;
403
403
+
blob: {
404
404
+
$type: 'blob';
405
405
+
ref: {
406
406
+
$link: string;
407
407
+
};
408
408
+
};
409
409
+
}) {
410
410
+
const pds = await getPDS(did);
411
411
+
return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
412
412
+
}
413
413
+
414
414
+
export function getCDNImageBlobUrl({
415
415
+
did,
416
416
+
blob
417
417
+
}: {
418
418
+
did?: string;
419
419
+
blob: {
420
420
+
$type: 'blob';
421
421
+
ref: {
422
422
+
$link: string;
423
423
+
};
424
424
+
};
425
425
+
}) {
426
426
+
did ??= user.did ?? undefined;
427
427
+
428
428
+
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
429
429
+
}
430
430
+
431
431
+
export async function searchActorsTypeahead(
432
432
+
q: string,
433
433
+
limit: number = 10,
434
434
+
host?: string
435
435
+
): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
436
436
+
host ??= 'https://public.api.bsky.app';
437
437
+
438
438
+
const client = new Client({
439
439
+
handler: simpleFetchHandler({ service: host })
440
440
+
});
441
441
+
442
442
+
const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
443
443
+
params: {
444
444
+
q,
445
445
+
limit
446
446
+
}
447
447
+
});
448
448
+
449
449
+
if (!response.ok) return { actors: [], q };
450
450
+
451
451
+
return { actors: response.data.actors, q };
452
452
+
}
453
453
+
454
454
+
export function createTID() {
455
455
+
return TID.now();
456
456
+
}
457
457
+
```
458
458
+
459
459
+
### `src/lib/atproto/index.ts`
460
460
+
461
461
+
```ts
462
462
+
export { user, login, signup, logout } from './auth.svelte';
463
463
+
464
464
+
export {
465
465
+
parseUri,
466
466
+
resolveHandle,
467
467
+
getPDS,
468
468
+
getDetailedProfile,
469
469
+
getClient,
470
470
+
listRecords,
471
471
+
getRecord,
472
472
+
putRecord,
473
473
+
deleteRecord,
474
474
+
uploadBlob,
475
475
+
describeRepo,
476
476
+
getBlobURL,
477
477
+
getCDNImageBlobUrl,
478
478
+
searchActorsTypeahead,
479
479
+
createTID
480
480
+
} from './methods';
481
481
+
```
482
482
+
483
483
+
### `src/lib/atproto/server/signed-cookie.ts`
484
484
+
485
485
+
```ts
486
486
+
import { createHmac, timingSafeEqual } from 'node:crypto';
487
487
+
488
488
+
import type { Cookies } from '@sveltejs/kit';
489
489
+
490
490
+
import { env } from '$env/dynamic/private';
491
491
+
import { dev } from '$app/environment';
492
492
+
493
493
+
const SEPARATOR = '.';
494
494
+
495
495
+
function getSecret(): string {
496
496
+
const secret = env.COOKIE_SECRET;
497
497
+
if (secret) return secret;
498
498
+
if (dev) return 'dev-cookie-secret-not-for-production';
499
499
+
throw new Error('COOKIE_SECRET is not set');
500
500
+
}
501
501
+
502
502
+
function toBase64Url(bytes: Uint8Array): string {
503
503
+
let binary = '';
504
504
+
for (const byte of bytes) binary += String.fromCharCode(byte);
505
505
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
506
506
+
}
507
507
+
508
508
+
function fromBase64Url(str: string): Uint8Array {
509
509
+
const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
510
510
+
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
511
511
+
const binary = atob(base64);
512
512
+
const bytes = new Uint8Array(binary.length);
513
513
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
514
514
+
return bytes;
515
515
+
}
516
516
+
517
517
+
function hmacSha256(data: string): Uint8Array {
518
518
+
return createHmac('sha256', getSecret()).update(data).digest();
519
519
+
}
520
520
+
521
521
+
export function getSignedCookie(cookies: Cookies, name: string): string | null {
522
522
+
const signed = cookies.get(name);
523
523
+
if (!signed) return null;
524
524
+
525
525
+
const idx = signed.lastIndexOf(SEPARATOR);
526
526
+
if (idx === -1) return null;
527
527
+
528
528
+
const value = signed.slice(0, idx);
529
529
+
const sig = signed.slice(idx + 1);
530
530
+
531
531
+
let expected: Uint8Array;
532
532
+
let got: Uint8Array;
533
533
+
try {
534
534
+
expected = hmacSha256(value);
535
535
+
got = fromBase64Url(sig);
536
536
+
} catch {
537
537
+
return null;
538
538
+
}
539
539
+
540
540
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
541
541
+
542
542
+
return value;
543
543
+
}
544
544
+
545
545
+
export function setSignedCookie(
546
546
+
cookies: Cookies,
547
547
+
name: string,
548
548
+
value: string,
549
549
+
options: Parameters<Cookies['set']>[2]
550
550
+
): void {
551
551
+
const sig = toBase64Url(hmacSha256(value));
552
552
+
const signed = `${value}${SEPARATOR}${sig}`;
553
553
+
cookies.set(name, signed, options);
554
554
+
}
555
555
+
```
556
556
+
557
557
+
### `src/lib/atproto/server/kv-store.ts`
558
558
+
559
559
+
```ts
560
560
+
import type { Store } from '@atcute/oauth-node-client';
561
561
+
562
562
+
export class KVStore<K extends string, V> implements Store<K, V> {
563
563
+
private kv: KVNamespace;
564
564
+
private expirationTtl?: number;
565
565
+
566
566
+
constructor(kv: KVNamespace, options?: { expirationTtl?: number }) {
567
567
+
this.kv = kv;
568
568
+
this.expirationTtl = options?.expirationTtl;
569
569
+
}
570
570
+
571
571
+
async get(key: K): Promise<V | undefined> {
572
572
+
const value = await this.kv.get(key, 'text');
573
573
+
if (value === null) return undefined;
574
574
+
return JSON.parse(value) as V;
575
575
+
}
576
576
+
577
577
+
async set(key: K, value: V): Promise<void> {
578
578
+
await this.kv.put(key, JSON.stringify(value), {
579
579
+
expirationTtl: this.expirationTtl
580
580
+
});
581
581
+
}
582
582
+
583
583
+
async delete(key: K): Promise<void> {
584
584
+
await this.kv.delete(key);
585
585
+
}
586
586
+
587
587
+
async clear(): Promise<void> {
588
588
+
let cursor: string | undefined;
589
589
+
do {
590
590
+
const result = await this.kv.list({ cursor });
591
591
+
for (const key of result.keys) {
592
592
+
await this.kv.delete(key.name);
593
593
+
}
594
594
+
cursor = result.list_complete ? undefined : result.cursor;
595
595
+
} while (cursor);
596
596
+
}
597
597
+
}
598
598
+
```
599
599
+
600
600
+
### `src/lib/atproto/server/oauth.ts`
601
601
+
602
602
+
```ts
603
603
+
import {
604
604
+
OAuthClient,
605
605
+
MemoryStore,
606
606
+
type ClientAssertionPrivateJwk,
607
607
+
type OAuthClientStores,
608
608
+
type OAuthSession,
609
609
+
type StoredSession,
610
610
+
type StoredState
611
611
+
} from '@atcute/oauth-node-client';
612
612
+
import type { Did } from '@atcute/lexicons';
613
613
+
import {
614
614
+
CompositeDidDocumentResolver,
615
615
+
CompositeHandleResolver,
616
616
+
DohJsonHandleResolver,
617
617
+
LocalActorResolver,
618
618
+
PlcDidDocumentResolver,
619
619
+
WebDidDocumentResolver,
620
620
+
WellKnownHandleResolver
621
621
+
} from '@atcute/identity-resolver';
622
622
+
import { KVStore } from './kv-store';
623
623
+
import { DOH_RESOLVER, REDIRECT_PATH } from '../settings';
624
624
+
import { scope } from '../metadata';
625
625
+
import { dev } from '$app/environment';
626
626
+
627
627
+
function createActorResolver() {
628
628
+
return new LocalActorResolver({
629
629
+
handleResolver: new CompositeHandleResolver({
630
630
+
methods: {
631
631
+
dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }),
632
632
+
http: new WellKnownHandleResolver()
633
633
+
}
634
634
+
}),
635
635
+
didDocumentResolver: new CompositeDidDocumentResolver({
636
636
+
methods: {
637
637
+
plc: new PlcDidDocumentResolver(),
638
638
+
web: new WebDidDocumentResolver()
639
639
+
}
640
640
+
})
641
641
+
});
642
642
+
}
643
643
+
644
644
+
function createStores(env?: App.Platform['env']): OAuthClientStores {
645
645
+
if (env?.OAUTH_SESSIONS && env?.OAUTH_STATES) {
646
646
+
return {
647
647
+
sessions: new KVStore<Did, StoredSession>(env.OAUTH_SESSIONS),
648
648
+
states: new KVStore<string, StoredState>(env.OAUTH_STATES, { expirationTtl: 600 })
649
649
+
};
650
650
+
}
651
651
+
return {
652
652
+
sessions: new MemoryStore<Did, StoredSession>(),
653
653
+
states: new MemoryStore<string, StoredState>({ ttl: 600_000 })
654
654
+
};
655
655
+
}
656
656
+
657
657
+
export function createOAuthClient(env?: App.Platform['env']): OAuthClient {
658
658
+
const actorResolver = createActorResolver();
659
659
+
const stores = createStores(env);
660
660
+
661
661
+
if (dev && !env?.OAUTH_PUBLIC_URL) {
662
662
+
return new OAuthClient({
663
663
+
metadata: {
664
664
+
redirect_uris: [`http://127.0.0.1:5183${REDIRECT_PATH}`],
665
665
+
scope
666
666
+
},
667
667
+
actorResolver,
668
668
+
stores
669
669
+
});
670
670
+
}
671
671
+
672
672
+
if (!env?.OAUTH_PUBLIC_URL) {
673
673
+
throw new Error('OAUTH_PUBLIC_URL is not set');
674
674
+
}
675
675
+
if (!env.CLIENT_ASSERTION_KEY) {
676
676
+
throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key');
677
677
+
}
678
678
+
const site = env.OAUTH_PUBLIC_URL;
679
679
+
const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY);
680
680
+
681
681
+
return new OAuthClient({
682
682
+
metadata: {
683
683
+
client_id: site + '/oauth-client-metadata.json',
684
684
+
redirect_uris: [site + REDIRECT_PATH],
685
685
+
scope,
686
686
+
jwks_uri: site + '/oauth/jwks.json'
687
687
+
},
688
688
+
keyset: [key],
689
689
+
actorResolver,
690
690
+
stores
691
691
+
});
692
692
+
}
693
693
+
694
694
+
export type { OAuthSession };
695
695
+
```
696
696
+
697
697
+
### `src/lib/atproto/server/oauth.remote.ts`
698
698
+
699
699
+
```ts
700
700
+
import * as v from 'valibot';
701
701
+
import { error } from '@sveltejs/kit';
702
702
+
import { command, getRequestEvent } from '$app/server';
703
703
+
import { createOAuthClient } from './oauth';
704
704
+
import { getSignedCookie } from './signed-cookie';
705
705
+
import { scope } from '../metadata';
706
706
+
import { signUpPDS } from '../settings';
707
707
+
import type { ActorIdentifier, Did } from '@atcute/lexicons';
708
708
+
709
709
+
export const oauthLogin = command(
710
710
+
v.object({
711
711
+
handle: v.optional(v.pipe(v.string(), v.minLength(3))),
712
712
+
signup: v.optional(v.boolean())
713
713
+
}),
714
714
+
async (input) => {
715
715
+
const { platform } = getRequestEvent();
716
716
+
717
717
+
try {
718
718
+
const oauth = createOAuthClient(platform?.env);
719
719
+
720
720
+
const target = input.signup
721
721
+
? ({ type: 'pds', serviceUrl: signUpPDS } as const)
722
722
+
: ({ type: 'account', identifier: input.handle as ActorIdentifier } as const);
723
723
+
724
724
+
const { url } = await oauth.authorize({
725
725
+
target,
726
726
+
scope,
727
727
+
prompt: input.signup ? 'create' : undefined
728
728
+
});
729
729
+
730
730
+
return { url: url.toString() };
731
731
+
} catch (e) {
732
732
+
if (e && typeof e === 'object' && 'status' in e) throw e;
733
733
+
const message = e instanceof Error ? e.message : 'Login failed';
734
734
+
error(400, message);
735
735
+
}
736
736
+
}
737
737
+
);
738
738
+
739
739
+
export const oauthLogout = command(async () => {
740
740
+
const { cookies, platform } = getRequestEvent();
741
741
+
const did = getSignedCookie(cookies, 'did') as Did | null;
742
742
+
743
743
+
if (did) {
744
744
+
try {
745
745
+
const oauth = createOAuthClient(platform?.env);
746
746
+
await oauth.revoke(did);
747
747
+
} catch (e) {
748
748
+
console.error('Error revoking session:', e);
749
749
+
}
750
750
+
}
751
751
+
752
752
+
cookies.delete('did', { path: '/' });
753
753
+
754
754
+
return { ok: true };
755
755
+
});
756
756
+
```
757
757
+
758
758
+
### `src/lib/atproto/server/repo.remote.ts`
759
759
+
760
760
+
```ts
761
761
+
import { error } from '@sveltejs/kit';
762
762
+
import { command, getRequestEvent } from '$app/server';
763
763
+
import * as v from 'valibot';
764
764
+
import { permissions } from '../settings';
765
765
+
766
766
+
const collectionSchema = v.pipe(
767
767
+
v.string(),
768
768
+
v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/),
769
769
+
v.check(
770
770
+
(c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')),
771
771
+
'Collection not in allowed list'
772
772
+
)
773
773
+
);
774
774
+
775
775
+
const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/)));
776
776
+
777
777
+
export const putRecord = command(
778
778
+
v.object({
779
779
+
collection: collectionSchema,
780
780
+
rkey: rkeySchema,
781
781
+
record: v.record(v.string(), v.unknown())
782
782
+
}),
783
783
+
async (input) => {
784
784
+
const { locals } = getRequestEvent();
785
785
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
786
786
+
787
787
+
const response = await locals.client.post('com.atproto.repo.putRecord', {
788
788
+
input: {
789
789
+
collection: input.collection as `${string}.${string}.${string}`,
790
790
+
repo: locals.did,
791
791
+
rkey: input.rkey || 'self',
792
792
+
record: input.record
793
793
+
}
794
794
+
});
795
795
+
796
796
+
return response.data;
797
797
+
}
798
798
+
);
799
799
+
800
800
+
export const deleteRecord = command(
801
801
+
v.object({
802
802
+
collection: collectionSchema,
803
803
+
rkey: rkeySchema
804
804
+
}),
805
805
+
async (input) => {
806
806
+
const { locals } = getRequestEvent();
807
807
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
808
808
+
809
809
+
const response = await locals.client.post('com.atproto.repo.deleteRecord', {
810
810
+
input: {
811
811
+
collection: input.collection as `${string}.${string}.${string}`,
812
812
+
repo: locals.did,
813
813
+
rkey: input.rkey || 'self'
814
814
+
}
815
815
+
});
816
816
+
817
817
+
return { ok: response.ok };
818
818
+
}
819
819
+
);
820
820
+
821
821
+
export const uploadBlob = command(
822
822
+
v.object({
823
823
+
blob: v.instance(Blob)
824
824
+
}),
825
825
+
async (input) => {
826
826
+
const { locals } = getRequestEvent();
827
827
+
if (!locals.client || !locals.did) error(401, 'Not authenticated');
828
828
+
829
829
+
const response = await locals.client.post('com.atproto.repo.uploadBlob', {
830
830
+
params: { repo: locals.did },
831
831
+
input: input.blob
832
832
+
});
833
833
+
834
834
+
if (!response.ok) error(500, 'Upload failed');
835
835
+
836
836
+
return response.data.blob as {
837
837
+
$type: 'blob';
838
838
+
ref: { $link: string };
839
839
+
mimeType: string;
840
840
+
size: number;
841
841
+
};
842
842
+
}
843
843
+
);
844
844
+
```
845
845
+
846
846
+
### `src/lib/atproto/server/session.ts`
847
847
+
848
848
+
```ts
849
849
+
import type { Cookies } from '@sveltejs/kit';
850
850
+
import { Client } from '@atcute/client';
851
851
+
import type { Did } from '@atcute/lexicons';
852
852
+
import type { OAuthSession } from '@atcute/oauth-node-client';
853
853
+
import { createOAuthClient } from './oauth';
854
854
+
import { getSignedCookie } from './signed-cookie';
855
855
+
856
856
+
export type SessionLocals = {
857
857
+
session: OAuthSession | null;
858
858
+
client: Client | null;
859
859
+
did: Did | null;
860
860
+
};
861
861
+
862
862
+
export async function restoreSession(
863
863
+
cookies: Cookies,
864
864
+
env?: App.Platform['env']
865
865
+
): Promise<SessionLocals> {
866
866
+
const did = getSignedCookie(cookies, 'did') as Did | null;
867
867
+
868
868
+
if (!did) {
869
869
+
return { session: null, client: null, did: null };
870
870
+
}
871
871
+
872
872
+
try {
873
873
+
const oauth = createOAuthClient(env);
874
874
+
const session = await oauth.restore(did);
875
875
+
876
876
+
return {
877
877
+
session,
878
878
+
client: new Client({ handler: session }),
879
879
+
did
880
880
+
};
881
881
+
} catch (e) {
882
882
+
console.error('Failed to restore session:', e);
883
883
+
cookies.delete('did', { path: '/' });
884
884
+
return { session: null, client: null, did: null };
885
885
+
}
886
886
+
}
887
887
+
```
888
888
+
889
889
+
### `src/lib/atproto/server/profile.ts`
890
890
+
891
891
+
```ts
892
892
+
import type { Did } from '@atcute/lexicons';
893
893
+
import { getDetailedProfile, describeRepo } from '../methods';
894
894
+
895
895
+
const PROFILE_CACHE_TTL = 60 * 60; // 1 hour
896
896
+
897
897
+
export async function loadProfile(did: Did, profileCache?: KVNamespace) {
898
898
+
if (profileCache) {
899
899
+
try {
900
900
+
const cached = await profileCache.get(did, 'json');
901
901
+
if (cached) return cached as Record<string, unknown>;
902
902
+
} catch {
903
903
+
// Cache read failed, continue to fresh fetch
904
904
+
}
905
905
+
}
906
906
+
907
907
+
const profile = await fetchProfile(did);
908
908
+
909
909
+
if (profileCache && profile) {
910
910
+
profileCache
911
911
+
.put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL })
912
912
+
.catch(() => {});
913
913
+
}
914
914
+
915
915
+
return profile;
916
916
+
}
917
917
+
918
918
+
async function fetchProfile(did: Did) {
919
919
+
try {
920
920
+
let profile = await getDetailedProfile({ did });
921
921
+
922
922
+
if (!profile || profile.handle === 'handle.invalid') {
923
923
+
const repo = await describeRepo({ did });
924
924
+
profile = {
925
925
+
did,
926
926
+
handle: repo?.handle || 'handle.invalid'
927
927
+
} as typeof profile;
928
928
+
}
929
929
+
930
930
+
return profile;
931
931
+
} catch (e) {
932
932
+
console.error('Failed to load profile:', e);
933
933
+
return undefined;
934
934
+
}
935
935
+
}
936
936
+
```
937
937
+
938
938
+
### `src/lib/atproto/scripts/generate-key.ts`
939
939
+
940
940
+
```ts
941
941
+
import { generateClientAssertionKey } from '@atcute/oauth-node-client';
942
942
+
943
943
+
const key = await generateClientAssertionKey('main-key');
944
944
+
console.log(JSON.stringify(key));
945
945
+
```
946
946
+
947
947
+
### `src/lib/atproto/scripts/generate-secret.ts`
948
948
+
949
949
+
```ts
950
950
+
import { randomBytes } from 'node:crypto';
951
951
+
952
952
+
console.log(randomBytes(32).toString('base64url'));
953
953
+
```
954
954
+
955
955
+
### `src/lib/atproto/scripts/setup-dev.ts`
956
956
+
957
957
+
```ts
958
958
+
import { existsSync } from 'node:fs';
959
959
+
import { copyFile, readFile, writeFile } from 'node:fs/promises';
960
960
+
import { resolve } from 'node:path';
961
961
+
import { randomBytes } from 'node:crypto';
962
962
+
963
963
+
import { generateClientAssertionKey } from '@atcute/oauth-node-client';
964
964
+
965
965
+
const cwd = process.cwd();
966
966
+
const examplePath = resolve(cwd, '.env.example');
967
967
+
const envPath = resolve(cwd, '.env');
968
968
+
969
969
+
if (!existsSync(envPath)) {
970
970
+
if (!existsSync(examplePath)) {
971
971
+
throw new Error(`missing .env.example (expected at ${examplePath})`);
972
972
+
}
973
973
+
await copyFile(examplePath, envPath);
974
974
+
console.log(`created ${envPath}`);
975
975
+
}
976
976
+
977
977
+
const upsertVar = (input: string, key: string, value: string): string => {
978
978
+
const line = `${key}=${value}`;
979
979
+
const re = new RegExp(`^${key}=.*$`, 'm');
980
980
+
981
981
+
if (re.test(input)) {
982
982
+
const match = input.match(re);
983
983
+
const current = match ? match[0].slice(key.length + 1).trim() : '';
984
984
+
if (current === '' || current === "''" || current === '""' || current.includes('...')) {
985
985
+
return input.replace(re, line);
986
986
+
}
987
987
+
return input;
988
988
+
}
989
989
+
990
990
+
const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n';
991
991
+
return `${input}${suffix}${line}\n`;
992
992
+
};
993
993
+
994
994
+
let vars = await readFile(envPath, 'utf8');
995
995
+
996
996
+
const secret = randomBytes(32).toString('base64url');
997
997
+
vars = upsertVar(vars, 'COOKIE_SECRET', secret);
998
998
+
999
999
+
const jwk = await generateClientAssertionKey('main-key');
1000
1000
+
vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk));
1001
1001
+
1002
1002
+
await writeFile(envPath, vars);
1003
1003
+
console.log(`updated ${envPath}`);
1004
1004
+
```
1005
1005
+
1006
1006
+
### `src/routes/(oauth)/oauth/callback/+server.ts`
1007
1007
+
1008
1008
+
```ts
1009
1009
+
import { redirect } from '@sveltejs/kit';
1010
1010
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
1011
1011
+
import { setSignedCookie } from '$lib/atproto/server/signed-cookie';
1012
1012
+
import { dev } from '$app/environment';
1013
1013
+
import type { RequestHandler } from './$types';
1014
1014
+
1015
1015
+
export const GET: RequestHandler = async ({ url, platform, cookies }) => {
1016
1016
+
const oauth = createOAuthClient(platform?.env);
1017
1017
+
1018
1018
+
try {
1019
1019
+
const { session } = await oauth.callback(url.searchParams);
1020
1020
+
1021
1021
+
setSignedCookie(cookies, 'did', session.did, {
1022
1022
+
path: '/',
1023
1023
+
httpOnly: true,
1024
1024
+
secure: !dev,
1025
1025
+
sameSite: 'lax',
1026
1026
+
maxAge: 60 * 60 * 24 * 180 // 180 days
1027
1027
+
});
1028
1028
+
} catch (e) {
1029
1029
+
console.error('OAuth callback failed:', e);
1030
1030
+
redirect(303, '/?error=auth_failed');
1031
1031
+
}
1032
1032
+
1033
1033
+
redirect(303, '/');
1034
1034
+
};
1035
1035
+
```
1036
1036
+
1037
1037
+
### `src/routes/(oauth)/oauth/jwks.json/+server.ts`
1038
1038
+
1039
1039
+
```ts
1040
1040
+
import { json } from '@sveltejs/kit';
1041
1041
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
1042
1042
+
import type { RequestHandler } from './$types';
1043
1043
+
1044
1044
+
export const GET: RequestHandler = async ({ platform }) => {
1045
1045
+
const oauth = createOAuthClient(platform?.env);
1046
1046
+
return json(oauth.jwks ?? { keys: [] });
1047
1047
+
};
1048
1048
+
```
1049
1049
+
1050
1050
+
### `src/routes/(oauth)/oauth-client-metadata.json/+server.ts`
1051
1051
+
1052
1052
+
```ts
1053
1053
+
import { json } from '@sveltejs/kit';
1054
1054
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
1055
1055
+
import type { RequestHandler } from './$types';
1056
1056
+
1057
1057
+
export const GET: RequestHandler = async ({ platform }) => {
1058
1058
+
const oauth = createOAuthClient(platform?.env);
1059
1059
+
return json(oauth.metadata);
1060
1060
+
};
1061
1061
+
```
1062
1062
+
1063
1063
+
### `.env.example`
1064
1064
+
1065
1065
+
```
1066
1066
+
# Generate both with: pnpm env:setup-dev
1067
1067
+
CLIENT_ASSERTION_KEY=
1068
1068
+
COOKIE_SECRET=
1069
1069
+
1070
1070
+
# Set to your tunnel URL to use a confidential client in dev
1071
1071
+
# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com
1072
1072
+
```
1073
1073
+
1074
1074
+
## Step 3: Modify existing files
1075
1075
+
1076
1076
+
### `src/app.d.ts`
1077
1077
+
1078
1078
+
Add these to the existing `App` namespace. Merge with any existing `Locals` or `Platform` fields — do not remove existing fields.
1079
1079
+
1080
1080
+
```ts
1081
1081
+
import type { OAuthSession } from '@atcute/oauth-node-client';
1082
1082
+
import type { Client } from '@atcute/client';
1083
1083
+
import type { Did } from '@atcute/lexicons';
1084
1084
+
```
1085
1085
+
1086
1086
+
Add to `App.Locals`:
1087
1087
+
1088
1088
+
```ts
1089
1089
+
session: OAuthSession | null;
1090
1090
+
client: Client | null;
1091
1091
+
did: Did | null;
1092
1092
+
```
1093
1093
+
1094
1094
+
Add to `App.Platform`:
1095
1095
+
1096
1096
+
```ts
1097
1097
+
env: {
1098
1098
+
OAUTH_SESSIONS: KVNamespace;
1099
1099
+
OAUTH_STATES: KVNamespace;
1100
1100
+
CLIENT_ASSERTION_KEY: string;
1101
1101
+
COOKIE_SECRET: string;
1102
1102
+
OAUTH_PUBLIC_URL: string;
1103
1103
+
PROFILE_CACHE?: KVNamespace;
1104
1104
+
};
1105
1105
+
```
1106
1106
+
1107
1107
+
Add at the bottom of the file (for lexicon type augmentation):
1108
1108
+
1109
1109
+
```ts
1110
1110
+
import type {} from '@atcute/atproto';
1111
1111
+
import type {} from '@atcute/bluesky';
1112
1112
+
```
1113
1113
+
1114
1114
+
### `src/hooks.server.ts`
1115
1115
+
1116
1116
+
Add session restoration. If the file already has a `handle` export, wrap both in `sequence()` from `@sveltejs/kit`.
1117
1117
+
1118
1118
+
```ts
1119
1119
+
import type { Handle } from '@sveltejs/kit';
1120
1120
+
import { restoreSession } from '$lib/atproto/server/session';
1121
1121
+
1122
1122
+
const atprotoHandle: Handle = async ({ event, resolve }) => {
1123
1123
+
const { session, client, did } = await restoreSession(
1124
1124
+
event.cookies, event.platform?.env
1125
1125
+
);
1126
1126
+
event.locals.session = session;
1127
1127
+
event.locals.client = client;
1128
1128
+
event.locals.did = did;
1129
1129
+
return resolve(event);
1130
1130
+
};
1131
1131
+
```
1132
1132
+
1133
1133
+
If no existing hooks: `export const handle = atprotoHandle;`
1134
1134
+
1135
1135
+
If existing hooks: `export const handle = sequence(existingHandle, atprotoHandle);` (import `sequence` from `@sveltejs/kit`)
1136
1136
+
1137
1137
+
### `src/routes/+layout.server.ts`
1138
1138
+
1139
1139
+
Add profile loading. Merge with any existing load function.
1140
1140
+
1141
1141
+
```ts
1142
1142
+
import type { LayoutServerLoad } from './$types';
1143
1143
+
import { loadProfile } from '$lib/atproto/server/profile';
1144
1144
+
1145
1145
+
export const load: LayoutServerLoad = async ({ locals, platform }) => {
1146
1146
+
if (!locals.did) return { did: null, profile: null };
1147
1147
+
const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE);
1148
1148
+
return { did: locals.did, profile };
1149
1149
+
};
1150
1150
+
```
1151
1151
+
1152
1152
+
If a load function already exists, merge the profile data into its return value.
1153
1153
+
1154
1154
+
### `src/routes/+layout.svelte` (foxui only)
1155
1155
+
1156
1156
+
Only if the user chose `foxui`. Add the login modal to the existing layout:
1157
1157
+
1158
1158
+
```svelte
1159
1159
+
<script lang="ts">
1160
1160
+
import { AtprotoLoginModal } from '@foxui/social';
1161
1161
+
import { login, signup } from '$lib/atproto';
1162
1162
+
</script>
1163
1163
+
1164
1164
+
<!-- keep existing layout content, add this at the bottom: -->
1165
1165
+
<AtprotoLoginModal
1166
1166
+
login={async (handle) => {
1167
1167
+
await login(handle);
1168
1168
+
return true;
1169
1169
+
}}
1170
1170
+
signup={async () => {
1171
1171
+
signup();
1172
1172
+
return true;
1173
1173
+
}}
1174
1174
+
/>
1175
1175
+
```
1176
1176
+
1177
1177
+
To show the modal from anywhere, use `@foxui/social` state and `@foxui/core` components:
1178
1178
+
1179
1179
+
```svelte
1180
1180
+
<script lang="ts">
1181
1181
+
import { Button } from '@foxui/core';
1182
1182
+
import { atProtoLoginModalState } from '@foxui/social';
1183
1183
+
import { user, logout } from '$lib/atproto';
1184
1184
+
</script>
1185
1185
+
1186
1186
+
{#if user.isLoggedIn}
1187
1187
+
<p>Signed in as {user.profile?.handle ?? user.did}</p>
1188
1188
+
<Button onclick={() => logout()}>Sign Out</Button>
1189
1189
+
{:else}
1190
1190
+
<Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button>
1191
1191
+
{/if}
1192
1192
+
```
1193
1193
+
1194
1194
+
`@foxui/core` also exports `Avatar`, `Input`, and other UI primitives you can use.
1195
1195
+
1196
1196
+
### `src/routes/user/+page.svelte` (basic only)
1197
1197
+
1198
1198
+
Only if the user chose `basic`. Create this file:
1199
1199
+
1200
1200
+
```svelte
1201
1201
+
<script lang="ts">
1202
1202
+
import { user, login, logout } from '$lib/atproto';
1203
1203
+
1204
1204
+
let handle = $state('');
1205
1205
+
let error = $state('');
1206
1206
+
let loading = $state(false);
1207
1207
+
1208
1208
+
async function handleLogin() {
1209
1209
+
if (!handle.trim()) return;
1210
1210
+
loading = true;
1211
1211
+
error = '';
1212
1212
+
try {
1213
1213
+
await login(handle);
1214
1214
+
} catch (e) {
1215
1215
+
error = e instanceof Error ? e.message : 'Login failed';
1216
1216
+
loading = false;
1217
1217
+
}
1218
1218
+
}
1219
1219
+
</script>
1220
1220
+
1221
1221
+
<div class="mx-auto max-w-sm p-8">
1222
1222
+
{#if user.isLoggedIn}
1223
1223
+
<p class="mb-4">Signed in as <strong>{user.profile?.handle ?? user.did}</strong></p>
1224
1224
+
<button
1225
1225
+
class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
1226
1226
+
onclick={() => logout()}
1227
1227
+
>
1228
1228
+
Sign Out
1229
1229
+
</button>
1230
1230
+
{:else}
1231
1231
+
<h1 class="mb-4 text-xl font-bold">Sign in</h1>
1232
1232
+
<form onsubmit={handleLogin} class="flex flex-col gap-3">
1233
1233
+
<input
1234
1234
+
type="text"
1235
1235
+
bind:value={handle}
1236
1236
+
placeholder="handle.bsky.social"
1237
1237
+
class="rounded border px-3 py-2"
1238
1238
+
disabled={loading}
1239
1239
+
/>
1240
1240
+
{#if error}
1241
1241
+
<p class="text-sm text-red-600">{error}</p>
1242
1242
+
{/if}
1243
1243
+
<button
1244
1244
+
type="submit"
1245
1245
+
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
1246
1246
+
disabled={loading || !handle.trim()}
1247
1247
+
>
1248
1248
+
{loading ? 'Signing in...' : 'Sign In'}
1249
1249
+
</button>
1250
1250
+
</form>
1251
1251
+
{/if}
1252
1252
+
</div>
1253
1253
+
```
1254
1254
+
1255
1255
+
If the project does not use Tailwind, replace the Tailwind classes with plain inline styles.
1256
1256
+
1257
1257
+
### `svelte.config.js`
1258
1258
+
1259
1259
+
Add `remoteFunctions: true` inside `kit.experimental`:
1260
1260
+
1261
1261
+
```js
1262
1262
+
kit: {
1263
1263
+
adapter: adapter(),
1264
1264
+
experimental: {
1265
1265
+
remoteFunctions: true
1266
1266
+
}
1267
1267
+
}
1268
1268
+
```
1269
1269
+
1270
1270
+
If `experimental` already exists, merge into it. Do not remove other experimental flags.
1271
1271
+
1272
1272
+
### `vite.config.ts`
1273
1273
+
1274
1274
+
Add dev server config for loopback OAuth:
1275
1275
+
1276
1276
+
```ts
1277
1277
+
server: {
1278
1278
+
host: '127.0.0.1',
1279
1279
+
port: 5183
1280
1280
+
}
1281
1281
+
```
1282
1282
+
1283
1283
+
Add this inside `defineConfig()`. Do not remove existing plugins or config.
1284
1284
+
1285
1285
+
### `wrangler.jsonc`
1286
1286
+
1287
1287
+
Add or merge these fields:
1288
1288
+
1289
1289
+
- Add `"nodejs_compat_v2"` to `compatibility_flags` (create the array if it doesn't exist)
1290
1290
+
- Add `"OAUTH_PUBLIC_URL": "https://your-domain.com"` to `vars` (create `vars` if needed)
1291
1291
+
- Add KV namespace placeholders to `kv_namespaces`:
1292
1292
+
1293
1293
+
```jsonc
1294
1294
+
{ "binding": "OAUTH_SESSIONS", "id": "TODO" },
1295
1295
+
{ "binding": "OAUTH_STATES", "id": "TODO" }
1296
1296
+
```
1297
1297
+
1298
1298
+
Do not remove existing bindings or vars.
1299
1299
+
1300
1300
+
### `tsconfig.json`
1301
1301
+
1302
1302
+
Add `"@cloudflare/workers-types"` to `compilerOptions.types`. Create the `types` array if it doesn't exist.
1303
1303
+
1304
1304
+
### `package.json`
1305
1305
+
1306
1306
+
Add these to the `scripts` section:
1307
1307
+
1308
1308
+
```json
1309
1309
+
"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
1310
1310
+
"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
1311
1311
+
"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
1312
1312
+
```
1313
1313
+
1314
1314
+
### `.gitignore`
1315
1315
+
1316
1316
+
Ensure these lines are present:
1317
1317
+
1318
1318
+
```
1319
1319
+
.env
1320
1320
+
.env.*
1321
1321
+
!.env.example
1322
1322
+
```
1323
1323
+
1324
1324
+
## Step 4: Run setup and verify
1325
1325
+
1326
1326
+
1. Run `pnpm env:setup-dev` to generate secrets in `.env`
1327
1327
+
2. Run `pnpm dev` to start the dev server
1328
1328
+
3. Verify it starts on `http://127.0.0.1:5183`
1329
1329
+
4. Tell the user:
1330
1330
+
- Dev mode uses a loopback client (no keys needed)
1331
1331
+
- For production: create KV namespaces with `npx wrangler kv namespace create OAUTH_SESSIONS` and `OAUTH_STATES`, update the IDs in `wrangler.jsonc`, set `OAUTH_PUBLIC_URL` to their domain, and run `npx wrangler secret put CLIENT_ASSERTION_KEY` / `COOKIE_SECRET` with values from `pnpm env:generate-key` / `pnpm env:generate-secret`
1332
1332
+
1333
1333
+
## Usage examples
1334
1334
+
1335
1335
+
### Login / Logout
1336
1336
+
1337
1337
+
```svelte
1338
1338
+
<script lang="ts">
1339
1339
+
import { user, login, logout } from '$lib/atproto';
1340
1340
+
</script>
1341
1341
+
1342
1342
+
{#if user.isLoggedIn}
1343
1343
+
<p>Signed in as {user.did}</p>
1344
1344
+
<button onclick={() => logout()}>Sign Out</button>
1345
1345
+
{:else}
1346
1346
+
<button onclick={() => login('user.bsky.social')}>Sign In</button>
1347
1347
+
{/if}
1348
1348
+
```
1349
1349
+
1350
1350
+
### Write operations
1351
1351
+
1352
1352
+
```ts
1353
1353
+
import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto';
1354
1354
+
1355
1355
+
await putRecord({
1356
1356
+
collection: 'your.collection.name',
1357
1357
+
rkey: createTID(),
1358
1358
+
record: { text: 'hello', createdAt: new Date().toISOString() }
1359
1359
+
});
1360
1360
+
1361
1361
+
await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' });
1362
1362
+
1363
1363
+
const blob = await uploadBlob({ blob: file });
1364
1364
+
```
1365
1365
+
1366
1366
+
### Read operations (no auth needed)
1367
1367
+
1368
1368
+
```ts
1369
1369
+
import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto';
1370
1370
+
1371
1371
+
const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' });
1372
1372
+
const profile = await getDetailedProfile({ did: 'did:plc:...' });
1373
1373
+
```
···
1
1
-
# svelte atproto cloudflare workers oauth demo
2
2
-
3
3
-
A SvelteKit app that authenticates users via AT Protocol OAuth on Cloudflare Workers. Uses server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session/state storage, and SvelteKit remote functions for type-safe client-server communication.
4
4
-
5
5
-
## Prerequisites
6
6
-
7
7
-
- [Node.js](https://nodejs.org/) (v18+)
8
8
-
- [pnpm](https://pnpm.io/)
9
9
-
- A [Cloudflare account](https://dash.cloudflare.com/sign-up)
10
10
-
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) (`pnpm add -g wrangler`)
11
11
-
- A domain pointed at Cloudflare (for production)
1
1
+
# svelte atproto cloudflare workers oauth
12
2
13
13
-
## Local Development
3
3
+
SvelteKit + AT Protocol OAuth on Cloudflare Workers. Server-side OAuth with `@atcute/oauth-node-client`, Cloudflare KV for session/state storage, and SvelteKit remote functions for type-safe client-server communication.
14
4
15
15
-
### 1. Install dependencies
5
5
+
## Quick Start
16
6
17
7
```sh
18
8
pnpm install
9
9
+
pnpm dev
19
10
```
20
11
21
21
-
### 2. Run the dev server
12
12
+
In dev mode the app uses a **loopback OAuth client** (no keys, in-memory storage). It binds to `127.0.0.1:5183` — required for AT Protocol loopback OAuth.
13
13
+
14
14
+
### Dev with tunnel (confidential client)
15
15
+
16
16
+
To test the full production flow locally with a tunnel like [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/):
22
17
23
18
```sh
24
24
-
pnpm dev
19
19
+
pnpm env:setup-dev # generates secrets in .env
20
20
+
# add OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com to .env
21
21
+
cloudflared tunnel --url http://localhost:5183 # start tunnel
22
22
+
pnpm dev # start dev server
25
23
```
26
24
27
27
-
In dev mode, the app uses a **loopback OAuth client** (public, no keys needed) with in-memory stores. It binds to `127.0.0.1:5183` — this is required for AT Protocol loopback OAuth.
28
28
-
29
29
-
No Cloudflare setup is needed for local development.
30
30
-
31
25
## Production Deployment
32
26
33
27
### 1. Create KV namespaces
34
28
35
35
-
Create two KV namespaces for storing OAuth sessions and states:
36
36
-
37
29
```sh
38
30
npx wrangler kv namespace create OAUTH_SESSIONS
39
31
npx wrangler kv namespace create OAUTH_STATES
40
32
```
41
33
42
42
-
Each command outputs an ID. Update `wrangler.toml` with the actual IDs:
34
34
+
Add the IDs to `wrangler.jsonc`:
43
35
44
44
-
```toml
45
45
-
[[kv_namespaces]]
46
46
-
binding = "OAUTH_SESSIONS"
47
47
-
id = "<your-sessions-namespace-id>"
48
48
-
49
49
-
[[kv_namespaces]]
50
50
-
binding = "OAUTH_STATES"
51
51
-
id = "<your-states-namespace-id>"
36
36
+
```jsonc
37
37
+
"kv_namespaces": [
38
38
+
{ "binding": "OAUTH_SESSIONS", "id": "<your-id>" },
39
39
+
{ "binding": "OAUTH_STATES", "id": "<your-id>" }
40
40
+
]
52
41
```
53
42
54
54
-
### 2. Generate a client assertion key
43
43
+
### 2. Set your public URL
55
44
56
56
-
AT Protocol OAuth requires a confidential client with a private key for production. Generate one:
45
45
+
In `wrangler.jsonc`:
57
46
58
58
-
```sh
59
59
-
pnpm generate-key
47
47
+
```jsonc
48
48
+
"vars": {
49
49
+
"OAUTH_PUBLIC_URL": "https://your-domain.com"
50
50
+
}
60
51
```
61
52
62
62
-
This outputs a JSON key. Add it as a Cloudflare Workers secret:
53
53
+
### 3. Generate and set secrets
63
54
64
55
```sh
65
65
-
npx wrangler secret put CLIENT_ASSERTION_KEY
56
56
+
pnpm env:generate-key
57
57
+
npx wrangler secret put CLIENT_ASSERTION_KEY # paste generated key
58
58
+
59
59
+
pnpm env:generate-secret
60
60
+
npx wrangler secret put COOKIE_SECRET # paste generated secret
66
61
```
67
62
68
68
-
When prompted, paste the JSON key value.
63
63
+
### 4. Configure permissions
69
64
70
70
-
### 3. Configure your domain
71
71
-
72
72
-
Edit `src/lib/atproto/settings.ts` and set `SITE` to your production domain:
65
65
+
Edit `src/lib/atproto/settings.ts`:
73
66
74
67
```ts
75
75
-
export const SITE = dev ? 'http://localhost:5183' : 'https://your-domain.com';
68
68
+
export const permissions = {
69
69
+
collections: ['xyz.statusphere.status'], // collections your app can read/write
70
70
+
rpc: {}, // authenticated RPC requests
71
71
+
blobs: [] // blob types your app can upload
72
72
+
} as const;
76
73
```
77
74
78
78
-
### 4. Serve the OAuth client metadata
75
75
+
The OAuth scope is auto-generated from this config.
79
76
80
80
-
AT Protocol OAuth requires a client metadata JSON to be publicly accessible at `https://your-domain.com/oauth-client-metadata.json`. This is referenced as the `client_id`.
77
77
+
### 5. Deploy
81
78
82
82
-
Create `static/oauth-client-metadata.json` with your domain info, or serve it via a route. The metadata is constructed in `src/lib/atproto/metadata.ts` — the required fields are:
83
83
-
84
84
-
```json
85
85
-
{
86
86
-
"client_id": "https://your-domain.com/oauth-client-metadata.json",
87
87
-
"redirect_uris": ["https://your-domain.com/oauth/callback"],
88
88
-
"scope": "atproto repo:xyz.statusphere.status",
89
89
-
"jwks_uri": "https://your-domain.com/oauth/jwks.json"
90
90
-
}
79
79
+
```sh
80
80
+
npx wrangler deploy
91
81
```
92
82
93
93
-
The scope is auto-generated from the `permissions` config in `src/lib/atproto/settings.ts`.
83
83
+
Set up a custom domain in the Cloudflare dashboard (Worker > Settings > Domains & Routes) so the OAuth client metadata URL matches your `client_id`.
94
84
95
95
-
### 5. Configure permissions
85
85
+
## Scripts
96
86
97
97
-
Edit the `permissions` object in `src/lib/atproto/settings.ts` to control what your app can do:
87
87
+
| Script | Description |
88
88
+
|---|---|
89
89
+
| `pnpm dev` | Start dev server |
90
90
+
| `pnpm build` | Build for production |
91
91
+
| `pnpm check` | Run svelte-check |
92
92
+
| `pnpm env:generate-key` | Generate client assertion key |
93
93
+
| `pnpm env:generate-secret` | Generate cookie signing secret |
94
94
+
| `pnpm env:setup-dev` | Generate both and write to `.env` |
98
95
99
99
-
```ts
100
100
-
export const permissions = {
101
101
-
// collections your app can create/delete/update records in
102
102
-
collections: ['xyz.statusphere.status'],
96
96
+
## Adding to an existing project
103
97
104
104
-
// authenticated proxied RPC requests (e.g. to appview services)
105
105
-
rpc: {},
98
98
+
**With an AI agent** — paste this into Claude Code (or similar) in your existing repo:
106
99
107
107
-
// blob types your app can upload
108
108
-
blobs: []
109
109
-
} as const;
110
100
```
111
111
-
112
112
-
### 6. Deploy
113
113
-
114
114
-
```sh
115
115
-
pnpm build
116
116
-
npx wrangler deploy
101
101
+
add atproto oauth to this project https://raw.githubusercontent.com/flo-bit/svelte-atproto-oauth-cloudflare-workers/main/AGENT_SETUP.md
117
102
```
118
103
119
119
-
Or deploy directly:
104
104
+
The [agent prompt](AGENT_SETUP.md) will ask you a few questions and set everything up.
120
105
121
121
-
```sh
122
122
-
npx wrangler deploy
123
123
-
```
124
124
-
125
125
-
### 7. Set up a custom domain (recommended)
126
126
-
127
127
-
In the Cloudflare dashboard, go to your Worker > Settings > Domains & Routes and add your custom domain. This is needed so the OAuth client metadata URL matches your `client_id`.
106
106
+
**Manually** — see [SETUP.md](SETUP.md) for a step-by-step guide.
128
107
129
108
## Project Structure
130
109
131
110
```
132
132
-
src/
133
133
-
├── lib/
134
134
-
│ ├── atproto/
135
135
-
│ │ ├── auth.svelte.ts # Client-side auth state (derived from server data)
136
136
-
│ │ ├── metadata.ts # OAuth client metadata construction
137
137
-
│ │ ├── methods.ts # AT Protocol helper methods
138
138
-
│ │ ├── oauth.remote.ts # Remote functions: login, logout
139
139
-
│ │ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob
140
140
-
│ │ └── settings.ts # Site URL, permissions, constants
141
141
-
│ └── server/
142
142
-
│ ├── oauth.ts # OAuthClient factory (dev vs prod)
143
143
-
│ └── kv-store.ts # Cloudflare KV-backed Store implementation
144
144
-
├── routes/
145
145
-
│ ├── oauth/
146
146
-
│ │ ├── callback/ # OAuth callback handler (GET redirect)
147
147
-
│ │ └── jwks.json/ # Public JWKS endpoint
148
148
-
│ ├── +layout.server.ts # Loads user profile from session
149
149
-
│ ├── +layout.svelte
150
150
-
│ ├── +page.server.ts # Loads page-specific data
151
151
-
│ └── +page.svelte
152
152
-
└── hooks.server.ts # Session restoration from cookie
111
111
+
src/lib/atproto/
112
112
+
├── auth.svelte.ts # Client-side auth state & login/logout/signup
113
113
+
├── index.ts # Public exports
114
114
+
├── metadata.ts # OAuth scope from permissions
115
115
+
├── methods.ts # AT Protocol helpers (read/write/resolve)
116
116
+
├── settings.ts # Permissions config, constants
117
117
+
├── server/
118
118
+
│ ├── oauth.ts # OAuthClient factory (loopback vs confidential)
119
119
+
│ ├── oauth.remote.ts # Remote functions: login, logout
120
120
+
│ ├── repo.remote.ts # Remote functions: putRecord, deleteRecord, uploadBlob
121
121
+
│ ├── session.ts # Session restoration from signed cookie
122
122
+
│ ├── profile.ts # Profile loading with optional KV cache
123
123
+
│ ├── kv-store.ts # Cloudflare KV-backed Store
124
124
+
│ └── signed-cookie.ts # HMAC-signed cookie helpers
125
125
+
└── scripts/
126
126
+
├── generate-key.ts
127
127
+
├── generate-secret.ts
128
128
+
└── setup-dev.ts
129
129
+
130
130
+
src/routes/(oauth)/
131
131
+
├── oauth/callback/+server.ts
132
132
+
├── oauth/jwks.json/+server.ts
133
133
+
└── oauth-client-metadata.json/+server.ts
153
134
```
154
135
155
136
## How It Works
156
137
157
157
-
- **Authentication**: Server-side OAuth flow via `@atcute/oauth-node-client`. Sessions are stored in Cloudflare KV and identified by a `did` cookie.
158
158
-
- **Remote functions**: Write operations (putRecord, deleteRecord, uploadBlob) and auth actions (login, logout) use SvelteKit remote functions — type-safe server calls without manual API routes.
159
159
-
- **Dev mode**: Uses AT Protocol's loopback client (no keys, in-memory storage).
160
160
-
- **Prod mode**: Uses a confidential client with `private_key_jwt` assertion and KV-backed stores.
138
138
+
- **Auth**: Server-side OAuth via `@atcute/oauth-node-client`. Sessions stored in KV, identified by HMAC-signed `did` cookie.
139
139
+
- **Remote functions**: Write operations and auth actions use SvelteKit remote functions — type-safe server calls without manual API routes.
140
140
+
- **Dev mode**: Loopback client by default. Set `OAUTH_PUBLIC_URL` in `.env` for confidential client via tunnel.
141
141
+
- **Prod mode**: Confidential client with `private_key_jwt`, KV stores, `OAUTH_PUBLIC_URL` from `wrangler.jsonc`.
161
142
162
143
## License
163
144
···
1
1
+
# Adding AT Protocol OAuth to your SvelteKit + Cloudflare Workers project
2
2
+
3
3
+
## 1. Install dependencies
4
4
+
5
5
+
```sh
6
6
+
pnpm add valibot
7
7
+
pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx
8
8
+
```
9
9
+
10
10
+
Add any lexicon types you need (e.g. `@atcute/atproto`, `@atcute/bluesky`).
11
11
+
12
12
+
## 2. Copy files
13
13
+
14
14
+
Copy these into your project:
15
15
+
16
16
+
- `src/lib/atproto/` — auth state, methods, server logic, scripts
17
17
+
- `src/routes/(oauth)/` — OAuth callback, JWKS, and client metadata endpoints
18
18
+
19
19
+
## 3. Configure
20
20
+
21
21
+
**`src/lib/atproto/settings.ts`** — set your app's permissions:
22
22
+
23
23
+
```ts
24
24
+
export const permissions = {
25
25
+
collections: ['your.collection.name'],
26
26
+
rpc: {},
27
27
+
blobs: []
28
28
+
} as const;
29
29
+
```
30
30
+
31
31
+
The OAuth scope is auto-generated from this config.
32
32
+
33
33
+
**`src/app.d.ts`** — add session types:
34
34
+
35
35
+
```ts
36
36
+
import type { OAuthSession } from '@atcute/oauth-node-client';
37
37
+
import type { Client } from '@atcute/client';
38
38
+
import type { Did } from '@atcute/lexicons';
39
39
+
40
40
+
declare global {
41
41
+
namespace App {
42
42
+
interface Locals {
43
43
+
session: OAuthSession | null;
44
44
+
client: Client | null;
45
45
+
did: Did | null;
46
46
+
}
47
47
+
interface Platform {
48
48
+
env: {
49
49
+
OAUTH_SESSIONS: KVNamespace;
50
50
+
OAUTH_STATES: KVNamespace;
51
51
+
CLIENT_ASSERTION_KEY: string;
52
52
+
COOKIE_SECRET: string;
53
53
+
OAUTH_PUBLIC_URL: string;
54
54
+
PROFILE_CACHE?: KVNamespace; // optional
55
55
+
};
56
56
+
}
57
57
+
}
58
58
+
}
59
59
+
60
60
+
import type {} from '@atcute/atproto';
61
61
+
import type {} from '@atcute/bluesky';
62
62
+
export {};
63
63
+
```
64
64
+
65
65
+
**`src/hooks.server.ts`** — restore session on every request:
66
66
+
67
67
+
```ts
68
68
+
import type { Handle } from '@sveltejs/kit';
69
69
+
import { restoreSession } from '$lib/atproto/server/session';
70
70
+
71
71
+
export const handle: Handle = async ({ event, resolve }) => {
72
72
+
const { session, client, did } = await restoreSession(
73
73
+
event.cookies, event.platform?.env
74
74
+
);
75
75
+
event.locals.session = session;
76
76
+
event.locals.client = client;
77
77
+
event.locals.did = did;
78
78
+
return resolve(event);
79
79
+
};
80
80
+
```
81
81
+
82
82
+
If you already have hooks, use SvelteKit's `sequence` helper to combine them.
83
83
+
84
84
+
**`wrangler.jsonc`** — add KV namespaces and public URL:
85
85
+
86
86
+
```sh
87
87
+
npx wrangler kv namespace create OAUTH_SESSIONS
88
88
+
npx wrangler kv namespace create OAUTH_STATES
89
89
+
```
90
90
+
91
91
+
```jsonc
92
92
+
{
93
93
+
"compatibility_flags": ["nodejs_compat_v2"],
94
94
+
"vars": {
95
95
+
"OAUTH_PUBLIC_URL": "https://your-domain.com"
96
96
+
},
97
97
+
"kv_namespaces": [
98
98
+
{ "binding": "OAUTH_SESSIONS", "id": "<your-id>" },
99
99
+
{ "binding": "OAUTH_STATES", "id": "<your-id>" }
100
100
+
]
101
101
+
}
102
102
+
```
103
103
+
104
104
+
**`package.json`** — add helper scripts:
105
105
+
106
106
+
```json
107
107
+
{
108
108
+
"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
109
109
+
"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
110
110
+
"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
111
111
+
}
112
112
+
```
113
113
+
114
114
+
**`.env.example`**:
115
115
+
116
116
+
```
117
117
+
CLIENT_ASSERTION_KEY=
118
118
+
COOKIE_SECRET=
119
119
+
# Set to your tunnel URL to use a confidential client in dev
120
120
+
OAUTH_PUBLIC_URL=
121
121
+
```
122
122
+
123
123
+
## 4. Load profile (optional)
124
124
+
125
125
+
Add a `src/routes/+layout.server.ts` to load the user's Bluesky profile on every page:
126
126
+
127
127
+
```ts
128
128
+
import type { LayoutServerLoad } from './$types';
129
129
+
import { loadProfile } from '$lib/atproto/server/profile';
130
130
+
131
131
+
export const load: LayoutServerLoad = async ({ locals, platform }) => {
132
132
+
if (!locals.did) return { did: null, profile: null };
133
133
+
const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE);
134
134
+
return { did: locals.did, profile };
135
135
+
};
136
136
+
```
137
137
+
138
138
+
For optional profile caching, create a KV namespace and add it to `wrangler.jsonc`:
139
139
+
140
140
+
```sh
141
141
+
npx wrangler kv namespace create PROFILE_CACHE
142
142
+
```
143
143
+
144
144
+
## 5. Generate secrets
145
145
+
146
146
+
For local dev:
147
147
+
148
148
+
```sh
149
149
+
pnpm env:setup-dev
150
150
+
```
151
151
+
152
152
+
For production:
153
153
+
154
154
+
```sh
155
155
+
pnpm env:generate-key
156
156
+
npx wrangler secret put CLIENT_ASSERTION_KEY # paste the generated key
157
157
+
158
158
+
pnpm env:generate-secret
159
159
+
npx wrangler secret put COOKIE_SECRET # paste the generated secret
160
160
+
```
161
161
+
162
162
+
## 6. Add login UI
163
163
+
164
164
+
### Option A: `@foxui/social` login modal (recommended)
165
165
+
166
166
+
Install the UI packages:
167
167
+
168
168
+
```sh
169
169
+
pnpm add @foxui/social @foxui/core
170
170
+
```
171
171
+
172
172
+
Add the login modal to your root layout (`src/routes/+layout.svelte`):
173
173
+
174
174
+
```svelte
175
175
+
<script lang="ts">
176
176
+
import { AtprotoLoginModal } from '@foxui/social';
177
177
+
import { login, signup } from '$lib/atproto';
178
178
+
179
179
+
let { children } = $props();
180
180
+
</script>
181
181
+
182
182
+
{@render children()}
183
183
+
184
184
+
<AtprotoLoginModal
185
185
+
login={async (handle) => {
186
186
+
await login(handle);
187
187
+
return true;
188
188
+
}}
189
189
+
signup={async () => {
190
190
+
signup();
191
191
+
return true;
192
192
+
}}
193
193
+
/>
194
194
+
```
195
195
+
196
196
+
Then open the modal from anywhere:
197
197
+
198
198
+
```svelte
199
199
+
<script lang="ts">
200
200
+
import { Button } from '@foxui/core';
201
201
+
import { atProtoLoginModalState } from '@foxui/social';
202
202
+
import { user, logout } from '$lib/atproto';
203
203
+
</script>
204
204
+
205
205
+
{#if user.isLoggedIn}
206
206
+
<p>Signed in as {user.profile?.handle ?? user.did}</p>
207
207
+
<Button onclick={() => logout()}>Sign Out</Button>
208
208
+
{:else}
209
209
+
<Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button>
210
210
+
{/if}
211
211
+
```
212
212
+
213
213
+
### Option B: Simple inline login
214
214
+
215
215
+
```svelte
216
216
+
<script lang="ts">
217
217
+
import { user, login, logout } from '$lib/atproto';
218
218
+
</script>
219
219
+
220
220
+
{#if user.isLoggedIn}
221
221
+
<p>Signed in as {user.did}</p>
222
222
+
<button onclick={() => logout()}>Sign Out</button>
223
223
+
{:else}
224
224
+
<button onclick={() => login('user.bsky.social')}>Sign In</button>
225
225
+
{/if}
226
226
+
```
227
227
+
228
228
+
### Write operations
229
229
+
230
230
+
```ts
231
231
+
import { putRecord, deleteRecord, uploadBlob } from '$lib/atproto';
232
232
+
233
233
+
await putRecord({
234
234
+
collection: 'your.collection.name',
235
235
+
rkey: 'some-key',
236
236
+
record: { text: 'hello', createdAt: new Date().toISOString() }
237
237
+
});
238
238
+
239
239
+
await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' });
240
240
+
241
241
+
const blob = await uploadBlob({ blob: file });
242
242
+
```
243
243
+
244
244
+
### Read operations (no auth needed)
245
245
+
246
246
+
```ts
247
247
+
import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto';
248
248
+
249
249
+
const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' });
250
250
+
const profile = await getDetailedProfile({ did: 'did:plc:...' });
251
251
+
```
252
252
+
253
253
+
### Server load functions
254
254
+
255
255
+
```ts
256
256
+
export const load = async ({ locals }) => {
257
257
+
if (!locals.client || !locals.did) return { data: null };
258
258
+
259
259
+
const response = await locals.client.get('com.atproto.repo.listRecords', {
260
260
+
params: { repo: locals.did, collection: 'your.collection.name' }
261
261
+
});
262
262
+
263
263
+
return { data: response.data };
264
264
+
};
265
265
+
```
266
266
+
267
267
+
## Dev with tunnel (optional)
268
268
+
269
269
+
To test the confidential client flow locally:
270
270
+
271
271
+
1. `pnpm env:setup-dev`
272
272
+
2. Add tunnel URL to `.env`: `OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com`
273
273
+
3. `cloudflared tunnel --url http://localhost:5183`
274
274
+
4. `pnpm dev`
275
275
+
276
276
+
Without `OAUTH_PUBLIC_URL`, dev mode uses a loopback public client (no keys needed).
···
12
12
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
13
13
"format": "prettier --write .",
14
14
"lint": "prettier --check . && eslint .",
15
15
-
"generate-key": "npx tsx scripts/generate-key.ts"
15
15
+
"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
16
16
+
"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
17
17
+
"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
16
18
},
17
19
"devDependencies": {
18
20
"@atcute/atproto": "^3.1.10",
···
49
51
},
50
52
"license": "MIT",
51
53
"dependencies": {
54
54
+
"@foxui/core": "^0.5.1",
55
55
+
"@foxui/social": "^0.5.1",
56
56
+
"@foxui/time": "^0.5.1",
52
57
"valibot": "^1.2.0"
53
58
}
54
59
}
···
8
8
9
9
.:
10
10
dependencies:
11
11
+
'@foxui/core':
12
12
+
specifier: ^0.5.1
13
13
+
version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
14
14
+
'@foxui/social':
15
15
+
specifier: ^0.5.1
16
16
+
version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
17
17
+
'@foxui/time':
18
18
+
specifier: ^0.5.1
19
19
+
version: 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
11
20
valibot:
12
21
specifier: ^1.2.0
13
22
version: 1.2.0(typescript@5.9.3)
···
166
175
167
176
'@atcute/util-text@1.1.1':
168
177
resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==, tarball: https://registry.npmjs.org/@atcute/util-text/-/util-text-1.1.1.tgz}
178
178
+
179
179
+
'@atproto/api@0.18.21':
180
180
+
resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==, tarball: https://registry.npmjs.org/@atproto/api/-/api-0.18.21.tgz}
181
181
+
182
182
+
'@atproto/common-web@0.4.17':
183
183
+
resolution: {integrity: sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==, tarball: https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.17.tgz}
184
184
+
185
185
+
'@atproto/lex-data@0.0.12':
186
186
+
resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==, tarball: https://registry.npmjs.org/@atproto/lex-data/-/lex-data-0.0.12.tgz}
187
187
+
188
188
+
'@atproto/lex-json@0.0.12':
189
189
+
resolution: {integrity: sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA==, tarball: https://registry.npmjs.org/@atproto/lex-json/-/lex-json-0.0.12.tgz}
190
190
+
191
191
+
'@atproto/lexicon@0.6.1':
192
192
+
resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==, tarball: https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.1.tgz}
193
193
+
194
194
+
'@atproto/syntax@0.4.3':
195
195
+
resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==, tarball: https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.3.tgz}
196
196
+
197
197
+
'@atproto/xrpc@0.7.7':
198
198
+
resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==, tarball: https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.7.7.tgz}
169
199
170
200
'@badrap/valita@0.4.6':
171
201
resolution: {integrity: sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==, tarball: https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz}
···
596
626
'@floating-ui/utils@0.2.10':
597
627
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==, tarball: https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz}
598
628
629
629
+
'@foxui/core@0.5.1':
630
630
+
resolution: {integrity: sha512-WhcwTYy2bipsfrBmNjHF88gfmigr10KeWqNBVrwWmkFjAeXy8J9rj9CSLpeRQCHMmrNDqjVq7VahxWRuz0NO9g==, tarball: https://registry.npmjs.org/@foxui/core/-/core-0.5.1.tgz}
631
631
+
peerDependencies:
632
632
+
svelte: '>=5'
633
633
+
tailwindcss: '>=3'
634
634
+
635
635
+
'@foxui/social@0.5.1':
636
636
+
resolution: {integrity: sha512-PupbByfLdykQqzxjTZkMpy43LHaLrivI0DEb9Psew0+1PCMDqFSoDk00BVl5vtaPOlBfOwdQsDICa4kR0LApHg==, tarball: https://registry.npmjs.org/@foxui/social/-/social-0.5.1.tgz}
637
637
+
peerDependencies:
638
638
+
svelte: '>=5'
639
639
+
tailwindcss: '>=3'
640
640
+
641
641
+
'@foxui/time@0.5.1':
642
642
+
resolution: {integrity: sha512-P7mLM0UVarRdAI0owUUVzsa+VTp+ZQWENNM9BD0H9VESG49dIwP6sbfCRFFecCLijUHP+aIcnO3bnuK9BDx3rQ==, tarball: https://registry.npmjs.org/@foxui/time/-/time-0.5.1.tgz}
643
643
+
peerDependencies:
644
644
+
svelte: '>=5'
645
645
+
tailwindcss: '>=3'
646
646
+
599
647
'@humanfs/core@0.19.1':
600
648
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==, tarball: https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz}
601
649
engines: {node: '>=18.18.0'}
···
770
818
771
819
'@jridgewell/trace-mapping@0.3.9':
772
820
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz}
821
821
+
822
822
+
'@number-flow/svelte@0.3.13':
823
823
+
resolution: {integrity: sha512-mvbxDeSFa1o/E4vGhrWuawAFCgcn5qTQ/s++FIoD88es5+JQa/aMQUypTy7qXIreTtTvncpIbkKdw9DMnweaSw==, tarball: https://registry.npmjs.org/@number-flow/svelte/-/svelte-0.3.13.tgz}
824
824
+
peerDependencies:
825
825
+
svelte: ^4 || ^5
773
826
774
827
'@polka/url@1.0.0-next.29':
775
828
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==, tarball: https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz}
···
1132
1185
resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==, tarball: https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.1.tgz}
1133
1186
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1134
1187
1188
1188
+
'@use-gesture/core@10.3.1':
1189
1189
+
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==, tarball: https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz}
1190
1190
+
1191
1191
+
'@use-gesture/vanilla@10.3.1':
1192
1192
+
resolution: {integrity: sha512-lT4scGLu59ovA3zmtUonukAGcA0AdOOh+iwNDS05Bsu7Lq9aZToDHhI6D8Q2qvsVraovtsLLYwPrWdG/noMAKw==, tarball: https://registry.npmjs.org/@use-gesture/vanilla/-/vanilla-10.3.1.tgz}
1193
1193
+
1135
1194
acorn-jsx@5.3.2:
1136
1195
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, tarball: https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz}
1137
1196
peerDependencies:
···
1156
1215
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==, tarball: https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz}
1157
1216
engines: {node: '>= 0.4'}
1158
1217
1218
1218
+
await-lock@2.2.2:
1219
1219
+
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==, tarball: https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz}
1220
1220
+
1159
1221
axobject-query@4.1.0:
1160
1222
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==, tarball: https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz}
1161
1223
engines: {node: '>= 0.4'}
···
1170
1232
'@internationalized/date': ^3.8.1
1171
1233
svelte: ^5.33.0
1172
1234
1235
1235
+
bits-ui@2.16.2:
1236
1236
+
resolution: {integrity: sha512-bgEpRRF7Ck9nRP1pbuKVxpaSMrz+8Pm0y+dmuvlkrSe+uUwIQECef29y6eslFHM6pCAubUh7STrsTLUUp8fzFQ==, tarball: https://registry.npmjs.org/bits-ui/-/bits-ui-2.16.2.tgz}
1237
1237
+
engines: {node: '>=20'}
1238
1238
+
peerDependencies:
1239
1239
+
'@internationalized/date': ^3.8.1
1240
1240
+
svelte: ^5.33.0
1241
1241
+
1173
1242
blake3-wasm@2.1.5:
1174
1243
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, tarball: https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz}
1175
1244
···
1216
1285
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, tarball: https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz}
1217
1286
engines: {node: '>=18'}
1218
1287
1288
1288
+
core-js@3.48.0:
1289
1289
+
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==, tarball: https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz}
1290
1290
+
1219
1291
cross-spawn@7.0.6:
1220
1292
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, tarball: https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz}
1221
1293
engines: {node: '>= 8'}
···
1224
1296
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, tarball: https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz}
1225
1297
engines: {node: '>=4'}
1226
1298
hasBin: true
1299
1299
+
1300
1300
+
custom-event-polyfill@1.0.7:
1301
1301
+
resolution: {integrity: sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==, tarball: https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz}
1227
1302
1228
1303
debug@4.4.3:
1229
1304
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, tarball: https://registry.npmjs.org/debug/-/debug-4.4.3.tgz}
···
1251
1326
1252
1327
devalue@5.6.2:
1253
1328
resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==, tarball: https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz}
1329
1329
+
1330
1330
+
emoji-picker-element@1.29.0:
1331
1331
+
resolution: {integrity: sha512-lQm8YayfwIP5j+Xe1O2Fjul7hv2b4spPS16X99O4qKQdzDQaEeiAqqYaRONHncewcpisUf6qGFJkhM2G3riEdA==, tarball: https://registry.npmjs.org/emoji-picker-element/-/emoji-picker-element-1.29.0.tgz}
1254
1332
1255
1333
enhanced-resolve@5.18.4:
1256
1334
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==, tarball: https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz}
···
1401
1479
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, tarball: https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz}
1402
1480
engines: {node: '>=8'}
1403
1481
1482
1482
+
hls.js@1.6.15:
1483
1483
+
resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==, tarball: https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz}
1484
1484
+
1404
1485
ignore@5.3.2:
1405
1486
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, tarball: https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz}
1406
1487
engines: {node: '>= 4'}
···
1420
1501
inline-style-parser@0.2.7:
1421
1502
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, tarball: https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz}
1422
1503
1504
1504
+
is-emoji-supported@0.0.5:
1505
1505
+
resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==, tarball: https://registry.npmjs.org/is-emoji-supported/-/is-emoji-supported-0.0.5.tgz}
1506
1506
+
1423
1507
is-extglob@2.1.1:
1424
1508
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, tarball: https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz}
1425
1509
engines: {node: '>=0.10.0'}
···
1433
1517
1434
1518
isexe@2.0.0:
1435
1519
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, tarball: https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz}
1520
1520
+
1521
1521
+
iso-datestring-validator@2.2.2:
1522
1522
+
resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==, tarball: https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz}
1436
1523
1437
1524
jiti@2.6.1:
1438
1525
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz}
···
1538
1625
lilconfig@2.1.0:
1539
1626
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, tarball: https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz}
1540
1627
engines: {node: '>=10'}
1628
1628
+
1629
1629
+
loadjs@4.3.0:
1630
1630
+
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==, tarball: https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz}
1541
1631
1542
1632
locate-character@3.0.0:
1543
1633
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==, tarball: https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz}
···
1572
1662
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==, tarball: https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz}
1573
1663
engines: {node: '>=16 || 14 >=14.17'}
1574
1664
1665
1665
+
mode-watcher@1.1.0:
1666
1666
+
resolution: {integrity: sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==, tarball: https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz}
1667
1667
+
peerDependencies:
1668
1668
+
svelte: ^5.27.0
1669
1669
+
1575
1670
mri@1.2.0:
1576
1671
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, tarball: https://registry.npmjs.org/mri/-/mri-1.2.0.tgz}
1577
1672
engines: {node: '>=4'}
···
1583
1678
ms@2.1.3:
1584
1679
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, tarball: https://registry.npmjs.org/ms/-/ms-2.1.3.tgz}
1585
1680
1681
1681
+
multiformats@9.9.0:
1682
1682
+
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==, tarball: https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz}
1683
1683
+
1586
1684
nanoid@3.3.11:
1587
1685
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==, tarball: https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz}
1588
1686
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
···
1599
1697
node-gyp-build@4.8.4:
1600
1698
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, tarball: https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz}
1601
1699
hasBin: true
1700
1700
+
1701
1701
+
number-flow@0.5.12:
1702
1702
+
resolution: {integrity: sha512-CIs21h2JkfYG4rfgERaUNAk0Cz+Ef14fNJfSCbGGhgRgconQc9b7rcCQfi9SZ36kNjVXmsl2BrzDbjGtEgumAA==, tarball: https://registry.npmjs.org/number-flow/-/number-flow-0.5.12.tgz}
1602
1703
1603
1704
obug@2.1.1:
1604
1705
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==, tarball: https://registry.npmjs.org/obug/-/obug-2.1.1.tgz}
···
1640
1741
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==, tarball: https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz}
1641
1742
engines: {node: '>=12'}
1642
1743
1744
1744
+
plyr@3.8.4:
1745
1745
+
resolution: {integrity: sha512-DrzLbK9Wol3zeiuZCleD9aUOl0KAaBHR9H6WVVVYPZ4Ya+LYxUFTgSF1jooHcMQCv96Ws96wCaZzIoP3bES8pQ==, tarball: https://registry.npmjs.org/plyr/-/plyr-3.8.4.tgz}
1746
1746
+
1643
1747
postcss-load-config@3.1.4:
1644
1748
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==, tarball: https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz}
1645
1749
engines: {node: '>= 10'}
···
1746
1850
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, tarball: https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz}
1747
1851
engines: {node: '>=6'}
1748
1852
1853
1853
+
rangetouch@2.0.1:
1854
1854
+
resolution: {integrity: sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==, tarball: https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz}
1855
1855
+
1749
1856
readdirp@4.1.2:
1750
1857
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz}
1751
1858
engines: {node: '>= 14.18.0'}
···
1766
1873
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
1767
1874
hasBin: true
1768
1875
1876
1876
+
runed@0.23.4:
1877
1877
+
resolution: {integrity: sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==, tarball: https://registry.npmjs.org/runed/-/runed-0.23.4.tgz}
1878
1878
+
peerDependencies:
1879
1879
+
svelte: ^5.7.0
1880
1880
+
1881
1881
+
runed@0.25.0:
1882
1882
+
resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==, tarball: https://registry.npmjs.org/runed/-/runed-0.25.0.tgz}
1883
1883
+
peerDependencies:
1884
1884
+
svelte: ^5.7.0
1885
1885
+
1886
1886
+
runed@0.28.0:
1887
1887
+
resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==, tarball: https://registry.npmjs.org/runed/-/runed-0.28.0.tgz}
1888
1888
+
peerDependencies:
1889
1889
+
svelte: ^5.7.0
1890
1890
+
1769
1891
runed@0.35.1:
1770
1892
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==, tarball: https://registry.npmjs.org/runed/-/runed-0.35.1.tgz}
1771
1893
peerDependencies:
···
1839
1961
svelte:
1840
1962
optional: true
1841
1963
1964
1964
+
svelte-sonner@1.0.7:
1965
1965
+
resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==, tarball: https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz}
1966
1966
+
peerDependencies:
1967
1967
+
svelte: ^5.0.0
1968
1968
+
1842
1969
svelte-toolbelt@0.10.6:
1843
1970
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz}
1844
1971
engines: {node: '>=18', pnpm: '>=8.7.0'}
1845
1972
peerDependencies:
1846
1973
svelte: ^5.30.2
1847
1974
1975
1975
+
svelte-toolbelt@0.7.1:
1976
1976
+
resolution: {integrity: sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==, tarball: https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz}
1977
1977
+
engines: {node: '>=18', pnpm: '>=8.7.0'}
1978
1978
+
peerDependencies:
1979
1979
+
svelte: ^5.0.0
1980
1980
+
1848
1981
svelte@5.48.0:
1849
1982
resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==, tarball: https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz}
1850
1983
engines: {node: '>=18'}
···
1852
1985
tabbable@6.4.0:
1853
1986
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==, tarball: https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz}
1854
1987
1988
1988
+
tailwind-merge@3.5.0:
1989
1989
+
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==, tarball: https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz}
1990
1990
+
1991
1991
+
tailwind-variants@3.2.2:
1992
1992
+
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==, tarball: https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz}
1993
1993
+
engines: {node: '>=16.x', pnpm: '>=7.x'}
1994
1994
+
peerDependencies:
1995
1995
+
tailwind-merge: '>=3.0.0'
1996
1996
+
tailwindcss: '*'
1997
1997
+
peerDependenciesMeta:
1998
1998
+
tailwind-merge:
1999
1999
+
optional: true
2000
2000
+
1855
2001
tailwindcss@4.1.18:
1856
2002
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==, tarball: https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz}
1857
2003
···
1863
2009
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==, tarball: https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz}
1864
2010
engines: {node: '>=12.0.0'}
1865
2011
2012
2012
+
tlds@1.261.0:
2013
2013
+
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==, tarball: https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz}
2014
2014
+
hasBin: true
2015
2015
+
1866
2016
totalist@3.0.1:
1867
2017
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==, tarball: https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz}
1868
2018
engines: {node: '>=6'}
···
1897
2047
engines: {node: '>=14.17'}
1898
2048
hasBin: true
1899
2049
2050
2050
+
uint8arrays@3.0.0:
2051
2051
+
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==, tarball: https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz}
2052
2052
+
1900
2053
undici-types@7.16.0:
1901
2054
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz}
1902
2055
···
1912
2065
1913
2066
uri-js@4.4.1:
1914
2067
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, tarball: https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz}
2068
2068
+
2069
2069
+
url-polyfill@1.1.14:
2070
2070
+
resolution: {integrity: sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==, tarball: https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz}
1915
2071
1916
2072
util-deprecate@1.0.2:
1917
2073
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, tarball: https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz}
···
2029
2185
zimmerframe@1.1.4:
2030
2186
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==, tarball: https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz}
2031
2187
2188
2188
+
zod@3.25.76:
2189
2189
+
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, tarball: https://registry.npmjs.org/zod/-/zod-3.25.76.tgz}
2190
2190
+
2032
2191
snapshots:
2033
2192
2034
2193
'@atcute/atproto@3.1.10':
···
2131
2290
dependencies:
2132
2291
unicode-segmenter: 0.14.5
2133
2292
2293
2293
+
'@atproto/api@0.18.21':
2294
2294
+
dependencies:
2295
2295
+
'@atproto/common-web': 0.4.17
2296
2296
+
'@atproto/lexicon': 0.6.1
2297
2297
+
'@atproto/syntax': 0.4.3
2298
2298
+
'@atproto/xrpc': 0.7.7
2299
2299
+
await-lock: 2.2.2
2300
2300
+
multiformats: 9.9.0
2301
2301
+
tlds: 1.261.0
2302
2302
+
zod: 3.25.76
2303
2303
+
2304
2304
+
'@atproto/common-web@0.4.17':
2305
2305
+
dependencies:
2306
2306
+
'@atproto/lex-data': 0.0.12
2307
2307
+
'@atproto/lex-json': 0.0.12
2308
2308
+
'@atproto/syntax': 0.4.3
2309
2309
+
zod: 3.25.76
2310
2310
+
2311
2311
+
'@atproto/lex-data@0.0.12':
2312
2312
+
dependencies:
2313
2313
+
multiformats: 9.9.0
2314
2314
+
tslib: 2.8.1
2315
2315
+
uint8arrays: 3.0.0
2316
2316
+
unicode-segmenter: 0.14.5
2317
2317
+
2318
2318
+
'@atproto/lex-json@0.0.12':
2319
2319
+
dependencies:
2320
2320
+
'@atproto/lex-data': 0.0.12
2321
2321
+
tslib: 2.8.1
2322
2322
+
2323
2323
+
'@atproto/lexicon@0.6.1':
2324
2324
+
dependencies:
2325
2325
+
'@atproto/common-web': 0.4.17
2326
2326
+
'@atproto/syntax': 0.4.3
2327
2327
+
iso-datestring-validator: 2.2.2
2328
2328
+
multiformats: 9.9.0
2329
2329
+
zod: 3.25.76
2330
2330
+
2331
2331
+
'@atproto/syntax@0.4.3':
2332
2332
+
dependencies:
2333
2333
+
tslib: 2.8.1
2334
2334
+
2335
2335
+
'@atproto/xrpc@0.7.7':
2336
2336
+
dependencies:
2337
2337
+
'@atproto/lexicon': 0.6.1
2338
2338
+
zod: 3.25.76
2339
2339
+
2134
2340
'@badrap/valita@0.4.6': {}
2135
2341
2136
2342
'@cloudflare/kv-asset-handler@0.4.2': {}
···
2390
2596
2391
2597
'@floating-ui/utils@0.2.10': {}
2392
2598
2599
2599
+
'@foxui/core@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)':
2600
2600
+
dependencies:
2601
2601
+
'@number-flow/svelte': 0.3.13(svelte@5.48.0)
2602
2602
+
bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)
2603
2603
+
clsx: 2.1.1
2604
2604
+
mode-watcher: 1.1.0(svelte@5.48.0)
2605
2605
+
svelte: 5.48.0
2606
2606
+
svelte-sonner: 1.0.7(svelte@5.48.0)
2607
2607
+
tailwind-merge: 3.5.0
2608
2608
+
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18)
2609
2609
+
tailwindcss: 4.1.18
2610
2610
+
transitivePeerDependencies:
2611
2611
+
- '@internationalized/date'
2612
2612
+
- '@sveltejs/kit'
2613
2613
+
2614
2614
+
'@foxui/social@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)':
2615
2615
+
dependencies:
2616
2616
+
'@atproto/api': 0.18.21
2617
2617
+
'@foxui/core': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
2618
2618
+
'@foxui/time': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
2619
2619
+
'@use-gesture/vanilla': 10.3.1
2620
2620
+
bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)
2621
2621
+
emoji-picker-element: 1.29.0
2622
2622
+
hls.js: 1.6.15
2623
2623
+
is-emoji-supported: 0.0.5
2624
2624
+
plyr: 3.8.4
2625
2625
+
svelte: 5.48.0
2626
2626
+
tailwindcss: 4.1.18
2627
2627
+
transitivePeerDependencies:
2628
2628
+
- '@internationalized/date'
2629
2629
+
- '@sveltejs/kit'
2630
2630
+
2631
2631
+
'@foxui/time@0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)':
2632
2632
+
dependencies:
2633
2633
+
'@foxui/core': 0.5.1(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(tailwindcss@4.1.18)
2634
2634
+
'@number-flow/svelte': 0.3.13(svelte@5.48.0)
2635
2635
+
bits-ui: 2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)
2636
2636
+
svelte: 5.48.0
2637
2637
+
tailwindcss: 4.1.18
2638
2638
+
transitivePeerDependencies:
2639
2639
+
- '@internationalized/date'
2640
2640
+
- '@sveltejs/kit'
2641
2641
+
2393
2642
'@humanfs/core@0.19.1': {}
2394
2643
2395
2644
'@humanfs/node@0.16.7':
···
2524
2773
dependencies:
2525
2774
'@jridgewell/resolve-uri': 3.1.2
2526
2775
'@jridgewell/sourcemap-codec': 1.5.5
2776
2776
+
2777
2777
+
'@number-flow/svelte@0.3.13(svelte@5.48.0)':
2778
2778
+
dependencies:
2779
2779
+
esm-env: 1.2.2
2780
2780
+
number-flow: 0.5.12
2781
2781
+
svelte: 5.48.0
2527
2782
2528
2783
'@polka/url@1.0.0-next.29': {}
2529
2784
···
2851
3106
'@typescript-eslint/types': 8.53.1
2852
3107
eslint-visitor-keys: 4.2.1
2853
3108
3109
3109
+
'@use-gesture/core@10.3.1': {}
3110
3110
+
3111
3111
+
'@use-gesture/vanilla@10.3.1':
3112
3112
+
dependencies:
3113
3113
+
'@use-gesture/core': 10.3.1
3114
3114
+
2854
3115
acorn-jsx@5.3.2(acorn@8.15.0):
2855
3116
dependencies:
2856
3117
acorn: 8.15.0
···
2872
3133
2873
3134
aria-query@5.3.2: {}
2874
3135
3136
3136
+
await-lock@2.2.2: {}
3137
3137
+
2875
3138
axobject-query@4.1.0: {}
2876
3139
2877
3140
balanced-match@1.0.2: {}
···
2889
3152
transitivePeerDependencies:
2890
3153
- '@sveltejs/kit'
2891
3154
3155
3155
+
bits-ui@2.16.2(@internationalized/date@3.10.1)(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0):
3156
3156
+
dependencies:
3157
3157
+
'@floating-ui/core': 1.7.3
3158
3158
+
'@floating-ui/dom': 1.7.4
3159
3159
+
'@internationalized/date': 3.10.1
3160
3160
+
esm-env: 1.2.2
3161
3161
+
runed: 0.35.1(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)
3162
3162
+
svelte: 5.48.0
3163
3163
+
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)
3164
3164
+
tabbable: 6.4.0
3165
3165
+
transitivePeerDependencies:
3166
3166
+
- '@sveltejs/kit'
3167
3167
+
2892
3168
blake3-wasm@2.1.5: {}
2893
3169
2894
3170
brace-expansion@1.1.12:
···
2929
3205
2930
3206
cookie@1.1.1: {}
2931
3207
3208
3208
+
core-js@3.48.0: {}
3209
3209
+
2932
3210
cross-spawn@7.0.6:
2933
3211
dependencies:
2934
3212
path-key: 3.1.1
···
2936
3214
which: 2.0.2
2937
3215
2938
3216
cssesc@3.0.0: {}
3217
3217
+
3218
3218
+
custom-event-polyfill@1.0.7: {}
2939
3219
2940
3220
debug@4.4.3:
2941
3221
dependencies:
···
2951
3231
2952
3232
devalue@5.6.2: {}
2953
3233
3234
3234
+
emoji-picker-element@1.29.0: {}
3235
3235
+
2954
3236
enhanced-resolve@5.18.4:
2955
3237
dependencies:
2956
3238
graceful-fs: 4.2.11
···
3161
3443
3162
3444
has-flag@4.0.0: {}
3163
3445
3446
3446
+
hls.js@1.6.15: {}
3447
3447
+
3164
3448
ignore@5.3.2: {}
3165
3449
3166
3450
ignore@7.0.5: {}
···
3174
3458
3175
3459
inline-style-parser@0.2.7: {}
3176
3460
3461
3461
+
is-emoji-supported@0.0.5: {}
3462
3462
+
3177
3463
is-extglob@2.1.1: {}
3178
3464
3179
3465
is-glob@4.0.3:
···
3185
3471
'@types/estree': 1.0.8
3186
3472
3187
3473
isexe@2.0.0: {}
3474
3474
+
3475
3475
+
iso-datestring-validator@2.2.2: {}
3188
3476
3189
3477
jiti@2.6.1: {}
3190
3478
···
3262
3550
3263
3551
lilconfig@2.1.0: {}
3264
3552
3553
3553
+
loadjs@4.3.0: {}
3554
3554
+
3265
3555
locate-character@3.0.0: {}
3266
3556
3267
3557
locate-path@6.0.0:
···
3298
3588
dependencies:
3299
3589
brace-expansion: 2.0.2
3300
3590
3591
3591
+
mode-watcher@1.1.0(svelte@5.48.0):
3592
3592
+
dependencies:
3593
3593
+
runed: 0.25.0(svelte@5.48.0)
3594
3594
+
svelte: 5.48.0
3595
3595
+
svelte-toolbelt: 0.7.1(svelte@5.48.0)
3596
3596
+
3301
3597
mri@1.2.0: {}
3302
3598
3303
3599
mrmime@2.0.1: {}
3304
3600
3305
3601
ms@2.1.3: {}
3306
3602
3603
3603
+
multiformats@9.9.0: {}
3604
3604
+
3307
3605
nanoid@3.3.11: {}
3308
3606
3309
3607
nanoid@5.1.6: {}
···
3311
3609
natural-compare@1.4.0: {}
3312
3610
3313
3611
node-gyp-build@4.8.4: {}
3612
3612
+
3613
3613
+
number-flow@0.5.12:
3614
3614
+
dependencies:
3615
3615
+
esm-env: 1.2.2
3314
3616
3315
3617
obug@2.1.1: {}
3316
3618
···
3347
3649
3348
3650
picomatch@4.0.3: {}
3349
3651
3652
3652
+
plyr@3.8.4:
3653
3653
+
dependencies:
3654
3654
+
core-js: 3.48.0
3655
3655
+
custom-event-polyfill: 1.0.7
3656
3656
+
loadjs: 4.3.0
3657
3657
+
rangetouch: 2.0.1
3658
3658
+
url-polyfill: 1.1.14
3659
3659
+
3350
3660
postcss-load-config@3.1.4(postcss@8.5.6):
3351
3661
dependencies:
3352
3662
lilconfig: 2.1.0
···
3390
3700
3391
3701
punycode@2.3.1: {}
3392
3702
3703
3703
+
rangetouch@2.0.1: {}
3704
3704
+
3393
3705
readdirp@4.1.2: {}
3394
3706
3395
3707
regexparam@3.0.0: {}
···
3429
3741
'@rollup/rollup-win32-x64-msvc': 4.56.0
3430
3742
fsevents: 2.3.3
3431
3743
3744
3744
+
runed@0.23.4(svelte@5.48.0):
3745
3745
+
dependencies:
3746
3746
+
esm-env: 1.2.2
3747
3747
+
svelte: 5.48.0
3748
3748
+
3749
3749
+
runed@0.25.0(svelte@5.48.0):
3750
3750
+
dependencies:
3751
3751
+
esm-env: 1.2.2
3752
3752
+
svelte: 5.48.0
3753
3753
+
3754
3754
+
runed@0.28.0(svelte@5.48.0):
3755
3755
+
dependencies:
3756
3756
+
esm-env: 1.2.2
3757
3757
+
svelte: 5.48.0
3758
3758
+
3432
3759
runed@0.35.1(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0):
3433
3760
dependencies:
3434
3761
dequal: 2.0.3
···
3526
3853
optionalDependencies:
3527
3854
svelte: 5.48.0
3528
3855
3856
3856
+
svelte-sonner@1.0.7(svelte@5.48.0):
3857
3857
+
dependencies:
3858
3858
+
runed: 0.28.0(svelte@5.48.0)
3859
3859
+
svelte: 5.48.0
3860
3860
+
3529
3861
svelte-toolbelt@0.10.6(@sveltejs/kit@2.50.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(svelte@5.48.0):
3530
3862
dependencies:
3531
3863
clsx: 2.1.1
···
3535
3867
transitivePeerDependencies:
3536
3868
- '@sveltejs/kit'
3537
3869
3870
3870
+
svelte-toolbelt@0.7.1(svelte@5.48.0):
3871
3871
+
dependencies:
3872
3872
+
clsx: 2.1.1
3873
3873
+
runed: 0.23.4(svelte@5.48.0)
3874
3874
+
style-to-object: 1.0.14
3875
3875
+
svelte: 5.48.0
3876
3876
+
3538
3877
svelte@5.48.0:
3539
3878
dependencies:
3540
3879
'@jridgewell/remapping': 2.3.5
···
3555
3894
3556
3895
tabbable@6.4.0: {}
3557
3896
3897
3897
+
tailwind-merge@3.5.0: {}
3898
3898
+
3899
3899
+
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.1.18):
3900
3900
+
dependencies:
3901
3901
+
tailwindcss: 4.1.18
3902
3902
+
optionalDependencies:
3903
3903
+
tailwind-merge: 3.5.0
3904
3904
+
3558
3905
tailwindcss@4.1.18: {}
3559
3906
3560
3907
tapable@2.3.0: {}
···
3563
3910
dependencies:
3564
3911
fdir: 6.5.0(picomatch@4.0.3)
3565
3912
picomatch: 4.0.3
3913
3913
+
3914
3914
+
tlds@1.261.0: {}
3566
3915
3567
3916
totalist@3.0.1: {}
3568
3917
···
3596
3945
3597
3946
typescript@5.9.3: {}
3598
3947
3948
3948
+
uint8arrays@3.0.0:
3949
3949
+
dependencies:
3950
3950
+
multiformats: 9.9.0
3951
3951
+
3599
3952
undici-types@7.16.0: {}
3600
3953
3601
3954
undici@7.18.2: {}
···
3609
3962
uri-js@4.4.1:
3610
3963
dependencies:
3611
3964
punycode: 2.3.1
3965
3965
+
3966
3966
+
url-polyfill@1.1.14: {}
3612
3967
3613
3968
util-deprecate@1.0.2: {}
3614
3969
···
3691
4046
youch-core: 0.3.3
3692
4047
3693
4048
zimmerframe@1.1.4: {}
4049
4049
+
4050
4050
+
zod@3.25.76: {}
···
1
1
-
import { generateClientAssertionKey } from '@atcute/oauth-node-client';
2
2
-
3
3
-
const key = await generateClientAssertionKey('main-key');
4
4
-
const json = JSON.stringify(key);
5
5
-
6
6
-
console.log('Generated client assertion key.\n');
7
7
-
console.log('Set it as a Cloudflare Workers secret:\n');
8
8
-
console.log(' npx wrangler secret put CLIENT_ASSERTION_KEY\n');
9
9
-
console.log('Then paste this value:\n');
10
10
-
console.log(json);
···
1
1
@import 'tailwindcss';
2
2
3
3
@plugin '@tailwindcss/forms';
4
4
+
@source "../node_modules/@foxui";
4
5
5
6
/* @custom-variant dark (&:where(.dark, .dark *)); */
6
7
···
10
10
interface Locals {
11
11
session: OAuthSession | null;
12
12
client: Client | null;
13
13
-
did: Did | undefined;
13
13
+
did: Did | null;
14
14
}
15
15
// interface PageData {}
16
16
// interface PageState {}
···
19
19
OAUTH_SESSIONS: KVNamespace;
20
20
OAUTH_STATES: KVNamespace;
21
21
CLIENT_ASSERTION_KEY: string;
22
22
+
COOKIE_SECRET: string;
23
23
+
OAUTH_PUBLIC_URL: string;
24
24
+
PROFILE_CACHE?: KVNamespace;
22
25
};
23
26
}
24
27
}
···
1
1
import type { Handle } from '@sveltejs/kit';
2
2
-
import { createOAuthClient } from '$lib/server/oauth';
3
3
-
import { Client } from '@atcute/client';
4
4
-
import type { Did } from '@atcute/lexicons';
2
2
+
import { restoreSession } from '$lib/atproto/server/session';
5
3
6
4
export const handle: Handle = async ({ event, resolve }) => {
7
7
-
event.locals.session = null;
8
8
-
event.locals.client = null;
9
9
-
event.locals.did = undefined;
5
5
+
const { session, client, did } = await restoreSession(
6
6
+
event.cookies,
7
7
+
event.platform?.env
8
8
+
);
10
9
11
11
-
const did = event.cookies.get('did') as Did | undefined;
12
12
-
13
13
-
if (did) {
14
14
-
try {
15
15
-
const oauth = createOAuthClient(event.platform?.env);
16
16
-
const session = await oauth.restore(did);
17
17
-
18
18
-
event.locals.session = session;
19
19
-
event.locals.client = new Client({ handler: session });
20
20
-
event.locals.did = did;
21
21
-
} catch (e) {
22
22
-
console.error('Failed to restore session:', e);
23
23
-
event.cookies.delete('did', { path: '/' });
24
24
-
}
25
25
-
}
10
10
+
event.locals.session = session;
11
11
+
event.locals.client = client;
12
12
+
event.locals.did = did;
26
13
27
14
return resolve(event);
28
15
};
···
1
1
-
<script lang="ts">
2
2
-
import { Avatar as AvatarPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
3
3
-
4
4
-
let {
5
5
-
src,
6
6
-
alt,
7
7
-
fallback,
8
8
-
ref = $bindable(null),
9
9
-
10
10
-
imageRef = $bindable(null),
11
11
-
imageClass,
12
12
-
13
13
-
fallbackRef = $bindable(null),
14
14
-
fallbackClass,
15
15
-
16
16
-
class: className,
17
17
-
...restProps
18
18
-
}: WithoutChildrenOrChild<AvatarPrimitive.RootProps> & {
19
19
-
fallback?: string;
20
20
-
imageRef?: HTMLImageElement | null;
21
21
-
imageClass?: string;
22
22
-
fallbackRef?: HTMLElement | null;
23
23
-
fallbackClass?: string;
24
24
-
25
25
-
src?: string;
26
26
-
alt?: string;
27
27
-
} = $props();
28
28
-
</script>
29
29
-
30
30
-
<div
31
31
-
class={[
32
32
-
'border-base-300 bg-base-200 text-base-900 dark:border-base-800 dark:bg-base-900 dark:text-base-50 relative isolate flex size-6 shrink-0 overflow-hidden rounded-full border',
33
33
-
className
34
34
-
]}
35
35
-
{...restProps}
36
36
-
bind:this={ref}
37
37
-
>
38
38
-
{#if fallback}
39
39
-
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 font-medium"
40
40
-
>{fallback}</span
41
41
-
>
42
42
-
{:else}
43
43
-
<svg
44
44
-
xmlns="http://www.w3.org/2000/svg"
45
45
-
viewBox="0 0 24 24"
46
46
-
fill="currentColor"
47
47
-
class="text-base-400 dark:text-base-600 absolute top-1/2 left-1/2 mt-[15%] size-full -translate-x-1/2 -translate-y-1/2"
48
48
-
>
49
49
-
<path
50
50
-
fill-rule="evenodd"
51
51
-
d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z"
52
52
-
clip-rule="evenodd"
53
53
-
/>
54
54
-
</svg>
55
55
-
{/if}
56
56
-
{#if src}
57
57
-
<img
58
58
-
{src}
59
59
-
alt={alt ?? ''}
60
60
-
class="z-10 aspect-square size-full object-cover"
61
61
-
onerror={() => {
62
62
-
imageRef?.classList.add('hidden');
63
63
-
}}
64
64
-
/>
65
65
-
{/if}
66
66
-
</div>
···
1
1
-
<script lang="ts">
2
2
-
import type { HTMLButtonAttributes } from 'svelte/elements';
3
3
-
4
4
-
type Props = HTMLButtonAttributes & {
5
5
-
children: () => any;
6
6
-
ref?: HTMLButtonElement | null;
7
7
-
8
8
-
};
9
9
-
10
10
-
let { children, ref = $bindable(), class: className, ...props }: Props = $props();
11
11
-
</script>
12
12
-
13
13
-
<button
14
14
-
bind:this={ref}
15
15
-
class={[
16
16
-
'bg-accent-600 hover:bg-accent-500 focus-visible:outline-accent-600 text-white',
17
17
-
'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
18
18
-
className
19
19
-
]}
20
20
-
{...props}
21
21
-
>
22
22
-
{@render children()}
23
23
-
</button>
···
1
1
-
<script lang="ts">
2
2
-
import { AppBskyActorDefs } from '@atcute/bluesky';
3
3
-
import { Combobox } from 'bits-ui';
4
4
-
import { searchActorsTypeahead } from '$lib/atproto';
5
5
-
import Avatar from './Avatar.svelte';
6
6
-
7
7
-
let results: AppBskyActorDefs.ProfileViewBasic[] = $state([]);
8
8
-
9
9
-
async function search(q: string) {
10
10
-
if (!q || q.length < 2) {
11
11
-
results = [];
12
12
-
return;
13
13
-
}
14
14
-
results = (await searchActorsTypeahead(q, 5)).actors;
15
15
-
}
16
16
-
let open = $state(false);
17
17
-
18
18
-
let {
19
19
-
value = $bindable(),
20
20
-
onselected,
21
21
-
ref = $bindable()
22
22
-
}: {
23
23
-
value: string;
24
24
-
onselected: (actor: AppBskyActorDefs.ProfileViewBasic) => void;
25
25
-
ref?: HTMLInputElement | null;
26
26
-
} = $props();
27
27
-
</script>
28
28
-
29
29
-
<Combobox.Root
30
30
-
type="single"
31
31
-
onOpenChangeComplete={(o) => {
32
32
-
if (!o) results = [];
33
33
-
}}
34
34
-
bind:value={
35
35
-
() => {
36
36
-
return value;
37
37
-
},
38
38
-
(val) => {
39
39
-
const profile = results.find((v) => v.handle === val);
40
40
-
if (profile) onselected?.(profile);
41
41
-
42
42
-
value = val;
43
43
-
}
44
44
-
}
45
45
-
bind:open={
46
46
-
() => {
47
47
-
return open && results.length > 0;
48
48
-
},
49
49
-
(val) => {
50
50
-
open = val;
51
51
-
}
52
52
-
}
53
53
-
>
54
54
-
<Combobox.Input
55
55
-
bind:ref
56
56
-
oninput={(e) => {
57
57
-
value = e.currentTarget.value;
58
58
-
search(e.currentTarget.value);
59
59
-
}}
60
60
-
class="w-full touch-none rounded-full border-0 bg-white ring-0 outline-1 -outline-offset-1 outline-gray-300 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-accent-600 dark:bg-white/5 dark:outline-white/10 dark:focus-within:outline-accent-500 dark:placeholder:text-base-400"
61
61
-
placeholder="handle"
62
62
-
id=""
63
63
-
aria-label="enter your handle"
64
64
-
/>
65
65
-
<Combobox.Content
66
66
-
class="z-100 max-h-[30dvh] w-full rounded-2xl border border-base-300 bg-base-50 dark:bg-base-900 dark:border-base-800 shadow-lg"
67
67
-
sideOffset={10}
68
68
-
align="start"
69
69
-
side="top"
70
70
-
>
71
71
-
<Combobox.Viewport class="w-full p-1">
72
72
-
{#each results as actor (actor.did)}
73
73
-
<Combobox.Item
74
74
-
class="rounded-button my-0.5 flex w-full cursor-pointer items-center gap-2 rounded-xl p-2 px-2 data-highlighted:bg-accent-100 dark:data-highlighted:bg-accent-600/30"
75
75
-
value={actor.handle}
76
76
-
label={actor.handle}
77
77
-
>
78
78
-
{#snippet children()}
79
79
-
<Avatar
80
80
-
src={actor.avatar?.replace('avatar', 'avatar_thumbnail')}
81
81
-
alt=""
82
82
-
class="size-6 rounded-full"
83
83
-
/>
84
84
-
{actor.handle}
85
85
-
{/snippet}
86
86
-
</Combobox.Item>
87
87
-
{/each}
88
88
-
</Combobox.Viewport>
89
89
-
</Combobox.Content>
90
90
-
</Combobox.Root>
···
1
1
-
<script lang="ts" module>
2
2
-
export const loginModalState = $state({
3
3
-
visible: false,
4
4
-
show: () => (loginModalState.visible = true),
5
5
-
hide: () => (loginModalState.visible = false)
6
6
-
});
7
7
-
</script>
8
8
-
9
9
-
<script lang="ts">
10
10
-
import { login, signup } from '$lib/atproto';
11
11
-
import type { ActorIdentifier, Did } from '@atcute/lexicons';
12
12
-
import Button from './Button.svelte';
13
13
-
import { onMount, tick } from 'svelte';
14
14
-
import SecondaryButton from './SecondaryButton.svelte';
15
15
-
import HandleInput from './HandleInput.svelte';
16
16
-
import { AppBskyActorDefs } from '@atcute/bluesky';
17
17
-
import Avatar from './Avatar.svelte';
18
18
-
19
19
-
let { signUp = true, loginOnSelect = true }: { signUp?: boolean; loginOnSelect?: boolean } =
20
20
-
$props();
21
21
-
22
22
-
function extractErrorMessage(err: unknown): string {
23
23
-
const msg = err instanceof Error ? err.message : String(err);
24
24
-
try {
25
25
-
const parsed = JSON.parse(msg);
26
26
-
if (typeof parsed?.message === 'string') return parsed.message;
27
27
-
} catch {}
28
28
-
return msg;
29
29
-
}
30
30
-
31
31
-
let value = $state('');
32
32
-
let error: string | null = $state(null);
33
33
-
let loadingLogin = $state(false);
34
34
-
let loadingSignup = $state(false);
35
35
-
36
36
-
async function onSubmit(event?: Event) {
37
37
-
event?.preventDefault();
38
38
-
if (loadingLogin) return;
39
39
-
40
40
-
error = null;
41
41
-
loadingLogin = true;
42
42
-
43
43
-
try {
44
44
-
await login(value as ActorIdentifier);
45
45
-
} catch (err) {
46
46
-
error = extractErrorMessage(err);
47
47
-
} finally {
48
48
-
loadingLogin = false;
49
49
-
}
50
50
-
}
51
51
-
52
52
-
let input: HTMLInputElement | null = $state(null);
53
53
-
let submitButton: HTMLButtonElement | null = $state(null);
54
54
-
55
55
-
$effect(() => {
56
56
-
if (!loginModalState.visible) {
57
57
-
error = null;
58
58
-
value = '';
59
59
-
loadingLogin = false;
60
60
-
selectedActor = undefined;
61
61
-
} else {
62
62
-
focusInput();
63
63
-
}
64
64
-
});
65
65
-
66
66
-
function focusInput() {
67
67
-
tick().then(() => {
68
68
-
input?.focus();
69
69
-
});
70
70
-
}
71
71
-
function focusSubmit() {
72
72
-
tick().then(() => {
73
73
-
submitButton?.focus();
74
74
-
});
75
75
-
}
76
76
-
77
77
-
let selectedActor: AppBskyActorDefs.ProfileViewBasic | undefined = $state();
78
78
-
79
79
-
let recentLogins: Record<Did, AppBskyActorDefs.ProfileViewBasic> = $state({});
80
80
-
81
81
-
onMount(() => {
82
82
-
try {
83
83
-
recentLogins = JSON.parse(localStorage.getItem('recent-logins') || '{}');
84
84
-
} catch {}
85
85
-
});
86
86
-
87
87
-
function removeRecentLogin(did: Did) {
88
88
-
try {
89
89
-
delete recentLogins[did];
90
90
-
91
91
-
localStorage.setItem('recent-logins', JSON.stringify(recentLogins));
92
92
-
} catch {}
93
93
-
}
94
94
-
95
95
-
let recentLoginsView = $state(true);
96
96
-
97
97
-
let showRecentLogins = $derived(
98
98
-
Object.keys(recentLogins).length > 0 && !loadingLogin && !selectedActor && recentLoginsView
99
99
-
);
100
100
-
</script>
101
101
-
102
102
-
{#if loginModalState.visible}
103
103
-
<div
104
104
-
class="fixed inset-0 z-100 w-screen overflow-y-auto"
105
105
-
aria-labelledby="modal-title"
106
106
-
role="dialog"
107
107
-
aria-modal="true"
108
108
-
>
109
109
-
<div
110
110
-
class="bg-base-50/90 dark:bg-base-950/90 fixed inset-0 backdrop-blur-sm transition-opacity"
111
111
-
onclick={() => (loginModalState.visible = false)}
112
112
-
aria-hidden="true"
113
113
-
></div>
114
114
-
115
115
-
<div class="pointer-events-none fixed inset-0 isolate z-10 w-screen overflow-y-auto">
116
116
-
<div
117
117
-
class="flex min-h-full w-screen items-end justify-center p-4 text-center sm:items-center sm:p-0"
118
118
-
>
119
119
-
<div
120
120
-
class="border-base-200 bg-base-100 dark:border-base-700 dark:bg-base-800 pointer-events-auto relative w-full transform overflow-hidden rounded-2xl border px-4 pt-4 pb-4 text-left shadow-xl transition-all sm:my-8 sm:max-w-sm sm:p-6"
121
121
-
>
122
122
-
<h3 class="text-base-900 dark:text-base-100 font-semibold" id="modal-title">
123
123
-
Login with your internet handle
124
124
-
</h3>
125
125
-
126
126
-
<div class="text-base-800 dark:text-base-200 mt-2 mb-2 text-xs font-light">
127
127
-
e.g. your bluesky account
128
128
-
</div>
129
129
-
130
130
-
<form onsubmit={onSubmit} class="mt-2 flex w-full flex-col gap-2">
131
131
-
{#if showRecentLogins}
132
132
-
<div class="mt-2 mb-2 text-sm font-medium">Recent logins</div>
133
133
-
<div class="flex flex-col gap-2">
134
134
-
{#each Object.values(recentLogins)
135
135
-
.filter((l) => l.handle && l.handle !== 'handle.invalid')
136
136
-
.slice(0, 4) as recentLogin}
137
137
-
<div class="group">
138
138
-
<div
139
139
-
class="group-hover:bg-base-300 bg-base-200 dark:bg-base-700 dark:hover:bg-base-600 dark:border-base-500/50 border-base-300 relative flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold transition-colors duration-100"
140
140
-
>
141
141
-
<div class="flex items-center gap-2">
142
142
-
<Avatar class="size-6" src={recentLogin.avatar} />
143
143
-
{recentLogin.handle}
144
144
-
</div>
145
145
-
<button
146
146
-
class="z-20 cursor-pointer"
147
147
-
onclick={() => {
148
148
-
value = recentLogin.handle;
149
149
-
selectedActor = recentLogin;
150
150
-
if (loginOnSelect) onSubmit();
151
151
-
else focusSubmit();
152
152
-
}}
153
153
-
>
154
154
-
<div class="absolute inset-0 h-full w-full"></div>
155
155
-
<span class="sr-only">login</span>
156
156
-
</button>
157
157
-
158
158
-
<button
159
159
-
onclick={() => {
160
160
-
removeRecentLogin(recentLogin.did);
161
161
-
}}
162
162
-
class="z-30 cursor-pointer rounded-full p-0.5"
163
163
-
>
164
164
-
<svg
165
165
-
xmlns="http://www.w3.org/2000/svg"
166
166
-
fill="none"
167
167
-
viewBox="0 0 24 24"
168
168
-
stroke-width="1.5"
169
169
-
stroke="currentColor"
170
170
-
class="size-3"
171
171
-
>
172
172
-
<path
173
173
-
stroke-linecap="round"
174
174
-
stroke-linejoin="round"
175
175
-
d="M6 18 18 6M6 6l12 12"
176
176
-
/>
177
177
-
</svg>
178
178
-
<span class="sr-only">sign in with other account</span>
179
179
-
</button>
180
180
-
</div>
181
181
-
</div>
182
182
-
{/each}
183
183
-
</div>
184
184
-
{:else if !selectedActor}
185
185
-
<div class="mt-4 w-full">
186
186
-
<HandleInput
187
187
-
bind:value
188
188
-
onselected={(a) => {
189
189
-
selectedActor = a;
190
190
-
value = a.handle;
191
191
-
if (loginOnSelect) onSubmit();
192
192
-
else focusSubmit();
193
193
-
}}
194
194
-
bind:ref={input}
195
195
-
/>
196
196
-
</div>
197
197
-
{:else}
198
198
-
<div
199
199
-
class="bg-base-200 dark:bg-base-700 border-base-300 dark:border-base-600 mt-4 flex h-10 w-full items-center justify-between gap-2 rounded-full border px-2 font-semibold"
200
200
-
>
201
201
-
<div class="flex items-center gap-2">
202
202
-
<Avatar class="size-6" src={selectedActor.avatar} />
203
203
-
{selectedActor.handle}
204
204
-
</div>
205
205
-
206
206
-
<button
207
207
-
onclick={() => {
208
208
-
selectedActor = undefined;
209
209
-
value = '';
210
210
-
}}
211
211
-
type="button"
212
212
-
class="cursor-pointer rounded-full p-0.5"
213
213
-
>
214
214
-
<svg
215
215
-
xmlns="http://www.w3.org/2000/svg"
216
216
-
fill="none"
217
217
-
viewBox="0 0 24 24"
218
218
-
stroke-width="1.5"
219
219
-
stroke="currentColor"
220
220
-
class="size-3"
221
221
-
>
222
222
-
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
223
223
-
</svg>
224
224
-
<span class="sr-only">sign in with other account</span>
225
225
-
</button>
226
226
-
</div>
227
227
-
{/if}
228
228
-
229
229
-
{#if error}
230
230
-
<p class="text-accent-500 text-sm font-semibold">{error}</p>
231
231
-
{/if}
232
232
-
233
233
-
<div class="mt-4">
234
234
-
{#if showRecentLogins}
235
235
-
<div class="mt-2 mb-4 text-sm font-medium">Or login with new handle</div>
236
236
-
237
237
-
<Button
238
238
-
onclick={() => {
239
239
-
recentLoginsView = false;
240
240
-
focusInput();
241
241
-
}}
242
242
-
class="w-full">Login with new handle</Button
243
243
-
>
244
244
-
{:else}
245
245
-
<Button bind:ref={submitButton} type="submit" disabled={loadingLogin} class="w-full"
246
246
-
>{loadingLogin ? 'Loading...' : 'Login'}</Button
247
247
-
>
248
248
-
{/if}
249
249
-
</div>
250
250
-
251
251
-
{#if signUp}
252
252
-
<div
253
253
-
class="border-base-200 dark:border-base-700 text-base-800 dark:text-base-200 mt-4 border-t pt-4 text-sm leading-7"
254
254
-
>
255
255
-
Don't have an account?
256
256
-
<div class="mt-3">
257
257
-
<SecondaryButton
258
258
-
onclick={async () => {
259
259
-
loadingSignup = true;
260
260
-
await signup();
261
261
-
}}
262
262
-
disabled={loadingSignup}
263
263
-
class="w-full">{loadingSignup ? 'Loading...' : 'Sign Up'}</SecondaryButton
264
264
-
>
265
265
-
</div>
266
266
-
</div>
267
267
-
{/if}
268
268
-
</form>
269
269
-
</div>
270
270
-
</div>
271
271
-
</div>
272
272
-
</div>
273
273
-
{/if}
···
1
1
-
<script lang="ts">
2
2
-
import type { HTMLButtonAttributes } from 'svelte/elements';
3
3
-
4
4
-
type Props = HTMLButtonAttributes & {
5
5
-
children: () => any;
6
6
-
};
7
7
-
8
8
-
let { children, class: className, ...props }: Props = $props();
9
9
-
</script>
10
10
-
11
11
-
<button
12
12
-
class={[
13
13
-
'bg-base-300 dark:bg-base-700 dark:text-base-50 dark:hover:bg-base-600 text-black transition-colors duration-100 hover:bg-base-200 focus-visible:outline-base-600',
14
14
-
'inline-flex cursor-pointer justify-center rounded-full px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
15
15
-
className
16
16
-
]}
17
17
-
{...props}
18
18
-
>
19
19
-
{@render children()}
20
20
-
</button>
···
1
1
-
export { default as LoginModal, loginModalState } from './LoginModal.svelte';
···
4
4
5
5
export const user = {
6
6
get profile() {
7
7
-
return page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | undefined;
7
7
+
return (page.data?.profile as AppBskyActorDefs.ProfileViewDetailed | null) ?? null;
8
8
},
9
9
get isLoggedIn() {
10
10
return !!page.data?.did;
11
11
},
12
12
get did() {
13
13
-
return page.data?.did as Did | undefined;
13
13
+
return (page.data?.did as Did | null) ?? null;
14
14
}
15
15
};
16
16
17
17
-
export async function login(handle: ActorIdentifier) {
17
17
+
export async function login(handle: string) {
18
18
if (handle.startsWith('did:')) {
19
19
if (handle.length < 6) throw new Error('DID must be at least 6 characters');
20
20
} else if (handle.includes('.') && handle.length > 3) {
···
27
27
throw new Error('Please provide a valid handle or DID.');
28
28
}
29
29
30
30
-
const { oauthLogin } = await import('./oauth.remote');
30
30
+
const { oauthLogin } = await import('./server/oauth.remote');
31
31
const { url } = await oauthLogin({ handle });
32
32
window.location.assign(url);
33
33
···
40
40
}
41
41
42
42
export async function signup() {
43
43
-
const { oauthLogin } = await import('./oauth.remote');
43
43
+
const { oauthLogin } = await import('./server/oauth.remote');
44
44
const { url } = await oauthLogin({ signup: true });
45
45
window.location.assign(url);
46
46
···
53
53
54
54
export async function logout() {
55
55
try {
56
56
-
const { oauthLogout } = await import('./oauth.remote');
56
56
+
const { oauthLogout } = await import('./server/oauth.remote');
57
57
await oauthLogout();
58
58
} catch (e) {
59
59
console.error('Error logging out:', e);
···
1
1
export { user, login, signup, logout } from './auth.svelte';
2
2
-
export { metadata } from './metadata';
3
2
4
3
export {
5
4
parseUri,
···
15
14
describeRepo,
16
15
getBlobURL,
17
16
getCDNImageBlobUrl,
18
18
-
searchActorsTypeahead
17
17
+
searchActorsTypeahead,
18
18
+
createTID
19
19
} from './methods';
···
1
1
-
import { permissions, REDIRECT_PATH, SITE } from './settings';
1
1
+
import { permissions } from './settings';
2
2
3
3
function constructScope() {
4
4
-
const repos = permissions.collections.map((collection) => 'repo:' + collection).join(' ');
4
4
+
const parts: string[] = ['atproto'];
5
5
6
6
-
let rpcs = '';
6
6
+
for (const collection of permissions.collections) {
7
7
+
parts.push('repo:' + collection);
8
8
+
}
9
9
+
7
10
for (const [key, value] of Object.entries(permissions.rpc ?? {})) {
8
8
-
if (Array.isArray(value)) {
9
9
-
rpcs += value.map((lxm) => 'rpc?lxm=' + lxm + '&aud=' + key).join(' ');
10
10
-
} else {
11
11
-
rpcs += 'rpc?lxm=' + value + '&aud=' + key;
11
11
+
const lxms = Array.isArray(value) ? value : [value];
12
12
+
for (const lxm of lxms) {
13
13
+
parts.push('rpc?lxm=' + lxm + '&aud=' + key);
12
14
}
13
15
}
14
16
15
15
-
let blobScope: string | undefined = undefined;
16
16
-
if (Array.isArray(permissions.blobs) && permissions.blobs.length > 0) {
17
17
-
blobScope = 'blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&');
18
18
-
} else if (permissions.blobs && permissions.blobs.length > 0) {
19
19
-
blobScope = 'blob:' + permissions.blobs;
17
17
+
if (permissions.blobs.length > 0) {
18
18
+
parts.push('blob?' + permissions.blobs.map((b) => 'accept=' + b).join('&'));
20
19
}
21
20
22
22
-
const scope = ['atproto', repos, rpcs, blobScope].filter((v) => v?.trim()).join(' ');
23
23
-
return scope;
21
21
+
return parts.join(' ');
24
22
}
25
23
26
24
export const scope = constructScope();
27
27
-
28
28
-
export function constructMetadata() {
29
29
-
return {
30
30
-
client_id: SITE + '/oauth-client-metadata.json',
31
31
-
redirect_uris: [SITE + REDIRECT_PATH] as [string],
32
32
-
scope,
33
33
-
jwks_uri: SITE + '/oauth/jwks.json'
34
34
-
};
35
35
-
}
36
36
-
37
37
-
export const metadata = constructMetadata();
···
1
1
import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons';
2
2
import { user } from './auth.svelte';
3
3
-
import type { AllowedCollection } from './settings';
3
3
+
import { DOH_RESOLVER, type AllowedCollection } from './settings';
4
4
import {
5
5
CompositeDidDocumentResolver,
6
6
CompositeHandleResolver,
···
30
30
export async function resolveHandle({ handle }: { handle: Handle }) {
31
31
const handleResolver = new CompositeHandleResolver({
32
32
methods: {
33
33
-
dns: new DohJsonHandleResolver({ dohUrl: 'https://mozilla.cloudflare-dns.com/dns-query' }),
33
33
+
dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }),
34
34
http: new WellKnownHandleResolver()
35
35
}
36
36
});
···
64
64
*/
65
65
export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
66
66
data ??= {};
67
67
-
data.did ??= user.did;
67
67
+
data.did ??= user.did ?? undefined;
68
68
69
69
if (!data.did) throw new Error('Error getting detailed profile: no did');
70
70
···
111
111
limit?: number;
112
112
client?: Client;
113
113
}) {
114
114
-
did ??= user.did;
114
114
+
did ??= user.did ?? undefined;
115
115
if (!collection) {
116
116
throw new Error('Missing parameters for listRecords');
117
117
}
118
118
if (!did) {
119
119
-
throw new Error('Missing did for getRecord');
119
119
+
throw new Error('Missing did for listRecords');
120
120
}
121
121
122
122
client ??= await getClient({ did });
···
159
159
rkey?: string;
160
160
client?: Client;
161
161
}) {
162
162
-
did ??= user.did;
162
162
+
did ??= user.did ?? undefined;
163
163
164
164
if (!collection) {
165
165
throw new Error('Missing parameters for getRecord');
···
195
195
}) {
196
196
if (!user.did) throw new Error('Not logged in');
197
197
198
198
-
const { putRecord: putRecordRemote } = await import('./repo.remote');
198
198
+
const { putRecord: putRecordRemote } = await import('./server/repo.remote');
199
199
const data = await putRecordRemote({ collection, rkey, record });
200
200
return { ok: true, data };
201
201
}
···
212
212
}) {
213
213
if (!user.did) throw new Error('Not logged in');
214
214
215
215
-
const { deleteRecord: deleteRecordRemote } = await import('./repo.remote');
215
215
+
const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote');
216
216
const data = await deleteRecordRemote({ collection, rkey });
217
217
return data.ok;
218
218
}
···
223
223
export async function uploadBlob({ blob }: { blob: Blob }) {
224
224
if (!user.did) throw new Error("Can't upload blob: Not logged in");
225
225
226
226
-
const { uploadBlob: uploadBlobRemote } = await import('./repo.remote');
226
226
+
const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote');
227
227
return await uploadBlobRemote({ blob });
228
228
}
229
229
···
231
231
* Gets metadata about a repository.
232
232
*/
233
233
export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
234
234
-
did ??= user.did;
234
234
+
did ??= user.did ?? undefined;
235
235
if (!did) {
236
236
throw new Error('Error describeRepo: No did');
237
237
}
···
281
281
};
282
282
};
283
283
}) {
284
284
-
did ??= user.did;
284
284
+
did ??= user.did ?? undefined;
285
285
286
286
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
287
287
}
···
1
1
import * as v from 'valibot';
2
2
import { error } from '@sveltejs/kit';
3
3
import { command, getRequestEvent } from '$app/server';
4
4
-
import { createOAuthClient } from '$lib/server/oauth';
5
5
-
import { scope } from '$lib/atproto/metadata';
6
6
-
import { signUpPDS } from '$lib/atproto/settings';
4
4
+
import { createOAuthClient } from './oauth';
5
5
+
import { getSignedCookie } from './signed-cookie';
6
6
+
import { scope } from '../metadata';
7
7
+
import { signUpPDS } from '../settings';
7
8
import type { ActorIdentifier, Did } from '@atcute/lexicons';
8
9
9
10
export const oauthLogin = command(
···
38
39
39
40
export const oauthLogout = command(async () => {
40
41
const { cookies, platform } = getRequestEvent();
41
41
-
const did = cookies.get('did') as Did | undefined;
42
42
+
const did = getSignedCookie(cookies, 'did') as Did | null;
42
43
43
44
if (did) {
44
45
try {
···
1
1
import { error } from '@sveltejs/kit';
2
2
import { command, getRequestEvent } from '$app/server';
3
3
import * as v from 'valibot';
4
4
+
import { permissions } from '../settings';
4
5
6
6
+
// Validate collection format and check against allowed list from settings
5
7
const collectionSchema = v.pipe(
6
8
v.string(),
7
7
-
v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/)
9
9
+
v.regex(/^[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*\.[a-zA-Z][a-zA-Z0-9-]*$/),
10
10
+
v.check(
11
11
+
(c) => permissions.collections.some((allowed) => c === allowed || allowed.startsWith(c + '?')),
12
12
+
'Collection not in allowed list'
13
13
+
)
8
14
);
9
15
16
16
+
// AT Protocol rkey: TID, 'self', or other valid record keys (alphanumeric, dash, underscore, dot)
17
17
+
const rkeySchema = v.optional(v.pipe(v.string(), v.regex(/^[a-zA-Z0-9._:~-]{1,512}$/)));
18
18
+
10
19
export const putRecord = command(
11
20
v.object({
12
21
collection: collectionSchema,
13
13
-
rkey: v.optional(v.string()),
22
22
+
rkey: rkeySchema,
14
23
record: v.record(v.string(), v.unknown())
15
24
}),
16
25
async (input) => {
···
33
42
export const deleteRecord = command(
34
43
v.object({
35
44
collection: collectionSchema,
36
36
-
rkey: v.optional(v.string())
45
45
+
rkey: rkeySchema
37
46
}),
38
47
async (input) => {
39
48
const { locals } = getRequestEvent();
···
64
73
input: input.blob
65
74
});
66
75
67
67
-
if (!response?.ok) error(500, 'Upload failed');
76
76
+
if (!response.ok) error(500, 'Upload failed');
68
77
69
78
return response.data.blob as {
70
79
$type: 'blob';
···
1
1
+
import { generateClientAssertionKey } from '@atcute/oauth-node-client';
2
2
+
3
3
+
const key = await generateClientAssertionKey('main-key');
4
4
+
console.log(JSON.stringify(key));
···
1
1
+
import { randomBytes } from 'node:crypto';
2
2
+
3
3
+
console.log(randomBytes(32).toString('base64url'));
···
1
1
+
import { existsSync } from 'node:fs';
2
2
+
import { copyFile, readFile, writeFile } from 'node:fs/promises';
3
3
+
import { resolve } from 'node:path';
4
4
+
import { randomBytes } from 'node:crypto';
5
5
+
6
6
+
import { generateClientAssertionKey } from '@atcute/oauth-node-client';
7
7
+
8
8
+
const cwd = process.cwd();
9
9
+
const examplePath = resolve(cwd, '.env.example');
10
10
+
const envPath = resolve(cwd, '.env');
11
11
+
12
12
+
if (!existsSync(envPath)) {
13
13
+
if (!existsSync(examplePath)) {
14
14
+
throw new Error(`missing .env.example (expected at ${examplePath})`);
15
15
+
}
16
16
+
await copyFile(examplePath, envPath);
17
17
+
console.log(`created ${envPath}`);
18
18
+
}
19
19
+
20
20
+
const upsertVar = (input: string, key: string, value: string): string => {
21
21
+
const line = `${key}=${value}`;
22
22
+
const re = new RegExp(`^${key}=.*$`, 'm');
23
23
+
24
24
+
if (re.test(input)) {
25
25
+
const match = input.match(re);
26
26
+
const current = match ? match[0].slice(key.length + 1).trim() : '';
27
27
+
// Only overwrite if empty/placeholder
28
28
+
if (current === '' || current === "''" || current === '""' || current.includes('...')) {
29
29
+
return input.replace(re, line);
30
30
+
}
31
31
+
return input;
32
32
+
}
33
33
+
34
34
+
const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n';
35
35
+
return `${input}${suffix}${line}\n`;
36
36
+
};
37
37
+
38
38
+
let vars = await readFile(envPath, 'utf8');
39
39
+
40
40
+
const secret = randomBytes(32).toString('base64url');
41
41
+
vars = upsertVar(vars, 'COOKIE_SECRET', secret);
42
42
+
43
43
+
const jwk = await generateClientAssertionKey('main-key');
44
44
+
vars = upsertVar(vars, 'CLIENT_ASSERTION_KEY', JSON.stringify(jwk));
45
45
+
46
46
+
await writeFile(envPath, vars);
47
47
+
console.log(`updated ${envPath}`);
···
1
1
+
import type { Did } from '@atcute/lexicons';
2
2
+
import { getDetailedProfile, describeRepo } from '../methods';
3
3
+
4
4
+
const PROFILE_CACHE_TTL = 60 * 60; // 1 hour
5
5
+
6
6
+
/**
7
7
+
* Loads a user's profile, with optional KV caching.
8
8
+
* Falls back to a fresh fetch if the cache KV doesn't exist or on cache miss.
9
9
+
* Returns undefined if the profile can't be loaded.
10
10
+
*/
11
11
+
export async function loadProfile(did: Did, profileCache?: KVNamespace) {
12
12
+
// Try cache first
13
13
+
if (profileCache) {
14
14
+
try {
15
15
+
const cached = await profileCache.get(did, 'json');
16
16
+
if (cached) return cached as Record<string, unknown>;
17
17
+
} catch {
18
18
+
// Cache read failed, continue to fresh fetch
19
19
+
}
20
20
+
}
21
21
+
22
22
+
const profile = await fetchProfile(did);
23
23
+
24
24
+
// Write to cache (fire-and-forget)
25
25
+
if (profileCache && profile) {
26
26
+
profileCache
27
27
+
.put(did, JSON.stringify(profile), { expirationTtl: PROFILE_CACHE_TTL })
28
28
+
.catch(() => {});
29
29
+
}
30
30
+
31
31
+
return profile;
32
32
+
}
33
33
+
34
34
+
async function fetchProfile(did: Did) {
35
35
+
try {
36
36
+
let profile = await getDetailedProfile({ did });
37
37
+
38
38
+
if (!profile || profile.handle === 'handle.invalid') {
39
39
+
const repo = await describeRepo({ did });
40
40
+
profile = {
41
41
+
did,
42
42
+
handle: repo?.handle || 'handle.invalid'
43
43
+
} as typeof profile;
44
44
+
}
45
45
+
46
46
+
return profile;
47
47
+
} catch (e) {
48
48
+
console.error('Failed to load profile:', e);
49
49
+
return undefined;
50
50
+
}
51
51
+
}
···
1
1
+
import type { Cookies } from '@sveltejs/kit';
2
2
+
import { Client } from '@atcute/client';
3
3
+
import type { Did } from '@atcute/lexicons';
4
4
+
import type { OAuthSession } from '@atcute/oauth-node-client';
5
5
+
import { createOAuthClient } from './oauth';
6
6
+
import { getSignedCookie } from './signed-cookie';
7
7
+
8
8
+
export type SessionLocals = {
9
9
+
session: OAuthSession | null;
10
10
+
client: Client | null;
11
11
+
did: Did | null;
12
12
+
};
13
13
+
14
14
+
/**
15
15
+
* Restores an OAuth session from the signed `did` cookie.
16
16
+
* Returns session locals to be assigned to `event.locals`.
17
17
+
* Deletes the cookie if the session can't be restored.
18
18
+
*/
19
19
+
export async function restoreSession(
20
20
+
cookies: Cookies,
21
21
+
env?: App.Platform['env']
22
22
+
): Promise<SessionLocals> {
23
23
+
const did = getSignedCookie(cookies, 'did') as Did | null;
24
24
+
25
25
+
if (!did) {
26
26
+
return { session: null, client: null, did: null };
27
27
+
}
28
28
+
29
29
+
try {
30
30
+
const oauth = createOAuthClient(env);
31
31
+
const session = await oauth.restore(did);
32
32
+
33
33
+
return {
34
34
+
session,
35
35
+
client: new Client({ handler: session }),
36
36
+
did
37
37
+
};
38
38
+
} catch (e) {
39
39
+
console.error('Failed to restore session:', e);
40
40
+
cookies.delete('did', { path: '/' });
41
41
+
return { session: null, client: null, did: null };
42
42
+
}
43
43
+
}
···
1
1
+
import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
+
3
3
+
import type { Cookies } from '@sveltejs/kit';
4
4
+
5
5
+
import { env } from '$env/dynamic/private';
6
6
+
import { dev } from '$app/environment';
7
7
+
8
8
+
const SEPARATOR = '.';
9
9
+
10
10
+
function getSecret(): string {
11
11
+
const secret = env.COOKIE_SECRET;
12
12
+
if (secret) return secret;
13
13
+
if (dev) return 'dev-cookie-secret-not-for-production';
14
14
+
throw new Error('COOKIE_SECRET is not set');
15
15
+
}
16
16
+
17
17
+
function toBase64Url(bytes: Uint8Array): string {
18
18
+
let binary = '';
19
19
+
for (const byte of bytes) binary += String.fromCharCode(byte);
20
20
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21
21
+
}
22
22
+
23
23
+
function fromBase64Url(str: string): Uint8Array {
24
24
+
const padded = str + '='.repeat((4 - (str.length % 4)) % 4);
25
25
+
const base64 = padded.replace(/-/g, '+').replace(/_/g, '/');
26
26
+
const binary = atob(base64);
27
27
+
const bytes = new Uint8Array(binary.length);
28
28
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
29
29
+
return bytes;
30
30
+
}
31
31
+
32
32
+
function hmacSha256(data: string): Uint8Array {
33
33
+
return createHmac('sha256', getSecret()).update(data).digest();
34
34
+
}
35
35
+
36
36
+
export function getSignedCookie(cookies: Cookies, name: string): string | null {
37
37
+
const signed = cookies.get(name);
38
38
+
if (!signed) return null;
39
39
+
40
40
+
const idx = signed.lastIndexOf(SEPARATOR);
41
41
+
if (idx === -1) return null;
42
42
+
43
43
+
const value = signed.slice(0, idx);
44
44
+
const sig = signed.slice(idx + 1);
45
45
+
46
46
+
let expected: Uint8Array;
47
47
+
let got: Uint8Array;
48
48
+
try {
49
49
+
expected = hmacSha256(value);
50
50
+
got = fromBase64Url(sig);
51
51
+
} catch {
52
52
+
return null;
53
53
+
}
54
54
+
55
55
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
56
56
+
57
57
+
return value;
58
58
+
}
59
59
+
60
60
+
export function setSignedCookie(
61
61
+
cookies: Cookies,
62
62
+
name: string,
63
63
+
value: string,
64
64
+
options: Parameters<Cookies['set']>[2]
65
65
+
): void {
66
66
+
const sig = toBase64Url(hmacSha256(value));
67
67
+
const signed = `${value}${SEPARATOR}${sig}`;
68
68
+
cookies.set(name, signed, options);
69
69
+
}
···
1
1
import { dev } from '$app/environment';
2
2
3
3
-
export const SITE = dev
4
4
-
? 'http://localhost:5183'
5
5
-
: 'https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev';
6
6
-
7
3
type Permissions = {
8
4
collections: readonly string[];
9
5
rpc: Record<string, string | string[]>;
···
26
22
27
23
export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
28
24
29
29
-
// which PDS to use for signup
25
25
+
// which PDS to use for signup (change to your preferred PDS)
30
26
const devPDS = 'https://pds.rip/';
31
27
const prodPDS = 'https://selfhosted.social/';
32
28
export const signUpPDS = dev ? devPDS : prodPDS;
···
18
18
WellKnownHandleResolver
19
19
} from '@atcute/identity-resolver';
20
20
import { KVStore } from './kv-store';
21
21
-
import { DOH_RESOLVER, REDIRECT_PATH, SITE } from '$lib/atproto/settings';
22
22
-
import { scope } from '$lib/atproto/metadata';
21
21
+
import { DOH_RESOLVER, REDIRECT_PATH } from '../settings';
22
22
+
import { scope } from '../metadata';
23
23
import { dev } from '$app/environment';
24
24
25
25
function createActorResolver() {
···
57
57
const actorResolver = createActorResolver();
58
58
const stores = createStores(env);
59
59
60
60
-
if (dev) {
61
61
-
// In development, use loopback client (public, no keyset).
60
60
+
if (dev && !env?.OAUTH_PUBLIC_URL) {
61
61
+
// Dev without tunnel: loopback public client (no keyset).
62
62
// Omit client_id — the library builds it automatically from redirect_uris + scope.
63
63
// redirect_uris must use 127.0.0.1 (not localhost).
64
64
return new OAuthClient({
···
71
71
});
72
72
}
73
73
74
74
-
// In production, use confidential client with keyset
75
75
-
if (!env?.CLIENT_ASSERTION_KEY) {
76
76
-
throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm generate-key && npx wrangler secret put CLIENT_ASSERTION_KEY');
74
74
+
// Confidential client (production, or dev with tunnel via OAUTH_PUBLIC_URL)
75
75
+
if (!env?.OAUTH_PUBLIC_URL) {
76
76
+
throw new Error('OAUTH_PUBLIC_URL is not set');
77
77
}
78
78
+
if (!env.CLIENT_ASSERTION_KEY) {
79
79
+
throw new Error('CLIENT_ASSERTION_KEY secret is not set. Run: pnpm env:generate-key');
80
80
+
}
81
81
+
const site = env.OAUTH_PUBLIC_URL;
78
82
const key: ClientAssertionPrivateJwk = JSON.parse(env.CLIENT_ASSERTION_KEY);
79
83
80
84
return new OAuthClient({
81
85
metadata: {
82
82
-
client_id: SITE + '/oauth-client-metadata.json',
83
83
-
redirect_uris: [SITE + REDIRECT_PATH],
86
86
+
client_id: site + '/oauth-client-metadata.json',
87
87
+
redirect_uris: [site + REDIRECT_PATH],
84
88
scope,
85
85
-
jwks_uri: SITE + '/oauth/jwks.json'
89
89
+
jwks_uri: site + '/oauth/jwks.json'
86
90
},
87
91
keyset: [key],
88
92
actorResolver,
···
1
1
+
import { redirect } from '@sveltejs/kit';
2
2
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
3
3
+
import { setSignedCookie } from '$lib/atproto/server/signed-cookie';
4
4
+
import { dev } from '$app/environment';
5
5
+
import type { RequestHandler } from './$types';
6
6
+
7
7
+
export const GET: RequestHandler = async ({ url, platform, cookies }) => {
8
8
+
const oauth = createOAuthClient(platform?.env);
9
9
+
10
10
+
// oauth.callback() validates the state parameter (CSRF protection) and
11
11
+
// exchanges the authorization code for tokens via the token endpoint.
12
12
+
try {
13
13
+
const { session } = await oauth.callback(url.searchParams);
14
14
+
15
15
+
setSignedCookie(cookies, 'did', session.did, {
16
16
+
path: '/',
17
17
+
httpOnly: true,
18
18
+
secure: !dev,
19
19
+
sameSite: 'lax',
20
20
+
maxAge: 60 * 60 * 24 * 180 // 180 days
21
21
+
});
22
22
+
} catch (e) {
23
23
+
console.error('OAuth callback failed:', e);
24
24
+
redirect(303, '/?error=auth_failed');
25
25
+
}
26
26
+
27
27
+
redirect(303, '/');
28
28
+
};
···
1
1
import type { LayoutServerLoad } from './$types';
2
2
-
import { getDetailedProfile, describeRepo } from '$lib/atproto/methods';
2
2
+
import { loadProfile } from '$lib/atproto/server/profile';
3
3
4
4
-
export const load: LayoutServerLoad = async ({ locals }) => {
4
4
+
export const load: LayoutServerLoad = async ({ locals, platform }) => {
5
5
if (!locals.did || !locals.client) {
6
6
-
return { did: undefined, profile: undefined };
6
6
+
return { did: null, profile: null };
7
7
}
8
8
9
9
-
let profile;
10
10
-
try {
11
11
-
profile = await getDetailedProfile({ did: locals.did });
12
12
-
13
13
-
if (!profile || profile.handle === 'handle.invalid') {
14
14
-
const repo = await describeRepo({ did: locals.did });
15
15
-
profile = {
16
16
-
did: locals.did,
17
17
-
handle: repo?.handle || 'handle.invalid'
18
18
-
};
19
19
-
}
20
20
-
} catch (e) {
21
21
-
console.error('Failed to load profile:', e);
22
22
-
}
9
9
+
const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE);
23
10
24
11
return {
25
12
did: locals.did,
···
1
1
<script lang="ts">
2
2
import '../app.css';
3
3
-
4
4
-
import LoginModal from '$lib/atproto/UI/LoginModal.svelte';
3
3
+
import { AtprotoLoginModal } from '@foxui/social';
4
4
+
import { login, signup } from '$lib/atproto';
5
5
6
6
let { children } = $props();
7
7
</script>
8
8
9
9
{@render children()}
10
10
11
11
-
<LoginModal />
11
11
+
<AtprotoLoginModal
12
12
+
login={async (handle) => {
13
13
+
await login(handle);
14
14
+
return true;
15
15
+
}}
16
16
+
signup={async () => {
17
17
+
signup();
18
18
+
return true;
19
19
+
}}
20
20
+
/>
···
1
1
<script lang="ts">
2
2
import { invalidateAll } from '$app/navigation';
3
3
+
import { flip } from 'svelte/animate';
4
4
+
import { scale } from 'svelte/transition';
3
5
import { user, logout } from '$lib/atproto';
4
4
-
import Avatar from '$lib/atproto/UI/Avatar.svelte';
5
5
-
import Button from '$lib/atproto/UI/Button.svelte';
6
6
-
import { loginModalState } from '$lib/atproto/UI/LoginModal.svelte';
7
7
-
import { createTID } from '$lib/atproto/methods';
8
8
-
import { putRecord } from '$lib/atproto/repo.remote';
6
6
+
import { Button, Avatar } from '@foxui/core';
7
7
+
import { atProtoLoginModalState, EmojiPicker } from '@foxui/social';
8
8
+
import { RelativeTime } from '@foxui/time';
9
9
10
10
-
let emojis = ['😅', '🫶', '🤗', '🙃', '😊', '🤔'];
10
10
+
import { createTID } from '$lib/atproto/methods';
11
11
+
import { putRecord } from '$lib/atproto/server/repo.remote';
11
12
12
13
let { data } = $props();
13
14
</script>
···
23
24
24
25
{#if !user.isLoggedIn}
25
26
<div class="mt-8 text-sm">not logged in</div>
26
26
-
<Button class="mt-4" onclick={() => loginModalState.show()}>Login</Button>
27
27
+
<Button class="mt-4" onclick={() => atProtoLoginModalState.show()}>Login</Button>
27
28
{/if}
28
29
29
30
{#if user.isLoggedIn}
···
36
37
37
38
<div class="my-4 text-sm">
38
39
Statusphere test:
39
39
-
<div class="mt-2 flex flex-wrap gap-2">
40
40
-
{#each emojis as emoji (emoji)}
41
41
-
<button
42
42
-
class="bg-base-100 dark:bg-base-950 cursor-pointer rounded-xl p-1 px-2"
43
43
-
onclick={async () => {
44
44
-
await putRecord({
45
45
-
rkey: createTID(),
46
46
-
collection: 'xyz.statusphere.status',
47
47
-
record: {
48
48
-
status: emoji,
49
49
-
createdAt: new Date()
50
50
-
}
51
51
-
});
52
52
-
await invalidateAll();
53
53
-
}}>{emoji}</button
54
54
-
>
55
55
-
{/each}
56
56
-
</div>
57
57
-
{#if data.statuses.length > 0}
58
58
-
<div class="mt-4 text-sm">Recent statuses:</div>
59
59
-
<div class="mt-2 flex flex-wrap gap-1">
60
60
-
{#each data.statuses as status (status.rkey)}
61
61
-
<span class="bg-base-100 dark:bg-base-950 rounded-xl p-1 px-2" title={new Date(status.createdAt).toLocaleString()}>{status.status}</span>
62
62
-
{/each}
63
63
-
</div>
64
64
-
{/if}
40
40
+
<EmojiPicker
41
41
+
onpicked={async (emoji) => {
42
42
+
await putRecord({
43
43
+
rkey: createTID(),
44
44
+
collection: 'xyz.statusphere.status',
45
45
+
record: {
46
46
+
status: emoji.unicode,
47
47
+
createdAt: new Date()
48
48
+
}
49
49
+
});
50
50
+
await invalidateAll();
51
51
+
}}
52
52
+
/>
53
53
+
{#if data.statuses.length > 0}
54
54
+
<div class="mt-4 text-sm">Recent statuses:</div>
55
55
+
<ul class="mt-2">
56
56
+
{#each data.statuses as status, i (status.rkey)}
57
57
+
<li class="flex items-center gap-2 py-1" animate:flip={{ duration: 300 }}>
58
58
+
{#if i === 0}
59
59
+
<span class="text-2xl" in:scale={{ duration: 300 }}>{status.status}</span>
60
60
+
{:else}
61
61
+
<span class="text-2xl">{status.status}</span>
62
62
+
{/if}
63
63
+
<span class="text-base-400 dark:text-base-500 text-sm">
64
64
+
<RelativeTime date={new Date(status.createdAt)} locale="en-US" />
65
65
+
</span>
66
66
+
</li>
67
67
+
{/each}
68
68
+
</ul>
69
69
+
{/if}
65
70
</div>
66
71
67
72
<Button class="mt-4" onclick={() => logout()}>Sign Out</Button>
···
1
1
import { json } from '@sveltejs/kit';
2
2
-
import { createOAuthClient } from '$lib/server/oauth';
2
2
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
3
3
import type { RequestHandler } from './$types';
4
4
5
5
export const GET: RequestHandler = async ({ platform }) => {
···
1
1
-
import { redirect } from '@sveltejs/kit';
2
2
-
import { createOAuthClient } from '$lib/server/oauth';
3
3
-
import { dev } from '$app/environment';
4
4
-
import type { RequestHandler } from './$types';
5
5
-
6
6
-
export const GET: RequestHandler = async ({ url, platform, cookies }) => {
7
7
-
const oauth = createOAuthClient(platform?.env);
8
8
-
9
9
-
const { session } = await oauth.callback(url.searchParams);
10
10
-
11
11
-
cookies.set('did', session.did, {
12
12
-
path: '/',
13
13
-
httpOnly: true,
14
14
-
secure: !dev,
15
15
-
sameSite: 'lax',
16
16
-
maxAge: 60 * 60 * 24 * 180 // 180 days
17
17
-
});
18
18
-
19
19
-
redirect(303, '/');
20
20
-
};
···
1
1
import { json } from '@sveltejs/kit';
2
2
-
import { createOAuthClient } from '$lib/server/oauth';
2
2
+
import { createOAuthClient } from '$lib/atproto/server/oauth';
3
3
import type { RequestHandler } from './$types';
4
4
5
5
export const GET: RequestHandler = async ({ platform }) => {
···
1
1
-
/**
2
2
-
* For more details on how to configure Wrangler, refer to:
3
3
-
* https://developers.cloudflare.com/workers/wrangler/configuration/
4
4
-
*/
5
1
{
6
2
"$schema": "node_modules/wrangler/config-schema.json",
7
3
"name": "svelte-atproto-oauth-cloudflare-workers",
8
4
"main": ".svelte-kit/cloudflare/_worker.js",
9
5
"compatibility_date": "2025-12-25",
10
10
-
"compatibility_flags": ["nodejs_als"],
6
6
+
"compatibility_flags": ["nodejs_compat_v2"],
11
7
"assets": {
12
8
"binding": "ASSETS",
13
9
"directory": ".svelte-kit/cloudflare"
···
15
11
"observability": {
16
12
"enabled": true
17
13
},
18
18
-
/**
19
19
-
* Smart Placement
20
20
-
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
21
21
-
*/
22
22
-
// "placement": { "mode": "smart" }
23
23
-
/**
24
24
-
* Bindings
25
25
-
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
26
26
-
* databases, object storage, AI inference, real-time communication and more.
27
27
-
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
28
28
-
*/
29
29
-
/**
30
30
-
* Environment Variables
31
31
-
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
32
32
-
* Note: Use secrets to store sensitive data.
33
33
-
* https://developers.cloudflare.com/workers/configuration/secrets/
34
34
-
*/
35
35
-
"vars": {},
14
14
+
"vars": {
15
15
+
"OAUTH_PUBLIC_URL": "https://svelte-atproto-oauth-cloudflare-workers.flobit-dev.workers.dev"
16
16
+
},
36
17
"kv_namespaces": [
37
18
{
38
19
"binding": "OAUTH_SESSIONS",
···
43
24
"id": "115d8089d9ca4592a3a5e4a7d9146610"
44
25
}
45
26
]
46
46
-
47
47
-
/**
48
48
-
* Service Bindings (communicate between multiple Workers)
49
49
-
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
50
50
-
*/
51
51
-
// "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ]
52
27
}