@svadmin/supabase 0.10.0 → 0.11.1
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/package.json +3 -3
- package/src/auth-provider.ts +59 -4
- package/src/index.ts +0 -4
- package/src/supabase.test.ts +54 -5
- package/src/supacloud.ts +22 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Supabase adapters — DataProvider, AuthProvider, LiveProvider, AuditHandler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
|
-
"@svadmin/core": "^0.
|
|
23
|
+
"@svadmin/core": "^0.25.2",
|
|
24
24
|
"@supabase/supabase-js": "^2.0.0",
|
|
25
25
|
"@refinedev/supabase": "^5.0.0",
|
|
26
|
-
"@supacloud/js": "^0.
|
|
26
|
+
"@supacloud/js": "^0.12.0"
|
|
27
27
|
},
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"author": "zuohuadong",
|
package/src/auth-provider.ts
CHANGED
|
@@ -3,6 +3,26 @@ import type { SupabaseClient } from '@supabase/supabase-js';
|
|
|
3
3
|
import type { AuthProvider, Identity, AuthActionResult, CheckResult } from '@svadmin/core';
|
|
4
4
|
import { audit } from '@svadmin/core';
|
|
5
5
|
|
|
6
|
+
const INVALID_REFRESH_TOKEN_MESSAGES = [
|
|
7
|
+
'refresh token is not valid',
|
|
8
|
+
'invalid refresh token',
|
|
9
|
+
'refresh_token_not_found',
|
|
10
|
+
'jwt expired',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function isInvalidRefreshTokenError(error: unknown): error is Error {
|
|
14
|
+
if (!(error instanceof Error)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const message = error.message.toLowerCase();
|
|
19
|
+
return INVALID_REFRESH_TOKEN_MESSAGES.some((fragment) => message.includes(fragment));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function clearInvalidSession(client: SupabaseClient): Promise<void> {
|
|
23
|
+
await client.auth.signOut({ scope: 'local' });
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider {
|
|
7
27
|
return {
|
|
8
28
|
async login({ email, password }: Record<string, unknown>): Promise<AuthActionResult> {
|
|
@@ -23,16 +43,42 @@ export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider
|
|
|
23
43
|
},
|
|
24
44
|
|
|
25
45
|
async check(): Promise<CheckResult> {
|
|
26
|
-
const { data: { session } } = await client.auth.getSession();
|
|
46
|
+
const { data: { session }, error } = await client.auth.getSession();
|
|
47
|
+
if (error) {
|
|
48
|
+
if (isInvalidRefreshTokenError(error)) {
|
|
49
|
+
await clearInvalidSession(client);
|
|
50
|
+
return {
|
|
51
|
+
authenticated: false,
|
|
52
|
+
redirectTo: '/login',
|
|
53
|
+
logout: true,
|
|
54
|
+
error: { message: 'Session expired, please sign in again.' },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
authenticated: false,
|
|
60
|
+
redirectTo: '/login',
|
|
61
|
+
logout: true,
|
|
62
|
+
error: { message: 'Session validation failed.' },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
27
65
|
if (session) return { authenticated: true };
|
|
28
66
|
return { authenticated: false, redirectTo: '/login', logout: true };
|
|
29
67
|
},
|
|
30
68
|
|
|
31
69
|
async getIdentity(): Promise<Identity | null> {
|
|
32
|
-
const { data: { user } } = await client.auth.getUser();
|
|
70
|
+
const { data: { user }, error } = await client.auth.getUser();
|
|
71
|
+
if (error && isInvalidRefreshTokenError(error)) {
|
|
72
|
+
await clearInvalidSession(client);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
33
75
|
if (!user) return null;
|
|
34
76
|
|
|
35
|
-
const { data: { session } } = await client.auth.getSession();
|
|
77
|
+
const { data: { session }, error: sessionError } = await client.auth.getSession();
|
|
78
|
+
if (sessionError && isInvalidRefreshTokenError(sessionError)) {
|
|
79
|
+
await clearInvalidSession(client);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
36
82
|
|
|
37
83
|
return {
|
|
38
84
|
id: user.id,
|
|
@@ -44,7 +90,11 @@ export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider
|
|
|
44
90
|
},
|
|
45
91
|
|
|
46
92
|
async getPermissions(): Promise<unknown> {
|
|
47
|
-
const { data: { user } } = await client.auth.getUser();
|
|
93
|
+
const { data: { user }, error } = await client.auth.getUser();
|
|
94
|
+
if (error && isInvalidRefreshTokenError(error)) {
|
|
95
|
+
await clearInvalidSession(client);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
48
98
|
return user?.user_metadata?.role ?? 'user';
|
|
49
99
|
},
|
|
50
100
|
|
|
@@ -71,6 +121,11 @@ export function createSupabaseAuthProvider(client: SupabaseClient): AuthProvider
|
|
|
71
121
|
},
|
|
72
122
|
|
|
73
123
|
async onError(error: unknown): Promise<{ redirectTo?: string; logout?: boolean }> {
|
|
124
|
+
if (isInvalidRefreshTokenError(error)) {
|
|
125
|
+
await clearInvalidSession(client);
|
|
126
|
+
return { redirectTo: '/login', logout: true };
|
|
127
|
+
}
|
|
128
|
+
|
|
74
129
|
if (error instanceof Error && error.message.includes('401')) {
|
|
75
130
|
// Pre-flight check: race-condition guard
|
|
76
131
|
// Supabase might have just refreshed the token in the background right after our fetch failed
|
package/src/index.ts
CHANGED
|
@@ -10,11 +10,7 @@ export {
|
|
|
10
10
|
} from './supacloud';
|
|
11
11
|
export type {
|
|
12
12
|
SupaCloudTaskClient,
|
|
13
|
-
SupaCloudTaskHandle,
|
|
14
|
-
SupaCloudTaskProvider,
|
|
15
13
|
SupaCloudTaskRecord,
|
|
16
|
-
SupaCloudTaskSubmitOptions,
|
|
17
|
-
SupaCloudTaskSubscription,
|
|
18
14
|
CreateSupaCloudTaskProviderOptions,
|
|
19
15
|
CreateSupaCloudTaskLiveProviderOptions,
|
|
20
16
|
} from './supacloud';
|
package/src/supabase.test.ts
CHANGED
|
@@ -135,6 +135,24 @@ describe('Supabase AuthProvider', () => {
|
|
|
135
135
|
expect(result.logout).toBe(true);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test('check clears invalid refresh token sessions', async () => {
|
|
139
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
140
|
+
const client = createMockSupabaseClient({
|
|
141
|
+
auth: {
|
|
142
|
+
getSession: mock(async () => ({
|
|
143
|
+
data: { session: null },
|
|
144
|
+
error: new Error('Refresh token is not valid'),
|
|
145
|
+
})),
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const auth = createSupabaseAuthProvider(client);
|
|
149
|
+
const result = await auth.check();
|
|
150
|
+
expect(result.authenticated).toBe(false);
|
|
151
|
+
expect(result.logout).toBe(true);
|
|
152
|
+
expect(result.redirectTo).toBe('/login');
|
|
153
|
+
expect(client.auth.signOut).toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
138
156
|
test('getIdentity surfaces user metadata', async () => {
|
|
139
157
|
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
140
158
|
const auth = createSupabaseAuthProvider(createMockSupabaseClient());
|
|
@@ -152,6 +170,22 @@ describe('Supabase AuthProvider', () => {
|
|
|
152
170
|
expect(role).toBe('admin');
|
|
153
171
|
});
|
|
154
172
|
|
|
173
|
+
test('getIdentity clears invalid refresh token sessions', async () => {
|
|
174
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
175
|
+
const client = createMockSupabaseClient({
|
|
176
|
+
auth: {
|
|
177
|
+
getUser: mock(async () => ({
|
|
178
|
+
data: { user: null },
|
|
179
|
+
error: new Error('Invalid refresh token'),
|
|
180
|
+
})),
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
const auth = createSupabaseAuthProvider(client);
|
|
184
|
+
const identity = await auth.getIdentity();
|
|
185
|
+
expect(identity).toBeNull();
|
|
186
|
+
expect(client.auth.signOut).toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
155
189
|
test('onError returns logout true on 401 with no session', async () => {
|
|
156
190
|
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
157
191
|
const client = createMockSupabaseClient({
|
|
@@ -172,6 +206,16 @@ describe('Supabase AuthProvider', () => {
|
|
|
172
206
|
expect(result.logout).toBeUndefined();
|
|
173
207
|
expect(client.auth.signOut).not.toHaveBeenCalled();
|
|
174
208
|
});
|
|
209
|
+
|
|
210
|
+
test('onError logs out on invalid refresh token', async () => {
|
|
211
|
+
const { createSupabaseAuthProvider } = await import('./auth-provider');
|
|
212
|
+
const client = createMockSupabaseClient();
|
|
213
|
+
const auth = createSupabaseAuthProvider(client);
|
|
214
|
+
const result = await auth.onError!(new Error('Refresh token is not valid'));
|
|
215
|
+
expect(result.logout).toBe(true);
|
|
216
|
+
expect(result.redirectTo).toBe('/login');
|
|
217
|
+
expect(client.auth.signOut).toHaveBeenCalled();
|
|
218
|
+
});
|
|
175
219
|
});
|
|
176
220
|
|
|
177
221
|
|
|
@@ -242,26 +286,31 @@ describe('SupaCloud Task Provider', () => {
|
|
|
242
286
|
|
|
243
287
|
test('list normalizes object payload with data field', async () => {
|
|
244
288
|
const { createSupaCloudTaskProvider } = await import('./supacloud');
|
|
245
|
-
const supacloud = {
|
|
246
|
-
submit: mock(async () => ({ wait: async () => ({ id: 'task-1' }) })),
|
|
247
|
-
get: mock(async () => ({ id: 'task-1' })),
|
|
289
|
+
const supacloud: import('@svadmin/core').TaskProvider<{ id: string; status: string }> = {
|
|
290
|
+
submit: mock(async () => ({ wait: async () => ({ id: 'task-1', status: 'queued' }) })),
|
|
291
|
+
get: mock(async () => ({ id: 'task-1', status: 'queued' })),
|
|
248
292
|
list: mock(async () => ({ data: [{ id: 'task-1', status: 'queued' }] })),
|
|
249
293
|
};
|
|
250
294
|
|
|
251
295
|
const provider = createSupaCloudTaskProvider({ supacloud });
|
|
296
|
+
if (!provider.list) throw new Error('provider.list should exist');
|
|
252
297
|
const tasks = await provider.list();
|
|
253
298
|
|
|
254
|
-
expect(tasks).toEqual(
|
|
299
|
+
expect(tasks).toEqual({
|
|
300
|
+
data: [{ id: 'task-1', status: 'queued' }],
|
|
301
|
+
total: 1,
|
|
302
|
+
});
|
|
255
303
|
});
|
|
256
304
|
|
|
257
305
|
test('listDlq throws clear error when capability is missing', async () => {
|
|
258
306
|
const { createSupaCloudTaskProvider } = await import('./supacloud');
|
|
259
|
-
const supacloud = {
|
|
307
|
+
const supacloud: import('@svadmin/core').TaskProvider<{ id: string }> = {
|
|
260
308
|
submit: mock(async () => ({ wait: async () => ({ id: 'task-1' }) })),
|
|
261
309
|
get: mock(async () => ({ id: 'task-1' })),
|
|
262
310
|
};
|
|
263
311
|
|
|
264
312
|
const provider = createSupaCloudTaskProvider({ supacloud });
|
|
313
|
+
if (!provider.listDlq) throw new Error('provider.listDlq should exist');
|
|
265
314
|
|
|
266
315
|
await expect(provider.listDlq()).rejects.toThrow('tasks.listDlq');
|
|
267
316
|
});
|
package/src/supacloud.ts
CHANGED
|
@@ -1,31 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import type {
|
|
2
|
+
LiveEvent,
|
|
3
|
+
LiveProvider,
|
|
4
|
+
SubmitTaskOptions,
|
|
5
|
+
TaskHandle,
|
|
6
|
+
TaskListResult,
|
|
7
|
+
TaskProvider,
|
|
8
|
+
TaskRecord,
|
|
9
|
+
TaskSubscription,
|
|
10
|
+
} from '@svadmin/core';
|
|
11
|
+
|
|
12
|
+
export interface SupaCloudTaskRecord extends TaskRecord {
|
|
12
13
|
[key: string]: unknown;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export interface SupaCloudTaskSubscription {
|
|
16
|
-
unsubscribe(): void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export interface SupaCloudTaskHandle<TTask extends SupaCloudTaskRecord = SupaCloudTaskRecord> {
|
|
20
|
-
id?: string;
|
|
21
|
-
wait(): Promise<TTask>;
|
|
22
|
-
subscribe?(callback: (task: TTask) => void): SupaCloudTaskSubscription | (() => void) | void;
|
|
23
|
-
cancel?(): Promise<unknown>;
|
|
24
|
-
retry?(): Promise<unknown>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
16
|
export interface SupaCloudTaskClient<TTask extends SupaCloudTaskRecord = SupaCloudTaskRecord> {
|
|
28
|
-
submit(taskName: string, options?:
|
|
17
|
+
submit(taskName: string, options?: SubmitTaskOptions): Promise<TaskHandle<TTask>>;
|
|
29
18
|
get(taskId: string): Promise<TTask>;
|
|
30
19
|
list?(params?: Record<string, unknown>): Promise<TTask[] | { data?: TTask[] }>;
|
|
31
20
|
listDlq?(params?: Record<string, unknown>): Promise<TTask[] | { data?: TTask[] }>;
|
|
@@ -34,20 +23,7 @@ export interface SupaCloudTaskClient<TTask extends SupaCloudTaskRecord = SupaClo
|
|
|
34
23
|
subscribe?(
|
|
35
24
|
taskId: string,
|
|
36
25
|
callback: (task: TTask) => void
|
|
37
|
-
):
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface SupaCloudTaskProvider<TTask extends SupaCloudTaskRecord = SupaCloudTaskRecord> {
|
|
41
|
-
submit(taskName: string, options?: SupaCloudTaskSubmitOptions): Promise<SupaCloudTaskHandle<TTask>>;
|
|
42
|
-
get(taskId: string): Promise<TTask>;
|
|
43
|
-
list(params?: Record<string, unknown>): Promise<TTask[] | { data?: TTask[] }>;
|
|
44
|
-
listDlq(params?: Record<string, unknown>): Promise<TTask[] | { data?: TTask[] }>;
|
|
45
|
-
cancel(taskId: string): Promise<unknown>;
|
|
46
|
-
retry(taskId: string): Promise<unknown>;
|
|
47
|
-
subscribe(
|
|
48
|
-
taskId: string,
|
|
49
|
-
callback: (task: TTask) => void
|
|
50
|
-
): SupaCloudTaskSubscription | (() => void) | void;
|
|
26
|
+
): TaskSubscription | (() => void) | void;
|
|
51
27
|
}
|
|
52
28
|
|
|
53
29
|
export interface CreateSupaCloudTaskProviderOptions<
|
|
@@ -65,7 +41,7 @@ export interface CreateSupaCloudTaskLiveProviderOptions<
|
|
|
65
41
|
}
|
|
66
42
|
|
|
67
43
|
function normalizeTaskSubscription(
|
|
68
|
-
subscription:
|
|
44
|
+
subscription: TaskSubscription | (() => void) | void,
|
|
69
45
|
): (() => void) | undefined {
|
|
70
46
|
if (!subscription) return undefined;
|
|
71
47
|
if (typeof subscription === 'function') return subscription;
|
|
@@ -77,8 +53,12 @@ function normalizeTaskSubscription(
|
|
|
77
53
|
|
|
78
54
|
function normalizeTaskList<TTask extends SupaCloudTaskRecord>(
|
|
79
55
|
value: TTask[] | { data?: TTask[] },
|
|
80
|
-
): TTask
|
|
81
|
-
|
|
56
|
+
): TaskListResult<TTask> {
|
|
57
|
+
const data = Array.isArray(value) ? value : (value.data ?? []);
|
|
58
|
+
return {
|
|
59
|
+
data,
|
|
60
|
+
total: data.length,
|
|
61
|
+
};
|
|
82
62
|
}
|
|
83
63
|
|
|
84
64
|
function defaultMapTaskToEvent<TTask extends SupaCloudTaskRecord>(
|
|
@@ -94,7 +74,7 @@ function defaultMapTaskToEvent<TTask extends SupaCloudTaskRecord>(
|
|
|
94
74
|
|
|
95
75
|
export function createSupaCloudTaskProvider<
|
|
96
76
|
TTask extends SupaCloudTaskRecord = SupaCloudTaskRecord,
|
|
97
|
-
>(options: CreateSupaCloudTaskProviderOptions<TTask>):
|
|
77
|
+
>(options: CreateSupaCloudTaskProviderOptions<TTask>): TaskProvider<TTask> {
|
|
98
78
|
const { supacloud } = options;
|
|
99
79
|
|
|
100
80
|
return {
|