1
/**2
* Service Definition for the authorization capability seam (`ctx.authorization`):3
* obtaining a credential nobody can supply from configuration alone, because4
* getting it requires a conversation with the human — open this page, paste5
* 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 keyed9
* by the `CredentialKey` that flow writes, and the flow talks to whatever10
* surface started it through one neutral vocabulary of notices and prompts. So11
* a second authorization protocol arrives as another flow rather than as12
* another seam, and a surface that renders one flow renders all of them.13
*14
* ```ts15
* 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-authorization27
*/29
import { Context, Service } from '@deepseek-ai/cordis'30
import type { CredentialKey, CredentialRecord } from '@deepseek-ai/dsh-credentials'31
import { HarnessError } from '@deepseek-ai/dsh-llm'33
import type {34
AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,35
AuthorizationSettlement,36
} from './types.ts'38
export type {39
AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,40
AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus,41
} from './types.ts'43
declare module '@deepseek-ai/cordis' {44
interface Context {45
authorization: AuthorizationService46
}48
interface Events {49
/**50
* One authorization attempt has finished and released its key. Fires for51
* every terminal outcome, failures included, so a surface watching a key it52
* did not start (a second browser tab) learns the attempt is over.53
* @mode emit54
* @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): void58
}59
}61
/** Stable error taxonomy for authorization failures. */62
export class AuthorizationError extends HarnessError {63
constructor(message: string, code: string, options?: ErrorOptions) {64
super(message, code, options)65
this.name = 'AuthorizationError'66
}67
}69
/**70
* The rejection an {@link AuthorizationInteraction.prompt} uses to say the71
* human declined — dismissed the question, chose not to answer — rather than72
* that the surface broke. An attempt whose flow fails after a prompt was73
* 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's75
* "no" may reject with this class: a prompt withdrawn by its own `signal` (a76
* flow retiring the losing question of a race) must reject with something77
* else, or a later genuine failure would be misread as a decline.78
*/79
export class AuthorizationDeclinedError extends AuthorizationError {80
constructor(message = 'the authorization prompt was declined') {81
super(message, 'DECLINED')82
this.name = 'AuthorizationDeclinedError'83
}84
}86
/**87
* What a running flow is given to talk to the human. Every member is scoped to88
* one attempt: the flow neither knows nor chooses which surface is listening.89
*/90
export interface AuthorizationSession {91
/** The method id the caller picked, always one this flow declared. */92
readonly method: string93
/** Aborted when the caller withdraws or `cancel()` is called for this key. */94
readonly signal: AbortSignal95
/**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: a103
* 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): void107
/**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
}116
/**117
* A plugin's knowledge of how to obtain one credential. The flow owns the118
* write: `run()` resolving means the record for `key` is committed through119
* `ctx.credentials` during that run, which the seam confirms — a commit120
* observed within the attempt, still present after it — before reporting121
* success. Committing inside the flow is what lets a library that persists122
* through its own store adapter (pi-ai's `Models.login()`) stay the single123
* writer instead of being copied back out and written twice.124
*/125
export interface AuthorizationFlow {126
/** The credential record this flow writes. Its scope names the owning plugin. */127
readonly key: CredentialKey128
/** User-facing name of what is being authorized. */129
readonly label: string130
/**131
* The methods offered, most preferred first; a caller naming none gets the132
* first. Typed non-empty because a flow with nothing to run is a flow that133
* 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
}145
/**146
* The surface half of one attempt. Supplied with the request rather than147
* registered, because the caller that starts an authorization is the one that148
* 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
*/151
export 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): void157
/**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 other162
* rejection reads as the surface failing, not as an answer.163
*/164
prompt(prompt: AuthorizationPrompt): Promise<string>165
}167
/** One request to authorize a key. */168
export interface AuthorizationRequest {169
/** The credential record to authorize; a flow must be registered for it. */170
key: CredentialKey171
/** Which of the flow's methods to run. Defaults to the flow's first. */172
method?: string173
/** The surface that will render this attempt's notices and prompts. */174
interaction: AuthorizationInteraction175
/** Withdraws the whole attempt. */176
signal?: AbortSignal177
}179
/** One attempt in flight, with the handle that withdraws it. */180
interface InFlight {181
readonly controller: AbortController182
committing: boolean183
}185
/**186
* `ctx.authorization`: a registry of credential-obtaining flows, one attempt at187
* a time per key.188
*/189
export 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']193
private readonly flows = new Map<CredentialKey, AuthorizationFlow>()194
private readonly running = new Map<CredentialKey, InFlight>()196
constructor(ctx: Context) {197
super(ctx, 'authorization')198
}200
/**201
* Offer a way to obtain one credential. One flow per key: two plugins202
* claiming the same key would each write a record in their own format, and203
* 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 runner219
// belongs to a plugin that is going away, so letting it keep prompting220
// 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
}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
}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
}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
}255
/**256
* Withdraw the attempt running for a key, if any. Separate from the257
* request's own signal because a request/response transport answers a Cancel258
* 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
}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 than270
* 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 this275
* attempt and observed, or `cancelled` when the human declined or the276
* 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, or280
* `NOT_COMMITTED` when the flow resolved without committing a record281
* during the attempt.282
*/283
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome> {284
const { key } = request285
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].id290
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 it300
// 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 does302
// 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.status315
return outcome316
} 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 the320
// next attempt is not refused by the one that just finished.321
this.settle(key, settlement)322
}323
}325
/* jscpd:ignore-start -- deliberate symmetry with the credentials seam's326
commit fan-out (`CredentialProvider`): the contained-dispatch shape is the327
reviewed listener-lifecycle contract, and extracting it would couple the328
two seams' event semantics. */329
/**330
* Fan `authorization/settled` out with contained listener failures: every331
* listener runs, and a sync throw or async rejection is logged without332
* changing the finished attempt's own outcome. The attempt is already333
* over and its key released when this fires, so a broken watcher (that334
* second browser tab) can never turn the caller's settled result into a335
* 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 */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
}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. A368
// flow is supposed to stop when its signal fires, but one that does not369
// would otherwise hold the key for the life of the process, and a wedged370
// key is indistinguishable from a busy one from the outside. The orphaned371
// run is left to finish on its own; nothing waits on it, and a record it372
// 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 already375
// 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 properties379
// because closure writes do not narrow locals across awaits: the prompt380
// wrapper sees a decline first-hand (a flow that rewraps the rejection on381
// its way out cannot hide it), and confirming the commit means confirming382
// it happened *now* — on a re-auth the record already exists, so presence383
// alone would let a flow that wrote nothing report the stale credential384
// 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 = true388
})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 = true400
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 cannot407
// render a notice (a page whose connection just closed) loses the408
// 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 = true415
throw error416
}),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 be421
// 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, not427
// failures: the human said no, or closed the page. Anything else is428
// the flow failing and belongs to the caller, cause chain intact.429
if (signal.aborted || observed.declined) return { status: 'cancelled' }430
throw error431
}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
}450
export default AuthorizationService