@youzan-cloud/zan-saas-cli 0.1.0-beta.4 → 0.1.0-beta.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.
Files changed (66) hide show
  1. package/bin/zan-saas-cli.js +8 -11
  2. package/internal/registry/manifest.json +25 -20
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/skills/zan-saas/SKILL.md +3 -3
  6. package/skills/zan-saas/references/host-entry.md +8 -0
  7. package/skills/zan-saas-order/SKILL.md +12 -1
  8. package/skills/zan-saas-order/references/branch-counts.md +45 -0
  9. package/skills/zan-saas-order/references/order-get.md +2 -0
  10. package/skills/zan-saas-order/references/order-list.md +2 -0
  11. package/skills/zan-saas-order/references/order-scan.md +2 -0
  12. package/skills/zan-saas-org/SKILL.md +6 -0
  13. package/skills/zan-saas-org/references/unit-list.md +2 -0
  14. package/skills/zan-saas-shared/SKILL.md +20 -5
  15. package/skills/zan-saas-shared/references/authentication.md +9 -4
  16. package/skills/zan-saas-shared/references/gateway-errors.md +29 -0
  17. package/skills/zan-saas-shared/references/shop-context.md +39 -0
  18. package/skills/zan-saas-valuecard/SKILL.md +8 -2
  19. package/skills/zan-saas-valuecard/references/transaction-list.md +25 -0
  20. package/skills/zan-saas-valuecard/references/transaction-scan.md +23 -0
  21. package/src/auth/credential-source.js +26 -0
  22. package/src/auth/credential-store.js +61 -5
  23. package/src/auth/session.js +80 -27
  24. package/src/cli/command-paths.js +3 -2
  25. package/src/cli/context-help.js +3 -1
  26. package/src/cli/program.js +20 -4
  27. package/src/commands/native/access/commands.index.json +1 -1
  28. package/src/commands/native/access/role/list/command.js +4 -32
  29. package/src/commands/native/customer/commands.index.json +1 -1
  30. package/src/commands/native/customer/customer-tag/list/command.js +2 -2
  31. package/src/commands/native/member/commands.index.json +1 -1
  32. package/src/commands/native/order/commands.index.json +3 -3
  33. package/src/commands/native/order/order/get/command.js +14 -9
  34. package/src/commands/native/order/order/get/schema.json +197 -41
  35. package/src/commands/native/order/order/list/command.js +9 -6
  36. package/src/commands/native/order/order/list/schema.json +140 -10
  37. package/src/commands/native/order/order/scan/command.js +8 -5
  38. package/src/commands/native/order/order/scan/scan.js +1 -1
  39. package/src/commands/native/order/order/scan/schema.json +135 -8
  40. package/src/commands/native/order/shared/query.js +57 -9
  41. package/src/commands/native/order/shared/search.js +1 -1
  42. package/src/commands/native/org/commands.index.json +4 -4
  43. package/src/commands/native/valuecard/commands.index.json +20 -2
  44. package/src/commands/native/valuecard/domain.json +1 -1
  45. package/src/commands/native/valuecard/transaction/list/command.js +110 -0
  46. package/src/commands/native/valuecard/transaction/list/projection.js +71 -0
  47. package/src/commands/native/valuecard/transaction/list/schema.json +888 -0
  48. package/src/commands/native/valuecard/transaction/scan/command.js +130 -0
  49. package/src/commands/native/valuecard/transaction/scan/projection.js +58 -0
  50. package/src/commands/native/valuecard/transaction/scan/scan.js +70 -0
  51. package/src/commands/native/valuecard/transaction/scan/schema.json +954 -0
  52. package/src/commands/system/api.js +1 -1
  53. package/src/commands/system/auth.js +13 -2
  54. package/src/commands/system/contracts.js +27 -19
  55. package/src/commands/system/shop-contracts.js +97 -0
  56. package/src/commands/system/shop.js +128 -27
  57. package/src/execution/error-details.js +13 -18
  58. package/src/execution/gateway-guidance.js +43 -0
  59. package/src/execution/shop-query.js +6 -3
  60. package/src/identity/context-store.js +14 -10
  61. package/src/identity/file-store.js +147 -0
  62. package/src/identity/session-store.js +261 -0
  63. package/src/identity/shop-location.js +158 -0
  64. package/src/identity/unit-membership.js +42 -0
  65. package/src/transport/api.js +78 -11
  66. package/src/transport/shop-app.js +8 -1
@@ -3,6 +3,7 @@
3
3
  const { executionFor } = require('./request-state');
4
4
  const { issueReasons } = require('./error-model');
5
5
  const { ParserError } = require('./parser-error');
6
+ const { gatewayGuidance } = require('./gateway-guidance');
6
7
  const contexts = new WeakMap();
7
8
  const reasons = new Set(issueReasons);
