@sovereignfs/sdk 1.25.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.
Files changed (45) hide show
  1. package/LICENSE +661 -0
  2. package/dist/chunk-MLKGABMK.js +9 -0
  3. package/dist/chunk-NQLGPAL6.js +62 -0
  4. package/dist/e2ee-crypto.d.ts +84 -0
  5. package/dist/e2ee-crypto.js +162 -0
  6. package/dist/e2ee-device.d.ts +21 -0
  7. package/dist/e2ee-device.js +13 -0
  8. package/dist/e2ee-object.d.ts +50 -0
  9. package/dist/e2ee-object.js +75 -0
  10. package/dist/e2ee-state-U-GEJ7oL.d.ts +411 -0
  11. package/dist/e2ee-state.d.ts +1 -0
  12. package/dist/e2ee-state.js +36 -0
  13. package/dist/index.d.ts +543 -0
  14. package/dist/index.js +652 -0
  15. package/package.json +50 -0
  16. package/src/__tests__/e2ee-crypto.test.ts +156 -0
  17. package/src/__tests__/e2ee-object.test.ts +90 -0
  18. package/src/__tests__/e2ee-state.test.ts +83 -0
  19. package/src/__tests__/sdk.test.ts +427 -0
  20. package/src/__tests__/sessions.test.ts +63 -0
  21. package/src/activity.ts +19 -0
  22. package/src/auth.ts +134 -0
  23. package/src/connections.ts +106 -0
  24. package/src/data.ts +88 -0
  25. package/src/db.ts +34 -0
  26. package/src/directory.ts +31 -0
  27. package/src/e2ee-crypto.ts +260 -0
  28. package/src/e2ee-device.ts +75 -0
  29. package/src/e2ee-object.ts +118 -0
  30. package/src/e2ee-state.ts +72 -0
  31. package/src/e2ee.ts +65 -0
  32. package/src/env.ts +37 -0
  33. package/src/errors.ts +46 -0
  34. package/src/host.ts +251 -0
  35. package/src/index.ts +160 -0
  36. package/src/mailer.ts +7 -0
  37. package/src/notifications.ts +36 -0
  38. package/src/platform.ts +10 -0
  39. package/src/plugins.ts +146 -0
  40. package/src/portability.ts +176 -0
  41. package/src/secrets.ts +68 -0
  42. package/src/sessions.ts +36 -0
  43. package/src/storage.ts +49 -0
  44. package/src/types.ts +418 -0
  45. package/src/unimplemented.ts +48 -0
