create-windy 0.6.9 → 0.6.10
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/README.md +4 -1
- package/dist/cli.js +860 -305
- package/package.json +1 -1
- package/template/.windy-template.json +3 -3
- package/template/README.md +4 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +2 -0
- package/template/apps/agent-server/tests/test_multimodal.py +30 -0
- package/template/apps/agent-server/tests/test_postgres_store.py +1 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +1 -0
- package/template/apps/server/src/agent/remote-runtime.test.ts +1 -0
- package/template/apps/server/src/agent/run-audit.ts +1 -0
- package/template/apps/server/src/agent/run-contracts.ts +1 -0
- package/template/apps/server/src/agent/run-facade.ts +2 -2
- package/template/apps/server/src/agent/run-operation-registry.ts +7 -0
- package/template/apps/server/src/agent/run-output-schema.ts +15 -0
- package/template/apps/server/src/agent/run-release.ts +11 -0
- package/template/apps/server/src/agent/supervisor-output-schema.test.ts +95 -0
- package/template/apps/server/src/agent/vision-input.ts +37 -2
- package/template/apps/server/src/license/pairing-http.ts +138 -0
- package/template/apps/server/src/license/pairing-routes.test.ts +282 -0
- package/template/apps/server/src/license/pairing-routes.ts +257 -0
- package/template/apps/server/src/license/pairing-service.test.ts +320 -0
- package/template/apps/server/src/license/pairing-service.ts +321 -0
- package/template/apps/server/src/license/restriction.test.ts +11 -0
- package/template/apps/server/src/license/restriction.ts +5 -0
- package/template/apps/server/src/license/routes.ts +8 -1
- package/template/apps/server/src/license/runtime-service.ts +20 -0
- package/template/apps/web/package.json +6 -4
- package/template/apps/web/src/components/license/LicensePairingCard.vue +177 -0
- package/template/apps/web/src/components/license/LicensePairingCard.webtest.ts +113 -0
- package/template/apps/web/src/composables/useLicensePairing.ts +168 -0
- package/template/apps/web/src/composables/useLicenseRuntime.ts +11 -0
- package/template/apps/web/src/pages/license/LicensePage.vue +8 -0
- package/template/apps/web/src/services/license-api.ts +60 -0
- package/template/apps/web/src/style.webtest.ts +5 -6
- package/template/docs/architecture/ai-runtime.md +5 -0
- package/template/docs/license/license-pairing-activation.md +67 -0
- package/template/docs/operations/incremental-delivery.md +6 -6
- package/template/docs/platform/agent-supervisor-output-contract.md +80 -0
- package/template/packages/ai-client/package.json +1 -1
- package/template/packages/ai-client/src/agent/contracts/final-answer.test.ts +67 -0
- package/template/packages/ai-client/src/agent/contracts/final-answer.ts +53 -0
- package/template/packages/ai-client/src/agent/contracts/index.ts +1 -0
- package/template/packages/modules/index.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/system-admin-ui-catalog.ts +5 -1
- package/template/packages/modules/src/system-agent-supervisor.test.ts +48 -0
- package/template/packages/modules/src/system-agent-supervisor.ts +101 -0
- package/template/packages/modules/src/system-api-control-plane.ts +37 -0
- package/template/packages/modules/src/system-license-pairing.test.ts +44 -0
- package/template/packages/modules/src/system-module-catalog.ts +8 -18
- package/template/packages/modules/src/system-module-menu-selection.ts +15 -0
- package/template/packages/modules/src/system-module-schema-catalog.ts +14 -0
- package/template/packages/modules/src/system-modules.ts +2 -0
- package/template/packages/server-sdk/index.ts +5 -0
- package/template/packages/server-sdk/package.json +3 -1
- package/template/packages/server-sdk/src/ai-operation-registration.ts +4 -0
- package/template/packages/server-sdk/src/system-agent-supervisor.test.ts +87 -0
- package/template/packages/server-sdk/src/system-agent-supervisor.ts +89 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign } from "node:crypto";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import {
|
|
4
|
+
createLicensePairingCode,
|
|
5
|
+
createLicenseSigningPayload,
|
|
6
|
+
} from "@southwind-ai/license";
|
|
7
|
+
import {
|
|
8
|
+
licenseVersionKey,
|
|
9
|
+
type LicenseV1Claims,
|
|
10
|
+
type SignedLicenseV1,
|
|
11
|
+
} from "@southwind-ai/shared";
|
|
12
|
+
import { LicensePairingService } from "./pairing-service.js";
|
|
13
|
+
import { PlatformLicenseRuntime } from "./runtime-service.js";
|
|
14
|
+
|
|
15
|
+
describe("License 手机配对会话", () => {
|
|
16
|
+
test("过期会话拒绝投递并清理为 expired", async () => {
|
|
17
|
+
const fixture = createFixture();
|
|
18
|
+
let now = new Date();
|
|
19
|
+
const service = new LicensePairingService(fixture.runtime, {
|
|
20
|
+
ttlMs: 1_000,
|
|
21
|
+
now: () => now,
|
|
22
|
+
});
|
|
23
|
+
const created = service.create(callback);
|
|
24
|
+
now = new Date(now.getTime() + 1_001);
|
|
25
|
+
|
|
26
|
+
expect(service.status(created.request.sessionId).status).toBe("expired");
|
|
27
|
+
await expect(
|
|
28
|
+
service.deliver(
|
|
29
|
+
created.request.sessionId,
|
|
30
|
+
created.request.token,
|
|
31
|
+
JSON.stringify(fixture.license),
|
|
32
|
+
),
|
|
33
|
+
).rejects.toMatchObject({ code: "PAIRING_EXPIRED", status: 410 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("错误 token 和篡改签名都不能进入 delivered", async () => {
|
|
37
|
+
const fixture = createFixture();
|
|
38
|
+
const service = new LicensePairingService(fixture.runtime);
|
|
39
|
+
const created = service.create(callback);
|
|
40
|
+
|
|
41
|
+
await expect(
|
|
42
|
+
service.deliver(
|
|
43
|
+
created.request.sessionId,
|
|
44
|
+
"invalid-delivery-token",
|
|
45
|
+
JSON.stringify(fixture.license),
|
|
46
|
+
),
|
|
47
|
+
).rejects.toMatchObject({ code: "PAIRING_TOKEN_INVALID", status: 403 });
|
|
48
|
+
|
|
49
|
+
const tampered = {
|
|
50
|
+
...fixture.license,
|
|
51
|
+
signature: `A${fixture.license.signature.slice(1)}`,
|
|
52
|
+
};
|
|
53
|
+
await expect(
|
|
54
|
+
service.deliver(
|
|
55
|
+
created.request.sessionId,
|
|
56
|
+
created.request.token,
|
|
57
|
+
JSON.stringify(tampered),
|
|
58
|
+
),
|
|
59
|
+
).rejects.toMatchObject({
|
|
60
|
+
code: "PAIRING_LICENSE_REJECTED",
|
|
61
|
+
details: { reasons: expect.arrayContaining(["invalid-signature"]) },
|
|
62
|
+
});
|
|
63
|
+
expect(service.status(created.request.sessionId).status).toBe("waiting");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("同 token 和同 canonical License 可幂等重放,其它 License 拒绝", async () => {
|
|
67
|
+
const other = signedLicense("license_other");
|
|
68
|
+
const fixture = createFixture([other]);
|
|
69
|
+
const service = new LicensePairingService(fixture.runtime);
|
|
70
|
+
const created = service.create(callback);
|
|
71
|
+
const delivered = await service.deliver(
|
|
72
|
+
created.request.sessionId,
|
|
73
|
+
created.request.token,
|
|
74
|
+
JSON.stringify(fixture.license),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
service.deliver(
|
|
79
|
+
created.request.sessionId,
|
|
80
|
+
`${created.request.token}x`,
|
|
81
|
+
JSON.stringify(fixture.license),
|
|
82
|
+
),
|
|
83
|
+
).rejects.toMatchObject({ code: "PAIRING_TOKEN_INVALID", status: 403 });
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
service.deliver(
|
|
87
|
+
created.request.sessionId,
|
|
88
|
+
created.request.token,
|
|
89
|
+
JSON.stringify(fixture.license, null, 2),
|
|
90
|
+
),
|
|
91
|
+
).resolves.toEqual(delivered);
|
|
92
|
+
await expect(
|
|
93
|
+
service.deliver(
|
|
94
|
+
created.request.sessionId,
|
|
95
|
+
created.request.token,
|
|
96
|
+
JSON.stringify(other.license),
|
|
97
|
+
),
|
|
98
|
+
).rejects.toMatchObject({ code: "PAIRING_LICENSE_CONFLICT", status: 409 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("连续五次错误确认后锁定并销毁待安装 License", async () => {
|
|
102
|
+
const fixture = createFixture();
|
|
103
|
+
const service = new LicensePairingService(fixture.runtime);
|
|
104
|
+
const created = service.create(callback);
|
|
105
|
+
await service.deliver(
|
|
106
|
+
created.request.sessionId,
|
|
107
|
+
created.request.token,
|
|
108
|
+
JSON.stringify(fixture.license),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
|
112
|
+
await expect(
|
|
113
|
+
service.confirm(created.request.sessionId, "00000000", actorContext),
|
|
114
|
+
).rejects.toMatchObject({
|
|
115
|
+
code: "PAIRING_CODE_INVALID",
|
|
116
|
+
details: { attemptsRemaining: 5 - attempt },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
await expect(
|
|
120
|
+
service.confirm(created.request.sessionId, "00000000", actorContext),
|
|
121
|
+
).rejects.toMatchObject({ code: "PAIRING_LOCKED", status: 423 });
|
|
122
|
+
expect(service.status(created.request.sessionId)).toMatchObject({
|
|
123
|
+
status: "locked",
|
|
124
|
+
attemptsRemaining: 0,
|
|
125
|
+
});
|
|
126
|
+
expect(fixture.store.installCalls).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("正确配对码只触发一次既有原子安装入口", async () => {
|
|
130
|
+
const fixture = createFixture();
|
|
131
|
+
const service = new LicensePairingService(fixture.runtime);
|
|
132
|
+
const created = service.create(callback);
|
|
133
|
+
const delivered = await service.deliver(
|
|
134
|
+
created.request.sessionId,
|
|
135
|
+
created.request.token,
|
|
136
|
+
JSON.stringify(fixture.license),
|
|
137
|
+
);
|
|
138
|
+
expect(delivered).toMatchObject({
|
|
139
|
+
id: created.request.sessionId,
|
|
140
|
+
status: "delivered",
|
|
141
|
+
license: {
|
|
142
|
+
licenseVersion: { key: "advanced", name: "高级付费版" },
|
|
143
|
+
expiresAt: "2030-08-01T00:00:00.000Z",
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
expect(delivered).not.toHaveProperty("token");
|
|
147
|
+
expect(delivered).not.toHaveProperty("customer");
|
|
148
|
+
expect(service.status(created.request.sessionId)).toMatchObject({
|
|
149
|
+
license: {
|
|
150
|
+
licenseVersion: { key: "advanced", name: "高级付费版" },
|
|
151
|
+
expiresAt: "2030-08-01T00:00:00.000Z",
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const code = await createLicensePairingCode(
|
|
155
|
+
created.request,
|
|
156
|
+
fixture.license,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const installed = await service.confirm(
|
|
160
|
+
created.request.sessionId,
|
|
161
|
+
`${code.slice(0, 4)}-${code.slice(4).toLowerCase()}`,
|
|
162
|
+
actorContext,
|
|
163
|
+
);
|
|
164
|
+
expect(installed.result.status).toBe("installed");
|
|
165
|
+
expect(service.status(created.request.sessionId).status).toBe("consumed");
|
|
166
|
+
expect(fixture.store.installCalls).toBe(1);
|
|
167
|
+
await expect(
|
|
168
|
+
service.confirm(created.request.sessionId, code, actorContext),
|
|
169
|
+
).rejects.toMatchObject({ code: "PAIRING_CONSUMED", status: 409 });
|
|
170
|
+
expect(fixture.store.installCalls).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("安装器拒绝时保留 delivered 会话供管理员修正后重试", async () => {
|
|
174
|
+
const fixture = createFixture();
|
|
175
|
+
let installCalls = 0;
|
|
176
|
+
fixture.runtime.install = async () => {
|
|
177
|
+
installCalls += 1;
|
|
178
|
+
if (installCalls === 1) throw new Error("storage unavailable");
|
|
179
|
+
return {
|
|
180
|
+
result: {
|
|
181
|
+
status: "rejected",
|
|
182
|
+
failures: ["license-not-newer"],
|
|
183
|
+
},
|
|
184
|
+
snapshot: {
|
|
185
|
+
status: "missing",
|
|
186
|
+
restricted: true,
|
|
187
|
+
reminder: { level: "none" },
|
|
188
|
+
canViewDetails: true,
|
|
189
|
+
},
|
|
190
|
+
} as Awaited<ReturnType<PlatformLicenseRuntime["install"]>>;
|
|
191
|
+
};
|
|
192
|
+
const service = new LicensePairingService(fixture.runtime);
|
|
193
|
+
const created = service.create(callback);
|
|
194
|
+
await service.deliver(
|
|
195
|
+
created.request.sessionId,
|
|
196
|
+
created.request.token,
|
|
197
|
+
JSON.stringify(fixture.license),
|
|
198
|
+
);
|
|
199
|
+
const code = await createLicensePairingCode(
|
|
200
|
+
created.request,
|
|
201
|
+
fixture.license,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await expect(
|
|
205
|
+
service.confirm(created.request.sessionId, code, actorContext),
|
|
206
|
+
).rejects.toThrow("storage unavailable");
|
|
207
|
+
expect(service.status(created.request.sessionId).status).toBe("delivered");
|
|
208
|
+
const result = await service.confirm(
|
|
209
|
+
created.request.sessionId,
|
|
210
|
+
code,
|
|
211
|
+
actorContext,
|
|
212
|
+
);
|
|
213
|
+
expect(result.result.status).toBe("rejected");
|
|
214
|
+
expect(service.status(created.request.sessionId).status).toBe("delivered");
|
|
215
|
+
expect(installCalls).toBe(2);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("会话容量只回收终态,活跃会话满时 fail closed", () => {
|
|
219
|
+
const fixture = createFixture();
|
|
220
|
+
const service = new LicensePairingService(fixture.runtime, {
|
|
221
|
+
maxSessions: 1,
|
|
222
|
+
});
|
|
223
|
+
service.create(callback);
|
|
224
|
+
expect(() => service.create(callback)).toThrow(
|
|
225
|
+
expect.objectContaining({ code: "PAIRING_CAPACITY_REACHED" }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("SDK 拒绝公网或不安全回调且失败创建不占用容量", () => {
|
|
230
|
+
const fixture = createFixture();
|
|
231
|
+
const service = new LicensePairingService(fixture.runtime, {
|
|
232
|
+
maxSessions: 1,
|
|
233
|
+
});
|
|
234
|
+
expect(() =>
|
|
235
|
+
service.create(
|
|
236
|
+
(sessionId) =>
|
|
237
|
+
`https://license.example.com/api/license/runtime/pairing/sessions/${sessionId}/deliver`,
|
|
238
|
+
),
|
|
239
|
+
).toThrow(expect.objectContaining({ code: "PAIRING_REQUEST_INVALID" }));
|
|
240
|
+
expect(service.create(callback).status).toBe("waiting");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
function callback(sessionId: string) {
|
|
245
|
+
return `https://windy.internal/api/license/runtime/pairing/sessions/${sessionId}/deliver`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const actorContext = {
|
|
249
|
+
actor: {
|
|
250
|
+
id: "user_1",
|
|
251
|
+
type: "user" as const,
|
|
252
|
+
name: "管理员",
|
|
253
|
+
roleCodes: ["admin"],
|
|
254
|
+
permissionKeys: ["system.license.manage"],
|
|
255
|
+
},
|
|
256
|
+
requestId: "request_pairing",
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function createFixture(additional: ReturnType<typeof signedLicense>[] = []) {
|
|
260
|
+
const signed = signedLicense();
|
|
261
|
+
const store = {
|
|
262
|
+
current: undefined as string | undefined,
|
|
263
|
+
installCalls: 0,
|
|
264
|
+
readCurrent() {
|
|
265
|
+
return this.current;
|
|
266
|
+
},
|
|
267
|
+
replaceCurrentAtomically(value: string) {
|
|
268
|
+
this.installCalls += 1;
|
|
269
|
+
this.current = value;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const runtime = new PlatformLicenseRuntime({
|
|
273
|
+
productCode: "windy-platform",
|
|
274
|
+
machineCode: "machine_1",
|
|
275
|
+
publicKeys: Object.fromEntries(
|
|
276
|
+
[signed, ...additional].map((current) => [
|
|
277
|
+
current.license.publicKeyId,
|
|
278
|
+
current.publicKeyPem,
|
|
279
|
+
]),
|
|
280
|
+
),
|
|
281
|
+
store,
|
|
282
|
+
});
|
|
283
|
+
return { runtime, store, license: signed.license };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function signedLicense(licenseId = "license_pairing") {
|
|
287
|
+
const claims: LicenseV1Claims = {
|
|
288
|
+
licenseId,
|
|
289
|
+
productCode: "windy-platform",
|
|
290
|
+
customer: { id: "customer_1", code: "C-1", name: "客户一" },
|
|
291
|
+
project: { id: "project_1", code: "P-1", name: "项目一" },
|
|
292
|
+
contract: { id: "contract_1", contractNumber: "HT-1", version: "1" },
|
|
293
|
+
machineCode: "machine_1",
|
|
294
|
+
issuedAt: "2026-08-01T00:00:00.000Z",
|
|
295
|
+
expiresAt: "2030-08-01T00:00:00.000Z",
|
|
296
|
+
licenseVersion: {
|
|
297
|
+
key: licenseVersionKey("advanced"),
|
|
298
|
+
name: "高级付费版",
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
|
302
|
+
const unsigned = {
|
|
303
|
+
formatVersion: 1,
|
|
304
|
+
algorithm: "ED25519",
|
|
305
|
+
publicKeyId: `pairing-key-${licenseId}`,
|
|
306
|
+
claims,
|
|
307
|
+
} as const;
|
|
308
|
+
const license: SignedLicenseV1 = {
|
|
309
|
+
...unsigned,
|
|
310
|
+
signature: sign(
|
|
311
|
+
null,
|
|
312
|
+
Buffer.from(createLicenseSigningPayload(unsigned)),
|
|
313
|
+
privateKey,
|
|
314
|
+
).toString("base64"),
|
|
315
|
+
};
|
|
316
|
+
return {
|
|
317
|
+
license,
|
|
318
|
+
publicKeyPem: publicKey.export({ type: "spki", format: "pem" }).toString(),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeJson,
|
|
4
|
+
createLicensePairingCode,
|
|
5
|
+
createLicensePairingRequest,
|
|
6
|
+
normalizeLicensePairingCode,
|
|
7
|
+
type LicensePairingRequestV1,
|
|
8
|
+
} from "@southwind-ai/license";
|
|
9
|
+
import type { LicenseInstallActorContext } from "./runtime-service.js";
|
|
10
|
+
import type { PlatformLicenseRuntime } from "./runtime-service.js";
|
|
11
|
+
|
|
12
|
+
export type LicensePairingStatus =
|
|
13
|
+
| "waiting"
|
|
14
|
+
| "delivered"
|
|
15
|
+
| "expired"
|
|
16
|
+
| "locked"
|
|
17
|
+
| "consumed";
|
|
18
|
+
|
|
19
|
+
export interface LicensePairingServiceOptions {
|
|
20
|
+
ttlMs?: number;
|
|
21
|
+
maxSessions?: number;
|
|
22
|
+
maxConfirmationAttempts?: number;
|
|
23
|
+
now?: () => Date;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class LicensePairingError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
readonly code: string,
|
|
29
|
+
readonly status: number,
|
|
30
|
+
message: string,
|
|
31
|
+
readonly details?: Record<string, unknown>,
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "LicensePairingError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PairingSession {
|
|
39
|
+
request: LicensePairingRequestV1;
|
|
40
|
+
status: LicensePairingStatus;
|
|
41
|
+
createdAtMs: number;
|
|
42
|
+
failedAttempts: number;
|
|
43
|
+
rawLicense?: string;
|
|
44
|
+
pairingCode?: string;
|
|
45
|
+
licenseSummary?: {
|
|
46
|
+
licenseVersion: { key: string; name: string };
|
|
47
|
+
expiresAt: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class LicensePairingService {
|
|
52
|
+
private readonly sessions = new Map<string, PairingSession>();
|
|
53
|
+
private readonly ttlMs: number;
|
|
54
|
+
private readonly maxSessions: number;
|
|
55
|
+
private readonly maxAttempts: number;
|
|
56
|
+
private readonly now: () => Date;
|
|
57
|
+
private mutationQueue: Promise<void> = Promise.resolve();
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly runtime: PlatformLicenseRuntime,
|
|
61
|
+
options: LicensePairingServiceOptions = {},
|
|
62
|
+
) {
|
|
63
|
+
this.ttlMs = positiveInteger(options.ttlMs, 5 * 60 * 1000, "ttlMs");
|
|
64
|
+
this.maxSessions = positiveInteger(options.maxSessions, 256, "maxSessions");
|
|
65
|
+
this.maxAttempts = positiveInteger(
|
|
66
|
+
options.maxConfirmationAttempts,
|
|
67
|
+
5,
|
|
68
|
+
"maxConfirmationAttempts",
|
|
69
|
+
);
|
|
70
|
+
this.now = options.now || (() => new Date());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
create(callbackUrl: string | ((sessionId: string) => string)) {
|
|
74
|
+
this.reclaimCapacity();
|
|
75
|
+
const now = this.now().getTime();
|
|
76
|
+
const target = this.runtime.pairingTarget();
|
|
77
|
+
const sessionId = randomUUID();
|
|
78
|
+
const request: LicensePairingRequestV1 = {
|
|
79
|
+
type: "windy-license-pairing",
|
|
80
|
+
version: 1,
|
|
81
|
+
sessionId,
|
|
82
|
+
token: randomBytes(32).toString("base64url"),
|
|
83
|
+
nonce: randomBytes(24).toString("base64url"),
|
|
84
|
+
productCode: target.productCode,
|
|
85
|
+
machineCode: target.machineCode,
|
|
86
|
+
callbackUrl:
|
|
87
|
+
typeof callbackUrl === "function"
|
|
88
|
+
? callbackUrl(sessionId)
|
|
89
|
+
: callbackUrl,
|
|
90
|
+
expiresAt: new Date(now + this.ttlMs).toISOString(),
|
|
91
|
+
};
|
|
92
|
+
let qrPayload: string;
|
|
93
|
+
try {
|
|
94
|
+
qrPayload = createLicensePairingRequest(request);
|
|
95
|
+
} catch {
|
|
96
|
+
throw pairingError(
|
|
97
|
+
"PAIRING_REQUEST_INVALID",
|
|
98
|
+
400,
|
|
99
|
+
"配对请求不符合内网回调安全约束",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
this.sessions.set(request.sessionId, {
|
|
103
|
+
request,
|
|
104
|
+
status: "waiting",
|
|
105
|
+
createdAtMs: now,
|
|
106
|
+
failedAttempts: 0,
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
request,
|
|
110
|
+
qrPayload,
|
|
111
|
+
status: "waiting" as const,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
status(sessionId: string) {
|
|
116
|
+
const session = this.requireSession(sessionId);
|
|
117
|
+
this.expire(session);
|
|
118
|
+
return this.present(session);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async deliver(
|
|
122
|
+
sessionId: string,
|
|
123
|
+
token: string,
|
|
124
|
+
rawLicense: string,
|
|
125
|
+
): Promise<ReturnType<LicensePairingService["status"]>> {
|
|
126
|
+
return this.exclusive(async () => {
|
|
127
|
+
const session = this.requireSession(sessionId);
|
|
128
|
+
this.expire(session);
|
|
129
|
+
if (!safeEqual(token, session.request.token)) {
|
|
130
|
+
throw pairingError("PAIRING_TOKEN_INVALID", 403, "配对投递凭证无效");
|
|
131
|
+
}
|
|
132
|
+
if (session.status !== "waiting" && session.status !== "delivered") {
|
|
133
|
+
this.requireState(session, "waiting");
|
|
134
|
+
}
|
|
135
|
+
const validation = await this.runtime.validateCandidate(rawLicense);
|
|
136
|
+
if (!validation.valid || !validation.license) {
|
|
137
|
+
throw new LicensePairingError(
|
|
138
|
+
"PAIRING_LICENSE_REJECTED",
|
|
139
|
+
400,
|
|
140
|
+
"License 验签或目标校验失败",
|
|
141
|
+
{ reasons: validation.failures },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const canonicalLicense = canonicalizeJson(validation.license);
|
|
145
|
+
if (session.status === "delivered") {
|
|
146
|
+
if (
|
|
147
|
+
session.rawLicense &&
|
|
148
|
+
safeEqual(canonicalLicense, session.rawLicense)
|
|
149
|
+
) {
|
|
150
|
+
return this.present(session);
|
|
151
|
+
}
|
|
152
|
+
throw pairingError(
|
|
153
|
+
"PAIRING_LICENSE_CONFLICT",
|
|
154
|
+
409,
|
|
155
|
+
"配对会话已经投递了其它 License",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const pairingCode = await createLicensePairingCode(
|
|
159
|
+
session.request,
|
|
160
|
+
canonicalLicense,
|
|
161
|
+
);
|
|
162
|
+
this.expire(session);
|
|
163
|
+
this.requireState(session, "waiting");
|
|
164
|
+
session.pairingCode = pairingCode;
|
|
165
|
+
session.rawLicense = canonicalLicense;
|
|
166
|
+
session.licenseSummary = {
|
|
167
|
+
licenseVersion: validation.license.claims.licenseVersion,
|
|
168
|
+
expiresAt: validation.license.claims.expiresAt,
|
|
169
|
+
};
|
|
170
|
+
session.status = "delivered";
|
|
171
|
+
return this.present(session);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async confirm(
|
|
176
|
+
sessionId: string,
|
|
177
|
+
code: string,
|
|
178
|
+
context: LicenseInstallActorContext,
|
|
179
|
+
) {
|
|
180
|
+
return this.exclusive(async () => {
|
|
181
|
+
const session = this.requireSession(sessionId);
|
|
182
|
+
this.expire(session);
|
|
183
|
+
this.requireState(session, "delivered");
|
|
184
|
+
let normalized = "";
|
|
185
|
+
try {
|
|
186
|
+
normalized = normalizeLicensePairingCode(code);
|
|
187
|
+
} catch {
|
|
188
|
+
// Invalid syntax is intentionally counted as an incorrect attempt.
|
|
189
|
+
}
|
|
190
|
+
if (!session.pairingCode || !safeEqual(normalized, session.pairingCode)) {
|
|
191
|
+
session.failedAttempts += 1;
|
|
192
|
+
if (session.failedAttempts >= this.maxAttempts) {
|
|
193
|
+
this.finish(session, "locked");
|
|
194
|
+
throw pairingError(
|
|
195
|
+
"PAIRING_LOCKED",
|
|
196
|
+
423,
|
|
197
|
+
"配对确认次数过多,会话已锁定",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
throw new LicensePairingError(
|
|
201
|
+
"PAIRING_CODE_INVALID",
|
|
202
|
+
400,
|
|
203
|
+
"配对码错误",
|
|
204
|
+
{ attemptsRemaining: this.maxAttempts - session.failedAttempts },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const candidate = session.rawLicense;
|
|
208
|
+
if (!candidate) {
|
|
209
|
+
throw pairingError(
|
|
210
|
+
"PAIRING_STATE_INVALID",
|
|
211
|
+
409,
|
|
212
|
+
"配对会话缺少待安装 License",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const installed = await this.runtime.install(candidate, context);
|
|
216
|
+
if (installed.result.status !== "rejected") {
|
|
217
|
+
this.finish(session, "consumed");
|
|
218
|
+
}
|
|
219
|
+
return installed;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private present(session: PairingSession) {
|
|
224
|
+
return {
|
|
225
|
+
id: session.request.sessionId,
|
|
226
|
+
status: session.status,
|
|
227
|
+
expiresAt: session.request.expiresAt,
|
|
228
|
+
attemptsRemaining: Math.max(0, this.maxAttempts - session.failedAttempts),
|
|
229
|
+
...(session.licenseSummary ? { license: session.licenseSummary } : {}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private requireSession(sessionId: string): PairingSession {
|
|
234
|
+
const session = this.sessions.get(sessionId);
|
|
235
|
+
if (!session)
|
|
236
|
+
throw pairingError("PAIRING_NOT_FOUND", 404, "配对会话不存在");
|
|
237
|
+
return session;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private requireState(
|
|
241
|
+
session: PairingSession,
|
|
242
|
+
expected: LicensePairingStatus,
|
|
243
|
+
) {
|
|
244
|
+
if (session.status === expected) return;
|
|
245
|
+
const status = session.status === "expired" ? 410 : 409;
|
|
246
|
+
throw new LicensePairingError(
|
|
247
|
+
`PAIRING_${session.status.toUpperCase()}`,
|
|
248
|
+
status,
|
|
249
|
+
`配对会话当前状态为 ${session.status}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private expire(session: PairingSession): void {
|
|
254
|
+
if (
|
|
255
|
+
(session.status === "waiting" || session.status === "delivered") &&
|
|
256
|
+
this.now().getTime() >= Date.parse(session.request.expiresAt)
|
|
257
|
+
) {
|
|
258
|
+
this.finish(session, "expired");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private finish(session: PairingSession, status: LicensePairingStatus): void {
|
|
263
|
+
session.status = status;
|
|
264
|
+
session.rawLicense = undefined;
|
|
265
|
+
session.pairingCode = undefined;
|
|
266
|
+
if (status !== "consumed") session.licenseSummary = undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private reclaimCapacity(): void {
|
|
270
|
+
for (const session of this.sessions.values()) this.expire(session);
|
|
271
|
+
if (this.sessions.size < this.maxSessions) return;
|
|
272
|
+
const reclaimable = [...this.sessions.values()]
|
|
273
|
+
.filter(({ status }) =>
|
|
274
|
+
["expired", "locked", "consumed"].includes(status),
|
|
275
|
+
)
|
|
276
|
+
.sort((left, right) => left.createdAtMs - right.createdAtMs);
|
|
277
|
+
for (const session of reclaimable) {
|
|
278
|
+
this.sessions.delete(session.request.sessionId);
|
|
279
|
+
if (this.sessions.size < this.maxSessions) return;
|
|
280
|
+
}
|
|
281
|
+
throw pairingError(
|
|
282
|
+
"PAIRING_CAPACITY_REACHED",
|
|
283
|
+
503,
|
|
284
|
+
"配对会话已满,请稍后再试",
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private async exclusive<T>(operation: () => Promise<T>): Promise<T> {
|
|
289
|
+
const result = this.mutationQueue.then(operation);
|
|
290
|
+
this.mutationQueue = result.then(
|
|
291
|
+
() => undefined,
|
|
292
|
+
() => undefined,
|
|
293
|
+
);
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function safeEqual(left: string, right: string): boolean {
|
|
299
|
+
const leftDigest = Buffer.from(left);
|
|
300
|
+
const rightDigest = Buffer.from(right);
|
|
301
|
+
return (
|
|
302
|
+
leftDigest.length === rightDigest.length &&
|
|
303
|
+
timingSafeEqual(leftDigest, rightDigest)
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function positiveInteger(
|
|
308
|
+
value: number | undefined,
|
|
309
|
+
fallback: number,
|
|
310
|
+
name: string,
|
|
311
|
+
): number {
|
|
312
|
+
const resolved = value ?? fallback;
|
|
313
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
314
|
+
throw new RangeError(`${name} 必须为正整数`);
|
|
315
|
+
}
|
|
316
|
+
return resolved;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function pairingError(code: string, status: number, message: string) {
|
|
320
|
+
return new LicensePairingError(code, status, message);
|
|
321
|
+
}
|
|
@@ -28,6 +28,17 @@ describe("License 限制模式 HTTP 白名单", () => {
|
|
|
28
28
|
["POST", "/api/auth/logout", true],
|
|
29
29
|
["GET", "/api/license/runtime", true],
|
|
30
30
|
["POST", "/api/license/runtime/install", true],
|
|
31
|
+
["POST", "/api/license/runtime/pairing/sessions", true],
|
|
32
|
+
["GET", "/api/license/runtime/pairing/sessions/session_1", true],
|
|
33
|
+
["POST", "/api/license/runtime/pairing/sessions/session_1/deliver", true],
|
|
34
|
+
[
|
|
35
|
+
"OPTIONS",
|
|
36
|
+
"/api/license/runtime/pairing/sessions/session_1/deliver",
|
|
37
|
+
true,
|
|
38
|
+
],
|
|
39
|
+
["POST", "/api/license/runtime/pairing/sessions/session_1/confirm", true],
|
|
40
|
+
["GET", "/api/license/runtime/pairing/sessions", false],
|
|
41
|
+
["PUT", "/api/license/runtime/pairing/sessions/session_1/deliver", false],
|
|
31
42
|
["POST", "/api/ready", false],
|
|
32
43
|
["GET", "/api/metrics/extra", false],
|
|
33
44
|
["GET", "/api/auth/session/extra", false],
|
|
@@ -18,6 +18,7 @@ const RESTRICTED_HTTP_ALLOWLIST = new Set([
|
|
|
18
18
|
"GET /api/license/runtime",
|
|
19
19
|
"GET /api/admin/bootstrap",
|
|
20
20
|
"POST /api/license/runtime/install",
|
|
21
|
+
"POST /api/license/runtime/pairing/sessions",
|
|
21
22
|
"GET /api/system/roles/export",
|
|
22
23
|
"GET /api/system/departments/export",
|
|
23
24
|
"GET /api/system/menus/export",
|
|
@@ -27,6 +28,10 @@ const RESTRICTED_HTTP_ALLOWLIST = new Set([
|
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
const RESTRICTED_HTTP_TEMPLATE_ALLOWLIST = new Set([
|
|
31
|
+
"GET /api/license/runtime/pairing/sessions/:sessionId",
|
|
32
|
+
"POST /api/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
33
|
+
"OPTIONS /api/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
34
|
+
"POST /api/license/runtime/pairing/sessions/:sessionId/confirm",
|
|
30
35
|
"POST /api/system/users/exports",
|
|
31
36
|
"GET /api/system/users/exports/:id",
|
|
32
37
|
"POST /api/system/users/exports/:id/cancel",
|