@avelonjs/neon 0.3.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,304 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+
3
+ interface LocalUser {
4
+ id: string
5
+ email: string
6
+ passwordHash: string
7
+ totpEnabled: boolean
8
+ emailVerified: boolean
9
+ }
10
+
11
+ interface LocalSession {
12
+ id: string
13
+ userId: string
14
+ token: string
15
+ expiresAt: number
16
+ }
17
+
18
+ interface LocalReset {
19
+ email: string
20
+ token: string
21
+ }
22
+
23
+ interface LocalVerification {
24
+ email: string
25
+ token: string
26
+ }
27
+
28
+ function hashPassword(password: string): string {
29
+ return createHash('sha256').update(password).digest('hex')
30
+ }
31
+
32
+ /**
33
+ * Minimal Better Auth-shaped HTTP server for live identity conformance.
34
+ *
35
+ * Real deployments point the same driver at Neon Auth. This server exists so cookie and password
36
+ * flows can be verified without a hosted Neon Auth project.
37
+ */
38
+ export class LocalAuthServer {
39
+ readonly #users = new Map<string, LocalUser>()
40
+ readonly #sessions = new Map<string, LocalSession>()
41
+ readonly #resets = new Map<string, LocalReset>()
42
+ readonly #verifications = new Map<string, LocalVerification>()
43
+ readonly #magicLinks = new Map<string, string>()
44
+ #server: ReturnType<typeof Bun.serve> | undefined
45
+ #url = ''
46
+
47
+ /** Base Auth URL, e.g. `http://127.0.0.1:3000/api/auth`. */
48
+ get url(): string {
49
+ return this.#url
50
+ }
51
+
52
+ /** Starts the server on an ephemeral port. */
53
+ async start(): Promise<string> {
54
+ const self = this
55
+ this.#server = Bun.serve({
56
+ port: 0,
57
+ async fetch(request) {
58
+ return self.#handle(request)
59
+ },
60
+ })
61
+ this.#url = `http://127.0.0.1:${this.#server.port}/api/auth`
62
+ return this.#url
63
+ }
64
+
65
+ /** Clears users, sessions, reset tokens, and verification tokens without restarting the listener. */
66
+ reset(): void {
67
+ this.#users.clear()
68
+ this.#sessions.clear()
69
+ this.#resets.clear()
70
+ this.#verifications.clear()
71
+ this.#magicLinks.clear()
72
+ }
73
+
74
+ /** Stops the server and clears fixture state. */
75
+ async stop(): Promise<void> {
76
+ this.#server?.stop(true)
77
+ this.#server = undefined
78
+ this.reset()
79
+ }
80
+
81
+ /** Returns the magic-link token issued for an email, if any. */
82
+ magicLinkToken(email: string): string | undefined {
83
+ return [...this.#magicLinks.entries()].find(([, value]) => value === email)?.[0]
84
+ }
85
+
86
+ /** Returns the recovery token issued for an email, if any. */
87
+ recoveryToken(email: string): string | undefined {
88
+ return [...this.#resets.values()].find((entry) => entry.email === email)?.token
89
+ }
90
+
91
+ /** Returns the email confirmation token issued for an email, if any. */
92
+ verificationToken(email: string): string | undefined {
93
+ return [...this.#verifications.values()].find((entry) => entry.email === email)?.token
94
+ }
95
+
96
+ async #handle(request: Request): Promise<Response> {
97
+ const url = new URL(request.url)
98
+ const path = url.pathname.replace(/^\/api\/auth/, '')
99
+
100
+ if (request.method === 'POST' && path === '/sign-up/email') {
101
+ const body = (await request.json()) as { email?: string; password?: string }
102
+ if (!body.email || !body.password) {
103
+ return fail(400, 'INVALID_EMAIL', 'Invalid email')
104
+ }
105
+ if ([...this.#users.values()].some((user) => user.email === body.email)) {
106
+ return fail(422, 'USER_ALREADY_EXISTS', 'User already exists')
107
+ }
108
+ const user: LocalUser = {
109
+ id: randomUUID(),
110
+ email: body.email,
111
+ passwordHash: hashPassword(body.password),
112
+ totpEnabled: true,
113
+ emailVerified: false,
114
+ }
115
+ this.#users.set(user.id, user)
116
+ return json(this.#sessionPayload(user))
117
+ }
118
+
119
+ if (request.method === 'POST' && path === '/sign-in/email') {
120
+ const body = (await request.json()) as { email?: string; password?: string }
121
+ const user = [...this.#users.values()].find((entry) => entry.email === body.email)
122
+ if (!user || user.passwordHash !== hashPassword(body.password ?? '')) {
123
+ return fail(401, 'INVALID_EMAIL_OR_PASSWORD', 'Invalid email or password')
124
+ }
125
+ return json(this.#sessionPayload(user))
126
+ }
127
+
128
+ if (request.method === 'GET' && path === '/get-session') {
129
+ const session = this.#sessionFromAuth(request)
130
+ if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
131
+ const user = this.#users.get(session.userId)
132
+ if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
133
+ return json({
134
+ user: {
135
+ id: user.id,
136
+ email: user.email,
137
+ emailVerified: user.emailVerified,
138
+ twoFactorEnabled: user.totpEnabled,
139
+ },
140
+ session: {
141
+ id: session.id,
142
+ token: session.token,
143
+ expiresAt: new Date(session.expiresAt).toISOString(),
144
+ },
145
+ })
146
+ }
147
+
148
+ if (request.method === 'POST' && path === '/sign-out') {
149
+ const session = this.#sessionFromAuth(request)
150
+ if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
151
+ this.#sessions.delete(session.token)
152
+ return json({})
153
+ }
154
+
155
+ if (request.method === 'POST' && path === '/change-password') {
156
+ const session = this.#sessionFromAuth(request)
157
+ if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
158
+ const user = this.#users.get(session.userId)
159
+ if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
160
+ const body = (await request.json()) as { newPassword?: string }
161
+ if (!body.newPassword) return fail(400, 'BAD_REQUEST', 'Missing required fields')
162
+ user.passwordHash = hashPassword(body.newPassword)
163
+ return json({ user: { id: user.id, email: user.email } })
164
+ }
165
+
166
+ if (request.method === 'POST' && path === '/forget-password') {
167
+ const body = (await request.json()) as { email?: string }
168
+ if (body.email) {
169
+ const token = `assay-reset-${randomUUID()}`
170
+ this.#resets.set(token, { email: body.email, token })
171
+ }
172
+ return json({})
173
+ }
174
+
175
+ if (request.method === 'POST' && path === '/reset-password') {
176
+ const body = (await request.json()) as { token?: string; newPassword?: string }
177
+ if (!body.token || !body.newPassword)
178
+ return fail(400, 'BAD_REQUEST', 'Missing required fields')
179
+ const reset = this.#resets.get(body.token)
180
+ if (!reset) return fail(400, 'INVALID_TOKEN', 'Invalid token')
181
+ const user = [...this.#users.values()].find((entry) => entry.email === reset.email)
182
+ if (!user) return fail(400, 'INVALID_TOKEN', 'Invalid token')
183
+ user.passwordHash = hashPassword(body.newPassword)
184
+ this.#resets.delete(body.token)
185
+ return json({ user: { id: user.id, email: user.email } })
186
+ }
187
+
188
+ if (request.method === 'POST' && path === '/sign-in/magic-link') {
189
+ const body = (await request.json()) as { email?: string }
190
+ if (body.email) this.#magicLinks.set(`assay-magic-${randomUUID()}`, body.email)
191
+ return json({ status: true })
192
+ }
193
+
194
+ if (request.method === 'GET' && path === '/magic-link/verify') {
195
+ const token = url.searchParams.get('token')
196
+ const email = token === null ? undefined : this.#magicLinks.get(token)
197
+ const user = email
198
+ ? [...this.#users.values()].find((entry) => entry.email === email)
199
+ : undefined
200
+ if (token === null || !user) return fail(401, 'INVALID_TOKEN', 'Invalid token')
201
+ this.#magicLinks.delete(token)
202
+ return json(this.#sessionPayload(user))
203
+ }
204
+
205
+ if (request.method === 'GET' && path === '/two-factor/status') {
206
+ const session = this.#sessionFromAuth(request)
207
+ if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
208
+ const user = this.#users.get(session.userId)
209
+ if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
210
+ return json({ enabled: user.totpEnabled })
211
+ }
212
+
213
+ if (request.method === 'POST' && path === '/two-factor/verify-totp') {
214
+ const session = this.#sessionFromAuth(request)
215
+ if (!session) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
216
+ const user = this.#users.get(session.userId)
217
+ if (!user) return fail(401, 'UNAUTHORIZED', 'Session is not valid')
218
+ const body = (await request.json()) as { code?: string }
219
+ if (!user.totpEnabled || body.code !== '123456') {
220
+ return fail(400, 'INVALID_TWO_FACTOR_CODE', 'Invalid two factor code')
221
+ }
222
+ return json({})
223
+ }
224
+
225
+ if (request.method === 'POST' && path === '/send-verification-email') {
226
+ const body = (await request.json()) as { email?: string }
227
+ if (body.email && [...this.#users.values()].some((user) => user.email === body.email)) {
228
+ const token = `assay-verify-${randomUUID()}`
229
+ this.#verifications.set(token, { email: body.email, token })
230
+ }
231
+ return json({})
232
+ }
233
+
234
+ if (request.method === 'GET' && path === '/verify-email') {
235
+ const token = url.searchParams.get('token')
236
+ if (!token) return fail(400, 'BAD_REQUEST', 'Missing required fields')
237
+ const verification = this.#verifications.get(token)
238
+ if (!verification) return fail(400, 'INVALID_TOKEN', 'Invalid token')
239
+ const user = [...this.#users.values()].find((entry) => entry.email === verification.email)
240
+ if (!user) return fail(400, 'INVALID_TOKEN', 'Invalid token')
241
+ user.emailVerified = true
242
+ this.#verifications.delete(token)
243
+ return json({ user: { id: user.id, email: user.email, emailVerified: true } })
244
+ }
245
+
246
+ return fail(404, 'NOT_FOUND', 'Requested path is invalid')
247
+ }
248
+
249
+ #sessionPayload(user: LocalUser): AuthPayload {
250
+ const session = this.#createSession(user.id)
251
+ return {
252
+ user: { id: user.id, email: user.email },
253
+ token: session.token,
254
+ session: {
255
+ id: session.id,
256
+ token: session.token,
257
+ expiresAt: new Date(session.expiresAt).toISOString(),
258
+ },
259
+ }
260
+ }
261
+
262
+ #createSession(userId: string): LocalSession {
263
+ const session: LocalSession = {
264
+ id: randomUUID(),
265
+ userId,
266
+ token: randomUUID(),
267
+ expiresAt: Date.now() + 60 * 60 * 1000,
268
+ }
269
+ this.#sessions.set(session.token, session)
270
+ return session
271
+ }
272
+
273
+ #sessionFromAuth(request: Request): LocalSession | undefined {
274
+ const header = request.headers.get('authorization')
275
+ if (header === null || !header.startsWith('Bearer ')) return undefined
276
+ const token = header.slice('Bearer '.length)
277
+ const session = this.#sessions.get(token)
278
+ if (session === undefined || session.expiresAt <= Date.now()) return undefined
279
+ return session
280
+ }
281
+ }
282
+
283
+ interface AuthPayload {
284
+ user: { id: string; email: string }
285
+ token: string
286
+ session: { id: string; token: string; expiresAt: string }
287
+ }
288
+
289
+ /**
290
+ * A Better Auth error envelope.
291
+ *
292
+ * Neon Auth answers a failure with a SCREAMING_SNAKE `code` and a `message` sentence. The driver
293
+ * classifies on that envelope, so this server emits it rather than a shape invented to match.
294
+ */
295
+ function fail(status: number, code: string, message: string): Response {
296
+ return json({ code, message }, status)
297
+ }
298
+
299
+ function json(body: unknown, status = 200): Response {
300
+ return new Response(JSON.stringify(body), {
301
+ status,
302
+ headers: { 'Content-Type': 'application/json' },
303
+ })
304
+ }
@@ -0,0 +1,19 @@
1
+ /** Actor returned by the Neon identity driver. */
2
+ export interface NeonActor {
3
+ /** Stable actor identifier. */
4
+ id: string
5
+ /** Sign-in email address. */
6
+ email: string
7
+ }
8
+
9
+ /** Session returned by the Neon identity driver. */
10
+ export interface NeonSession {
11
+ /** Stable session identifier. */
12
+ id: string
13
+ /** Session 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 session 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,271 @@
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 Neon queue driver. */
13
+ export const neonQueueCapabilities = {
14
+ delayed: true,
15
+ retries: true,
16
+ deadLetter: true,
17
+ } as const
18
+
19
+ /** Construction options for {@link createNeonQueue}. */
20
+ export interface NeonQueueOptions {
21
+ /** Neon or 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.NEON_DATABASE_URL ??
46
+ process.env.POSTGRES_URL ??
47
+ process.env.DATABASE_URL ??
48
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
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
+ * Neon queue driver using Postgres `FOR UPDATE SKIP LOCKED`.
104
+ *
105
+ * Jobs live on the same Neon database as the rest of the stack. Skip-locked tables carry retry,
106
+ * delay, and dead-letter semantics without requiring pgmq.
107
+ */
108
+ export class NeonQueue
109
+ implements
110
+ QueueDriver<typeof neonQueueCapabilities, SQL>,
111
+ DelayedQueueSurface,
112
+ RetryQueueSurface,
113
+ DeadLetterQueueSurface
114
+ {
115
+ readonly name = 'neon'
116
+ readonly instance: string
117
+ readonly capabilities = neonQueueCapabilities
118
+
119
+ readonly #sql: SQL
120
+ readonly #maxTries: number
121
+
122
+ constructor(options: { url: string; instance: string; maxTries: number }) {
123
+ this.instance = options.instance
124
+ this.#sql = new SQL(options.url)
125
+ this.#maxTries = options.maxTries
126
+ }
127
+
128
+ raw(): SQL {
129
+ return this.#sql
130
+ }
131
+
132
+ async reset(): Promise<void> {
133
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_failed_jobs')
134
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_jobs')
135
+ await this.#sql.unsafe(SCHEMA_SQL)
136
+ }
137
+
138
+ async close(): Promise<void> {
139
+ await this.#sql.close()
140
+ }
141
+
142
+ async enqueue<TPayload>(job: QueueJob<TPayload>): Promise<string> {
143
+ return this.#insert(job, new Date())
144
+ }
145
+
146
+ async enqueueAt<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
147
+ return this.#insert(job, availableAt)
148
+ }
149
+
150
+ async drain(
151
+ handler: (receipt: QueueReceipt) => Promise<void>,
152
+ options?: { readonly queue?: string; readonly limit?: number },
153
+ ): Promise<number> {
154
+ await this.#sql.unsafe(SCHEMA_SQL)
155
+ const limit = options?.limit ?? 2_147_483_647
156
+ const rows = (await this.#sql.unsafe(
157
+ `WITH picked AS MATERIALIZED (
158
+ SELECT id FROM avelon_jobs
159
+ WHERE available_at <= now()
160
+ AND locked_at IS NULL
161
+ AND ($1::boolean OR queue IS NOT DISTINCT FROM $2)
162
+ ORDER BY available_at ASC
163
+ FOR UPDATE SKIP LOCKED
164
+ LIMIT $3
165
+ )
166
+ UPDATE avelon_jobs AS jobs
167
+ SET locked_at = now(), attempt = jobs.attempt + 1
168
+ FROM picked
169
+ WHERE jobs.id = picked.id
170
+ RETURNING jobs.id, jobs.queue, jobs.name, jobs.payload, jobs.attempt, jobs.available_at`,
171
+ [options?.queue === undefined, options?.queue ?? null, limit],
172
+ )) as JobRow[]
173
+
174
+ for (const row of rows) {
175
+ const receipt: QueueReceipt = { id: row.id, job: toJob(row), attempt: row.attempt }
176
+ try {
177
+ await handler(receipt)
178
+ await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
179
+ } catch (error: unknown) {
180
+ if (this.capabilities.retries && row.attempt < this.#maxTries) {
181
+ await this.#sql.unsafe(`UPDATE avelon_jobs SET locked_at = NULL WHERE id = $1`, [row.id])
182
+ continue
183
+ }
184
+ if (this.capabilities.deadLetter) {
185
+ await this.#sql.unsafe(
186
+ `INSERT INTO avelon_failed_jobs (id, queue, name, payload, attempt, error, failed_at)
187
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, now())`,
188
+ [
189
+ row.id,
190
+ row.queue,
191
+ row.name,
192
+ encodePayload(row.payload),
193
+ row.attempt,
194
+ failureMessage(error),
195
+ ],
196
+ )
197
+ }
198
+ await this.#sql.unsafe(`DELETE FROM avelon_jobs WHERE id = $1`, [row.id])
199
+ }
200
+ }
201
+
202
+ return rows.length
203
+ }
204
+
205
+ async retry(id: string, delaySeconds?: number): Promise<void> {
206
+ const delay = delaySeconds ?? 0
207
+ await this.#sql.unsafe(
208
+ `UPDATE avelon_jobs
209
+ SET locked_at = NULL, available_at = now() + ($2 * interval '1 second')
210
+ WHERE id = $1`,
211
+ [id, delay],
212
+ )
213
+ }
214
+
215
+ async failed(queue?: string): Promise<readonly FailedQueueJob[]> {
216
+ await this.#sql.unsafe(SCHEMA_SQL)
217
+ const rows = (await this.#sql.unsafe(
218
+ `SELECT id, queue, name, payload, attempt, error, failed_at
219
+ FROM avelon_failed_jobs
220
+ WHERE ($1::text IS NULL OR queue IS NOT DISTINCT FROM $1)
221
+ ORDER BY failed_at ASC`,
222
+ [queue ?? null],
223
+ )) as FailedRow[]
224
+ return rows.map((row) => ({
225
+ id: row.id,
226
+ job: toJob(row),
227
+ attempt: row.attempt,
228
+ error: row.error,
229
+ failedAt: asDate(row.failed_at),
230
+ }))
231
+ }
232
+
233
+ async replay(id: string): Promise<void> {
234
+ const rows = (await this.#sql.unsafe(
235
+ `DELETE FROM avelon_failed_jobs WHERE id = $1
236
+ RETURNING id, queue, name, payload`,
237
+ [id],
238
+ )) as Array<Pick<FailedRow, 'id' | 'queue' | 'name' | 'payload'>>
239
+ const row = rows[0]
240
+ if (row === undefined) return
241
+ await this.#sql.unsafe(
242
+ `INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
243
+ VALUES ($1, $2, $3, $4::jsonb, 0, now(), NULL)`,
244
+ [row.id, row.queue, row.name, encodePayload(row.payload)],
245
+ )
246
+ }
247
+
248
+ async forget(id: string): Promise<void> {
249
+ await this.#sql.unsafe(`DELETE FROM avelon_failed_jobs WHERE id = $1`, [id])
250
+ }
251
+
252
+ async #insert<TPayload>(job: QueueJob<TPayload>, availableAt: Date): Promise<string> {
253
+ await this.#sql.unsafe(SCHEMA_SQL)
254
+ const id = crypto.randomUUID()
255
+ await this.#sql.unsafe(
256
+ `INSERT INTO avelon_jobs (id, queue, name, payload, attempt, available_at, locked_at)
257
+ VALUES ($1, $2, $3, $4::jsonb, 0, $5, NULL)`,
258
+ [id, job.queue ?? null, job.name, encodePayload(job.payload), availableAt.toISOString()],
259
+ )
260
+ return id
261
+ }
262
+ }
263
+
264
+ /** Creates a skip-locked queue driver from options or environment defaults. */
265
+ export function createNeonQueue(options: NeonQueueOptions = {}): NeonQueue {
266
+ return new NeonQueue({
267
+ url: options.url ?? defaultUrl(),
268
+ instance: options.instance ?? 'default',
269
+ maxTries: options.maxTries ?? 3,
270
+ })
271
+ }
@@ -0,0 +1 @@
1
+ export { createNeonQueue, NeonQueue, neonQueueCapabilities, type NeonQueueOptions } from './driver'