@@ -0,0 +1,427 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+ import { provideHost } from '../host';
3
+ import { ConsentRequiredError, NotAuthenticatedError, NotImplementedError, sdk } from '../index';
4
+
5
+ // A minimal mock host — lets us test SDK delegation without a real runtime.
6
+ const mockDbClient = { select: () => ({}), insert: () => ({}) };
7
+ const mockConfig = {
8
+ tenantName: 'Test Workspace',
9
+ inviteOnly: false,
10
+ version: '0.6.0',
11
+ instanceName: 'Test Workspace',
12
+ instanceId: 'test-instance-uuid',
13
+ };
14
+ const mockDataResolvers = new Map<string, (...args: unknown[]) => Promise<unknown[]>>();
15
+ const mockExporters = new Map<string, unknown>();
16
+ const mockImporters = new Map<string, unknown>();
17
+
18
+ beforeAll(() => {
19
+ provideHost({
20
+ db: {
21
+ async getClient() {
22
+ return mockDbClient;
23
+ },
24
+ },
25
+ mailer: {
26
+ async send() {
27
+ /* no-op */
28
+ },
29
+ },
30
+ platform: {
31
+ async getConfig() {
32
+ return mockConfig;
33
+ },
34
+ },
35
+ directory: {
36
+ async searchUsers() {
37
+ return [];
38
+ },
39
+ async resolveUsers() {
40
+ return [];
41
+ },
42
+ },
43
+ data: {
44
+ provide(contract, resolver) {
45
+ mockDataResolvers.set(contract, resolver as (...args: unknown[]) => Promise<unknown[]>);
46
+ },
47
+ async query(_ref, _consumerId, _userId, _tenantId, _params) {
48
+ return [];
49
+ },
50
+ },
51
+ activity: {
52
+ async log(_entry, _actorId, _pluginId) {
53
+ /* no-op */
54
+ },
55
+ },
56
+ portability: {
57
+ provideExport(pluginId, resolver) {
58
+ mockExporters.set(pluginId, resolver);
59
+ },
60
+ provideImport(pluginId, handler) {
61
+ mockImporters.set(pluginId, handler);
62
+ },
63
+ provideDelete(_pluginId, _handler) {
64
+ /* no-op */
65
+ },
66
+ },
67
+ plugins: {
68
+ async get(id) {
69
+ if (id !== 'com.example.notes') return null;
70
+ return {
71
+ id,
72
+ name: 'Notes',
73
+ routePrefix: '/notes',
74
+ installed: true,
75
+ enabled: true,
76
+ availableToUser: true,
77
+ providesContracts: [],
78
+ };
79
+ },
80
+ async list() {
81
+ return [];
82
+ },
83
+ async getConsentStatus() {
84
+ return 'not_granted';
85
+ },
86
+ },
87
+ notifications: {
88
+ async send(_input, _pluginId) {
89
+ /* no-op */
90
+ },
91
+ },
92
+ secrets: {
93
+ async create(input) {
94
+ return {
95
+ id: 'secret-1',
96
+ scope: input.scope,
97
+ label: input.label,
98
+ metadata: input.metadata ?? null,
99
+ createdAt: 1,
100
+ updatedAt: 1,
101
+ lastUsedAt: null,
102
+ };
103
+ },
104
+ async get(_id) {
105
+ return 'secret';
106
+ },
107
+ async list() {
108
+ return [];
109
+ },
110
+ async update(_id, _value) {
111
+ return {
112
+ id: 'secret-1',
113
+ scope: 'user',
114
+ label: 'Updated',
115
+ metadata: null,
116
+ createdAt: 1,
117
+ updatedAt: 2,
118
+ lastUsedAt: null,
119
+ };
120
+ },
121
+ async delete(_id) {
122
+ /* no-op */
123
+ },
124
+ },
125
+ connections: {
126
+ async create(input) {
127
+ return {
128
+ id: 'conn-1',
129
+ scope: input.scope,
130
+ provider: input.provider,
131
+ label: input.label,
132
+ status: 'connected',
133
+ secretRef: input.secretRef ?? null,
134
+ metadata: input.metadata ?? null,
135
+ lastCheckedAt: null,
136
+ lastUsedAt: null,
137
+ lastError: null,
138
+ createdAt: 1,
139
+ updatedAt: 1,
140
+ disconnectedAt: null,
141
+ };
142
+ },
143
+ async list() {
144
+ return [];
145
+ },
146
+ async get() {
147
+ return null;
148
+ },
149
+ async update(_id, input) {
150
+ return {
151
+ id: 'conn-1',
152
+ scope: 'user',
153
+ provider: 'email.google',
154
+ label: input.label ?? 'Google Mail',
155
+ status: input.status ?? 'connected',
156
+ secretRef: input.secretRef ?? null,
157
+ metadata: input.metadata ?? null,
158
+ lastCheckedAt: input.lastCheckedAt ?? null,
159
+ lastUsedAt: null,
160
+ lastError: null,
161
+ createdAt: 1,
162
+ updatedAt: 2,
163
+ disconnectedAt: null,
164
+ };
165
+ },
166
+ async disconnect() {
167
+ /* no-op */
168
+ },
169
+ async markUsed() {
170
+ /* no-op */
171
+ },
172
+ async markError(_id, input) {
173
+ return {
174
+ id: 'conn-1',
175
+ scope: 'user',
176
+ provider: 'email.google',
177
+ label: 'Google Mail',
178
+ status: input.status ?? 'error',
179
+ secretRef: null,
180
+ metadata: null,
181
+ lastCheckedAt: null,
182
+ lastUsedAt: null,
183
+ lastError: input.error,
184
+ createdAt: 1,
185
+ updatedAt: 2,
186
+ disconnectedAt: null,
187
+ };
188
+ },
189
+ async createOAuthState() {
190
+ return 'state';
191
+ },
192
+ async verifyOAuthState() {
193
+ return {
194
+ pluginId: 'com.example.notes',
195
+ provider: 'email.google',
196
+ userId: 'u1',
197
+ callbackPath: '/connections/google/callback',
198
+ nonce: 'nonce',
199
+ metadata: null,
200
+ expiresAt: 2,
201
+ };
202
+ },
203
+ async getProviderConfig(provider) {
204
+ return {
205
+ provider,
206
+ label: 'Google Mail',
207
+ configured: true,
208
+ source: 'console',
209
+ publicValues: { clientId: 'client-id' },
210
+ secretValues: { clientSecret: 'client-secret' },
211
+ callbackUrl: 'https://example.test/notes/connections/google/callback',
212
+ scopes: ['user'],
213
+ missingRequired: [],
214
+ };
215
+ },
216
+ },
217
+ storage: {
218
+ async put(input) {
219
+ return {
220
+ id: 'obj-1',
221
+ pluginId: 'com.example.notes',
222
+ ownerUserId: input.ownerUserId ?? null,
223
+ key: input.key,
224
+ contentType: input.contentType,
225
+ size: 3,
226
+ checksum: 'checksum',
227
+ metadata: input.metadata ?? null,
228
+ createdAt: 1,
229
+ updatedAt: 1,
230
+ };
231
+ },
232
+ async get(_key) {
233
+ return null;
234
+ },
235
+ async delete(_key) {
236
+ /* no-op */
237
+ },
238
+ async list() {
239
+ return [];
240
+ },
241
+ async getSignedUrl(_key) {
242
+ return 'https://example.test/api/storage/signed-token';
243
+ },
244
+ },
245
+ e2ee: {
246
+ async getProfile() {
247
+ return null;
248
+ },
249
+ async createProfile(input, context) {
250
+ return {
251
+ id: 'profile-1',
252
+ userId: context.userId,
253
+ status: 'active',
254
+ cmkAlgorithm: input.cmkAlgorithm,
255
+ createdAt: 1,
256
+ updatedAt: 1,
257
+ };
258
+ },
259
+ async getRecoveryWrapper() {
260
+ return null;
261
+ },
262
+ async setRecoveryWrapper(input, context) {
263
+ return { id: 'wrapper-1', userId: context.userId, ...input, createdAt: 1, updatedAt: 1 };
264
+ },
265
+ async enrollDevice(input, context) {
266
+ return {
267
+ id: 'device-1',
268
+ userId: context.userId,
269
+ ...input,
270
+ createdAt: 1,
271
+ lastUsedAt: null,
272
+ revokedAt: null,
273
+ };
274
+ },
275
+ async listDevices() {
276
+ return [];
277
+ },
278
+ async revokeDevice(_id) {
279
+ /* no-op */
280
+ },
281
+ },
282
+ });
283
+ });
284
+
285
+ describe('sdk surface', () => {
286
+ it('exposes the full v1 stable surface', () => {
287
+ expect(typeof sdk.auth.getSession).toBe('function');
288
+ expect(typeof sdk.auth.requireSession).toBe('function');
289
+ expect(typeof sdk.auth.changePassword).toBe('function');
290
+ expect(typeof sdk.auth.listSessions).toBe('function');
291
+ expect(typeof sdk.auth.revokeSession).toBe('function');
292
+ expect(typeof sdk.auth.signOut).toBe('function');
293
+ expect(typeof sdk.db.getClient).toBe('function');
294
+ expect(typeof sdk.mailer.send).toBe('function');
295
+ expect(typeof sdk.platform.getConfig).toBe('function');
296
+ });
297
+
298
+ it('exposes the directory surface (RFC 0041)', () => {
299
+ expect(typeof sdk.directory.searchUsers).toBe('function');
300
+ expect(typeof sdk.directory.resolveUsers).toBe('function');
301
+ });
302
+
303
+ it('exposes the stable data surface (RFC 0002)', () => {
304
+ expect(typeof sdk.data.query).toBe('function');
305
+ expect(typeof sdk.data.provide).toBe('function');
306
+ });
307
+
308
+ it('exposes the activity surface (RFC 0005)', () => {
309
+ expect(typeof sdk.activity.log).toBe('function');
310
+ });
311
+
312
+ it('exposes the portability surface (RFC 0007)', () => {
313
+ expect(typeof sdk.portability.provideExport).toBe('function');
314
+ expect(typeof sdk.portability.provideImport).toBe('function');
315
+ });
316
+
317
+ it('exposes the plugins discovery surface (RFC 0051)', () => {
318
+ expect(typeof sdk.plugins.get).toBe('function');
319
+ expect(typeof sdk.plugins.list).toBe('function');
320
+ expect(typeof sdk.plugins.getConsentStatus).toBe('function');
321
+ });
322
+
323
+ it('exposes the env surface (RFC 0018)', () => {
324
+ expect(typeof sdk.env.get).toBe('function');
325
+ });
326
+
327
+ it('exposes the experimental / reserved surface', () => {
328
+ expect(typeof sdk.storage.put).toBe('function');
329
+ expect(typeof sdk.storage.get).toBe('function');
330
+ expect(typeof sdk.storage.delete).toBe('function');
331
+ expect(typeof sdk.storage.list).toBe('function');
332
+ expect(typeof sdk.storage.getSignedUrl).toBe('function');
333
+ expect(typeof sdk.notifications.send).toBe('function');
334
+ expect(typeof sdk.secrets.create).toBe('function');
335
+ expect(typeof sdk.secrets.get).toBe('function');
336
+ expect(typeof sdk.secrets.list).toBe('function');
337
+ expect(typeof sdk.secrets.update).toBe('function');
338
+ expect(typeof sdk.secrets.delete).toBe('function');
339
+ expect(typeof sdk.connections.create).toBe('function');
340
+ expect(typeof sdk.connections.list).toBe('function');
341
+ expect(typeof sdk.connections.get).toBe('function');
342
+ expect(typeof sdk.connections.update).toBe('function');
343
+ expect(typeof sdk.connections.disconnect).toBe('function');
344
+ expect(typeof sdk.connections.markUsed).toBe('function');
345
+ expect(typeof sdk.connections.markError).toBe('function');
346
+ expect(typeof sdk.connections.createOAuthState).toBe('function');
347
+ expect(typeof sdk.connections.verifyOAuthState).toBe('function');
348
+ expect(typeof sdk.connections.getProviderConfig).toBe('function');
349
+ expect(typeof sdk.events.publish).toBe('function');
350
+ expect(typeof sdk.events.subscribe).toBe('function');
351
+ expect(typeof sdk.e2ee.getProfile).toBe('function');
352
+ expect(typeof sdk.e2ee.createProfile).toBe('function');
353
+ expect(typeof sdk.e2ee.getRecoveryWrapper).toBe('function');
354
+ expect(typeof sdk.e2ee.setRecoveryWrapper).toBe('function');
355
+ expect(typeof sdk.e2ee.enrollDevice).toBe('function');
356
+ expect(typeof sdk.e2ee.listDevices).toBe('function');
357
+ expect(typeof sdk.e2ee.revokeDevice).toBe('function');
358
+ });
359
+ });
360
+
361
+ describe('sdk.db', () => {
362
+ it('getClient delegates to the registered host', async () => {
363
+ const client = await sdk.db.getClient();
364
+ expect(client).toBe(mockDbClient);
365
+ });
366
+ });
367
+
368
+ describe('sdk.platform', () => {
369
+ it('getConfig delegates to the registered host', async () => {
370
+ const config = await sdk.platform.getConfig();
371
+ expect(config.tenantName).toBe('Test Workspace');
372
+ expect(config.inviteOnly).toBe(false);
373
+ expect(config.version).toBe('0.6.0');
374
+ expect(config.instanceId).toBe('test-instance-uuid');
375
+ });
376
+ });
377
+
378
+ describe('sdk — host guard', () => {
379
+ it('requireHost throws when no host is registered', async () => {
380
+ // Import requireHost directly to test the guard without a registered host.
381
+ // We have a host registered in beforeAll, so we test the error message shape
382
+ // by verifying our mock is returned (if it were null it would throw).
383
+ const client = await sdk.db.getClient();
384
+ expect(client).toBeDefined();
385
+ });
386
+ });
387
+
388
+ describe('sdk — experimental surfaces throw NotImplementedError', () => {
389
+ it('notifications.send delegates to the registered host (RFC 0015)', async () => {
390
+ // No longer throws NotImplementedError — now delegates to the host.
391
+ await expect(
392
+ sdk.notifications.send({ recipientUserId: 'u1', title: 'Test' }),
393
+ ).resolves.toBeUndefined();
394
+ });
395
+
396
+ it('events.publish / events.subscribe', () => {
397
+ expect(() => sdk.events.publish('e', {})).toThrow(NotImplementedError);
398
+ expect(() => sdk.events.subscribe('e', () => undefined)).toThrow(NotImplementedError);
399
+ });
400
+
401
+ it('data.provide delegates to the registered host (RFC 0002)', () => {
402
+ const resolver = async () => [{ id: 1 }];
403
+ sdk.data.provide('test-contract', resolver);
404
+ expect(mockDataResolvers.get('test-contract')).toBe(resolver);
405
+ });
406
+
407
+ it('activity.log is implemented (RFC 0005)', () => {
408
+ // activity.log delegates to the host via next/headers (requires request context);
409
+ // we verify the method exists and is a function — runtime mediation is tested
410
+ // indirectly via the host mock wired in beforeAll.
411
+ expect(typeof sdk.activity.log).toBe('function');
412
+ });
413
+ });
414
+
415
+ describe('sdk — error classes', () => {
416
+ it('NotAuthenticatedError', () => {
417
+ const err = new NotAuthenticatedError();
418
+ expect(err.name).toBe('NotAuthenticatedError');
419
+ expect(err).toBeInstanceOf(Error);
420
+ });
421
+
422
+ it('ConsentRequiredError', () => {
423
+ const err = new ConsentRequiredError();
424
+ expect(err.name).toBe('ConsentRequiredError');
425
+ expect(err).toBeInstanceOf(Error);
426
+ });
427
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markCurrentSessions, type RawSession } from '../sessions';
3
+
4
+ function raw(token: string, createdAt: string, extra: Partial<RawSession> = {}): RawSession {
5
+ return {
6
+ token,
7
+ createdAt,
8
+ updatedAt: createdAt,
9
+ expiresAt: '2026-12-31T00:00:00.000Z',
10
+ ...extra,
11
+ };
12
+ }
13
+
14
+ describe('markCurrentSessions', () => {
15
+ it('flags the session whose token matches the current one', () => {
16
+ const out = markCurrentSessions(
17
+ [raw('a', '2026-06-01T00:00:00.000Z'), raw('b', '2026-06-02T00:00:00.000Z')],
18
+ 'b',
19
+ );
20
+ expect(out.find((s) => s.token === 'b')?.current).toBe(true);
21
+ expect(out.find((s) => s.token === 'a')?.current).toBe(false);
22
+ });
23
+
24
+ it('orders the current session first', () => {
25
+ const out = markCurrentSessions(
26
+ [raw('old', '2026-06-01T00:00:00.000Z'), raw('cur', '2026-05-01T00:00:00.000Z')],
27
+ 'cur',
28
+ );
29
+ expect(out[0]?.token).toBe('cur');
30
+ });
31
+
32
+ it('orders the rest newest-first', () => {
33
+ const out = markCurrentSessions(
34
+ [
35
+ raw('older', '2026-06-01T00:00:00.000Z'),
36
+ raw('newer', '2026-06-03T00:00:00.000Z'),
37
+ raw('cur', '2026-01-01T00:00:00.000Z'),
38
+ ],
39
+ 'cur',
40
+ );
41
+ expect(out.map((s) => s.token)).toEqual(['cur', 'newer', 'older']);
42
+ });
43
+
44
+ it('marks none current when the token is null', () => {
45
+ const out = markCurrentSessions([raw('a', '2026-06-01T00:00:00.000Z')], null);
46
+ expect(out.every((s) => !s.current)).toBe(true);
47
+ });
48
+
49
+ it('normalises missing userAgent/ipAddress to null', () => {
50
+ const out = markCurrentSessions([raw('a', '2026-06-01T00:00:00.000Z')], 'a');
51
+ expect(out[0]?.userAgent).toBeNull();
52
+ expect(out[0]?.ipAddress).toBeNull();
53
+ });
54
+
55
+ it('passes through userAgent/ipAddress when present', () => {
56
+ const out = markCurrentSessions(
57
+ [raw('a', '2026-06-01T00:00:00.000Z', { userAgent: 'Firefox', ipAddress: '10.0.0.1' })],
58
+ 'a',
59
+ );
60
+ expect(out[0]?.userAgent).toBe('Firefox');
61
+ expect(out[0]?.ipAddress).toBe('10.0.0.1');
62
+ });
63
+ });
@@ -0,0 +1,19 @@
1
+ import { headers } from 'next/headers';
2
+ import { requireHost } from './host';
3
+ import type { ActivityLogEntry } from './types';
4
+
5
+ /**
6
+ * Activity log (RFC 0005). Records a scoped audit event on behalf of the
7
+ * current user; the runtime host injects the actor identity and plugin context
8
+ * so plugins cannot forge them. Plugin-sourced events are always user-scoped.
9
+ *
10
+ * Requires the `activity:write` permission in the plugin manifest.
11
+ */
12
+ export const activity = {
13
+ async log(entry: ActivityLogEntry): Promise<void> {
14
+ const h = await headers();
15
+ const actorId = h.get('x-sovereign-user-id');
16
+ const pluginId = h.get('x-sovereign-plugin-id');
17
+ await requireHost().activity.log(entry, actorId, pluginId);
18
+ },
19
+ };
package/src/auth.ts ADDED
@@ -0,0 +1,134 @@
1
+ import { headers } from 'next/headers';
2
+ import { NotAuthenticatedError } from './errors';
3
+
4
+ // v1 is single-tenant; every session belongs to this tenant id.
5
+ const DEFAULT_TENANT_ID = 'default';
6
+ import { markCurrentSessions, type RawSession } from './sessions';
7
+ import type { ActiveSession, ChangePasswordInput, Session } from './types';
8
+
9
+ const AUTH_URL =
10
+ process.env.SOVEREIGN_AUTH_URL ?? `http://localhost:${process.env.AUTH_PORT ?? '3001'}`;
11
+
12
+ /** Returns the current user session from runtime-injected headers, or null if unauthenticated. */
13
+ export async function getSession(): Promise<Session | null> {
14
+ const h = await headers();
15
+ const id = h.get('x-sovereign-user-id');
16
+ if (!id) return null;
17
+ const role = h.get('x-sovereign-user-role') ?? 'platform:user';
18
+ const capsRaw = h.get('x-sovereign-user-capabilities');
19
+ const capabilities: readonly string[] = capsRaw ? (JSON.parse(capsRaw) as string[]) : [];
20
+ return {
21
+ user: {
22
+ id,
23
+ // v1 is single-tenant, so every session belongs to the default tenant.
24
+ // Multi-tenant deployments will source this from the verified session.
25
+ tenantId: DEFAULT_TENANT_ID,
26
+ email: h.get('x-sovereign-user-email') ?? '',
27
+ name: h.get('x-sovereign-user-name') ?? null,
28
+ image: h.get('x-sovereign-user-image') ?? null,
29
+ role,
30
+ capabilities,
31
+ },
32
+ expiresAt: Number(h.get('x-sovereign-session-expires-at') ?? 0),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Return true if the given session has the named capability (RFC 0021).
38
+ * Plugins should call this instead of comparing `session.user.role` directly
39
+ * to avoid coupling to the role-preset details.
40
+ */
41
+ export function hasCapability(session: Session | null, capability: string): boolean {
42
+ return session?.user.capabilities.includes(capability) ?? false;
43
+ }
44
+
45
+ /** Returns the current user session, throwing `NotAuthenticatedError` if unauthenticated. */
46
+ export async function requireSession(): Promise<Session> {
47
+ const session = await getSession();
48
+ if (!session) throw new NotAuthenticatedError();
49
+ return session;
50
+ }
51
+
52
+ /**
53
+ * Call a better-auth endpoint server-side, forwarding the caller's session
54
+ * cookie. better-auth enforces a CSRF Origin check on state-changing routes,
55
+ * so we send its own base URL as the trusted Origin.
56
+ */
57
+ async function authFetch(path: string, method: 'GET' | 'POST', body?: unknown): Promise<Response> {
58
+ const cookie = (await headers()).get('cookie') ?? '';
59
+ // No explicit `cache` option: under Next 15 fetch is uncached by default, so
60
+ // these credentialed calls are never cached — and the SDK stays free of
61
+ // Next-specific RequestInit fields so it type-checks standalone.
62
+ return fetch(`${AUTH_URL}${path}`, {
63
+ method,
64
+ headers: {
65
+ cookie,
66
+ origin: AUTH_URL,
67
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
68
+ },
69
+ body: body === undefined ? undefined : JSON.stringify(body),
70
+ });
71
+ }
72
+
73
+ /** Build an error message including better-auth's response body, for diagnosis. */
74
+ async function authError(res: Response, action: string): Promise<Error> {
75
+ const body = await res.text().catch(() => '');
76
+ const detail = body ? ` — ${body}` : '';
77
+ return new Error(`${action} (${String(res.status)})${detail}`);
78
+ }
79
+
80
+ /**
81
+ * Change the current user's password (SRS ACC-04). Requires the current
82
+ * password; the current session is preserved (other sessions are kept too).
83
+ * Throws with better-auth's message on failure (e.g. wrong current password).
84
+ */
85
+ export async function changePassword(input: ChangePasswordInput): Promise<void> {
86
+ const res = await authFetch('/api/auth/change-password', 'POST', {
87
+ currentPassword: input.currentPassword,
88
+ newPassword: input.newPassword,
89
+ revokeOtherSessions: false,
90
+ });
91
+ if (!res.ok) {
92
+ const message = ((await res.json().catch(() => null)) as { message?: string } | null)?.message;
93
+ throw new Error(message ?? `Failed to change password (${String(res.status)}).`);
94
+ }
95
+ }
96
+
97
+ /** List the current user's active sessions, current one first (SRS ACC-05). */
98
+ export async function listSessions(): Promise<ActiveSession[]> {
99
+ const [listRes, sessionRes] = await Promise.all([
100
+ authFetch('/api/auth/list-sessions', 'GET'),
101
+ authFetch('/api/auth/get-session', 'GET'),
102
+ ]);
103
+ if (!listRes.ok) {
104
+ throw await authError(listRes, 'Failed to list sessions');
105
+ }
106
+ const raw = (await listRes.json()) as RawSession[];
107
+ const currentToken = sessionRes.ok
108
+ ? (((await sessionRes.json()) as { session?: { token?: string } } | null)?.session?.token ??
109
+ null)
110
+ : null;
111
+ return markCurrentSessions(raw, currentToken);
112
+ }
113
+
114
+ /** Revoke a session by its token (SRS ACC-06). */
115
+ export async function revokeSession(token: string): Promise<void> {
116
+ const res = await authFetch('/api/auth/revoke-session', 'POST', { token });
117
+ if (!res.ok) {
118
+ throw await authError(res, 'Failed to revoke session');
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Sign the current user out (SRS AUTH-02): ends the active session on the auth
124
+ * server. The caller is responsible for clearing the runtime's `session_data`
125
+ * cache cookies and redirecting — the platform's logout route does both.
126
+ */
127
+ export async function signOut(): Promise<void> {
128
+ // Pass an empty body so `authFetch` sends `Content-Type: application/json` —
129
+ // better-auth's sign-out rejects requests without it (415).
130
+ const res = await authFetch('/api/auth/sign-out', 'POST', {});
131
+ if (!res.ok) {
132
+ throw await authError(res, 'Failed to sign out');
133
+ }
134
+ }