@inventec/dsh-copilot-auth 1.2.7 → 1.2.8

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inventec/dsh-copilot-auth",
3
- "version": "1.2.7",
4
- "description": "为内置 Copilot 提供方补设备码登录/注销设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。适配 DSH 0.1.7+(settings API 重塑后;≤0.1.5 请用 1.1.x)。",
3
+ "version": "1.2.8",
4
+ "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.8:请求撤回/禁用不安全退出、补充模型/重建模型列表双入口(协议 v2)。适配 DSH 0.1.7+(≤0.1.5 请用 1.1.x)。",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "iasi",
@@ -29,6 +29,13 @@
29
29
  "src/atomic-json.mjs",
30
30
  "src/state.mjs",
31
31
  "src/refresh-flow.mjs",
32
+ "src/client-http.mjs",
33
+ "src/runtime-scope.mjs",
34
+ "src/auth-host.mjs",
35
+ "src/auth-flow.mjs",
36
+ "src/model-update.mjs",
37
+ "src/refresh-service.mjs",
38
+ "src/refresh-transaction.mjs",
32
39
  "src/catalog-overlay.json"
33
40
  ],
34
41
  "scripts": {
@@ -0,0 +1,190 @@
1
+ // auth-flow.mjs — 浏览器授权状态机(T5):串行请求、代次保护、轮询退避、卸载停止。
2
+ // 契约:createAuthFlow({fetchImpl, clock, onState}) -> {init, start, cancel, refresh, dispose}
3
+ // - 单一 init 聚合 /status;start 202 → 轮询;409 → attempt.shared 并转入对现有 attempt 的轮询
4
+ // - 请求代次(generation)保护:慢响应不得覆盖新操作;dispose 仅停止本页请求
5
+ // - 轮询 1 秒;连续读取失败退避 1/2/4/8/15 秒封顶;累计失败 60 秒转手动(connectivity=manual)
6
+ // - 写操作(start/cancel)绝不自动重发
7
+ import { requestJson } from "./client-http.mjs";
8
+
9
+ const POLL_INTERVAL_MS = 1000;
10
+ const BACKOFF_STEPS_S = [1, 2, 4, 8, 15];
11
+ const AUTO_RETRY_LIMIT_MS = 60_000;
12
+ const LIVE_STATUSES = ["starting", "waiting", "finishing"];
13
+
14
+ const defaultClock = () => ({
15
+ now: () => Date.now(),
16
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
17
+ clearTimeout: (id) => clearTimeout(id),
18
+ });
19
+
20
+ export function createAuthFlow({ fetchImpl, clock = defaultClock(), onState = () => {} } = {}) {
21
+ let generation = 0;
22
+ let timer = null;
23
+ let failures = 0;
24
+ let backoffIdx = 0;
25
+ let failureWindowStart = null;
26
+ let last = { phase: "loading", connectivity: "online", shared: false };
27
+
28
+ const emit = (patch) => {
29
+ last = { ...last, ...patch };
30
+ onState({ ...last });
31
+ };
32
+ const stale = (gen) => gen !== generation;
33
+ const clearTimer = () => {
34
+ if (timer !== null) {
35
+ clock.clearTimeout(timer);
36
+ timer = null;
37
+ }
38
+ };
39
+ const schedule = (gen, delayMs) => {
40
+ if (stale(gen)) return;
41
+ clearTimer();
42
+ timer = clock.setTimeout(() => {
43
+ timer = null;
44
+ void pollTick(gen);
45
+ }, delayMs);
46
+ };
47
+
48
+ function applySnapshot(gen, s) {
49
+ if (stale(gen)) return;
50
+ emit({ attempt: s, riskLatch: s?.riskLatch ?? null });
51
+ if (LIVE_STATUSES.includes(s?.status)) {
52
+ emit({ phase: "waiting" });
53
+ schedule(gen, POLL_INTERVAL_MS);
54
+ return;
55
+ }
56
+ if (s?.status === "authorized") {
57
+ emit({ phase: "authorized", error: undefined });
58
+ return;
59
+ }
60
+ if (s?.status === "failed") {
61
+ emit({ phase: "failed", error: s?.error ?? "authorization-failed" });
62
+ return;
63
+ }
64
+ if (s?.status === "withdrawal-pending-unverified" || s?.status === "timed-out-unverified") {
65
+ emit({ phase: "risk", riskKind: s.status });
66
+ return;
67
+ }
68
+ emit({ phase: "idle" });
69
+ }
70
+
71
+ async function pollTick(gen) {
72
+ const r = await requestJson(fetchImpl, "/copilot-auth/state", { method: "GET" });
73
+ if (stale(gen)) return;
74
+ if (r.ok) {
75
+ failures = 0;
76
+ backoffIdx = 0;
77
+ failureWindowStart = null;
78
+ emit({ connectivity: "online" });
79
+ applySnapshot(gen, r.body);
80
+ return;
81
+ }
82
+ failures += 1;
83
+ if (failureWindowStart === null) failureWindowStart = clock.now();
84
+ const cumulative = clock.now() - failureWindowStart;
85
+ if (cumulative >= AUTO_RETRY_LIMIT_MS) {
86
+ // 60 秒上限:转手动重试,保留最后确认的状态(连接异常文案由 UI 呈现)
87
+ emit({ connectivity: "manual" });
88
+ return;
89
+ }
90
+ emit({ connectivity: "retrying" });
91
+ const delayS = BACKOFF_STEPS_S[Math.min(backoffIdx, BACKOFF_STEPS_S.length - 1)];
92
+ backoffIdx += 1;
93
+ schedule(gen, delayS * 1000);
94
+ }
95
+
96
+ function beginPolling(gen) {
97
+ void pollTick(gen);
98
+ }
99
+
100
+ async function init() {
101
+ const gen = ++generation;
102
+ clearTimer();
103
+ const status = await requestJson(fetchImpl, "/copilot-auth/status", { method: "GET" });
104
+ if (stale(gen)) return;
105
+ if (!status.ok) {
106
+ emit({ phase: "idle", error: status.error?.messageKey });
107
+ return;
108
+ }
109
+ const body = status.body ?? {};
110
+ if (body.refresh?.scopeAvailable === false) {
111
+ emit({ phase: "unavailable" });
112
+ return;
113
+ }
114
+ emit({ status: body });
115
+ if (body.authorization?.riskLatch) {
116
+ emit({ riskLatch: body.authorization.riskLatch });
117
+ }
118
+ if (body.configured === true) {
119
+ emit({ phase: "authorized" });
120
+ return;
121
+ }
122
+ // 未配置时回查最近一次 attempt:恢复轮询或如实呈现终态
123
+ const r = await requestJson(fetchImpl, "/copilot-auth/state", { method: "GET" });
124
+ if (stale(gen)) return;
125
+ if (!r.ok) {
126
+ emit({ phase: "idle" });
127
+ return;
128
+ }
129
+ applySnapshot(gen, r.body);
130
+ }
131
+
132
+ async function start() {
133
+ const gen = ++generation;
134
+ clearTimer();
135
+ failures = 0;
136
+ backoffIdx = 0;
137
+ failureWindowStart = null;
138
+ emit({ phase: "waiting", shared: false, error: undefined, connectivity: "online" });
139
+ const r = await requestJson(fetchImpl, "/copilot-auth/start", { method: "POST" });
140
+ if (stale(gen)) return;
141
+ if (r.ok) {
142
+ beginPolling(gen);
143
+ return;
144
+ }
145
+ const errorCode = r.error?.details?.errorCode;
146
+ if (r.httpStatus === 409) {
147
+ // 共同尝试:不是失败——展示 shared 并转入对现有 attempt 的轮询
148
+ emit({ shared: true });
149
+ beginPolling(gen);
150
+ return;
151
+ }
152
+ if (errorCode === "auth-unsafe") {
153
+ emit({ phase: "risk", riskKind: "auth-unsafe" });
154
+ return;
155
+ }
156
+ if (errorCode === "scope-unavailable" || r.httpStatus === 503) {
157
+ emit({ phase: "unavailable" });
158
+ return;
159
+ }
160
+ emit({ phase: "failed", error: errorCode ?? r.error?.messageKey });
161
+ }
162
+
163
+ async function cancel() {
164
+ // 写操作:单次调用,绝不自动重发
165
+ const r = await requestJson(fetchImpl, "/copilot-auth/cancel", { method: "POST" });
166
+ if (r.ok) {
167
+ emit({ withdrawalDelivery: r.body?.withdrawalDelivery ?? "unavailable" });
168
+ return;
169
+ }
170
+ emit({ withdrawalDelivery: "failed", error: r.error?.details?.errorCode ?? r.error?.messageKey });
171
+ }
172
+
173
+ function refresh() {
174
+ // 手动重试(60 秒上限后):恢复自动轮询并重置失败窗口
175
+ const gen = ++generation;
176
+ clearTimer();
177
+ failures = 0;
178
+ backoffIdx = 0;
179
+ failureWindowStart = null;
180
+ emit({ connectivity: "online" });
181
+ void pollTick(gen);
182
+ }
183
+
184
+ function dispose() {
185
+ generation += 1;
186
+ clearTimer();
187
+ }
188
+
189
+ return { init, start, cancel, refresh, dispose };
190
+ }
@@ -0,0 +1,182 @@
1
+ // auth-host.mjs — 授权控制器(T3):单实例尝试、软撤回+风险锁存、禁用退出/重授权、
2
+ // intentVersion 与首次填充 handoff。
3
+ // 契约见 docs/plans/2026-10-04-copilot-auth-models-unified-implementation-plan.md:
4
+ // - start:先 intentIO.bump() 持久 intentVersion+1(失败不发 begin),再 begin;202 先行语义保留
5
+ // - cancel:先 bump,再调用宿主撤销(invoked/unavailable/failed),设进程内 riskLatch
6
+ // - logout/reauthorize:一律拒绝 logout-safety-unavailable,绝不 deleteRecord
7
+ // - onAuthorized(handoff) 仅在 outcome=authorized 且 originIntentVersion 仍当前且无撤回/超时标记时触发
8
+ // - riskLatch 进程级:重挂/dispose 不清;15 分钟等待超时由 snapshot 以注入 clock 惰性判定
9
+ import { randomUUID } from "node:crypto";
10
+ import { CREDENTIAL_KEY } from "./shared.mjs";
11
+
12
+ export const WAIT_LIMIT_MS = 15 * 60 * 1000;
13
+ const LIVE_STATUSES = ["starting", "waiting", "finishing"];
14
+
15
+ // 授权错误脱敏:仅保留安全字符集(字母/数字/空格/点/冒号/逗号/连字符),
16
+ // 阻断上游异常文本或疑似 token(含 = ; / + _ 等的串)进入状态面。
17
+ const SAFE_AUTH_ERROR = /^[A-Za-z0-9 .,:-]{1,140}$/;
18
+
19
+ export function sanitizeAuthError(err) {
20
+ const msg = String(err?.message ?? err);
21
+ return SAFE_AUTH_ERROR.test(msg) ? msg : "authorization-failed";
22
+ }
23
+
24
+ function authError(code, message, extra = {}) {
25
+ const err = new Error(message);
26
+ err.code = code;
27
+ Object.assign(err, extra);
28
+ return err;
29
+ }
30
+
31
+ export function createAuthorizationController(ctx, { scope, intentIO, clock = { now: () => Date.now() }, onAuthorized } = {}) {
32
+ let attempt = null;
33
+ let riskLatch = null; // { reason, at } — 进程级风险锁存,本进程生命周期内不解除
34
+
35
+ const setRiskLatch = (reason) => {
36
+ if (!riskLatch) riskLatch = { reason, at: clock.now() };
37
+ };
38
+
39
+ const requireScope = () => {
40
+ if (!scope?.known) throw authError("SCOPE_UNAVAILABLE", "scope-unavailable");
41
+ };
42
+
43
+ function start() {
44
+ requireScope();
45
+ if (riskLatch) throw authError("AUTH_UNSAFE", "auth-unsafe", { riskLatch });
46
+ if (attempt && LIVE_STATUSES.includes(attempt.status)) {
47
+ throw authError("ATTEMPT_RUNNING", "already running", { attemptId: attempt.attemptId });
48
+ }
49
+ // 先持久意图版本,失败不发 begin(安全门拒绝动作不产生副作用)
50
+ const originIntentVersion = intentIO.bump();
51
+ const a = {
52
+ attemptId: randomUUID(),
53
+ status: "starting",
54
+ startedAt: clock.now(),
55
+ notices: [],
56
+ originIntentVersion,
57
+ withdrawalRequested: false,
58
+ timedOut: false,
59
+ withdrawalDelivery: undefined,
60
+ error: undefined,
61
+ staleIntent: false,
62
+ };
63
+ attempt = a;
64
+ const interaction = {
65
+ notify: (notice) => {
66
+ if (attempt !== a) return;
67
+ a.notices.push(notice);
68
+ if (notice && typeof notice === "object") {
69
+ if (a.status === "starting") a.status = "waiting";
70
+ if (typeof notice.code === "string") a.code = notice.code;
71
+ if (typeof notice.url === "string") a.url = notice.url;
72
+ if (typeof notice.expiresAt === "string") a.expiresAt = notice.expiresAt;
73
+ }
74
+ },
75
+ // 企业域名提问答空串(github.com 组织账号);其余 prompt 都是未预期的
76
+ prompt: (p) => {
77
+ if (p && typeof p.message === "string" && p.message.includes("Enterprise")) {
78
+ return Promise.resolve("");
79
+ }
80
+ return Promise.reject(new Error("unexpected prompt: " + (p?.message ?? String(p))));
81
+ },
82
+ };
83
+ // 响应先行:begin 以后台任务执行,start 返回路径不得 await begin
84
+ void Promise.resolve()
85
+ .then(() => ctx.authorization.begin({ key: CREDENTIAL_KEY, method: "oauth", interaction }))
86
+ .then((outcome) => {
87
+ if (attempt !== a) return;
88
+ if (outcome && outcome.status === "authorized") {
89
+ const versionStillCurrent = a.originIntentVersion === intentIO.read();
90
+ const intact = versionStillCurrent && !a.withdrawalRequested && !a.timedOut;
91
+ a.status = "authorized";
92
+ a.staleIntent = !intact;
93
+ if (intact && typeof onAuthorized === "function") {
94
+ // 释放意图:handoff 只携带来源标识,晚到事实不生成新意图(T4 消费前再核)
95
+ onAuthorized({
96
+ attemptId: a.attemptId,
97
+ originIntentVersion: a.originIntentVersion,
98
+ authorizedAt: new Date(clock.now()).toISOString(),
99
+ });
100
+ }
101
+ } else {
102
+ // 宿主 cancelled 只是源结果:晚写不可排除,转待核实并锁存风险(D-01 软撤回)
103
+ a.status = "withdrawal-pending-unverified";
104
+ setRiskLatch("withdrawal-unverified");
105
+ }
106
+ })
107
+ .catch((err) => {
108
+ if (attempt !== a) return;
109
+ a.status = "failed";
110
+ a.error = sanitizeAuthError(err);
111
+ });
112
+ return { attemptId: a.attemptId };
113
+ }
114
+
115
+ async function cancel() {
116
+ requireScope();
117
+ // 先持久失效本地旧意图,再发撤回调用
118
+ intentIO.bump();
119
+ let withdrawalDelivery = "unavailable";
120
+ if (attempt) {
121
+ attempt.withdrawalRequested = true;
122
+ if (LIVE_STATUSES.includes(attempt.status)) attempt.status = "withdrawal-pending-unverified";
123
+ }
124
+ const cancelFn = ctx.authorization?.cancel;
125
+ if (typeof cancelFn === "function") {
126
+ try {
127
+ await cancelFn(CREDENTIAL_KEY);
128
+ withdrawalDelivery = "invoked";
129
+ } catch {
130
+ withdrawalDelivery = "failed";
131
+ }
132
+ }
133
+ // delivery 写入 attempt:刷新页面后 /state 恢复仍如实展示「已请求撤回」事实
134
+ if (attempt) attempt.withdrawalDelivery = withdrawalDelivery;
135
+ // 任何响应都不承诺晚写停止:风险锁存待核实
136
+ setRiskLatch("withdrawal-requested");
137
+ return { withdrawalDelivery };
138
+ }
139
+
140
+ function logout() {
141
+ // 当前宿主无法证明安全退出(D-01):统一拒绝,不调用 deleteRecord
142
+ throw authError("LOGOUT_UNAVAILABLE", "logout-safety-unavailable");
143
+ }
144
+
145
+ function snapshot() {
146
+ // 15 分钟本地等待上限:惰性判定(无后台定时器),不伪造设备码倒计时
147
+ if (attempt && LIVE_STATUSES.includes(attempt.status) && clock.now() > attempt.startedAt + WAIT_LIMIT_MS) {
148
+ attempt.status = "timed-out-unverified";
149
+ attempt.timedOut = true;
150
+ setRiskLatch("wait-timeout");
151
+ }
152
+ if (!attempt) {
153
+ return { attemptId: null, status: "idle", startedAt: null, waitDeadline: null, riskLatch };
154
+ }
155
+ return {
156
+ attemptId: attempt.attemptId,
157
+ status: attempt.status,
158
+ startedAt: attempt.startedAt,
159
+ code: attempt.code,
160
+ url: attempt.url,
161
+ expiresAt: attempt.expiresAt,
162
+ waitDeadline: attempt.startedAt + WAIT_LIMIT_MS,
163
+ withdrawalDelivery: attempt.withdrawalDelivery,
164
+ riskLatch,
165
+ staleIntent: attempt.staleIntent,
166
+ notices: [...attempt.notices],
167
+ error: attempt.error,
168
+ };
169
+ }
170
+
171
+ // T4 消费:handoff 是否仍完好(同 attempt、版本仍当前、无撤回/超时标记)
172
+ function handoffIntact(handoff) {
173
+ if (!attempt || !handoff) return false;
174
+ return attempt.attemptId === handoff.attemptId
175
+ && attempt.originIntentVersion === handoff.originIntentVersion
176
+ && attempt.originIntentVersion === intentIO.read()
177
+ && !attempt.withdrawalRequested
178
+ && !attempt.timedOut;
179
+ }
180
+
181
+ return { start, cancel, logout, snapshot, handoffIntact };
182
+ }
@@ -0,0 +1,80 @@
1
+ // client-http.mjs — 浏览器/host 双侧可用的 HTTP/JSON 解析层(T1)。
2
+ // 契约:requestJson(fetchImpl, url, init) -> {ok, httpStatus, body, error}
3
+ // - 网络拒绝 → network-error(httpStatus=null)
4
+ // - 非 JSON(HTML 代理页/空体/坏 JSON)→ 保留 httpStatus,bad-gateway(5xx 或 HTML)/ bad-response
5
+ // - JSON 但失败 → http-error,errorCode 仅保留安全形状(字母开头,字母/数字/空格/连字符)
6
+ // - details 白名单:{httpStatus?, contentType?, errorCode?};绝不透传原始响应体或异常文本
7
+ // - 写调用不自动重试
8
+ const SAFE_ERROR_CODE = /^[A-Za-z][A-Za-z0-9 -]{0,79}$/;
9
+ const SAFE_CONTENT_TYPE = /^[\w./+-]{0,100}$/;
10
+
11
+ export function sanitizeErrorCode(value) {
12
+ return typeof value === "string" && SAFE_ERROR_CODE.test(value) ? value : "http-error";
13
+ }
14
+
15
+ // 错误响应的 body 深度脱敏:仅保留安全形状的字符串(字母/数字/空格/连字符,
16
+ // 覆盖 already running、logout-safety-unavailable、uuid 等),其余字符串丢弃,
17
+ // 防止上游异常文本或疑似 token 随 body 泄漏;ok:true 的 body 为域数据,原样返回。
18
+ const SAFE_STRING = /^[A-Za-z0-9][A-Za-z0-9 -]{0,127}$/;
19
+
20
+ function deepSanitize(value) {
21
+ if (typeof value === "string") return SAFE_STRING.test(value) ? value : undefined;
22
+ if (value === null || typeof value === "number" || typeof value === "boolean") return value;
23
+ if (Array.isArray(value)) return value.map(deepSanitize);
24
+ if (value && typeof value === "object") {
25
+ const out = {};
26
+ for (const [k, v] of Object.entries(value)) {
27
+ const s = deepSanitize(v);
28
+ if (s !== undefined) out[k] = s;
29
+ }
30
+ return out;
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function sanitizeContentType(value) {
36
+ if (typeof value !== "string" || !SAFE_CONTENT_TYPE.test(value)) return undefined;
37
+ return value;
38
+ }
39
+
40
+ export async function requestJson(fetchImpl, url, init = {}) {
41
+ let res;
42
+ try {
43
+ res = await fetchImpl(url, init);
44
+ } catch {
45
+ return { ok: false, httpStatus: null, body: undefined, error: { messageKey: "network-error", details: {} } };
46
+ }
47
+ const httpStatus = res.status;
48
+ let text = "";
49
+ try {
50
+ text = await res.text();
51
+ } catch {
52
+ text = "";
53
+ }
54
+ if (typeof text !== "string" || text.trim() === "") {
55
+ return {
56
+ ok: false,
57
+ httpStatus,
58
+ body: undefined,
59
+ error: { messageKey: httpStatus >= 500 ? "bad-gateway" : "bad-response", details: { httpStatus } },
60
+ };
61
+ }
62
+ let body;
63
+ try {
64
+ body = JSON.parse(text);
65
+ } catch {
66
+ const contentType = sanitizeContentType(res.headers?.get?.("content-type"));
67
+ const looksHtml = contentType?.includes("html") || /^\s*<(?:!doctype|html)/i.test(text);
68
+ const messageKey = httpStatus >= 500 || looksHtml ? "bad-gateway" : "bad-response";
69
+ const details = { httpStatus };
70
+ if (contentType !== undefined) details.contentType = contentType;
71
+ return { ok: false, httpStatus, body: undefined, error: { messageKey, details } };
72
+ }
73
+ const ok = res.ok && body?.ok !== false;
74
+ if (ok) return { ok: true, httpStatus, body, error: undefined };
75
+ const details = {};
76
+ if (!res.ok) details.httpStatus = httpStatus;
77
+ const errorCode = sanitizeErrorCode(body?.error);
78
+ if (errorCode !== undefined) details.errorCode = errorCode;
79
+ return { ok: false, httpStatus, body: deepSanitize(body), error: { messageKey: "http-error", details } };
80
+ }