@avelonjs/supabase 0.1.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 +21 -0
- package/README.md +270 -0
- package/package.json +53 -0
- package/src/database/compile.ts +198 -0
- package/src/database/driver.ts +437 -0
- package/src/database/errors.ts +93 -0
- package/src/database/fixtures.ts +206 -0
- package/src/database/index.ts +17 -0
- package/src/database/normalize.ts +1 -0
- package/src/database/wards.ts +124 -0
- package/src/identity/cookies.ts +57 -0
- package/src/identity/driver.ts +251 -0
- package/src/identity/index.ts +14 -0
- package/src/identity/local-auth.ts +209 -0
- package/src/identity/types.ts +19 -0
- package/src/index.ts +6 -0
- package/src/queue/driver.ts +268 -0
- package/src/queue/index.ts +6 -0
- package/src/social/driver.ts +130 -0
- package/src/social/index.ts +8 -0
- package/src/social/local-auth.ts +56 -0
- package/src/social/types.ts +7 -0
- package/src/storage/driver.ts +228 -0
- package/src/storage/index.ts +6 -0
- package/src/tokens/driver.ts +216 -0
- package/src/tokens/index.ts +6 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
|
|
3
|
+
/** SQL that provisions assay fixtures, upsert RPC, and a minimal auth.uid() helper. */
|
|
4
|
+
export const SUPABASE_ASSAY_FIXTURE_SQL = `
|
|
5
|
+
DO $roles$ BEGIN
|
|
6
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
|
|
7
|
+
CREATE ROLE anon NOLOGIN NOINHERIT;
|
|
8
|
+
END IF;
|
|
9
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
|
|
10
|
+
CREATE ROLE authenticated NOLOGIN NOINHERIT;
|
|
11
|
+
END IF;
|
|
12
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
13
|
+
CREATE ROLE service_role NOLOGIN NOINHERIT BYPASSRLS;
|
|
14
|
+
END IF;
|
|
15
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN
|
|
16
|
+
CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD 'avelon';
|
|
17
|
+
END IF;
|
|
18
|
+
END
|
|
19
|
+
$roles$;
|
|
20
|
+
|
|
21
|
+
GRANT anon TO authenticator;
|
|
22
|
+
GRANT authenticated TO authenticator;
|
|
23
|
+
GRANT service_role TO authenticator;
|
|
24
|
+
|
|
25
|
+
DO $connect$ BEGIN
|
|
26
|
+
EXECUTE format(
|
|
27
|
+
'GRANT CONNECT ON DATABASE %I TO authenticator, anon, authenticated, service_role',
|
|
28
|
+
current_database()
|
|
29
|
+
);
|
|
30
|
+
END
|
|
31
|
+
$connect$;
|
|
32
|
+
|
|
33
|
+
CREATE SCHEMA IF NOT EXISTS auth;
|
|
34
|
+
|
|
35
|
+
CREATE OR REPLACE FUNCTION auth.uid()
|
|
36
|
+
RETURNS text
|
|
37
|
+
LANGUAGE sql
|
|
38
|
+
STABLE
|
|
39
|
+
AS $$
|
|
40
|
+
SELECT COALESCE(
|
|
41
|
+
NULLIF(current_setting('request.jwt.claim.sub', true), ''),
|
|
42
|
+
NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub'
|
|
43
|
+
);
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
DROP TABLE IF EXISTS assay_reactions CASCADE;
|
|
47
|
+
DROP TABLE IF EXISTS assay_comments CASCADE;
|
|
48
|
+
DROP TABLE IF EXISTS assay_posts CASCADE;
|
|
49
|
+
DROP TABLE IF EXISTS assay_profiles CASCADE;
|
|
50
|
+
DROP TABLE IF EXISTS assay_users CASCADE;
|
|
51
|
+
DROP FUNCTION IF EXISTS assay_echo(jsonb);
|
|
52
|
+
DROP FUNCTION IF EXISTS avelon_upsert_subset(text, jsonb, text[], text[], text[]);
|
|
53
|
+
|
|
54
|
+
CREATE TABLE assay_users (
|
|
55
|
+
id text PRIMARY KEY,
|
|
56
|
+
email text NOT NULL UNIQUE,
|
|
57
|
+
name text NOT NULL,
|
|
58
|
+
age integer NOT NULL,
|
|
59
|
+
nickname text
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE assay_profiles (
|
|
63
|
+
id text PRIMARY KEY,
|
|
64
|
+
user_id text NOT NULL UNIQUE REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
65
|
+
bio text NOT NULL
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE TABLE assay_posts (
|
|
69
|
+
id text PRIMARY KEY,
|
|
70
|
+
user_id text NOT NULL REFERENCES assay_users(id) ON DELETE CASCADE,
|
|
71
|
+
title text NOT NULL,
|
|
72
|
+
score integer NOT NULL,
|
|
73
|
+
published_at timestamptz
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE assay_comments (
|
|
77
|
+
id text PRIMARY KEY,
|
|
78
|
+
post_id text NOT NULL REFERENCES assay_posts(id) ON DELETE CASCADE,
|
|
79
|
+
body text NOT NULL,
|
|
80
|
+
position integer NOT NULL
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE assay_reactions (
|
|
84
|
+
id text PRIMARY KEY,
|
|
85
|
+
comment_id text NOT NULL REFERENCES assay_comments(id) ON DELETE CASCADE,
|
|
86
|
+
kind text NOT NULL
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE OR REPLACE FUNCTION assay_echo(args jsonb)
|
|
90
|
+
RETURNS jsonb
|
|
91
|
+
LANGUAGE sql
|
|
92
|
+
IMMUTABLE
|
|
93
|
+
AS $$
|
|
94
|
+
SELECT args;
|
|
95
|
+
$$;
|
|
96
|
+
|
|
97
|
+
CREATE OR REPLACE FUNCTION avelon_upsert_subset(
|
|
98
|
+
target_table text,
|
|
99
|
+
input_rows jsonb,
|
|
100
|
+
conflict_columns text[],
|
|
101
|
+
update_columns text[],
|
|
102
|
+
returning_columns text[] DEFAULT NULL
|
|
103
|
+
) RETURNS jsonb
|
|
104
|
+
LANGUAGE plpgsql
|
|
105
|
+
SECURITY INVOKER
|
|
106
|
+
SET search_path = pg_catalog, public
|
|
107
|
+
AS $function$
|
|
108
|
+
DECLARE
|
|
109
|
+
table_oid regclass;
|
|
110
|
+
qualified_table text;
|
|
111
|
+
insert_columns text[];
|
|
112
|
+
all_columns text[];
|
|
113
|
+
insert_sql text;
|
|
114
|
+
conflict_sql text;
|
|
115
|
+
update_sql text;
|
|
116
|
+
returning_sql text;
|
|
117
|
+
statement text;
|
|
118
|
+
result jsonb;
|
|
119
|
+
BEGIN
|
|
120
|
+
IF target_table !~ '^assay_[a-z0-9_]+$' THEN
|
|
121
|
+
RAISE EXCEPTION 'invalid target table';
|
|
122
|
+
END IF;
|
|
123
|
+
qualified_table := format('public.%I', target_table);
|
|
124
|
+
table_oid := to_regclass(qualified_table);
|
|
125
|
+
IF table_oid IS NULL THEN
|
|
126
|
+
RAISE EXCEPTION 'target table does not exist';
|
|
127
|
+
END IF;
|
|
128
|
+
IF jsonb_typeof(input_rows) <> 'array' OR jsonb_array_length(input_rows) = 0 THEN
|
|
129
|
+
RAISE EXCEPTION 'input_rows must be a non-empty array';
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
SELECT array_agg(key ORDER BY key)
|
|
133
|
+
INTO insert_columns
|
|
134
|
+
FROM jsonb_object_keys(input_rows -> 0) AS key;
|
|
135
|
+
IF insert_columns IS NULL OR cardinality(insert_columns) = 0 THEN
|
|
136
|
+
RAISE EXCEPTION 'input rows must not be empty';
|
|
137
|
+
END IF;
|
|
138
|
+
IF conflict_columns IS NULL OR cardinality(conflict_columns) = 0
|
|
139
|
+
OR update_columns IS NULL OR cardinality(update_columns) = 0 THEN
|
|
140
|
+
RAISE EXCEPTION 'conflict and update columns must be non-empty';
|
|
141
|
+
END IF;
|
|
142
|
+
|
|
143
|
+
all_columns := insert_columns || conflict_columns || update_columns || COALESCE(returning_columns, ARRAY[]::text[]);
|
|
144
|
+
IF EXISTS (
|
|
145
|
+
SELECT 1
|
|
146
|
+
FROM unnest(all_columns) AS requested(column_name)
|
|
147
|
+
WHERE requested.column_name !~ '^[A-Za-z_][A-Za-z0-9_]*$'
|
|
148
|
+
OR NOT EXISTS (
|
|
149
|
+
SELECT 1
|
|
150
|
+
FROM pg_attribute
|
|
151
|
+
WHERE attrelid = table_oid
|
|
152
|
+
AND attname = requested.column_name
|
|
153
|
+
AND attnum > 0
|
|
154
|
+
AND NOT attisdropped
|
|
155
|
+
)
|
|
156
|
+
) THEN
|
|
157
|
+
RAISE EXCEPTION 'unknown or invalid column';
|
|
158
|
+
END IF;
|
|
159
|
+
|
|
160
|
+
SELECT string_agg(format('%I', column_name), ', ')
|
|
161
|
+
INTO insert_sql
|
|
162
|
+
FROM unnest(insert_columns) AS columns(column_name);
|
|
163
|
+
SELECT string_agg(format('%I', column_name), ', ')
|
|
164
|
+
INTO conflict_sql
|
|
165
|
+
FROM unnest(conflict_columns) AS columns(column_name);
|
|
166
|
+
SELECT string_agg(format('%1$I = EXCLUDED.%1$I', column_name), ', ')
|
|
167
|
+
INTO update_sql
|
|
168
|
+
FROM unnest(update_columns) AS columns(column_name);
|
|
169
|
+
IF returning_columns IS NULL THEN
|
|
170
|
+
returning_sql := '*';
|
|
171
|
+
ELSE
|
|
172
|
+
SELECT string_agg(format('%I', column_name), ', ')
|
|
173
|
+
INTO returning_sql
|
|
174
|
+
FROM unnest(returning_columns) AS columns(column_name);
|
|
175
|
+
END IF;
|
|
176
|
+
|
|
177
|
+
statement := format(
|
|
178
|
+
'WITH input AS (SELECT * FROM jsonb_populate_recordset(NULL::%1$s, $1)), affected AS (' ||
|
|
179
|
+
'INSERT INTO %1$s (%2$s) SELECT %2$s FROM input ' ||
|
|
180
|
+
'ON CONFLICT (%3$s) DO UPDATE SET %4$s RETURNING %5$s) ' ||
|
|
181
|
+
'SELECT COALESCE(jsonb_agg(to_jsonb(affected)), ''[]''::jsonb) FROM affected',
|
|
182
|
+
qualified_table,
|
|
183
|
+
insert_sql,
|
|
184
|
+
conflict_sql,
|
|
185
|
+
update_sql,
|
|
186
|
+
returning_sql
|
|
187
|
+
);
|
|
188
|
+
EXECUTE statement USING input_rows INTO result;
|
|
189
|
+
RETURN result;
|
|
190
|
+
END;
|
|
191
|
+
$function$;
|
|
192
|
+
|
|
193
|
+
GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
|
|
194
|
+
GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role;
|
|
195
|
+
GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated, service_role;
|
|
196
|
+
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role;
|
|
197
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated, service_role;
|
|
198
|
+
GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, service_role;
|
|
199
|
+
|
|
200
|
+
NOTIFY pgrst, 'reload schema';
|
|
201
|
+
`
|
|
202
|
+
|
|
203
|
+
/** Provisions empty assay fixtures and helper routines for live Supabase database tests. */
|
|
204
|
+
export async function resetSupabaseAssayFixtures(sql: SQL): Promise<void> {
|
|
205
|
+
await sql.unsafe(SUPABASE_ASSAY_FIXTURE_SQL)
|
|
206
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { compilePostgrest, applyPostgrestPredicate, type CompiledPostgrestRequest } from './compile'
|
|
2
|
+
export {
|
|
3
|
+
createSupabaseDatabase,
|
|
4
|
+
SupabaseDatabase,
|
|
5
|
+
supabaseDatabaseCapabilities,
|
|
6
|
+
type SupabaseDatabaseOptions,
|
|
7
|
+
} from './driver'
|
|
8
|
+
export { mapPostgrestError, SUPABASE_DATABASE_ERROR_MAP } from './errors'
|
|
9
|
+
export { resetSupabaseAssayFixtures, SUPABASE_ASSAY_FIXTURE_SQL } from './fixtures'
|
|
10
|
+
export { combinedPredicate, normalizePredicate } from './normalize'
|
|
11
|
+
export {
|
|
12
|
+
compileAllWardPolicies,
|
|
13
|
+
compileWardPredicate,
|
|
14
|
+
compileWardPolicySql,
|
|
15
|
+
type WardPolicy,
|
|
16
|
+
} from './wards'
|
|
17
|
+
export type { PostgresMigration } from '@avelonjs/postgres'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { combinedPredicate, normalizePredicate } from '@avelonjs/postgres'
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Invalid, type CompareOp, type Predicate } from '@avelonjs/core'
|
|
2
|
+
import { normalizePredicate } from '@avelonjs/postgres'
|
|
3
|
+
|
|
4
|
+
/** One ward policy synchronized into Postgres RLS. */
|
|
5
|
+
export interface WardPolicy {
|
|
6
|
+
/** Stable policy name. */
|
|
7
|
+
name: string
|
|
8
|
+
/** Target table. */
|
|
9
|
+
table: string
|
|
10
|
+
/** SQL command the policy covers. */
|
|
11
|
+
command: 'select' | 'insert' | 'update' | 'delete' | 'all'
|
|
12
|
+
/** USING expression for read/update/delete (and ALL). */
|
|
13
|
+
using?: Predicate | boolean
|
|
14
|
+
/** WITH CHECK expression for insert/update (and ALL). */
|
|
15
|
+
check?: Predicate | boolean
|
|
16
|
+
/** Role the policy applies to. Defaults to `authenticated`. */
|
|
17
|
+
role?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function invalid(message: string): never {
|
|
21
|
+
throw new Invalid(message, { metadata: { fields: { ward: [message] } } })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function quoteIdentifier(identifier: string): string {
|
|
25
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
26
|
+
invalid(`Ward identifier '${identifier}' is not a simple SQL identifier.`)
|
|
27
|
+
}
|
|
28
|
+
return `"${identifier}"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SQL_OPERATOR: Record<CompareOp, string> = {
|
|
32
|
+
'=': '=',
|
|
33
|
+
'!=': '<>',
|
|
34
|
+
'<': '<',
|
|
35
|
+
'<=': '<=',
|
|
36
|
+
'>': '>',
|
|
37
|
+
'>=': '>=',
|
|
38
|
+
like: 'LIKE',
|
|
39
|
+
ilike: 'ILIKE',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Compiles a ward predicate into a Postgres RLS boolean SQL expression.
|
|
44
|
+
*
|
|
45
|
+
* Values are inlined only when they are JSON-safe literals. Actor claims use `auth.uid()` via the
|
|
46
|
+
* reserved compare form `{ kind: 'compare', column, op: '=', value: { claim: 'uid' } }`.
|
|
47
|
+
*/
|
|
48
|
+
export function compileWardPredicate(predicate: Predicate | boolean): string {
|
|
49
|
+
if (typeof predicate === 'boolean') return predicate ? 'TRUE' : 'FALSE'
|
|
50
|
+
return compileNormalized(normalizePredicate(predicate))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compileNormalized(predicate: Predicate): string {
|
|
54
|
+
switch (predicate.kind) {
|
|
55
|
+
case 'const':
|
|
56
|
+
return predicate.value ? 'TRUE' : 'FALSE'
|
|
57
|
+
case 'compare':
|
|
58
|
+
return `${quoteIdentifier(predicate.column)} ${SQL_OPERATOR[predicate.op]} ${compileValue(predicate.value)}`
|
|
59
|
+
case 'null':
|
|
60
|
+
return `${quoteIdentifier(predicate.column)} IS ${predicate.negated ? 'NOT ' : ''}NULL`
|
|
61
|
+
case 'in':
|
|
62
|
+
if (predicate.values.length === 0) return predicate.negated ? 'TRUE' : 'FALSE'
|
|
63
|
+
return `${quoteIdentifier(predicate.column)} ${predicate.negated ? 'NOT ' : ''}IN (${predicate.values.map(compileValue).join(', ')})`
|
|
64
|
+
case 'and':
|
|
65
|
+
return `(${predicate.predicates.map(compileNormalized).join(' AND ')})`
|
|
66
|
+
case 'or':
|
|
67
|
+
return `(${predicate.predicates.map(compileNormalized).join(' OR ')})`
|
|
68
|
+
case 'not':
|
|
69
|
+
return `NOT (${compileNormalized(predicate.predicate)})`
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function compileValue(value: unknown): string {
|
|
74
|
+
if (isClaim(value)) {
|
|
75
|
+
if (value.claim === 'uid') return 'auth.uid()::text'
|
|
76
|
+
invalid(`Unsupported auth claim '${value.claim}'.`)
|
|
77
|
+
}
|
|
78
|
+
if (typeof value === 'string') return `'${value.replaceAll("'", "''")}'`
|
|
79
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
80
|
+
if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
|
|
81
|
+
if (value === null) return 'NULL'
|
|
82
|
+
invalid(`Ward predicate value type '${typeof value}' cannot compile to RLS SQL.`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isClaim(value: unknown): value is { claim: string } {
|
|
86
|
+
return typeof value === 'object' && value !== null && 'claim' in value && typeof (value as { claim: unknown }).claim === 'string'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Builds DROP/CREATE POLICY statements for one ward policy. */
|
|
90
|
+
export function compileWardPolicySql(policy: WardPolicy): string[] {
|
|
91
|
+
const role = quoteIdentifier(policy.role ?? 'authenticated')
|
|
92
|
+
const table = quoteIdentifier(policy.table)
|
|
93
|
+
const name = quoteIdentifier(policy.name)
|
|
94
|
+
const command = policy.command.toUpperCase()
|
|
95
|
+
const statements = [`DROP POLICY IF EXISTS ${name} ON ${table}`]
|
|
96
|
+
|
|
97
|
+
const using =
|
|
98
|
+
policy.using === undefined ? undefined : compileWardPredicate(policy.using)
|
|
99
|
+
const check =
|
|
100
|
+
policy.check === undefined ? undefined : compileWardPredicate(policy.check)
|
|
101
|
+
|
|
102
|
+
if (policy.command === 'insert' && check === undefined) {
|
|
103
|
+
invalid(`Insert ward policy '${policy.name}' requires a check predicate.`)
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
(policy.command === 'select' || policy.command === 'delete') &&
|
|
107
|
+
using === undefined
|
|
108
|
+
) {
|
|
109
|
+
invalid(`Ward policy '${policy.name}' requires a using predicate.`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let create = `CREATE POLICY ${name} ON ${table} FOR ${command} TO ${role}`
|
|
113
|
+
if (using !== undefined) create += ` USING (${using})`
|
|
114
|
+
if (check !== undefined) create += ` WITH CHECK (${check})`
|
|
115
|
+
statements.push(create)
|
|
116
|
+
statements.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`)
|
|
117
|
+
statements.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`)
|
|
118
|
+
return statements
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Compiles every registered ward into ordered SQL statements. */
|
|
122
|
+
export function compileAllWardPolicies(policies: readonly WardPolicy[]): string[] {
|
|
123
|
+
return policies.flatMap(compileWardPolicySql)
|
|
124
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { RequestCookies } from '@avelonjs/core'
|
|
2
|
+
import type { SupabaseSession } from './types'
|
|
3
|
+
|
|
4
|
+
/** Default cookie name used when no project-ref chunked cookie is configured. */
|
|
5
|
+
export const DEFAULT_SESSION_COOKIE = 'sb-avelon-auth-token'
|
|
6
|
+
|
|
7
|
+
/** Reads and parses the session cookie, returning null when absent or malformed. */
|
|
8
|
+
export function readSessionCookie(
|
|
9
|
+
cookies: RequestCookies,
|
|
10
|
+
cookieName: string,
|
|
11
|
+
): SupabaseSession | null {
|
|
12
|
+
const raw = cookies.get(cookieName)
|
|
13
|
+
if (raw === undefined || raw.length === 0) return null
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw) as Partial<SupabaseSession> & { expiresAt?: string }
|
|
16
|
+
if (
|
|
17
|
+
typeof parsed.id !== 'string' ||
|
|
18
|
+
typeof parsed.accessToken !== 'string' ||
|
|
19
|
+
typeof parsed.refreshToken !== 'string' ||
|
|
20
|
+
typeof parsed.expiresAt !== 'string'
|
|
21
|
+
) {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
const expiresAt = new Date(parsed.expiresAt)
|
|
25
|
+
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() <= Date.now()) return null
|
|
26
|
+
return {
|
|
27
|
+
id: parsed.id,
|
|
28
|
+
accessToken: parsed.accessToken,
|
|
29
|
+
refreshToken: parsed.refreshToken,
|
|
30
|
+
expiresAt,
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Writes the session cookie for the current request scope. */
|
|
38
|
+
export function writeSessionCookie(
|
|
39
|
+
cookies: RequestCookies,
|
|
40
|
+
cookieName: string,
|
|
41
|
+
session: SupabaseSession,
|
|
42
|
+
): void {
|
|
43
|
+
cookies.set(
|
|
44
|
+
cookieName,
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
id: session.id,
|
|
47
|
+
accessToken: session.accessToken,
|
|
48
|
+
refreshToken: session.refreshToken,
|
|
49
|
+
expiresAt: session.expiresAt.toISOString(),
|
|
50
|
+
}),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Clears the session cookie for the current request scope. */
|
|
55
|
+
export function clearSessionCookie(cookies: RequestCookies, cookieName: string): void {
|
|
56
|
+
cookies.delete(cookieName)
|
|
57
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Invalid,
|
|
3
|
+
Unauthenticated,
|
|
4
|
+
type IdentityDriver,
|
|
5
|
+
type MagicLinkIdentitySurface,
|
|
6
|
+
type PasswordIdentitySurface,
|
|
7
|
+
type RequestCookies,
|
|
8
|
+
} from '@avelonjs/core'
|
|
9
|
+
import {
|
|
10
|
+
clearSessionCookie,
|
|
11
|
+
DEFAULT_SESSION_COOKIE,
|
|
12
|
+
readSessionCookie,
|
|
13
|
+
writeSessionCookie,
|
|
14
|
+
} from './cookies'
|
|
15
|
+
import type { SupabaseActor, SupabaseSession } from './types'
|
|
16
|
+
|
|
17
|
+
/** Exact capability declaration for the Supabase identity driver. */
|
|
18
|
+
export const supabaseIdentityCapabilities = {
|
|
19
|
+
passwords: true,
|
|
20
|
+
magicLinks: true,
|
|
21
|
+
oauth: false,
|
|
22
|
+
organizations: false,
|
|
23
|
+
mfa: [] as const,
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
/** Construction options for {@link createSupabaseIdentity}. */
|
|
27
|
+
export interface SupabaseIdentityOptions {
|
|
28
|
+
/** GoTrue Auth base URL, e.g. `http://127.0.0.1:54321/auth/v1`. */
|
|
29
|
+
authUrl?: string
|
|
30
|
+
/** Publishable or service API key sent as `apikey`. */
|
|
31
|
+
apiKey?: string
|
|
32
|
+
/** Configured identity instance name. */
|
|
33
|
+
instance?: string
|
|
34
|
+
/** Session cookie name written into the request cookie jar. */
|
|
35
|
+
cookieName?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TokenResponse {
|
|
39
|
+
user?: { id?: string; email?: string | null }
|
|
40
|
+
access_token?: string
|
|
41
|
+
refresh_token?: string
|
|
42
|
+
expires_in?: number
|
|
43
|
+
error?: string
|
|
44
|
+
error_description?: string
|
|
45
|
+
msg?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Supabase Auth identity driver.
|
|
50
|
+
*
|
|
51
|
+
* The config-time factory receives request-scoped cookies explicitly. Session state lives in that
|
|
52
|
+
* cookie jar so SSR and route handlers share one request-local identity view.
|
|
53
|
+
*/
|
|
54
|
+
export class SupabaseIdentity
|
|
55
|
+
implements
|
|
56
|
+
IdentityDriver<
|
|
57
|
+
typeof supabaseIdentityCapabilities,
|
|
58
|
+
{ authUrl: string },
|
|
59
|
+
SupabaseActor,
|
|
60
|
+
SupabaseSession
|
|
61
|
+
>,
|
|
62
|
+
PasswordIdentitySurface<SupabaseActor>,
|
|
63
|
+
MagicLinkIdentitySurface
|
|
64
|
+
{
|
|
65
|
+
readonly name = 'supabase'
|
|
66
|
+
readonly instance: string
|
|
67
|
+
readonly capabilities = supabaseIdentityCapabilities
|
|
68
|
+
|
|
69
|
+
readonly #cookies: RequestCookies
|
|
70
|
+
readonly #authUrl: string
|
|
71
|
+
readonly #apiKey: string
|
|
72
|
+
readonly #cookieName: string
|
|
73
|
+
|
|
74
|
+
constructor(cookies: RequestCookies, options: Required<SupabaseIdentityOptions>) {
|
|
75
|
+
this.#cookies = cookies
|
|
76
|
+
this.#authUrl = options.authUrl.replace(/\/$/, '')
|
|
77
|
+
this.#apiKey = options.apiKey
|
|
78
|
+
this.#cookieName = options.cookieName
|
|
79
|
+
this.instance = options.instance
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
raw(): { authUrl: string } {
|
|
83
|
+
return { authUrl: this.#authUrl }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async user(): Promise<SupabaseActor | null> {
|
|
87
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
88
|
+
if (!session) return null
|
|
89
|
+
const response = await this.#request('/user', {
|
|
90
|
+
method: 'GET',
|
|
91
|
+
headers: this.#authHeaders(session.accessToken),
|
|
92
|
+
})
|
|
93
|
+
if (!response.ok) return null
|
|
94
|
+
const body = (await response.json()) as { id?: string; email?: string | null }
|
|
95
|
+
if (typeof body.id !== 'string' || typeof body.email !== 'string') return null
|
|
96
|
+
return { id: body.id, email: body.email }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async session(): Promise<SupabaseSession | null> {
|
|
100
|
+
return readSessionCookie(this.#cookies, this.#cookieName)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async signOut(): Promise<void> {
|
|
104
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
105
|
+
if (!session) {
|
|
106
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
107
|
+
metadata: { guard: 'identity' },
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
await this.#request('/logout', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: this.#authHeaders(session.accessToken),
|
|
113
|
+
})
|
|
114
|
+
clearSessionCookie(this.#cookies, this.#cookieName)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async register(email: string, password: string): Promise<SupabaseActor> {
|
|
118
|
+
const response = await this.#request('/signup', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: this.#jsonHeaders(),
|
|
121
|
+
body: JSON.stringify({ email, password }),
|
|
122
|
+
})
|
|
123
|
+
const body = (await response.json()) as TokenResponse
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Invalid(body.error_description ?? body.msg ?? body.error ?? 'Registration failed.', {
|
|
126
|
+
metadata: { fields: { email: ['Registration failed'] } },
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return this.#commitSession(body)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async signInWithPassword(email: string, password: string): Promise<SupabaseActor> {
|
|
133
|
+
const response = await this.#request('/token?grant_type=password', {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: this.#jsonHeaders(),
|
|
136
|
+
body: JSON.stringify({ email, password }),
|
|
137
|
+
})
|
|
138
|
+
const body = (await response.json()) as TokenResponse
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Unauthenticated(body.error_description ?? body.error ?? 'Invalid login credentials.', {
|
|
141
|
+
metadata: { guard: 'identity' },
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return this.#commitSession(body)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async sendPasswordReset(email: string): Promise<void> {
|
|
148
|
+
await this.#request('/recover', {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: this.#jsonHeaders(),
|
|
151
|
+
body: JSON.stringify({ email }),
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async resetPassword(token: string, password: string): Promise<void> {
|
|
156
|
+
const response = await this.#request('/verify', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: this.#jsonHeaders(),
|
|
159
|
+
body: JSON.stringify({ type: 'recovery', token, password }),
|
|
160
|
+
})
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
const body = (await response.json()) as TokenResponse
|
|
163
|
+
throw new Invalid(body.error_description ?? body.error ?? 'Password reset failed.', {
|
|
164
|
+
metadata: { fields: { token: ['Invalid recovery token'] } },
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async updatePassword(password: string): Promise<void> {
|
|
170
|
+
const session = readSessionCookie(this.#cookies, this.#cookieName)
|
|
171
|
+
if (!session) {
|
|
172
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
173
|
+
metadata: { guard: 'identity' },
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
const response = await this.#request('/user', {
|
|
177
|
+
method: 'PUT',
|
|
178
|
+
headers: { ...this.#jsonHeaders(), ...this.#authHeaders(session.accessToken) },
|
|
179
|
+
body: JSON.stringify({ password }),
|
|
180
|
+
})
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
throw new Unauthenticated('No authenticated session is present.', {
|
|
183
|
+
metadata: { guard: 'identity' },
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async sendMagicLink(email: string, redirectTo?: string): Promise<void> {
|
|
189
|
+
await this.#request('/otp', {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: this.#jsonHeaders(),
|
|
192
|
+
body: JSON.stringify({ email, create_user: true, gotrue_meta_security: {}, ...(redirectTo ? { email_redirect_to: redirectTo } : {}) }),
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#commitSession(body: TokenResponse): SupabaseActor {
|
|
197
|
+
if (
|
|
198
|
+
typeof body.user?.id !== 'string' ||
|
|
199
|
+
typeof body.user.email !== 'string' ||
|
|
200
|
+
typeof body.access_token !== 'string' ||
|
|
201
|
+
typeof body.refresh_token !== 'string'
|
|
202
|
+
) {
|
|
203
|
+
throw new Invalid('Auth response was missing a session.', {
|
|
204
|
+
metadata: { fields: { session: ['Incomplete auth response'] } },
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600
|
|
208
|
+
const session: SupabaseSession = {
|
|
209
|
+
id: body.access_token,
|
|
210
|
+
accessToken: body.access_token,
|
|
211
|
+
refreshToken: body.refresh_token,
|
|
212
|
+
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
|
213
|
+
}
|
|
214
|
+
writeSessionCookie(this.#cookies, this.#cookieName, session)
|
|
215
|
+
return { id: body.user.id, email: body.user.email }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#jsonHeaders(): Record<string, string> {
|
|
219
|
+
return {
|
|
220
|
+
apikey: this.#apiKey,
|
|
221
|
+
'Content-Type': 'application/json',
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
#authHeaders(accessToken: string): Record<string, string> {
|
|
226
|
+
return {
|
|
227
|
+
apikey: this.#apiKey,
|
|
228
|
+
Authorization: `Bearer ${accessToken}`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async #request(path: string, init: RequestInit): Promise<Response> {
|
|
233
|
+
return fetch(`${this.#authUrl}${path}`, init)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Creates the config-time identity factory pinned by conformance:
|
|
239
|
+
* `(cookies: RequestCookies) => SupabaseIdentity`.
|
|
240
|
+
*/
|
|
241
|
+
export function createSupabaseIdentity(
|
|
242
|
+
options: SupabaseIdentityOptions = {},
|
|
243
|
+
): (cookies: RequestCookies) => SupabaseIdentity {
|
|
244
|
+
const resolved: Required<SupabaseIdentityOptions> = {
|
|
245
|
+
authUrl: options.authUrl ?? process.env.SUPABASE_AUTH_URL ?? 'http://127.0.0.1:54321/auth/v1',
|
|
246
|
+
apiKey: options.apiKey ?? process.env.SUPABASE_ANON_KEY ?? 'local-anon-key',
|
|
247
|
+
instance: options.instance ?? 'default',
|
|
248
|
+
cookieName: options.cookieName ?? DEFAULT_SESSION_COOKIE,
|
|
249
|
+
}
|
|
250
|
+
return (cookies) => new SupabaseIdentity(cookies, resolved)
|
|
251
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
clearSessionCookie,
|
|
3
|
+
DEFAULT_SESSION_COOKIE,
|
|
4
|
+
readSessionCookie,
|
|
5
|
+
writeSessionCookie,
|
|
6
|
+
} from './cookies'
|
|
7
|
+
export {
|
|
8
|
+
createSupabaseIdentity,
|
|
9
|
+
SupabaseIdentity,
|
|
10
|
+
supabaseIdentityCapabilities,
|
|
11
|
+
type SupabaseIdentityOptions,
|
|
12
|
+
} from './driver'
|
|
13
|
+
export { LocalAuthServer } from './local-auth'
|
|
14
|
+
export type { SupabaseActor, SupabaseSession } from './types'
|