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