@youzan-cloud/zan-saas-cli 0.1.0-beta.4 → 0.1.0-beta.7
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 +9 -8
- 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-org/SKILL.md +4 -0
- package/skills/zan-saas-shared/SKILL.md +16 -5
- package/skills/zan-saas-shared/references/authentication.md +9 -4
- package/skills/zan-saas-shared/references/shop-context.md +37 -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/program.js +20 -4
- package/src/commands/native/access/commands.index.json +1 -1
- 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/org/commands.index.json +4 -4
- 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 +66 -0
- package/src/commands/system/shop.js +128 -27
- 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 +153 -0
- package/src/transport/api.js +78 -11
- package/src/transport/shop-app.js +8 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const account = require('../transport/account-api');
|
|
4
|
+
const { oauthConfig } = require('../auth/config');
|
|
5
|
+
const { readCredential } = require('../auth/credential-store');
|
|
6
|
+
const { authSource } = require('../auth/credential-source');
|
|
7
|
+
const { requestApi, snapshotApiIdentity, snapshotApiEnvironment } = require('../transport/api');
|
|
8
|
+
const sessions = require('./session-store');
|
|
9
|
+
const { readSavedContext } = require('./context-store');
|
|
10
|
+
const { createContext } = require('./context');
|
|
11
|
+
|
|
12
|
+
function id(value) {
|
|
13
|
+
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
14
|
+
throw sessions.fail('invalid_target', 'kdtId 必须是正整数店铺 ID。');
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function selection(context, { tenantName = null, unitName = null, role = null } = {}) {
|
|
20
|
+
return {
|
|
21
|
+
tenantRootKdtId: context.hq, tenantName, currentUnitKdtId: context.shopId,
|
|
22
|
+
unitName, position: context.orgType, role
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function identify(env) {
|
|
27
|
+
const { environment: pinned, source, credentialRef: ref } = await snapshotApiIdentity(env);
|
|
28
|
+
const user = await account.whoami(pinned);
|
|
29
|
+
const actor = { user, pinned, ref, source };
|
|
30
|
+
assertIdentity(actor, env);
|
|
31
|
+
return actor;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertIdentity(actor, env) {
|
|
35
|
+
const credential = authSource(env) === 'env' ? null : readCredential(oauthConfig(env).server, env);
|
|
36
|
+
if (actor.ref !== sessions.credentialRef(env, credential)) {
|
|
37
|
+
throw sessions.fail('context_conflict', '登录状态在核实账号期间发生变化,请重新查看当前身份。');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rejectOverride(env, explicitContext) {
|
|
42
|
+
if (env.ZAN_SAAS_CONTEXT || explicitContext !== undefined) {
|
|
43
|
+
throw sessions.fail('context_override_conflict',
|
|
44
|
+
'本次上下文由显式输入覆盖,请使用会话调用环境切店后核对 shop current。');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function rootLocation(kdtId, actor) {
|
|
49
|
+
id(kdtId);
|
|
50
|
+
const page = await account.listShops({ includeKdtIds: [kdtId], pageNum: 1, pageSize: 1 }, actor.pinned);
|
|
51
|
+
const shop = page.items.find(item => item.kdtId === kdtId);
|
|
52
|
+
if (!shop) throw new Error(`当前账号下没有可选店铺 ${kdtId}。执行 zan-saas shop list 查看可选店铺。`);
|
|
53
|
+
if (!shop.orgType) throw new Error(`店铺 ${kdtId} 不是本版支持的总部或单店入口。`);
|
|
54
|
+
const status = await account.staffStatus(kdtId, actor.pinned);
|
|
55
|
+
if (status === account.staffStatuses.disabled) throw new Error(`你在店铺 ${kdtId} 的员工账号已停用,不能选择。`);
|
|
56
|
+
const context = createContext({
|
|
57
|
+
userId: actor.user.userId, hq: kdtId, shopId: kdtId, orgType: shop.orgType
|
|
58
|
+
});
|
|
59
|
+
return { context, selection: selection(context, {
|
|
60
|
+
tenantName: shop.shopName, unitName: shop.shopName, role: shop.shopRole
|
|
61
|
+
}) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function unitLocation(kdtId, root, actor) {
|
|
65
|
+
id(kdtId);
|
|
66
|
+
if (kdtId === root.context.hq) return root;
|
|
67
|
+
if (root.context.orgType !== 'hq') {
|
|
68
|
+
throw sessions.fail('unit_scope_unresolved', '当前店铺没有已核实的连锁总部,不能进入下级组织。');
|
|
69
|
+
}
|
|
70
|
+
const response = await requestApi({ path: 'youzan.shop.basic.get/3.0.0', body: {} },
|
|
71
|
+
await snapshotApiEnvironment(actor.pinned, kdtId),
|
|
72
|
+
{ shopApp: false, identityCheck: false, maxResponseBytes: () => 256 * 1024 });
|
|
73
|
+
let envelope;
|
|
74
|
+
try { envelope = JSON.parse(response.body); } catch { /* Report a bounded protocol error below. */ }
|
|
75
|
+
const data = envelope?.data;
|
|
76
|
+
if (!response.ok || envelope?.success !== true || envelope.code !== 200 ||
|
|
77
|
+
!data || data.sid !== Number(kdtId) || data.root_kdt_id !== Number(root.context.hq)) {
|
|
78
|
+
throw sessions.fail('unit_scope_unresolved', '未能核实目标组织及所属连锁,未改变当前位置。');
|
|
79
|
+
}
|
|
80
|
+
if (![2001, 2002].includes(data.role)) {
|
|
81
|
+
throw sessions.fail('unit_not_supported', '本版仅支持已核实的连锁门店或网店位置。');
|
|
82
|
+
}
|
|
83
|
+
const context = createContext({ ...root.context, shopId: kdtId, orgType: 'shop' });
|
|
84
|
+
return { context, selection: selection(context, {
|
|
85
|
+
tenantName: root.selection.tenantName,
|
|
86
|
+
unitName: typeof data.name === 'string' && data.name.trim() ? data.name : null, role: data.role
|
|
87
|
+
}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function displayName(location) {
|
|
91
|
+
const { tenantName, unitName, currentUnitKdtId, tenantRootKdtId } = location.selection;
|
|
92
|
+
if (currentUnitKdtId === tenantRootKdtId) return tenantName || unitName;
|
|
93
|
+
return tenantName && unitName ? `${tenantName} / ${unitName}` : unitName || tenantName;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function result(action, effect, env) {
|
|
97
|
+
const state = sessions.snapshot(env);
|
|
98
|
+
const location = state.binding?.location;
|
|
99
|
+
return { context: location?.context ?? null, selection: location?.selection ?? null,
|
|
100
|
+
binding: sessions.publicBinding(state), effect: { action, ...effect } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function initialize(options = {}, env = process.env) {
|
|
104
|
+
if (!sessions.sessionId(env)) throw sessions.fail('session_missing', '会话初始化需要 ZAN_SAAS_SESSION_ID。');
|
|
105
|
+
rejectOverride(env);
|
|
106
|
+
const actor = await identify(env);
|
|
107
|
+
let legacy = null;
|
|
108
|
+
const existing = sessions.snapshot(env, { userId: actor.user.userId, credentialRef: actor.ref });
|
|
109
|
+
if (!options.resume && !existing.binding && !existing.recent && !existing.legacyChecked &&
|
|
110
|
+
!env.ZAN_SAAS_ACCOUNT_API_BASE_URL && !env.ZAN_SAAS_API_BASE_URL) {
|
|
111
|
+
const saved = readSavedContext(env);
|
|
112
|
+
if (saved?.context.userId === actor.user.userId && saved.context.hq === saved.context.shopId) {
|
|
113
|
+
const root = await rootLocation(saved.context.hq, actor);
|
|
114
|
+
if (JSON.stringify(root.context) === JSON.stringify(saved.context)) legacy = root;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const currentChanged = sessions.initialize({ ...options, legacy,
|
|
118
|
+
verifiedIdentity: { userId: actor.user.userId, credentialRef: actor.ref } }, env);
|
|
119
|
+
return result('init', { currentChanged, recentChanged: false }, env);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function select(kdtId, options = {}, env = process.env, explicitContext) {
|
|
123
|
+
id(kdtId);
|
|
124
|
+
if (!sessions.sessionId(env)) throw sessions.fail('session_missing', '会话切换需要 ZAN_SAAS_SESSION_ID。');
|
|
125
|
+
rejectOverride(env, explicitContext);
|
|
126
|
+
if (env.ZAN_SAAS_KDT_ID && env.ZAN_SAAS_KDT_ID !== kdtId) {
|
|
127
|
+
throw sessions.fail('context_override_conflict', '请求店铺头覆盖与切换目标不同,未改变当前位置。');
|
|
128
|
+
}
|
|
129
|
+
const expected = sessions.snapshot(env);
|
|
130
|
+
if (!expected.binding) throw sessions.fail('session_uninitialized', '请先执行 zan-saas shop session init。');
|
|
131
|
+
const actor = await identify(env);
|
|
132
|
+
if (actor.user.userId !== expected.identity?.userId || actor.ref !== expected.identity?.credentialRef) {
|
|
133
|
+
throw sessions.fail('context_conflict', '登录身份已改变,请重新初始化本会话。');
|
|
134
|
+
}
|
|
135
|
+
const rootId = options.unit ? expected.binding.location?.context.hq : kdtId;
|
|
136
|
+
if (!rootId) throw sessions.fail('unit_scope_unresolved', '请先选择当前连锁。');
|
|
137
|
+
const root = await rootLocation(rootId, actor);
|
|
138
|
+
const location = options.unit ? await unitLocation(kdtId, root, actor) : root;
|
|
139
|
+
const effect = sessions.commit(location, expected, options, env);
|
|
140
|
+
return result(options.unit ? 'use_unit' : 'use_root', effect, env);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolve(kdtId, options = {}, env = process.env) {
|
|
144
|
+
id(kdtId);
|
|
145
|
+
const rootId = options.rootKdtId === undefined ? kdtId : id(options.rootKdtId);
|
|
146
|
+
const actor = await identify(env);
|
|
147
|
+
const root = await rootLocation(rootId, actor);
|
|
148
|
+
const location = await unitLocation(kdtId, root, actor);
|
|
149
|
+
return { ...location, effect: { action: 'resolve', currentChanged: false, recentChanged: false },
|
|
150
|
+
revision: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { selection, identify, assertIdentity, initialize, select, resolve, displayName, rejectOverride };
|
package/src/transport/api.js
CHANGED
|
@@ -11,6 +11,8 @@ const { runWithRequestState, beginRequest } = require('../execution/request-stat
|
|
|
11
11
|
|
|
12
12
|
const apiPathPattern = /^\/?([a-z][a-z0-9._-]*)\/([0-9]+\.[0-9]+\.[0-9]+)$/;
|
|
13
13
|
const apiSessions = new AsyncLocalStorage();
|
|
14
|
+
const boundTargets = new WeakMap();
|
|
15
|
+
const pinnedCredentials = new WeakMap();
|
|
14
16
|
|
|
15
17
|
function runWithApiSession(action, environment) {
|
|
16
18
|
return runWithRequestState(() =>
|
|
@@ -20,10 +22,22 @@ function runWithApiSession(action, environment) {
|
|
|
20
22
|
function apiEnvironment() {
|
|
21
23
|
const session = apiSessions.getStore();
|
|
22
24
|
if (!session) return Object.freeze({ ...process.env });
|
|
23
|
-
session.env ||= Object.freeze({
|
|
25
|
+
session.env ||= Object.freeze({
|
|
26
|
+
...(session.environment || process.env),
|
|
27
|
+
...(Object.hasOwn(session, 'context') ? { ZAN_SAAS_CONTEXT: session.context } : {})
|
|
28
|
+
});
|
|
24
29
|
return session.env;
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
function bindApiContext(context, environment = process.env) {
|
|
33
|
+
const session = apiSessions.getStore();
|
|
34
|
+
if (session) {
|
|
35
|
+
session.environment = environment;
|
|
36
|
+
session.context = JSON.stringify(context ?? null);
|
|
37
|
+
session.env = undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
// Business APIs select the shop per request; account APIs serve the user and send no shop.
|
|
28
42
|
const gateways = {
|
|
29
43
|
business: { key: 'ZAN_SAAS_API_BASE_URL', base: defaultApiBaseUrl, label: 'API base URL', shop: true },
|
|
@@ -46,14 +60,15 @@ class ApiResponseLimitError extends Error {
|
|
|
46
60
|
}
|
|
47
61
|
}
|
|
48
62
|
|
|
49
|
-
async function
|
|
63
|
+
async function accessCredential(env) {
|
|
50
64
|
try {
|
|
65
|
+
if (pinnedCredentials.has(env)) return pinnedCredentials.get(env);
|
|
51
66
|
const session = apiSessions.getStore();
|
|
52
67
|
if (session && env === session.env) {
|
|
53
68
|
session.credential ||= resolveAccessToken(env);
|
|
54
|
-
return
|
|
69
|
+
return await session.credential;
|
|
55
70
|
}
|
|
56
|
-
return
|
|
71
|
+
return await resolveAccessToken(env);
|
|
57
72
|
} catch (error) {
|
|
58
73
|
if (error instanceof AuthRequiredError || error instanceof AuthConfigurationError) {
|
|
59
74
|
throw new ApiConfigurationError(error.message,
|
|
@@ -63,6 +78,20 @@ async function accessToken(env) {
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
function pinEnvironment(env, credential, overrides = {}) {
|
|
82
|
+
const snapshot = Object.freeze({ ...env, ...overrides });
|
|
83
|
+
pinnedCredentials.set(snapshot, credential);
|
|
84
|
+
if (boundTargets.has(env)) boundTargets.set(snapshot, boundTargets.get(env));
|
|
85
|
+
return snapshot;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function snapshotApiIdentity(env = apiEnvironment()) {
|
|
89
|
+
requestAddress('youzan.user.info.get/1.0.0', env, gateways.account);
|
|
90
|
+
const credential = await accessCredential(env);
|
|
91
|
+
return { environment: pinEnvironment(env, credential), source: credential.source,
|
|
92
|
+
credentialRef: credential.credentialRef };
|
|
93
|
+
}
|
|
94
|
+
|
|
66
95
|
// Personal tokens select the shop per request: ZAN_SAAS_KDT_ID, else the shopId of ZAN_SAAS_CONTEXT,
|
|
67
96
|
// else the shop saved by `zan-saas shop use`.
|
|
68
97
|
function shopHeader(env) {
|
|
@@ -76,7 +105,7 @@ function shopHeader(env) {
|
|
|
76
105
|
return state.status === 'ready' ? state.context.shopId : undefined;
|
|
77
106
|
}
|
|
78
107
|
|
|
79
|
-
|
|
108
|
+
function requestAddress(path, env, gateway) {
|
|
80
109
|
const base = env[gateway.key] || gateway.base;
|
|
81
110
|
const match = apiPathPattern.exec(path);
|
|
82
111
|
if (!match) throw new ApiConfigurationError('API path must be <api-name>/<version>');
|
|
@@ -88,19 +117,47 @@ async function endpoint(path, env, gateway = gateways.business) {
|
|
|
88
117
|
url.password || url.search || url.hash || url.origin === 'null') {
|
|
89
118
|
throw new ApiConfigurationError(`${gateway.label} must be an HTTPS URL without credentials, query or fragment`);
|
|
90
119
|
}
|
|
91
|
-
// Resolve the token last so invalid configuration never triggers a login or refresh.
|
|
92
|
-
const token = await accessToken(env);
|
|
93
120
|
const requestUrl = new URL(
|
|
94
121
|
`${url.pathname.replace(/\/$/, '')}/${match[1]}/${match[2]}`, url.origin
|
|
95
122
|
);
|
|
123
|
+
return requestUrl;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function endpoint(path, env, gateway = gateways.business) {
|
|
127
|
+
const requestUrl = requestAddress(path, env, gateway);
|
|
128
|
+
// Resolve the token last so invalid configuration never triggers a login or refresh.
|
|
129
|
+
const credential = await accessCredential(env);
|
|
130
|
+
const { token } = credential;
|
|
96
131
|
requestUrl.searchParams.set('access_token', token);
|
|
97
|
-
return { requestUrl, token };
|
|
132
|
+
return { requestUrl, token, credential };
|
|
98
133
|
}
|
|
99
134
|
|
|
100
135
|
async function requestApi({ path, body }, env = apiEnvironment(), options = {}) {
|
|
101
136
|
const target = gateways[options.gateway || 'business'];
|
|
102
137
|
if (!target) throw new ApiConfigurationError(`Unknown API gateway: ${options.gateway}`);
|
|
103
138
|
const kdtId = target.shop ? shopHeader(env) : undefined;
|
|
139
|
+
if (target.shop && env.ZAN_SAAS_SESSION_ID !== undefined && options.identityCheck !== false) {
|
|
140
|
+
const state = readContext(contextInput(env));
|
|
141
|
+
if (state.status !== 'ready') {
|
|
142
|
+
throw new ApiConfigurationError('会话没有有效上下文,请先初始化并选择店铺。', 'session_uninitialized');
|
|
143
|
+
}
|
|
144
|
+
if (env.ZAN_SAAS_KDT_ID && env.ZAN_SAAS_KDT_ID !== state.context.shopId &&
|
|
145
|
+
boundTargets.get(env) !== env.ZAN_SAAS_KDT_ID) {
|
|
146
|
+
throw new ApiConfigurationError('请求店铺头与会话位置冲突,业务请求未发送。', 'context_override_conflict');
|
|
147
|
+
}
|
|
148
|
+
const { credential } = await endpoint(path, env, target);
|
|
149
|
+
env = pinEnvironment(env, credential);
|
|
150
|
+
let user;
|
|
151
|
+
try { user = await require('./account-api').whoami(env); }
|
|
152
|
+
catch { throw new ApiConfigurationError('无法核实当前凭证账号,业务请求未发送。', 'context_identity_mismatch'); }
|
|
153
|
+
if (user.userId !== state.context.userId) {
|
|
154
|
+
throw new ApiConfigurationError('当前凭证账号与任务上下文不一致或无法核实,业务请求未发送。', 'context_identity_mismatch');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!pinnedCredentials.has(env)) {
|
|
158
|
+
const { credential } = await endpoint(path, env, target);
|
|
159
|
+
env = pinEnvironment(env, credential);
|
|
160
|
+
}
|
|
104
161
|
const send = () => sendApi(path, body, env, target, kdtId, options);
|
|
105
162
|
const response = await send();
|
|
106
163
|
if (!kdtId || env.ZAN_SAAS_SHOP_APP_INIT === '0' || options.shopApp === false) return response;
|
|
@@ -189,11 +246,21 @@ async function readResponseBody(response, maxResponseBytes) {
|
|
|
189
246
|
}
|
|
190
247
|
|
|
191
248
|
async function snapshotApiEnvironment(env = apiEnvironment(), kdtId) {
|
|
192
|
-
|
|
193
|
-
|
|
249
|
+
if (env.ZAN_SAAS_SESSION_ID !== undefined && env.ZAN_SAAS_KDT_ID &&
|
|
250
|
+
env.ZAN_SAAS_KDT_ID !== String(kdtId)) {
|
|
251
|
+
throw new ApiConfigurationError('显式请求店铺头与本次命令目标冲突。', 'context_override_conflict');
|
|
252
|
+
}
|
|
253
|
+
const context = contextInput(env);
|
|
254
|
+
const { credential } = await endpoint('youzan.shop.basic.get/3.0.0', env);
|
|
255
|
+
const snapshot = pinEnvironment(env, credential, {
|
|
256
|
+
ZAN_SAAS_KDT_ID: String(kdtId),
|
|
257
|
+
ZAN_SAAS_CONTEXT: typeof context === 'string' ? context : JSON.stringify(context ?? null)
|
|
258
|
+
});
|
|
259
|
+
boundTargets.set(snapshot, String(kdtId));
|
|
260
|
+
return snapshot;
|
|
194
261
|
}
|
|
195
262
|
|
|
196
263
|
module.exports = {
|
|
197
264
|
requestApi, snapshotApiEnvironment, runWithApiSession, ApiConfigurationError,
|
|
198
|
-
ApiResponseLimitError, readResponseBody
|
|
265
|
+
ApiResponseLimitError, readResponseBody, bindApiContext, snapshotApiIdentity
|
|
199
266
|
};
|
|
@@ -5,6 +5,10 @@ const { callAccountApi, paths } = require('./account-api');
|
|
|
5
5
|
// The business gateway answers 1106 when the selected shop has no usable app. The request never
|
|
6
6
|
// reached the API, so it is safe to send again once the app is ready.
|
|
7
7
|
const noAppCode = '1106';
|
|
8
|
+
// bifrost-gateway maps the oauth 1106 to its own 4204 and keeps only the message. 4204 also covers
|
|
9
|
+
// other permission failures, so it counts as "no app" only together with this message.
|
|
10
|
+
const noAppGatewayCode = '4204';
|
|
11
|
+
const noAppMessage = '该店铺下没有可用的应用';
|
|
8
12
|
// CloudAppStateEnums: only 2 is ready; 4, 5 and 6 never become ready without a person stepping in.
|
|
9
13
|
const appStates = Object.freeze({ 1: '审核中', 2: '已创建', 3: '初始化中', 4: '初始化失败', 5: '审核驳回', 6: '已删除' });
|
|
10
14
|
const readyState = 2;
|
|
@@ -16,7 +20,10 @@ function isNoAppResponse(response) {
|
|
|
16
20
|
try { json = JSON.parse(response.body); } catch { return false; }
|
|
17
21
|
if (!json || typeof json !== 'object') return false;
|
|
18
22
|
const source = json.gw_err_resp && typeof json.gw_err_resp === 'object' ? json.gw_err_resp : json;
|
|
19
|
-
|
|
23
|
+
const code = String(source.code ?? source.err_code);
|
|
24
|
+
if (code === noAppCode) return true;
|
|
25
|
+
const message = source.err_msg ?? source.message ?? source.msg;
|
|
26
|
+
return code === noAppGatewayCode && typeof message === 'string' && message.includes(noAppMessage);
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
const stateText = app => (app ? `应用 ${app.appId} ${appStates[app.appState] || `状态 ${app.appState}`}` : '应用尚未生成');
|