@buddhilive/dsh-credentials 0.1.2-alpha.3

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/credentials/README.md
5
+ README.md: 30eff7799e664b08224e2d9732b2fcee4867ad98
6
+ README.zh.md: d20050324106cec46e6ae7d7a13b4ece085ab893
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ ---
2
+ description: "The credential seam for users and maintainers resolving, describing, or storing credentials — reference values and durable records — without putting secret values in configuration."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @buddhilive/dsh-credentials
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ `dsh-credentials` keeps secret values out of configuration: you store an API key once and reference it by name (`DEEPSEEK_API_KEY`) from settings or `cordis.yml`, and the product supplies the value when a provider request needs it. Beside those references it also keeps durable credential records — per-plugin entries such as an authorization grant or provider environment values — so a plugin holds what it manages for its own ids across restarts. A rotated key takes effect on the very next request — no restart, no configuration edit. Configuration UIs can tell you whether a key or record is set, where it comes from, and whether you can change it, without ever showing a value. Storing an empty value counts as "no key", so a blank can never masquerade as a configured secret; a record's presence is the whole fact, so an entry carrying no value is a deliberate statement, not a blank.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ This package is the part of the product that stores and looks up secret values: store a key once, reference it by name everywhere, and read, check, or remove it at any time. It also keeps durable credential records, so a plugin can store, update, and remove the credentials it holds for its own ids. The product's default composition already includes a credential store; a custom composition loads the local store package with a file path.
29
+
30
+ ### When to use it
31
+
32
+ Use a credential store whenever configuration must stay free of secret values: settings files that are synced, shared, or rendered in a configuration UI, or teams that rotate keys without editing configuration. Use records when a plugin must keep credentials with no single environment variable — an authorization grant from a sign-in flow, or provider environment values — and when a configuration UI should list what a user is authorized for. A configuration UI can show whether a key or record is set, where it comes from, and whether you can change it — never the value itself. If you only need one fixed environment variable, read that variable directly and skip the store.
33
+
34
+ ### Adding it to your composition
35
+
36
+ Load the local store package with a document path:
37
+
38
+ ```yaml
39
+ - name: '@buddhilive/dsh-credentials-local'
40
+ config:
41
+ path: /absolute/path/to/.credentials.yaml
42
+ ```
43
+
44
+ The local store README owns the full configuration surface; the generated [configuration catalog](../../../docs/config-catalog.md#buddhilivedsh-credentials-local) is the exhaustive field list.
45
+
46
+ ### Storing, checking, and removing keys
47
+
48
+ ```ts
49
+ import type { Context } from '@deepseek-ai/cordis'
50
+ import { credentialRef } from '@buddhilive/dsh-credentials'
51
+
52
+ declare const ctx: Context
53
+
54
+ const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
55
+ const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
56
+ const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
57
+ await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
58
+ await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
59
+ ```
60
+
61
+ Store a key with `set`, remove it with `unset`, check its status with `describe`, and read the current value with `resolve` when an operation needs it. `describe` reports whether the key is set, where it comes from, and whether you can write to it — it never returns the value.
62
+
63
+ ### Storing, updating, and removing records
64
+
65
+ A plugin addresses each record by `<scope>/<id>` — its own registered name plus an id it chooses, such as a provider route key — and reads, modifies, or removes what it holds:
66
+
67
+ ```ts
68
+ import type { Context } from '@deepseek-ai/cordis'
69
+ import { credentialKey } from '@buddhilive/dsh-credentials'
70
+
71
+ declare const ctx: Context
72
+
73
+ const key = credentialKey('llm-pi-ai', 'openai-codex') // <owner>/<id>, branded
74
+ const hit = await ctx.credentials.readRecord(key) // CredentialRecord | undefined
75
+ await ctx.credentials.describeRecord(key) // { configured, kind?, writable } — never the value
76
+ await ctx.credentials.listRecords() // [{ key, kind }] — never values
77
+ await ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { token: '…' } }))
78
+ await ctx.credentials.deleteRecord(key) // no-op when absent
79
+ ```
80
+
81
+ `modifyRecord` is the only write path: it hands your mutation the record as it stands at the moment the write is exclusive, and returning `undefined` leaves the entry untouched. Records have no empty-value rule — a record carrying neither a key nor environment values states that its owner confirmed ambient authentication — and a configuration UI can enumerate every record to show what you are authorized for and find records a removed plugin left behind.
82
+
83
+ ### Using a key in configuration
84
+
85
+ A settings section or `cordis.yml` entry names a key instead of containing it — an LLM adapter, for example, takes `apiKeyEnv`:
86
+
87
+ ```yaml
88
+ apiKeyEnv: DEEPSEEK_API_KEY
89
+ ```
90
+
91
+ Requests that need the key use its current stored value, so rotating the key takes effect on the very next request — no restart and no configuration edit.
92
+
93
+ ### What can go wrong
94
+
95
+ - **A key the launching environment supplies cannot be overwritten** — `DEEPSEEK_API_KEY=… dsh` (or a CI secret, a container `-e`) wins for this run and is reported read-only; clear the variable in the launching shell before storing a different value.
96
+ - **An empty value cannot be stored** — storing an empty string is refused; remove the key instead.
97
+ - **Key values never appear in configuration UIs or diagnostics** — the UI shows whether a key is set, where it comes from, and whether you can change it; the value itself stays in the store.
98
+
99
+ -----
100
+
101
+ <a id="understand-the-implementation"></a>
102
+ ## Understand the implementation
103
+
104
+ <details>
105
+ <summary>Implementation internals — click to expand</summary>
106
+
107
+ This section explains the design decisions behind the package and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package).
108
+
109
+ ### Design philosophy
110
+
111
+ One doctrine and four consequences:
112
+
113
+ - **Configuration carries references, never secrets.** A settings section or `cordis.yml` entry names a credential; the value behind the reference lives with a provider. The settings document stays safe to sync and to render, `describe()` answers without holding a value, and rotating a secret touches no configuration file.
114
+ - **Consumers resolve per operation.** Resolution is a per-call read with no cross-operation cache; that read is the hot-update mechanism.
115
+ - **An empty stored value is absent.** `resolve` skips it, `describe` reports it unconfigured — a blank can never masquerade as a configured secret.
116
+ - **Records are durable, and presence is the fact.** A record is stored per `<scope>/<id>` and survives restarts; the empty-value rule does not apply, so an `api-key` record carrying neither a key nor environment values is a deliberate statement, not a blank.
117
+ - **Listener failures are contained.** `notifyUpdated` fans `credentials/reference-updated` out so every listener runs; a sync throw or async rejection is logged without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran.
118
+
119
+ ### The credentials/reference-updated event
120
+
121
+ `credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge.
122
+
123
+ `credentials/record-updated (key)` fires after a committed change to a stored record — a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. It stays a separate event because the two key grammars are disjoint: a listener receiving both spaces on one event could not tell which one a subject belongs to.
124
+
125
+ ### Record write and read paths
126
+
127
+ `modifyRecord` is the only record write path because a correct write depends on the current value: a token refresh is read-decide-replace, and the mutation sees the record as it stands at the moment the write is exclusive — returning `undefined` leaves the entry untouched. Exclusion holds across processes where the backing store supports it, which is what stops two processes rotating one refresh token from losing whichever wrote first. Reads mirror the reference half but never layer: nothing can shadow a record, and a `grant` payload is returned exactly as its owner wrote it, because only the owning plugin can interpret it.
128
+
129
+ ### Source map
130
+
131
+ | File | Role |
132
+ |---|---|
133
+ | [`src/index.ts`](src/index.ts) | Service Definition: the `credentialRef`/`credentialKey` brands, `ResolvedCredential`/`CredentialRecordInfo`, the abstract provider over both key spaces, contained fan-out |
134
+ | [`src/types.ts`](src/types.ts) | Client-safe type surface: the `CredentialRef` and `CredentialKey` brands, the stored-record union, the `CredentialInfo` reference view, the `credentials/reference-updated` and `credentials/record-updated` declarations |
135
+ | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: `credentials/reference-updated` only fires while a credentials service is live |
136
+
137
+ ### Client-safe types
138
+
139
+ The `./types` subpath export holds the event declarations together with the `CredentialRef` and `CredentialKey` brands, the stored-record union they name, and the `CredentialInfo` reference view a configuration surface reads, and the package root re-exports them. A consumer outside the Host compilation face therefore reads the very signature the Host emits instead of restating it.
140
+
141
+ ### Lifecycle
142
+
143
+ The service is a Cordis `Service` registered by the provider: disposing the mounting fiber removes `ctx.credentials`. The invariant companion checks that `credentials/reference-updated` never fires without a live service — an emission after disposal means a provider leaked work past its teardown quiescence.
144
+
145
+ </details>
146
+
147
+ -----
148
+
149
+ <a id="further-exploration"></a>
150
+ ## Further Exploration
151
+
152
+ Read these pages when the package-level contract is not enough. They move from the shared subsystem vocabulary to the shipped store and the capability architecture.
153
+
154
+ - [Credentials subsystem reference](../../../docs/subsystems/credentials.md) — `CredentialRef`/`CredentialKey`, per-operation resolution, UI-safe info, provider layers, and the generated cordis surface.
155
+ - [Local credentials store](../credentials-local/README.md) — the default on-machine store: where keys and records live and how the environment layers rank.
156
+ - [Capability seams](../../../docs/capability-seams.md) — the Service Definition / Service Provider / Consumer split this package follows.
157
+
158
+ -----
159
+
160
+ <a id="model-experience"></a>
161
+ ## Model Experience
162
+
163
+ Indirectly, through the consuming adapter, which resolves each credential reference and owns every model-facing use a value authorizes.
164
+
165
+ #### KV Cache effect
166
+
167
+ No direct invalidation; resolved values never enter a request prefix.
168
+
169
+ ## Known Limitations and Deferred Work
170
+
171
+ <a id="known-limitations-and-deferred-work"></a>
172
+
173
+
174
+ These limits define when this package is a poor fit or needs special care. They are current package constraints, not a task backlog.
175
+
176
+ - **References have no enumeration** — the seam answers questions about references it is given; configuration surfaces learn them from settings schemas, so a `list()` over that half has no current consumer. Records do enumerate, because they have no schema to be discovered from.
177
+ - **References are environment-variable-shaped** — one flat POSIX-identifier namespace, because a reference doubles as the environment name it resolves through. Records carry the richer `<owner>/<id>` addressing.
178
+ - **Process-environment changes are invisible** — no notification can fire for a variable changed in the launching shell; a UI only re-reads `describe()` on its own navigation.
179
+ - **A record's owner is its scope, and nothing verifies the scope is mounted** — the seam stores what it is given and reports what it stores; recognizing an orphan is the caller's join between `listRecords()` and whatever registry owns that scope, and the seam has no registry of its own to check against.
180
+
181
+ <a id="dev-note"></a>
182
+ ### Dev Note
183
+
184
+ <details>
185
+ <summary>Working context for maintainers — click to expand</summary>
186
+
187
+ This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and in the package code.
188
+
189
+ The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets. None is shipped, and no current consumer requires one.
190
+
191
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,191 @@
1
+ ---
2
+ description: "面向用户与维护者的凭据 seam:在不把机密值写进配置的前提下解析、描述或存储凭据——引用值与持久化记录。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @buddhilive/dsh-credentials
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ `dsh-credentials` 让机密值留在配置之外:API 密钥只存一次,在 settings 或 `cordis.yml` 中按名引用(`DEEPSEEK_API_KEY`),产品在提供方请求需要时提供该值。在这些引用之外,它还保存持久化的凭据记录——按插件组织的条目,例如授权 grant 或提供方环境值——让插件跨重启持有它为自身 id 管理的凭据。轮换后的密钥会作用于紧随其后的下一次请求——无需重启,无需改配置。配置界面能告诉你某个密钥或记录是否已设置、来自哪里、能否修改,而绝不显示值本身。存储空值等于「没有密钥」,因此空白永远不会伪装成已配置的机密;记录的存在本身就是全部事实,一条不含任何值的条目是有意陈述,而不是空白。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 本包是产品中负责存储与查询机密值的部分:密钥只存一次、处处按名引用,并可在任意时刻读取、检查或移除。它还保存持久化的凭据记录,让插件可以为自身 id 存储、更新或移除它持有的凭据。产品的默认组合已包含凭据存储;自定义组合只需加载本地存储包并给出文件路径。
29
+
30
+ ### 何时使用
31
+
32
+ 只要配置需要与机密值绝缘,就使用凭据存储:需要同步、共享或渲染进配置界面的设置文件,或希望在不改配置的情况下轮换密钥的团队。当插件必须保存没有单一环境变量的凭据——登录流程产生的授权 grant,或提供方环境值——并希望配置界面能列出用户已授权什么时,请使用记录。配置界面能显示某个密钥或记录是否已设置、来自哪里、能否修改——但绝不显示值本身。如果只需要一个固定的环境变量,直接读该变量即可,无需存储。
33
+
34
+ ### 加入你的组合
35
+
36
+ 加载本地存储包并给出文档路径:
37
+
38
+ ```yaml
39
+ - name: '@buddhilive/dsh-credentials-local'
40
+ config:
41
+ path: /absolute/path/to/.credentials.yaml
42
+ ```
43
+
44
+ 本地存储 README 拥有完整配置面;生成的[配置目录](../../../docs/config-catalog.zh.md#buddhilivedsh-credentials-local)是穷尽式字段清单。
45
+
46
+ ### 存储、检查与移除密钥
47
+
48
+ ```ts
49
+ import type { Context } from '@deepseek-ai/cordis'
50
+ import { credentialRef } from '@buddhilive/dsh-credentials'
51
+
52
+ declare const ctx: Context
53
+
54
+ const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
55
+ const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
56
+ const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
57
+ await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
58
+ await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
59
+ ```
60
+
61
+ 用 `set` 存储密钥、用 `unset` 移除、用 `describe` 检查状态、在操作需要时用 `resolve` 读取当前值。`describe` 报告密钥是否已设置、来自哪里、能否写入——它绝不返回值。
62
+
63
+ ### 存储、更新与移除记录
64
+
65
+ 插件按 `<scope>/<id>` 寻址每条记录——自身注册名加一个自选 id,例如提供方路由键——并读取、修改或移除它所持有的内容:
66
+
67
+ ```ts
68
+ import type { Context } from '@deepseek-ai/cordis'
69
+ import { credentialKey } from '@buddhilive/dsh-credentials'
70
+
71
+ declare const ctx: Context
72
+
73
+ const key = credentialKey('llm-pi-ai', 'openai-codex') // <owner>/<id>, branded
74
+ const hit = await ctx.credentials.readRecord(key) // CredentialRecord | undefined
75
+ await ctx.credentials.describeRecord(key) // { configured, kind?, writable } — never the value
76
+ await ctx.credentials.listRecords() // [{ key, kind }] — never values
77
+ await ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { token: '…' } }))
78
+ await ctx.credentials.deleteRecord(key) // no-op when absent
79
+ ```
80
+
81
+ `modifyRecord` 是唯一写路径:它让你的变更函数看到写入取得独占那一刻的记录,返回 `undefined` 则保持原状。记录没有空值规则——一条既无 key 也无环境值的记录,陈述的是其拥有者确认了 ambient 认证——配置界面还可以枚举每条记录,显示你已授权什么,并找出已卸载插件留下的记录。
82
+
83
+ ### 在配置中使用密钥
84
+
85
+ settings 分节或 `cordis.yml` 条目按名引用密钥,而不是包含密钥本身——例如 LLM(大语言模型)适配器接受 `apiKeyEnv`:
86
+
87
+ ```yaml
88
+ apiKeyEnv: DEEPSEEK_API_KEY
89
+ ```
90
+
91
+ 需要该密钥的请求使用它当前存储的值,因此轮换密钥会作用于紧随其后的下一次请求——无需重启,无需改配置。
92
+
93
+ ### 可能出错的地方
94
+
95
+ - **启动环境提供的密钥无法被覆盖**——`DEEPSEEK_API_KEY=… dsh`(或 CI 机密、容器 `-e`)在本轮运行中优先,并被报告为只读;请先在启动 shell 中清除该变量,再存储其他值。
96
+ - **空值无法存储**——存储空字符串会被拒绝;请改为移除密钥。
97
+ - **密钥值绝不会出现在配置界面或诊断信息中**——界面只显示密钥是否已设置、来自哪里、能否修改;值本身留在存储中。
98
+
99
+ -----
100
+
101
+ <a id="understand-the-implementation"></a>
102
+ ## 理解实现
103
+
104
+ <details>
105
+ <summary>实现细节——点击展开</summary>
106
+
107
+ 本节解释本包背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。
108
+
109
+ ### 设计理念
110
+
111
+ 一条准则,四个推论:
112
+
113
+ - **配置只携带引用,绝不携带机密。** settings 分节或 `cordis.yml` 条目点名一个凭据;引用背后的值存放在提供方处。设置文档可以放心同步、放心渲染,`describe()` 无需持有值就能回答,轮换机密不触碰任何配置文件。
114
+ - **消费方按操作解析。** 解析是一次按调用读取,无跨操作缓存;这次读取正是热更新机制。
115
+ - **空的存储值等于不存在。** `resolve` 跳过它,`describe` 报告未配置——空白永远不会伪装成已配置的机密。
116
+ - **记录是持久化的,存在即事实。** 记录按 `<scope>/<id>` 存储并跨重启保留;空值规则不适用,因此一条既无 key 也无环境值的 `api-key` 记录是有意陈述,而不是空白。
117
+ - **监听器失败被包含。** `notifyUpdated` 扇出 `credentials/reference-updated`,保证每个监听器都会运行;同步抛出与异步拒绝都会被记录,不改变已提交操作的结果,`INVARIANT` 编码的失败除外——它们在所有监听器运行完毕后重新抛出。
118
+
119
+ ### credentials/reference-updated 事件
120
+
121
+ `credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
122
+
123
+ `credentials/record-updated (key)` 在存储记录发生已提交变更后触发——一次确实写入的 `modifyRecord`、一次确实移除的 `deleteRecord`,或在存储中观察到的外部编辑。它保持独立事件,因为两个键文法互斥:一个监听器若在同一事件上同时收到两个空间,将无法分辨主体属于哪一边。
124
+
125
+ ### 记录写入与读取路径
126
+
127
+ `modifyRecord` 是唯一写路径,因为正确的写入依赖当前值:刷新 token 是「读—决定—替换」,变更函数看到的是写入取得独占那一刻的记录——返回 `undefined` 则保持原状。独占在支持它的底层存储上跨进程成立,这正是防止两个进程同时轮换一个 refresh token、丢掉先写那一个的机制。读取与引用一侧对称,但绝不分层:没有任何东西能遮蔽记录,`grant` 的 payload 会原样返回给其拥有者,因为只有拥有它的插件能解释它。
128
+
129
+ ### 源码地图
130
+
131
+ | 文件 | 职责 |
132
+ |---|---|
133
+ | [`src/index.ts`](src/index.ts) | Service Definition:`credentialRef`/`credentialKey` 品牌、`ResolvedCredential`/`CredentialRecordInfo`、覆盖两个键空间的抽象提供方、包含式扇出 |
134
+ | [`src/types.ts`](src/types.ts) | 客户端安全类型面:`CredentialRef` 与 `CredentialKey` 品牌、存储记录联合类型、`CredentialInfo` 引用视图、`credentials/reference-updated` 与 `credentials/record-updated` 事件声明 |
135
+ | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:`credentials/reference-updated` 只在凭据服务存活时触发 |
136
+
137
+ ### 客户端安全类型
138
+
139
+ `./types` 子路径出口把事件声明与其点名的 `CredentialRef`、`CredentialKey` 品牌、存储记录联合类型,以及配置界面读取的 `CredentialInfo` 引用视图放在一起,包根继续 re-export 它们。于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
140
+
141
+ ### 生命周期
142
+
143
+ 服务是提供方注册的 Cordis `Service`:释放挂载 fiber 会移除 `ctx.credentials`。不变式伴生插件检查 `credentials/reference-updated` 绝不在服务存活之前触发——释放后仍有发射意味着提供方把工作泄漏到了 teardown 完全停稳之后。
144
+
145
+ </details>
146
+
147
+ -----
148
+
149
+ <a id="further-exploration"></a>
150
+ ## 进一步探索
151
+
152
+ 当包级约定不够用时阅读以下页面。它们从共享子系统词汇逐步进入随附存储与能力架构。
153
+
154
+ - [凭据子系统参考](../../../docs/subsystems/credentials.zh.md)——`CredentialRef`/`CredentialKey`、按操作解析、对 UI 安全的信息、提供方层与生成的 cordis 接口面。
155
+ - [本地凭据存储](../credentials-local/README.zh.md)——默认本机存储:密钥与记录存放在哪里、环境层如何排序。
156
+ - [能力 seam](../../../docs/capability-seams.zh.md)——本包遵循的 Service Definition / Service Provider / Consumer 拆分。
157
+
158
+ -----
159
+
160
+ <a id="model-experience"></a>
161
+ ## 模型体验
162
+
163
+ 经由消费方适配器间接生效:适配器解析每个凭据引用,并拥有值所授权的全部模型可见用途。
164
+
165
+ #### KV Cache 影响
166
+
167
+ 无直接失效;解析出的值绝不进入请求前缀。
168
+
169
+ ## 已知限制与延期工作
170
+
171
+ <a id="known-limitations-and-deferred-work"></a>
172
+
173
+
174
+ 这些限制说明本包何时不合适或需要特别注意。它们是当前包约束,不是任务积压。
175
+
176
+ - **引用不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,对这一半做 `list()` 没有当前消费方。记录出于无 schema 可发现的原因则可枚举。
177
+ - **引用限定为环境变量形状**——单一扁平的 POSIX 标识符命名空间,因为引用同时就是它借以解析的环境变量名。记录使用更丰富的 `<owner>/<id>` 寻址。
178
+ - **进程环境变化不可见**——无法为启动 shell 中改变的变量发出通知;界面只能在自身导航时重新读取 `describe()`。
179
+ - **记录的拥有者就是它的 scope,而没有任何环节核验该 scope 是否已挂载**——seam 存下被交予的内容,并报告它存了什么;识别孤儿是调用方在 `listRecords()` 与拥有该 scope 的注册表之间做的连接,seam 自身没有可供核对的注册表。
180
+
181
+ <a id="dev-note"></a>
182
+ ### 开发备注
183
+
184
+ <details>
185
+ <summary>维护者的工作上下文——点击展开</summary>
186
+
187
+ 本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为与限制以上文和包代码为准。
188
+
189
+ 该 seam 的接口为 keyring、辅助命令与 KMS 后端提供方预留了扩展空间;远端设置提供方永远不必携带机密。目前没有任何一种随附,也没有当前消费方需要它们。
190
+
191
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,159 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import { brandString } from "@buddhilive/dsh-brand";
3
+ //#region lib/types/index.js
4
+ /**
5
+ * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
6
+ * *references* to secrets — environment-variable names — while providers own
7
+ * the actual values and their storage. Consumers resolve a reference once per
8
+ * operation, so a changed credential reaches the next operation without any
9
+ * plugin restart, and configuration surfaces describe a reference without
10
+ * ever seeing its value.
11
+ * @module @buddhilive/dsh-credentials
12
+ */
13
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ /** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
15
+ const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
16
+ /**
17
+ * Brand a raw string as a {@link CredentialRef}.
18
+ * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
19
+ * @returns the branded reference.
20
+ */
21
+ function credentialRef(value) {
22
+ if (!isCredentialRefName(value)) throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
23
+ return brandString(value);
24
+ }
25
+ /**
26
+ * Whether a raw string could name a reference at all. Consumers that receive
27
+ * environment-variable names from somewhere else — a provider library's own
28
+ * ambient discovery, a hook payload — ask this before resolving, because a name
29
+ * outside the grammar has no reference to miss and should read as "not set"
30
+ * rather than as a thrown error.
31
+ * @param value - candidate reference.
32
+ * @returns true when {@link credentialRef} would accept it.
33
+ */
34
+ function isCredentialRefName(value) {
35
+ return REF_PATTERN.test(value);
36
+ }
37
+ /**
38
+ * Whether a raw string could be a {@link credentialKey} segment at all.
39
+ * Consumers whose addressing units come from somewhere else — a settings dict
40
+ * key, a library's own provider id — ask this before building a key, because a
41
+ * unit outside the grammar can never have stored a record and should read as
42
+ * "nothing stored" rather than as a thrown error.
43
+ * @param value - candidate segment.
44
+ * @returns true when {@link credentialKey} would accept it as either segment.
45
+ */
46
+ function isCredentialKeySegment(value) {
47
+ return KEY_SEGMENT_PATTERN.test(value);
48
+ }
49
+ /**
50
+ * Brand a scope and an id as a {@link CredentialKey}.
51
+ * @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
52
+ * @param id - that plugin's own addressing unit, such as a provider route key.
53
+ * @returns the branded key.
54
+ * @throws TypeError when either segment is not a lowercase hyphenated identifier.
55
+ */
56
+ function credentialKey(scope, id) {
57
+ for (const segment of [scope, id]) if (!KEY_SEGMENT_PATTERN.test(segment)) throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
58
+ return brandString(`${scope}/${id}`);
59
+ }
60
+ /**
61
+ * Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
62
+ * read half of {@link credentialKey}, for a provider admitting keys off disk.
63
+ * @param value - candidate key in its joined form.
64
+ * @returns the branded key.
65
+ * @throws TypeError when the value is not exactly two valid segments.
66
+ */
67
+ function parseCredentialKey(value) {
68
+ const segments = value.split("/");
69
+ const [scope, id] = segments;
70
+ if (segments.length !== 2 || scope === void 0 || id === void 0) throw new TypeError(`credential key "${value}" must be "<scope>/<id>"`);
71
+ return credentialKey(scope, id);
72
+ }
73
+ /**
74
+ * The owning plugin's name for one key. A record whose scope names no
75
+ * currently registered owner is an orphan, which a configuration surface must
76
+ * report as such rather than as a working credential.
77
+ * @param key - the key to read.
78
+ * @returns the scope segment.
79
+ */
80
+ function credentialKeyScope(key) {
81
+ return key.slice(0, key.indexOf("/"));
82
+ }
83
+ /**
84
+ * The owning plugin's own addressing unit for one key — the half that plugin
85
+ * chose, such as a provider route.
86
+ * @param key - the key to read.
87
+ * @returns the id segment.
88
+ */
89
+ function credentialKeyId(key) {
90
+ return key.slice(key.indexOf("/") + 1);
91
+ }
92
+ /**
93
+ * Abstract credential service over two key spaces that answer two questions.
94
+ *
95
+ * A {@link CredentialRef} answers "what is behind this environment-variable
96
+ * name", layered over the process environment, the provider-managed store, and
97
+ * `.env` files. One seam-wide rule binds that half: an empty stored value is
98
+ * absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
99
+ * so a blank never masquerades as a configured secret.
100
+ *
101
+ * A {@link CredentialKey} answers "what credential does this plugin hold for
102
+ * this id". Nothing can layer here — an authorization grant has no
103
+ * environment to be read from — so presence of the record is the whole fact,
104
+ * and {@link modifyRecord} is the only write path because a correct write
105
+ * depends on the current value (a token refresh is read-decide-replace under
106
+ * one lock).
107
+ */
108
+ var CredentialProvider = class extends Service {
109
+ constructor(ctx) {
110
+ super(ctx, "credentials");
111
+ }
112
+ /**
113
+ * Fan `credentials/reference-updated` out with contained listener failures: every
114
+ * listener runs, and a sync throw or async rejection is logged without
115
+ * changing the committed operation's outcome — except `INVARIANT`-coded
116
+ * failures, which rethrow after every listener ran (the rethrow reaches the
117
+ * caller only from synchronous listeners, so invariant checks on this event
118
+ * must not be async functions). Providers call this only after the write or
119
+ * reload actually committed, so a broken observer can never make a durable
120
+ * change look failed.
121
+ * @param ref - the reference whose stored value changed.
122
+ */
123
+ notifyUpdated(ref) {
124
+ this.fanOut("credentials/reference-updated", ref);
125
+ }
126
+ /**
127
+ * Fan `credentials/record-updated` out on exactly the terms
128
+ * {@link notifyUpdated} documents, for the record half of the seam.
129
+ * @param key - the record whose stored value changed.
130
+ */
131
+ notifyRecordUpdated(key) {
132
+ this.fanOut("credentials/record-updated", key);
133
+ }
134
+ /** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
135
+ fanOut(event, subject) {
136
+ let invariantFailure;
137
+ const args = [event, subject];
138
+ for (const listener of this.ctx.events.dispatch("emit", args)) try {
139
+ const returned = listener(subject);
140
+ if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
141
+ this.warnListenerFailure(event, subject, error);
142
+ });
143
+ } catch (error) {
144
+ if (error?.code === "INVARIANT") {
145
+ invariantFailure ??= error;
146
+ continue;
147
+ }
148
+ this.warnListenerFailure(event, subject, error);
149
+ }
150
+ if (invariantFailure !== void 0) throw invariantFailure;
151
+ }
152
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
153
+ warnListenerFailure(event, subject, error) {
154
+ this.ctx.logger.warn("credentials: a %s listener for \"%s\" failed", event, subject);
155
+ this.ctx.logger.warn(error);
156
+ }
157
+ };
158
+ //#endregion
159
+ export { CredentialProvider, CredentialProvider as default, credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName, parseCredentialKey };
@@ -0,0 +1,31 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@buddhilive/dsh-credentials`.
4
+ * @module @buddhilive/dsh-credentials/invariant
5
+ */
6
+ const PACKAGE_NAME = "@buddhilive/dsh-credentials";
7
+ /** Cordis companion plugin name. */
8
+ const name = "credentials-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * Install the commit-event lifecycle contract: `credentials/reference-updated` names a
13
+ * committed provider-source change, so it can only fire while a credentials
14
+ * service is live — an emission after disposal means a provider leaked work
15
+ * past its teardown quiescence. The value relation itself (`describe`
16
+ * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
17
+ * each provider's own suite.
18
+ */
19
+ const install = (ctx, fail) => {
20
+ ctx.on("credentials/reference-updated", (ref) => {
21
+ if (ctx.get("credentials") === void 0) fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`);
22
+ });
23
+ };
24
+ /**
25
+ * Register this package's invariant companion.
26
+ * @param ctx - Cordis context carrying the invariant service.
27
+ * @returns the installed registration's disposer after setup succeeds.
28
+ */
29
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
30
+ //#endregion
31
+ export { apply, inject, name };
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
3
+ * *references* to secrets — environment-variable names — while providers own
4
+ * the actual values and their storage. Consumers resolve a reference once per
5
+ * operation, so a changed credential reaches the next operation without any
6
+ * plugin restart, and configuration surfaces describe a reference without
7
+ * ever seeing its value.
8
+ * @module @buddhilive/dsh-credentials
9
+ */
10
+ import { Context, Service } from '@deepseek-ai/cordis';
11
+ import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRef } from './types.ts';
12
+ export type { ApiKeyRecord, CredentialInfo, CredentialKey, CredentialRecord, CredentialRef, GrantRecord, } from './types.ts';
13
+ /**
14
+ * Brand a raw string as a {@link CredentialRef}.
15
+ * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
16
+ * @returns the branded reference.
17
+ */
18
+ export declare function credentialRef(value: string): CredentialRef;
19
+ /**
20
+ * Whether a raw string could name a reference at all. Consumers that receive
21
+ * environment-variable names from somewhere else — a provider library's own
22
+ * ambient discovery, a hook payload — ask this before resolving, because a name
23
+ * outside the grammar has no reference to miss and should read as "not set"
24
+ * rather than as a thrown error.
25
+ * @param value - candidate reference.
26
+ * @returns true when {@link credentialRef} would accept it.
27
+ */
28
+ export declare function isCredentialRefName(value: string): boolean;
29
+ /**
30
+ * Whether a raw string could be a {@link credentialKey} segment at all.
31
+ * Consumers whose addressing units come from somewhere else — a settings dict
32
+ * key, a library's own provider id — ask this before building a key, because a
33
+ * unit outside the grammar can never have stored a record and should read as
34
+ * "nothing stored" rather than as a thrown error.
35
+ * @param value - candidate segment.
36
+ * @returns true when {@link credentialKey} would accept it as either segment.
37
+ */
38
+ export declare function isCredentialKeySegment(value: string): boolean;
39
+ /**
40
+ * Brand a scope and an id as a {@link CredentialKey}.
41
+ * @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
42
+ * @param id - that plugin's own addressing unit, such as a provider route key.
43
+ * @returns the branded key.
44
+ * @throws TypeError when either segment is not a lowercase hyphenated identifier.
45
+ */
46
+ export declare function credentialKey(scope: string, id: string): CredentialKey;
47
+ /**
48
+ * Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
49
+ * read half of {@link credentialKey}, for a provider admitting keys off disk.
50
+ * @param value - candidate key in its joined form.
51
+ * @returns the branded key.
52
+ * @throws TypeError when the value is not exactly two valid segments.
53
+ */
54
+ export declare function parseCredentialKey(value: string): CredentialKey;
55
+ /**
56
+ * The owning plugin's name for one key. A record whose scope names no
57
+ * currently registered owner is an orphan, which a configuration surface must
58
+ * report as such rather than as a working credential.
59
+ * @param key - the key to read.
60
+ * @returns the scope segment.
61
+ */
62
+ export declare function credentialKeyScope(key: CredentialKey): string;
63
+ /**
64
+ * The owning plugin's own addressing unit for one key — the half that plugin
65
+ * chose, such as a provider route.
66
+ * @param key - the key to read.
67
+ * @returns the id segment.
68
+ */
69
+ export declare function credentialKeyId(key: CredentialKey): string;
70
+ /** One resolved credential value and the source layer that supplied it. */
71
+ export interface ResolvedCredential {
72
+ /** The non-empty secret value. */
73
+ value: string;
74
+ /** Provider-defined source layer id (the local provider uses `env`, `file`, `project-env`, and `user-env`). */
75
+ source: string;
76
+ }
77
+ /** Presence and writability facts for one record, safe for configuration UIs — never the value. */
78
+ export interface CredentialRecordInfo {
79
+ /**
80
+ * Whether a record is stored. Unlike a reference, presence alone answers
81
+ * this: an {@link ApiKeyRecord} carrying neither a key nor environment
82
+ * values states that its owner confirmed ambient authentication, which is
83
+ * configured, not blank.
84
+ */
85
+ configured: boolean;
86
+ /** Discriminant of the stored record; absent while none is stored. */
87
+ kind?: CredentialRecord['kind'];
88
+ /** Whether {@link CredentialProvider.modifyRecord} would currently succeed. */
89
+ writable: boolean;
90
+ }
91
+ /** One stored record's address and tag, for enumeration — never its value. */
92
+ export interface CredentialRecordEntry {
93
+ /** The record's address. */
94
+ key: CredentialKey;
95
+ /** Discriminant of the stored record. */
96
+ kind: CredentialRecord['kind'];
97
+ }
98
+ declare module '@deepseek-ai/cordis' {
99
+ interface Context {
100
+ credentials: CredentialProvider;
101
+ }
102
+ }
103
+ /**
104
+ * Abstract credential service over two key spaces that answer two questions.
105
+ *
106
+ * A {@link CredentialRef} answers "what is behind this environment-variable
107
+ * name", layered over the process environment, the provider-managed store, and
108
+ * `.env` files. One seam-wide rule binds that half: an empty stored value is
109
+ * absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
110
+ * so a blank never masquerades as a configured secret.
111
+ *
112
+ * A {@link CredentialKey} answers "what credential does this plugin hold for
113
+ * this id". Nothing can layer here — an authorization grant has no
114
+ * environment to be read from — so presence of the record is the whole fact,
115
+ * and {@link modifyRecord} is the only write path because a correct write
116
+ * depends on the current value (a token refresh is read-decide-replace under
117
+ * one lock).
118
+ */
119
+ export declare abstract class CredentialProvider extends Service {
120
+ constructor(ctx: Context);
121
+ /**
122
+ * Resolve one reference to its current value. Resolution is per call:
123
+ * consumers re-resolve at each operation and must not cache across
124
+ * operations — that per-operation read is what makes a changed credential
125
+ * reach the next operation without a restart.
126
+ * @param ref - the reference to resolve.
127
+ * @returns the value and its source, or `undefined` while unconfigured.
128
+ */
129
+ abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>;
130
+ /**
131
+ * Describe one reference for configuration surfaces without exposing the
132
+ * value.
133
+ * @param ref - the reference to describe.
134
+ * @returns configured state, supplying source, and writability.
135
+ */
136
+ abstract describe(ref: CredentialRef): Promise<CredentialInfo>;
137
+ /**
138
+ * Durably store one value in the provider-managed writable source. Rejects
139
+ * while a read-only source shadows the reference — the write would appear
140
+ * to succeed while resolution keeps returning the shadowing value — and
141
+ * rejects an empty value (use {@link unset}).
142
+ * @param ref - the reference to store.
143
+ * @param value - the non-empty secret value.
144
+ */
145
+ abstract set(ref: CredentialRef, value: string): Promise<void>;
146
+ /**
147
+ * Remove one reference from the provider-managed writable source; removing
148
+ * an absent reference is a no-op. Rejects while a read-only source shadows
149
+ * the reference, like {@link set}.
150
+ * @param ref - the reference to remove.
151
+ */
152
+ abstract unset(ref: CredentialRef): Promise<void>;
153
+ /**
154
+ * Read one stored record. The value is returned as its owner wrote it; a
155
+ * {@link GrantRecord} payload is not interpreted on the way out.
156
+ * @param key - the record to read.
157
+ * @returns the record, or `undefined` while none is stored.
158
+ */
159
+ abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>;
160
+ /**
161
+ * Describe one record for configuration surfaces without exposing its value.
162
+ * @param key - the record to describe.
163
+ * @returns presence, discriminant, and writability.
164
+ */
165
+ abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>;
166
+ /**
167
+ * Enumerate every stored record's address and tag. Unlike the reference
168
+ * half, which has no enumeration because configuration surfaces learn which
169
+ * references exist from settings schemas, records have no such discovery
170
+ * path: a surface that cannot list them cannot show what a user is
171
+ * authorized for, nor find an orphan left by an uninstalled plugin.
172
+ * @returns every stored record, values excluded.
173
+ */
174
+ abstract listRecords(): Promise<readonly CredentialRecordEntry[]>;
175
+ /**
176
+ * Serialized read-modify-write over one record — the only write path.
177
+ * `mutate` sees the record as it stands at the moment the write is
178
+ * exclusive, and returning `undefined` leaves the entry untouched. Exclusion
179
+ * holds across processes where the backing store supports it, which is what
180
+ * makes a token refresh safe: two processes rotating one refresh token
181
+ * concurrently would otherwise lose whichever wrote first.
182
+ * @param key - the record to modify.
183
+ * @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
184
+ * @returns the record after the write, or the current one when `mutate` declined.
185
+ */
186
+ abstract modifyRecord(key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>): Promise<CredentialRecord | undefined>;
187
+ /**
188
+ * Remove one record; removing an absent record is a no-op.
189
+ * @param key - the record to remove.
190
+ */
191
+ abstract deleteRecord(key: CredentialKey): Promise<void>;
192
+ /**
193
+ * Fan `credentials/reference-updated` out with contained listener failures: every
194
+ * listener runs, and a sync throw or async rejection is logged without
195
+ * changing the committed operation's outcome — except `INVARIANT`-coded
196
+ * failures, which rethrow after every listener ran (the rethrow reaches the
197
+ * caller only from synchronous listeners, so invariant checks on this event
198
+ * must not be async functions). Providers call this only after the write or
199
+ * reload actually committed, so a broken observer can never make a durable
200
+ * change look failed.
201
+ * @param ref - the reference whose stored value changed.
202
+ */
203
+ protected notifyUpdated(ref: CredentialRef): void;
204
+ /**
205
+ * Fan `credentials/record-updated` out on exactly the terms
206
+ * {@link notifyUpdated} documents, for the record half of the seam.
207
+ * @param key - the record whose stored value changed.
208
+ */
209
+ protected notifyRecordUpdated(key: CredentialKey): void;
210
+ /** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
211
+ private fanOut;
212
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
213
+ private warnListenerFailure;
214
+ }
215
+ export default CredentialProvider;
216
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
3
+ * *references* to secrets — environment-variable names — while providers own
4
+ * the actual values and their storage. Consumers resolve a reference once per
5
+ * operation, so a changed credential reaches the next operation without any
6
+ * plugin restart, and configuration surfaces describe a reference without
7
+ * ever seeing its value.
8
+ * @module @buddhilive/dsh-credentials
9
+ */
10
+ import { Service } from '@deepseek-ai/cordis';
11
+ import { brandString } from '@buddhilive/dsh-brand';
12
+ const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ /** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
14
+ const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/;
15
+ /**
16
+ * Brand a raw string as a {@link CredentialRef}.
17
+ * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
18
+ * @returns the branded reference.
19
+ */
20
+ export function credentialRef(value) {
21
+ if (!isCredentialRefName(value)) {
22
+ throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`);
23
+ }
24
+ return brandString(value);
25
+ }
26
+ /**
27
+ * Whether a raw string could name a reference at all. Consumers that receive
28
+ * environment-variable names from somewhere else — a provider library's own
29
+ * ambient discovery, a hook payload — ask this before resolving, because a name
30
+ * outside the grammar has no reference to miss and should read as "not set"
31
+ * rather than as a thrown error.
32
+ * @param value - candidate reference.
33
+ * @returns true when {@link credentialRef} would accept it.
34
+ */
35
+ export function isCredentialRefName(value) {
36
+ return REF_PATTERN.test(value);
37
+ }
38
+ /**
39
+ * Whether a raw string could be a {@link credentialKey} segment at all.
40
+ * Consumers whose addressing units come from somewhere else — a settings dict
41
+ * key, a library's own provider id — ask this before building a key, because a
42
+ * unit outside the grammar can never have stored a record and should read as
43
+ * "nothing stored" rather than as a thrown error.
44
+ * @param value - candidate segment.
45
+ * @returns true when {@link credentialKey} would accept it as either segment.
46
+ */
47
+ export function isCredentialKeySegment(value) {
48
+ return KEY_SEGMENT_PATTERN.test(value);
49
+ }
50
+ /**
51
+ * Brand a scope and an id as a {@link CredentialKey}.
52
+ * @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
53
+ * @param id - that plugin's own addressing unit, such as a provider route key.
54
+ * @returns the branded key.
55
+ * @throws TypeError when either segment is not a lowercase hyphenated identifier.
56
+ */
57
+ export function credentialKey(scope, id) {
58
+ for (const segment of [scope, id]) {
59
+ if (!KEY_SEGMENT_PATTERN.test(segment)) {
60
+ throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`);
61
+ }
62
+ }
63
+ return brandString(`${scope}/${id}`);
64
+ }
65
+ /**
66
+ * Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
67
+ * read half of {@link credentialKey}, for a provider admitting keys off disk.
68
+ * @param value - candidate key in its joined form.
69
+ * @returns the branded key.
70
+ * @throws TypeError when the value is not exactly two valid segments.
71
+ */
72
+ export function parseCredentialKey(value) {
73
+ const segments = value.split('/');
74
+ const [scope, id] = segments;
75
+ if (segments.length !== 2 || scope === undefined || id === undefined) {
76
+ throw new TypeError(`credential key "${value}" must be "<scope>/<id>"`);
77
+ }
78
+ return credentialKey(scope, id);
79
+ }
80
+ /**
81
+ * The owning plugin's name for one key. A record whose scope names no
82
+ * currently registered owner is an orphan, which a configuration surface must
83
+ * report as such rather than as a working credential.
84
+ * @param key - the key to read.
85
+ * @returns the scope segment.
86
+ */
87
+ export function credentialKeyScope(key) {
88
+ // The brand's only constructors both validate two segments, so the split
89
+ // cannot come back short here.
90
+ return key.slice(0, key.indexOf('/'));
91
+ }
92
+ /**
93
+ * The owning plugin's own addressing unit for one key — the half that plugin
94
+ * chose, such as a provider route.
95
+ * @param key - the key to read.
96
+ * @returns the id segment.
97
+ */
98
+ export function credentialKeyId(key) {
99
+ return key.slice(key.indexOf('/') + 1);
100
+ }
101
+ /**
102
+ * Abstract credential service over two key spaces that answer two questions.
103
+ *
104
+ * A {@link CredentialRef} answers "what is behind this environment-variable
105
+ * name", layered over the process environment, the provider-managed store, and
106
+ * `.env` files. One seam-wide rule binds that half: an empty stored value is
107
+ * absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
108
+ * so a blank never masquerades as a configured secret.
109
+ *
110
+ * A {@link CredentialKey} answers "what credential does this plugin hold for
111
+ * this id". Nothing can layer here — an authorization grant has no
112
+ * environment to be read from — so presence of the record is the whole fact,
113
+ * and {@link modifyRecord} is the only write path because a correct write
114
+ * depends on the current value (a token refresh is read-decide-replace under
115
+ * one lock).
116
+ */
117
+ export class CredentialProvider extends Service {
118
+ constructor(ctx) {
119
+ super(ctx, 'credentials');
120
+ }
121
+ /**
122
+ * Fan `credentials/reference-updated` out with contained listener failures: every
123
+ * listener runs, and a sync throw or async rejection is logged without
124
+ * changing the committed operation's outcome — except `INVARIANT`-coded
125
+ * failures, which rethrow after every listener ran (the rethrow reaches the
126
+ * caller only from synchronous listeners, so invariant checks on this event
127
+ * must not be async functions). Providers call this only after the write or
128
+ * reload actually committed, so a broken observer can never make a durable
129
+ * change look failed.
130
+ * @param ref - the reference whose stored value changed.
131
+ */
132
+ notifyUpdated(ref) {
133
+ this.fanOut('credentials/reference-updated', ref);
134
+ }
135
+ /**
136
+ * Fan `credentials/record-updated` out on exactly the terms
137
+ * {@link notifyUpdated} documents, for the record half of the seam.
138
+ * @param key - the record whose stored value changed.
139
+ */
140
+ notifyRecordUpdated(key) {
141
+ this.fanOut('credentials/record-updated', key);
142
+ }
143
+ /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
144
+ fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
145
+ contract, and extracting it would couple the two seams' event semantics. */
146
+ /** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
147
+ fanOut(event, subject) {
148
+ let invariantFailure;
149
+ const args = [event, subject];
150
+ for (const listener of this.ctx.events.dispatch('emit', args)) {
151
+ try {
152
+ const returned = listener(subject);
153
+ if (returned != null && typeof returned.then === 'function') {
154
+ void Promise.resolve(returned).then(undefined, (error) => {
155
+ this.warnListenerFailure(event, subject, error);
156
+ });
157
+ }
158
+ }
159
+ catch (error) {
160
+ if (error?.code === 'INVARIANT') {
161
+ invariantFailure ??= error;
162
+ continue;
163
+ }
164
+ this.warnListenerFailure(event, subject, error);
165
+ }
166
+ }
167
+ if (invariantFailure !== undefined)
168
+ throw invariantFailure;
169
+ }
170
+ /* jscpd:ignore-end */
171
+ /** Contained-listener diagnostic shared by the sync and async failure paths. */
172
+ warnListenerFailure(event, subject, error) {
173
+ this.ctx.logger.warn('credentials: a %s listener for "%s" failed', event, subject);
174
+ this.ctx.logger.warn(error);
175
+ }
176
+ }
177
+ export default CredentialProvider;
178
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@buddhilive/dsh-credentials`.
3
+ * @module @buddhilive/dsh-credentials/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "credentials-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,31 @@
1
+ /**
2
+ * Package-owned invariant companion for `@buddhilive/dsh-credentials`.
3
+ * @module @buddhilive/dsh-credentials/invariant
4
+ */
5
+ const PACKAGE_NAME = '@buddhilive/dsh-credentials';
6
+ /** Cordis companion plugin name. */
7
+ export const name = 'credentials-invariant';
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export const inject = ['invariants'];
10
+ /**
11
+ * Install the commit-event lifecycle contract: `credentials/reference-updated` names a
12
+ * committed provider-source change, so it can only fire while a credentials
13
+ * service is live — an emission after disposal means a provider leaked work
14
+ * past its teardown quiescence. The value relation itself (`describe`
15
+ * agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
16
+ * each provider's own suite.
17
+ */
18
+ const install = (ctx, fail) => {
19
+ ctx.on('credentials/reference-updated', (ref) => {
20
+ if (ctx.get('credentials') === undefined) {
21
+ fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`);
22
+ }
23
+ });
24
+ };
25
+ /**
26
+ * Register this package's invariant companion.
27
+ * @param ctx - Cordis context carrying the invariant service.
28
+ * @returns the installed registration's disposer after setup succeeds.
29
+ */
30
+ export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
31
+ //# sourceMappingURL=invariant.js.map
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Client-safe type surface of the credential seam: the two key brands, the
3
+ * stored-record union, the reference view crossing the Remote wire, and the
4
+ * seam's Cordis event declarations. Types only — no runtime code, and nothing
5
+ * here reaches a Host-only symbol, so a Client compilation face reads exactly
6
+ * the signature the Host emits.
7
+ *
8
+ * @module @buddhilive/dsh-credentials/types
9
+ */
10
+ import type { Branded } from '@buddhilive/dsh-brand';
11
+ /** Nominal reference to one credential: a POSIX-style environment-variable name. */
12
+ export type CredentialRef = Branded<'CredentialRef'>;
13
+ /**
14
+ * Nominal address of one stored credential record: `<scope>/<id>`, where
15
+ * `scope` is the registered name of the plugin that owns the record and `id`
16
+ * is that plugin's own addressing unit (an LLM adapter uses its provider route
17
+ * key).
18
+ *
19
+ * The scope is the owner rather than the domain because a record's payload is
20
+ * written in its owner's format: two plugins serving the same provider name
21
+ * would otherwise read each other's payload, and a record left behind by an
22
+ * uninstalled plugin could not be told apart from a live one. The `/` also
23
+ * keeps this grammar disjoint from {@link CredentialRef}, so the two key
24
+ * spaces can never collide.
25
+ */
26
+ export type CredentialKey = Branded<'CredentialKey'>;
27
+ /**
28
+ * A credential the harness itself understands: an api key, provider
29
+ * environment values, or both. Either field may be absent — a record carrying
30
+ * neither states that the owner confirmed this route authenticates from its
31
+ * own ambient discovery, which is a different fact from having no record.
32
+ */
33
+ export interface ApiKeyRecord {
34
+ /** Discriminant. */
35
+ readonly kind: 'api-key';
36
+ /** The non-empty secret value, when this credential is a key at all. */
37
+ readonly key?: string;
38
+ /** Provider environment values such as `AWS_PROFILE`; names are POSIX identifiers. */
39
+ readonly env?: Readonly<Record<string, string>>;
40
+ }
41
+ /**
42
+ * The product of one authorization grant, kept verbatim for its owner. The
43
+ * seam never reads, validates, or reshapes {@link payload}: it is written in
44
+ * the owning plugin's format and only that plugin can interpret it. The single
45
+ * constraint is that it survives a JSON round trip.
46
+ */
47
+ export interface GrantRecord {
48
+ /** Discriminant. */
49
+ readonly kind: 'grant';
50
+ /** Owner-defined JSON value; opaque to the seam and to every other plugin. */
51
+ readonly payload: unknown;
52
+ }
53
+ /** One durable credential record, tagged by what the seam may do with it. */
54
+ export type CredentialRecord = ApiKeyRecord | GrantRecord;
55
+ /**
56
+ * Source and writability facts for one reference, safe for configuration UIs —
57
+ * never the value. The view has no slot a value could ride in, which is what
58
+ * lets the whole read half cross the Remote wire.
59
+ */
60
+ export interface CredentialInfo {
61
+ /** Whether resolving the reference would currently return a value. */
62
+ configured: boolean;
63
+ /** Source layer currently supplying the value; absent while unconfigured. */
64
+ source?: string;
65
+ /** Whether the active provider can write this reference. */
66
+ writable: boolean;
67
+ }
68
+ declare module '@deepseek-ai/cordis' {
69
+ interface Events {
70
+ /**
71
+ * Committed change to a provider-managed credential source: a `set`, an
72
+ * `unset`, or an external edit observed in storage. Ambient
73
+ * process-environment changes are not observable and never emit. Listener
74
+ * failures are contained and logged — a sync throw and an async rejection
75
+ * alike — without changing the committed operation's outcome, except
76
+ * `INVARIANT`-coded failures, which rethrow after every listener ran;
77
+ * that rethrow reaches the emitter only from synchronous listeners, so
78
+ * invariant checks on this event must not be async functions.
79
+ * @param ref - the reference whose stored value changed.
80
+ * @mode emit
81
+ */
82
+ 'credentials/reference-updated'(ref: CredentialRef): void;
83
+ /**
84
+ * Committed change to a stored credential record: a `modifyRecord` that
85
+ * wrote, a `deleteRecord` that removed, or an external edit observed in
86
+ * storage. Separate from `credentials/reference-updated` because the two key
87
+ * grammars are disjoint — a listener that received both on one event could
88
+ * not tell which space a subject belongs to. Listener failures are
89
+ * contained on the same terms as `credentials/reference-updated`.
90
+ * @param key - the record whose stored value changed.
91
+ * @mode emit
92
+ */
93
+ 'credentials/record-updated'(key: CredentialKey): void;
94
+ }
95
+ }
96
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Client-safe type surface of the credential seam: the two key brands, the
3
+ * stored-record union, the reference view crossing the Remote wire, and the
4
+ * seam's Cordis event declarations. Types only — no runtime code, and nothing
5
+ * here reaches a Host-only symbol, so a Client compilation face reads exactly
6
+ * the signature the Host emits.
7
+ *
8
+ * @module @buddhilive/dsh-credentials/types
9
+ */
10
+ export {};
11
+ //# sourceMappingURL=types.js.map
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@buddhilive/dsh-credentials",
3
+ "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values",
4
+ "version": "0.1.2-alpha.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Buddhilive/buddhi-ai-harness.git",
11
+ "directory": "packages/credentials/credentials"
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
+ "@deepseek-ai/cordis": "^4.0.2",
41
+ "@buddhilive/dsh-invariants": "^0.1.2-alpha.3"
42
+ },
43
+ "devDependencies": {
44
+ "@deepseek-ai/cordis": "^4.0.2",
45
+ "@buddhilive/dsh-invariants": "^0.1.2-alpha.3"
46
+ },
47
+ "dependencies": {
48
+ "@buddhilive/dsh-brand": "^0.1.2-alpha.3"
49
+ }
50
+ }