@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,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,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,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
|
+
}
|