@svadmin/supabase 0.13.9 → 0.15.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/package.json +7 -7
- package/src/audit-handler.ts +59 -10
- package/src/auth-contract.ts +169 -0
- package/src/auth-provider.ts +262 -123
- package/src/data-provider.ts +112 -178
- package/src/index.ts +16 -11
- package/src/live-provider.ts +193 -75
- package/src/permission-adapter.ts +28 -0
- package/src/rpc.ts +11 -29
- package/src/sdk-boundary.ts +79 -0
- package/src/supacloud.ts +220 -594
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@svadmin/supabase",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Supabase adapters — DataProvider, AuthProvider, LiveProvider, AuditHandler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@svadmin/core": "^0.
|
|
27
|
+
"@svadmin/core": "^0.52.0",
|
|
28
28
|
"@supabase/supabase-js": "^2.112.4",
|
|
29
29
|
"@refinedev/supabase": "^6.0.2",
|
|
30
30
|
"@supacloud/js": "^0.23.1"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"author": "zuohuadong",
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|
|
36
|
-
"url": "https://github.com/vibeunion/svadmin.git",
|
|
36
|
+
"url": "git+https://github.com/vibeunion/svadmin.git",
|
|
37
37
|
"directory": "packages/supabase"
|
|
38
38
|
},
|
|
39
39
|
"homepage": "https://github.com/vibeunion/svadmin#readme",
|
|
@@ -55,11 +55,11 @@
|
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@
|
|
58
|
+
"@sinclair/typebox": "^0.34.52",
|
|
59
|
+
"@svadmin/refine-adapter": "^0.11.6"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
|
-
"@
|
|
62
|
-
"@supabase/supabase-js": "^2.112.4"
|
|
63
|
-
"@supacloud/js": "^0.23.1"
|
|
62
|
+
"@supacloud/js": "0.23.1",
|
|
63
|
+
"@supabase/supabase-js": "^2.112.4"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/audit-handler.ts
CHANGED
|
@@ -1,16 +1,65 @@
|
|
|
1
|
-
// Supabase AuditHandler — writes audit entries to a Supabase table
|
|
2
|
-
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
3
1
|
import type { AuditHandler, AuditEntry } from '@svadmin/core';
|
|
2
|
+
import { definedOptions } from '@svadmin/core/options';
|
|
3
|
+
import { decodeAuditEntry, snapshotPlainData, checkExact, type JsonValue } from '@svadmin/core/schema';
|
|
4
|
+
import { Type } from '@sinclair/typebox';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
export interface SupabaseAuditRow {
|
|
7
|
+
action: AuditEntry['action'];
|
|
8
|
+
resource?: string;
|
|
9
|
+
record_id: string | null;
|
|
10
|
+
user_id?: string;
|
|
11
|
+
details: JsonValue;
|
|
12
|
+
created_at: AuditEntry['timestamp'];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SupabaseAuditClient {
|
|
16
|
+
from(table: 'audit_log'): {
|
|
17
|
+
insert(row: SupabaseAuditRow): PromiseLike<unknown>;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SupabaseAuditError extends Error {
|
|
22
|
+
constructor(readonly code: 'WRITE_REJECTED' | 'WRITE_OUTCOME_UNKNOWN') {
|
|
23
|
+
super(code === 'WRITE_REJECTED' ? 'Audit write was rejected.' : 'Audit write outcome could not be confirmed.');
|
|
24
|
+
this.name = 'SupabaseAuditError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const receiptSchema = Type.Object({
|
|
29
|
+
data: Type.Null(), error: Type.Null(), count: Type.Null(),
|
|
30
|
+
status: Type.Union([Type.Literal(201), Type.Literal(204)]), statusText: Type.String(),
|
|
31
|
+
});
|
|
32
|
+
const rejectedSchema = Type.Object({
|
|
33
|
+
data: Type.Null(), count: Type.Null(),
|
|
34
|
+
error: Type.Object({
|
|
35
|
+
code: Type.String({ minLength: 1 }), message: Type.String(),
|
|
36
|
+
details: Type.Union([Type.String(), Type.Null()]),
|
|
37
|
+
hint: Type.Union([Type.String(), Type.Null()]),
|
|
38
|
+
}),
|
|
39
|
+
status: Type.Union([
|
|
40
|
+
Type.Literal(400), Type.Literal(401), Type.Literal(403),
|
|
41
|
+
Type.Literal(404), Type.Literal(405), Type.Literal(409),
|
|
42
|
+
Type.Literal(413), Type.Literal(415), Type.Literal(422), Type.Literal(429),
|
|
43
|
+
]),
|
|
44
|
+
statusText: Type.String(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Preserve the full validated event in JSON while retaining indexed query columns. */
|
|
48
|
+
export function createSupabaseAuditHandler(client: SupabaseAuditClient): AuditHandler {
|
|
49
|
+
return async value => {
|
|
50
|
+
const entry = decodeAuditEntry(value);
|
|
51
|
+
const row = {
|
|
8
52
|
action: entry.action,
|
|
9
|
-
resource: entry.resource,
|
|
10
|
-
record_id: entry.recordId ? String(entry.recordId)
|
|
11
|
-
|
|
12
|
-
details: entry.details,
|
|
53
|
+
...definedOptions({ resource: entry.resource, user_id: entry.userId }),
|
|
54
|
+
record_id: entry.recordId === undefined ? null : String(entry.recordId),
|
|
55
|
+
details: snapshotPlainData(entry),
|
|
13
56
|
created_at: entry.timestamp,
|
|
14
|
-
}
|
|
57
|
+
} satisfies SupabaseAuditRow;
|
|
58
|
+
let result: unknown;
|
|
59
|
+
try { result = snapshotPlainData(await client.from('audit_log').insert(row)); }
|
|
60
|
+
catch { throw new SupabaseAuditError('WRITE_OUTCOME_UNKNOWN'); }
|
|
61
|
+
if (checkExact(receiptSchema, result)) return;
|
|
62
|
+
if (checkExact(rejectedSchema, result)) throw new SupabaseAuditError('WRITE_REJECTED');
|
|
63
|
+
throw new SupabaseAuditError('WRITE_OUTCOME_UNKNOWN');
|
|
15
64
|
};
|
|
16
65
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Type, type Static, type TSchema } from '@sinclair/typebox';
|
|
2
|
+
import { checkExact, snapshotPlainData } from '@svadmin/core/schema';
|
|
3
|
+
|
|
4
|
+
const nonempty = Type.String({ minLength: 1 });
|
|
5
|
+
const record = Type.Record(Type.String(), Type.Unknown());
|
|
6
|
+
const nullableText = Type.Union([Type.String(), Type.Null()]);
|
|
7
|
+
const username = Type.Optional(nonempty);
|
|
8
|
+
|
|
9
|
+
export const credentials = Type.Object({ email: nonempty, password: nonempty, username }, { additionalProperties: false });
|
|
10
|
+
export const registration = Type.Object({
|
|
11
|
+
email: nonempty, password: nonempty, username, name: Type.Optional(Type.String()),
|
|
12
|
+
}, { additionalProperties: false });
|
|
13
|
+
export const resetRequest = Type.Object({ email: nonempty, username }, { additionalProperties: false });
|
|
14
|
+
export const passwordUpdate = Type.Object({
|
|
15
|
+
password: nonempty, confirmPassword: Type.Optional(nonempty),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
|
|
18
|
+
export const identityMetadata = Type.Object({
|
|
19
|
+
name: Type.Optional(nullableText), avatar_url: Type.Optional(nullableText),
|
|
20
|
+
});
|
|
21
|
+
export const userSchema = Type.Object({
|
|
22
|
+
id: nonempty, aud: nonempty, created_at: nonempty,
|
|
23
|
+
app_metadata: record, user_metadata: Type.Intersect([identityMetadata, record]),
|
|
24
|
+
email: Type.Optional(Type.String()), phone: Type.Optional(Type.String()),
|
|
25
|
+
role: Type.Optional(Type.String()),
|
|
26
|
+
is_anonymous: Type.Optional(Type.Boolean()),
|
|
27
|
+
});
|
|
28
|
+
export type SupabaseAuthUser = Static<typeof userSchema>;
|
|
29
|
+
|
|
30
|
+
export const sessionSchema = Type.Object({
|
|
31
|
+
access_token: nonempty, refresh_token: nonempty, token_type: Type.Literal('bearer'),
|
|
32
|
+
expires_in: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
33
|
+
expires_at: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
34
|
+
user: userSchema,
|
|
35
|
+
});
|
|
36
|
+
export type SupabaseAuthSession = Static<typeof sessionSchema>;
|
|
37
|
+
|
|
38
|
+
export const loginResponse = Type.Object({
|
|
39
|
+
data: Type.Object({ user: userSchema, session: sessionSchema }), error: Type.Null(),
|
|
40
|
+
});
|
|
41
|
+
export const registrationResponse = Type.Object({
|
|
42
|
+
data: Type.Object({ user: userSchema, session: Type.Union([sessionSchema, Type.Null()]) }), error: Type.Null(),
|
|
43
|
+
});
|
|
44
|
+
export const sessionResponse = Type.Object({
|
|
45
|
+
data: Type.Object({ session: Type.Union([sessionSchema, Type.Null()]) }), error: Type.Null(),
|
|
46
|
+
});
|
|
47
|
+
export const userResponse = Type.Object({
|
|
48
|
+
data: Type.Object({ user: Type.Union([userSchema, Type.Null()]) }), error: Type.Null(),
|
|
49
|
+
});
|
|
50
|
+
export const updateResponse = Type.Object({
|
|
51
|
+
data: Type.Object({ user: userSchema }), error: Type.Null(),
|
|
52
|
+
});
|
|
53
|
+
export const resetResponse = Type.Object({
|
|
54
|
+
data: Type.Object({}, { additionalProperties: false }), error: Type.Null(),
|
|
55
|
+
});
|
|
56
|
+
export const signOutResponse = Type.Object({ error: Type.Null() });
|
|
57
|
+
|
|
58
|
+
const errorMessages = {
|
|
59
|
+
INVALID_AUTH_INPUT: 'Invalid authentication input.',
|
|
60
|
+
INVALID_AUTH_RESPONSE: 'Invalid authentication response.',
|
|
61
|
+
INVALID_AUTH_SESSION: 'Invalid authentication session.',
|
|
62
|
+
AUTH_SESSION_EXPIRED: 'Session expired, please sign in again.',
|
|
63
|
+
AUTH_SESSION_CHANGED: 'Authentication session changed. Retry the operation.',
|
|
64
|
+
AUTH_REJECTED: 'Authentication request was rejected.',
|
|
65
|
+
AUTH_REQUEST_FAILED: 'Authentication request failed.',
|
|
66
|
+
AUTH_CLEANUP_FAILED: 'Session cleanup could not be confirmed.',
|
|
67
|
+
WRITE_OUTCOME_UNKNOWN: 'Authentication operation may have succeeded; its outcome could not be confirmed.',
|
|
68
|
+
PERMISSION_LOOKUP_FAILED: 'Permission lookup failed.',
|
|
69
|
+
INVALID_PERMISSIONS: 'Invalid permission response.',
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
export class SupabaseAuthError extends Error {
|
|
73
|
+
constructor(readonly code: keyof typeof errorMessages) {
|
|
74
|
+
super(errorMessages[code]);
|
|
75
|
+
this.name = 'SupabaseAuthError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type BoundaryPhase = 'input' | 'response' | 'session';
|
|
80
|
+
|
|
81
|
+
export function decodeAuth<S extends TSchema>(
|
|
82
|
+
schema: S, value: unknown, phase: BoundaryPhase,
|
|
83
|
+
): Static<S> {
|
|
84
|
+
try {
|
|
85
|
+
const candidate = snapshotPlainData(value);
|
|
86
|
+
if (checkExact(schema, candidate)) return candidate;
|
|
87
|
+
} catch {
|
|
88
|
+
// Credential payloads and reflection errors must not become error details.
|
|
89
|
+
}
|
|
90
|
+
throw new SupabaseAuthError(phase === 'input' ? 'INVALID_AUTH_INPUT'
|
|
91
|
+
: phase === 'session' ? 'INVALID_AUTH_SESSION' : 'INVALID_AUTH_RESPONSE');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Project only own data fields: SDK Error instances are not JSON records. */
|
|
95
|
+
function errorField(error: object, key: string): unknown {
|
|
96
|
+
const property = Object.getOwnPropertyDescriptor(error, key);
|
|
97
|
+
if (!property) return undefined;
|
|
98
|
+
if (!('value' in property)) throw new TypeError('Invalid error descriptor');
|
|
99
|
+
return property.value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function inspectAuthError(error: unknown): 'expired' | 'unauthorized' | 'rejected' | 'other' {
|
|
103
|
+
try {
|
|
104
|
+
if (typeof error !== 'object' || error === null) return 'other';
|
|
105
|
+
const code = errorField(error, 'code');
|
|
106
|
+
const status = errorField(error, 'status');
|
|
107
|
+
const name = errorField(error, 'name');
|
|
108
|
+
if ((code !== undefined && typeof code !== 'string') ||
|
|
109
|
+
(name !== undefined && typeof name !== 'string') ||
|
|
110
|
+
(status !== undefined && (typeof status !== 'number' || !Number.isInteger(status)))) return 'other';
|
|
111
|
+
if (code === 'refresh_token_not_found' || code === 'refresh_token_already_used' ||
|
|
112
|
+
code === 'session_not_found' || code === 'user_not_found' || code === 'bad_jwt' ||
|
|
113
|
+
code === 'invalid_jwt' || name === 'AuthSessionMissingError') return 'expired';
|
|
114
|
+
if (status === 401) return 'unauthorized';
|
|
115
|
+
if (typeof status === 'number' && status >= 400 && status < 500) return 'rejected';
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore uninspectable errors, including throwing or revoked proxies.
|
|
118
|
+
}
|
|
119
|
+
return 'other';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function requestFailure(error: unknown, write: boolean, phase: 'response' | 'session'): SupabaseAuthError {
|
|
123
|
+
switch (inspectAuthError(error)) {
|
|
124
|
+
case 'expired': return new SupabaseAuthError('AUTH_SESSION_EXPIRED');
|
|
125
|
+
case 'unauthorized': return new SupabaseAuthError(phase === 'session' ? 'AUTH_SESSION_EXPIRED' : 'AUTH_REJECTED');
|
|
126
|
+
case 'rejected': return new SupabaseAuthError('AUTH_REJECTED');
|
|
127
|
+
case 'other': return new SupabaseAuthError(write ? 'WRITE_OUTCOME_UNKNOWN' : 'AUTH_REQUEST_FAILED');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Successful results must match a schema; failed results never expose SDK messages. */
|
|
132
|
+
export async function authRequest<S extends TSchema>(
|
|
133
|
+
invoke: () => Promise<unknown>, schema: S, phase: 'response' | 'session', write = false,
|
|
134
|
+
): Promise<Static<S>> {
|
|
135
|
+
let result: unknown;
|
|
136
|
+
try { result = await invoke(); }
|
|
137
|
+
catch (error) { throw requestFailure(error, write, phase); }
|
|
138
|
+
|
|
139
|
+
let error: unknown;
|
|
140
|
+
try {
|
|
141
|
+
if (typeof result !== 'object' || result === null || Array.isArray(result)) {
|
|
142
|
+
throw new TypeError('Invalid response');
|
|
143
|
+
}
|
|
144
|
+
const descriptor = Object.getOwnPropertyDescriptor(result, 'error');
|
|
145
|
+
if (!descriptor || !('value' in descriptor)) throw new TypeError('Invalid response');
|
|
146
|
+
error = descriptor.value;
|
|
147
|
+
} catch {
|
|
148
|
+
throw new SupabaseAuthError(write ? 'WRITE_OUTCOME_UNKNOWN'
|
|
149
|
+
: phase === 'session' ? 'INVALID_AUTH_SESSION' : 'INVALID_AUTH_RESPONSE');
|
|
150
|
+
}
|
|
151
|
+
if (error !== null) {
|
|
152
|
+
if (typeof error !== 'object' || error === null) {
|
|
153
|
+
throw new SupabaseAuthError(write ? 'WRITE_OUTCOME_UNKNOWN'
|
|
154
|
+
: phase === 'session' ? 'INVALID_AUTH_SESSION' : 'INVALID_AUTH_RESPONSE');
|
|
155
|
+
}
|
|
156
|
+
throw requestFailure(error, write, phase);
|
|
157
|
+
}
|
|
158
|
+
try { return decodeAuth(schema, result, phase); }
|
|
159
|
+
catch {
|
|
160
|
+
throw new SupabaseAuthError(write ? 'WRITE_OUTCOME_UNKNOWN'
|
|
161
|
+
: phase === 'session' ? 'INVALID_AUTH_SESSION' : 'INVALID_AUTH_RESPONSE');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateIdentifier(input: { email: string; username?: string }): void {
|
|
166
|
+
if (input.username !== undefined && input.username !== input.email) {
|
|
167
|
+
throw new SupabaseAuthError('INVALID_AUTH_INPUT');
|
|
168
|
+
}
|
|
169
|
+
}
|