@x-otto/credentials 0.0.1-alpha.1 → 0.0.1-alpha.2

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/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @x-otto/credentials
2
+
3
+ 凭据文件生命周期原语叶包——原子写盘 / 影子 overlay / 过期判断 / 跨进程刷新锁。
4
+
5
+ ## 包定位
6
+
7
+ 零 provider 语义、零域知识的凭据文件处理原语。`@x-otto/ai`(provider 域)与 `@x-otto/mcp`(server 域)各自保留目录布局、key 命名、token endpoint 发现逻辑,只消费本包的四类原语:
8
+
9
+ | 原语 | 文件 | 语义 |
10
+ |---|---|---|
11
+ | 原子写盘 | `atomic-file.ts` | `ensurePrivateDir(0700)` + `tmp-${pid}` 写入 + `chmod 0600` + `rename` 原子替换 + 失败清理 tmp |
12
+ | 影子 overlay | `overlay-store.ts` | 读穿透 base(继承磁盘),写/删只进内存,进程退出后磁盘逐字节不变(RFC-345) |
13
+ | 过期判断 | `expiry.ts` | `isExpiredAt(ts, skew)` + `OAUTH_SKEW_MS = 60_000` 单源 |
14
+ | 跨进程刷新锁 | `refresh-lock.ts` | `createRefreshLock(dir, prefix)` 锁工厂 + `refreshUnderCrossProcessLock` 协调骨架 |
15
+
16
+ ## 安全语义
17
+
18
+ - **原子性**:`rename` 保证读者永远看不到半写状态。
19
+ - **权限收紧**:凭据文件 `0600`、目录 `0700`,从落盘第一刻起不可被其他本地用户读取。权限收紧失败(非 POSIX 文件系统)仅降级安全性,不阻断主流程。
20
+ - **fail-soft 读**:`ENOENT`(文件不存在)/ `SyntaxError`(JSON 损坏)返回 fallback——视为「无凭据」而非报错。`EACCES`(权限拒绝)**rethrow**——那是环境故障,不是「无凭据」。
21
+
22
+ ## 用法
23
+
24
+ ```ts
25
+ import {
26
+ ensurePrivateDir, writeJsonAtomic, readJsonFile, deleteFileIfExists,
27
+ isExpiredAt, OAUTH_SKEW_MS,
28
+ createOverlayStore,
29
+ createRefreshLock, refreshUnderCrossProcessLock,
30
+ } from '@x-otto/credentials'
31
+
32
+ // 原子写盘
33
+ await ensurePrivateDir('/path/to/creds')
34
+ await writeJsonAtomic('/path/to/creds/token.json', { access: '...', refresh: '...' })
35
+ const data = await readJsonFile('/path/to/creds/token.json', { access: '' })
36
+
37
+ // 过期判断(各域自行取字段:ai 传 credentials.expires,mcp 传 credentials.expiresAt)
38
+ if (isExpiredAt(credentials.expires)) { /* 需要刷新 */ }
39
+
40
+ // 影子 overlay(读磁盘、写内存)
41
+ const overlay = createOverlayStore(baseStore)
42
+
43
+ // 跨进程互斥刷新
44
+ const lock = createRefreshLock('/path/to/creds', 'auth-refresh')
45
+ const refreshed = await refreshUnderCrossProcessLock({
46
+ lock, key: 'provider-id',
47
+ load: () => readFromDisk(),
48
+ isFresh: (c) => !isExpiredAt(c.expires),
49
+ refresh: () => callOAuthTokenEndpoint(),
50
+ save: (c) => writeToDisk(c),
51
+ })
52
+ ```
53
+
54
+ ## 锁文件前缀不可变约束
55
+
56
+ `createRefreshLock(dir, prefix)` 的 `prefix` 一旦上线**字节级不可变**——换前缀 = 新旧进程各锁各的文件 = 等于没锁(滚动升级互斥硬约束)。当前已上线的前缀:
57
+
58
+ - `auth-refresh`(ai 域)
59
+ - `mcp-refresh`(mcp 域)
60
+
61
+ ## 消费方
62
+
63
+ - `@x-otto/ai`:`auth-store.ts`(原子写盘 + skew 常量)、`oauth-refresh-lock.ts`(锁工厂)
64
+ - `@x-otto/mcp`:`token-store.ts`(原子写盘 + overlay)、`token.ts`(过期判断)、`refresh-lock.ts`(锁工厂 + 协调骨架)
65
+
66
+ ## 依赖
67
+
68
+ - `@x-otto/shared`(file-lock 原语 + OAuthError 类型)
69
+
70
+ ## 测试
71
+
72
+ ```bash
73
+ npx vitest run packages/credentials/tests/credentials.test.ts
74
+ ```
75
+
76
+ 18 项测试覆盖四条语义基线 + 失败路径清理 + 权限位断言 + EACCES rethrow + 陈旧锁恢复。
package/dist/index.d.ts CHANGED
@@ -24,12 +24,6 @@ declare const OAUTH_SKEW_MS = 60000;
24
24
  * @param skewMs 时钟偏移余量,缺省 `OAUTH_SKEW_MS`。
25
25
  */
26
26
  declare function isExpiredAt(expiresAt: number | null | undefined, skewMs?: number): boolean;
27
- /**
28
- * 凭据是否在 `windowMs` 内即将过期(预警/提前刷新窗口,与 isExpiredAt 同一 skew 语义)。
29
- * 可用于「提前 N 分钟刷新」策略;当前无生产消费方,作为过期判断族的语义补全
30
- * (避免未来第三处手写 `Date.now() + X >= expiresAt`)。
31
- */
32
- declare function expiresWithin(expiresAt: number | null | undefined, windowMs: number): boolean;
33
27
  //#endregion
34
28
  //#region src/atomic-file.d.ts
35
29
  /**
@@ -99,6 +93,43 @@ interface RefreshLock {
99
93
  * 换前缀 = 新旧进程各锁各的文件 = 等于没锁(ai 的滚动升级硬约束)。
100
94
  */
101
95
  declare function createRefreshLock(lockDir: string, prefix: string): RefreshLock;
