@x-otto/credentials 0.0.1-alpha.1
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/dist/index.d.ts +132 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/package.json +30 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//#region src/expiry.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* expiry.ts —— OAuth 凭据过期判断单源(RFC-374 M1)。
|
|
4
|
+
*
|
|
5
|
+
* 背景(S2 收敛):ai 与 mcp 各写了一份「过期 + skew」判定——ai 的 `OAUTH_SKEW_MS`
|
|
6
|
+
* (auth-store-constants.ts)与 mcp 的 `skewSeconds = 60`(token.ts)语义相同、取值
|
|
7
|
+
* 相同、实现各自独立。本文件收敛为唯一实现;凭据形状不统一(ai 用 `{expires}`、
|
|
8
|
+
* mcp 用 `{expiresAt}`),故函数只收「过期时间戳」数值,由各域自行取字段——
|
|
9
|
+
* 不强行统一凭据类型(那会把两个域的迁移搅在一起,违反先建后迁的隔离原则)。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* OAuth 过期判断的时钟偏移余量(RFC-140 D2 语义,沿用 ai 既有值):网络延迟 +
|
|
13
|
+
* 客户端/服务端时钟漂移可能导致 token 在服务端已失效但客户端仍判定为有效,
|
|
14
|
+
* 下一次 API 调用才发现 401。提前 `OAUTH_SKEW_MS` 触发刷新可消除这类边界窗口
|
|
15
|
+
* ——业界惯例 30-60s,此处取 60s。
|
|
16
|
+
*/
|
|
17
|
+
declare const OAUTH_SKEW_MS = 60000;
|
|
18
|
+
/**
|
|
19
|
+
* 凭据是否已(或即将)过期。`expiresAt == null` = 无过期信息 → 判为未过期
|
|
20
|
+
* (与 ai/mcp 既有行为一致:宁可先试、401 再刷新,不因缺字段拒绝可用凭据)。
|
|
21
|
+
*
|
|
22
|
+
* @param expiresAt 过期时间戳(epoch ms)。各域自行取字段:ai 传 `credentials.expires`、
|
|
23
|
+
* mcp 传 `credentials.expiresAt`。
|
|
24
|
+
* @param skewMs 时钟偏移余量,缺省 `OAUTH_SKEW_MS`。
|
|
25
|
+
*/
|
|
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
|
+
//#endregion
|
|
34
|
+
//#region src/atomic-file.d.ts
|
|
35
|
+
/**
|
|
36
|
+
* 确保凭据目录存在且权限收紧到 0700。mode 仅对新建目录生效(受 umask 影响),
|
|
37
|
+
* 已存在目录需显式 chmod 收紧;权限收紧失败不阻断主流程(如非 POSIX 文件系统),
|
|
38
|
+
* 仅降级安全性——与 ai/mcp 既有行为一致。
|
|
39
|
+
*/
|
|
40
|
+
declare function ensurePrivateDir(dir: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* 原子写 JSON 文件:tmp-${pid} 写入 → chmod 0600 → rename 原子替换。
|
|
43
|
+
* 失败时清理 tmp 后 rethrow。rename 保证读者永远看不到半写状态;
|
|
44
|
+
* tmp+chmod 保证凭据从落盘第一刻起就不可被其他本地用户读取。
|
|
45
|
+
*/
|
|
46
|
+
declare function writeJsonAtomic(path: string, data: unknown): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* 读 JSON 文件;ENOENT 返回 `fallback`,解析失败返回 `fallback`(凭据文件的
|
|
49
|
+
* fail-soft 读取语义:不存在/损坏都视为「无凭据」,与 ai/mcp 既有行为一致)。
|
|
50
|
+
* 其余错误(EACCES 等)rethrow——那不是「无凭据」而是环境故障,调用方需要知道。
|
|
51
|
+
*/
|
|
52
|
+
declare function readJsonFile<T>(path: string, fallback: T): Promise<T>;
|
|
53
|
+
/** 删除文件;ENOENT 静默(幂等删除语义)。 */
|
|
54
|
+
declare function deleteFileIfExists(path: string): Promise<void>;
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/overlay-store.d.ts
|
|
57
|
+
/**
|
|
58
|
+
* overlay-store.ts —— 影子模式 overlay 的通用实现(RFC-374 M1)。
|
|
59
|
+
*
|
|
60
|
+
* 背景(S2 收敛):ai 的 AuthStore overlay 与 mcp 的 createOverlayTokenStore 各自
|
|
61
|
+
* 实现了同一语义(RFC-345,对齐 Chromium OverlayUserPrefStore)——读穿透到 base
|
|
62
|
+
* (继承磁盘既有数据),写/删只进内存 overlay,进程退出后磁盘数据逐字节不变。
|
|
63
|
+
* 本文件提供「按 key 存取」store 的通用 overlay 包装;ai/mcp 的凭据 store 都符合
|
|
64
|
+
* 该接口形状,各自只需传自己的 base 实现。
|
|
65
|
+
*
|
|
66
|
+
* 语义细节(与两域既有行为逐条对齐):
|
|
67
|
+
* - load:overlay 命中优先(含「本会话内 delete 过」的墓碑,返回 undefined);否则回退 base;
|
|
68
|
+
* - save:只写 overlay,绝不落盘;
|
|
69
|
+
* - delete:只在 overlay 记墓碑,不删磁盘;
|
|
70
|
+
* - list:base 列表并入 overlay 新增、扣除 overlay 墓碑。
|
|
71
|
+
*/
|
|
72
|
+
/** 按 key 存取的通用 store 接口(凭据域的公共形状)。 */
|
|
73
|
+
interface ReadWriteStore<T> {
|
|
74
|
+
load(key: string): Promise<T | undefined>;
|
|
75
|
+
save(key: string, value: T): Promise<void>;
|
|
76
|
+
delete(key: string): Promise<void>;
|
|
77
|
+
list(): Promise<string[]>;
|
|
78
|
+
}
|
|
79
|
+
/** 创建影子模式 overlay store。`base` 为真实持久层(文件/内存等)。 */
|
|
80
|
+
declare function createOverlayStore<T>(base: ReadWriteStore<T>): ReadWriteStore<T>;
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region src/refresh-lock.d.ts
|
|
83
|
+
/** 陈旧锁清理阈值 = 等待释放超时(与 ai 的规则9一致,两者必须相等)。 */
|
|
84
|
+
declare const REFRESH_LOCK_TIMEOUT_MS = 15000;
|
|
85
|
+
/** 跨进程刷新锁句柄(per-key 操作)。 */
|
|
86
|
+
interface RefreshLock {
|
|
87
|
+
/** 尝试获取锁。成功 true(调用方持锁,须 try/finally 释放);被占用/异常 false。 */
|
|
88
|
+
acquire(key: string, timeoutMs?: number): Promise<boolean>;
|
|
89
|
+
/** 释放锁(幂等,并停止心跳)。 */
|
|
90
|
+
release(key: string): Promise<void>;
|
|
91
|
+
/** 等待另一进程持有的锁释放,超时返回 false(调用方走读盘兜底)。 */
|
|
92
|
+
waitForRelease(key: string, timeoutMs?: number): Promise<boolean>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 创建跨进程刷新锁工厂。
|
|
96
|
+
*
|
|
97
|
+
* @param lockDir 锁文件目录(与凭据文件同目录,对齐 ai「锁挨着 auth.json」风格)。
|
|
98
|
+
* @param prefix 锁文件前缀(如 `auth-refresh`/`mcp-refresh`)。**一旦上线不可变**——
|
|
99
|
+
* 换前缀 = 新旧进程各锁各的文件 = 等于没锁(ai 的滚动升级硬约束)。
|
|
100
|
+
*/
|
|
101
|
+
declare function createRefreshLock(lockDir: string, prefix: string): RefreshLock;
|
|
102
|
+
interface RefreshUnderLockOptions<T> {
|
|
103
|
+
/** 锁工厂(createRefreshLock 产物,域侧持有)。 */
|
|
104
|
+
lock: RefreshLock;
|
|
105
|
+
/** 锁 key(域侧 sanitize 后的标识)。 */
|
|
106
|
+
key: string;
|
|
107
|
+
/** 读取当前凭据(可能已被另一进程刷新)。 */
|
|
108
|
+
load(): Promise<T | undefined>;
|
|
109
|
+
/** 凭据是否仍新鲜(未过期、可直接用)。 */
|
|
110
|
+
isFresh(credentials: T): boolean;
|
|
111
|
+
/** 真正发起刷新网络请求。 */
|
|
112
|
+
refresh(): Promise<T>;
|
|
113
|
+
/** 保存刷新成功的新凭据。 */
|
|
114
|
+
save(credentials: T): Promise<void>;
|
|
115
|
+
/** 陈旧锁阈值,缺省 REFRESH_LOCK_TIMEOUT_MS。 */
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 跨进程互斥刷新:同一时刻同一 key 全机器只有一个进程真正发起 `refresh()` 网络请求,
|
|
120
|
+
* 其余进程等待后直接读盘复用结果。
|
|
121
|
+
*
|
|
122
|
+
* 与 ai `OAuthRefreshCoordinator.refreshWithCrossProcessLock` 的语义对齐(本函数是
|
|
123
|
+
* 其公共骨架的提取;ai 的永久失败缓存/磁盘恢复等额外层仍在其自身实现中):
|
|
124
|
+
* - 拿不到锁 → 等待释放(超时=陈旧锁阈值)→ 读盘看另一进程是否已刷新成功,是则复用;
|
|
125
|
+
* 否则抛错(不无限等待、不用陈旧 refresh_token 继续打网络)。
|
|
126
|
+
* - 拿到锁 → 重读一次确认是否仍不新鲜(本进程可能在「发现不新鲜→真正拿锁」的窗口里
|
|
127
|
+
* 已被另一进程抢先刷新),仍不新鲜才真正刷新并落盘;`finally` 保证无论成败都释放锁。
|
|
128
|
+
*/
|
|
129
|
+
declare function refreshUnderCrossProcessLock<T>(options: RefreshUnderLockOptions<T>): Promise<T>;
|
|
130
|
+
//#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 };
|
|
132
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +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};
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@x-otto/credentials",
|
|
3
|
+
"version": "0.0.1-alpha.1",
|
|
4
|
+
"description": "凭据文件生命周期原语叶包——原子写盘/影子 overlay/过期判断/跨进程刷新锁(RFC-374 M1)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@x-otto/shared": "0.1.0-alpha.6"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"registry": "https://registry.npmjs.org",
|
|
23
|
+
"tag": "alpha"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsdown",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
}
|
|
30
|
+
}
|