8
9
  const constraintKeys = ['type', 'enum', 'const', 'minimum', 'maximum', 'exclusiveMinimum',
@@ -203,7 +204,8 @@ function localContextReason(error, details, execution) {
203
204
  execution.observedAttempts === 0 ? error.diagnosticReason : undefined;
204
205
  }
205
206
 
206
- function describeRecovery(details, execution, missingAuthentication = false, contextReason, responseReason) {
207
+ function describeRecovery(details, execution, missingAuthentication = false, contextReason, responseReason,
208
+ gateway = gatewayGuidance(details, undefined, execution)) {
207
209
  if (contextReason) return { action: 'stop_and_inspect', nextStep:
208
210
  ['context_missing', 'target_missing'].includes(contextReason)
209
211
  ? '未登录先执行 zan-saas auth login;登录后用 zan-saas shop list 和 zan-saas shop use <kdtId> 选店。缺少上下文时仅可显式指定店铺做 --dry-run,不自动切店或重放。'
@@ -221,14 +223,13 @@ function describeRecovery(details, execution, missingAuthentication = false, con
221
223
  if (details.category === 'provider_unavailable' && details.command === 'privacy.field.decrypt') {
222
224
  return { action: 'stop_and_inspect', nextStep: '当前仅支持离线预览,请加 --dry-run;真实解密服务尚未接入。' };
223
225
  }
226
+ if (responseReason) return { action: 'stop_and_inspect',
227
+ nextStep: `${responseReason.nextStep} 不自动重放、切店或换身份。` };
228
+ if (gateway) return { action: gateway.action, nextStep: gateway.nextStep };
224
229
  if (missingAuthentication || details.category === 'authentication') {
225
230
  return { action: 'complete_authentication',
226
231
  nextStep: '检查 zan-saas auth status --format json,按公共认证流程完成登录;不在聊天中提供凭证,不自动重放原调用。' };
227
232
  }
228
- if (gatewayAccessDenied(details)) {
229
- return { action: 'request_access_review',
230
- nextStep: '请检查当前账号是否有该店铺权限,以及店铺应用是否已获该接口的开放能力授权;可提供接口和 trace_id 给管理员排查,不自动重放。' };
231
- }
232
233
  if (details.category === 'permission') {
233
234
  return { action: 'request_access_review',
234
235
  nextStep: '核对原账号、目标店铺与服务端授权,可提供脱敏接口及 trace_id 给管理员;不切店、换身份或自动重放。' };
@@ -237,8 +238,6 @@ function describeRecovery(details, execution, missingAuthentication = false, con
237
238
  return { action: 'stop_and_inspect',
238
239
  nextStep: '店铺核验失败,后续业务请求尚未发送;本次已有核验请求记录,核对原店铺和凭证后停止,不自动切店或重放。' };
239
240
  }
240
- if (responseReason) return { action: 'stop_and_inspect',
241
- nextStep: `${responseReason.nextStep} 不自动重放、切店或换身份。` };
242
241
  if (details.requestState === 'sent' && details.category === 'business_rejected') {
243
242
  return { action: 'stop_and_inspect',
244
243
  nextStep: '接口已拒绝本次查询;将诊断中的 api、businessCode 或 gatewayCode、trace_id 提供给管理员核查服务端原因。错误码本身不能证明是权限或数据问题,不自动重放、切店或换身份。' };
@@ -275,19 +274,14 @@ function businessRejectionMessage(details) {
275
274
  return `接口拒绝了本次查询${code ? `(${code})` : ''},已停止处理。`;
276
275
  }
277
276
 
278
- function gatewayAccessDenied(details) {
279
- return details?.requestState === 'sent' &&
280
- ['business_rejected', 'permission'].includes(details.category) &&
281
- [4204, '4204'].includes(details.gatewayCode);
282
- }
283
-
284
277
  function finalizeError(error, registeredContext) {
285
278
  if (error instanceof ParserError) return structuredClone(error.result);
286
279
  const context = contexts.get(error) || registeredContext;
287
280
  if (context?.schema.errors?.diagnosticsVersion !== 2) {
288
- if (gatewayAccessDenied(error.cliDetails)) {
289
- error.message = '网关拒绝访问:没有操作权限。';
290
- error.cliDetails.nextStep = '请检查当前账号是否有该店铺权限,以及店铺应用是否已获该接口的开放能力授权;可提供接口和 trace_id 给管理员排查。';
281
+ const gateway = gatewayGuidance(error.cliDetails, context);
282
+ if (gateway) {
283
+ error.message = gateway.message;
284
+ error.cliDetails.nextStep = gateway.nextStep;
291
285
  }
292
286
  return { success: false, code: -10001, message: error.message,
293
287
  ...(error.cliDetails ? { details: error.cliDetails } : {}) };
@@ -372,7 +366,8 @@ function diagnosticError(error, context, execution) {
372
366
  if (error.issuesTruncated) details.issuesTruncated = true;
373
367
  const contextReason = localContextReason(error, details, execution);
374
368
  const responseReason = declaredResponseReason(error, context, details, execution);
375
- const recovery = describeRecovery(details, execution, error.diagnosticReason === 'missing_authentication', contextReason, responseReason);
369
+ const gateway = !inputError && !contextReason && !responseReason ? gatewayGuidance(details, context, execution) : undefined;
370
+ const recovery = describeRecovery(details, execution, error.diagnosticReason === 'missing_authentication', contextReason, responseReason, gateway);
376
371
  const groupConflict = inputError && context.path === 'item.group.list' &&
377
372
  issues.some(issue => issue.path === '/parentGroupId' && issue.reason === 'constraint' && issue.relatedPaths?.includes('/title'))
378
373
  ? context.schema.errors.parentTitleConflict : undefined;
@@ -382,9 +377,9 @@ function diagnosticError(error, context, execution) {
382
377
  const result = { success: false, code: -10001,
383
378
  message: inputError ? (groupConflict?.message || issueMessage(issues)) :
384
379
  contextReason ? localContextMessages[contextReason] :
385
- gatewayAccessDenied(details) ? '网关拒绝访问:没有操作权限。' :
386
380
  details.category === 'provider_unavailable' && context.path === 'privacy.field.decrypt' ? '当前仅支持离线预览,真实解密服务尚未接入' :
387
381
  responseReason ? responseReason.message :
382
+ gateway ? gateway.message :
388
383
  details.category === 'business_rejected' && details.requestState === 'sent' ? businessRejectionMessage(details) :
389
384
  issues.some(issue => issue.source === 'output') ?
390
385
  `返回数据字段 ${issues.find(issue => issue.source === 'output').path} 不符合要求,已停止查询` :
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const authenticationStep = '先区分当前凭证来源与目标店铺、应用授权范围;个人登录使用 zan-saas auth login 重新登录授权,显式环境身份由凭证提供方更新或补齐配置。zan-saas auth status 仅查看本地保存状态,不能证明环境 token 有效;不在聊天中提供凭证,不自动切换来源或刷新,不自动重放。';
4
+ const supportStep = '先按同版 help/schema 核对参数契约;参数符合且持续失败时,将诊断中实际保留的 api、gatewayCode 和追踪号提供给支持人员排查,不提供凭证、客户标识或请求 body,不自动重放。';
5
+ const guidance = new Map([
6
+ [4101, { message: '网关限制了本次请求:调用过于频繁或受限。', action: 'stop_and_inspect',
7
+ nextStep: '停止本轮后续请求,检查调用频率、并发与可用配额;降低频率后由调用方重新决定是否发起。持续受限时提供诊断中实际保留的 api、gatewayCode 和追踪号排查,不保证固定恢复时间,不自动等待或重试,不自动重放。' }],
8
+ [4201, { message: '网关拒绝了本次请求:请求凭证无效。', action: 'complete_authentication',
9
+ nextStep: `核查凭证值以及店铺与应用授权;不能仅凭此码确定密码、员工权限或某一项授权失效。${authenticationStep}` }],
10
+ [4202, { message: '网关拒绝了本次请求:请求凭证已过期。', action: 'complete_authentication',
11
+ nextStep: authenticationStep }],
12
+ [4203, { message: '网关拒绝了本次请求:请求凭证不存在。', action: 'complete_authentication',
13
+ nextStep: authenticationStep }],
14
+ [4204, { message: '网关拒绝访问:没有操作权限。', action: 'request_access_review',
15
+ nextStep: '请检查当前账号是否有该店铺权限,以及店铺应用是否已获该接口的开放能力授权;不能仅凭此码确定根因,持续拒绝时提供诊断中实际保留的 api、gatewayCode 和追踪号给管理员核查服务端限制,不自动申请授权、切店或换身份,不自动重放。' }],
16
+ [4009, { message: '网关无法解析本次请求的客户标识 open_id。', action: 'stop_and_inspect',
17
+ nextStep: '按同版契约核对客户标识、标识类型与目标店铺;这不是登录凭证问题,也不能据此认定客户在所有店铺均不存在,不自动登录或切店,不自动重放。' }],
18
+ [5001, { message: '本次请求失败:网关报告上游系统异常。', action: 'stop_and_inspect', nextStep: supportStep }],
19
+ [5002, { message: '本次请求失败:网关报告上游业务异常。', action: 'stop_and_inspect', nextStep: supportStep }]
20
+ ]);
21
+
22
+ function gatewayGuidance(details, context, execution) {
23
+ if (details?.requestState !== 'sent' ||
24
+ (execution && (execution.requestState !== 'sent' || execution.coverage !== 'complete')) ||
25
+ details.issues?.length) return undefined;
26
+ const raw = details.gatewayCode;
27
+ if (typeof raw !== 'number' && !(typeof raw === 'string' && /^[1-9]\d{3}$/.test(raw))) return undefined;
28
+ const code = Number(raw);
29
+ const selected = guidance.get(code);
30
+ if (!selected) return undefined;
31
+ const compatible = details.category === 'business_rejected' ||
32
+ (details.category === 'authentication' && [4201, 4202, 4203].includes(code)) ||
33
+ (details.category === 'permission' && code === 4204) ||
34
+ (details.category === 'http' && ((details.httpStatus === 429 && code === 4101) ||
35
+ (details.httpStatus >= 500 && [5001, 5002].includes(code))));
36
+ if (!compatible) return undefined;
37
+ const customerOption = code === 4009 && context?.command?.options.some(option => option.long === '--yz-open-id');
38
+ const bindingStep = details.stage === 'shop-binding' ? '店铺核验失败,后续业务请求尚未发送。' : '';
39
+ const customerStep = customerOption ? '核对本命令 --yz-open-id 的客户标识。' : '';
40
+ return { ...selected, nextStep: `${bindingStep}${customerStep}${selected.nextStep}` };
41
+ }
42
+
43
+ module.exports = { gatewayGuidance };
@@ -12,20 +12,23 @@ function failure(command, category, stage, scope = {}, requestState = 'not_sent'
12
12
  return new QueryError(category, stage, scope, facts, requestState, command, nextStep);
13
13
  }
14
14
 
15
- async function createBoundShopQuery(target, command, bindingStage = 'shop-binding', { onResponse, transportOptions } = {}) {
15
+ async function createBoundShopQuery(target, command, bindingStage = 'shop-binding', {
16
+ onResponse, transportOptions, environment, beforeRequest
17
+ } = {}) {
16
18
  const scope = Object.freeze({ kdtId: target.kdtId });
17
19
  if (!Number.isSafeInteger(scope.kdtId) || scope.kdtId < 1) {
18
20
  throw failure(command, 'validation', 'scope');
19
21
  }
20
22
  let env;
21
23
  try {
22
- env = await snapshotApiEnvironment(undefined, scope.kdtId);
24
+ env = await snapshotApiEnvironment(environment, scope.kdtId);
23
25
  } catch (error) {
24
26
  const rejected = failure(command, 'configuration', 'configuration', scope);
25
27
  rejected.diagnosticReason = error.diagnosticReason;
26
28
  throw rejected;
27
29
  }
28
30
  async function read(path, body, stage, successCodes = [200]) {
31
+ if (beforeRequest) beforeRequest(path, stage);
29
32
  const { result, facts, rawBody, responseBytes } = await readApiEnvelope(path, body,
30
33
  (category, details, state, at) => failure(command, category,
31
34
  at === 'configuration' || category === transportOptions?.limitCategory ? at : stage,
@@ -43,7 +46,7 @@ async function createBoundShopQuery(target, command, bindingStage = 'shop-bindin
43
46
  if (data.sid !== scope.kdtId) {
44
47
  throw failure(command, 'scope_unresolved', bindingStage, scope, 'sent', facts);
45
48
  }
46
- return { read, binding };
49
+ return { read, binding, environment: env };
47
50
  }
48
51
 
49
52
  async function createShopQuery(target, command, options = {}) {
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { oauthConfig } = require('../auth/config');
4
- const { readStore, storePath, writeStore } = require('../auth/credential-store');
4
+ const { storePath } = require('../auth/credential-store');
5
+ const { readFileStore, updateFileStore } = require('./file-store');
5
6
  const { createContext, readContext } = require('./context');
6
7
 
7
8
  const fileName = 'context.json';
@@ -12,7 +13,7 @@ function contextPath(env = process.env) {
12
13
 
13
14
  // The selected shop is kept per oauth origin, like the credential it was chosen with.
14
15
  function readSavedContext(env = process.env) {
15
- const entry = readStore(fileName, env)[oauthConfig(env).server];
16
+ const entry = readFileStore(contextPath(env))[oauthConfig(env).server];
16
17
  const state = readContext(entry?.context);
17
18
  if (state.status !== 'ready') return null;
18
19
  return Object.freeze({
@@ -25,24 +26,27 @@ function readSavedContext(env = process.env) {
25
26
  function saveContext(input, { shopName } = {}, env = process.env) {
26
27
  const context = createContext(input);
27
28
  const { server } = oauthConfig(env);
28
- const data = readStore(fileName, env);
29
- data[server] = { context, ...(shopName ? { shopName } : {}), savedAt: new Date().toISOString() };
30
- writeStore(fileName, data, env);
29
+ updateFileStore(contextPath(env), data => {
30
+ data[server] = { context, ...(shopName ? { shopName } : {}), savedAt: new Date().toISOString() };
31
+ });
31
32
  return context;
32
33
  }
33
34
 
34
35
  function deleteContext(env = process.env) {
35
36
  const { server } = oauthConfig(env);
36
- const data = readStore(fileName, env);
37
- if (!Object.hasOwn(data, server)) return false;
38
- delete data[server];
39
- writeStore(fileName, data, env);
40
- return true;
37
+ return updateFileStore(contextPath(env), data => {
38
+ if (!Object.hasOwn(data, server)) return false;
39
+ delete data[server];
40
+ return true;
41
+ });
41
42
  }
42
43
 
43
44
  // ZAN_SAAS_CONTEXT wins, even when invalid; otherwise the shop chosen with `shop use`.
44
45
  function contextInput(env = process.env) {
45
46
  if (env.ZAN_SAAS_CONTEXT) return env.ZAN_SAAS_CONTEXT;
47
+ if (env.ZAN_SAAS_SESSION_ID !== undefined) {
48
+ return require('./session-store').sessionContext(env);
49
+ }
46
50
  try {
47
51
  return readSavedContext(env)?.context;
48
52
  } catch {
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { createHash, randomUUID } = require('node:crypto');
6
+
7
+ const pause = new Int32Array(new SharedArrayBuffer(4));
8
+ const digest = text => createHash('sha256').update(text).digest('hex');
9
+
10
+ function reclaim(lock, previous) {
11
+ const guard = `${lock}.reclaim`;
12
+ let fd;
13
+ try { fd = fs.openSync(guard, 'wx', 0o600); }
14
+ catch (error) {
15
+ if (error.code === 'EEXIST') return false;
16
+ throw storageError();
17
+ }
18
+ try {
19
+ if (readText(lock) !== previous) return false;
20
+ fs.unlinkSync(lock);
21
+ return true;
22
+ } finally {
23
+ fs.closeSync(fd);
24
+ fs.unlinkSync(guard);
25
+ }
26
+ }
27
+
28
+ function storageError(reason = 'context_store_invalid') {
29
+ const error = new Error(reason === 'context_lock_timeout'
30
+ ? '选店记录正在被其他调用更新,请稍后核对当前位置。'
31
+ : '选店记录损坏或无法读取,请核对本机上下文文件;未回退到其他店铺。');
32
+ error.diagnosticReason = reason;
33
+ return error;
34
+ }
35
+
36
+ function readText(filename) {
37
+ try {
38
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
39
+ try {
40
+ if (!fs.fstatSync(fd).isFile()) throw storageError();
41
+ return fs.readFileSync(fd, 'utf8');
42
+ } finally { fs.closeSync(fd); }
43
+ } catch (error) {
44
+ if (error.code === 'ENOENT') return null;
45
+ throw storageError();
46
+ }
47
+ }
48
+
49
+ function withLock(filename, action) {
50
+ const lock = `${filename}.lock`;
51
+ const started = Date.now();
52
+ const owner = JSON.stringify({ pid: process.pid, nonce: randomUUID() });
53
+ let fd;
54
+ for (;;) {
55
+ try {
56
+ fd = fs.openSync(lock, 'wx', 0o600);
57
+ fs.writeFileSync(fd, owner);
58
+ break;
59
+ } catch (error) {
60
+ if (error.code !== 'EEXIST') throw storageError();
61
+ const previous = readText(lock);
62
+ try {
63
+ const { pid } = JSON.parse(previous);
64
+ if (Number.isSafeInteger(pid) && pid > 0) {
65
+ try { process.kill(pid, 0); }
66
+ catch (dead) {
67
+ if (dead.code === 'ESRCH' && readText(lock) === previous) {
68
+ if (reclaim(lock, previous)) continue;
69
+ }
70
+ }
71
+ }
72
+ } catch { /* An incomplete lock is never stolen from a live writer. */ }
73
+ if (Date.now() - started >= 5000) throw storageError('context_lock_timeout');
74
+ Atomics.wait(pause, 0, 0, 10);
75
+ }
76
+ }
77
+ try { return action(); }
78
+ finally {
79
+ fs.closeSync(fd);
80
+ if (readText(lock) === owner) fs.unlinkSync(lock);
81
+ }
82
+ }
83
+
84
+ function parse(text) {
85
+ try {
86
+ const value = JSON.parse(text);
87
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw storageError();
88
+ return value;
89
+ } catch { throw storageError(); }
90
+ }
91
+
92
+ function readUnlocked(filename) {
93
+ const pending = readText(`${filename}.journal`);
94
+ if (pending?.endsWith('\n')) {
95
+ const record = parse(pending);
96
+ if (typeof record.payload !== 'string' || digest(record.payload) !== record.sha256) {
97
+ throw storageError();
98
+ }
99
+ return parse(record.payload);
100
+ }
101
+ const text = readText(filename);
102
+ return text === null ? {} : parse(text);
103
+ }
104
+
105
+ // Keep the existing inode: permissions granted to a file remain attached to that file.
106
+ function rewrite(filename, text) {
107
+ let fd;
108
+ try {
109
+ fd = fs.openSync(filename, fs.constants.O_RDWR | fs.constants.O_CREAT |
110
+ (fs.constants.O_NOFOLLOW || 0), 0o600);
111
+ if (!fs.fstatSync(fd).isFile()) throw storageError();
112
+ const bytes = Buffer.from(text);
113
+ let offset = 0;
114
+ while (offset < bytes.length) {
115
+ offset += fs.writeSync(fd, bytes, offset, bytes.length - offset, offset);
116
+ }
117
+ fs.ftruncateSync(fd, bytes.length);
118
+ fs.fsyncSync(fd);
119
+ fs.fchmodSync(fd, 0o600);
120
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
121
+ }
122
+
123
+ function readFileStore(filename) {
124
+ if (!fs.existsSync(path.dirname(filename))) return {};
125
+ return withLock(filename, () => readUnlocked(filename));
126
+ }
127
+
128
+ function updateFileStore(filename, update) {
129
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
130
+ return withLock(filename, () => {
131
+ const data = readUnlocked(filename);
132
+ const result = update(data);
133
+ const payload = `${JSON.stringify(data, null, 2)}\n`;
134
+ // Finish a previously committed rewrite before reusing its journal.
135
+ if (readText(`${filename}.journal`)?.endsWith('\n')) {
136
+ rewrite(filename, `${JSON.stringify(readUnlocked(filename), null, 2)}\n`);
137
+ }
138
+ rewrite(`${filename}.journal`, '');
139
+ // A durable redo record is the commit point. Readers can use it after an interrupted rewrite.
140
+ rewrite(`${filename}.journal`, `${JSON.stringify({ payload, sha256: digest(payload) })}\n`);
141
+ rewrite(filename, payload);
142
+ rewrite(`${filename}.journal`, '');
143
+ return result;
144
+ });
145
+ }
146
+
147
+ module.exports = { digest, readFileStore, updateFileStore, storageError };
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { oauthConfig, configDir, defaultAccountApiBaseUrl, defaultApiBaseUrl } = require('../auth/config');
6
+ const { createContext } = require('./context');
7
+ const { digest, readFileStore, updateFileStore, storageError } = require('./file-store');
8
+ const { readCredential, credentialPresence } = require('../auth/credential-store');
9
+ const { authSource, credentialChoice } = require('../auth/credential-source');
10
+
11
+ function fail(reason, message) {
12
+ const error = new Error(message);
13
+ error.diagnosticReason = reason;
14
+ return error;
15
+ }
16
+
17
+ function sessionId(env = process.env) {
18
+ const value = env.ZAN_SAAS_SESSION_ID;
19
+ if (value === undefined) return null;
20
+ if (typeof value !== 'string' || !value.trim() || /[\u0000-\u001f\u007f]/.test(value) ||
21
+ Buffer.byteLength(value) > 256) {
22
+ throw fail('session_missing', 'ZAN_SAAS_SESSION_ID 必须是宿主提供的非空会话标识,最长256字节。');
23
+ }
24
+ return digest(value);
25
+ }
26
+
27
+ function environmentKey(env) {
28
+ const server = oauthConfig(env).server;
29
+ const bases = [env.ZAN_SAAS_ACCOUNT_API_BASE_URL || defaultAccountApiBaseUrl,
30
+ env.ZAN_SAAS_API_BASE_URL || defaultApiBaseUrl].map(value => {
31
+ const url = new URL(value);
32
+ if (url.username || url.password || url.search || url.hash ||
33
+ !['https:', 'http:'].includes(url.protocol)) throw storageError();
34
+ return url.toString().replace(/\/$/, '');
35
+ });
36
+ return { server, key: digest(JSON.stringify([server, ...bases])) };
37
+ }
38
+
39
+ function statePath(env = process.env) {
40
+ return path.join(configDir(env), 'contexts-v2', `${environmentKey(env).key}.json`);
41
+ }
42
+
43
+ function exact(value, fields) {
44
+ return value && typeof value === 'object' && !Array.isArray(value) &&
45
+ Object.keys(value).length === fields.length && fields.every(key => Object.hasOwn(value, key));
46
+ }
47
+ const dictionary = value => value && typeof value === 'object' && !Array.isArray(value) &&
48
+ Object.keys(value).every(key => /^[a-f0-9]{64}$/.test(key));
49
+ const time = value => value === null || typeof value === 'string' &&
50
+ /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value));
51
+ const nullableName = value => value === null || typeof value === 'string' && Boolean(value.trim());
52
+
53
+ function validLocation(value, userId) {
54
+ if (!exact(value, ['context', 'selection'])) return false;
55
+ const { context, selection } = value;
56
+ try { createContext(context); } catch { return false; }
57
+ return context.userId === userId && exact(selection, [
58
+ 'tenantRootKdtId', 'tenantName', 'currentUnitKdtId', 'unitName', 'position', 'role'
59
+ ]) && selection.tenantRootKdtId === context.hq && selection.currentUnitKdtId === context.shopId &&
60
+ selection.position === context.orgType && nullableName(selection.tenantName) &&
61
+ nullableName(selection.unitName) && (selection.role === null ||
62
+ Number.isSafeInteger(selection.role) && selection.role >= 0);
63
+ }
64
+
65
+ function validate(data, env) {
66
+ if (!Object.keys(data).length) return {
67
+ schemaVersion: 2, server: environmentKey(env).server, identity: null, identities: {}, accounts: {}
68
+ };
69
+ if (!exact(data, ['schemaVersion', 'server', 'identity', 'identities', 'accounts']) ||
70
+ data.schemaVersion !== 2 || data.server !== environmentKey(env).server ||
71
+ !dictionary(data.accounts) || !dictionary(data.identities)) throw storageError();
72
+ for (const identity of [...Object.values(data.identities), ...(data.identity ? [data.identity] : [])]) {
73
+ if (!exact(identity, ['userId', 'credentialRef']) ||
74
+ !(identity.userId === null || typeof identity.userId === 'string' && identity.userId.trim()) ||
75
+ typeof identity.credentialRef !== 'string') throw storageError();
76
+ }
77
+ if (data.identity !== null && (!data.identity || typeof data.identity !== 'object')) throw storageError();
78
+ for (const [key, identity] of Object.entries(data.identities)) {
79
+ if (key !== digest(identity.credentialRef) || !identity.credentialRef.startsWith('env:') ||
80
+ identity.userId === null) throw storageError();
81
+ }
82
+ for (const [key, account] of Object.entries(data.accounts)) {
83
+ if (!exact(account, ['userId', 'recent', 'sessions', 'legacyChecked']) || typeof account.userId !== 'string' ||
84
+ !account.userId.trim() || digest(account.userId) !== key || !dictionary(account.sessions) ||
85
+ typeof account.legacyChecked !== 'boolean' ||
86
+ account.recent !== null && !validLocation(account.recent, account.userId)) throw storageError();
87
+ for (const binding of Object.values(account.sessions)) {
88
+ if (!exact(binding, ['state', 'revision', 'origin', 'initializedAt', 'selectedAt', 'location']) ||
89
+ !['needs_selection', 'ready', 'cleared'].includes(binding.state) ||
90
+ !Number.isSafeInteger(binding.revision) || binding.revision < 1 ||
91
+ !['explicit', 'inherited', 'legacy_import', 'none'].includes(binding.origin) ||
92
+ !time(binding.initializedAt) || !time(binding.selectedAt) ||
93
+ (binding.state === 'ready' ? !validLocation(binding.location, account.userId)
94
+ : binding.location !== null)) throw storageError();
95
+ }
96
+ }
97
+ return data;
98
+ }
99
+
100
+ const read = env => validate(readFileStore(statePath(env)), env);
101
+ function update(env, action) {
102
+ return updateFileStore(statePath(env), raw => {
103
+ const data = validate(raw, env);
104
+ const result = action(data);
105
+ Object.assign(raw, data);
106
+ validate(raw, env);
107
+ return result;
108
+ });
109
+ }
110
+
111
+ function credentialRef(env, credential) {
112
+ return credentialChoice(env, credential).credentialRef;
113
+ }
114
+
115
+ function activeIdentity(data, env) {
116
+ const mode = authSource(env);
117
+ if (mode !== 'env') {
118
+ const personal = credentialPresence(oauthConfig(env).server, env);
119
+ if (personal.present) {
120
+ return data.identity?.credentialRef === personal.credentialRef
121
+ ? data.identity : { userId: null, credentialRef: personal.credentialRef };
122
+ }
123
+ }
124
+ const ref = credentialRef(env);
125
+ return mode !== 'personal' && ref ? data.identities[digest(ref)] || null : null;
126
+ }
127
+
128
+ function assertCredential(identity, env) {
129
+ const credential = authSource(env) === 'env' ? null : readCredential(oauthConfig(env).server, env);
130
+ if (identity.credentialRef !== credentialRef(env, credential)) {
131
+ throw fail('context_conflict', '登录代次在执行期间改变,未提交旧身份的位置。');
132
+ }
133
+ }
134
+
135
+ function accountOf(data, env) {
136
+ const identity = activeIdentity(data, env);
137
+ return identity?.userId ? data.accounts[digest(identity.userId)] : null;
138
+ }
139
+
140
+ function snapshot(env = process.env, verifiedIdentity) {
141
+ const id = sessionId(env);
142
+ if (!id) throw fail('session_missing', '请由宿主逐次传入 ZAN_SAAS_SESSION_ID。');
143
+ const data = read(env);
144
+ const identity = verifiedIdentity || activeIdentity(data, env);
145
+ const account = identity?.userId ? data.accounts[digest(identity.userId)] : null;
146
+ const binding = account?.sessions[id] || null;
147
+ return { identity, binding, id, recent: account?.recent ?? null, legacyChecked: account?.legacyChecked ?? false };
148
+ }
149
+
150
+ function publicBinding(snapshotValue) {
151
+ const { binding, id, identity } = snapshotValue;
152
+ return {
153
+ mode: 'session', sessionRef: id.slice(0, 16),
154
+ state: binding?.state || (identity?.userId ? 'uninitialized' : 'identity_pending'),
155
+ revision: binding?.revision ?? null, origin: binding?.origin || 'none',
156
+ initializedAt: binding?.initializedAt ?? null, selectedAt: binding?.selectedAt ?? null
157
+ };
158
+ }
159
+
160
+ function initialize({ resume = false, legacy = null, verifiedIdentity } = {}, env = process.env) {
161
+ const id = sessionId(env);
162
+ if (!id) throw fail('session_missing', '会话初始化需要 ZAN_SAAS_SESSION_ID。');
163
+ return update(env, data => {
164
+ const identity = verifiedIdentity || activeIdentity(data, env);
165
+ if (!identity?.userId) throw fail('identity_pending', '请先核实当前登录账号,再初始化会话。');
166
+ assertCredential(identity, env);
167
+ const key = digest(identity.userId);
168
+ const account = data.accounts[key] || {
169
+ userId: identity.userId, recent: null, sessions: {}, legacyChecked: false
170
+ };
171
+ if (resume && !account.sessions[id]) {
172
+ throw fail('session_not_found', '原会话选店记录不存在,请明确重新选择店铺。');
173
+ }
174
+ if (identity.credentialRef.startsWith('env:')) data.identities[digest(identity.credentialRef)] = identity;
175
+ else data.identity = identity;
176
+ data.accounts[key] = account;
177
+ if (account.sessions[id]) return false;
178
+ const imported = legacy && validLocation(legacy, account.userId) ? legacy : null;
179
+ const location = account.recent || (!account.legacyChecked ? imported : null);
180
+ const now = new Date().toISOString();
181
+ account.sessions[id] = {
182
+ state: location ? 'ready' : 'needs_selection', revision: 1,
183
+ origin: account.recent ? 'inherited' : location ? 'legacy_import' : 'none',
184
+ initializedAt: now, selectedAt: location ? now : null, location
185
+ };
186
+ if (!account.recent && location) account.recent = location;
187
+ account.legacyChecked = true;
188
+ return true;
189
+ });
190
+ }
191
+
192
+ function commit(location, expected, { sessionOnly = false } = {}, env = process.env) {
193
+ const id = sessionId(env);
194
+ return update(env, data => {
195
+ const identity = activeIdentity(data, env);
196
+ const account = accountOf(data, env);
197
+ const previous = account?.sessions[id];
198
+ if (!previous) throw fail('session_uninitialized', '请先执行 zan-saas shop session init。');
199
+ assertCredential(identity, env);
200
+ if (identity.userId !== expected.identity?.userId ||
201
+ identity.credentialRef !== expected.identity?.credentialRef ||
202
+ previous.revision !== expected.binding?.revision) {
203
+ throw fail('context_conflict', '会话位置或登录身份已改变,请重新查看当前位置。');
204
+ }
205
+ if (!validLocation(location, account.userId)) throw storageError();
206
+ const currentChanged = JSON.stringify(previous.location) !== JSON.stringify(location);
207
+ const recentChanged = !sessionOnly && JSON.stringify(account.recent) !== JSON.stringify(location);
208
+ if (currentChanged) account.sessions[id] = {
209
+ ...previous, state: 'ready', revision: previous.revision + 1, origin: 'explicit',
210
+ selectedAt: new Date().toISOString(), location
211
+ };
212
+ if (!sessionOnly) account.recent = location;
213
+ return { currentChanged, recentChanged };
214
+ });
215
+ }
216
+
217
+ function clear({ recent = false } = {}, env = process.env) {
218
+ const id = sessionId(env);
219
+ return update(env, data => {
220
+ const account = accountOf(data, env);
221
+ const binding = account?.sessions[id];
222
+ if (!binding) throw fail('session_uninitialized', '请先执行 zan-saas shop session init。');
223
+ const currentChanged = binding.state !== 'cleared';
224
+ const recentChanged = recent && account.recent !== null;
225
+ if (currentChanged) account.sessions[id] = {
226
+ ...binding, state: 'cleared', location: null, origin: 'none',
227
+ revision: binding.revision + 1, selectedAt: null
228
+ };
229
+ if (recent) account.recent = null;
230
+ return { cleared: binding.state === 'ready', currentChanged, recentChanged };
231
+ });
232
+ }
233
+
234
+ function sessionContext(env) {
235
+ return snapshot(env).binding?.location?.context;
236
+ }
237
+
238
+ function resetIdentity(env, { logout = false, pendingCredentialRef = null } = {}) {
239
+ const directory = path.join(configDir(env), 'contexts-v2');
240
+ if (!fs.existsSync(directory)) return;
241
+ const server = oauthConfig(env).server;
242
+ for (const filename of fs.readdirSync(directory).filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
243
+ const target = path.join(directory, filename);
244
+ updateFileStore(target, data => {
245
+ if (data.server !== server) return;
246
+ if (logout) {
247
+ const protectedUsers = new Set(Object.values(data.identities || {}).map(item => item.userId));
248
+ if (data.identity?.userId) delete data.accounts[digest(data.identity.userId)];
249
+ else for (const [key, account] of Object.entries(data.accounts || {})) {
250
+ if (!protectedUsers.has(account.userId)) delete data.accounts[key];
251
+ }
252
+ }
253
+ data.identity = pendingCredentialRef ? { userId: null, credentialRef: pendingCredentialRef } : null;
254
+ });
255
+ }
256
+ }
257
+
258
+ module.exports = {
259
+ sessionId, environmentKey, statePath, credentialRef, snapshot, publicBinding,
260
+ initialize, commit, clear, sessionContext, resetIdentity, fail
261
+ };