96
+ /**
97
+ * 可选刷新策略层:将 ai 淬炼出的三层防御(永久失败缓存 / 磁盘恢复 / 可观测性)
98
+ * 泛化为可注入钩子,骨架零行为变化(未提供 strategy 时与原有行为完全一致)。
99
+ *
100
+ * 设计背景:ai 的 OAuthRefreshCoordinator 在叶包骨架之上多了三层策略——
101
+ * ① 永久失败缓存(invalid_grant fail-fast,不再竞锁/打网络);
102
+ * ② 刷新失败后磁盘恢复(另一进程可能已抢先刷新并落盘);
103
+ * ③ 恢复步骤可观测性(onRecoveryStep 旁路回调)。
104
+ * mcp 消费叶包骨架但此前无法获得这三层防御。策略钩子让 mcp 注入等价能力,
105
+ * 同时为 ai 后续迁移到骨架提供迁移路径(策略从 ai 内部 Map 提取为独立对象)。
106
+ */
107
+ interface RefreshStrategy<T> {
108
+ /**
109
+ * 同一凭据快照是否此前已被永久拒绝(如 invalid_grant)。
110
+ * 返回 true 时骨架跳过锁/网络,先尝试 recoverFromFailure,仍无恢复才抛错。
111
+ * 缺省(未提供)= 不 fail-fast。
112
+ */
113
+ shouldFailFast?(latest: T): boolean;
114
+ /**
115
+ * 从磁盘恢复:另一进程可能已抢先刷新并落盘新凭据。
116
+ * 在三个失败点调用:fail-fast 命中后、锁等待超时后、refresh() 失败后。
117
+ * 返回恢复的凭据或 undefined。调用方收到非 undefined 后仍需 isFresh 校验。
118
+ * 缺省(未提供)= 不恢复。
119
+ */
120
+ recoverFromFailure?(latest: T): Promise<T | undefined>;
121
+ /**
122
+ * 缓存永久失败(如 invalid_grant):同一凭据快照后续刷新 fail-fast 不打网络。
123
+ * 在 refresh() 失败后调用,调用方据 error 判定是否永久失败。
124
+ * 缺省(未提供)= 不缓存。
125
+ */
126
+ cacheFailure?(error: unknown, latest: T): void;
127
+ /**
128
+ * 恢复步骤可观测性回调(只读旁路,不影响控制流)。
129
+ * 骨架在 fail-fast / 锁等待 / 磁盘恢复等步骤触发时调用。
130
+ */
131
+ onStep?(step: string, detail?: string): void;
132
+ }
102
133
  interface RefreshUnderLockOptions<T> {
103
134
  /** 锁工厂(createRefreshLock 产物,域侧持有)。 */
104
135
  lock: RefreshLock;
@@ -114,19 +145,119 @@ interface RefreshUnderLockOptions<T> {
114
145
  save(credentials: T): Promise<void>;
115
146
  /** 陈旧锁阈值,缺省 REFRESH_LOCK_TIMEOUT_MS。 */
116
147
  timeoutMs?: number;
148
+ /** 可选策略层:永久失败缓存 / 磁盘恢复 / 可观测性。缺省时零行为变化。 */
149
+ strategy?: RefreshStrategy<T>;
117
150
  }
118
151
  /**
119
152
  * 跨进程互斥刷新:同一时刻同一 key 全机器只有一个进程真正发起 `refresh()` 网络请求,
120
153
  * 其余进程等待后直接读盘复用结果。
121
154
  *
122
- * ai `OAuthRefreshCoordinator.refreshWithCrossProcessLock` 的语义对齐(本函数是
123
- * 其公共骨架的提取;ai 的永久失败缓存/磁盘恢复等额外层仍在其自身实现中):
155
+ * 基本流程(无 strategy 时,与 RFC-374 M1 行为逐行等价):
124
156
  * - 拿不到锁 → 等待释放(超时=陈旧锁阈值)→ 读盘看另一进程是否已刷新成功,是则复用;
125
157
  * 否则抛错(不无限等待、不用陈旧 refresh_token 继续打网络)。
126
158
  * - 拿到锁 → 重读一次确认是否仍不新鲜(本进程可能在「发现不新鲜→真正拿锁」的窗口里
127
159
  * 已被另一进程抢先刷新),仍不新鲜才真正刷新并落盘;`finally` 保证无论成败都释放锁。
160
+ *
161
+ * 策略层扩展(strategy 提供时,对齐 ai OAuthRefreshCoordinator 三层防御):
162
+ * - shouldFailFast → 拿锁前检查永久失败缓存(invalid_grant fail-fast 不竞锁/不打网络),
163
+ * 先尝试 recoverFromFailure 磁盘恢复,仍无恢复才抛错;
164
+ * - recoverFromFailure → 在锁等待超时后 / refresh() 失败后调用,读盘看另一进程是否已落盘;
165
+ * - cacheFailure → refresh() 失败后调用,据 error 判定是否缓存为永久失败;
166
+ * - onStep → 各步骤旁路可观测性。
128
167
  */
129
168
  declare function refreshUnderCrossProcessLock<T>(options: RefreshUnderLockOptions<T>): Promise<T>;
130
169
  //#endregion
131
- export { OAUTH_SKEW_MS, REFRESH_LOCK_TIMEOUT_MS, type ReadWriteStore, type RefreshLock, type RefreshUnderLockOptions, createOverlayStore, createRefreshLock, deleteFileIfExists, ensurePrivateDir, expiresWithin, isExpiredAt, readJsonFile, refreshUnderCrossProcessLock, writeJsonAtomic };
170
+ //#region src/backend.d.ts
171
+ /**
172
+ * 后端能力声明——宿主据此决定是否套 overlay/锁组合器。
173
+ *
174
+ * 盲目给 keyring 套文件锁是浪费(keyring 的 OS 级原子操作不需要 file-lock);
175
+ * 盲目给文件后端跳过 chmod 是安全隐患(文件权限必须收紧到 0600)。
176
+ * 能力位让宿主只套「后端缺的能力」。
177
+ */
178
+ interface CredentialBackendCapabilities {
179
+ /**
180
+ * 写操作是原子的(读者不会观测到半写状态)。
181
+ * 文件后端=true(tmp+rename);keyring 后端取决于实现(security CLI 是原子的)。
182
+ * atomic=false 时宿主应套 overlay 组合器(读穿透、写只内存,消除半写观测)。
183
+ */
184
+ atomic: boolean;
185
+ /**
186
+ * 存储自带权限隔离(不需 chmod 收紧)。
187
+ * 文件后端=false(需 ensurePrivateDir 0700 + writeJsonAtomic 0600);
188
+ * keyring 后端=true(OS 级隔离,其他用户读不到)。
189
+ */
190
+ permSecure: boolean;
191
+ /**
192
+ * 支持跨进程互斥(不需宿主套文件锁)。
193
+ * 文件后端=false(需 createRefreshLock + file-lock);
194
+ * keyring 后端=true(security CLI 的 add/delete 是 OS 原子操作)。
195
+ * crossProcessLock=true 时宿主可跳过 refresh-lock 组合器。
196
+ */
197
+ crossProcessLock: boolean;
198
+ /**
199
+ * 支持外部变更感知(fs.watch 或等价)。
200
+ * 文件后端=true(fs.watch baseDir);keyring 后端=false(无原生通知)。
201
+ * watchable=true 且后端提供 watch() 时,宿主可接线外部变更感知。
202
+ */
203
+ watchable: boolean;
204
+ }
205
+ /**
206
+ * 凭据存储后端契约。文件、keyring、KMS 等各自实现此接口。
207
+ *
208
+ * 与 ReadWriteStore 的关系:形状相同(load/save/delete/list),但多了
209
+ * capabilities + watch。不继承 ReadWriteStore(后者无能力声明)。
210
+ *
211
+ * 泛型化:K=key 类型(provider id / server name / credential ref),
212
+ * V=凭据值类型(各域自定——ai 用 AuthEntry,mcp 用 OAuthCredentials)。
213
+ */
214
+ interface CredentialBackend<K extends string, V> {
215
+ /** 加载某 key 的凭据;不存在返回 undefined。 */
216
+ load(key: K): Promise<V | undefined>;
217
+ /** 保存凭据。后端据 capabilities.atomic 决定是否原子写入。 */
218
+ save(key: K, value: V): Promise<void>;
219
+ /** 删除凭据(幂等——删除不存在的 key 不报错)。 */
220
+ delete(key: K): Promise<void>;
221
+ /** 列出所有有凭据的 key。 */
222
+ list(): Promise<string[]>;
223
+ /** 后端能力声明——宿主据此决定组合器策略。 */
224
+ readonly capabilities: CredentialBackendCapabilities;
225
+ /**
226
+ * 可选:外部变更感知(capabilities.watchable=true 时提供)。
227
+ * 返回取消订阅函数。宿主据此接线跨进程凭据变更感知。
228
+ */
229
+ watch?(onChange: (key: K) => void): () => void;
230
+ }
231
+ /**
232
+ * 将 CredentialBackend 适配为 ReadWriteStore——供 createOverlayStore 等组合器使用。
233
+ * 后端缺的语义由宿主侧组合器补(overlay 补原子性、createRefreshLock 补跨进程锁)。
234
+ */
235
+ declare function backendToStore<K extends string, V>(backend: CredentialBackend<K, V>): ReadWriteStore<V>;
236
+ //#endregion
237
+ //#region src/file-backend.d.ts
238
+ interface FileBackendOptions {
239
+ /**
240
+ * 凭据文件扩展名,缺省 `.json`。key + 扩展名 = 文件名。
241
+ * 目录权限 0700,文件权限 0600(由 writeJsonAtomic 保证)。
242
+ */
243
+ fileExtension?: string;
244
+ /**
245
+ * 自定义 key → 文件名映射(缺省 = key + fileExtension)。
246
+ * 用于需要 sanitize key(如 mcp 的 sanitizeServerName)的场景。
247
+ * 调用方负责防路径穿越(返回值不含 `/`、`..`)。
248
+ */
249
+ fileName?: (key: string) => string;
250
+ }
251
+ /**
252
+ * 创建文件存储后端。每个 key 一个 JSON 文件,存于 baseDir 下。
253
+ *
254
+ * 安全纹理(与 ai auth-store / mcp token-store 逐条对齐):
255
+ * - 目录权限 0700(ensurePrivateDir)
256
+ * - 文件权限 0600(writeJsonAtomic 的 chmod)
257
+ * - 原子替换(tmp + rename,读者永远看不到半写状态)
258
+ * - fail-soft 读(ENOENT/损坏 → fallback,EACCES rethrow)
259
+ */
260
+ declare function createFileCredentialBackend<K extends string, V>(baseDir: string, options?: FileBackendOptions): CredentialBackend<K, V>;
261
+ //#endregion
262
+ export { type CredentialBackend, type CredentialBackendCapabilities, type FileBackendOptions, OAUTH_SKEW_MS, REFRESH_LOCK_TIMEOUT_MS, type ReadWriteStore, type RefreshLock, type RefreshStrategy, type RefreshUnderLockOptions, backendToStore, createFileCredentialBackend, createOverlayStore, createRefreshLock, deleteFileIfExists, ensurePrivateDir, isExpiredAt, readJsonFile, refreshUnderCrossProcessLock, writeJsonAtomic };
132
263
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/expiry.ts","../src/atomic-file.ts","../src/overlay-store.ts","../src/refresh-lock.ts"],"mappings":";;AAgBA;;;;;AAUA;;;;;AAUA;;;;cApBa,aAAA;;;;ACCb;;;;;iBDSgB,WAAA,CAAY,SAAA,6BAAsC,MAAA;;;;;;iBAUlD,aAAA,CACd,SAAA,6BACA,QAAA;;;;AAtBF;;;;iBCCsB,gBAAA,CAAiB,GAAA,WAAc,OAAA;ADSrD;;;;;AAAA,iBCKsB,eAAA,CAAgB,IAAA,UAAc,IAAA,YAAgB,OAAA;;;;;;iBAwB9C,YAAA,GAAA,CAAgB,IAAA,UAAc,QAAA,EAAU,CAAA,GAAI,OAAA,CAAQ,CAAA;;iBAapD,kBAAA,CAAmB,IAAA,WAAe,OAAA;;;;ADpDxD;;;;;AAUA;;;;;AAUA;;;;;UEnBiB,cAAA;EACf,IAAA,CAAK,GAAA,WAAc,OAAA,CAAQ,CAAA;EAC3B,IAAA,CAAK,GAAA,UAAa,KAAA,EAAO,CAAA,GAAI,OAAA;EAC7B,MAAA,CAAO,GAAA,WAAc,OAAA;EACrB,IAAA,IAAQ,OAAA;AAAA;;iBAIM,kBAAA,GAAA,CAAsB,IAAA,EAAM,cAAA,CAAe,CAAA,IAAK,cAAA,CAAe,CAAA;;;;cCGlE,uBAAA;;UAGI,WAAA;EHfS;EGiBxB,OAAA,CAAQ,GAAA,UAAa,SAAA,YAAqB,OAAA;EHP5B;EGSd,OAAA,CAAQ,GAAA,WAAc,OAAA;;EAEtB,cAAA,CAAe,GAAA,UAAa,SAAA,YAAqB,OAAA;AAAA;AHDnD;;;;;;;AAAA,iBGWgB,iBAAA,CAAkB,OAAA,UAAiB,MAAA,WAAiB,WAAA;AAAA,UAcnD,uBAAA;EF5CqB;EE8CpC,IAAA,EAAM,WAAA;EF9C+B;EEgDrC,GAAA;EFlCoB;EEoCpB,IAAA,IAAQ,OAAA,CAAQ,CAAA;;EAEhB,OAAA,CAAQ,WAAA,EAAa,CAAA;EFtCe;EEwCpC,OAAA,IAAW,OAAA,CAAQ,CAAA;EFxC+C;EE0ClE,IAAA,CAAK,WAAA,EAAa,CAAA,GAAI,OAAA;EF1CmD;EE4CzE,SAAA;AAAA;;;;;;;;;;;;iBAcoB,4BAAA,GAAA,CACpB,OAAA,EAAS,uBAAA,CAAwB,CAAA,IAChC,OAAA,CAAQ,CAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/expiry.ts","../src/atomic-file.ts","../src/overlay-store.ts","../src/refresh-lock.ts","../src/backend.ts","../src/file-backend.ts"],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import{chmod as e,mkdir as t,readFile as n,rename as r,unlink as i,writeFile as a}from"node:fs/promises";import{DEFAULT_FILE_LOCK_TIMEOUT_MS as o,OAuthError as s,acquireFileLock as c,releaseFileLock as l,waitForFileLockRelease as u}from"@x-otto/shared";const d=6e4;function f(e,t=d){return e==null?!1:Date.now()+t>=e}function p(e,t){return e==null?!1:Date.now()+t>=e}async function m(n){await t(n,{recursive:!0,mode:448});try{await e(n,448)}catch{}}async function h(t,n){let o=`${t}.tmp-${process.pid}`;try{await a(o,JSON.stringify(n,null,2),{encoding:`utf-8`,mode:384});try{await e(o,384)}catch{}await r(o,t)}catch(e){throw await i(o).catch(()=>{}),e}}async function g(e,t){try{let t=await n(e,`utf-8`);return JSON.parse(t)}catch(e){if(e?.code===`ENOENT`||e instanceof SyntaxError)return t;throw e}}async function _(e){try{await i(e)}catch(e){if(e?.code===`ENOENT`)return;throw e}}function v(e){let t=new Map,n=new Set;return{async load(r){if(t.has(r))return t.get(r);if(!n.has(r))return e.load(r)},async save(e,r){n.delete(e),t.set(e,r)},async delete(e){t.delete(e),n.add(e)},async list(){let r=new Set(await e.list());for(let e of n)r.delete(e);for(let e of t.keys())r.add(e);return[...r]}}}const y=o;function b(e,t){return{acquire(n,r=y){return c(e,n,{timeoutMs:r,prefix:t})},release(n){return l(e,n,{prefix:t})},waitForRelease(n,r=y){return u(e,n,{timeoutMs:r,prefix:t})}}}async function x(e){let{lock:t,key:n,load:r,isFresh:i,refresh:a,save:o,timeoutMs:c=y}=e;if(!await t.acquire(n,c)){await t.waitForRelease(n,c);let e=await r();if(e&&i(e))return e;throw new s(`OAuth credential refresh failed: another process is refreshing but did not complete in time`,{code:`OAUTH_REFRESH_FAILED`})}try{let e=await r();if(e&&i(e))return e;let t=await a();return await o(t),t}finally{await t.release(n)}}export{d as OAUTH_SKEW_MS,y as REFRESH_LOCK_TIMEOUT_MS,v as createOverlayStore,b as createRefreshLock,_ as deleteFileIfExists,m as ensurePrivateDir,p as expiresWithin,f as isExpiredAt,g as readJsonFile,x as refreshUnderCrossProcessLock,h as writeJsonAtomic};
1
+ import{chmod as e,mkdir as t,readFile as n,rename as r,unlink as i,writeFile as a}from"node:fs/promises";import{DEFAULT_FILE_LOCK_TIMEOUT_MS as o,OAuthError as s,acquireFileLock as c,releaseFileLock as l,waitForFileLockRelease as u}from"@x-otto/shared";import{watch as d}from"node:fs";import{join as f}from"node:path";const p=6e4;function m(e,t=p){return e==null?!1:Date.now()+t>=e}async function h(n){await t(n,{recursive:!0,mode:448});try{await e(n,448)}catch{}}async function g(t,n){let o=`${t}.tmp-${process.pid}`;try{await a(o,JSON.stringify(n,null,2),{encoding:`utf-8`,mode:384});try{await e(o,384)}catch{}await r(o,t)}catch(e){throw await i(o).catch(()=>{}),e}}async function _(e,t){try{let t=await n(e,`utf-8`);return JSON.parse(t)}catch(e){if(e?.code===`ENOENT`||e instanceof SyntaxError)return t;throw e}}async function v(e){try{await i(e)}catch(e){if(e?.code===`ENOENT`)return;throw e}}function y(e){let t=new Map,n=new Set;return{async load(r){if(t.has(r))return t.get(r);if(!n.has(r))return e.load(r)},async save(e,r){n.delete(e),t.set(e,r)},async delete(e){t.delete(e),n.add(e)},async list(){let r=new Set(await e.list());for(let e of n)r.delete(e);for(let e of t.keys())r.add(e);return[...r]}}}const b=o;function x(e,t){return{acquire(n,r=b){return c(e,n,{timeoutMs:r,prefix:t})},release(n){return l(e,n,{prefix:t})},waitForRelease(n,r=b){return u(e,n,{timeoutMs:r,prefix:t})}}}async function S(e){let{lock:t,key:n,load:r,isFresh:i,refresh:a,save:o,timeoutMs:c=b,strategy:l}=e;if(l?.shouldFailFast){let e=await r();if(e&&l.shouldFailFast(e)){if(l.onStep?.(`fail-fast`,`permanent failure cache hit, checking disk`),l.recoverFromFailure){let t=await l.recoverFromFailure(e);if(t&&i(t))return t}throw new s(`OAuth credential refresh failed: credential permanently rejected (invalid_grant)`,{code:`OAUTH_REFRESH_FAILED`})}}if(!await t.acquire(n,c)){await t.waitForRelease(n,c);let e=await r();if(e&&i(e))return e;if(l?.recoverFromFailure){l.onStep?.(`recover-after-wait`,`lock unavailable, checking disk`);let e=await r();if(e){let t=await l.recoverFromFailure(e);if(t&&i(t))return t}}throw new s(`OAuth credential refresh failed: another process is refreshing but did not complete in time`,{code:`OAUTH_REFRESH_FAILED`})}try{let e=await r();if(e&&i(e))return e;try{let e=await a();return await o(e),e}catch(t){if(l?.recoverFromFailure&&e){l.onStep?.(`recover-after-failure`,`refresh failed: ${t.message}`);let n=await l.recoverFromFailure(e);if(n&&i(n))return n}throw l?.cacheFailure&&e&&l.cacheFailure(t,e),t}}finally{await t.release(n)}}function C(e){return{load:t=>e.load(t),save:(t,n)=>e.save(t,n),delete:t=>e.delete(t),list:()=>e.list()}}const w={atomic:!0,permSecure:!1,crossProcessLock:!1,watchable:!0};function T(e,t={}){let n=t.fileExtension??`.json`,r=r=>f(e,t.fileName?t.fileName(r):`${r}${n}`);return{capabilities:w,async load(e){return _(r(e),void 0)},async save(t,n){await h(e),await g(r(t),n)},async delete(e){await v(r(e))},async list(){try{let{readdir:t}=await import(`node:fs/promises`),r=await t(e),i=[];for(let e of r)n&&!e.endsWith(n)||i.push(e.slice(0,e.length-n.length));return i}catch(e){if(e?.code===`ENOENT`)return[];throw e}},watch(t){let r=null,i=d(e,{recursive:!1},(e,i)=>{if(!i||!i.endsWith(n))return;r&&clearTimeout(r);let a=i.slice(0,i.length-n.length);r=setTimeout(()=>{r=null,t(a)},200)});return()=>i.close()}}}export{p as OAUTH_SKEW_MS,b as REFRESH_LOCK_TIMEOUT_MS,C as backendToStore,T as createFileCredentialBackend,y as createOverlayStore,x as createRefreshLock,v as deleteFileIfExists,h as ensurePrivateDir,m as isExpiredAt,_ as readJsonFile,S as refreshUnderCrossProcessLock,g as writeJsonAtomic};
2
2
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/expiry.ts","../src/atomic-file.ts","../src/overlay-store.ts","../src/refresh-lock.ts"],"sourcesContent":["/**\n * expiry.ts —— OAuth 凭据过期判断单源(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 与 mcp 各写了一份「过期 + skew」判定——ai 的 `OAUTH_SKEW_MS`\n * (auth-store-constants.ts)与 mcp 的 `skewSeconds = 60`(token.ts)语义相同、取值\n * 相同、实现各自独立。本文件收敛为唯一实现;凭据形状不统一(ai 用 `{expires}`、\n * mcp 用 `{expiresAt}`),故函数只收「过期时间戳」数值,由各域自行取字段——\n * 不强行统一凭据类型(那会把两个域的迁移搅在一起,违反先建后迁的隔离原则)。\n */\n\n/**\n * OAuth 过期判断的时钟偏移余量(RFC-140 D2 语义,沿用 ai 既有值):网络延迟 +\n * 客户端/服务端时钟漂移可能导致 token 在服务端已失效但客户端仍判定为有效,\n * 下一次 API 调用才发现 401。提前 `OAUTH_SKEW_MS` 触发刷新可消除这类边界窗口\n * ——业界惯例 30-60s,此处取 60s。\n */\nexport const OAUTH_SKEW_MS = 60_000\n\n/**\n * 凭据是否已(或即将)过期。`expiresAt == null` = 无过期信息 → 判为未过期\n * (与 ai/mcp 既有行为一致:宁可先试、401 再刷新,不因缺字段拒绝可用凭据)。\n *\n * @param expiresAt 过期时间戳(epoch ms)。各域自行取字段:ai 传 `credentials.expires`、\n * mcp 传 `credentials.expiresAt`。\n * @param skewMs 时钟偏移余量,缺省 `OAUTH_SKEW_MS`。\n */\nexport function isExpiredAt(expiresAt: number | null | undefined, skewMs: number = OAUTH_SKEW_MS): boolean {\n if (expiresAt == null) return false\n return Date.now() + skewMs >= expiresAt\n}\n\n/**\n * 凭据是否在 `windowMs` 内即将过期(预警/提前刷新窗口,与 isExpiredAt 同一 skew 语义)。\n * 可用于「提前 N 分钟刷新」策略;当前无生产消费方,作为过期判断族的语义补全\n * (避免未来第三处手写 `Date.now() + X >= expiresAt`)。\n */\nexport function expiresWithin(\n expiresAt: number | null | undefined,\n windowMs: number,\n): boolean {\n if (expiresAt == null) return false\n return Date.now() + windowMs >= expiresAt\n}\n","/**\n * atomic-file.ts —— 凭据文件的原子写盘纹理单源(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 的 `auth-store.ts` saveSerial 与 mcp 的 `token-store.ts` save\n * 各自实现了同一套纹理——ensureDir(0700) + tmp-${pid} 写入 + chmod 0600 + rename\n * 原子替换 + 失败清理 tmp。凭据文件的安全语义(「从落盘第一刻起不可被其他本地用户\n * 读取、读者永不观测到半写状态」)必须逐字节一致,双实现漂移即安全事故。\n * 本文件提供唯一实现;ai/mcp 各保留自己的目录布局与 key 命名(域职责),\n * 只把「怎么写一个凭据文件」交给这里。\n */\nimport { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'\n\n/**\n * 确保凭据目录存在且权限收紧到 0700。mode 仅对新建目录生效(受 umask 影响),\n * 已存在目录需显式 chmod 收紧;权限收紧失败不阻断主流程(如非 POSIX 文件系统),\n * 仅降级安全性——与 ai/mcp 既有行为一致。\n */\nexport async function ensurePrivateDir(dir: string): Promise<void> {\n await mkdir(dir, { recursive: true, mode: 0o700 })\n try {\n await chmod(dir, 0o700)\n } catch {\n // 降级安全性,不阻断。\n }\n}\n\n/**\n * 原子写 JSON 文件:tmp-${pid} 写入 → chmod 0600 → rename 原子替换。\n * 失败时清理 tmp 后 rethrow。rename 保证读者永远看不到半写状态;\n * tmp+chmod 保证凭据从落盘第一刻起就不可被其他本地用户读取。\n */\nexport async function writeJsonAtomic(path: string, data: unknown): Promise<void> {\n const tmpPath = `${path}.tmp-${process.pid}`\n try {\n await writeFile(tmpPath, JSON.stringify(data, null, 2), {\n encoding: 'utf-8',\n mode: 0o600,\n })\n try {\n await chmod(tmpPath, 0o600)\n } catch {\n // 同 ensurePrivateDir:权限收紧失败仅降级安全性,不阻断保存。\n }\n await rename(tmpPath, path)\n } catch (err) {\n await unlink(tmpPath).catch(() => {})\n throw err\n }\n}\n\n/**\n * 读 JSON 文件;ENOENT 返回 `fallback`,解析失败返回 `fallback`(凭据文件的\n * fail-soft 读取语义:不存在/损坏都视为「无凭据」,与 ai/mcp 既有行为一致)。\n * 其余错误(EACCES 等)rethrow——那不是「无凭据」而是环境故障,调用方需要知道。\n */\nexport async function readJsonFile<T>(path: string, fallback: T): Promise<T> {\n try {\n const raw = await readFile(path, 'utf-8')\n return JSON.parse(raw) as T\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code\n if (code === 'ENOENT') return fallback\n if (err instanceof SyntaxError) return fallback\n throw err\n }\n}\n\n/** 删除文件;ENOENT 静默(幂等删除语义)。 */\nexport async function deleteFileIfExists(path: string): Promise<void> {\n try {\n await unlink(path)\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code\n if (code === 'ENOENT') return\n throw err\n }\n}\n","/**\n * overlay-store.ts —— 影子模式 overlay 的通用实现(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 的 AuthStore overlay 与 mcp 的 createOverlayTokenStore 各自\n * 实现了同一语义(RFC-345,对齐 Chromium OverlayUserPrefStore)——读穿透到 base\n * (继承磁盘既有数据),写/删只进内存 overlay,进程退出后磁盘数据逐字节不变。\n * 本文件提供「按 key 存取」store 的通用 overlay 包装;ai/mcp 的凭据 store 都符合\n * 该接口形状,各自只需传自己的 base 实现。\n *\n * 语义细节(与两域既有行为逐条对齐):\n * - load:overlay 命中优先(含「本会话内 delete 过」的墓碑,返回 undefined);否则回退 base;\n * - save:只写 overlay,绝不落盘;\n * - delete:只在 overlay 记墓碑,不删磁盘;\n * - list:base 列表并入 overlay 新增、扣除 overlay 墓碑。\n */\n\n/** 按 key 存取的通用 store 接口(凭据域的公共形状)。 */\nexport interface ReadWriteStore<T> {\n load(key: string): Promise<T | undefined>\n save(key: string, value: T): Promise<void>\n delete(key: string): Promise<void>\n list(): Promise<string[]>\n}\n\n/** 创建影子模式 overlay store。`base` 为真实持久层(文件/内存等)。 */\nexport function createOverlayStore<T>(base: ReadWriteStore<T>): ReadWriteStore<T> {\n const writes = new Map<string, T>()\n const tombstones = new Set<string>()\n return {\n async load(key) {\n if (writes.has(key)) return writes.get(key)\n if (tombstones.has(key)) return undefined\n return base.load(key)\n },\n async save(key, value) {\n tombstones.delete(key)\n writes.set(key, value)\n },\n async delete(key) {\n writes.delete(key)\n tombstones.add(key)\n },\n async list() {\n const names = new Set<string>(await base.list())\n for (const t of tombstones) names.delete(t)\n for (const w of writes.keys()) names.add(w)\n return [...names]\n },\n }\n}\n","/**\n * refresh-lock.ts —— 跨进程凭据刷新锁 + 通用刷新协调(RFC-374 M1)。\n *\n * 背景:本机可能同时运行多个独立进程(CLI 会话、桥接进程等),共享同一份凭据目录。\n * 轮换型 refresh_token 在多进程几乎同时用同一个旧 token 刷新时会双花——服务端只认\n * 先到者,其余进程刷新失败并非凭据过期而是被抢先轮换。本文件提供:\n * ① `createRefreshLock`:per-key 跨进程文件锁工厂(委托 @x-otto/shared file-lock,\n * RFC-303 D7 原语,不做第三套锁实现);\n * ② `refreshUnderCrossProcessLock`:通用刷新协调流程(ai 的 OAuthRefreshCoordinator\n * 与 mcp 的 refreshWithCrossProcessLock 共有的骨架——拿锁 → 重读确认 → 刷新落盘\n * → finally 释放;拿不到锁 → 等待 → 读盘复用)。\n *\n * 凭据形状泛型化:ai 用 `{refresh,access,expires}`、mcp 用 `{refreshToken,accessToken,\n * expiresAt}`——本文件只经 `load/isFresh/save` 端口操作凭据,不感知形状。\n *\n * 域职责留在调用方:锁目录、key 命名(sanitize)、锁文件前缀(**字节级不可变**,\n * ai 的历史前缀 `auth-refresh` 与 mcp 的 `mcp-refresh` 都是滚动升级互斥的硬约束)、\n * token endpoint 与刷新网络调用。\n */\nimport {\n DEFAULT_FILE_LOCK_TIMEOUT_MS,\n OAuthError,\n acquireFileLock,\n releaseFileLock,\n waitForFileLockRelease,\n} from '@x-otto/shared'\n\n/** 陈旧锁清理阈值 = 等待释放超时(与 ai 的规则9一致,两者必须相等)。 */\nexport const REFRESH_LOCK_TIMEOUT_MS = DEFAULT_FILE_LOCK_TIMEOUT_MS\n\n/** 跨进程刷新锁句柄(per-key 操作)。 */\nexport interface RefreshLock {\n /** 尝试获取锁。成功 true(调用方持锁,须 try/finally 释放);被占用/异常 false。 */\n acquire(key: string, timeoutMs?: number): Promise<boolean>\n /** 释放锁(幂等,并停止心跳)。 */\n release(key: string): Promise<void>\n /** 等待另一进程持有的锁释放,超时返回 false(调用方走读盘兜底)。 */\n waitForRelease(key: string, timeoutMs?: number): Promise<boolean>\n}\n\n/**\n * 创建跨进程刷新锁工厂。\n *\n * @param lockDir 锁文件目录(与凭据文件同目录,对齐 ai「锁挨着 auth.json」风格)。\n * @param prefix 锁文件前缀(如 `auth-refresh`/`mcp-refresh`)。**一旦上线不可变**——\n * 换前缀 = 新旧进程各锁各的文件 = 等于没锁(ai 的滚动升级硬约束)。\n */\nexport function createRefreshLock(lockDir: string, prefix: string): RefreshLock {\n return {\n acquire(key, timeoutMs = REFRESH_LOCK_TIMEOUT_MS) {\n return acquireFileLock(lockDir, key, { timeoutMs, prefix })\n },\n release(key) {\n return releaseFileLock(lockDir, key, { prefix })\n },\n waitForRelease(key, timeoutMs = REFRESH_LOCK_TIMEOUT_MS) {\n return waitForFileLockRelease(lockDir, key, { timeoutMs, prefix })\n },\n }\n}\n\nexport interface RefreshUnderLockOptions<T> {\n /** 锁工厂(createRefreshLock 产物,域侧持有)。 */\n lock: RefreshLock\n /** 锁 key(域侧 sanitize 后的标识)。 */\n key: string\n /** 读取当前凭据(可能已被另一进程刷新)。 */\n load(): Promise<T | undefined>\n /** 凭据是否仍新鲜(未过期、可直接用)。 */\n isFresh(credentials: T): boolean\n /** 真正发起刷新网络请求。 */\n refresh(): Promise<T>\n /** 保存刷新成功的新凭据。 */\n save(credentials: T): Promise<void>\n /** 陈旧锁阈值,缺省 REFRESH_LOCK_TIMEOUT_MS。 */\n timeoutMs?: number\n}\n\n/**\n * 跨进程互斥刷新:同一时刻同一 key 全机器只有一个进程真正发起 `refresh()` 网络请求,\n * 其余进程等待后直接读盘复用结果。\n *\n * 与 ai `OAuthRefreshCoordinator.refreshWithCrossProcessLock` 的语义对齐(本函数是\n * 其公共骨架的提取;ai 的永久失败缓存/磁盘恢复等额外层仍在其自身实现中):\n * - 拿不到锁 → 等待释放(超时=陈旧锁阈值)→ 读盘看另一进程是否已刷新成功,是则复用;\n * 否则抛错(不无限等待、不用陈旧 refresh_token 继续打网络)。\n * - 拿到锁 → 重读一次确认是否仍不新鲜(本进程可能在「发现不新鲜→真正拿锁」的窗口里\n * 已被另一进程抢先刷新),仍不新鲜才真正刷新并落盘;`finally` 保证无论成败都释放锁。\n */\nexport async function refreshUnderCrossProcessLock<T>(\n options: RefreshUnderLockOptions<T>,\n): Promise<T> {\n const { lock, key, load, isFresh, refresh, save, timeoutMs = REFRESH_LOCK_TIMEOUT_MS } = options\n\n const gotLock = await lock.acquire(key, timeoutMs)\n if (!gotLock) {\n // 另一进程正在刷新——等待其完成。无论 released 与否都读一次盘:released 时持锁进程\n // 可能已刷新成功并落盘;超时时陈旧锁可能已被强制接管、新凭据也已落盘。\n await lock.waitForRelease(key, timeoutMs)\n const updated = await load()\n if (updated && isFresh(updated)) {\n return updated\n }\n throw new OAuthError(\n `OAuth credential refresh failed: another process is refreshing but did not complete in time`,\n { code: 'OAUTH_REFRESH_FAILED' },\n )\n }\n\n try {\n // 拿到锁后重读:覆盖「发现不新鲜→拿锁」窗口内另一进程已抢先刷新并落盘的竞态。\n const latest = await load()\n if (latest && isFresh(latest)) {\n return latest\n }\n const refreshed = await refresh()\n await save(refreshed)\n return refreshed\n } finally {\n await lock.release(key)\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/expiry.ts","../src/atomic-file.ts","../src/overlay-store.ts","../src/refresh-lock.ts","../src/backend.ts","../src/file-backend.ts"],"sourcesContent":["/**\n * expiry.ts —— OAuth 凭据过期判断单源(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 与 mcp 各写了一份「过期 + skew」判定——ai 的 `OAUTH_SKEW_MS`\n * (auth-store-constants.ts)与 mcp 的 `skewSeconds = 60`(token.ts)语义相同、取值\n * 相同、实现各自独立。本文件收敛为唯一实现;凭据形状不统一(ai 用 `{expires}`、\n * mcp 用 `{expiresAt}`),故函数只收「过期时间戳」数值,由各域自行取字段——\n * 不强行统一凭据类型(那会把两个域的迁移搅在一起,违反先建后迁的隔离原则)。\n */\n\n/**\n * OAuth 过期判断的时钟偏移余量(RFC-140 D2 语义,沿用 ai 既有值):网络延迟 +\n * 客户端/服务端时钟漂移可能导致 token 在服务端已失效但客户端仍判定为有效,\n * 下一次 API 调用才发现 401。提前 `OAUTH_SKEW_MS` 触发刷新可消除这类边界窗口\n * ——业界惯例 30-60s,此处取 60s。\n */\nexport const OAUTH_SKEW_MS = 60_000\n\n/**\n * 凭据是否已(或即将)过期。`expiresAt == null` = 无过期信息 → 判为未过期\n * (与 ai/mcp 既有行为一致:宁可先试、401 再刷新,不因缺字段拒绝可用凭据)。\n *\n * @param expiresAt 过期时间戳(epoch ms)。各域自行取字段:ai 传 `credentials.expires`、\n * mcp 传 `credentials.expiresAt`。\n * @param skewMs 时钟偏移余量,缺省 `OAUTH_SKEW_MS`。\n */\nexport function isExpiredAt(expiresAt: number | null | undefined, skewMs: number = OAUTH_SKEW_MS): boolean {\n if (expiresAt == null) return false\n return Date.now() + skewMs >= expiresAt\n}\n","/**\n * atomic-file.ts —— 凭据文件的原子写盘纹理单源(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 的 `auth-store.ts` saveSerial 与 mcp 的 `token-store.ts` save\n * 各自实现了同一套纹理——ensureDir(0700) + tmp-${pid} 写入 + chmod 0600 + rename\n * 原子替换 + 失败清理 tmp。凭据文件的安全语义(「从落盘第一刻起不可被其他本地用户\n * 读取、读者永不观测到半写状态」)必须逐字节一致,双实现漂移即安全事故。\n * 本文件提供唯一实现;ai/mcp 各保留自己的目录布局与 key 命名(域职责),\n * 只把「怎么写一个凭据文件」交给这里。\n */\nimport { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'\n\n/**\n * 确保凭据目录存在且权限收紧到 0700。mode 仅对新建目录生效(受 umask 影响),\n * 已存在目录需显式 chmod 收紧;权限收紧失败不阻断主流程(如非 POSIX 文件系统),\n * 仅降级安全性——与 ai/mcp 既有行为一致。\n */\nexport async function ensurePrivateDir(dir: string): Promise<void> {\n await mkdir(dir, { recursive: true, mode: 0o700 })\n try {\n await chmod(dir, 0o700)\n } catch {\n // 降级安全性,不阻断。\n }\n}\n\n/**\n * 原子写 JSON 文件:tmp-${pid} 写入 → chmod 0600 → rename 原子替换。\n * 失败时清理 tmp 后 rethrow。rename 保证读者永远看不到半写状态;\n * tmp+chmod 保证凭据从落盘第一刻起就不可被其他本地用户读取。\n */\nexport async function writeJsonAtomic(path: string, data: unknown): Promise<void> {\n const tmpPath = `${path}.tmp-${process.pid}`\n try {\n await writeFile(tmpPath, JSON.stringify(data, null, 2), {\n encoding: 'utf-8',\n mode: 0o600,\n })\n try {\n await chmod(tmpPath, 0o600)\n } catch {\n // 同 ensurePrivateDir:权限收紧失败仅降级安全性,不阻断保存。\n }\n await rename(tmpPath, path)\n } catch (err) {\n await unlink(tmpPath).catch(() => {})\n throw err\n }\n}\n\n/**\n * 读 JSON 文件;ENOENT 返回 `fallback`,解析失败返回 `fallback`(凭据文件的\n * fail-soft 读取语义:不存在/损坏都视为「无凭据」,与 ai/mcp 既有行为一致)。\n * 其余错误(EACCES 等)rethrow——那不是「无凭据」而是环境故障,调用方需要知道。\n */\nexport async function readJsonFile<T>(path: string, fallback: T): Promise<T> {\n try {\n const raw = await readFile(path, 'utf-8')\n return JSON.parse(raw) as T\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code\n if (code === 'ENOENT') return fallback\n if (err instanceof SyntaxError) return fallback\n throw err\n }\n}\n\n/** 删除文件;ENOENT 静默(幂等删除语义)。 */\nexport async function deleteFileIfExists(path: string): Promise<void> {\n try {\n await unlink(path)\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code\n if (code === 'ENOENT') return\n throw err\n }\n}\n","/**\n * overlay-store.ts —— 影子模式 overlay 的通用实现(RFC-374 M1)。\n *\n * 背景(S2 收敛):ai 的 AuthStore overlay 与 mcp 的 createOverlayTokenStore 各自\n * 实现了同一语义(RFC-345,对齐 Chromium OverlayUserPrefStore)——读穿透到 base\n * (继承磁盘既有数据),写/删只进内存 overlay,进程退出后磁盘数据逐字节不变。\n * 本文件提供「按 key 存取」store 的通用 overlay 包装;ai/mcp 的凭据 store 都符合\n * 该接口形状,各自只需传自己的 base 实现。\n *\n * 语义细节(与两域既有行为逐条对齐):\n * - load:overlay 命中优先(含「本会话内 delete 过」的墓碑,返回 undefined);否则回退 base;\n * - save:只写 overlay,绝不落盘;\n * - delete:只在 overlay 记墓碑,不删磁盘;\n * - list:base 列表并入 overlay 新增、扣除 overlay 墓碑。\n */\n\n/** 按 key 存取的通用 store 接口(凭据域的公共形状)。 */\nexport interface ReadWriteStore<T> {\n load(key: string): Promise<T | undefined>\n save(key: string, value: T): Promise<void>\n delete(key: string): Promise<void>\n list(): Promise<string[]>\n}\n\n/** 创建影子模式 overlay store。`base` 为真实持久层(文件/内存等)。 */\nexport function createOverlayStore<T>(base: ReadWriteStore<T>): ReadWriteStore<T> {\n const writes = new Map<string, T>()\n const tombstones = new Set<string>()\n return {\n async load(key) {\n if (writes.has(key)) return writes.get(key)\n if (tombstones.has(key)) return undefined\n return base.load(key)\n },\n async save(key, value) {\n tombstones.delete(key)\n writes.set(key, value)\n },\n async delete(key) {\n writes.delete(key)\n tombstones.add(key)\n },\n async list() {\n const names = new Set<string>(await base.list())\n for (const t of tombstones) names.delete(t)\n for (const w of writes.keys()) names.add(w)\n return [...names]\n },\n }\n}\n","/**\n * refresh-lock.ts —— 跨进程凭据刷新锁 + 通用刷新协调(RFC-374 M1)。\n *\n * 背景:本机可能同时运行多个独立进程(CLI 会话、桥接进程等),共享同一份凭据目录。\n * 轮换型 refresh_token 在多进程几乎同时用同一个旧 token 刷新时会双花——服务端只认\n * 先到者,其余进程刷新失败并非凭据过期而是被抢先轮换。本文件提供:\n * ① `createRefreshLock`:per-key 跨进程文件锁工厂(委托 @x-otto/shared file-lock,\n * RFC-303 D7 原语,不做第三套锁实现);\n * ② `refreshUnderCrossProcessLock`:通用刷新协调流程(ai 的 OAuthRefreshCoordinator\n * 与 mcp 的 refreshWithCrossProcessLock 共有的骨架——拿锁 → 重读确认 → 刷新落盘\n * → finally 释放;拿不到锁 → 等待 → 读盘复用)。\n *\n * 凭据形状泛型化:ai 用 `{refresh,access,expires}`、mcp 用 `{refreshToken,accessToken,\n * expiresAt}`——本文件只经 `load/isFresh/save` 端口操作凭据,不感知形状。\n *\n * 域职责留在调用方:锁目录、key 命名(sanitize)、锁文件前缀(**字节级不可变**,\n * ai 的历史前缀 `auth-refresh` 与 mcp 的 `mcp-refresh` 都是滚动升级互斥的硬约束)、\n * token endpoint 与刷新网络调用。\n */\nimport {\n DEFAULT_FILE_LOCK_TIMEOUT_MS,\n OAuthError,\n acquireFileLock,\n releaseFileLock,\n waitForFileLockRelease,\n} from '@x-otto/shared'\n\n/** 陈旧锁清理阈值 = 等待释放超时(与 ai 的规则9一致,两者必须相等)。 */\nexport const REFRESH_LOCK_TIMEOUT_MS = DEFAULT_FILE_LOCK_TIMEOUT_MS\n\n/** 跨进程刷新锁句柄(per-key 操作)。 */\nexport interface RefreshLock {\n /** 尝试获取锁。成功 true(调用方持锁,须 try/finally 释放);被占用/异常 false。 */\n acquire(key: string, timeoutMs?: number): Promise<boolean>\n /** 释放锁(幂等,并停止心跳)。 */\n release(key: string): Promise<void>\n /** 等待另一进程持有的锁释放,超时返回 false(调用方走读盘兜底)。 */\n waitForRelease(key: string, timeoutMs?: number): Promise<boolean>\n}\n\n/**\n * 创建跨进程刷新锁工厂。\n *\n * @param lockDir 锁文件目录(与凭据文件同目录,对齐 ai「锁挨着 auth.json」风格)。\n * @param prefix 锁文件前缀(如 `auth-refresh`/`mcp-refresh`)。**一旦上线不可变**——\n * 换前缀 = 新旧进程各锁各的文件 = 等于没锁(ai 的滚动升级硬约束)。\n */\nexport function createRefreshLock(lockDir: string, prefix: string): RefreshLock {\n return {\n acquire(key, timeoutMs = REFRESH_LOCK_TIMEOUT_MS) {\n return acquireFileLock(lockDir, key, { timeoutMs, prefix })\n },\n release(key) {\n return releaseFileLock(lockDir, key, { prefix })\n },\n waitForRelease(key, timeoutMs = REFRESH_LOCK_TIMEOUT_MS) {\n return waitForFileLockRelease(lockDir, key, { timeoutMs, prefix })\n },\n }\n}\n\n/**\n * 可选刷新策略层:将 ai 淬炼出的三层防御(永久失败缓存 / 磁盘恢复 / 可观测性)\n * 泛化为可注入钩子,骨架零行为变化(未提供 strategy 时与原有行为完全一致)。\n *\n * 设计背景:ai 的 OAuthRefreshCoordinator 在叶包骨架之上多了三层策略——\n * ① 永久失败缓存(invalid_grant fail-fast,不再竞锁/打网络);\n * ② 刷新失败后磁盘恢复(另一进程可能已抢先刷新并落盘);\n * ③ 恢复步骤可观测性(onRecoveryStep 旁路回调)。\n * mcp 消费叶包骨架但此前无法获得这三层防御。策略钩子让 mcp 注入等价能力,\n * 同时为 ai 后续迁移到骨架提供迁移路径(策略从 ai 内部 Map 提取为独立对象)。\n */\nexport interface RefreshStrategy<T> {\n /**\n * 同一凭据快照是否此前已被永久拒绝(如 invalid_grant)。\n * 返回 true 时骨架跳过锁/网络,先尝试 recoverFromFailure,仍无恢复才抛错。\n * 缺省(未提供)= 不 fail-fast。\n */\n shouldFailFast?(latest: T): boolean\n\n /**\n * 从磁盘恢复:另一进程可能已抢先刷新并落盘新凭据。\n * 在三个失败点调用:fail-fast 命中后、锁等待超时后、refresh() 失败后。\n * 返回恢复的凭据或 undefined。调用方收到非 undefined 后仍需 isFresh 校验。\n * 缺省(未提供)= 不恢复。\n */\n recoverFromFailure?(latest: T): Promise<T | undefined>\n\n /**\n * 缓存永久失败(如 invalid_grant):同一凭据快照后续刷新 fail-fast 不打网络。\n * 在 refresh() 失败后调用,调用方据 error 判定是否永久失败。\n * 缺省(未提供)= 不缓存。\n */\n cacheFailure?(error: unknown, latest: T): void\n\n /**\n * 恢复步骤可观测性回调(只读旁路,不影响控制流)。\n * 骨架在 fail-fast / 锁等待 / 磁盘恢复等步骤触发时调用。\n */\n onStep?(step: string, detail?: string): void\n}\n\nexport interface RefreshUnderLockOptions<T> {\n /** 锁工厂(createRefreshLock 产物,域侧持有)。 */\n lock: RefreshLock\n /** 锁 key(域侧 sanitize 后的标识)。 */\n key: string\n /** 读取当前凭据(可能已被另一进程刷新)。 */\n load(): Promise<T | undefined>\n /** 凭据是否仍新鲜(未过期、可直接用)。 */\n isFresh(credentials: T): boolean\n /** 真正发起刷新网络请求。 */\n refresh(): Promise<T>\n /** 保存刷新成功的新凭据。 */\n save(credentials: T): Promise<void>\n /** 陈旧锁阈值,缺省 REFRESH_LOCK_TIMEOUT_MS。 */\n timeoutMs?: number\n /** 可选策略层:永久失败缓存 / 磁盘恢复 / 可观测性。缺省时零行为变化。 */\n strategy?: RefreshStrategy<T>\n}\n\n/**\n * 跨进程互斥刷新:同一时刻同一 key 全机器只有一个进程真正发起 `refresh()` 网络请求,\n * 其余进程等待后直接读盘复用结果。\n *\n * 基本流程(无 strategy 时,与 RFC-374 M1 行为逐行等价):\n * - 拿不到锁 → 等待释放(超时=陈旧锁阈值)→ 读盘看另一进程是否已刷新成功,是则复用;\n * 否则抛错(不无限等待、不用陈旧 refresh_token 继续打网络)。\n * - 拿到锁 → 重读一次确认是否仍不新鲜(本进程可能在「发现不新鲜→真正拿锁」的窗口里\n * 已被另一进程抢先刷新),仍不新鲜才真正刷新并落盘;`finally` 保证无论成败都释放锁。\n *\n * 策略层扩展(strategy 提供时,对齐 ai OAuthRefreshCoordinator 三层防御):\n * - shouldFailFast → 拿锁前检查永久失败缓存(invalid_grant fail-fast 不竞锁/不打网络),\n * 先尝试 recoverFromFailure 磁盘恢复,仍无恢复才抛错;\n * - recoverFromFailure → 在锁等待超时后 / refresh() 失败后调用,读盘看另一进程是否已落盘;\n * - cacheFailure → refresh() 失败后调用,据 error 判定是否缓存为永久失败;\n * - onStep → 各步骤旁路可观测性。\n */\nexport async function refreshUnderCrossProcessLock<T>(\n options: RefreshUnderLockOptions<T>,\n): Promise<T> {\n const { lock, key, load, isFresh, refresh, save, timeoutMs = REFRESH_LOCK_TIMEOUT_MS, strategy } = options\n\n // 策略层:fail-fast 检查(拿锁前)——同一 refresh_token 快照此前被永久拒绝则不竞锁/不打网络。\n if (strategy?.shouldFailFast) {\n const latest = await load()\n if (latest && strategy.shouldFailFast(latest)) {\n strategy.onStep?.('fail-fast', 'permanent failure cache hit, checking disk')\n if (strategy.recoverFromFailure) {\n const recovered = await strategy.recoverFromFailure(latest)\n if (recovered && isFresh(recovered)) return recovered\n }\n throw new OAuthError(\n `OAuth credential refresh failed: credential permanently rejected (invalid_grant)`,\n { code: 'OAUTH_REFRESH_FAILED' },\n )\n }\n }\n\n const gotLock = await lock.acquire(key, timeoutMs)\n if (!gotLock) {\n // 另一进程正在刷新——等待其完成。无论 released 与否都读一次盘:released 时持锁进程\n // 可能已刷新成功并落盘;超时时陈旧锁可能已被强制接管、新凭据也已落盘。\n await lock.waitForRelease(key, timeoutMs)\n const updated = await load()\n if (updated && isFresh(updated)) {\n return updated\n }\n // 策略层:锁等待超时后尝试磁盘恢复\n if (strategy?.recoverFromFailure) {\n strategy.onStep?.('recover-after-wait', 'lock unavailable, checking disk')\n const latest = await load()\n if (latest) {\n const recovered = await strategy.recoverFromFailure(latest)\n if (recovered && isFresh(recovered)) return recovered\n }\n }\n throw new OAuthError(\n `OAuth credential refresh failed: another process is refreshing but did not complete in time`,\n { code: 'OAUTH_REFRESH_FAILED' },\n )\n }\n\n try {\n // 拿到锁后重读:覆盖「发现不新鲜→拿锁」窗口内另一进程已抢先刷新并落盘的竞态。\n const latest = await load()\n if (latest && isFresh(latest)) {\n return latest\n }\n\n try {\n const refreshed = await refresh()\n await save(refreshed)\n return refreshed\n } catch (error) {\n // 策略层:刷新失败后磁盘恢复——另一进程可能在此期间已抢先刷新并落盘。\n if (strategy?.recoverFromFailure && latest) {\n strategy.onStep?.('recover-after-failure', `refresh failed: ${(error as Error).message}`)\n const recovered = await strategy.recoverFromFailure(latest)\n if (recovered && isFresh(recovered)) return recovered\n }\n\n // 策略层:缓存永久失败(invalid_grant 等)——同一凭据快照后续 fail-fast。\n if (strategy?.cacheFailure && latest) {\n strategy.cacheFailure(error, latest)\n }\n\n throw error\n }\n } finally {\n await lock.release(key)\n }\n}\n","/**\n * backend.ts —— 凭据存储后端契约 + 能力声明(RFC-405 M1)。\n *\n * 定位:在 @x-otto/credentials 叶包定义「凭据存哪」的可替换层——\n * 文件后端、keyring 后端、KMS 后端等各自实现 CredentialBackend 接口,\n * 宿主据 capabilities 决定是否套 overlay/锁组合器。\n *\n * 与 deepseek-harness dsh-credentials 的差异:otto 的 OAuth token 生命周期\n * (轮换型 refresh_token + 跨进程锁 + 多进程共享)比 API key 引用解析重得多,\n * 需要更丰富的能力声明——不是所有后端都需要文件锁,也不是所有后端都需要 chmod。\n */\nimport type { ReadWriteStore } from './overlay-store'\n\n/**\n * 后端能力声明——宿主据此决定是否套 overlay/锁组合器。\n *\n * 盲目给 keyring 套文件锁是浪费(keyring 的 OS 级原子操作不需要 file-lock);\n * 盲目给文件后端跳过 chmod 是安全隐患(文件权限必须收紧到 0600)。\n * 能力位让宿主只套「后端缺的能力」。\n */\nexport interface CredentialBackendCapabilities {\n /**\n * 写操作是原子的(读者不会观测到半写状态)。\n * 文件后端=true(tmp+rename);keyring 后端取决于实现(security CLI 是原子的)。\n * atomic=false 时宿主应套 overlay 组合器(读穿透、写只内存,消除半写观测)。\n */\n atomic: boolean\n /**\n * 存储自带权限隔离(不需 chmod 收紧)。\n * 文件后端=false(需 ensurePrivateDir 0700 + writeJsonAtomic 0600);\n * keyring 后端=true(OS 级隔离,其他用户读不到)。\n */\n permSecure: boolean\n /**\n * 支持跨进程互斥(不需宿主套文件锁)。\n * 文件后端=false(需 createRefreshLock + file-lock);\n * keyring 后端=true(security CLI 的 add/delete 是 OS 原子操作)。\n * crossProcessLock=true 时宿主可跳过 refresh-lock 组合器。\n */\n crossProcessLock: boolean\n /**\n * 支持外部变更感知(fs.watch 或等价)。\n * 文件后端=true(fs.watch baseDir);keyring 后端=false(无原生通知)。\n * watchable=true 且后端提供 watch() 时,宿主可接线外部变更感知。\n */\n watchable: boolean\n}\n\n/**\n * 凭据存储后端契约。文件、keyring、KMS 等各自实现此接口。\n *\n * 与 ReadWriteStore 的关系:形状相同(load/save/delete/list),但多了\n * capabilities + watch。不继承 ReadWriteStore(后者无能力声明)。\n *\n * 泛型化:K=key 类型(provider id / server name / credential ref),\n * V=凭据值类型(各域自定——ai 用 AuthEntry,mcp 用 OAuthCredentials)。\n */\nexport interface CredentialBackend<K extends string, V> {\n /** 加载某 key 的凭据;不存在返回 undefined。 */\n load(key: K): Promise<V | undefined>\n /** 保存凭据。后端据 capabilities.atomic 决定是否原子写入。 */\n save(key: K, value: V): Promise<void>\n /** 删除凭据(幂等——删除不存在的 key 不报错)。 */\n delete(key: K): Promise<void>\n /** 列出所有有凭据的 key。 */\n list(): Promise<string[]>\n /** 后端能力声明——宿主据此决定组合器策略。 */\n readonly capabilities: CredentialBackendCapabilities\n /**\n * 可选:外部变更感知(capabilities.watchable=true 时提供)。\n * 返回取消订阅函数。宿主据此接线跨进程凭据变更感知。\n */\n watch?(onChange: (key: K) => void): () => void\n}\n\n/**\n * 将 CredentialBackend 适配为 ReadWriteStore——供 createOverlayStore 等组合器使用。\n * 后端缺的语义由宿主侧组合器补(overlay 补原子性、createRefreshLock 补跨进程锁)。\n */\nexport function backendToStore<K extends string, V>(\n backend: CredentialBackend<K, V>,\n): ReadWriteStore<V> {\n return {\n load: (key: string) => backend.load(key as K),\n save: (key: string, value: V) => backend.save(key as K, value),\n delete: (key: string) => backend.delete(key as K),\n list: () => backend.list(),\n }\n}\n","/**\n * file-backend.ts —— 文件存储后端实现(RFC-405 M2)。\n *\n * 组合现有 atomic-file 原语(ensurePrivateDir + writeJsonAtomic + readJsonFile +\n * deleteFileIfExists)为 CredentialBackend 契约的具体实现。不改动既有原语——\n * FileBackend 是原语的新消费者,不是替代品。createOverlayStore 仍可直接使用\n * (对现有 ai/mcp 消费方零影响)。\n *\n * 能力位:\n * - atomic: true(tmp-${pid} + rename 原子替换)\n * - permSecure: false(需 ensurePrivateDir 0700 + writeJsonAtomic chmod 0600)\n * - crossProcessLock: false(需宿主套 createRefreshLock + file-lock)\n * - watchable: true(fs.watch baseDir)\n */\nimport { watch } from 'node:fs'\nimport { join } from 'node:path'\nimport {\n ensurePrivateDir,\n writeJsonAtomic,\n readJsonFile,\n deleteFileIfExists,\n} from './atomic-file'\nimport type { CredentialBackend, CredentialBackendCapabilities } from './backend'\n\nconst FILE_CAPABILITIES: CredentialBackendCapabilities = {\n atomic: true,\n permSecure: false,\n crossProcessLock: false,\n watchable: true,\n}\n\nexport interface FileBackendOptions {\n /**\n * 凭据文件扩展名,缺省 `.json`。key + 扩展名 = 文件名。\n * 目录权限 0700,文件权限 0600(由 writeJsonAtomic 保证)。\n */\n fileExtension?: string\n /**\n * 自定义 key → 文件名映射(缺省 = key + fileExtension)。\n * 用于需要 sanitize key(如 mcp 的 sanitizeServerName)的场景。\n * 调用方负责防路径穿越(返回值不含 `/`、`..`)。\n */\n fileName?: (key: string) => string\n}\n\n/**\n * 创建文件存储后端。每个 key 一个 JSON 文件,存于 baseDir 下。\n *\n * 安全纹理(与 ai auth-store / mcp token-store 逐条对齐):\n * - 目录权限 0700(ensurePrivateDir)\n * - 文件权限 0600(writeJsonAtomic 的 chmod)\n * - 原子替换(tmp + rename,读者永远看不到半写状态)\n * - fail-soft 读(ENOENT/损坏 → fallback,EACCES rethrow)\n */\nexport function createFileCredentialBackend<K extends string, V>(\n baseDir: string,\n options: FileBackendOptions = {},\n): CredentialBackend<K, V> {\n const ext = options.fileExtension ?? '.json'\n const toPath = (key: string): string =>\n join(baseDir, options.fileName ? options.fileName(key) : `${key}${ext}`)\n\n return {\n capabilities: FILE_CAPABILITIES,\n\n async load(key) {\n return readJsonFile<V | undefined>(toPath(key), undefined)\n },\n\n async save(key, value) {\n await ensurePrivateDir(baseDir)\n await writeJsonAtomic(toPath(key), value)\n },\n\n async delete(key) {\n await deleteFileIfExists(toPath(key))\n },\n\n async list() {\n try {\n const { readdir } = await import('node:fs/promises')\n const entries = await readdir(baseDir)\n const names: string[] = []\n for (const f of entries) {\n if (ext && !f.endsWith(ext)) continue\n names.push(f.slice(0, f.length - ext.length))\n }\n return names\n } catch (err) {\n const code = (err as NodeJS.ErrnoException)?.code\n if (code === 'ENOENT') return []\n throw err\n }\n },\n\n watch(onChange) {\n let timer: ReturnType<typeof setTimeout> | null = null\n const watcher = watch(baseDir, { recursive: false }, (_eventType, filename) => {\n if (!filename || !filename.endsWith(ext)) return\n // 防抖:rename+chmod 等操作可能连续触发多次事件\n if (timer) clearTimeout(timer)\n const key = filename.slice(0, filename.length - ext.length)\n timer = setTimeout(() => {\n timer = null\n onChange(key as K)\n }, 200)\n })\n return () => watcher.close()\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x-otto/credentials",
3
- "version": "0.0.1-alpha.1",
3
+ "version": "0.0.1-alpha.2",
4
4
  "description": "凭据文件生命周期原语叶包——原子写盘/影子 overlay/过期判断/跨进程刷新锁(RFC-374 M1)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,7 @@
15
15
  "dist"
16
16
  ],
17
17
  "dependencies": {
18
- "@x-otto/shared": "0.1.0-alpha.6"
18
+ "@x-otto/shared": "0.1.0-alpha.7"
19
19
  },
20
20
  "publishConfig": {
21
21
  "access": "public",