@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,118 @@
1
+ import { Invalid, Unauthenticated, type SocialDriver, type SocialIdentity } from '@avelonjs/core'
2
+ import type { NeonSocialProfile } from './types'
3
+
4
+ /** Exact capability declaration for the Neon social driver. */
5
+ export const neonSocialCapabilities = {
6
+ providers: ['github', 'google'] as const,
7
+ } as const
8
+
9
+ type Provider = (typeof neonSocialCapabilities.providers)[number]
10
+
11
+ /** Construction options for {@link createNeonSocial}. */
12
+ export interface NeonSocialOptions {
13
+ /** Neon Auth / Better Auth base URL. */
14
+ authUrl?: string
15
+ /** Configured social connection name. */
16
+ instance?: string
17
+ }
18
+
19
+ function isProvider(provider: string): provider is Provider {
20
+ return neonSocialCapabilities.providers.some((candidate) => candidate === provider)
21
+ }
22
+
23
+ function invalid(field: string, message: string): never {
24
+ throw new Invalid(message, { metadata: { fields: { [field]: [message] } } })
25
+ }
26
+
27
+ function unauthenticated(message: string): never {
28
+ throw new Unauthenticated(message, { metadata: { guard: 'social' } })
29
+ }
30
+
31
+ /**
32
+ * Neon Auth social driver.
33
+ *
34
+ * `redirect()` builds a Better Auth social sign-in URL and records CSRF state on the driver
35
+ * instance. `callback()` verifies that state, then exchanges the authorization code over HTTP.
36
+ */
37
+ export class NeonSocial implements SocialDriver<
38
+ typeof neonSocialCapabilities,
39
+ { authUrl: string },
40
+ NeonSocialProfile
41
+ > {
42
+ readonly name = 'neon'
43
+ readonly instance: string
44
+ readonly capabilities = neonSocialCapabilities
45
+
46
+ readonly #authUrl: string
47
+ readonly #states = new Map<Provider, string>()
48
+ #nextState = 1
49
+
50
+ constructor(options: Required<NeonSocialOptions>) {
51
+ this.instance = options.instance
52
+ this.#authUrl = options.authUrl.replace(/\/$/, '')
53
+ }
54
+
55
+ raw(): { authUrl: string } {
56
+ return { authUrl: this.#authUrl }
57
+ }
58
+
59
+ async redirect(provider: string, callbackUrl: string, state?: string): Promise<string> {
60
+ if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
61
+ const expectedState = state ?? `assay-state-${this.#nextState++}`
62
+ this.#states.set(provider, expectedState)
63
+ const url = new URL(`${this.#authUrl}/sign-in/social`)
64
+ url.searchParams.set('provider', provider)
65
+ url.searchParams.set('callbackURL', callbackUrl)
66
+ url.searchParams.set('state', expectedState)
67
+ return url.toString()
68
+ }
69
+
70
+ async callback(
71
+ provider: string,
72
+ params: Readonly<Record<string, string>>,
73
+ callbackUrl: string,
74
+ ): Promise<SocialIdentity<NeonSocialProfile>> {
75
+ if (!isProvider(provider)) invalid('provider', 'Provider is not configured.')
76
+ const expectedState = this.#states.get(provider)
77
+ if (!expectedState || !params.state || params.state !== expectedState) {
78
+ unauthenticated('Authorization state could not be verified.')
79
+ }
80
+ this.#states.delete(provider)
81
+ if (params.error) unauthenticated(`Authorization failed: ${params.error}.`)
82
+ if (!params.code) invalid('code', 'Authorization code is required.')
83
+
84
+ const response = await fetch(`${this.#authUrl}/callback`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({
88
+ code: params.code,
89
+ provider,
90
+ callbackURL: callbackUrl,
91
+ }),
92
+ })
93
+ const body = (await response.json()) as {
94
+ error?: string
95
+ user?: { id?: string; email?: string; name?: string }
96
+ }
97
+ const user = body.user
98
+ if (!response.ok || user === undefined || typeof user.id !== 'string') {
99
+ unauthenticated(body.error ?? 'Authorization code exchange failed.')
100
+ }
101
+ return {
102
+ provider,
103
+ subject: user.id,
104
+ profile: {
105
+ displayName: user.name ?? user.email ?? user.id,
106
+ ...(user.email === undefined ? {} : { email: user.email }),
107
+ },
108
+ }
109
+ }
110
+ }
111
+
112
+ /** Creates a Neon social driver from options or environment defaults. */
113
+ export function createNeonSocial(options: NeonSocialOptions = {}): NeonSocial {
114
+ return new NeonSocial({
115
+ authUrl: options.authUrl ?? process.env.NEON_AUTH_URL ?? 'http://127.0.0.1:3000/api/auth',
116
+ instance: options.instance ?? 'default',
117
+ })
118
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ createNeonSocial,
3
+ NeonSocial,
4
+ neonSocialCapabilities,
5
+ type NeonSocialOptions,
6
+ } from './driver'
7
+ export { LocalSocialServer } from './local-auth'
8
+ export type { NeonSocialProfile } from './types'
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Minimal Better Auth-shaped OAuth token endpoint for social conformance.
3
+ *
4
+ * Real deployments point {@link createNeonSocial} at Neon Auth. This server exists so the HTTP
5
+ * callback exchange can run without a hosted OAuth app.
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:3000/api/auth`. */
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}/api/auth`
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(/^\/api\/auth/, '')
38
+
39
+ if (request.method === 'POST' && path === '/callback') {
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
+ user: {
46
+ id: `subject-${body.code}`,
47
+ email: 'actor@example.test',
48
+ name: 'Assay Actor',
49
+ },
50
+ })
51
+ }
52
+
53
+ return Response.json({ error: 'not_found' }, { status: 404 })
54
+ }
55
+ }
@@ -0,0 +1,7 @@
1
+ /** Provider profile returned by the Neon social driver. */
2
+ export interface NeonSocialProfile {
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,182 @@
1
+ import {
2
+ DeleteObjectCommand,
3
+ GetObjectCommand,
4
+ HeadObjectCommand,
5
+ PutObjectCommand,
6
+ S3Client,
7
+ } from '@aws-sdk/client-s3'
8
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
9
+ import {
10
+ NotFound,
11
+ type SignedUrlStorageSurface,
12
+ type StorageDriver,
13
+ type StorageObject,
14
+ } from '@avelonjs/core'
15
+
16
+ /** Exact capability declaration for the Neon S3 storage driver. */
17
+ export const neonStorageCapabilities = {
18
+ signedUrls: true,
19
+ transforms: [] as const,
20
+ } as const
21
+
22
+ /** Construction options for {@link createNeonStorage}. */
23
+ export interface NeonStorageOptions {
24
+ /** S3 bucket name. */
25
+ bucket?: string
26
+ /** Configured disk name. */
27
+ instance?: string
28
+ /** AWS region. Defaults to `AWS_REGION` or `us-east-1`. */
29
+ region?: string
30
+ /** Optional custom endpoint for R2, MinIO, or {@link LocalS3Server}. */
31
+ endpoint?: string
32
+ /** Access key. Defaults to `AWS_ACCESS_KEY_ID`. */
33
+ accessKeyId?: string
34
+ /** Secret key. Defaults to `AWS_SECRET_ACCESS_KEY`. */
35
+ secretAccessKey?: string
36
+ }
37
+
38
+ function notFound(path: string): never {
39
+ throw new NotFound(`Storage object ${path} was not found.`, {
40
+ metadata: { resource: 'storage-object', identifier: path },
41
+ })
42
+ }
43
+
44
+ async function readContents(contents: Uint8Array | AsyncIterable<Uint8Array>): Promise<Uint8Array> {
45
+ if (contents instanceof Uint8Array) return contents.slice()
46
+ const chunks: Uint8Array[] = []
47
+ let size = 0
48
+ for await (const chunk of contents) {
49
+ const copy = chunk.slice()
50
+ chunks.push(copy)
51
+ size += copy.byteLength
52
+ }
53
+ const result = new Uint8Array(size)
54
+ let offset = 0
55
+ for (const chunk of chunks) {
56
+ result.set(chunk, offset)
57
+ offset += chunk.byteLength
58
+ }
59
+ return result
60
+ }
61
+
62
+ function isMissing(error: unknown): boolean {
63
+ if (typeof error !== 'object' || error === null) return false
64
+ const name = Reflect.get(error, 'name')
65
+ const code = Reflect.get(error, '$metadata')
66
+ const httpStatus =
67
+ typeof code === 'object' && code !== null ? Reflect.get(code, 'httpStatusCode') : undefined
68
+ return name === 'NotFound' || name === 'NoSuchKey' || httpStatus === 404
69
+ }
70
+
71
+ /**
72
+ * S3 storage driver used by the Neon stack.
73
+ *
74
+ * Neon has no object store. Bytes go to S3 (or an S3-compatible endpoint). Signed read URLs are
75
+ * SigV4 query URLs from the AWS SDK.
76
+ */
77
+ export class NeonStorage
78
+ implements StorageDriver<typeof neonStorageCapabilities, S3Client>, SignedUrlStorageSurface
79
+ {
80
+ readonly name = 'neon'
81
+ readonly instance: string
82
+ readonly capabilities = neonStorageCapabilities
83
+
84
+ readonly #client: S3Client
85
+ readonly #bucket: string
86
+
87
+ constructor(options: {
88
+ bucket: string
89
+ instance: string
90
+ region: string
91
+ endpoint?: string
92
+ accessKeyId: string
93
+ secretAccessKey: string
94
+ }) {
95
+ this.instance = options.instance
96
+ this.#bucket = options.bucket
97
+ this.#client = new S3Client({
98
+ region: options.region,
99
+ // Vendor boundary: the AWS SDK client plus checksum flags that keep custom endpoints honest.
100
+ forcePathStyle: options.endpoint !== undefined,
101
+ ...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
102
+ credentials: {
103
+ accessKeyId: options.accessKeyId,
104
+ secretAccessKey: options.secretAccessKey,
105
+ },
106
+ requestChecksumCalculation: 'WHEN_REQUIRED',
107
+ responseChecksumValidation: 'WHEN_REQUIRED',
108
+ })
109
+ }
110
+
111
+ raw(): S3Client {
112
+ return this.#client
113
+ }
114
+
115
+ async put(
116
+ path: string,
117
+ contents: Uint8Array | AsyncIterable<Uint8Array>,
118
+ options?: { readonly contentType?: string },
119
+ ): Promise<StorageObject> {
120
+ const stored = await readContents(contents)
121
+ await this.#client.send(
122
+ new PutObjectCommand({
123
+ Bucket: this.#bucket,
124
+ Key: path,
125
+ Body: stored,
126
+ ...(options?.contentType === undefined ? {} : { ContentType: options.contentType }),
127
+ }),
128
+ )
129
+ return {
130
+ path,
131
+ size: stored.byteLength,
132
+ ...(options?.contentType === undefined ? {} : { contentType: options.contentType }),
133
+ }
134
+ }
135
+
136
+ async get(path: string): Promise<Uint8Array> {
137
+ try {
138
+ const response = await this.#client.send(
139
+ new GetObjectCommand({ Bucket: this.#bucket, Key: path }),
140
+ )
141
+ const body = response.Body
142
+ if (body === undefined) notFound(path)
143
+ return new Uint8Array(await body.transformToByteArray())
144
+ } catch (error: unknown) {
145
+ if (isMissing(error)) notFound(path)
146
+ throw error
147
+ }
148
+ }
149
+
150
+ async delete(path: string): Promise<void> {
151
+ await this.#client.send(new DeleteObjectCommand({ Bucket: this.#bucket, Key: path }))
152
+ }
153
+
154
+ async exists(path: string): Promise<boolean> {
155
+ try {
156
+ await this.#client.send(new HeadObjectCommand({ Bucket: this.#bucket, Key: path }))
157
+ return true
158
+ } catch (error: unknown) {
159
+ if (isMissing(error)) return false
160
+ throw error
161
+ }
162
+ }
163
+
164
+ async signedUrl(path: string, expiresInSeconds: number): Promise<string> {
165
+ if (!(await this.exists(path))) notFound(path)
166
+ return getSignedUrl(this.#client, new GetObjectCommand({ Bucket: this.#bucket, Key: path }), {
167
+ expiresIn: expiresInSeconds,
168
+ })
169
+ }
170
+ }
171
+
172
+ /** Creates an S3 storage driver from options or environment defaults. */
173
+ export function createNeonStorage(options: NeonStorageOptions = {}): NeonStorage {
174
+ return new NeonStorage({
175
+ bucket: options.bucket ?? process.env.NEON_S3_BUCKET ?? process.env.AWS_S3_BUCKET ?? 'avelon',
176
+ instance: options.instance ?? 'default',
177
+ region: options.region ?? process.env.AWS_REGION ?? 'us-east-1',
178
+ endpoint: options.endpoint ?? process.env.AWS_ENDPOINT_URL_S3 ?? process.env.AWS_ENDPOINT_URL,
179
+ accessKeyId: options.accessKeyId ?? process.env.AWS_ACCESS_KEY_ID ?? 'test',
180
+ secretAccessKey: options.secretAccessKey ?? process.env.AWS_SECRET_ACCESS_KEY ?? 'test',
181
+ })
182
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ createNeonStorage,
3
+ NeonStorage,
4
+ neonStorageCapabilities,
5
+ type NeonStorageOptions,
6
+ } from './driver'
7
+ export { LocalS3Server, signedUrlExpiryMs } from './local-s3'
@@ -0,0 +1,128 @@
1
+ interface StoredObject {
2
+ bytes: Uint8Array
3
+ contentType?: string
4
+ }
5
+
6
+ function objectKey(bucket: string, key: string): string {
7
+ return `${bucket}/${key}`
8
+ }
9
+
10
+ function parsePath(pathname: string): { bucket: string; key: string } | undefined {
11
+ const parts = pathname.split('/').filter((part) => part.length > 0)
12
+ const bucket = parts[0]
13
+ if (bucket === undefined) return undefined
14
+ return { bucket, key: parts.slice(1).join('/') }
15
+ }
16
+
17
+ /** Parses SigV4 query expiry. Returns epoch ms, or undefined when the request is not signed. */
18
+ export function signedUrlExpiryMs(url: URL): number | undefined {
19
+ const date = url.searchParams.get('X-Amz-Date')
20
+ const expires = Number(url.searchParams.get('X-Amz-Expires'))
21
+ if (date === null || date.length < 16 || !Number.isFinite(expires)) return undefined
22
+ const iso = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T${date.slice(9, 11)}:${date.slice(11, 13)}:${date.slice(13, 15)}Z`
23
+ const started = Date.parse(iso)
24
+ if (Number.isNaN(started)) return undefined
25
+ return started + expires * 1000
26
+ }
27
+
28
+ /**
29
+ * Minimal path-style S3 HTTP server for storage conformance.
30
+ *
31
+ * Real deployments point {@link createNeonStorage} at AWS S3, R2, or MinIO. This listener ignores
32
+ * SigV4 signatures and enforces `X-Amz-Date` / `X-Amz-Expires` so signed-URL expiry stays real.
33
+ */
34
+ export class LocalS3Server {
35
+ readonly #objects = new Map<string, StoredObject>()
36
+ #server: ReturnType<typeof Bun.serve> | undefined
37
+ #url = ''
38
+
39
+ /** Path-style S3 endpoint, e.g. `http://127.0.0.1:9000`. */
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}`
54
+ return this.#url
55
+ }
56
+
57
+ /** Clears stored objects without restarting the listener. */
58
+ reset(): void {
59
+ this.#objects.clear()
60
+ }
61
+
62
+ /** Stops the server and clears fixture state. */
63
+ async stop(): Promise<void> {
64
+ this.#server?.stop(true)
65
+ this.#server = undefined
66
+ this.reset()
67
+ }
68
+
69
+ async #handle(request: Request): Promise<Response> {
70
+ const url = new URL(request.url)
71
+ const parsed = parsePath(url.pathname)
72
+ if (parsed === undefined || parsed.key.length === 0) {
73
+ return s3Error(404, 'NoSuchKey', 'The specified key does not exist.')
74
+ }
75
+ const id = objectKey(parsed.bucket, parsed.key)
76
+
77
+ if (request.method === 'PUT') {
78
+ const buffer = new Uint8Array(await request.arrayBuffer())
79
+ const contentType = request.headers.get('content-type') ?? undefined
80
+ this.#objects.set(id, {
81
+ bytes: buffer,
82
+ ...(contentType === undefined ? {} : { contentType }),
83
+ })
84
+ return new Response(null, { status: 200, headers: { ETag: `"${buffer.byteLength}"` } })
85
+ }
86
+
87
+ if (request.method === 'DELETE') {
88
+ this.#objects.delete(id)
89
+ return new Response(null, { status: 204 })
90
+ }
91
+
92
+ const expiry = signedUrlExpiryMs(url)
93
+ if (expiry !== undefined && expiry <= Date.now()) {
94
+ return new Response('expired', { status: 403 })
95
+ }
96
+
97
+ const stored = this.#objects.get(id)
98
+ if (stored === undefined) {
99
+ return s3Error(404, 'NoSuchKey', 'The specified key does not exist.')
100
+ }
101
+
102
+ const headers: Record<string, string> = {
103
+ 'Content-Type': stored.contentType ?? 'application/octet-stream',
104
+ 'Content-Length': String(stored.bytes.byteLength),
105
+ ETag: `"${stored.bytes.byteLength}"`,
106
+ }
107
+
108
+ if (request.method === 'HEAD') {
109
+ return new Response(null, { status: 200, headers })
110
+ }
111
+
112
+ if (request.method === 'GET') {
113
+ const copy = new ArrayBuffer(stored.bytes.byteLength)
114
+ new Uint8Array(copy).set(stored.bytes)
115
+ return new Response(copy, { status: 200, headers })
116
+ }
117
+
118
+ return s3Error(405, 'MethodNotAllowed', 'The specified method is not allowed.')
119
+ }
120
+ }
121
+
122
+ function s3Error(status: number, code: string, message: string): Response {
123
+ const body = `<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message>${message}</Message></Error>`
124
+ return new Response(body, {
125
+ status,
126
+ headers: { 'Content-Type': 'application/xml' },
127
+ })
128
+ }