@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,220 @@
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 Neon token driver. */
12
+ export const neonTokenCapabilities = {
13
+ abilities: true,
14
+ expiration: true,
15
+ } as const
16
+
17
+ /** Construction options for {@link createNeonTokens}. */
18
+ export interface NeonTokenOptions {
19
+ /** Neon or Postgres URL used to persist hashed tokens. */
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.NEON_DATABASE_URL ??
41
+ process.env.POSTGRES_URL ??
42
+ process.env.DATABASE_URL ??
43
+ 'postgresql://postgres:avelon@127.0.0.1:5432/avelon_test'
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 Neon package.
88
+ *
89
+ * Neon 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 NeonTokens implements TokenDriver<typeof neonTokenCapabilities, SQL> {
93
+ readonly name = 'neon'
94
+ readonly instance: string
95
+ readonly capabilities = neonTokenCapabilities
96
+
97
+ readonly #sql: SQL
98
+ readonly #subject: string
99
+
100
+ constructor(
101
+ options: Required<Omit<NeonTokenOptions, 'url' | 'subject'>> & {
102
+ url: string
103
+ subject: string
104
+ },
105
+ ) {
106
+ this.instance = options.instance
107
+ this.#subject = options.subject
108
+ this.#sql = new SQL(options.url)
109
+ }
110
+
111
+ raw(): SQL {
112
+ return this.#sql
113
+ }
114
+
115
+ /** Recreates the empty signet table used by live conformance. */
116
+ async reset(): Promise<void> {
117
+ await this.#sql.unsafe('DROP TABLE IF EXISTS avelon_signets')
118
+ await this.#sql.unsafe(SCHEMA_SQL)
119
+ }
120
+
121
+ async close(): Promise<void> {
122
+ await this.#sql.close()
123
+ }
124
+
125
+ async verify(plainText: string): Promise<TokenRecord> {
126
+ await this.#sql.unsafe(SCHEMA_SQL)
127
+ const candidateHash = await hash(plainText)
128
+ const rows = (await this.#sql.unsafe(
129
+ `SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
130
+ FROM avelon_signets WHERE hash = $1`,
131
+ [candidateHash],
132
+ )) as StoredRow[]
133
+ const row = rows[0]
134
+ const expiresAt =
135
+ row?.expires_at === null || row?.expires_at === undefined ? null : asDate(row.expires_at)
136
+ if (
137
+ row === undefined ||
138
+ row.revoked ||
139
+ (expiresAt !== null && expiresAt.getTime() <= Date.now())
140
+ ) {
141
+ unauthenticated()
142
+ }
143
+ return toRecord({ ...row, abilities: normalizeAbilities(row.abilities) })
144
+ }
145
+
146
+ async issue(name: string, options: TokenIssueOptions = {}): Promise<IssuedToken> {
147
+ await this.#sql.unsafe(SCHEMA_SQL)
148
+ const id = crypto.randomUUID()
149
+ const plainText = `avelon-signet-${id}`
150
+ const createdAt = new Date()
151
+ const expiresAt = options.expiresAt ? new Date(options.expiresAt) : null
152
+ const abilities = [...(options.abilities ?? [])]
153
+ const rows = (await this.#sql.unsafe(
154
+ `INSERT INTO avelon_signets (id, subject, name, abilities, created_at, expires_at, hash, revoked)
155
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, false)
156
+ RETURNING id, subject, name, abilities, created_at, expires_at, hash, revoked`,
157
+ [
158
+ id,
159
+ this.#subject,
160
+ name,
161
+ JSON.stringify(abilities),
162
+ createdAt.toISOString(),
163
+ expiresAt?.toISOString() ?? null,
164
+ await hash(plainText),
165
+ ],
166
+ )) as StoredRow[]
167
+ const stored = rows[0]
168
+ if (stored === undefined) {
169
+ throw new DriverFault('Token issuance did not return a row.', {
170
+ metadata: { driver: 'tokens', operation: 'issue' },
171
+ })
172
+ }
173
+ const record = toRecord({ ...stored, abilities: normalizeAbilities(stored.abilities) })
174
+ return {
175
+ ...record,
176
+ expiresAt: options.expiresAt ? new Date(options.expiresAt.getTime()) : record.expiresAt,
177
+ plainText,
178
+ }
179
+ }
180
+
181
+ async list(): Promise<readonly TokenRecord[]> {
182
+ await this.#sql.unsafe(SCHEMA_SQL)
183
+ const rows = (await this.#sql.unsafe(
184
+ `SELECT id, subject, name, abilities, created_at, expires_at, hash, revoked
185
+ FROM avelon_signets WHERE subject = $1 ORDER BY created_at ASC`,
186
+ [this.#subject],
187
+ )) as StoredRow[]
188
+ return rows.map((row) => toRecord({ ...row, abilities: normalizeAbilities(row.abilities) }))
189
+ }
190
+
191
+ async revoke(id: string): Promise<void> {
192
+ await this.#sql.unsafe(SCHEMA_SQL)
193
+ await this.#sql.unsafe(`UPDATE avelon_signets SET revoked = true WHERE id = $1`, [id])
194
+ }
195
+ }
196
+
197
+ function normalizeAbilities(value: unknown): string[] {
198
+ if (Array.isArray(value))
199
+ return value.filter((entry): entry is string => typeof entry === 'string')
200
+ if (typeof value === 'string') {
201
+ try {
202
+ const parsed: unknown = JSON.parse(value)
203
+ return Array.isArray(parsed)
204
+ ? parsed.filter((entry): entry is string => typeof entry === 'string')
205
+ : []
206
+ } catch {
207
+ return []
208
+ }
209
+ }
210
+ return []
211
+ }
212
+
213
+ /** Creates a Postgres-backed token driver from options or environment defaults. */
214
+ export function createNeonTokens(options: NeonTokenOptions = {}): NeonTokens {
215
+ return new NeonTokens({
216
+ url: options.url ?? defaultUrl(),
217
+ instance: options.instance ?? 'default',
218
+ subject: options.subject ?? 'assay-subject',
219
+ })
220
+ }
@@ -0,0 +1,6 @@
1
+ export {
2
+ createNeonTokens,
3
+ NeonTokens,
4
+ neonTokenCapabilities,
5
+ type NeonTokenOptions,
6
+ } from './driver'