返回源码地图

packages/credentials/authorization/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 17 / 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Service Definition for the authorization capability seam (`ctx.authorization`):
3 * obtaining a credential nobody can supply from configuration alone, because
4 * getting it requires a conversation with the human — open this page, paste
5 * that code, pick an account.
6 *
7 * The seam owns the conversation and the lifecycle; it never owns the protocol.
8 * A plugin that knows how to obtain its own credential registers a flow keyed
9 * by the `CredentialKey` that flow writes, and the flow talks to whatever
10 * surface started it through one neutral vocabulary of notices and prompts. So
11 * a second authorization protocol arrives as another flow rather than as
12 * another seam, and a surface that renders one flow renders all of them.
13 *
14 * ```ts
15 * const dispose = ctx.authorization.registerFlow({
16 * key: credentialKey('llm-pi-ai', 'openai-codex'),
17 * label: 'ChatGPT (Codex)',
18 * methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
19 * async run(session) {
20 * session.notify({ message: 'Continue in your browser', url })
21 * await commitThroughCredentials(await exchange(session.signal))
22 * },
23 * })
24 * ```
25 *
26 * @module @deepseek-ai/dsh-authorization
27 */
28
29import { Context, Service } from '@deepseek-ai/cordis'
30import type { CredentialKey, CredentialRecord } from '@deepseek-ai/dsh-credentials'
31import { HarnessError } from '@deepseek-ai/dsh-llm'
32
33import type {
34 AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
35 AuthorizationSettlement,
36} from './types.ts'
37
38export type {
39 AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
40 AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus,
41} from './types.ts'
42
43declare module '@deepseek-ai/cordis' {
44 interface Context {
45 authorization: AuthorizationService
46 }
47
48 interface Events {
49 /**
50 * One authorization attempt has finished and released its key. Fires for
51 * every terminal outcome, failures included, so a surface watching a key it
52 * did not start (a second browser tab) learns the attempt is over.
53 * @mode emit
54 * @param key - the credential record the finished attempt was authorizing.
55 * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
56 */
57 'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
58 }
59}
60
61/** Stable error taxonomy for authorization failures. */
62export class AuthorizationError extends HarnessError {
63 constructor(message: string, code: string, options?: ErrorOptions) {
64 super(message, code, options)
65 this.name = 'AuthorizationError'
66 }
67}
68
69/**
70 * The rejection an {@link AuthorizationInteraction.prompt} uses to say the
71 * human declined — dismissed the question, chose not to answer — rather than
72 * that the surface broke. An attempt whose flow fails after a prompt was
73 * declined settles as `cancelled`, the same outcome as a withdrawn signal,
74 * because the human saying no is a refusal, not a breakage. Only a human's
75 * "no" may reject with this class: a prompt withdrawn by its own `signal` (a
76 * flow retiring the losing question of a race) must reject with something
77 * else, or a later genuine failure would be misread as a decline.
78 */
79export class AuthorizationDeclinedError extends AuthorizationError {
80 constructor(message = 'the authorization prompt was declined') {
81 super(message, 'DECLINED')
82 this.name = 'AuthorizationDeclinedError'
83 }
84}
85
86/**
87 * What a running flow is given to talk to the human. Every member is scoped to
88 * one attempt: the flow neither knows nor chooses which surface is listening.
89 */
90export interface AuthorizationSession {
91 /** The method id the caller picked, always one this flow declared. */
92 readonly method: string
93 /** Aborted when the caller withdraws or `cancel()` is called for this key. */
94 readonly signal: AbortSignal
95 /**
96 * Commit a record while rejecting cancelled attempts. Once admitted, cancellation waits for completion.
97 * @param record - credential owned by this flow.
98 * @returns after the credential store commits the record.
99 */
100 commit(record: CredentialRecord): Promise<void>
101 /**
102 * Report progress, or tell the human what to do next. Fire-and-forget: a
103 * surface that cannot render a notice must not stall the flow.
104 * @param notice - the message, and any page or code it refers to.
105 */
106 notify(notice: AuthorizationNotice): void
107 /**
108 * Ask the human a question the flow cannot answer for itself.
109 * @param prompt - what to ask, and how it should be presented.
110 * @returns what the human typed, or the chosen option's id.
111 * @throws when the human declines, or the prompt's own signal withdraws it.
112 */
113 prompt(prompt: AuthorizationPrompt): Promise<string>
114}
115
116/**
117 * A plugin's knowledge of how to obtain one credential. The flow owns the
118 * write: `run()` resolving means the record for `key` is committed through
119 * `ctx.credentials` during that run, which the seam confirms — a commit
120 * observed within the attempt, still present after it — before reporting
121 * success. Committing inside the flow is what lets a library that persists
122 * through its own store adapter (pi-ai's `Models.login()`) stay the single
123 * writer instead of being copied back out and written twice.
124 */
125export interface AuthorizationFlow {
126 /** The credential record this flow writes. Its scope names the owning plugin. */
127 readonly key: CredentialKey
128 /** User-facing name of what is being authorized. */
129 readonly label: string
130 /**
131 * The methods offered, most preferred first; a caller naming none gets the
132 * first. Typed non-empty because a flow with nothing to run is a flow that
133 * cannot be begun, and the type says so at the one place flows are written.
134 */
135 readonly methods: readonly [AuthorizationMethod, ...AuthorizationMethod[]]
136 /**
137 * Run one attempt to obtain and commit the credential.
138 * @param session - the chosen method, the cancellation signal, and the interaction callbacks.
139 * @returns once the record is committed.
140 * @throws when the attempt fails or the human declines.
141 */
142 run(session: AuthorizationSession): Promise<void>
143}
144
145/**
146 * The surface half of one attempt. Supplied with the request rather than
147 * registered, because the caller that starts an authorization is the one that
148 * can talk to the human about it: prompts reach exactly the page that asked,
149 * and a headless caller supplies an interaction that declines.
150 */
151export interface AuthorizationInteraction {
152 /**
153 * Render a notice from the running flow.
154 * @param notice - the message, and any page or code it refers to.
155 */
156 notify(notice: AuthorizationNotice): void
157 /**
158 * Put a question to the human and wait.
159 * @param prompt - what to ask, and how it should be presented.
160 * @returns the typed text, or the chosen option's id.
161 * @throws {AuthorizationDeclinedError} when the human declines; any other
162 * rejection reads as the surface failing, not as an answer.
163 */
164 prompt(prompt: AuthorizationPrompt): Promise<string>
165}
166
167/** One request to authorize a key. */
168export interface AuthorizationRequest {
169 /** The credential record to authorize; a flow must be registered for it. */
170 key: CredentialKey
171 /** Which of the flow's methods to run. Defaults to the flow's first. */
172 method?: string
173 /** The surface that will render this attempt's notices and prompts. */
174 interaction: AuthorizationInteraction
175 /** Withdraws the whole attempt. */
176 signal?: AbortSignal
177}
178
179/** One attempt in flight, with the handle that withdraws it. */
180interface InFlight {
181 readonly controller: AbortController
182 committing: boolean
183}
184
185/**
186 * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
187 * a time per key.
188 */
189export class AuthorizationService extends Service {
190 /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
191 static inject = ['credentials']
192
193 private readonly flows = new Map<CredentialKey, AuthorizationFlow>()
194 private readonly running = new Map<CredentialKey, InFlight>()
195
196 constructor(ctx: Context) {
197 super(ctx, 'authorization')
198 }
199
200 /**
201 * Offer a way to obtain one credential. One flow per key: two plugins
202 * claiming the same key would each write a record in their own format, and
203 * whichever ran last would leave the other reading a payload it cannot parse.
204 *
205 * @param flow - the key it writes, its label, its methods, and its runner.
206 * @returns Disposer that withdraws this flow.
207 * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
208 */
209 registerFlow(flow: AuthorizationFlow): () => void {
210 const dispose = this.ctx.effect(function* (this: AuthorizationService) {
211 if (this.flows.has(flow.key)) {
212 throw new AuthorizationError(
213 `an authorization flow for "${flow.key}" is already registered`, 'DUPLICATE_FLOW')
214 }
215 this.flows.set(flow.key, flow)
216 yield () => {
217 this.flows.delete(flow.key)
218 // A flow leaving mid-attempt takes its attempt with it: the runner
219 // belongs to a plugin that is going away, so letting it keep prompting
220 // would outlive the fiber that can answer for it.
221 this.cancel(flow.key)
222 }
223 }.bind(this), 'authorization.registerFlow()')
224 return () => void dispose()
225 }
226
227 /**
228 * Every registered flow, for a surface listing what can be authorized.
229 * @returns one entry per flow, in registration order.
230 */
231 list(): readonly AuthorizationEntry[] {
232 return [...this.flows.values()].map(flow => this.entry(flow))
233 }
234
235 /**
236 * One registered flow.
237 * @param key - the credential record to ask about.
238 * @returns the entry, or undefined when no flow claims that key.
239 */
240 describe(key: CredentialKey): AuthorizationEntry | undefined {
241 const flow = this.flows.get(key)
242 return flow === undefined ? undefined : this.entry(flow)
243 }
244
245 /** The public view of one registered flow. */
246 private entry(flow: AuthorizationFlow): AuthorizationEntry {
247 return {
248 key: flow.key,
249 label: flow.label,
250 methods: flow.methods,
251 inFlight: this.running.has(flow.key),
252 }
253 }
254
255 /**
256 * Withdraw the attempt running for a key, if any. Separate from the
257 * request's own signal because a request/response transport answers a Cancel
258 * button on a second call, with no handle on the first one's signal.
259 * @param key - the credential record whose attempt should stop.
260 */
261 cancel(key: CredentialKey): void {
262 const running = this.running.get(key)
263 if (running !== undefined && !running.committing) running.controller.abort()
264 }
265
266 /**
267 * Run one attempt to authorize a key, and report how it ended.
268 *
269 * One attempt per key at a time. A second caller is refused rather than
270 * joined: the two would be prompting different humans through the same flow,
271 * and the second would answer questions the first was asked.
272 *
273 * @param request - the key, the method, the surface, and the cancel signal.
274 * @returns `authorized` once the flow's record is committed during this
275 * attempt and observed, or `cancelled` when the human declined or the
276 * caller withdrew.
277 * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
278 * `UNKNOWN_METHOD` when the named method is not one the flow offers,
279 * `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
280 * `NOT_COMMITTED` when the flow resolved without committing a record
281 * during the attempt.
282 */
283 async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome> {
284 const { key } = request
285 const flow = this.flows.get(key)
286 if (flow === undefined) {
287 throw new AuthorizationError(`no authorization flow is registered for "${key}"`, 'NO_FLOW')
288 }
289 const method = request.method ?? flow.methods[0].id
290 if (!flow.methods.some(candidate => candidate.id === method)) {
291 throw new AuthorizationError(
292 `authorization flow for "${key}" offers no method "${method}"`, 'UNKNOWN_METHOD')
293 }
294 if (this.running.has(key)) {
295 throw new AuthorizationError(
296 `an authorization attempt for "${key}" is already running`, 'ALREADY_IN_FLIGHT')
297 }
298 // Withdrawn before it began: never claim the slot and never run the flow.
299 // Handing an aborted signal to `run()` would rely on every flow checking it
300 // before its first await, and one that does not would hang holding the key.
301 // Validation still runs first, so a caller naming a key or method that does
302 // not exist hears about it whether or not it also gave up.
303 if (request.signal?.aborted === true) return { status: 'cancelled' }
304 const controller = new AbortController()
305 const withdraw = (): void => {
306 const running = this.running.get(key)
307 if (running !== undefined && !running.committing) controller.abort(request.signal?.reason)
308 }
309 request.signal?.addEventListener('abort', withdraw, { once: true })
310 this.running.set(key, { controller, committing: false })
311 let settlement: AuthorizationSettlement = 'failed'
312 try {
313 const outcome = await this.attempt(flow, method, controller.signal, request.interaction)
314 settlement = outcome.status
315 return outcome
316 } finally {
317 request.signal?.removeEventListener('abort', withdraw)
318 this.running.delete(key)
319 // After the slot is released, so a listener that reacts by starting the
320 // next attempt is not refused by the one that just finished.
321 this.settle(key, settlement)
322 }
323 }
324
325 /* jscpd:ignore-start -- deliberate symmetry with the credentials seam's
326 commit fan-out (`CredentialProvider`): the contained-dispatch shape is the
327 reviewed listener-lifecycle contract, and extracting it would couple the
328 two seams' event semantics. */
329 /**
330 * Fan `authorization/settled` out with contained listener failures: every
331 * listener runs, and a sync throw or async rejection is logged without
332 * changing the finished attempt's own outcome. The attempt is already
333 * over and its key released when this fires, so a broken watcher (that
334 * second browser tab) can never turn the caller's settled result into a
335 * failure of its own.
336 */
337 private settle(key: CredentialKey, settlement: AuthorizationSettlement): void {
338 const args = ['authorization/settled', key, settlement]
339 for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
340 try {
341 const returned = listener(key, settlement)
342 if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
343 void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
344 this.warnSettledListenerFailure(key, error)
345 })
346 }
347 } catch (error) {
348 this.warnSettledListenerFailure(key, error)
349 }
350 }
351 }
352 /* jscpd:ignore-end */
353
354 /** Contained-listener diagnostic shared by the sync and async failure paths. */
355 private warnSettledListenerFailure(key: CredentialKey, error: unknown): void {
356 this.ctx.logger.warn('authorization: an authorization/settled listener for "%s" failed', key)
357 this.ctx.logger.warn(error)
358 }
359
360 /** Run the flow, then hold it to its half of the commit contract. */
361 private async attempt(
362 flow: AuthorizationFlow,
363 method: string,
364 signal: AbortSignal,
365 interaction: AuthorizationInteraction,
366 ): Promise<AuthorizationOutcome> {
367 // Withdrawal settles the attempt whether or not the flow reacts to it. A
368 // flow is supposed to stop when its signal fires, but one that does not
369 // would otherwise hold the key for the life of the process, and a wedged
370 // key is indistinguishable from a busy one from the outside. The orphaned
371 // run is left to finish on its own; nothing waits on it, and a record it
372 // still manages to commit is a record the human did authorize.
373 const withdrawn = new Promise<'withdrawn'>((resolve) => {
374 // `begin()` returns before claiming the key when its caller has already
375 // withdrawn, so this signal cannot already be aborted here.
376 signal.addEventListener('abort', () => { resolve('withdrawn') }, { once: true })
377 })
378 // What the seam itself witnessed during the run, held as properties
379 // because closure writes do not narrow locals across awaits: the prompt
380 // wrapper sees a decline first-hand (a flow that rewraps the rejection on
381 // its way out cannot hide it), and confirming the commit means confirming
382 // it happened *now* — on a re-auth the record already exists, so presence
383 // alone would let a flow that wrote nothing report the stale credential
384 // as freshly authorized.
385 const observed = { declined: false, committed: false }
386 const unwatch = this.ctx.on('credentials/record-updated', (key: CredentialKey) => {
387 if (key === flow.key) observed.committed = true
388 })
389 try {
390 const running = flow.run({
391 method,
392 signal,
393 commit: async (record) => {
394 signal.throwIfAborted()
395 const attempt = this.running.get(flow.key)
396 if (attempt === undefined || attempt.controller.signal !== signal) {
397 throw new AuthorizationError('authorization attempt is no longer active', 'CANCELLED')
398 }
399 attempt.committing = true
400 await this.ctx.credentials.modifyRecord(flow.key, () => Promise.resolve(record))
401 },
402 notify: (notice) => {
403 try {
404 interaction.notify(notice)
405 } catch (error) {
406 // Fire-and-forget is held at the seam: a surface that cannot
407 // render a notice (a page whose connection just closed) loses the
408 // notice, never the attempt.
409 this.ctx.logger.warn('authorization: the interaction surface failed to render a notice')
410 this.ctx.logger.warn(error)
411 }
412 },
413 prompt: prompt => interaction.prompt(prompt).catch((error: unknown) => {
414 if (error instanceof AuthorizationDeclinedError) observed.declined = true
415 throw error
416 }),
417 })
418 try {
419 if (await Promise.race([running.then(() => 'ran' as const), withdrawn]) === 'withdrawn') {
420 // Nothing awaits the orphan any more, so its eventual failure has to be
421 // marked handled or it would take down the process.
422 void running.catch(() => { this.ctx.logger.debug('authorization: withdrawn flow failed after the fact') })
423 return { status: 'cancelled' }
424 }
425 } catch (error) {
426 // A withdrawn attempt and a declined prompt are outcomes, not
427 // failures: the human said no, or closed the page. Anything else is
428 // the flow failing and belongs to the caller, cause chain intact.
429 if (signal.aborted || observed.declined) return { status: 'cancelled' }
430 throw error
431 }
432 } finally {
433 unwatch()
434 }
435 if (!observed.committed) {
436 throw new AuthorizationError(
437 `authorization flow for "${flow.key}" resolved without committing a credential record in this attempt`,
438 'NOT_COMMITTED')
439 }
440 const stored = await this.ctx.credentials.describeRecord(flow.key)
441 if (!stored.configured) {
442 throw new AuthorizationError(
443 `authorization flow for "${flow.key}" deleted its credential record instead of committing one`,
444 'NOT_COMMITTED')
445 }
446 return { status: 'authorized' }
447 }
448}
449
450export default AuthorizationService