@exvio/os-backend-core 0.4.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,282 @@
1
+ export type AIProviderName = string
2
+ export type AIOperation = 'text' | 'content' | 'chat' | 'stream' | 'object' | 'embed'
3
+
4
+ export type JSONPrimitive = string | number | boolean | null
5
+ export type JSONValue = JSONPrimitive | { readonly [key: string]: JSONValue } | readonly JSONValue[]
6
+ export type JSONSchema = Readonly<Record<string, unknown>>
7
+
8
+ export interface AIModelRef {
9
+ readonly provider: AIProviderName
10
+ readonly model: string
11
+ }
12
+
13
+ /** Short alias used by pricing and application adapters. */
14
+ export type AIModel = AIModelRef
15
+
16
+ export interface AIProviderConfig {
17
+ readonly apiKey?: string
18
+ /** Complete request endpoint. Prefer this name in new configuration. */
19
+ readonly endpoint?: string
20
+ /** @deprecated Legacy consumer field; interpreted as a complete endpoint. */
21
+ readonly baseUrl?: string
22
+ readonly settings?: Readonly<Record<string, unknown>>
23
+ }
24
+
25
+ export interface AIConfig {
26
+ readonly providers: Readonly<Record<AIProviderName, AIProviderConfig>>
27
+ readonly lanes: Readonly<Record<string, AIModelRef>>
28
+ }
29
+
30
+ export interface AIUsage {
31
+ readonly inputTokens: number
32
+ readonly outputTokens: number
33
+ readonly totalTokens: number
34
+ readonly cachedInputTokens?: number
35
+ readonly cacheMissInputTokens?: number
36
+ readonly reasoningTokens?: number
37
+ }
38
+
39
+ export type AIFinishReason =
40
+ | 'stop'
41
+ | 'length'
42
+ | 'tool_calls'
43
+ | 'content_filter'
44
+ | 'error'
45
+ | 'other'
46
+
47
+ export interface AIResult<T> {
48
+ readonly output: T
49
+ /** Actual provider used, not a model-prefix inference. */
50
+ readonly provider: AIProviderName
51
+ /** Actual model used by the provider. */
52
+ readonly model: string
53
+ readonly usage: AIUsage | null
54
+ readonly finishReason: AIFinishReason
55
+ readonly providerFinishReason?: string
56
+ readonly requestId?: string
57
+ }
58
+
59
+ export type AIContentPart =
60
+ | { readonly type: 'text'; readonly text: string }
61
+ | { readonly type: 'image'; readonly mimeType: string; readonly data: string }
62
+ | { readonly type: 'file'; readonly mimeType: string; readonly data: string; readonly fileName?: string }
63
+
64
+ export interface AIToolCall {
65
+ readonly id: string
66
+ readonly name: string
67
+ readonly arguments: Readonly<Record<string, unknown>>
68
+ }
69
+
70
+ export interface AIToolDefinition {
71
+ readonly name: string
72
+ readonly description: string
73
+ readonly parameters: JSONSchema
74
+ }
75
+
76
+ export type AIToolChoice = 'auto' | 'required' | 'none' | { readonly name: string }
77
+
78
+ export type AIMessage =
79
+ | { readonly role: 'system'; readonly content: string }
80
+ | { readonly role: 'user'; readonly content: string | readonly AIContentPart[] }
81
+ | { readonly role: 'assistant'; readonly content: string; readonly toolCalls?: readonly AIToolCall[] }
82
+ | {
83
+ readonly role: 'tool'
84
+ readonly toolCallId: string
85
+ readonly toolName?: string
86
+ readonly content: string
87
+ }
88
+
89
+ export interface AIChatOutput {
90
+ readonly text: string
91
+ readonly toolCalls: readonly AIToolCall[]
92
+ }
93
+
94
+ export interface AIEmbeddingOutput {
95
+ readonly embeddings: readonly (readonly number[])[]
96
+ }
97
+
98
+ export interface AIObjectSchema<T> {
99
+ readonly name?: string
100
+ readonly description?: string
101
+ readonly jsonSchema: JSONSchema
102
+ validate(value: unknown): T
103
+ }
104
+
105
+ export interface AIProviderRequestBase {
106
+ readonly model: string
107
+ readonly signal: AbortSignal
108
+ readonly timeoutMs: number
109
+ readonly systemInstruction?: string
110
+ readonly maxOutputTokens?: number
111
+ readonly temperature?: number
112
+ readonly topP?: number
113
+ readonly stopSequences?: readonly string[]
114
+ readonly responseFormat?: 'text' | 'json'
115
+ readonly disableThinking?: boolean
116
+ }
117
+
118
+ export interface AIProviderTextRequest extends AIProviderRequestBase {
119
+ readonly prompt: string
120
+ }
121
+
122
+ export interface AIProviderContentRequest extends AIProviderRequestBase {
123
+ readonly content: readonly AIContentPart[]
124
+ }
125
+
126
+ export interface AIProviderChatRequest extends AIProviderRequestBase {
127
+ readonly messages: readonly AIMessage[]
128
+ readonly tools?: readonly AIToolDefinition[]
129
+ readonly toolChoice?: AIToolChoice
130
+ }
131
+
132
+ export interface AIProviderObjectRequest<T = unknown> extends AIProviderRequestBase {
133
+ readonly prompt: string
134
+ readonly schema: AIObjectSchema<T>
135
+ }
136
+
137
+ export interface AIProviderEmbedRequest extends Pick<AIProviderRequestBase, 'model' | 'signal' | 'timeoutMs'> {
138
+ readonly input: readonly string[]
139
+ readonly dimensions?: number
140
+ }
141
+
142
+ export type AIStreamEvent =
143
+ | { readonly type: 'text-delta'; readonly delta: string }
144
+ | { readonly type: 'tool-call'; readonly toolCall: AIToolCall }
145
+ | { readonly type: 'finish'; readonly result: AIResult<AIChatOutput> }
146
+
147
+ export interface AIProviderCapabilities {
148
+ readonly text: boolean
149
+ readonly content: boolean
150
+ readonly chat: boolean
151
+ readonly streaming: boolean
152
+ readonly object: boolean
153
+ readonly embedding: boolean
154
+ readonly tools: boolean
155
+ }
156
+
157
+ /**
158
+ * Provider boundary. Config and credentials are supplied only to the factory;
159
+ * request/result objects never contain secrets.
160
+ */
161
+ export interface AIProvider {
162
+ readonly name: AIProviderName
163
+ readonly capabilities: AIProviderCapabilities
164
+ generateText(request: AIProviderTextRequest): Promise<AIResult<string>>
165
+ generateContent(request: AIProviderContentRequest): Promise<AIResult<string>>
166
+ generateChat(request: AIProviderChatRequest): Promise<AIResult<AIChatOutput>>
167
+ streamChat(request: AIProviderChatRequest): AsyncIterable<AIStreamEvent>
168
+ /** Provider returns decoded JSON; the runtime owns the single schema validation pass. */
169
+ generateObject?(request: AIProviderObjectRequest<unknown>): Promise<AIResult<unknown>>
170
+ embed?(request: AIProviderEmbedRequest): Promise<AIResult<AIEmbeddingOutput>>
171
+ }
172
+
173
+ export interface AIProviderFactoryContext {
174
+ readonly name: AIProviderName
175
+ readonly config: AIProviderConfig
176
+ readonly fetch: typeof globalThis.fetch
177
+ }
178
+
179
+ export interface AIProviderFactory {
180
+ (context: AIProviderFactoryContext): AIProvider | Promise<AIProvider>
181
+ /** Official endpoint accepted without a custom endpoint policy. */
182
+ readonly defaultEndpoint?: string
183
+ }
184
+
185
+ export type AIConfigLoader = (tenantId: number) => unknown | Promise<unknown>
186
+
187
+ export interface AIConfigSource {
188
+ loadConfig: AIConfigLoader
189
+ }
190
+
191
+ export interface AIRetryOptions {
192
+ /** Total attempts including the first. Runtime clamps this to a safe bound. */
193
+ readonly maxAttempts?: number
194
+ readonly baseDelayMs?: number
195
+ readonly maxDelayMs?: number
196
+ }
197
+
198
+ export interface AIRequestOptions {
199
+ readonly tenantId?: number
200
+ readonly lane?: string
201
+ /** Explicit provider + model pair. Prefix inference is intentionally unsupported. */
202
+ readonly model?: AIModelRef
203
+ readonly signal?: AbortSignal
204
+ readonly timeoutMs?: number
205
+ readonly systemInstruction?: string
206
+ readonly maxOutputTokens?: number
207
+ readonly temperature?: number
208
+ readonly topP?: number
209
+ readonly stopSequences?: readonly string[]
210
+ readonly responseFormat?: 'text' | 'json'
211
+ readonly disableThinking?: boolean
212
+ readonly retry?: AIRetryOptions
213
+ /** Secret-free scalar dimensions copied to invocation telemetry. */
214
+ readonly metadata?: Readonly<Record<string, string | number | boolean>>
215
+ }
216
+
217
+ export interface AIChatRequestOptions extends AIRequestOptions {
218
+ readonly tools?: readonly AIToolDefinition[]
219
+ readonly toolChoice?: AIToolChoice
220
+ }
221
+
222
+ export interface AIEmbedRequestOptions extends Pick<
223
+ AIRequestOptions,
224
+ 'tenantId' | 'lane' | 'model' | 'signal' | 'timeoutMs' | 'retry'
225
+ > {
226
+ readonly dimensions?: number
227
+ }
228
+
229
+ export interface AITelemetryEvent {
230
+ readonly operation: AIOperation
231
+ readonly tenantId: number
232
+ readonly provider: AIProviderName
233
+ readonly model: string
234
+ readonly durationMs: number
235
+ readonly attempts: number
236
+ readonly success: boolean
237
+ readonly usage: AIUsage | null
238
+ readonly finishReason?: AIFinishReason
239
+ readonly errorCode?: string
240
+ readonly metadata?: Readonly<Record<string, string | number | boolean>>
241
+ }
242
+
243
+ export type AITelemetrySink = (event: AITelemetryEvent) => void | Promise<void>
244
+
245
+ export interface AIClient {
246
+ generateText(prompt: string, options?: AIRequestOptions): Promise<AIResult<string>>
247
+ generateContent(content: readonly AIContentPart[], options?: AIRequestOptions): Promise<AIResult<string>>
248
+ generateChat(messages: readonly AIMessage[], options?: AIChatRequestOptions): Promise<AIResult<AIChatOutput>>
249
+ streamChat(messages: readonly AIMessage[], options?: AIChatRequestOptions): AsyncIterable<AIStreamEvent>
250
+ generateObject<T>(
251
+ prompt: string,
252
+ schema: AIObjectSchema<T>,
253
+ options?: AIRequestOptions,
254
+ ): Promise<AIResult<T>>
255
+ embed(input: string | readonly string[], options?: AIEmbedRequestOptions): Promise<AIResult<AIEmbeddingOutput>>
256
+ }
257
+
258
+ export type AIMissingTenantPolicy = 'throw' | 'use-default'
259
+
260
+ export interface AIEndpointPolicyInput {
261
+ readonly tenantId: number
262
+ readonly provider: AIProviderName
263
+ readonly endpoint: URL
264
+ readonly defaultEndpoint?: URL
265
+ }
266
+
267
+ export interface AIClientOptions {
268
+ readonly configSource: AIConfigSource | AIConfigLoader
269
+ readonly providers: ReadonlyMap<AIProviderName, AIProviderFactory> | Iterable<readonly [AIProviderName, AIProviderFactory]>
270
+ readonly tenantResolver?: () => number | null | undefined
271
+ readonly missingTenantPolicy?: AIMissingTenantPolicy
272
+ readonly defaultTenantId?: number
273
+ readonly defaultTimeoutMs?: number
274
+ readonly retry?: AIRetryOptions
275
+ readonly telemetry?: AITelemetrySink
276
+ /** Required for a configured endpoint that differs from provider default. */
277
+ readonly validateEndpoint?: (input: AIEndpointPolicyInput) => boolean | Promise<boolean>
278
+ /** HTTP providers use this transport; SDK-backed providers may reject a custom implementation. */
279
+ readonly fetch?: typeof globalThis.fetch
280
+ readonly now?: () => number
281
+ readonly random?: () => number
282
+ }
@@ -0,0 +1,316 @@
1
+ import { AuthError } from './errors.ts'
2
+ import { createSecureAuthToken } from './session.ts'
3
+ import type { OneTimeAuthStore } from './types.ts'
4
+
5
+ const BROWSER_TRANSACTION_PATTERN = /^obtx_v1_[A-Za-z0-9_-]{43}$/
6
+ const BROWSER_EXCHANGE_CODE_PATTERN = /^oex_v1_[A-Za-z0-9_-]{43}$/
7
+ const PROVIDER_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/
8
+ const KEY_PREFIX_PATTERN = /^[A-Za-z0-9:_-]{1,96}$/
9
+ const DEFAULT_TTL_SECONDS = 60
10
+ const MAX_SERIALIZED_BYTES = 2_048
11
+ const MAX_SESSION_GENERATION = 2_147_483_647
12
+ const encoder = new TextEncoder()
13
+
14
+ interface StoredOAuthBrowserExchange extends OAuthBrowserExchangePayload {
15
+ readonly v: 1
16
+ }
17
+
18
+ export interface OAuthBrowserExchangePayload {
19
+ readonly tenantId: number
20
+ readonly browserTransaction: string
21
+ readonly provider: string
22
+ readonly userId: number
23
+ /** Optional user revocation epoch captured before provider authentication. */
24
+ readonly sessionGeneration?: number
25
+ readonly issuedAt: number
26
+ readonly expiresAt: number
27
+ }
28
+
29
+ export interface OAuthBrowserExchangeIssueInput {
30
+ readonly tenantId: number
31
+ readonly browserTransaction: string
32
+ readonly provider: string
33
+ readonly userId: number
34
+ readonly sessionGeneration?: number
35
+ }
36
+
37
+ export interface OAuthBrowserExchangeConsumeInput {
38
+ readonly code: string
39
+ readonly tenantId: number
40
+ readonly browserTransaction?: string
41
+ }
42
+
43
+ export interface OAuthBrowserExchangeManager {
44
+ issue(input: OAuthBrowserExchangeIssueInput): Promise<string>
45
+ consume(input: OAuthBrowserExchangeConsumeInput): Promise<OAuthBrowserExchangePayload>
46
+ }
47
+
48
+ export interface CreateOAuthBrowserExchangeManagerOptions {
49
+ readonly store: OneTimeAuthStore
50
+ readonly ttlSeconds?: number
51
+ readonly keyPrefix?: string
52
+ /** Millisecond clock, primarily for deterministic contract tests. */
53
+ readonly now?: () => number
54
+ /** Must return an `oex_v1_` token with 32 bytes of entropy in production. */
55
+ readonly generateCode?: () => string
56
+ }
57
+
58
+ function invalidInput(): never {
59
+ throw new AuthError({ code: 'invalid_input', status: 400 })
60
+ }
61
+
62
+ function invalidExchange(status = 400): never {
63
+ throw new AuthError({ code: 'invalid_browser_exchange', status })
64
+ }
65
+
66
+ function assertTenantId(value: unknown): asserts value is number {
67
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) invalidInput()
68
+ }
69
+
70
+ function assertUserId(value: unknown): asserts value is number {
71
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) invalidInput()
72
+ }
73
+
74
+ function assertProvider(value: unknown): asserts value is string {
75
+ if (typeof value !== 'string' || !PROVIDER_PATTERN.test(value)) invalidInput()
76
+ }
77
+
78
+ function assertBrowserTransaction(value: unknown): asserts value is string {
79
+ if (typeof value !== 'string' || !BROWSER_TRANSACTION_PATTERN.test(value)) invalidInput()
80
+ }
81
+
82
+ function assertIssueInput(input: OAuthBrowserExchangeIssueInput): void {
83
+ if (!input || typeof input !== 'object' || Array.isArray(input)) invalidInput()
84
+ assertTenantId(input.tenantId)
85
+ assertBrowserTransaction(input.browserTransaction)
86
+ assertProvider(input.provider)
87
+ assertUserId(input.userId)
88
+ if (
89
+ input.sessionGeneration !== undefined
90
+ && (
91
+ !Number.isSafeInteger(input.sessionGeneration)
92
+ || input.sessionGeneration < 0
93
+ || input.sessionGeneration > MAX_SESSION_GENERATION
94
+ )
95
+ ) invalidInput()
96
+ }
97
+
98
+ function assertConsumeEnvelope(input: OAuthBrowserExchangeConsumeInput): void {
99
+ if (!input || typeof input !== 'object' || Array.isArray(input)) invalidExchange()
100
+ if (typeof input.code !== 'string' || !BROWSER_EXCHANGE_CODE_PATTERN.test(input.code)) {
101
+ invalidExchange()
102
+ }
103
+ // A malformed tenant is still caller input, while a valid but different
104
+ // tenant is a consumed transaction mismatch checked after the atomic take.
105
+ assertTenantId(input.tenantId)
106
+ }
107
+
108
+ function base64Url(bytes: Uint8Array): string {
109
+ let binary = ''
110
+ for (const byte of bytes) binary += String.fromCharCode(byte)
111
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, '')
112
+ }
113
+
114
+ async function storageKey(code: string, keyPrefix: string): Promise<string> {
115
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(code))
116
+ return `${keyPrefix}${base64Url(new Uint8Array(digest))}`
117
+ }
118
+
119
+ function parseStoredPayload(serialized: string, expectedTtlMs: number): StoredOAuthBrowserExchange {
120
+ if (encoder.encode(serialized).byteLength > MAX_SERIALIZED_BYTES) invalidExchange()
121
+
122
+ let parsed: unknown
123
+ try {
124
+ parsed = JSON.parse(serialized)
125
+ } catch {
126
+ invalidExchange()
127
+ }
128
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) invalidExchange()
129
+
130
+ const value = parsed as Record<string, unknown>
131
+ const allowedKeys = new Set([
132
+ 'v',
133
+ 'tenantId',
134
+ 'browserTransaction',
135
+ 'provider',
136
+ 'userId',
137
+ 'sessionGeneration',
138
+ 'issuedAt',
139
+ 'expiresAt',
140
+ ])
141
+ if (Object.keys(value).some(key => !allowedKeys.has(key))) invalidExchange()
142
+
143
+ try {
144
+ if (value.v !== 1) invalidInput()
145
+ assertTenantId(value.tenantId)
146
+ assertBrowserTransaction(value.browserTransaction)
147
+ assertProvider(value.provider)
148
+ assertUserId(value.userId)
149
+ if (
150
+ value.sessionGeneration !== undefined
151
+ && (
152
+ typeof value.sessionGeneration !== 'number'
153
+ || !Number.isSafeInteger(value.sessionGeneration)
154
+ || value.sessionGeneration < 0
155
+ || value.sessionGeneration > MAX_SESSION_GENERATION
156
+ )
157
+ ) invalidInput()
158
+ if (
159
+ typeof value.issuedAt !== 'number'
160
+ || !Number.isSafeInteger(value.issuedAt)
161
+ || value.issuedAt < 0
162
+ || typeof value.expiresAt !== 'number'
163
+ || !Number.isSafeInteger(value.expiresAt)
164
+ || value.expiresAt <= value.issuedAt
165
+ || value.expiresAt - value.issuedAt !== expectedTtlMs
166
+ ) invalidInput()
167
+ } catch {
168
+ invalidExchange()
169
+ }
170
+
171
+ return value as unknown as StoredOAuthBrowserExchange
172
+ }
173
+
174
+ /**
175
+ * Issue and atomically consume a short browser hand-off code after an OAuth
176
+ * provider callback. The store key contains only SHA-256(code); the adapter's
177
+ * `take` implementation must be one atomic GETDEL or one Lua command.
178
+ */
179
+ export function createOAuthBrowserExchangeManager(
180
+ options: CreateOAuthBrowserExchangeManagerOptions,
181
+ ): OAuthBrowserExchangeManager {
182
+ if (
183
+ !options
184
+ || typeof options !== 'object'
185
+ || !options.store
186
+ || typeof options.store.put !== 'function'
187
+ || typeof options.store.take !== 'function'
188
+ ) invalidInput()
189
+
190
+ const ttlSeconds = options.ttlSeconds ?? DEFAULT_TTL_SECONDS
191
+ const keyPrefix = options.keyPrefix ?? 'auth:oauth-browser-exchange:v1:'
192
+ const now = options.now ?? Date.now
193
+ const generateCode = options.generateCode ?? (() => `oex_v1_${createSecureAuthToken()}`)
194
+ if (
195
+ !Number.isSafeInteger(ttlSeconds)
196
+ || ttlSeconds < 30
197
+ || ttlSeconds > 300
198
+ || typeof keyPrefix !== 'string'
199
+ || !KEY_PREFIX_PATTERN.test(keyPrefix)
200
+ || typeof now !== 'function'
201
+ || typeof generateCode !== 'function'
202
+ ) invalidInput()
203
+
204
+ // Capture the methods, not just the object. Mutating a caller-owned adapter
205
+ // after construction cannot replace atomic take with a weaker operation.
206
+ const put = options.store.put.bind(options.store)
207
+ const take = options.store.take.bind(options.store)
208
+ const snapshot = Object.freeze({
209
+ put,
210
+ take,
211
+ ttlSeconds,
212
+ keyPrefix,
213
+ now,
214
+ generateCode,
215
+ })
216
+
217
+ return Object.freeze({
218
+ async issue(input: OAuthBrowserExchangeIssueInput): Promise<string> {
219
+ assertIssueInput(input)
220
+ let code: string
221
+ let issuedAt: number
222
+ try {
223
+ code = snapshot.generateCode()
224
+ issuedAt = snapshot.now()
225
+ } catch {
226
+ throw new AuthError({ code: 'invalid_browser_exchange', status: 500 })
227
+ }
228
+ const expiresAt = issuedAt + snapshot.ttlSeconds * 1_000
229
+ if (
230
+ typeof code !== 'string'
231
+ || !BROWSER_EXCHANGE_CODE_PATTERN.test(code)
232
+ || !Number.isSafeInteger(issuedAt)
233
+ || issuedAt < 0
234
+ || !Number.isSafeInteger(expiresAt)
235
+ ) invalidExchange(500)
236
+
237
+ const payload: StoredOAuthBrowserExchange = {
238
+ v: 1,
239
+ tenantId: input.tenantId,
240
+ browserTransaction: input.browserTransaction,
241
+ provider: input.provider,
242
+ userId: input.userId,
243
+ ...(input.sessionGeneration === undefined
244
+ ? {}
245
+ : { sessionGeneration: input.sessionGeneration }),
246
+ issuedAt,
247
+ expiresAt,
248
+ }
249
+ try {
250
+ await snapshot.put(
251
+ await storageKey(code, snapshot.keyPrefix),
252
+ JSON.stringify(payload),
253
+ snapshot.ttlSeconds,
254
+ )
255
+ } catch {
256
+ throw new AuthError({
257
+ code: 'auth_store_unavailable',
258
+ status: 503,
259
+ retryable: true,
260
+ })
261
+ }
262
+ return code
263
+ },
264
+
265
+ async consume(input: OAuthBrowserExchangeConsumeInput): Promise<OAuthBrowserExchangePayload> {
266
+ assertConsumeEnvelope(input)
267
+ let serialized: string | null
268
+ try {
269
+ // Take happens before browser/tenant comparison so a stolen or
270
+ // mis-bound code is burned and cannot be retried by another browser.
271
+ serialized = await snapshot.take(await storageKey(input.code, snapshot.keyPrefix))
272
+ } catch {
273
+ throw new AuthError({
274
+ code: 'auth_store_unavailable',
275
+ status: 503,
276
+ retryable: true,
277
+ })
278
+ }
279
+ if (serialized === null) invalidExchange()
280
+
281
+ const payload = parseStoredPayload(serialized, snapshot.ttlSeconds * 1_000)
282
+ let currentTime: number
283
+ try {
284
+ currentTime = snapshot.now()
285
+ } catch {
286
+ throw new AuthError({ code: 'invalid_browser_exchange', status: 500 })
287
+ }
288
+ if (!Number.isSafeInteger(currentTime) || currentTime < 0 || payload.issuedAt > currentTime) {
289
+ invalidExchange()
290
+ }
291
+ if (payload.expiresAt <= currentTime) {
292
+ throw new AuthError({ code: 'browser_exchange_expired', status: 400 })
293
+ }
294
+ if (
295
+ payload.tenantId !== input.tenantId
296
+ || typeof input.browserTransaction !== 'string'
297
+ || !BROWSER_TRANSACTION_PATTERN.test(input.browserTransaction)
298
+ || payload.browserTransaction !== input.browserTransaction
299
+ ) {
300
+ throw new AuthError({ code: 'browser_exchange_mismatch', status: 400 })
301
+ }
302
+
303
+ return Object.freeze({
304
+ tenantId: payload.tenantId,
305
+ browserTransaction: payload.browserTransaction,
306
+ provider: payload.provider,
307
+ userId: payload.userId,
308
+ ...(payload.sessionGeneration === undefined
309
+ ? {}
310
+ : { sessionGeneration: payload.sessionGeneration }),
311
+ issuedAt: payload.issuedAt,
312
+ expiresAt: payload.expiresAt,
313
+ })
314
+ },
315
+ })
316
+ }
@@ -0,0 +1,57 @@
1
+ export type AuthErrorCode =
2
+ | 'invalid_input'
3
+ | 'login_not_allowed'
4
+ | 'invalid_oauth_state'
5
+ | 'oauth_state_expired'
6
+ | 'oauth_state_mismatch'
7
+ | 'invalid_browser_exchange'
8
+ | 'browser_exchange_expired'
9
+ | 'browser_exchange_mismatch'
10
+ | 'invalid_passkey_challenge'
11
+ | 'auth_store_unavailable'
12
+ | 'invalid_session_token'
13
+ | 'invalid_session_handle'
14
+
15
+ const PUBLIC_MESSAGES: Readonly<Record<AuthErrorCode, string>> = Object.freeze({
16
+ invalid_input: 'Authentication input is invalid',
17
+ login_not_allowed: 'This login method is not allowed',
18
+ invalid_oauth_state: 'OAuth transaction is invalid or has already been used',
19
+ oauth_state_expired: 'OAuth transaction has expired',
20
+ oauth_state_mismatch: 'OAuth transaction does not match this request',
21
+ invalid_browser_exchange: 'OAuth browser exchange is invalid or has already been used',
22
+ browser_exchange_expired: 'OAuth browser exchange has expired',
23
+ browser_exchange_mismatch: 'OAuth browser exchange does not match this browser',
24
+ invalid_passkey_challenge: 'Passkey challenge is invalid or has already been used',
25
+ auth_store_unavailable: 'Authentication transaction store is unavailable',
26
+ invalid_session_token: 'Session token format is invalid',
27
+ invalid_session_handle: 'Session management handle format is invalid',
28
+ })
29
+
30
+ export interface AuthErrorOptions {
31
+ readonly code: AuthErrorCode
32
+ readonly status?: number
33
+ readonly retryable?: boolean
34
+ /** Accepted so callers can classify failures, but deliberately never retained. */
35
+ readonly cause?: unknown
36
+ /** Accepted for compatibility with adapters, but deliberately never retained. */
37
+ readonly secrets?: readonly string[]
38
+ }
39
+
40
+ /** A stable error envelope that cannot retain a raw cause, token, or secret. */
41
+ export class AuthError extends Error {
42
+ readonly code: AuthErrorCode
43
+ readonly status?: number
44
+ readonly retryable: boolean
45
+
46
+ constructor(options: AuthErrorOptions) {
47
+ super(PUBLIC_MESSAGES[options.code])
48
+ this.name = 'AuthError'
49
+ this.code = options.code
50
+ this.status = options.status
51
+ this.retryable = options.retryable ?? false
52
+ }
53
+ }
54
+
55
+ export function isAuthError(error: unknown): error is AuthError {
56
+ return error instanceof AuthError
57
+ }
@@ -0,0 +1,29 @@
1
+ export { AuthError, isAuthError } from './errors.ts'
2
+ export type * from './errors.ts'
3
+ export { createLoginPolicy } from './login-policy.ts'
4
+ export type * from './login-policy.ts'
5
+ export { createOAuthStateManager } from './oauth-state.ts'
6
+ export type * from './oauth-state.ts'
7
+ export { createOAuthBrowserExchangeManager } from './browser-exchange.ts'
8
+ export type * from './browser-exchange.ts'
9
+ export { redactAuthSecrets, redactAuthStorageKey } from './redaction.ts'
10
+ export {
11
+ PASSKEY_CHALLENGE_TTL_SECONDS,
12
+ PASSKEY_RECENT_AUTH_MAX_AGE_MS,
13
+ PasskeyAuthError,
14
+ commitPasskeyCounter,
15
+ createPasskeyChallengeManager,
16
+ createPasskeyRateLimiter,
17
+ createPasskeyUserHandle,
18
+ recentPasskeyAuthenticationAt,
19
+ } from './passkey.ts'
20
+ export type * from './passkey.ts'
21
+ export {
22
+ createSecureAuthToken,
23
+ createSessionBearerToken,
24
+ createSessionManagementHandle,
25
+ isSessionBearerToken,
26
+ matchesSessionManagementHandle,
27
+ remainingSessionTtl,
28
+ } from './session.ts'
29
+ export type * from './types.ts'