@monotykamary/dsh-authorization 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/credentials/authorization/README.md
5
+ README.md: e004bf871b6fe88e087076c9290ff89c8b89396a
6
+ README.zh.md: 0f14ffd52dc3f31ab2109de020dee7623b108323
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # dsh-authorization
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Authorization Service Definition (`ctx.authorization`). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol.
6
+
7
+ **A flow is a plugin's knowledge of how to get its own credential.** It is registered under the [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam.
8
+
9
+ **The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice.
10
+
11
+ **The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to.
12
+
13
+ ## Surface
14
+
15
+ ```ts
16
+ import type { Context } from '@monotykamary/cordis'
17
+ import { AuthorizationDeclinedError, type AuthorizationSession } from '@monotykamary/dsh-authorization'
18
+ import { credentialKey } from '@monotykamary/dsh-credentials'
19
+
20
+ declare const ctx: Context
21
+ declare const exchange: (signal: AbortSignal) => Promise<void>
22
+
23
+ const key = credentialKey('llm-pi-ai', 'openai-codex')
24
+
25
+ const dispose = ctx.authorization.registerFlow({
26
+ key,
27
+ label: 'ChatGPT (Codex)',
28
+ methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
29
+ async run(session: AuthorizationSession) {
30
+ session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
31
+ const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
32
+ // Commits the record through ctx.credentials before resolving.
33
+ await exchange(session.signal)
34
+ void code
35
+ },
36
+ })
37
+
38
+ ctx.authorization.list() // [{ key, label, methods, inFlight }]
39
+ ctx.authorization.describe(key) // the same entry, or undefined
40
+ await ctx.authorization.begin({ // { status: 'authorized' | 'cancelled' }
41
+ key,
42
+ interaction: { notify: () => {}, prompt: () => Promise.reject(new AuthorizationDeclinedError()) },
43
+ })
44
+ ctx.authorization.cancel(key) // withdraw whatever is running for the key
45
+ dispose()
46
+ ```
47
+
48
+ One attempt per key at a time. A second caller is refused with `ALREADY_IN_FLIGHT` rather than joined, because the two would be prompting different humans through one flow and the second would be answering questions the first was asked. `inFlight` is on the entry so a surface renders the button disabled instead of discovering this by error.
49
+
50
+ `cancel(key)` exists beside the request's own signal because a request/response transport answers a Cancel button on a second call, holding no handle on the first one's signal. A flow whose registration is disposed mid-attempt is withdrawn the same way: its runner belongs to a plugin that is going away.
51
+
52
+ An attempt whose caller has already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key. Validation still runs first, so a caller naming a key or method that does not exist hears about it whether or not it also gave up.
53
+
54
+ A human's "no" is an outcome, not a breakage. An interaction that declines rejects its prompt with `AuthorizationDeclinedError`, and an attempt that fails after a declined prompt settles as `cancelled`, exactly as a withdrawn signal does; any other prompt rejection stays a flow failure that reaches the caller. A notice is fire-and-forget on the same principle, held at the seam: a surface that cannot render one loses the notice, never the attempt.
55
+
56
+ `authorization/settled (key, settlement)` fires after the key is released, for every terminal outcome. `settlement` adds `failed` to the two statuses `begin()` can return: a failure reaches its own caller as a thrown error, so the event stream is the only place a watcher that did not start the attempt can tell a refusal from a breakage. Listener failures are contained: every listener runs, a throw or rejection is logged without changing the finished attempt's outcome, and only an `INVARIANT`-coded failure rethrows after the rest ran.
57
+
58
+ ## The interaction vocabulary
59
+
60
+ A notice is one-way and never carries a secret: a message, optionally the page the human must open and the code they must enter there. A prompt is a question the flow cannot answer — `text`, `secret`, or `select` — and `secret` differs from `text` only in presentation. A prompt carries its own `signal` so a flow that races a typed code against a browser callback can withdraw the losing question while the attempt continues; the request's signal withdraws the whole attempt instead.
61
+
62
+ The vocabulary is deliberately smaller than any one provider's: it describes what a surface must render, so a surface that renders one flow renders all of them.
63
+
64
+ ## Model Experience
65
+
66
+ None, as authorization is a configuration-time conversation with a human and no flow, notice, or prompt reaches a model request.
67
+
68
+ #### KV Cache effect
69
+
70
+ No invalidation; no authorization state enters a request prefix.
71
+
72
+ ## Known Limitations and Deferred Work
73
+
74
+ - **No flow is resumable** — an attempt lives in the process that started it, so a browser reload during a login abandons it and the human starts over. Durable attempts need a store this seam does not have.
75
+ - **Nothing revokes** — signing out is `ctx.credentials.deleteRecord(key)`, which forgets the local record without telling the issuer. A provider that needs a server-side revoke has no place to declare it yet.
76
+ - **A key with no flow is inert** — the seam reports what is registered, so a record left by an uninstalled plugin can be deleted but not re-authorized. Recognizing that orphan is the caller's join, as it is for [`listRecords()`](../credentials/README.md#surface).
package/README.zh.md ADDED
@@ -0,0 +1,76 @@
1
+ # dsh-authorization
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 授权 Service Definition(`ctx.authorization`)。有些凭据无法配置,只能获取:拿到它意味着与人对话——打开这个页面、粘贴那个码、选一个账号。本 seam 拥有这段对话及其生命周期,但从不拥有协议本身。
6
+
7
+ **flow 是某个插件"如何取得自己那份凭据"的知识。** 它以自己写入的 [`CredentialKey`](../credentials/README.zh.md#two-key-spaces-two-questions) 注册,因此 flow 声明了自己产出哪条记录,并通过该键的 scope 声明由哪个插件为记录内部的格式负责。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam。
8
+
9
+ **写入由 flow 拥有。** `run()` 返回即表示记录已经通过 `ctx.credentials` 提交;seam 核实的是它在本次尝试期间观察到的提交——只看记录存在与否,会让重新授权把陈旧记录冒充成新鲜的——并拒绝那些返回时没提交记录的 flow。让提交发生在 flow 内部,才能使一个通过自有 store 适配器持久化的库保持为唯一写入方,而不是把凭据复制出来再写第二遍。
10
+
11
+ **交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个界面,无头调用方则传入一个直接拒绝的交互实现。这样既不存在"环境提供方缺席"的问题,也不会出现某个提示该归两个已打开页面中哪一个的疑问。
12
+
13
+ ## 接口
14
+
15
+ ```ts
16
+ import type { Context } from '@monotykamary/cordis'
17
+ import { AuthorizationDeclinedError, type AuthorizationSession } from '@monotykamary/dsh-authorization'
18
+ import { credentialKey } from '@monotykamary/dsh-credentials'
19
+
20
+ declare const ctx: Context
21
+ declare const exchange: (signal: AbortSignal) => Promise<void>
22
+
23
+ const key = credentialKey('llm-pi-ai', 'openai-codex')
24
+
25
+ const dispose = ctx.authorization.registerFlow({
26
+ key,
27
+ label: 'ChatGPT (Codex)',
28
+ methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
29
+ async run(session: AuthorizationSession) {
30
+ session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
31
+ const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
32
+ // Commits the record through ctx.credentials before resolving.
33
+ await exchange(session.signal)
34
+ void code
35
+ },
36
+ })
37
+
38
+ ctx.authorization.list() // [{ key, label, methods, inFlight }]
39
+ ctx.authorization.describe(key) // the same entry, or undefined
40
+ await ctx.authorization.begin({ // { status: 'authorized' | 'cancelled' }
41
+ key,
42
+ interaction: { notify: () => {}, prompt: () => Promise.reject(new AuthorizationDeclinedError()) },
43
+ })
44
+ ctx.authorization.cancel(key) // withdraw whatever is running for the key
45
+ dispose()
46
+ ```
47
+
48
+ 同一个键同时只允许一次尝试。第二个调用方会收到 `ALREADY_IN_FLIGHT` 拒绝而不是被并入:否则两者会通过同一个 flow 向不同的人发问,而第二个人回答的是问给第一个人的问题。`inFlight` 放在 entry 上,界面据此把按钮渲染为禁用,而不是靠报错才发现。
49
+
50
+ `cancel(key)` 与请求自带的 signal 并存,是因为请求/响应式传输要用第二次调用来响应"取消"按钮,而它拿不到第一次调用的 signal。注册在尝试进行中被 dispose 的 flow 也以同样方式撤销:它的执行体属于一个正在离开的插件。
51
+
52
+ 调用方在发起前就已撤销的尝试,既不占用该键也不启动 flow——若指望每个 flow 都在首个 await 之前检查自己的 signal,那么没有检查的那个就会占着键一直挂起。校验仍然先执行,因此调用方给出的键或方法不存在时,无论它是否已经放弃都会收到报错。
53
+
54
+ 人的"不"是一种结果,不是故障。选择拒绝的交互实现让 prompt 以 `AuthorizationDeclinedError` 拒绝,在提示被拒之后才失败的尝试以 `cancelled` 结算,与 signal 撤销完全一致;其余任何 prompt 拒绝仍是抵达调用方的 flow 故障。notice 依同一原则即发即忘,并由 seam 兜底:渲染不了 notice 的界面丢掉的是那条 notice,而不是整次尝试。
55
+
56
+ `authorization/settled (key, settlement)` 在键释放之后触发,覆盖每一种终态。`settlement` 在 `begin()` 能返回的两种状态之外增加了 `failed`:失败以抛出的错误抵达其调用方,因此事件流是未发起该尝试的旁观者唯一能区分"被拒绝"与"出故障"的地方。监听器故障被就地遏制:每个监听器都会执行,抛错或拒绝只记录日志、不改变已结束尝试的结果,仅 `INVARIANT` 编码的故障在其余监听器执行完后重抛。
57
+
58
+ ## 交互词汇
59
+
60
+ notice 是单向的,且从不携带机密:一条消息,以及可选的"人需要打开的页面"和"需要在该页面输入的码"。prompt 是 flow 无法自答的问题——`text`、`secret` 或 `select`——其中 `secret` 与 `text` 的差别仅在呈现方式。prompt 自带 `signal`,使得一个让手输码与浏览器回调赛跑的 flow 可以在尝试继续的同时撤下落败的那个问题;撤销整次尝试则用请求的 signal。
61
+
62
+ 这套词汇刻意小于任何单个 provider 的词汇:它描述的是界面必须渲染什么,因此能渲染一个 flow 的界面就能渲染全部 flow。
63
+
64
+ ## Model Experience
65
+
66
+ 无,因为授权是配置期与人的对话,flow、notice 与 prompt 都不会抵达模型请求。
67
+
68
+ #### KV Cache effect
69
+
70
+ 不失效;任何授权状态都不会进入请求前缀。
71
+
72
+ ## Known Limitations and Deferred Work
73
+
74
+ - **flow 不可恢复** —— 一次尝试只存活于发起它的进程中,因此登录途中刷新浏览器会丢弃它,人需要重来。可持久的尝试需要一个本 seam 并不具备的存储。
75
+ - **没有吊销** —— 登出即 `ctx.credentials.deleteRecord(key)`,它只遗忘本地记录而不通知签发方。需要服务端吊销的 provider 目前无处声明这一点。
76
+ - **没有 flow 的键是惰性的** —— seam 只报告已注册的内容,因此被卸载插件遗留的记录可以删除但无法重新授权。识别这种孤儿记录由调用方自行 join,与 [`listRecords()`](../credentials/README.zh.md#surface) 的情况相同。
package/lib/index.js ADDED
@@ -0,0 +1,248 @@
1
+ import { Service } from "@monotykamary/cordis";
2
+ import { HarnessError } from "@monotykamary/dsh-llm";
3
+ //#region lib/types/index.js
4
+ /**
5
+ * Service Definition for the authorization capability seam (`ctx.authorization`):
6
+ * obtaining a credential nobody can supply from configuration alone, because
7
+ * getting it requires a conversation with the human — open this page, paste
8
+ * that code, pick an account.
9
+ *
10
+ * The seam owns the conversation and the lifecycle; it never owns the protocol.
11
+ * A plugin that knows how to obtain its own credential registers a flow keyed
12
+ * by the `CredentialKey` that flow writes, and the flow talks to whatever
13
+ * surface started it through one neutral vocabulary of notices and prompts. So
14
+ * a second authorization protocol arrives as another flow rather than as
15
+ * another seam, and a surface that renders one flow renders all of them.
16
+ *
17
+ * ```ts
18
+ * const dispose = ctx.authorization.registerFlow({
19
+ * key: credentialKey('llm-pi-ai', 'openai-codex'),
20
+ * label: 'ChatGPT (Codex)',
21
+ * methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
22
+ * async run(session) {
23
+ * session.notify({ message: 'Continue in your browser', url })
24
+ * await commitThroughCredentials(await exchange(session.signal))
25
+ * },
26
+ * })
27
+ * ```
28
+ *
29
+ * @module @monotykamary/dsh-authorization
30
+ */
31
+ /** Stable error taxonomy for authorization failures. */
32
+ var AuthorizationError = class extends HarnessError {
33
+ constructor(message, code, options) {
34
+ super(message, code, options);
35
+ this.name = "AuthorizationError";
36
+ }
37
+ };
38
+ /**
39
+ * The rejection an {@link AuthorizationInteraction.prompt} uses to say the
40
+ * human declined — dismissed the question, chose not to answer — rather than
41
+ * that the surface broke. An attempt whose flow fails after a prompt was
42
+ * declined settles as `cancelled`, the same outcome as a withdrawn signal,
43
+ * because the human saying no is a refusal, not a breakage. Only a human's
44
+ * "no" may reject with this class: a prompt withdrawn by its own `signal` (a
45
+ * flow retiring the losing question of a race) must reject with something
46
+ * else, or a later genuine failure would be misread as a decline.
47
+ */
48
+ var AuthorizationDeclinedError = class extends AuthorizationError {
49
+ constructor(message = "the authorization prompt was declined") {
50
+ super(message, "DECLINED");
51
+ this.name = "AuthorizationDeclinedError";
52
+ }
53
+ };
54
+ /**
55
+ * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
56
+ * a time per key.
57
+ */
58
+ var AuthorizationService = class extends Service {
59
+ /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
60
+ static inject = ["credentials"];
61
+ flows = /* @__PURE__ */ new Map();
62
+ running = /* @__PURE__ */ new Map();
63
+ constructor(ctx) {
64
+ super(ctx, "authorization");
65
+ }
66
+ /**
67
+ * Offer a way to obtain one credential. One flow per key: two plugins
68
+ * claiming the same key would each write a record in their own format, and
69
+ * whichever ran last would leave the other reading a payload it cannot parse.
70
+ *
71
+ * @param flow - the key it writes, its label, its methods, and its runner.
72
+ * @returns Disposer that withdraws this flow.
73
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
74
+ */
75
+ registerFlow(flow) {
76
+ const dispose = this.ctx.effect(function* () {
77
+ if (this.flows.has(flow.key)) throw new AuthorizationError(`an authorization flow for "${flow.key}" is already registered`, "DUPLICATE_FLOW");
78
+ this.flows.set(flow.key, flow);
79
+ yield () => {
80
+ this.flows.delete(flow.key);
81
+ this.running.get(flow.key)?.controller.abort();
82
+ };
83
+ }.bind(this), "authorization.registerFlow()");
84
+ return () => void dispose();
85
+ }
86
+ /**
87
+ * Every registered flow, for a surface listing what can be authorized.
88
+ * @returns one entry per flow, in registration order.
89
+ */
90
+ list() {
91
+ return [...this.flows.values()].map((flow) => this.entry(flow));
92
+ }
93
+ /**
94
+ * One registered flow.
95
+ * @param key - the credential record to ask about.
96
+ * @returns the entry, or undefined when no flow claims that key.
97
+ */
98
+ describe(key) {
99
+ const flow = this.flows.get(key);
100
+ return flow === void 0 ? void 0 : this.entry(flow);
101
+ }
102
+ /** The public view of one registered flow. */
103
+ entry(flow) {
104
+ return {
105
+ key: flow.key,
106
+ label: flow.label,
107
+ methods: flow.methods,
108
+ inFlight: this.running.has(flow.key)
109
+ };
110
+ }
111
+ /**
112
+ * Withdraw the attempt running for a key, if any. Separate from the
113
+ * request's own signal because a request/response transport answers a Cancel
114
+ * button on a second call, with no handle on the first one's signal.
115
+ * @param key - the credential record whose attempt should stop.
116
+ */
117
+ cancel(key) {
118
+ this.running.get(key)?.controller.abort();
119
+ }
120
+ /**
121
+ * Run one attempt to authorize a key, and report how it ended.
122
+ *
123
+ * One attempt per key at a time. A second caller is refused rather than
124
+ * joined: the two would be prompting different humans through the same flow,
125
+ * and the second would answer questions the first was asked.
126
+ *
127
+ * @param request - the key, the method, the surface, and the cancel signal.
128
+ * @returns `authorized` once the flow's record is committed during this
129
+ * attempt and observed, or `cancelled` when the human declined or the
130
+ * caller withdrew.
131
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
132
+ * `UNKNOWN_METHOD` when the named method is not one the flow offers,
133
+ * `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
134
+ * `NOT_COMMITTED` when the flow resolved without committing a record
135
+ * during the attempt.
136
+ */
137
+ async begin(request) {
138
+ const { key } = request;
139
+ const flow = this.flows.get(key);
140
+ if (flow === void 0) throw new AuthorizationError(`no authorization flow is registered for "${key}"`, "NO_FLOW");
141
+ const method = request.method ?? flow.methods[0].id;
142
+ if (!flow.methods.some((candidate) => candidate.id === method)) throw new AuthorizationError(`authorization flow for "${key}" offers no method "${method}"`, "UNKNOWN_METHOD");
143
+ if (this.running.has(key)) throw new AuthorizationError(`an authorization attempt for "${key}" is already running`, "ALREADY_IN_FLIGHT");
144
+ if (request.signal?.aborted === true) return { status: "cancelled" };
145
+ const controller = new AbortController();
146
+ const withdraw = () => {
147
+ controller.abort(request.signal?.reason);
148
+ };
149
+ request.signal?.addEventListener("abort", withdraw, { once: true });
150
+ this.running.set(key, { controller });
151
+ let settlement = "failed";
152
+ try {
153
+ const outcome = await this.attempt(flow, method, controller.signal, request.interaction);
154
+ settlement = outcome.status;
155
+ return outcome;
156
+ } finally {
157
+ request.signal?.removeEventListener("abort", withdraw);
158
+ this.running.delete(key);
159
+ this.settle(key, settlement);
160
+ }
161
+ }
162
+ /**
163
+ * Fan `authorization/settled` out with contained listener failures: every
164
+ * listener runs, and a sync throw or async rejection is logged without
165
+ * changing the finished attempt's own outcome — except `INVARIANT`-coded
166
+ * failures, which rethrow after every listener ran. The attempt is already
167
+ * over and its key released when this fires, so a broken watcher (that
168
+ * second browser tab) can never turn the caller's settled result into a
169
+ * failure of its own.
170
+ */
171
+ settle(key, settlement) {
172
+ let invariantFailure;
173
+ const args = [
174
+ "authorization/settled",
175
+ key,
176
+ settlement
177
+ ];
178
+ for (const listener of this.ctx.events.dispatch("emit", args)) try {
179
+ const returned = listener(key, settlement);
180
+ if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
181
+ this.warnSettledListenerFailure(key, error);
182
+ });
183
+ } catch (error) {
184
+ if (error?.code === "INVARIANT") {
185
+ invariantFailure ??= error;
186
+ continue;
187
+ }
188
+ this.warnSettledListenerFailure(key, error);
189
+ }
190
+ if (invariantFailure !== void 0) throw invariantFailure;
191
+ }
192
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
193
+ warnSettledListenerFailure(key, error) {
194
+ this.ctx.logger.warn("authorization: an authorization/settled listener for \"%s\" failed", key);
195
+ this.ctx.logger.warn(error);
196
+ }
197
+ /** Run the flow, then hold it to its half of the commit contract. */
198
+ async attempt(flow, method, signal, interaction) {
199
+ const withdrawn = new Promise((resolve) => {
200
+ signal.addEventListener("abort", () => {
201
+ resolve("withdrawn");
202
+ }, { once: true });
203
+ });
204
+ const observed = {
205
+ declined: false,
206
+ committed: false
207
+ };
208
+ const unwatch = this.ctx.on("credentials/record-updated", (key) => {
209
+ if (key === flow.key) observed.committed = true;
210
+ });
211
+ try {
212
+ const running = flow.run({
213
+ method,
214
+ signal,
215
+ notify: (notice) => {
216
+ try {
217
+ interaction.notify(notice);
218
+ } catch (error) {
219
+ this.ctx.logger.warn("authorization: the interaction surface failed to render a notice");
220
+ this.ctx.logger.warn(error);
221
+ }
222
+ },
223
+ prompt: (prompt) => interaction.prompt(prompt).catch((error) => {
224
+ if (error instanceof AuthorizationDeclinedError) observed.declined = true;
225
+ throw error;
226
+ })
227
+ });
228
+ try {
229
+ if (await Promise.race([running.then(() => "ran"), withdrawn]) === "withdrawn") {
230
+ running.catch(() => {
231
+ this.ctx.logger.debug("authorization: withdrawn flow failed after the fact");
232
+ });
233
+ return { status: "cancelled" };
234
+ }
235
+ } catch (error) {
236
+ if (signal.aborted || observed.declined) return { status: "cancelled" };
237
+ throw error;
238
+ }
239
+ } finally {
240
+ unwatch();
241
+ }
242
+ if (!observed.committed) throw new AuthorizationError(`authorization flow for "${flow.key}" resolved without committing a credential record in this attempt`, "NOT_COMMITTED");
243
+ if (!(await this.ctx.credentials.describeRecord(flow.key)).configured) throw new AuthorizationError(`authorization flow for "${flow.key}" deleted its credential record instead of committing one`, "NOT_COMMITTED");
244
+ return { status: "authorized" };
245
+ }
246
+ };
247
+ //#endregion
248
+ export { AuthorizationDeclinedError, AuthorizationError, AuthorizationService, AuthorizationService as default };
@@ -0,0 +1,36 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@monotykamary/dsh-authorization`.
4
+ * @module @monotykamary/dsh-authorization/invariant
5
+ */
6
+ const PACKAGE_NAME = "@monotykamary/dsh-authorization";
7
+ /** Cordis companion plugin name. */
8
+ const name = "authorization-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * Install the single-flight release contract: `authorization/settled` names a
13
+ * finished attempt, and the seam admits one attempt per key, so the key must
14
+ * already be free when the event fires. A slot still held at settlement is
15
+ * unrecoverable — every later `begin()` for that key is refused as
16
+ * `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the
17
+ * outside, because a wedged key looks exactly like a busy one.
18
+ */
19
+ const install = (ctx, fail) => {
20
+ ctx.on("authorization/settled", (key) => {
21
+ const authorization = ctx.get("authorization");
22
+ if (authorization === void 0) {
23
+ fail(`authorization/settled for "${key}" emitted without a live authorization service`);
24
+ return;
25
+ }
26
+ if (authorization.describe(key)?.inFlight === true) fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`);
27
+ });
28
+ };
29
+ /**
30
+ * Register this package's invariant companion.
31
+ * @param ctx - Cordis context carrying the invariant service.
32
+ * @returns the installed registration's disposer after setup succeeds.
33
+ */
34
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
35
+ //#endregion
36
+ export { apply, inject, name };
@@ -0,0 +1,223 @@
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 @monotykamary/dsh-authorization
27
+ */
28
+ import { Context, Service } from '@monotykamary/cordis';
29
+ import type { CredentialKey } from '@monotykamary/dsh-credentials';
30
+ import { HarnessError } from '@monotykamary/dsh-llm';
31
+ import type { AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt, AuthorizationSettlement } from './types.ts';
32
+ export type { AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt, AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus, } from './types.ts';
33
+ declare module '@monotykamary/cordis' {
34
+ interface Context {
35
+ authorization: AuthorizationService;
36
+ }
37
+ interface Events {
38
+ /**
39
+ * One authorization attempt has finished and released its key. Fires for
40
+ * every terminal outcome, failures included, so a surface watching a key it
41
+ * did not start (a second browser tab) learns the attempt is over.
42
+ * @mode emit
43
+ * @param key - the credential record the finished attempt was authorizing.
44
+ * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
45
+ */
46
+ 'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void;
47
+ }
48
+ }
49
+ /** Stable error taxonomy for authorization failures. */
50
+ export declare class AuthorizationError extends HarnessError {
51
+ constructor(message: string, code: string, options?: ErrorOptions);
52
+ }
53
+ /**
54
+ * The rejection an {@link AuthorizationInteraction.prompt} uses to say the
55
+ * human declined — dismissed the question, chose not to answer — rather than
56
+ * that the surface broke. An attempt whose flow fails after a prompt was
57
+ * declined settles as `cancelled`, the same outcome as a withdrawn signal,
58
+ * because the human saying no is a refusal, not a breakage. Only a human's
59
+ * "no" may reject with this class: a prompt withdrawn by its own `signal` (a
60
+ * flow retiring the losing question of a race) must reject with something
61
+ * else, or a later genuine failure would be misread as a decline.
62
+ */
63
+ export declare class AuthorizationDeclinedError extends AuthorizationError {
64
+ constructor(message?: string);
65
+ }
66
+ /**
67
+ * What a running flow is given to talk to the human. Every member is scoped to
68
+ * one attempt: the flow neither knows nor chooses which surface is listening.
69
+ */
70
+ export interface AuthorizationSession {
71
+ /** The method id the caller picked, always one this flow declared. */
72
+ readonly method: string;
73
+ /** Aborted when the caller withdraws or `cancel()` is called for this key. */
74
+ readonly signal: AbortSignal;
75
+ /**
76
+ * Report progress, or tell the human what to do next. Fire-and-forget: a
77
+ * surface that cannot render a notice must not stall the flow.
78
+ * @param notice - the message, and any page or code it refers to.
79
+ */
80
+ notify(notice: AuthorizationNotice): void;
81
+ /**
82
+ * Ask the human a question the flow cannot answer for itself.
83
+ * @param prompt - what to ask, and how it should be presented.
84
+ * @returns what the human typed, or the chosen option's id.
85
+ * @throws when the human declines, or the prompt's own signal withdraws it.
86
+ */
87
+ prompt(prompt: AuthorizationPrompt): Promise<string>;
88
+ }
89
+ /**
90
+ * A plugin's knowledge of how to obtain one credential. The flow owns the
91
+ * write: `run()` resolving means the record for `key` is committed through
92
+ * `ctx.credentials` during that run, which the seam confirms — a commit
93
+ * observed within the attempt, still present after it — before reporting
94
+ * success. Committing inside the flow is what lets a library that persists
95
+ * through its own store adapter (pi-ai's `Models.login()`) stay the single
96
+ * writer instead of being copied back out and written twice.
97
+ */
98
+ export interface AuthorizationFlow {
99
+ /** The credential record this flow writes. Its scope names the owning plugin. */
100
+ readonly key: CredentialKey;
101
+ /** User-facing name of what is being authorized. */
102
+ readonly label: string;
103
+ /**
104
+ * The methods offered, most preferred first; a caller naming none gets the
105
+ * first. Typed non-empty because a flow with nothing to run is a flow that
106
+ * cannot be begun, and the type says so at the one place flows are written.
107
+ */
108
+ readonly methods: readonly [AuthorizationMethod, ...AuthorizationMethod[]];
109
+ /**
110
+ * Run one attempt to obtain and commit the credential.
111
+ * @param session - the chosen method, the cancellation signal, and the interaction callbacks.
112
+ * @returns once the record is committed.
113
+ * @throws when the attempt fails or the human declines.
114
+ */
115
+ run(session: AuthorizationSession): Promise<void>;
116
+ }
117
+ /**
118
+ * The surface half of one attempt. Supplied with the request rather than
119
+ * registered, because the caller that starts an authorization is the one that
120
+ * can talk to the human about it: prompts reach exactly the page that asked,
121
+ * and a headless caller supplies an interaction that declines.
122
+ */
123
+ export interface AuthorizationInteraction {
124
+ /**
125
+ * Render a notice from the running flow.
126
+ * @param notice - the message, and any page or code it refers to.
127
+ */
128
+ notify(notice: AuthorizationNotice): void;
129
+ /**
130
+ * Put a question to the human and wait.
131
+ * @param prompt - what to ask, and how it should be presented.
132
+ * @returns the typed text, or the chosen option's id.
133
+ * @throws {AuthorizationDeclinedError} when the human declines; any other
134
+ * rejection reads as the surface failing, not as an answer.
135
+ */
136
+ prompt(prompt: AuthorizationPrompt): Promise<string>;
137
+ }
138
+ /** One request to authorize a key. */
139
+ export interface AuthorizationRequest {
140
+ /** The credential record to authorize; a flow must be registered for it. */
141
+ key: CredentialKey;
142
+ /** Which of the flow's methods to run. Defaults to the flow's first. */
143
+ method?: string;
144
+ /** The surface that will render this attempt's notices and prompts. */
145
+ interaction: AuthorizationInteraction;
146
+ /** Withdraws the whole attempt. */
147
+ signal?: AbortSignal;
148
+ }
149
+ /**
150
+ * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
151
+ * a time per key.
152
+ */
153
+ export declare class AuthorizationService extends Service {
154
+ /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
155
+ static inject: string[];
156
+ private readonly flows;
157
+ private readonly running;
158
+ constructor(ctx: Context);
159
+ /**
160
+ * Offer a way to obtain one credential. One flow per key: two plugins
161
+ * claiming the same key would each write a record in their own format, and
162
+ * whichever ran last would leave the other reading a payload it cannot parse.
163
+ *
164
+ * @param flow - the key it writes, its label, its methods, and its runner.
165
+ * @returns Disposer that withdraws this flow.
166
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
167
+ */
168
+ registerFlow(flow: AuthorizationFlow): () => void;
169
+ /**
170
+ * Every registered flow, for a surface listing what can be authorized.
171
+ * @returns one entry per flow, in registration order.
172
+ */
173
+ list(): readonly AuthorizationEntry[];
174
+ /**
175
+ * One registered flow.
176
+ * @param key - the credential record to ask about.
177
+ * @returns the entry, or undefined when no flow claims that key.
178
+ */
179
+ describe(key: CredentialKey): AuthorizationEntry | undefined;
180
+ /** The public view of one registered flow. */
181
+ private entry;
182
+ /**
183
+ * Withdraw the attempt running for a key, if any. Separate from the
184
+ * request's own signal because a request/response transport answers a Cancel
185
+ * button on a second call, with no handle on the first one's signal.
186
+ * @param key - the credential record whose attempt should stop.
187
+ */
188
+ cancel(key: CredentialKey): void;
189
+ /**
190
+ * Run one attempt to authorize a key, and report how it ended.
191
+ *
192
+ * One attempt per key at a time. A second caller is refused rather than
193
+ * joined: the two would be prompting different humans through the same flow,
194
+ * and the second would answer questions the first was asked.
195
+ *
196
+ * @param request - the key, the method, the surface, and the cancel signal.
197
+ * @returns `authorized` once the flow's record is committed during this
198
+ * attempt and observed, or `cancelled` when the human declined or the
199
+ * caller withdrew.
200
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
201
+ * `UNKNOWN_METHOD` when the named method is not one the flow offers,
202
+ * `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
203
+ * `NOT_COMMITTED` when the flow resolved without committing a record
204
+ * during the attempt.
205
+ */
206
+ begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>;
207
+ /**
208
+ * Fan `authorization/settled` out with contained listener failures: every
209
+ * listener runs, and a sync throw or async rejection is logged without
210
+ * changing the finished attempt's own outcome — except `INVARIANT`-coded
211
+ * failures, which rethrow after every listener ran. The attempt is already
212
+ * over and its key released when this fires, so a broken watcher (that
213
+ * second browser tab) can never turn the caller's settled result into a
214
+ * failure of its own.
215
+ */
216
+ private settle;
217
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
218
+ private warnSettledListenerFailure;
219
+ /** Run the flow, then hold it to its half of the commit contract. */
220
+ private attempt;
221
+ }
222
+ export default AuthorizationService;
223
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,299 @@
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 @monotykamary/dsh-authorization
27
+ */
28
+ import { Service } from '@monotykamary/cordis';
29
+ import { HarnessError } from '@monotykamary/dsh-llm';
30
+ /** Stable error taxonomy for authorization failures. */
31
+ export class AuthorizationError extends HarnessError {
32
+ constructor(message, code, options) {
33
+ super(message, code, options);
34
+ this.name = 'AuthorizationError';
35
+ }
36
+ }
37
+ /**
38
+ * The rejection an {@link AuthorizationInteraction.prompt} uses to say the
39
+ * human declined — dismissed the question, chose not to answer — rather than
40
+ * that the surface broke. An attempt whose flow fails after a prompt was
41
+ * declined settles as `cancelled`, the same outcome as a withdrawn signal,
42
+ * because the human saying no is a refusal, not a breakage. Only a human's
43
+ * "no" may reject with this class: a prompt withdrawn by its own `signal` (a
44
+ * flow retiring the losing question of a race) must reject with something
45
+ * else, or a later genuine failure would be misread as a decline.
46
+ */
47
+ export class AuthorizationDeclinedError extends AuthorizationError {
48
+ constructor(message = 'the authorization prompt was declined') {
49
+ super(message, 'DECLINED');
50
+ this.name = 'AuthorizationDeclinedError';
51
+ }
52
+ }
53
+ /**
54
+ * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
55
+ * a time per key.
56
+ */
57
+ export class AuthorizationService extends Service {
58
+ /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
59
+ static inject = ['credentials'];
60
+ flows = new Map();
61
+ running = new Map();
62
+ constructor(ctx) {
63
+ super(ctx, 'authorization');
64
+ }
65
+ /**
66
+ * Offer a way to obtain one credential. One flow per key: two plugins
67
+ * claiming the same key would each write a record in their own format, and
68
+ * whichever ran last would leave the other reading a payload it cannot parse.
69
+ *
70
+ * @param flow - the key it writes, its label, its methods, and its runner.
71
+ * @returns Disposer that withdraws this flow.
72
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
73
+ */
74
+ registerFlow(flow) {
75
+ const dispose = this.ctx.effect(function* () {
76
+ if (this.flows.has(flow.key)) {
77
+ throw new AuthorizationError(`an authorization flow for "${flow.key}" is already registered`, 'DUPLICATE_FLOW');
78
+ }
79
+ this.flows.set(flow.key, flow);
80
+ yield () => {
81
+ this.flows.delete(flow.key);
82
+ // A flow leaving mid-attempt takes its attempt with it: the runner
83
+ // belongs to a plugin that is going away, so letting it keep prompting
84
+ // would outlive the fiber that can answer for it.
85
+ this.running.get(flow.key)?.controller.abort();
86
+ };
87
+ }.bind(this), 'authorization.registerFlow()');
88
+ return () => void dispose();
89
+ }
90
+ /**
91
+ * Every registered flow, for a surface listing what can be authorized.
92
+ * @returns one entry per flow, in registration order.
93
+ */
94
+ list() {
95
+ return [...this.flows.values()].map(flow => this.entry(flow));
96
+ }
97
+ /**
98
+ * One registered flow.
99
+ * @param key - the credential record to ask about.
100
+ * @returns the entry, or undefined when no flow claims that key.
101
+ */
102
+ describe(key) {
103
+ const flow = this.flows.get(key);
104
+ return flow === undefined ? undefined : this.entry(flow);
105
+ }
106
+ /** The public view of one registered flow. */
107
+ entry(flow) {
108
+ return {
109
+ key: flow.key,
110
+ label: flow.label,
111
+ methods: flow.methods,
112
+ inFlight: this.running.has(flow.key),
113
+ };
114
+ }
115
+ /**
116
+ * Withdraw the attempt running for a key, if any. Separate from the
117
+ * request's own signal because a request/response transport answers a Cancel
118
+ * button on a second call, with no handle on the first one's signal.
119
+ * @param key - the credential record whose attempt should stop.
120
+ */
121
+ cancel(key) {
122
+ this.running.get(key)?.controller.abort();
123
+ }
124
+ /**
125
+ * Run one attempt to authorize a key, and report how it ended.
126
+ *
127
+ * One attempt per key at a time. A second caller is refused rather than
128
+ * joined: the two would be prompting different humans through the same flow,
129
+ * and the second would answer questions the first was asked.
130
+ *
131
+ * @param request - the key, the method, the surface, and the cancel signal.
132
+ * @returns `authorized` once the flow's record is committed during this
133
+ * attempt and observed, or `cancelled` when the human declined or the
134
+ * caller withdrew.
135
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
136
+ * `UNKNOWN_METHOD` when the named method is not one the flow offers,
137
+ * `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
138
+ * `NOT_COMMITTED` when the flow resolved without committing a record
139
+ * during the attempt.
140
+ */
141
+ async begin(request) {
142
+ const { key } = request;
143
+ const flow = this.flows.get(key);
144
+ if (flow === undefined) {
145
+ throw new AuthorizationError(`no authorization flow is registered for "${key}"`, 'NO_FLOW');
146
+ }
147
+ const method = request.method ?? flow.methods[0].id;
148
+ if (!flow.methods.some(candidate => candidate.id === method)) {
149
+ throw new AuthorizationError(`authorization flow for "${key}" offers no method "${method}"`, 'UNKNOWN_METHOD');
150
+ }
151
+ if (this.running.has(key)) {
152
+ throw new AuthorizationError(`an authorization attempt for "${key}" is already running`, 'ALREADY_IN_FLIGHT');
153
+ }
154
+ // Withdrawn before it began: never claim the slot and never run the flow.
155
+ // Handing an aborted signal to `run()` would rely on every flow checking it
156
+ // before its first await, and one that does not would hang holding the key.
157
+ // Validation still runs first, so a caller naming a key or method that does
158
+ // not exist hears about it whether or not it also gave up.
159
+ if (request.signal?.aborted === true)
160
+ return { status: 'cancelled' };
161
+ const controller = new AbortController();
162
+ const withdraw = () => { controller.abort(request.signal?.reason); };
163
+ request.signal?.addEventListener('abort', withdraw, { once: true });
164
+ this.running.set(key, { controller });
165
+ let settlement = 'failed';
166
+ try {
167
+ const outcome = await this.attempt(flow, method, controller.signal, request.interaction);
168
+ settlement = outcome.status;
169
+ return outcome;
170
+ }
171
+ finally {
172
+ request.signal?.removeEventListener('abort', withdraw);
173
+ this.running.delete(key);
174
+ // After the slot is released, so a listener that reacts by starting the
175
+ // next attempt is not refused by the one that just finished.
176
+ this.settle(key, settlement);
177
+ }
178
+ }
179
+ /* jscpd:ignore-start -- deliberate symmetry with the credentials seam's
180
+ commit fan-out (`CredentialProvider`): the contained-dispatch shape is the
181
+ reviewed listener-lifecycle contract, and extracting it would couple the
182
+ two seams' event semantics. */
183
+ /**
184
+ * Fan `authorization/settled` out with contained listener failures: every
185
+ * listener runs, and a sync throw or async rejection is logged without
186
+ * changing the finished attempt's own outcome — except `INVARIANT`-coded
187
+ * failures, which rethrow after every listener ran. The attempt is already
188
+ * over and its key released when this fires, so a broken watcher (that
189
+ * second browser tab) can never turn the caller's settled result into a
190
+ * failure of its own.
191
+ */
192
+ settle(key, settlement) {
193
+ let invariantFailure;
194
+ const args = ['authorization/settled', key, settlement];
195
+ for (const listener of this.ctx.events.dispatch('emit', args)) {
196
+ try {
197
+ const returned = listener(key, settlement);
198
+ if (returned != null && typeof returned.then === 'function') {
199
+ void Promise.resolve(returned).then(undefined, (error) => {
200
+ this.warnSettledListenerFailure(key, error);
201
+ });
202
+ }
203
+ }
204
+ catch (error) {
205
+ if (error?.code === 'INVARIANT') {
206
+ invariantFailure ??= error;
207
+ continue;
208
+ }
209
+ this.warnSettledListenerFailure(key, error);
210
+ }
211
+ }
212
+ if (invariantFailure !== undefined)
213
+ throw invariantFailure;
214
+ }
215
+ /* jscpd:ignore-end */
216
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
217
+ warnSettledListenerFailure(key, error) {
218
+ this.ctx.logger.warn('authorization: an authorization/settled listener for "%s" failed', key);
219
+ this.ctx.logger.warn(error);
220
+ }
221
+ /** Run the flow, then hold it to its half of the commit contract. */
222
+ async attempt(flow, method, signal, interaction) {
223
+ // Withdrawal settles the attempt whether or not the flow reacts to it. A
224
+ // flow is supposed to stop when its signal fires, but one that does not
225
+ // would otherwise hold the key for the life of the process, and a wedged
226
+ // key is indistinguishable from a busy one from the outside. The orphaned
227
+ // run is left to finish on its own; nothing waits on it, and a record it
228
+ // still manages to commit is a record the human did authorize.
229
+ const withdrawn = new Promise((resolve) => {
230
+ // `begin()` returns before claiming the key when its caller has already
231
+ // withdrawn, so this signal cannot already be aborted here.
232
+ signal.addEventListener('abort', () => { resolve('withdrawn'); }, { once: true });
233
+ });
234
+ // What the seam itself witnessed during the run, held as properties
235
+ // because closure writes do not narrow locals across awaits: the prompt
236
+ // wrapper sees a decline first-hand (a flow that rewraps the rejection on
237
+ // its way out cannot hide it), and confirming the commit means confirming
238
+ // it happened *now* — on a re-auth the record already exists, so presence
239
+ // alone would let a flow that wrote nothing report the stale credential
240
+ // as freshly authorized.
241
+ const observed = { declined: false, committed: false };
242
+ const unwatch = this.ctx.on('credentials/record-updated', (key) => {
243
+ if (key === flow.key)
244
+ observed.committed = true;
245
+ });
246
+ try {
247
+ const running = flow.run({
248
+ method,
249
+ signal,
250
+ notify: (notice) => {
251
+ try {
252
+ interaction.notify(notice);
253
+ }
254
+ catch (error) {
255
+ // Fire-and-forget is held at the seam: a surface that cannot
256
+ // render a notice (a page whose connection just closed) loses the
257
+ // notice, never the attempt.
258
+ this.ctx.logger.warn('authorization: the interaction surface failed to render a notice');
259
+ this.ctx.logger.warn(error);
260
+ }
261
+ },
262
+ prompt: prompt => interaction.prompt(prompt).catch((error) => {
263
+ if (error instanceof AuthorizationDeclinedError)
264
+ observed.declined = true;
265
+ throw error;
266
+ }),
267
+ });
268
+ try {
269
+ if (await Promise.race([running.then(() => 'ran'), withdrawn]) === 'withdrawn') {
270
+ // Nothing awaits the orphan any more, so its eventual failure has to be
271
+ // marked handled or it would take down the process.
272
+ void running.catch(() => { this.ctx.logger.debug('authorization: withdrawn flow failed after the fact'); });
273
+ return { status: 'cancelled' };
274
+ }
275
+ }
276
+ catch (error) {
277
+ // A withdrawn attempt and a declined prompt are outcomes, not
278
+ // failures: the human said no, or closed the page. Anything else is
279
+ // the flow failing and belongs to the caller, cause chain intact.
280
+ if (signal.aborted || observed.declined)
281
+ return { status: 'cancelled' };
282
+ throw error;
283
+ }
284
+ }
285
+ finally {
286
+ unwatch();
287
+ }
288
+ if (!observed.committed) {
289
+ throw new AuthorizationError(`authorization flow for "${flow.key}" resolved without committing a credential record in this attempt`, 'NOT_COMMITTED');
290
+ }
291
+ const stored = await this.ctx.credentials.describeRecord(flow.key);
292
+ if (!stored.configured) {
293
+ throw new AuthorizationError(`authorization flow for "${flow.key}" deleted its credential record instead of committing one`, 'NOT_COMMITTED');
294
+ }
295
+ return { status: 'authorized' };
296
+ }
297
+ }
298
+ export default AuthorizationService;
299
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@monotykamary/dsh-authorization`.
3
+ * @module @monotykamary/dsh-authorization/invariant
4
+ */
5
+ import type { Context } from '@monotykamary/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "authorization-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Package-owned invariant companion for `@monotykamary/dsh-authorization`.
3
+ * @module @monotykamary/dsh-authorization/invariant
4
+ */
5
+ const PACKAGE_NAME = '@monotykamary/dsh-authorization';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'authorization-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * Install the single-flight release contract: `authorization/settled` names a
12
+ * finished attempt, and the seam admits one attempt per key, so the key must
13
+ * already be free when the event fires. A slot still held at settlement is
14
+ * unrecoverable — every later `begin()` for that key is refused as
15
+ * `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the
16
+ * outside, because a wedged key looks exactly like a busy one.
17
+ */
18
+ const install = (ctx, fail) => {
19
+ ctx.on('authorization/settled', (key) => {
20
+ const authorization = ctx.get('authorization');
21
+ if (authorization === undefined) {
22
+ fail(`authorization/settled for "${key}" emitted without a live authorization service`);
23
+ return;
24
+ }
25
+ // A flow withdrawn during its own attempt settles with nothing left to
26
+ // describe, which is the disposer's documented behavior rather than a leak.
27
+ if (authorization.describe(key)?.inFlight === true) {
28
+ fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`);
29
+ }
30
+ });
31
+ };
32
+ /**
33
+ * Register this package's invariant companion.
34
+ * @param ctx - Cordis context carrying the invariant service.
35
+ * @returns the installed registration's disposer after setup succeeds.
36
+ */
37
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
38
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Wire-safe authorization types, free of cordis/service imports so browser type
3
+ * chains (apiproxy api → client) can consume them without loading this
4
+ * package's Context augmentation.
5
+ * @module @monotykamary/dsh-authorization/types
6
+ */
7
+ import type { CredentialKey } from '@monotykamary/dsh-credentials/types';
8
+ /** One way a flow can obtain its credential, named by the flow that offers it. */
9
+ export interface AuthorizationMethod {
10
+ /** Flow-owned identifier, echoed back when a caller picks this method. */
11
+ id: string;
12
+ /** User-facing label for a picker. */
13
+ label: string;
14
+ }
15
+ /** A running flow's report to whoever is watching it. Never carries a secret. */
16
+ export interface AuthorizationNotice {
17
+ /** What is happening, or what the human must do next. */
18
+ message: string;
19
+ /** A page the human must open to continue. */
20
+ url?: string;
21
+ /** A short code the human must enter on that page. */
22
+ code?: string;
23
+ }
24
+ /** One choice offered by a `select` prompt. */
25
+ export interface AuthorizationPromptOption {
26
+ /** Value returned when this option is chosen. */
27
+ id: string;
28
+ /** User-facing label. */
29
+ label: string;
30
+ /** Optional extra context rendered by capable surfaces. */
31
+ description?: string;
32
+ }
33
+ /**
34
+ * A question a flow must have answered before it can continue. `secret` differs
35
+ * from `text` only in presentation — a surface masks it and keeps it out of
36
+ * logs — and `select` answers with the chosen option's `id`.
37
+ */
38
+ export type AuthorizationPrompt = {
39
+ /**
40
+ * Withdraws this prompt alone, leaving the flow running. A flow that races a
41
+ * typed code against a browser callback aborts the losing prompt here; the
42
+ * whole authorization is cancelled through the request's signal instead.
43
+ */
44
+ signal?: AbortSignal;
45
+ } & ({
46
+ kind: 'text';
47
+ message: string;
48
+ placeholder?: string;
49
+ } | {
50
+ kind: 'secret';
51
+ message: string;
52
+ placeholder?: string;
53
+ } | {
54
+ kind: 'select';
55
+ message: string;
56
+ options: readonly AuthorizationPromptOption[];
57
+ });
58
+ /** How one authorization attempt ended, as its own caller sees it. */
59
+ export type AuthorizationStatus = 'authorized' | 'cancelled';
60
+ /**
61
+ * How one attempt ended, as an onlooker sees it. A failure reaches its caller
62
+ * as a thrown error rather than an outcome, so `failed` exists only here — on
63
+ * the event stream, where a watcher that did not start the attempt has no
64
+ * other way to tell a refusal from a breakage.
65
+ */
66
+ export type AuthorizationSettlement = AuthorizationStatus | 'failed';
67
+ /** The result of one `begin()` attempt. */
68
+ export interface AuthorizationOutcome {
69
+ /** `authorized` once the record is committed and observed; `cancelled` when the human or caller withdrew. */
70
+ status: AuthorizationStatus;
71
+ }
72
+ /** A registered flow as a surface sees it: what it authorizes and whether it is busy. */
73
+ export interface AuthorizationEntry {
74
+ /** The credential record this flow writes. */
75
+ key: CredentialKey;
76
+ /** User-facing name of what is being authorized. */
77
+ label: string;
78
+ /** The methods this flow offers, most preferred first. */
79
+ methods: readonly AuthorizationMethod[];
80
+ /** Whether an attempt for this key is running right now. */
81
+ inFlight: boolean;
82
+ }
83
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Wire-safe authorization types, free of cordis/service imports so browser type
3
+ * chains (apiproxy api → client) can consume them without loading this
4
+ * package's Context augmentation.
5
+ * @module @monotykamary/dsh-authorization/types
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@monotykamary/dsh-authorization",
3
+ "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
4
+ "version": "0.1.0",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/credentials/authorization"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./types": {
26
+ "types": "./lib/types/types.d.ts",
27
+ "default": "./lib/types/types.js"
28
+ },
29
+ "./src/*": "./src/*",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "lib/index.js",
34
+ "lib/invariant.js",
35
+ "lib/types/**/*.js",
36
+ "lib/types/**/*.d.ts"
37
+ ],
38
+ "license": "MIT",
39
+ "peerDependencies": {
40
+ "@monotykamary/dsh-invariants": "^0.1.0",
41
+ "@monotykamary/dsh-credentials": "^0.1.0",
42
+ "@monotykamary/dsh-llm": "^0.1.0",
43
+ "@monotykamary/cordis": "^4.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "@monotykamary/dsh-credentials": "^0.1.0",
47
+ "@monotykamary/dsh-invariants": "^0.1.0",
48
+ "@monotykamary/dsh-llm": "^0.1.0",
49
+ "@monotykamary/cordis": "^4.0.1"
50
+ }
51
+ }