index.ts 19 KB

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