@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.
- package/bin/zan-saas-cli.js +8 -11
- package/internal/registry/manifest.json +25 -20
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/zan-saas/SKILL.md +3 -3
- package/skills/zan-saas/references/host-entry.md +8 -0
- package/skills/zan-saas-order/SKILL.md +12 -1
- package/skills/zan-saas-order/references/branch-counts.md +45 -0
- package/skills/zan-saas-order/references/order-get.md +2 -0
- package/skills/zan-saas-order/references/order-list.md +2 -0
- package/skills/zan-saas-order/references/order-scan.md +2 -0
- package/skills/zan-saas-org/SKILL.md +6 -0
- package/skills/zan-saas-org/references/unit-list.md +2 -0
- package/skills/zan-saas-shared/SKILL.md +20 -5
- package/skills/zan-saas-shared/references/authentication.md +9 -4
- package/skills/zan-saas-shared/references/gateway-errors.md +29 -0
- package/skills/zan-saas-shared/references/shop-context.md +39 -0
- package/skills/zan-saas-valuecard/SKILL.md +8 -2
- package/skills/zan-saas-valuecard/references/transaction-list.md +25 -0
- package/skills/zan-saas-valuecard/references/transaction-scan.md +23 -0
- package/src/auth/credential-source.js +26 -0
- package/src/auth/credential-store.js +61 -5
- package/src/auth/session.js +80 -27
- package/src/cli/command-paths.js +3 -2
- package/src/cli/context-help.js +3 -1
- package/src/cli/program.js +20 -4
- package/src/commands/native/access/commands.index.json +1 -1
- package/src/commands/native/access/role/list/command.js +4 -32
- package/src/commands/native/customer/commands.index.json +1 -1
- package/src/commands/native/customer/customer-tag/list/command.js +2 -2
- package/src/commands/native/member/commands.index.json +1 -1
- package/src/commands/native/order/commands.index.json +3 -3
- package/src/commands/native/order/order/get/command.js +14 -9
- package/src/commands/native/order/order/get/schema.json +197 -41
- package/src/commands/native/order/order/list/command.js +9 -6
- package/src/commands/native/order/order/list/schema.json +140 -10
- package/src/commands/native/order/order/scan/command.js +8 -5
- package/src/commands/native/order/order/scan/scan.js +1 -1
- package/src/commands/native/order/order/scan/schema.json +135 -8
- package/src/commands/native/order/shared/query.js +57 -9
- package/src/commands/native/order/shared/search.js +1 -1
- package/src/commands/native/org/commands.index.json +4 -4
- package/src/commands/native/valuecard/commands.index.json +20 -2
- package/src/commands/native/valuecard/domain.json +1 -1
- package/src/commands/native/valuecard/transaction/list/command.js +110 -0
- package/src/commands/native/valuecard/transaction/list/projection.js +71 -0
- package/src/commands/native/valuecard/transaction/list/schema.json +888 -0
- package/src/commands/native/valuecard/transaction/scan/command.js +130 -0
- package/src/commands/native/valuecard/transaction/scan/projection.js +58 -0
- package/src/commands/native/valuecard/transaction/scan/scan.js +70 -0
- package/src/commands/native/valuecard/transaction/scan/schema.json +954 -0
- package/src/commands/system/api.js +1 -1
- package/src/commands/system/auth.js +13 -2
- package/src/commands/system/contracts.js +27 -19
- package/src/commands/system/shop-contracts.js +97 -0
- package/src/commands/system/shop.js +128 -27
- package/src/execution/error-details.js +13 -18
- package/src/execution/gateway-guidance.js +43 -0
- package/src/execution/shop-query.js +6 -3
- package/src/identity/context-store.js +14 -10
- package/src/identity/file-store.js +147 -0
- package/src/identity/session-store.js +261 -0
- package/src/identity/shop-location.js +158 -0
- package/src/identity/unit-membership.js +42 -0
- package/src/transport/api.js +78 -11
- 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
|
-
|
|
289
|
-
|
|
290
|
-
error.
|
|
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
|
|
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', {
|
|
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(
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
};
|