@kingironman2011/better-auth-bsky 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +238 -0
- package/dist/client.d.ts +63 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +22 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/server-DO9pjTl1.d.ts +1860 -0
- package/dist/server-DO9pjTl1.d.ts.map +1 -0
- package/dist/server-DS4UMolW.js +951 -0
- package/dist/server-DS4UMolW.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +2 -0
- package/package.json +103 -0
- package/src/client.test.ts +137 -0
- package/src/client.ts +24 -0
- package/src/index.ts +10 -0
- package/src/key-utils.test.ts +26 -0
- package/src/key-utils.ts +32 -0
- package/src/server.test.ts +368 -0
- package/src/server.ts +831 -0
- package/src/stores.test.ts +201 -0
- package/src/stores.ts +143 -0
- package/src/types.test.ts +90 -0
- package/src/types.ts +114 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
atproto,
|
|
4
|
+
fetchAtprotoProfilePublic,
|
|
5
|
+
atprotoPlaceholderEmail,
|
|
6
|
+
} from "./server.js";
|
|
7
|
+
|
|
8
|
+
describe("atproto plugin factory", () => {
|
|
9
|
+
const baseOptions = {
|
|
10
|
+
clientName: "Test App",
|
|
11
|
+
clientUri: "https://example.com",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
it("returns a plugin with id 'atproto'", () => {
|
|
15
|
+
const plugin = atproto(baseOptions);
|
|
16
|
+
expect(plugin.id).toBe("atproto");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("includes the database schema with user extensions", () => {
|
|
20
|
+
const plugin = atproto(baseOptions);
|
|
21
|
+
expect(plugin.schema).toBeDefined();
|
|
22
|
+
expect(plugin.schema).toHaveProperty("user");
|
|
23
|
+
expect(plugin.schema).toHaveProperty("atprotoSession");
|
|
24
|
+
expect(plugin.schema).toHaveProperty("atprotoState");
|
|
25
|
+
|
|
26
|
+
// Verify user schema has atprotoDid and atprotoHandle
|
|
27
|
+
expect(plugin.schema.user.fields).toHaveProperty("atprotoDid");
|
|
28
|
+
expect(plugin.schema.user.fields.atprotoDid.unique).toBe(true);
|
|
29
|
+
expect(plugin.schema.user.fields.atprotoDid.required).toBe(false);
|
|
30
|
+
expect(plugin.schema.user.fields).toHaveProperty("atprotoHandle");
|
|
31
|
+
expect(plugin.schema.user.fields.atprotoHandle.required).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("exposes error codes including new ones", () => {
|
|
35
|
+
const plugin = atproto(baseOptions);
|
|
36
|
+
expect(plugin.$ERROR_CODES).toEqual({
|
|
37
|
+
INVALID_HANDLE: {
|
|
38
|
+
code: "INVALID_HANDLE",
|
|
39
|
+
message: "Invalid ATProto handle or DID",
|
|
40
|
+
},
|
|
41
|
+
AUTHORIZATION_FAILED: {
|
|
42
|
+
code: "AUTHORIZATION_FAILED",
|
|
43
|
+
message: "Failed to start ATProto authorization",
|
|
44
|
+
},
|
|
45
|
+
CALLBACK_FAILED: {
|
|
46
|
+
code: "CALLBACK_FAILED",
|
|
47
|
+
message: "ATProto OAuth callback failed",
|
|
48
|
+
},
|
|
49
|
+
SESSION_NOT_FOUND: {
|
|
50
|
+
code: "SESSION_NOT_FOUND",
|
|
51
|
+
message: "No ATProto session found for the current user",
|
|
52
|
+
},
|
|
53
|
+
SIGNUP_DISABLED: {
|
|
54
|
+
code: "SIGNUP_DISABLED",
|
|
55
|
+
message: "New user registration via ATProto is disabled",
|
|
56
|
+
},
|
|
57
|
+
ACCOUNT_LINKING_DISABLED: {
|
|
58
|
+
code: "ACCOUNT_LINKING_DISABLED",
|
|
59
|
+
message: "Account linking is not enabled",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("has an init function", () => {
|
|
65
|
+
const plugin = atproto(baseOptions);
|
|
66
|
+
expect(typeof plugin.init).toBe("function");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("defines all expected endpoints including new ones", () => {
|
|
70
|
+
const plugin = atproto(baseOptions);
|
|
71
|
+
expect(plugin.endpoints).toHaveProperty("atprotoClientMetadata");
|
|
72
|
+
expect(plugin.endpoints).toHaveProperty("atprotoJwks");
|
|
73
|
+
expect(plugin.endpoints).toHaveProperty("signInAtproto");
|
|
74
|
+
expect(plugin.endpoints).toHaveProperty("atprotoCallback");
|
|
75
|
+
expect(plugin.endpoints).toHaveProperty("atprotoGetSession");
|
|
76
|
+
expect(plugin.endpoints).toHaveProperty("atprotoRestore");
|
|
77
|
+
expect(plugin.endpoints).toHaveProperty("atprotoSignOut");
|
|
78
|
+
expect(plugin.endpoints).toHaveProperty("getAtprotoClient");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("rate limiting", () => {
|
|
82
|
+
it("has rate limit rules for sign-in and callback", () => {
|
|
83
|
+
const plugin = atproto(baseOptions);
|
|
84
|
+
expect(plugin.rateLimit).toBeDefined();
|
|
85
|
+
expect(plugin.rateLimit).toHaveLength(2);
|
|
86
|
+
|
|
87
|
+
// Sign-in rate limit: 5 per 60s
|
|
88
|
+
const signInRule = plugin.rateLimit[0]!;
|
|
89
|
+
expect(signInRule.window).toBe(60);
|
|
90
|
+
expect(signInRule.max).toBe(5);
|
|
91
|
+
expect(signInRule.pathMatcher("/sign-in/atproto")).toBe(true);
|
|
92
|
+
expect(signInRule.pathMatcher("/other")).toBe(false);
|
|
93
|
+
|
|
94
|
+
// Callback rate limit: 10 per 60s
|
|
95
|
+
const callbackRule = plugin.rateLimit[1]!;
|
|
96
|
+
expect(callbackRule.window).toBe(60);
|
|
97
|
+
expect(callbackRule.max).toBe(10);
|
|
98
|
+
expect(callbackRule.pathMatcher("/atproto/callback")).toBe(true);
|
|
99
|
+
expect(callbackRule.pathMatcher("/other")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rate limit paths match custom path options", () => {
|
|
103
|
+
const plugin = atproto({
|
|
104
|
+
...baseOptions,
|
|
105
|
+
signInPath: "/custom/sign-in",
|
|
106
|
+
callbackPath: "/custom/callback",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(plugin.rateLimit[0]!.pathMatcher("/custom/sign-in")).toBe(true);
|
|
110
|
+
expect(plugin.rateLimit[0]!.pathMatcher("/sign-in/atproto")).toBe(false);
|
|
111
|
+
|
|
112
|
+
expect(plugin.rateLimit[1]!.pathMatcher("/custom/callback")).toBe(true);
|
|
113
|
+
expect(plugin.rateLimit[1]!.pathMatcher("/atproto/callback")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("disableSignUp option", () => {
|
|
118
|
+
it("accepts disableSignUp option", () => {
|
|
119
|
+
const plugin = atproto({ ...baseOptions, disableSignUp: true });
|
|
120
|
+
expect(plugin).toBeDefined();
|
|
121
|
+
expect(plugin.id).toBe("atproto");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("mapProfileToUser option", () => {
|
|
126
|
+
it("accepts mapProfileToUser callback", () => {
|
|
127
|
+
const plugin = atproto({
|
|
128
|
+
...baseOptions,
|
|
129
|
+
mapProfileToUser: (profile) => ({
|
|
130
|
+
name: profile.displayName ?? profile.handle,
|
|
131
|
+
image: profile.avatar,
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
expect(plugin).toBeDefined();
|
|
135
|
+
expect(plugin.id).toBe("atproto");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("plugin initialization", () => {
|
|
140
|
+
it("initializes without error for loopback URL (public client)", () => {
|
|
141
|
+
const plugin = atproto(baseOptions);
|
|
142
|
+
const mockAdapter = {
|
|
143
|
+
findOne: vi.fn(),
|
|
144
|
+
create: vi.fn(),
|
|
145
|
+
update: vi.fn(),
|
|
146
|
+
delete: vi.fn(),
|
|
147
|
+
deleteMany: vi.fn(),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
expect(() =>
|
|
151
|
+
plugin.init({
|
|
152
|
+
baseURL: "http://localhost:3000/api/auth",
|
|
153
|
+
adapter: mockAdapter,
|
|
154
|
+
}),
|
|
155
|
+
).not.toThrow();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("initializes without error for 127.0.0.1 URL (public client)", () => {
|
|
159
|
+
const plugin = atproto(baseOptions);
|
|
160
|
+
const mockAdapter = {
|
|
161
|
+
findOne: vi.fn(),
|
|
162
|
+
create: vi.fn(),
|
|
163
|
+
update: vi.fn(),
|
|
164
|
+
delete: vi.fn(),
|
|
165
|
+
deleteMany: vi.fn(),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
expect(() =>
|
|
169
|
+
plugin.init({
|
|
170
|
+
baseURL: "http://127.0.0.1:3000/api/auth",
|
|
171
|
+
adapter: mockAdapter,
|
|
172
|
+
}),
|
|
173
|
+
).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("warns when keyset is provided with loopback URL", () => {
|
|
177
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
178
|
+
const plugin = atproto({
|
|
179
|
+
...baseOptions,
|
|
180
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test mock keyset
|
|
181
|
+
keyset: [{ kty: "EC", crv: "P-256", kid: "k1", alg: "ES256" } as any],
|
|
182
|
+
});
|
|
183
|
+
const mockAdapter = {
|
|
184
|
+
findOne: vi.fn(),
|
|
185
|
+
create: vi.fn(),
|
|
186
|
+
update: vi.fn(),
|
|
187
|
+
delete: vi.fn(),
|
|
188
|
+
deleteMany: vi.fn(),
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
plugin.init({
|
|
192
|
+
baseURL: "http://localhost:3000/api/auth",
|
|
193
|
+
adapter: mockAdapter,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
197
|
+
expect.stringContaining("keyset provided but baseURL is loopback"),
|
|
198
|
+
);
|
|
199
|
+
warnSpy.mockRestore();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("initializes without error for HTTPS URL (confidential client)", () => {
|
|
203
|
+
const plugin = atproto({
|
|
204
|
+
...baseOptions,
|
|
205
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test mock keyset
|
|
206
|
+
keyset: [{ kty: "EC", crv: "P-256", kid: "k1", alg: "ES256" } as any],
|
|
207
|
+
});
|
|
208
|
+
const mockAdapter = {
|
|
209
|
+
findOne: vi.fn(),
|
|
210
|
+
create: vi.fn(),
|
|
211
|
+
update: vi.fn(),
|
|
212
|
+
delete: vi.fn(),
|
|
213
|
+
deleteMany: vi.fn(),
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
expect(() =>
|
|
217
|
+
plugin.init({
|
|
218
|
+
baseURL: "https://example.com/api/auth",
|
|
219
|
+
adapter: mockAdapter,
|
|
220
|
+
}),
|
|
221
|
+
).not.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("initializes as public client for HTTPS URL without keyset", () => {
|
|
225
|
+
const plugin = atproto(baseOptions);
|
|
226
|
+
const mockAdapter = {
|
|
227
|
+
findOne: vi.fn(),
|
|
228
|
+
create: vi.fn(),
|
|
229
|
+
update: vi.fn(),
|
|
230
|
+
delete: vi.fn(),
|
|
231
|
+
deleteMany: vi.fn(),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
expect(() =>
|
|
235
|
+
plugin.init({
|
|
236
|
+
baseURL: "https://example.com/api/auth",
|
|
237
|
+
adapter: mockAdapter,
|
|
238
|
+
}),
|
|
239
|
+
).not.toThrow();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("custom path options", () => {
|
|
244
|
+
it("accepts custom path configuration", () => {
|
|
245
|
+
const plugin = atproto({
|
|
246
|
+
...baseOptions,
|
|
247
|
+
clientMetadataPath: "/custom/metadata.json",
|
|
248
|
+
jwksPath: "/custom/jwks.json",
|
|
249
|
+
callbackPath: "/custom/callback",
|
|
250
|
+
signInPath: "/custom/sign-in",
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Plugin should still create successfully
|
|
254
|
+
expect(plugin.id).toBe("atproto");
|
|
255
|
+
expect(plugin.endpoints).toBeDefined();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("scope configuration", () => {
|
|
260
|
+
it("uses default scope when not specified", () => {
|
|
261
|
+
const plugin = atproto(baseOptions);
|
|
262
|
+
expect(plugin).toBeDefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("accepts a custom scope string", () => {
|
|
266
|
+
const plugin = atproto({
|
|
267
|
+
...baseOptions,
|
|
268
|
+
scope: "atproto transition:generic transition:chat.bsky",
|
|
269
|
+
});
|
|
270
|
+
expect(plugin).toBeDefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("accepts scope as an array of strings", () => {
|
|
274
|
+
const plugin = atproto({
|
|
275
|
+
...baseOptions,
|
|
276
|
+
scope: ["atproto", "rpc?lxm=app.bsky.actor.getProfile&aud=*"],
|
|
277
|
+
});
|
|
278
|
+
expect(plugin).toBeDefined();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("accepts scope array without explicit 'atproto' (auto-prepended)", () => {
|
|
282
|
+
const plugin = atproto({
|
|
283
|
+
...baseOptions,
|
|
284
|
+
scope: ["rpc?lxm=app.bsky.actor.getProfile&aud=*"],
|
|
285
|
+
});
|
|
286
|
+
expect(plugin).toBeDefined();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("atprotoPlaceholderEmail", () => {
|
|
292
|
+
it("generates a deterministic placeholder email from a DID", () => {
|
|
293
|
+
const email = atprotoPlaceholderEmail("did:plc:abc123");
|
|
294
|
+
expect(email).toBe("did_plc_abc123@atproto.invalid");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("uses the .invalid TLD", () => {
|
|
298
|
+
const email = atprotoPlaceholderEmail("did:web:example.com");
|
|
299
|
+
expect(email).toMatch(/@atproto\.invalid$/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("replaces all colons with underscores", () => {
|
|
303
|
+
const email = atprotoPlaceholderEmail("did:plc:xyz789");
|
|
304
|
+
expect(email).not.toContain(":");
|
|
305
|
+
expect(email).toBe("did_plc_xyz789@atproto.invalid");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("fetchAtprotoProfilePublic", () => {
|
|
310
|
+
it("is a function", () => {
|
|
311
|
+
expect(typeof fetchAtprotoProfilePublic).toBe("function");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("returns null on network error", async () => {
|
|
315
|
+
// Mock fetch to simulate network error
|
|
316
|
+
const originalFetch = globalThis.fetch;
|
|
317
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test mock
|
|
318
|
+
globalThis.fetch = vi
|
|
319
|
+
.fn()
|
|
320
|
+
.mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;
|
|
321
|
+
|
|
322
|
+
const result = await fetchAtprotoProfilePublic("did:plc:abc123");
|
|
323
|
+
expect(result).toBeNull();
|
|
324
|
+
|
|
325
|
+
globalThis.fetch = originalFetch;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns null on non-OK response", async () => {
|
|
329
|
+
const originalFetch = globalThis.fetch;
|
|
330
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test mock
|
|
331
|
+
globalThis.fetch = vi
|
|
332
|
+
.fn()
|
|
333
|
+
.mockResolvedValue({ ok: false, status: 404 }) as unknown as typeof fetch;
|
|
334
|
+
|
|
335
|
+
const result = await fetchAtprotoProfilePublic("did:plc:abc123");
|
|
336
|
+
expect(result).toBeNull();
|
|
337
|
+
|
|
338
|
+
globalThis.fetch = originalFetch;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("returns profile data on successful response", async () => {
|
|
342
|
+
const originalFetch = globalThis.fetch;
|
|
343
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test mock
|
|
344
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
345
|
+
ok: true,
|
|
346
|
+
json: () =>
|
|
347
|
+
Promise.resolve({
|
|
348
|
+
did: "did:plc:abc123",
|
|
349
|
+
handle: "user.bsky.social",
|
|
350
|
+
displayName: "Test User",
|
|
351
|
+
avatar: "https://example.com/avatar.jpg",
|
|
352
|
+
description: "Hello world",
|
|
353
|
+
}),
|
|
354
|
+
}) as unknown as typeof fetch;
|
|
355
|
+
|
|
356
|
+
const result = await fetchAtprotoProfilePublic("did:plc:abc123");
|
|
357
|
+
expect(result).toEqual({
|
|
358
|
+
did: "did:plc:abc123",
|
|
359
|
+
handle: "user.bsky.social",
|
|
360
|
+
displayName: "Test User",
|
|
361
|
+
avatar: "https://example.com/avatar.jpg",
|
|
362
|
+
banner: undefined,
|
|
363
|
+
description: "Hello world",
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
globalThis.fetch = originalFetch;
|
|
367
|
+
});
|
|
368
|
+
});
|