@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.
- package/README.md +466 -0
- package/package.json +48 -0
- package/src/ai/client.ts +1059 -0
- package/src/ai/errors.ts +114 -0
- package/src/ai/index.ts +27 -0
- package/src/ai/model-policy.ts +27 -0
- package/src/ai/pricing.ts +158 -0
- package/src/ai/providers/deepseek.ts +61 -0
- package/src/ai/providers/gemini.ts +919 -0
- package/src/ai/providers/openai-compatible.ts +731 -0
- package/src/ai/providers/sse.ts +163 -0
- package/src/ai/registry.ts +65 -0
- package/src/ai/schema.ts +382 -0
- package/src/ai/types.ts +282 -0
- package/src/auth/browser-exchange.ts +316 -0
- package/src/auth/errors.ts +57 -0
- package/src/auth/index.ts +29 -0
- package/src/auth/login-policy.ts +95 -0
- package/src/auth/oauth-state.ts +332 -0
- package/src/auth/passkey.ts +760 -0
- package/src/auth/redaction.ts +142 -0
- package/src/auth/session.ts +106 -0
- package/src/auth/types.ts +72 -0
- package/src/changelog/catalogue.ts +140 -0
- package/src/changelog/index.ts +5 -0
- package/src/changelog/locale.ts +122 -0
- package/src/changelog/service.ts +148 -0
- package/src/changelog/types.ts +122 -0
- package/src/db/bypass-tenant.ts +15 -0
- package/src/db/plugins/tenant-filter.ts +518 -0
- package/src/db/tenant-context.ts +51 -0
- package/src/guide/index.ts +5 -0
- package/src/guide/markdown.ts +108 -0
- package/src/guide/service.ts +345 -0
- package/src/guide/source.ts +116 -0
- package/src/guide/types.ts +177 -0
- package/src/index.ts +1 -0
- package/src/tenant.ts +14 -0
package/src/ai/types.ts
ADDED
|
@@ -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'
|