@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,56 @@
1
+ /**
2
+ * Minimal GoTrue-shaped OAuth token endpoint for social conformance.
3
+ *
4
+ * Real deployments point {@link createSupabaseSocial} at Supabase Auth. This server exists so the
5
+ * HTTP callback exchange can run without the full Docker stack.
6
+ */
7
+ export class LocalSocialServer {
8
+ #server: ReturnType<typeof Bun.serve> | undefined
9
+ #url = ''
10
+
11
+ /** Base Auth URL, e.g. `http://127.0.0.1:54321/auth/v1`. */
12
+ get url(): string {
13
+ return this.#url
14
+ }
15
+
16
+ /** Starts the server on an ephemeral port. */
17
+ async start(): Promise<string> {
18
+ const self = this
19
+ this.#server = Bun.serve({
20
+ port: 0,
21
+ async fetch(request) {
22
+ return self.#handle(request)
23
+ },
24
+ })
25
+ this.#url = `http://127.0.0.1:${this.#server.port}/auth/v1`
26
+ return this.#url
27
+ }
28
+
29
+ /** Stops the fixture listener. */
30
+ async stop(): Promise<void> {
31
+ this.#server?.stop(true)
32
+ this.#server = undefined
33
+ }
34
+
35
+ async #handle(request: Request): Promise<Response> {
36
+ const url = new URL(request.url)
37
+ const path = url.pathname.replace(/^\/auth\/v1/, '')
38
+
39
+ if (request.method === 'POST' && path === '/token') {
40
+ const body = (await request.json()) as { code?: string; provider?: string }
41
+ if (body.code !== 'valid-code') {
42
+ return Response.json({ error: 'invalid_grant' }, { status: 400 })
43
+ }
44
+ return Response.json({
45
+ provider: body.provider ?? 'github',
46
+ user: {
47
+ id: `subject-${body.code}`,
48
+ email: 'actor@example.test',
49
+ user_metadata: { full_name: 'Assay Actor' },
50
+ },
51
+ })
52
+ }
53
+
54
+ return Response.json({ error: 'not_found' }, { status: 404 })
55
+ }
56
+ }
@@ -0,0 +1,7 @@
1
+ /** Provider profile returned by the Supabase social driver. */
2
+ export interface SupabaseSocialProfile {
3
+ /** Display name when the provider supplies one. */
4
+ displayName: string
5
+ /** Email address when the provider supplies one. */
6
+ email?: string
7
+ }
@@ -0,0 +1,228 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { SQL } from 'bun'
3
+ import {
4
+ NotFound,
5
+ type SignedUrlStorageSurface,
6
+ type StorageDriver,
7
+ type StorageObject,
8
+ } from '@avelonjs/core'
9
+
10
+ /** Exact capability declaration for the Supabase storage driver. */
11
+ export const supabaseStorageCapabilities = {
12
+ signedUrls: true,
13
+ transforms: [] as const,
14
+ } as const
15
+
16
+ /** Construction options for {@link createSupabaseStorage}. */
17
+ export interface SupabaseStorageOptions {
18
+ /** Postgres URL used to persist object bytes. */
19
+ url?: string
20
+ /** Configured disk name. */
21
+ instance?: string
22
+ /** HMAC secret used to sign read URLs. */
23
+ signingSecret?: string
24
+ }
25
+
26
+ interface StoredRow {
27
+ path: string
28
+ contents: Uint8Array | Buffer
29
+ content_type: string | null
30
+ size: number
31
+ }
32
+
33
+ function defaultUrl(): string {
34
+ return (
35
+ process.env.SUPABASE_DB_URL ??
36
+ process.env.POSTGRES_URL ??
37
+ process.env.DATABASE_URL ??
38
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase'
39
+ )
40
+ }
41
+
42
+ async function readContents(contents: Uint8Array | AsyncIterable<Uint8Array>): Promise<Uint8Array> {
43
+ if (contents instanceof Uint8Array) return contents.slice()
44
+ const chunks: Uint8Array[] = []
45
+ let size = 0
46
+ for await (const chunk of contents) {
47
+ const copy = chunk.slice()
48
+ chunks.push(copy)
49
+ size += copy.byteLength
50
+ }
51
+ const result = new Uint8Array(size)
52
+ let offset = 0
53
+ for (const chunk of chunks) {
54
+ result.set(chunk, offset)
55
+ offset += chunk.byteLength
56
+ }
57
+ return result
58
+ }
59
+
60
+ function notFound(path: string): never {
61
+ throw new NotFound(`Storage object ${path} was not found.`, {
62
+ metadata: { resource: 'storage-object', identifier: path },
63
+ })
64
+ }
65
+
66
+ const SCHEMA_SQL = `
67
+ CREATE TABLE IF NOT EXISTS avelon_storage_objects (
68
+ path text PRIMARY KEY,
69
+ contents bytea NOT NULL,
70
+ content_type text,
71
+ size integer NOT NULL
72
+ );
73
+ `
74
+
75
+ /**
76
+ * Postgres-backed storage driver with HMAC signed read URLs.
77
+ *
78
+ * Object bytes persist in `avelon_storage_objects`. Signed URLs are served by an in-process HTTP
79
+ * listener because the v1 contract requires a fetchable read URL with a real expiry, and this
80
+ * environment has no Supabase Storage service.
81
+ */
82
+ export class SupabaseStorage
83
+ implements
84
+ StorageDriver<typeof supabaseStorageCapabilities, SQL>,
85
+ SignedUrlStorageSurface
86
+ {
87
+ readonly name = 'supabase'
88
+ readonly instance: string
89
+ readonly capabilities = supabaseStorageCapabilities
90
+
91
+ readonly #sql: SQL
92
+ readonly #secret: string
93
+ #server: ReturnType<typeof Bun.serve> | undefined
94
+ #origin = ''
95
+
96
+ constructor(options: { url: string; instance: string; signingSecret: string }) {
97
+ this.instance = options.instance
98
+ this.#sql = new SQL(options.url)
99
+ this.#secret = options.signingSecret
100
+ }
101
+
102
+ raw(): SQL {
103
+ return this.#sql
104
+ }
105
+
106
+ /** Recreates the empty object table and starts the signed-URL listener. */
107
+ async reset(): Promise<void> {
108
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_storage_objects')
109
+ await this.#sql.unsafe(SCHEMA_SQL)
110
+ await this.#ensureServer()
111
+ }
112
+
113
+ async close(): Promise<void> {
114
+ this.#server?.stop(true)
115
+ this.#server = undefined
116
+ await this.#sql.close()
117
+ }
118
+
119
+ async put(
120
+ path: string,
121
+ contents: Uint8Array | AsyncIterable<Uint8Array>,
122
+ options?: { readonly contentType?: string },
123
+ ): Promise<StorageObject> {
124
+ await this.#sql.unsafe(SCHEMA_SQL)
125
+ const stored = await readContents(contents)
126
+ await this.#sql.unsafe(
127
+ `INSERT INTO avelon_storage_objects (path, contents, content_type, size)
128
+ VALUES ($1, $2, $3, $4)
129
+ ON CONFLICT (path) DO UPDATE SET contents = EXCLUDED.contents, content_type = EXCLUDED.content_type, size = EXCLUDED.size`,
130
+ [path, stored, options?.contentType ?? null, stored.byteLength],
131
+ )
132
+ return {
133
+ path,
134
+ size: stored.byteLength,
135
+ ...(options?.contentType === undefined ? {} : { contentType: options.contentType }),
136
+ }
137
+ }
138
+
139
+ async get(path: string): Promise<Uint8Array> {
140
+ const row = await this.#row(path)
141
+ if (!row) notFound(path)
142
+ return toBytes(row.contents)
143
+ }
144
+
145
+ async delete(path: string): Promise<void> {
146
+ await this.#sql.unsafe(SCHEMA_SQL)
147
+ await this.#sql.unsafe(`DELETE FROM avelon_storage_objects WHERE path = $1`, [path])
148
+ }
149
+
150
+ async exists(path: string): Promise<boolean> {
151
+ return (await this.#row(path)) !== undefined
152
+ }
153
+
154
+ async signedUrl(path: string, expiresInSeconds: number): Promise<string> {
155
+ if (!(await this.exists(path))) notFound(path)
156
+ await this.#ensureServer()
157
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds
158
+ const signature = sign(this.#secret, path, expiresAt)
159
+ const url = new URL('/object', this.#origin)
160
+ url.searchParams.set('path', path)
161
+ url.searchParams.set('exp', String(expiresAt))
162
+ url.searchParams.set('sig', signature)
163
+ return url.toString()
164
+ }
165
+
166
+ async #row(path: string): Promise<StoredRow | undefined> {
167
+ await this.#sql.unsafe(SCHEMA_SQL)
168
+ const rows = (await this.#sql.unsafe(
169
+ `SELECT path, contents, content_type, size FROM avelon_storage_objects WHERE path = $1`,
170
+ [path],
171
+ )) as StoredRow[]
172
+ return rows[0]
173
+ }
174
+
175
+ async #ensureServer(): Promise<void> {
176
+ if (this.#server) return
177
+ const self = this
178
+ this.#server = Bun.serve({
179
+ port: 0,
180
+ async fetch(request) {
181
+ const url = new URL(request.url)
182
+ if (url.pathname !== '/object') return new Response('not found', { status: 404 })
183
+ const path = url.searchParams.get('path') ?? ''
184
+ const exp = Number(url.searchParams.get('exp'))
185
+ const sig = url.searchParams.get('sig') ?? ''
186
+ if (!Number.isFinite(exp) || exp * 1000 <= Date.now()) {
187
+ return new Response('expired', { status: 403 })
188
+ }
189
+ const expected = sign(self.#secret, path, exp)
190
+ if (!safeEqual(expected, sig)) return new Response('invalid signature', { status: 403 })
191
+ const row = await self.#row(path)
192
+ if (!row) return new Response('not found', { status: 404 })
193
+ const bytes = toBytes(row.contents)
194
+ const buffer = new ArrayBuffer(bytes.byteLength)
195
+ new Uint8Array(buffer).set(bytes)
196
+ return new Response(buffer, {
197
+ headers: {
198
+ 'Content-Type': row.content_type ?? 'application/octet-stream',
199
+ },
200
+ })
201
+ },
202
+ })
203
+ this.#origin = `http://127.0.0.1:${this.#server.port}`
204
+ }
205
+ }
206
+
207
+ function toBytes(contents: Uint8Array | Buffer): Uint8Array {
208
+ return contents instanceof Uint8Array ? contents.slice() : new Uint8Array(contents)
209
+ }
210
+
211
+ function sign(secret: string, path: string, expiresAt: number): string {
212
+ return createHmac('sha256', secret).update(`${path}:${expiresAt}`).digest('hex')
213
+ }
214
+
215
+ function safeEqual(left: string, right: string): boolean {
216
+ const a = Buffer.from(left)
217
+ const b = Buffer.from(right)
218
+ return a.length === b.length && timingSafeEqual(a, b)
219
+ }
220
+
221
+ /** Creates a Postgres-backed storage driver from options or environment defaults. */
222
+ export function createSupabaseStorage(options: SupabaseStorageOptions = {}): SupabaseStorage {
223
+ return new SupabaseStorage({
224
+ url: options.url ?? defaultUrl(),
225
+ instance: options.instance ?? 'default',
226
+ signingSecret: options.signingSecret ?? process.env.AVELON_STORAGE_SIGNING_SECRET ?? 'avelon-storage-secret',
227
+ })
228
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createSupabaseStorage,
3
+ SupabaseStorage,
4
+ supabaseStorageCapabilities,
5
+ type SupabaseStorageOptions,
6
+ } from './driver'
@@ -0,0 +1,216 @@
1
+ import { SQL } from 'bun'
2
+ import {
3
+ DriverFault,
4
+ Unauthenticated,
5
+ type IssuedToken,
6
+ type TokenDriver,
7
+ type TokenIssueOptions,
8
+ type TokenRecord,
9
+ } from '@avelonjs/core'
10
+
11
+ /** Exact capability declaration for the Supabase token driver. */
12
+ export const supabaseTokenCapabilities = {
13
+ abilities: true,
14
+ expiration: true,
15
+ } as const
16
+
17
+ /** Construction options for {@link createSupabaseTokens}. */
18
+ export interface SupabaseTokenOptions {
19
+ /** Postgres URL used to persist hashed tokens. Defaults to `SUPABASE_DB_URL` / `POSTGRES_URL`. */
20
+ url?: string
21
+ /** Configured token issuer name. */
22
+ instance?: string
23
+ /** Current actor subject stored on issued tokens. */
24
+ subject?: string
25
+ }
26
+
27
+ interface StoredRow {
28
+ id: string
29
+ subject: string
30
+ name: string
31
+ abilities: string[]
32
+ created_at: Date | string
33
+ expires_at: Date | string | null
34
+ hash: string
35
+ revoked: boolean
36
+ }
37
+
38
+ function defaultUrl(): string {
39
+ return (
40
+ process.env.SUPABASE_DB_URL ??
41
+ process.env.POSTGRES_URL ??
42
+ process.env.DATABASE_URL ??
43
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_supabase'
44
+ )
45
+ }
46
+
47
+ function unauthenticated(): never {
48
+ throw new Unauthenticated('The API token is invalid or expired.', {
49
+ metadata: { guard: 'tokens' },
50
+ })
51
+ }
52
+
53
+ async function hash(plainText: string): Promise<string> {
54
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(plainText))
55
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
56
+ }
57
+
58
+ function asDate(value: Date | string): Date {
59
+ return value instanceof Date ? value : new Date(value)
60
+ }
61
+
62
+ function toRecord(row: StoredRow): TokenRecord {
63
+ return {
64
+ id: row.id,
65
+ subject: row.subject,
66
+ name: row.name,
67
+ abilities: [...row.abilities],
68
+ createdAt: asDate(row.created_at),
69
+ expiresAt: row.expires_at === null ? null : asDate(row.expires_at),
70
+ }
71
+ }
72
+
73
+ const SCHEMA_SQL = `
74
+ CREATE TABLE IF NOT EXISTS avelon_signets (
75
+ id text PRIMARY KEY,
76
+ subject text NOT NULL,
77
+ name text NOT NULL,
78
+ abilities jsonb NOT NULL DEFAULT '[]'::jsonb,
79
+ created_at timestamptz NOT NULL DEFAULT now(),
80
+ expires_at timestamptz,
81
+ hash text NOT NULL UNIQUE,
82
+ revoked boolean NOT NULL DEFAULT false
83
+ );
84
+ `
85
+
86
+ /**
87
+ * Postgres-backed Signet driver used by the Supabase package.
88
+ *
89
+ * Supabase Auth does not issue named API tokens. Hashed credentials live in `avelon_signets` so
90
+ * verify/list/revoke are durable and the plaintext secret is returned only at issuance.
91
+ */
92
+ export class SupabaseTokens implements TokenDriver<typeof supabaseTokenCapabilities, SQL> {
93
+ readonly name = 'supabase'
94
+ readonly instance: string
95
+ readonly capabilities = supabaseTokenCapabilities
96
+
97
+ readonly #sql: SQL
98
+ readonly #subject: string
99
+
100
+ constructor(options: Required<Omit<SupabaseTokenOptions, 'url' | 'subject'>> & {
101
+ url: string
102
+ subject: string
103
+ }) {
104
+ this.instance = options.instance
105
+ this.#subject = options.subject
106
+ this.#sql = new SQL(options.url)
107
+ }
108
+
109
+ raw(): SQL {
110
+ return this.#sql
111
+ }
112
+
113
+ /** Recreates the empty signet table used by live conformance. */
114
+ async reset(): Promise<void> {
115
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_signets')
116
+ await this.#sql.unsafe(SCHEMA_SQL)
117
+ }
118
+
119
+ async close(): Promise<void> {
120
+ await this.#sql.close()
121
+ }
122
+
123
+ async verify(plainText: string): Promise<TokenRecord> {
124
+ await this.#sql.unsafe(SCHEMA_SQL)
125
+ const candidateHash = await hash(plainText)
126
+ const rows = (await this.#sql.unsafe(
127
+ `SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
128
+ FROM avelon_signets WHERE hash = $1`,
129
+ [candidateHash],
130
+ )) as StoredRow[]
131
+ const row = rows[0]
132
+ const expiresAt = row?.expires_at === null || row?.expires_at === undefined ? null : asDate(row.expires_at)
133
+ if (
134
+ row === undefined ||
135
+ row.revoked ||
136
+ (expiresAt !== null && expiresAt.getTime() <= Date.now())
137
+ ) {
138
+ unauthenticated()
139
+ }
140
+ return toRecord({ ...row, abilities: normalizeAbilities(row.abilities) })
141
+ }
142
+
143
+ async issue(name: string, options: TokenIssueOptions = {}): Promise<IssuedToken> {
144
+ await this.#sql.unsafe(SCHEMA_SQL)
145
+ const id = crypto.randomUUID()
146
+ const plainText = `avelon-signet-${id}`
147
+ const createdAt = new Date()
148
+ const expiresAt = options.expiresAt ? new Date(options.expiresAt) : null
149
+ const abilities = [...(options.abilities ?? [])]
150
+ const rows = (await this.#sql.unsafe(
151
+ `INSERT INTO avelon_signets (id, subject, name, abilities, created_at, expires_at, hash, revoked)
152
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, false)
153
+ RETURNING id, subject, name, abilities, created_at, expires_at, hash, revoked`,
154
+ [
155
+ id,
156
+ this.#subject,
157
+ name,
158
+ JSON.stringify(abilities),
159
+ createdAt.toISOString(),
160
+ expiresAt?.toISOString() ?? null,
161
+ await hash(plainText),
162
+ ],
163
+ )) as StoredRow[]
164
+ const stored = rows[0]
165
+ if (stored === undefined) {
166
+ throw new DriverFault('Token issuance did not return a row.', {
167
+ metadata: { driver: 'tokens', operation: 'issue' },
168
+ })
169
+ }
170
+ const record = toRecord({ ...stored, abilities: normalizeAbilities(stored.abilities) })
171
+ return {
172
+ ...record,
173
+ expiresAt: options.expiresAt ? new Date(options.expiresAt.getTime()) : record.expiresAt,
174
+ plainText,
175
+ }
176
+ }
177
+
178
+ async list(): Promise<readonly TokenRecord[]> {
179
+ await this.#sql.unsafe(SCHEMA_SQL)
180
+ const rows = (await this.#sql.unsafe(
181
+ `SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
182
+ FROM avelon_signets WHERE subject = $1 ORDER BY created_at ASC`,
183
+ [this.#subject],
184
+ )) as StoredRow[]
185
+ return rows.map((row) => toRecord({ ...row, abilities: normalizeAbilities(row.abilities) }))
186
+ }
187
+
188
+ async revoke(id: string): Promise<void> {
189
+ await this.#sql.unsafe(SCHEMA_SQL)
190
+ await this.#sql.unsafe(`UPDATE avelon_signets SET revoked = true WHERE id = $1`, [id])
191
+ }
192
+ }
193
+
194
+ function normalizeAbilities(value: unknown): string[] {
195
+ if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === 'string')
196
+ if (typeof value === 'string') {
197
+ try {
198
+ const parsed: unknown = JSON.parse(value)
199
+ return Array.isArray(parsed)
200
+ ? parsed.filter((entry): entry is string => typeof entry === 'string')
201
+ : []
202
+ } catch {
203
+ return []
204
+ }
205
+ }
206
+ return []
207
+ }
208
+
209
+ /** Creates a Postgres-backed token driver from options or environment defaults. */
210
+ export function createSupabaseTokens(options: SupabaseTokenOptions = {}): SupabaseTokens {
211
+ return new SupabaseTokens({
212
+ url: options.url ?? defaultUrl(),
213
+ instance: options.instance ?? 'default',
214
+ subject: options.subject ?? 'assay-subject',
215
+ })
216
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createSupabaseTokens,
3
+ SupabaseTokens,
4
+ supabaseTokenCapabilities,
5
+ type SupabaseTokenOptions,
6
+ } from './driver'