@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.
@@ -0,0 +1,209 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ interface LocalUser {
4
+ id: string
5
+ email: string
6
+ passwordHash: string
7
+ }
8
+
9
+ interface LocalSession {
10
+ id: string
11
+ userId: string
12
+ accessToken: string
13
+ refreshToken: string
14
+ expiresAt: number
15
+ }
16
+
17
+ interface LocalReset {
18
+ email: string
19
+ token: string
20
+ }
21
+
22
+ function hashPassword(password: string): string {
23
+ return createHash('sha256').update(password).digest('hex')
24
+ }
25
+
26
+ /**
27
+ * Minimal GoTrue-shaped Auth HTTP server for live identity conformance.
28
+ *
29
+ * Real deployments point the same driver at Supabase Auth; this server exists so cookie and
30
+ * password flows can be verified without the full Docker stack.
31
+ */
32
+ export class LocalAuthServer {
33
+ readonly #users = new Map<string, LocalUser>()
34
+ readonly #sessions = new Map<string, LocalSession>()
35
+ readonly #resets = new Map<string, LocalReset>()
36
+ #server: ReturnType<typeof Bun.serve> | undefined
37
+ #url = ''
38
+
39
+ /** Base Auth URL, e.g. `http://127.0.0.1:54321/auth/v1`. */
40
+ get url(): string {
41
+ return this.#url
42
+ }
43
+
44
+ /** Starts the server on an ephemeral port. */
45
+ async start(): Promise<string> {
46
+ const self = this
47
+ this.#server = Bun.serve({
48
+ port: 0,
49
+ async fetch(request) {
50
+ return self.#handle(request)
51
+ },
52
+ })
53
+ this.#url = `http://127.0.0.1:${this.#server.port}/auth/v1`
54
+ return this.#url
55
+ }
56
+
57
+ /** Clears users, sessions, and reset tokens without restarting the listener. */
58
+ reset(): void {
59
+ this.#users.clear()
60
+ this.#sessions.clear()
61
+ this.#resets.clear()
62
+ }
63
+
64
+ /** Stops the server and clears fixture state. */
65
+ async stop(): Promise<void> {
66
+ this.#server?.stop(true)
67
+ this.#server = undefined
68
+ this.reset()
69
+ }
70
+
71
+ /** Returns the recovery token issued for an email, if any. */
72
+ recoveryToken(email: string): string | undefined {
73
+ return [...this.#resets.values()].find((entry) => entry.email === email)?.token
74
+ }
75
+
76
+ async #handle(request: Request): Promise<Response> {
77
+ const url = new URL(request.url)
78
+ const path = url.pathname.replace(/^\/auth\/v1/, '')
79
+
80
+ if (request.method === 'POST' && path === '/signup') {
81
+ const body = (await request.json()) as { email?: string; password?: string }
82
+ if (!body.email || !body.password) return json({ error: 'invalid' }, 400)
83
+ if ([...this.#users.values()].some((user) => user.email === body.email)) {
84
+ return json({ error: 'user_exists' }, 422)
85
+ }
86
+ const user: LocalUser = {
87
+ id: randomUUID(),
88
+ email: body.email,
89
+ passwordHash: hashPassword(body.password),
90
+ }
91
+ this.#users.set(user.id, user)
92
+ const session = this.#createSession(user.id)
93
+ return json({
94
+ user: { id: user.id, email: user.email },
95
+ access_token: session.accessToken,
96
+ refresh_token: session.refreshToken,
97
+ expires_in: 3600,
98
+ token_type: 'bearer',
99
+ })
100
+ }
101
+
102
+ if (request.method === 'POST' && path === '/token' && url.searchParams.get('grant_type') === 'password') {
103
+ const body = (await request.json()) as { email?: string; password?: string }
104
+ const user = [...this.#users.values()].find((entry) => entry.email === body.email)
105
+ if (!user || user.passwordHash !== hashPassword(body.password ?? '')) {
106
+ return json({ error: 'invalid_grant', error_description: 'Invalid login credentials' }, 400)
107
+ }
108
+ const session = this.#createSession(user.id)
109
+ return json({
110
+ user: { id: user.id, email: user.email },
111
+ access_token: session.accessToken,
112
+ refresh_token: session.refreshToken,
113
+ expires_in: 3600,
114
+ token_type: 'bearer',
115
+ })
116
+ }
117
+
118
+ if (request.method === 'GET' && path === '/user') {
119
+ const session = this.#sessionFromAuth(request)
120
+ if (!session) return json({ error: 'not_authenticated' }, 401)
121
+ const user = this.#users.get(session.userId)
122
+ if (!user) return json({ error: 'not_authenticated' }, 401)
123
+ return json({ id: user.id, email: user.email })
124
+ }
125
+
126
+ if (request.method === 'POST' && path === '/logout') {
127
+ const session = this.#sessionFromAuth(request)
128
+ if (!session) return json({ error: 'not_authenticated' }, 401)
129
+ this.#sessions.delete(session.accessToken)
130
+ return new Response(null, { status: 204 })
131
+ }
132
+
133
+ if (request.method === 'PUT' && path === '/user') {
134
+ const session = this.#sessionFromAuth(request)
135
+ if (!session) return json({ error: 'not_authenticated' }, 401)
136
+ const user = this.#users.get(session.userId)
137
+ if (!user) return json({ error: 'not_authenticated' }, 401)
138
+ const body = (await request.json()) as { password?: string }
139
+ if (!body.password) return json({ error: 'invalid' }, 400)
140
+ user.passwordHash = hashPassword(body.password)
141
+ return json({ id: user.id, email: user.email })
142
+ }
143
+
144
+ if (request.method === 'POST' && path === '/recover') {
145
+ const body = (await request.json()) as { email?: string }
146
+ if (body.email) {
147
+ const token = `assay-reset-${randomUUID()}`
148
+ this.#resets.set(token, { email: body.email, token })
149
+ }
150
+ return json({})
151
+ }
152
+
153
+ if (request.method === 'PUT' && path === '/user' && url.searchParams.has('token')) {
154
+ return json({ error: 'use verify' }, 400)
155
+ }
156
+
157
+ if (request.method === 'POST' && path === '/verify') {
158
+ const body = (await request.json()) as {
159
+ type?: string
160
+ token?: string
161
+ password?: string
162
+ }
163
+ if (body.type === 'recovery' && body.token && body.password) {
164
+ const reset = this.#resets.get(body.token)
165
+ if (!reset) return json({ error: 'invalid_token' }, 400)
166
+ const user = [...this.#users.values()].find((entry) => entry.email === reset.email)
167
+ if (!user) return json({ error: 'invalid_token' }, 400)
168
+ user.passwordHash = hashPassword(body.password)
169
+ this.#resets.delete(body.token)
170
+ return json({ user: { id: user.id, email: user.email } })
171
+ }
172
+ return json({ error: 'invalid' }, 400)
173
+ }
174
+
175
+ if (request.method === 'POST' && path === '/otp') {
176
+ return json({})
177
+ }
178
+
179
+ return json({ error: 'not_found' }, 404)
180
+ }
181
+
182
+ #createSession(userId: string): LocalSession {
183
+ const session: LocalSession = {
184
+ id: randomUUID(),
185
+ userId,
186
+ accessToken: randomUUID(),
187
+ refreshToken: randomUUID(),
188
+ expiresAt: Date.now() + 60 * 60 * 1000,
189
+ }
190
+ this.#sessions.set(session.accessToken, session)
191
+ return session
192
+ }
193
+
194
+ #sessionFromAuth(request: Request): LocalSession | undefined {
195
+ const header = request.headers.get('authorization')
196
+ if (header === null || !header.startsWith('Bearer ')) return undefined
197
+ const token = header.slice('Bearer '.length)
198
+ const session = this.#sessions.get(token)
199
+ if (session === undefined || session.expiresAt <= Date.now()) return undefined
200
+ return session
201
+ }
202
+ }
203
+
204
+ function json(body: unknown, status = 200): Response {
205
+ return new Response(JSON.stringify(body), {
206
+ status,
207
+ headers: { 'Content-Type': 'application/json' },
208
+ })
209
+ }
@@ -0,0 +1,19 @@
1
+ /** Actor returned by the Supabase identity driver. */
2
+ export interface SupabaseActor {
3
+ /** Stable actor identifier. */
4
+ id: string
5
+ /** Sign-in email address. */
6
+ email: string
7
+ }
8
+
9
+ /** Session returned by the Supabase identity driver. */
10
+ export interface SupabaseSession {
11
+ /** Stable session identifier. */
12
+ id: string
13
+ /** Access token retained for subsequent Auth API calls. */
14
+ accessToken: string
15
+ /** Refresh token used for rotation when present. */
16
+ refreshToken: string
17
+ /** Time after which the access token should be treated as expired. */
18
+ expiresAt: Date
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './database'
2
+ export * from './identity'
3
+ export * from './queue'
4
+ export * from './social'
5
+ export * from './storage'
6
+ export * from './tokens'
@@ -0,0 +1,268 @@
1
+ import { SQL } from 'bun'
2
+ import type {
3
+ DeadLetterQueueSurface,
4
+ DelayedQueueSurface,
5
+ FailedQueueJob,
6
+ QueueDriver,
7
+ QueueJob,
8
+ QueueReceipt,
9
+ RetryQueueSurface,
10
+ } from '@avelonjs/core'
11
+
12
+ /** Exact capability declaration for the Supabase queue driver. */
13
+ export const supabaseQueueCapabilities = {
14
+ delayed: true,
15
+ retries: true,
16
+ deadLetter: true,
17
+ } as const
18
+
19
+ /** Construction options for {@link createSupabaseQueue}. */
20
+ export interface SupabaseQueueOptions {
21
+ /** Postgres URL used to persist jobs. */
22
+ url?: string
23
+ /** Configured queue connection name. */
24
+ instance?: string
25
+ /** Attempts before a job is dead-lettered. */
26
+ maxTries?: number
27
+ }
28
+
29
+ interface JobRow {
30
+ id: string
31
+ queue: string | null
32
+ name: string
33
+ payload: unknown
34
+ attempt: number
35
+ available_at: Date | string
36
+ }
37
+
38
+ interface FailedRow extends JobRow {
39
+ error: string
40
+ failed_at: Date | string
41
+ }
42
+
43
+ function defaultUrl(): string {
44
+ return (
45
+ process.env.SUPABASE_DB_URL ??
46
+ process.env.POSTGRES_URL ??
47
+ process.env.DATABASE_URL ??
48
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase'
49
+ )
50
+ }
51
+
52
+ function asDate(value: Date | string): Date {
53
+ return value instanceof Date ? value : new Date(value)
54
+ }
55
+
56
+ function failureMessage(error: unknown): string {
57
+ return error instanceof Error ? error.message : String(error)
58
+ }
59
+
60
+ function toJob(row: JobRow): QueueJob {
61
+ return {
62
+ name: row.name,
63
+ payload: decodePayload(row.payload),
64
+ ...(row.queue === null ? {} : { queue: row.queue }),
65
+ }
66
+ }
67
+
68
+ function decodePayload(value: unknown): unknown {
69
+ if (typeof value !== 'string') return value
70
+ try {
71
+ return JSON.parse(value) as unknown
72
+ } catch {
73
+ return value
74
+ }
75
+ }
76
+
77
+ function encodePayload(value: unknown): string {
78
+ return typeof value === 'string' ? value : JSON.stringify(value)
79
+ }
80
+
81
+ const SCHEMA_SQL = `
82
+ CREATE TABLE IF NOT EXISTS avelon_jobs (
83
+ id text PRIMARY KEY,
84
+ queue text,
85
+ name text NOT NULL,
86
+ payload jsonb NOT NULL,
87
+ attempt integer NOT NULL DEFAULT 0,
88
+ available_at timestamptz NOT NULL,
89
+ locked_at timestamptz
90
+ );
91
+ CREATE TABLE IF NOT EXISTS avelon_failed_jobs (
92
+ id text PRIMARY KEY,
93
+ queue text,
94
+ name text NOT NULL,
95
+ payload jsonb NOT NULL,
96
+ attempt integer NOT NULL,
97
+ error text NOT NULL,
98
+ failed_at timestamptz NOT NULL
99
+ );
100
+ `
101
+
102
+ /**
103
+ * Postgres queue driver using `FOR UPDATE SKIP LOCKED`.
104
+ *
105
+ * The Wave A package names pgmq. This environment does not ship that extension, so the durable
106
+ * semantics are implemented with skip-locked tables rather than silently declaring the capability
107
+ * false. Swap the SQL dialect for pgmq when the extension is present in CI.
108
+ */
109
+ export class SupabaseQueue
110
+ implements
111
+ QueueDriver<typeof supabaseQueueCapabilities, SQL>,
112
+ DelayedQueueSurface,
113
+ RetryQueueSurface,
114
+ DeadLetterQueueSurface
115
+ {
116
+ readonly name = 'supabase'
117
+ readonly instance: string
118
+ readonly capabilities = supabaseQueueCapabilities
119
+
120
+ readonly #sql: SQL
121
+ readonly #maxTries: number
122
+
123
+ constructor(options: { url: string; instance: string; maxTries: number }) {
124
+ this.instance = options.instance
125
+ this.#sql = new SQL(options.url)
126
+ this.#maxTries = options.maxTries
127
+ }
128
+
129
+ raw(): SQL {
130
+ return this.#sql
131
+ }
132
+
133
+ async reset(): Promise<void> {
134
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_failed_jobs')
135
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_jobs')
136
+ await this.#sql.unsafe(SCHEMA_SQL)
137
+ }
138
+
139
+ async close(): Promise<void> {
140
+ await this.#sql.close()
141
+ }
142
+
143
+ async enqueue<TPayload>(job: QueueJob<TPayload>): Promise<string> {
144
+ return this.#insert(job, new Date())
145
+ }
146
+
147
+ async enqueueAt<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
148
+ return this.#insert(job, availableAt)
149
+ }
150
+
151
+ async drain(
152
+ handler: (receipt: QueueReceipt) => Promise<void>,
153
+ options?: { readonly queue?: string; readonly limit?: number },
154
+ ): Promise<number> {
155
+ await this.#sql.unsafe(SCHEMA_SQL)
156
+ const limit = options?.limit ?? 2_147_483_647
157
+ const rows = (await this.#sql.unsafe(
158
+ `WITH picked AS MATERIALIZED (
159
+ SELECT id FROM avelon_jobs
160
+ WHERE available_at <= now()
161
+ AND locked_at IS NULL
162
+ AND ($1::boolean OR queue IS NOT DISTINCT FROM $2)
163
+ ORDER BY available_at ASC
164
+ FOR UPDATE SKIP LOCKED
165
+ LIMIT $3
166
+ )
167
+ UPDATE avelon_jobs AS jobs
168
+ SET locked_at = now(), attempt = jobs.attempt + 1
169
+ FROM picked
170
+ WHERE jobs.id = picked.id
171
+ RETURNING jobs.id, jobs.queue, jobs.name, jobs.payload, jobs.attempt, jobs.available_at`,
172
+ [options?.queue === undefined, options?.queue ?? null, limit],
173
+ )) as JobRow[]
174
+
175
+ for (const row of rows) {
176
+ const receipt: QueueReceipt = { id: row.id, job: toJob(row), attempt: row.attempt }
177
+ try {
178
+ await handler(receipt)
179
+ await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
180
+ } catch (error: unknown) {
181
+ if (this.capabilities.retries && row.attempt < this.#maxTries) {
182
+ await this.#sql.unsafe(
183
+ `UPDATE avelon_jobs SET locked_at = NULL WHERE id = $1`,
184
+ [row.id],
185
+ )
186
+ continue
187
+ }
188
+ if (this.capabilities.deadLetter) {
189
+ await this.#sql.unsafe(
190
+ `INSERT INTO avelon_failed_jobs (id, queue, name, payload, attempt, error, failed_at)
191
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`,
192
+ [row.id, row.queue, row.name, encodePayload(row.payload), row.attempt, failureMessage(error)],
193
+ )
194
+ }
195
+ await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
196
+ }
197
+ }
198
+
199
+ return rows.length
200
+ }
201
+
202
+ async retry(id: string, delaySeconds?: number): Promise<void> {
203
+ const delay = delaySeconds ?? 0
204
+ await this.#sql.unsafe(
205
+ `UPDATE avelon_jobs
206
+ SET locked_at = NULL, available_at = now() + ($2 * interval '1 second')
207
+ WHERE id = $1`,
208
+ [id, delay],
209
+ )
210
+ }
211
+
212
+ async failed(queue?: string): Promise<readonly FailedQueueJob[]> {
213
+ await this.#sql.unsafe(SCHEMA_SQL)
214
+ const rows = (await this.#sql.unsafe(
215
+ `SELECT id, queue, name, payload, attempt, error, failed_at
216
+ FROM avelon_failed_jobs
217
+ WHERE ($1::text IS NULL OR queue IS NOT DISTINCT FROM $1)
218
+ ORDER BY failed_at ASC`,
219
+ [queue ?? null],
220
+ )) as FailedRow[]
221
+ return rows.map((row) => ({
222
+ id: row.id,
223
+ job: toJob(row),
224
+ attempt: row.attempt,
225
+ error: row.error,
226
+ failedAt: asDate(row.failed_at),
227
+ }))
228
+ }
229
+
230
+ async replay(id: string): Promise<void> {
231
+ const rows = (await this.#sql.unsafe(
232
+ `DELETE FROM avelon_failed_jobs WHERE id = $1
233
+ RETURNING id, queue, name, payload`,
234
+ [id],
235
+ )) as Array<Pick<FailedRow, 'id' | 'queue' | 'name' | 'payload'>>
236
+ const row = rows[0]
237
+ if (row === undefined) return
238
+ await this.#sql.unsafe(
239
+ `INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
240
+ VALUES ($1, $2, $3, $4::jsonb, 0, now(), NULL)`,
241
+ [row.id, row.queue, row.name, encodePayload(row.payload)],
242
+ )
243
+ }
244
+
245
+ async forget(id: string): Promise<void> {
246
+ await this.#sql.unsafe(`DELETE FROM avelon_failed_jobs WHERE id = $1`, [id])
247
+ }
248
+
249
+ async #insert<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
250
+ await this.#sql.unsafe(SCHEMA_SQL)
251
+ const id = crypto.randomUUID()
252
+ await this.#sql.unsafe(
253
+ `INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
254
+ VALUES ($1, $2, $3, $4::jsonb, 0, $5, NULL)`,
255
+ [id, job.queue ?? null, job.name, encodePayload(job.payload), availableAt.toISOString()],
256
+ )
257
+ return id
258
+ }
259
+ }
260
+
261
+ /** Creates a Postgres skip-locked queue driver from options or environment defaults. */
262
+ export function createSupabaseQueue(options: SupabaseQueueOptions = {}): SupabaseQueue {
263
+ return new SupabaseQueue({
264
+ url: options.url ?? defaultUrl(),
265
+ instance: options.instance ?? 'default',
266
+ maxTries: options.maxTries ?? 3,
267
+ })
268
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createSupabaseQueue,
3
+ SupabaseQueue,
4
+ supabaseQueueCapabilities,
5
+ type SupabaseQueueOptions,
6
+ } from './driver'
@@ -0,0 +1,130 @@
1
+ import {
2
+ Invalid,
3
+ Unauthenticated,
4
+ type SocialDriver,
5
+ type SocialIdentity,
6
+ } from '@avelonjs/core'
7
+ import type { SupabaseSocialProfile } from './types'
8
+
9
+ /** Exact capability declaration for the Supabase social driver. */
10
+ export const supabaseSocialCapabilities = {
11
+ providers: ['github', 'google'] as const,
12
+ } as const
13
+
14
+ type Provider = (typeof supabaseSocialCapabilities.providers)[number]
15
+
16
+ /** Construction options for {@link createSupabaseSocial}. */
17
+ export interface SupabaseSocialOptions {
18
+ /** GoTrue Auth base URL, e.g. `http://127.0.0.1:54321/auth/v1`. */
19
+ authUrl?: string
20
+ /** Publishable API key sent as `apikey`. */
21
+ apiKey?: string
22
+ /** Configured social connection name. */
23
+ instance?: string
24
+ }
25
+
26
+ function isProvider(provider: string): provider is Provider {
27
+ return supabaseSocialCapabilities.providers.some((candidate) => candidate === provider)
28
+ }
29
+
30
+ function invalid(field: string, message: string): never {
31
+ throw new Invalid(message, { metadata: { fields: { [field]: [message] } } })
32
+ }
33
+
34
+ function unauthenticated(message: string): never {
35
+ throw new Unauthenticated(message, { metadata: { guard: 'social' } })
36
+ }
37
+
38
+ /**
39
+ * Supabase Auth social driver.
40
+ *
41
+ * `redirect()` builds a GoTrue authorize URL and records CSRF state on the driver instance.
42
+ * `callback()` verifies that state, then exchanges the authorization code over HTTP.
43
+ */
44
+ export class SupabaseSocial
45
+ implements SocialDriver<typeof supabaseSocialCapabilities, { authUrl: string }, SupabaseSocialProfile>
46
+ {
47
+ readonly name = 'supabase'
48
+ readonly instance: string
49
+ readonly capabilities = supabaseSocialCapabilities
50
+
51
+ readonly #authUrl: string
52
+ readonly #apiKey: string
53
+ readonly #states = new Map<Provider, string>()
54
+ #nextState = 1
55
+
56
+ constructor(options: Required<SupabaseSocialOptions>) {
57
+ this.instance = options.instance
58
+ this.#authUrl = options.authUrl.replace(/\/$/, '')
59
+ this.#apiKey = options.apiKey
60
+ }
61
+
62
+ raw(): { authUrl: string } {
63
+ return { authUrl: this.#authUrl }
64
+ }
65
+
66
+ async redirect(provider: string, callbackUrl: string, state?: string): Promise<string> {
67
+ if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
68
+ const expectedState = state ?? `assay-state-${this.#nextState++}`
69
+ this.#states.set(provider, expectedState)
70
+ const url = new URL(`${this.#authUrl}/authorize`)
71
+ url.searchParams.set('provider', provider)
72
+ url.searchParams.set('redirect_to', callbackUrl)
73
+ url.searchParams.set('state', expectedState)
74
+ return url.toString()
75
+ }
76
+
77
+ async callback(
78
+ provider: string,
79
+ params: Readonly<Record<string, string>>,
80
+ callbackUrl: string,
81
+ ): Promise<SocialIdentity<SupabaseSocialProfile>> {
82
+ if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
83
+ const expectedState = this.#states.get(provider)
84
+ if (!expectedState || !params.state || params.state !== expectedState) {
85
+ unauthenticated('Authorization state could not be verified.')
86
+ }
87
+ this.#states.delete(provider)
88
+ if (params.error) unauthenticated(`Authorization failed: ${params.error}.`)
89
+ if (!params.code) invalid('code', 'Authorization code is required.')
90
+
91
+ const response = await fetch(`${this.#authUrl}/token`, {
92
+ method: 'POST',
93
+ headers: {
94
+ apikey: this.#apiKey,
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ body: JSON.stringify({
98
+ code: params.code,
99
+ provider,
100
+ redirect_to: callbackUrl,
101
+ }),
102
+ })
103
+ const body = (await response.json()) as {
104
+ error?: string
105
+ provider?: string
106
+ user?: { id?: string; email?: string; user_metadata?: { full_name?: string } }
107
+ }
108
+ const user = body.user
109
+ if (!response.ok || user === undefined || typeof user.id !== 'string') {
110
+ unauthenticated(body.error ?? 'Authorization code exchange failed.')
111
+ }
112
+ return {
113
+ provider,
114
+ subject: user.id,
115
+ profile: {
116
+ displayName: user.user_metadata?.full_name ?? user.email ?? user.id,
117
+ ...(user.email === undefined ? {} : { email: user.email }),
118
+ },
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Creates a Supabase social driver from options or environment defaults. */
124
+ export function createSupabaseSocial(options: SupabaseSocialOptions = {}): SupabaseSocial {
125
+ return new SupabaseSocial({
126
+ authUrl: options.authUrl ?? process.env.SUPABASE_AUTH_URL ?? 'http://127.0.0.1:54321/auth/v1',
127
+ apiKey: options.apiKey ?? process.env.SUPABASE_ANON_KEY ?? 'local-anon-key',
128
+ instance: options.instance ?? 'default',
129
+ })
130
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ createSupabaseSocial,
3
+ SupabaseSocial,
4
+ supabaseSocialCapabilities,
5
+ type SupabaseSocialOptions,
6
+ } from './driver'
7
+ export { LocalSocialServer } from './local-auth'
8
+ export type { SupabaseSocialProfile } from './types'