@inventec/dsh-copilot-auth 1.2.7 → 1.2.9

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.9",
4
+ "description": "为内置 Copilot 提供方补设备码登录设置页,预置开箱路由;凭据入内置凭据库,模型目录兜底不覆盖定制。v1.2.9:修复授权页恒未登录/失败(漏传 fetchImpl)、失败原因分层展示+自愈重试、已登录拒绝替换性授权(协议 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,213 @@
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, fn = () => pollTick(gen)) => {
40
+ if (stale(gen)) return;
41
+ clearTimer();
42
+ timer = clock.setTimeout(() => {
43
+ timer = null;
44
+ void fn();
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
+ failures = 0;
104
+ backoffIdx = 0;
105
+ failureWindowStart = null;
106
+ await initAttempt(gen);
107
+ }
108
+
109
+ // init 内核:/status 失败时按 1/2/4/8/15 秒退避自动重试,累计 60 秒转手动;
110
+ // 不把加载失败伪装成「未登录」(历史缺陷:502 窗口期打开页面会显示未登录+授权按钮)。
111
+ async function initAttempt(gen) {
112
+ const status = await requestJson(fetchImpl, "/copilot-auth/status", { method: "GET" });
113
+ if (stale(gen)) return;
114
+ if (!status.ok) {
115
+ failures += 1;
116
+ if (failureWindowStart === null) failureWindowStart = clock.now();
117
+ const cumulative = clock.now() - failureWindowStart;
118
+ if (cumulative >= AUTO_RETRY_LIMIT_MS) {
119
+ emit({ phase: "idle", error: status.error?.messageKey, connectivity: "manual" });
120
+ return;
121
+ }
122
+ emit({ phase: "loading", connectivity: "retrying", error: status.error?.messageKey });
123
+ const delayS = BACKOFF_STEPS_S[Math.min(backoffIdx, BACKOFF_STEPS_S.length - 1)];
124
+ backoffIdx += 1;
125
+ schedule(gen, delayS * 1000, () => initAttempt(gen));
126
+ return;
127
+ }
128
+ failures = 0;
129
+ backoffIdx = 0;
130
+ failureWindowStart = null;
131
+ emit({ connectivity: "online" });
132
+ const body = status.body ?? {};
133
+ if (body.refresh?.scopeAvailable === false) {
134
+ emit({ phase: "unavailable" });
135
+ return;
136
+ }
137
+ emit({ status: body });
138
+ if (body.authorization?.riskLatch) {
139
+ emit({ riskLatch: body.authorization.riskLatch });
140
+ }
141
+ if (body.configured === true) {
142
+ emit({ phase: "authorized", error: undefined });
143
+ return;
144
+ }
145
+ // 未配置时回查最近一次 attempt:恢复轮询或如实呈现终态
146
+ const r = await requestJson(fetchImpl, "/copilot-auth/state", { method: "GET" });
147
+ if (stale(gen)) return;
148
+ if (!r.ok) {
149
+ emit({ phase: "idle", error: r.error?.messageKey, connectivity: "manual" });
150
+ return;
151
+ }
152
+ applySnapshot(gen, r.body);
153
+ }
154
+
155
+ async function start() {
156
+ const gen = ++generation;
157
+ clearTimer();
158
+ failures = 0;
159
+ backoffIdx = 0;
160
+ failureWindowStart = null;
161
+ emit({ phase: "waiting", shared: false, error: undefined, connectivity: "online" });
162
+ const r = await requestJson(fetchImpl, "/copilot-auth/start", { method: "POST" });
163
+ if (stale(gen)) return;
164
+ if (r.ok) {
165
+ beginPolling(gen);
166
+ return;
167
+ }
168
+ const errorCode = r.error?.details?.errorCode;
169
+ if (errorCode === "already-configured") {
170
+ // 后端确认已有凭据:本页是过期的未登录视图(如短暂 read-error 窗口),
171
+ // 直接恢复已登录视图,不发起注定失败的替换性授权
172
+ emit({ phase: "authorized", error: undefined, shared: false });
173
+ return;
174
+ }
175
+ if (r.httpStatus === 409) {
176
+ // 共同尝试:不是失败——展示 shared 并转入对现有 attempt 的轮询
177
+ emit({ shared: true });
178
+ beginPolling(gen);
179
+ return;
180
+ }
181
+ if (errorCode === "auth-unsafe") {
182
+ emit({ phase: "risk", riskKind: "auth-unsafe" });
183
+ return;
184
+ }
185
+ if (errorCode === "scope-unavailable" || r.httpStatus === 503) {
186
+ emit({ phase: "unavailable" });
187
+ return;
188
+ }
189
+ emit({ phase: "failed", error: errorCode ?? r.error?.messageKey });
190
+ }
191
+
192
+ async function cancel() {
193
+ // 写操作:单次调用,绝不自动重发
194
+ const r = await requestJson(fetchImpl, "/copilot-auth/cancel", { method: "POST" });
195
+ if (r.ok) {
196
+ emit({ withdrawalDelivery: r.body?.withdrawalDelivery ?? "unavailable" });
197
+ return;
198
+ }
199
+ emit({ withdrawalDelivery: "failed", error: r.error?.details?.errorCode ?? r.error?.messageKey });
200
+ }
201
+
202
+ function refresh() {
203
+ // 手动「查询状态」:完整重跑 init(status→state),可从过期的未登录/失败视图恢复
204
+ void init();
205
+ }
206
+
207
+ function dispose() {
208
+ generation += 1;
209
+ clearTimer();
210
+ }
211
+
212
+ return { init, start, cancel, refresh, dispose };
213
+ }
@@ -0,0 +1,192 @@
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
+ async 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
+ // 初始授权条件(设计 Q21):已有凭据时禁止新的授权尝试——那是对既有授权的
50
+ // 隐式替换(历史实证:已登录状态下点「授权登录」,begin 带着已存在的凭据发起
51
+ // 设备流,attempt 永远停在 starting,最终以「失败」收场)。「重新授权」在宿主
52
+ // 可证明安全前默认禁用。describeRecord 自身失败不阻断(读不到≠存在)。
53
+ try {
54
+ const info = await ctx.credentials.describeRecord(CREDENTIAL_KEY);
55
+ if (info?.configured === true) throw authError("ALREADY_CONFIGURED", "already-configured");
56
+ } catch (err) {
57
+ if (err?.code === "ALREADY_CONFIGURED") throw err;
58
+ }
59
+ // 先持久意图版本,失败不发 begin(安全门拒绝动作不产生副作用)
60
+ const originIntentVersion = intentIO.bump();
61
+ const a = {
62
+ attemptId: randomUUID(),
63
+ status: "starting",
64
+ startedAt: clock.now(),
65
+ notices: [],
66
+ originIntentVersion,
67
+ withdrawalRequested: false,
68
+ timedOut: false,
69
+ withdrawalDelivery: undefined,
70
+ error: undefined,
71
+ staleIntent: false,
72
+ };
73
+ attempt = a;
74
+ const interaction = {
75
+ notify: (notice) => {
76
+ if (attempt !== a) return;
77
+ a.notices.push(notice);
78
+ if (notice && typeof notice === "object") {
79
+ if (a.status === "starting") a.status = "waiting";
80
+ if (typeof notice.code === "string") a.code = notice.code;
81
+ if (typeof notice.url === "string") a.url = notice.url;
82
+ if (typeof notice.expiresAt === "string") a.expiresAt = notice.expiresAt;
83
+ }
84
+ },
85
+ // 企业域名提问答空串(github.com 组织账号);其余 prompt 都是未预期的
86
+ prompt: (p) => {
87
+ if (p && typeof p.message === "string" && p.message.includes("Enterprise")) {
88
+ return Promise.resolve("");
89
+ }
90
+ return Promise.reject(new Error("unexpected prompt: " + (p?.message ?? String(p))));
91
+ },
92
+ };
93
+ // 响应先行:begin 以后台任务执行,start 返回路径不得 await begin
94
+ void Promise.resolve()
95
+ .then(() => ctx.authorization.begin({ key: CREDENTIAL_KEY, method: "oauth", interaction }))
96
+ .then((outcome) => {
97
+ if (attempt !== a) return;
98
+ if (outcome && outcome.status === "authorized") {
99
+ const versionStillCurrent = a.originIntentVersion === intentIO.read();
100
+ const intact = versionStillCurrent && !a.withdrawalRequested && !a.timedOut;
101
+ a.status = "authorized";
102
+ a.staleIntent = !intact;
103
+ if (intact && typeof onAuthorized === "function") {
104
+ // 释放意图:handoff 只携带来源标识,晚到事实不生成新意图(T4 消费前再核)
105
+ onAuthorized({
106
+ attemptId: a.attemptId,
107
+ originIntentVersion: a.originIntentVersion,
108
+ authorizedAt: new Date(clock.now()).toISOString(),
109
+ });
110
+ }
111
+ } else {
112
+ // 宿主 cancelled 只是源结果:晚写不可排除,转待核实并锁存风险(D-01 软撤回)
113
+ a.status = "withdrawal-pending-unverified";
114
+ setRiskLatch("withdrawal-unverified");
115
+ }
116
+ })
117
+ .catch((err) => {
118
+ if (attempt !== a) return;
119
+ a.status = "failed";
120
+ a.error = sanitizeAuthError(err);
121
+ });
122
+ return { attemptId: a.attemptId };
123
+ }
124
+
125
+ async function cancel() {
126
+ requireScope();
127
+ // 先持久失效本地旧意图,再发撤回调用
128
+ intentIO.bump();
129
+ let withdrawalDelivery = "unavailable";
130
+ if (attempt) {
131
+ attempt.withdrawalRequested = true;
132
+ if (LIVE_STATUSES.includes(attempt.status)) attempt.status = "withdrawal-pending-unverified";
133
+ }
134
+ const cancelFn = ctx.authorization?.cancel;
135
+ if (typeof cancelFn === "function") {
136
+ try {
137
+ await cancelFn(CREDENTIAL_KEY);
138
+ withdrawalDelivery = "invoked";
139
+ } catch {
140
+ withdrawalDelivery = "failed";
141
+ }
142
+ }
143
+ // delivery 写入 attempt:刷新页面后 /state 恢复仍如实展示「已请求撤回」事实
144
+ if (attempt) attempt.withdrawalDelivery = withdrawalDelivery;
145
+ // 任何响应都不承诺晚写停止:风险锁存待核实
146
+ setRiskLatch("withdrawal-requested");
147
+ return { withdrawalDelivery };
148
+ }
149
+
150
+ function logout() {
151
+ // 当前宿主无法证明安全退出(D-01):统一拒绝,不调用 deleteRecord
152
+ throw authError("LOGOUT_UNAVAILABLE", "logout-safety-unavailable");
153
+ }
154
+
155
+ function snapshot() {
156
+ // 15 分钟本地等待上限:惰性判定(无后台定时器),不伪造设备码倒计时
157
+ if (attempt && LIVE_STATUSES.includes(attempt.status) && clock.now() > attempt.startedAt + WAIT_LIMIT_MS) {
158
+ attempt.status = "timed-out-unverified";
159
+ attempt.timedOut = true;
160
+ setRiskLatch("wait-timeout");
161
+ }
162
+ if (!attempt) {
163
+ return { attemptId: null, status: "idle", startedAt: null, waitDeadline: null, riskLatch };
164
+ }
165
+ return {
166
+ attemptId: attempt.attemptId,
167
+ status: attempt.status,
168
+ startedAt: attempt.startedAt,
169
+ code: attempt.code,
170
+ url: attempt.url,
171
+ expiresAt: attempt.expiresAt,
172
+ waitDeadline: attempt.startedAt + WAIT_LIMIT_MS,
173
+ withdrawalDelivery: attempt.withdrawalDelivery,
174
+ riskLatch,
175
+ staleIntent: attempt.staleIntent,
176
+ notices: [...attempt.notices],
177
+ error: attempt.error,
178
+ };
179
+ }
180
+
181
+ // T4 消费:handoff 是否仍完好(同 attempt、版本仍当前、无撤回/超时标记)
182
+ function handoffIntact(handoff) {
183
+ if (!attempt || !handoff) return false;
184
+ return attempt.attemptId === handoff.attemptId
185
+ && attempt.originIntentVersion === handoff.originIntentVersion
186
+ && attempt.originIntentVersion === intentIO.read()
187
+ && !attempt.withdrawalRequested
188
+ && !attempt.timedOut;
189
+ }
190
+
191
+ return { start, cancel, logout, snapshot, handoffIntact };
192
+ }
@@ -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
+ }