create-windy 0.6.9 → 0.6.11
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 +865 -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/src/southwind_agent_server/report_presenter.py +19 -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_report_routes.py +86 -0
- package/template/apps/agent-server/tests/test_report_store.py +62 -1
- 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/ai-runtime-report/remote-parser.ts +10 -0
- package/template/apps/server/src/ai-runtime-report/service.test.ts +1 -0
- 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/pages/system/AiRuntimePage.vue +3 -1
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.vue +43 -31
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeFilters.webtest.ts +15 -4
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeModelUsageList.vue +48 -0
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeModelUsageList.webtest.ts +32 -0
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.vue +2 -1
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunDetailDialog.webtest.ts +70 -0
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.vue +29 -2
- package/template/apps/web/src/pages/system/components/ai-runtime/AiRuntimeRunTable.webtest.ts +56 -0
- package/template/apps/web/src/services/ai-runtime-api.ts +7 -10
- package/template/apps/web/src/services/ai-runtime-api.webtest.ts +115 -2
- 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-runtime-observability.md +6 -0
- package/template/docs/platform/agent-supervisor-output-contract.md +97 -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
- package/template/packages/shared/package.json +1 -1
- package/template/packages/shared/src/ai-runtime-report.ts +19 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { boundedJsonBodyErrorResponse } from "@southwind-ai/license";
|
|
2
|
+
import { LicensePairingError } from "./pairing-service.js";
|
|
3
|
+
|
|
4
|
+
export function createPairingCorsPolicy(
|
|
5
|
+
env: Record<string, string | undefined>,
|
|
6
|
+
) {
|
|
7
|
+
const configured = env.WINDY_LICENSE_PAIRING_ORIGINS;
|
|
8
|
+
const candidates =
|
|
9
|
+
configured === undefined
|
|
10
|
+
? env.NODE_ENV === "production"
|
|
11
|
+
? []
|
|
12
|
+
: ["http://localhost:9843"]
|
|
13
|
+
: configured.split(",").map((value) => value.trim());
|
|
14
|
+
const origins = new Set(candidates.filter(isExactHttpOrigin));
|
|
15
|
+
return {
|
|
16
|
+
enabled: origins.size > 0,
|
|
17
|
+
accept: (request: Request) => {
|
|
18
|
+
const origin = request.headers.get("origin") || "";
|
|
19
|
+
return origins.has(origin) ? origin : undefined;
|
|
20
|
+
},
|
|
21
|
+
headers: (origin: string) => ({
|
|
22
|
+
"access-control-allow-origin": origin,
|
|
23
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
24
|
+
"access-control-allow-headers": "Content-Type",
|
|
25
|
+
"access-control-allow-private-network": "true",
|
|
26
|
+
vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers, Access-Control-Request-Private-Network",
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function allowedPreflightHeaders(value: string | null): boolean {
|
|
32
|
+
if (!value) return true;
|
|
33
|
+
return value
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((header) => header.trim().toLowerCase())
|
|
36
|
+
.every((header) => header === "content-type");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function requireCallbackOrigin(
|
|
40
|
+
value: unknown,
|
|
41
|
+
request: Request,
|
|
42
|
+
): string {
|
|
43
|
+
const callbackOrigin = requiredText(value, "callbackOrigin");
|
|
44
|
+
if (!isExactHttpOrigin(callbackOrigin)) {
|
|
45
|
+
throw new LicensePairingError(
|
|
46
|
+
"PAIRING_CALLBACK_ORIGIN_INVALID",
|
|
47
|
+
400,
|
|
48
|
+
"callbackOrigin 必须是精确的 HTTP(S) Origin",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (request.headers.get("origin") !== callbackOrigin) {
|
|
52
|
+
throw new LicensePairingError(
|
|
53
|
+
"PAIRING_CALLBACK_ORIGIN_MISMATCH",
|
|
54
|
+
403,
|
|
55
|
+
"请求 Origin 与 callbackOrigin 不一致",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return callbackOrigin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function requireDeliveryContentType(request: Request): void {
|
|
62
|
+
const value = request.headers.get("content-type")?.toLowerCase() || "";
|
|
63
|
+
if (value.startsWith("application/json") || value.startsWith("text/plain")) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
throw new LicensePairingError(
|
|
67
|
+
"PAIRING_CONTENT_TYPE_INVALID",
|
|
68
|
+
415,
|
|
69
|
+
"投递只接受 application/json 或 text/plain JSON",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function requiredText(value: unknown, name: string): string {
|
|
74
|
+
if (typeof value === "string" && value.trim()) return value;
|
|
75
|
+
throw new LicensePairingError(
|
|
76
|
+
"PAIRING_REQUEST_INVALID",
|
|
77
|
+
400,
|
|
78
|
+
`${name} 必须为非空字符串`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function pairingErrorResponse(
|
|
83
|
+
error: unknown,
|
|
84
|
+
headers: Record<string, string> = {},
|
|
85
|
+
): Response {
|
|
86
|
+
const bodyError = boundedJsonBodyErrorResponse(error);
|
|
87
|
+
if (bodyError) return noStore(bodyError, headers);
|
|
88
|
+
if (error instanceof LicensePairingError) {
|
|
89
|
+
return json(
|
|
90
|
+
{ error: error.code, reason: error.message, ...error.details },
|
|
91
|
+
error.status,
|
|
92
|
+
headers,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function corsRejected(): Response {
|
|
99
|
+
return json({ error: "PAIRING_CORS_REJECTED" }, 403, { vary: "Origin" });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function json(
|
|
103
|
+
body: unknown,
|
|
104
|
+
status = 200,
|
|
105
|
+
headers: Record<string, string> = {},
|
|
106
|
+
): Response {
|
|
107
|
+
return Response.json(body, { status, headers: responseHeaders(headers) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function noStore(
|
|
111
|
+
response: Response,
|
|
112
|
+
headers: Record<string, string> = {},
|
|
113
|
+
) {
|
|
114
|
+
const nextHeaders = new Headers(response.headers);
|
|
115
|
+
for (const [key, value] of Object.entries(responseHeaders(headers))) {
|
|
116
|
+
nextHeaders.set(key, value);
|
|
117
|
+
}
|
|
118
|
+
return new Response(response.body, {
|
|
119
|
+
status: response.status,
|
|
120
|
+
headers: nextHeaders,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function responseHeaders(headers: Record<string, string>) {
|
|
125
|
+
return { ...headers, "cache-control": "no-store" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isExactHttpOrigin(value: string): boolean {
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(value);
|
|
131
|
+
return (
|
|
132
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
133
|
+
url.origin === value
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Elysia } from "elysia";
|
|
3
|
+
import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
|
|
4
|
+
import { registerLicensePairingRoutes } from "./pairing-routes.js";
|
|
5
|
+
import type { LicensePairingService } from "./pairing-service.js";
|
|
6
|
+
import type { PlatformLicenseRuntime } from "./runtime-service.js";
|
|
7
|
+
|
|
8
|
+
describe("License 配对 HTTP 边界", () => {
|
|
9
|
+
test("创建会话要求认证且必须通过 Manifest authorizer", async () => {
|
|
10
|
+
const paths: string[] = [];
|
|
11
|
+
const service = mockService();
|
|
12
|
+
const app = createApp(service, {
|
|
13
|
+
authorize: async (_context, _method, path) => {
|
|
14
|
+
paths.push(path);
|
|
15
|
+
return Response.json({ error: "FORBIDDEN" }, { status: 403 });
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const anonymous = await app.handle(
|
|
20
|
+
request("/api/license/runtime/pairing/sessions", "POST"),
|
|
21
|
+
);
|
|
22
|
+
expect(anonymous.status).toBe(401);
|
|
23
|
+
expect(anonymous.headers.get("cache-control")).toBe("no-store");
|
|
24
|
+
expect(paths).toEqual([]);
|
|
25
|
+
|
|
26
|
+
const denied = await app.handle(
|
|
27
|
+
request("/api/license/runtime/pairing/sessions", "POST", true),
|
|
28
|
+
);
|
|
29
|
+
expect(denied.status).toBe(403);
|
|
30
|
+
expect(denied.headers.get("cache-control")).toBe("no-store");
|
|
31
|
+
expect(paths).toEqual(["/api/license/runtime/pairing/sessions"]);
|
|
32
|
+
expect(service.createCalls).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("创建会话只接受与请求 Origin 一致的 callbackOrigin", async () => {
|
|
36
|
+
const service = mockService();
|
|
37
|
+
const app = createApp(service, allowAuthorizer);
|
|
38
|
+
const mismatch = await app.handle(
|
|
39
|
+
request(
|
|
40
|
+
"/api/license/runtime/pairing/sessions",
|
|
41
|
+
"POST",
|
|
42
|
+
true,
|
|
43
|
+
{ callbackOrigin: "https://windy.internal" },
|
|
44
|
+
{
|
|
45
|
+
origin: "https://other.internal",
|
|
46
|
+
"content-type": "application/json",
|
|
47
|
+
},
|
|
48
|
+
),
|
|
49
|
+
);
|
|
50
|
+
expect(mismatch.status).toBe(403);
|
|
51
|
+
expect(service.createCalls).toBe(0);
|
|
52
|
+
|
|
53
|
+
const accepted = await app.handle(
|
|
54
|
+
request(
|
|
55
|
+
"/api/license/runtime/pairing/sessions",
|
|
56
|
+
"POST",
|
|
57
|
+
true,
|
|
58
|
+
{ callbackOrigin: "https://windy.internal" },
|
|
59
|
+
{
|
|
60
|
+
origin: "https://windy.internal",
|
|
61
|
+
"content-type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
expect(accepted.status).toBe(201);
|
|
66
|
+
expect(service.callbackUrl).toBe(
|
|
67
|
+
"https://windy.internal/api/license/runtime/pairing/sessions/session_1/deliver",
|
|
68
|
+
);
|
|
69
|
+
await expect(accepted.json()).resolves.toMatchObject({
|
|
70
|
+
id: "session_1",
|
|
71
|
+
token: "delivery-token",
|
|
72
|
+
nonce: "pairing-nonce",
|
|
73
|
+
status: "waiting",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const confirmed = await app.handle(
|
|
77
|
+
request(
|
|
78
|
+
"/api/license/runtime/pairing/sessions/session_1/confirm",
|
|
79
|
+
"POST",
|
|
80
|
+
true,
|
|
81
|
+
{ pairingCode: "GTM8FFTX" },
|
|
82
|
+
{ "content-type": "application/json" },
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
expect(confirmed.status).toBe(200);
|
|
86
|
+
expect(service.confirmCalls).toEqual([["session_1", "GTM8FFTX"]]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("允许来源可用 text/plain 自动投递,响应固定 no-store 和精确 CORS", async () => {
|
|
90
|
+
const service = mockService();
|
|
91
|
+
const bindings: string[] = [];
|
|
92
|
+
const app = createApp(service, {
|
|
93
|
+
authorize: async (_context, method, path) => {
|
|
94
|
+
bindings.push(`${method} ${path}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
const response = await app.handle(
|
|
99
|
+
request(
|
|
100
|
+
"/api/license/runtime/pairing/sessions/session_1/deliver",
|
|
101
|
+
"POST",
|
|
102
|
+
false,
|
|
103
|
+
{ token: "delivery-token", license: "canonical-license" },
|
|
104
|
+
{
|
|
105
|
+
origin: "https://phone.example",
|
|
106
|
+
"content-type": "text/plain;charset=UTF-8",
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(response.status).toBe(200);
|
|
112
|
+
expect(response.headers.get("access-control-allow-origin")).toBe(
|
|
113
|
+
"https://phone.example",
|
|
114
|
+
);
|
|
115
|
+
expect(response.headers.get("access-control-allow-private-network")).toBe(
|
|
116
|
+
"true",
|
|
117
|
+
);
|
|
118
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
119
|
+
expect(response.headers.get("vary")).toContain("Origin");
|
|
120
|
+
expect(service.deliverCalls).toEqual([
|
|
121
|
+
["session_1", "delivery-token", "canonical-license"],
|
|
122
|
+
]);
|
|
123
|
+
expect(bindings).toEqual([
|
|
124
|
+
"POST /api/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("未知 Origin 在读取 token 前拒绝,严格预检只允许 POST Content-Type", async () => {
|
|
129
|
+
const service = mockService();
|
|
130
|
+
const app = createApp(service, allowAuthorizer);
|
|
131
|
+
const rejected = await app.handle(
|
|
132
|
+
request(
|
|
133
|
+
"/api/license/runtime/pairing/sessions/session_1/deliver",
|
|
134
|
+
"POST",
|
|
135
|
+
false,
|
|
136
|
+
{ token: "secret-token", license: "secret-license" },
|
|
137
|
+
{ origin: "https://attacker.example", "content-type": "text/plain" },
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
expect(rejected.status).toBe(403);
|
|
141
|
+
expect(rejected.headers.get("access-control-allow-origin")).toBeNull();
|
|
142
|
+
expect(service.deliverCalls).toEqual([]);
|
|
143
|
+
|
|
144
|
+
const preflight = await app.handle(
|
|
145
|
+
request(
|
|
146
|
+
"/api/license/runtime/pairing/sessions/session_1/deliver",
|
|
147
|
+
"OPTIONS",
|
|
148
|
+
false,
|
|
149
|
+
undefined,
|
|
150
|
+
{
|
|
151
|
+
origin: "https://phone.example",
|
|
152
|
+
"access-control-request-method": "POST",
|
|
153
|
+
"access-control-request-headers": "content-type",
|
|
154
|
+
"access-control-request-private-network": "true",
|
|
155
|
+
},
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
expect(preflight.status).toBe(204);
|
|
159
|
+
expect(preflight.headers.get("access-control-allow-methods")).toBe(
|
|
160
|
+
"POST, OPTIONS",
|
|
161
|
+
);
|
|
162
|
+
expect(preflight.headers.get("access-control-allow-headers")).toBe(
|
|
163
|
+
"Content-Type",
|
|
164
|
+
);
|
|
165
|
+
expect(preflight.headers.get("access-control-allow-private-network")).toBe(
|
|
166
|
+
"true",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("生产未配置来源时关闭创建入口", async () => {
|
|
171
|
+
const service = mockService();
|
|
172
|
+
const app = createApp(service, allowAuthorizer, { NODE_ENV: "production" });
|
|
173
|
+
const response = await app.handle(
|
|
174
|
+
request("/api/license/runtime/pairing/sessions", "POST", true),
|
|
175
|
+
);
|
|
176
|
+
expect(response.status).toBe(503);
|
|
177
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
178
|
+
error: "PAIRING_DISABLED",
|
|
179
|
+
});
|
|
180
|
+
expect(service.createCalls).toBe(0);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function createApp(
|
|
185
|
+
service: ReturnType<typeof mockService>,
|
|
186
|
+
authorizer: StaticRouteAuthorizer,
|
|
187
|
+
env: Record<string, string | undefined> = {
|
|
188
|
+
NODE_ENV: "production",
|
|
189
|
+
WINDY_LICENSE_PAIRING_ORIGINS: "https://phone.example",
|
|
190
|
+
},
|
|
191
|
+
) {
|
|
192
|
+
const app = new Elysia({ prefix: "/api" }).derive(({ request: current }) => ({
|
|
193
|
+
guardContext: {
|
|
194
|
+
actor: current.headers.has("authorization")
|
|
195
|
+
? {
|
|
196
|
+
id: "user_1",
|
|
197
|
+
type: "user" as const,
|
|
198
|
+
name: "管理员",
|
|
199
|
+
roleCodes: ["admin"],
|
|
200
|
+
permissionKeys: ["system.license.manage"],
|
|
201
|
+
}
|
|
202
|
+
: { type: "anonymous" as const, roleCodes: [], permissionKeys: [] },
|
|
203
|
+
requestId: "request_pairing",
|
|
204
|
+
},
|
|
205
|
+
}));
|
|
206
|
+
registerLicensePairingRoutes(
|
|
207
|
+
app,
|
|
208
|
+
{
|
|
209
|
+
presentSnapshot: (snapshot: unknown) => snapshot,
|
|
210
|
+
} as unknown as PlatformLicenseRuntime,
|
|
211
|
+
authorizer,
|
|
212
|
+
{
|
|
213
|
+
env,
|
|
214
|
+
service: service as unknown as LicensePairingService,
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
return app;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mockService() {
|
|
221
|
+
return {
|
|
222
|
+
createCalls: 0,
|
|
223
|
+
callbackUrl: "",
|
|
224
|
+
deliverCalls: [] as string[][],
|
|
225
|
+
confirmCalls: [] as string[][],
|
|
226
|
+
create(callback: string | ((id: string) => string)) {
|
|
227
|
+
this.createCalls += 1;
|
|
228
|
+
this.callbackUrl =
|
|
229
|
+
typeof callback === "function" ? callback("session_1") : callback;
|
|
230
|
+
return {
|
|
231
|
+
request: {
|
|
232
|
+
sessionId: "session_1",
|
|
233
|
+
token: "delivery-token",
|
|
234
|
+
nonce: "pairing-nonce",
|
|
235
|
+
productCode: "windy-platform",
|
|
236
|
+
machineCode: "machine_1",
|
|
237
|
+
expiresAt: "2030-08-01T00:00:00.000Z",
|
|
238
|
+
callbackUrl: this.callbackUrl,
|
|
239
|
+
},
|
|
240
|
+
qrPayload: "qr",
|
|
241
|
+
status: "waiting",
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
status: () => ({ status: "waiting" }),
|
|
245
|
+
async deliver(id: string, token: string, license: string) {
|
|
246
|
+
this.deliverCalls.push([id, token, license]);
|
|
247
|
+
return { id, status: "delivered" };
|
|
248
|
+
},
|
|
249
|
+
async confirm(id: string, pairingCode: string) {
|
|
250
|
+
this.confirmCalls.push([id, pairingCode]);
|
|
251
|
+
return {
|
|
252
|
+
result: { status: "installed", failures: [] },
|
|
253
|
+
snapshot: {
|
|
254
|
+
status: "valid",
|
|
255
|
+
restricted: false,
|
|
256
|
+
reminder: { level: "none" },
|
|
257
|
+
canViewDetails: true,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const allowAuthorizer: StaticRouteAuthorizer = {
|
|
265
|
+
authorize: async () => undefined,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function request(
|
|
269
|
+
path: string,
|
|
270
|
+
method: string,
|
|
271
|
+
authenticated = false,
|
|
272
|
+
body?: unknown,
|
|
273
|
+
headers: Record<string, string> = {},
|
|
274
|
+
) {
|
|
275
|
+
const nextHeaders = new Headers(headers);
|
|
276
|
+
if (authenticated) nextHeaders.set("authorization", "Bearer user-token");
|
|
277
|
+
return new Request(`http://localhost${path}`, {
|
|
278
|
+
method,
|
|
279
|
+
headers: nextHeaders,
|
|
280
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { readBoundedJsonObject } from "@southwind-ai/license";
|
|
2
|
+
import type { RequestGuardContext } from "../guards.js";
|
|
3
|
+
import type { StaticRouteAuthorizer } from "../module-host/static-route-authorization.js";
|
|
4
|
+
import type { RouteApp } from "../system/routes.js";
|
|
5
|
+
import {
|
|
6
|
+
allowedPreflightHeaders,
|
|
7
|
+
corsRejected,
|
|
8
|
+
createPairingCorsPolicy,
|
|
9
|
+
json,
|
|
10
|
+
noStore,
|
|
11
|
+
pairingErrorResponse,
|
|
12
|
+
requireCallbackOrigin,
|
|
13
|
+
requireDeliveryContentType,
|
|
14
|
+
requiredText,
|
|
15
|
+
responseHeaders,
|
|
16
|
+
} from "./pairing-http.js";
|
|
17
|
+
import { LicensePairingService } from "./pairing-service.js";
|
|
18
|
+
import type { PlatformLicenseRuntime } from "./runtime-service.js";
|
|
19
|
+
|
|
20
|
+
interface PairingRouteContext {
|
|
21
|
+
guardContext: RequestGuardContext;
|
|
22
|
+
request: Request;
|
|
23
|
+
params: { sessionId: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PairingRouteApp = Pick<RouteApp, "get" | "post"> & {
|
|
27
|
+
options(path: string, handler: (context: unknown) => unknown): unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export interface PairingRouteOptions {
|
|
31
|
+
env?: Record<string, string | undefined>;
|
|
32
|
+
service?: LicensePairingService;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerLicensePairingRoutes(
|
|
36
|
+
app: PairingRouteApp,
|
|
37
|
+
runtime: PlatformLicenseRuntime,
|
|
38
|
+
authorizer: StaticRouteAuthorizer,
|
|
39
|
+
options: PairingRouteOptions = {},
|
|
40
|
+
) {
|
|
41
|
+
const env = options.env || process.env;
|
|
42
|
+
const service = options.service || new LicensePairingService(runtime);
|
|
43
|
+
const cors = createPairingCorsPolicy(env);
|
|
44
|
+
|
|
45
|
+
app.post(
|
|
46
|
+
"/license/runtime/pairing/sessions",
|
|
47
|
+
async (rawContext) => {
|
|
48
|
+
const context = rawContext as PairingRouteContext;
|
|
49
|
+
const denied = await authorizeUser(
|
|
50
|
+
context,
|
|
51
|
+
authorizer,
|
|
52
|
+
"POST",
|
|
53
|
+
"/api/license/runtime/pairing/sessions",
|
|
54
|
+
);
|
|
55
|
+
if (denied) return denied;
|
|
56
|
+
if (!cors.enabled) {
|
|
57
|
+
return json(
|
|
58
|
+
{ error: "PAIRING_DISABLED", reason: "未配置手机 Web 允许来源" },
|
|
59
|
+
503,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const body = await readBoundedJsonObject(context.request);
|
|
64
|
+
const callbackOrigin = requireCallbackOrigin(
|
|
65
|
+
body.callbackOrigin,
|
|
66
|
+
context.request,
|
|
67
|
+
);
|
|
68
|
+
const created = service.create(
|
|
69
|
+
(sessionId) =>
|
|
70
|
+
`${callbackOrigin}/api/license/runtime/pairing/sessions/${sessionId}/deliver`,
|
|
71
|
+
);
|
|
72
|
+
return json(
|
|
73
|
+
{
|
|
74
|
+
id: created.request.sessionId,
|
|
75
|
+
token: created.request.token,
|
|
76
|
+
nonce: created.request.nonce,
|
|
77
|
+
productCode: created.request.productCode,
|
|
78
|
+
machineCode: created.request.machineCode,
|
|
79
|
+
expiresAt: created.request.expiresAt,
|
|
80
|
+
qrPayload: created.qrPayload,
|
|
81
|
+
status: created.status,
|
|
82
|
+
},
|
|
83
|
+
201,
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return pairingErrorResponse(error);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
{ parse: "none" },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
app.get(
|
|
93
|
+
"/license/runtime/pairing/sessions/:sessionId",
|
|
94
|
+
async (rawContext) => {
|
|
95
|
+
const context = rawContext as PairingRouteContext;
|
|
96
|
+
const denied = await authorizeUser(
|
|
97
|
+
context,
|
|
98
|
+
authorizer,
|
|
99
|
+
"GET",
|
|
100
|
+
"/api/license/runtime/pairing/sessions/:sessionId",
|
|
101
|
+
);
|
|
102
|
+
if (denied) return denied;
|
|
103
|
+
try {
|
|
104
|
+
return json(service.status(context.params.sessionId));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return pairingErrorResponse(error);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
app.options(
|
|
112
|
+
"/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
113
|
+
async (rawContext) => {
|
|
114
|
+
const context = rawContext as PairingRouteContext;
|
|
115
|
+
const denied = await authorizeCallbackBinding(
|
|
116
|
+
context,
|
|
117
|
+
authorizer,
|
|
118
|
+
"OPTIONS",
|
|
119
|
+
"/api/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
120
|
+
);
|
|
121
|
+
if (denied) return denied;
|
|
122
|
+
const origin = cors.accept(context.request);
|
|
123
|
+
if (!origin) return corsRejected();
|
|
124
|
+
if (
|
|
125
|
+
context.request.headers.get("access-control-request-method") !==
|
|
126
|
+
"POST" ||
|
|
127
|
+
!allowedPreflightHeaders(
|
|
128
|
+
context.request.headers.get("access-control-request-headers"),
|
|
129
|
+
)
|
|
130
|
+
) {
|
|
131
|
+
return json(
|
|
132
|
+
{ error: "PAIRING_CORS_REJECTED" },
|
|
133
|
+
403,
|
|
134
|
+
cors.headers(origin),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return new Response(null, {
|
|
138
|
+
status: 204,
|
|
139
|
+
headers: responseHeaders(cors.headers(origin)),
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
app.post(
|
|
145
|
+
"/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
146
|
+
async (rawContext) => {
|
|
147
|
+
const context = rawContext as PairingRouteContext;
|
|
148
|
+
const denied = await authorizeCallbackBinding(
|
|
149
|
+
context,
|
|
150
|
+
authorizer,
|
|
151
|
+
"POST",
|
|
152
|
+
"/api/license/runtime/pairing/sessions/:sessionId/deliver",
|
|
153
|
+
);
|
|
154
|
+
if (denied) return denied;
|
|
155
|
+
const origin = cors.accept(context.request);
|
|
156
|
+
if (!origin) return corsRejected();
|
|
157
|
+
const headers = cors.headers(origin);
|
|
158
|
+
try {
|
|
159
|
+
requireDeliveryContentType(context.request);
|
|
160
|
+
const body = await readBoundedJsonObject(context.request);
|
|
161
|
+
const token = requiredText(body.token, "token");
|
|
162
|
+
const license = requiredText(body.license, "license");
|
|
163
|
+
return json(
|
|
164
|
+
await service.deliver(context.params.sessionId, token, license),
|
|
165
|
+
200,
|
|
166
|
+
headers,
|
|
167
|
+
);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return pairingErrorResponse(error, headers);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{ parse: "none" },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
app.post(
|
|
176
|
+
"/license/runtime/pairing/sessions/:sessionId/confirm",
|
|
177
|
+
async (rawContext) => {
|
|
178
|
+
const context = rawContext as PairingRouteContext;
|
|
179
|
+
const denied = await authorizeUser(
|
|
180
|
+
context,
|
|
181
|
+
authorizer,
|
|
182
|
+
"POST",
|
|
183
|
+
"/api/license/runtime/pairing/sessions/:sessionId/confirm",
|
|
184
|
+
);
|
|
185
|
+
if (denied) return denied;
|
|
186
|
+
try {
|
|
187
|
+
const body = await readBoundedJsonObject(context.request);
|
|
188
|
+
const installed = await service.confirm(
|
|
189
|
+
context.params.sessionId,
|
|
190
|
+
requiredText(body.pairingCode, "pairingCode"),
|
|
191
|
+
{
|
|
192
|
+
actor: context.guardContext.actor,
|
|
193
|
+
requestId: context.guardContext.requestId,
|
|
194
|
+
clientIp: context.guardContext.clientIp,
|
|
195
|
+
userAgent: context.guardContext.userAgent,
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
if (installed.result.status === "rejected") {
|
|
199
|
+
return json(
|
|
200
|
+
{
|
|
201
|
+
error: "LICENSE_INSTALL_REJECTED",
|
|
202
|
+
reasons: installed.result.failures,
|
|
203
|
+
snapshot: runtime.presentSnapshot(
|
|
204
|
+
installed.snapshot,
|
|
205
|
+
context.guardContext.actor,
|
|
206
|
+
),
|
|
207
|
+
},
|
|
208
|
+
400,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return json({
|
|
212
|
+
installStatus: installed.result.status,
|
|
213
|
+
snapshot: runtime.presentSnapshot(
|
|
214
|
+
installed.snapshot,
|
|
215
|
+
context.guardContext.actor,
|
|
216
|
+
),
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return pairingErrorResponse(error);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
{ parse: "none" },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function authorizeCallbackBinding(
|
|
227
|
+
context: PairingRouteContext,
|
|
228
|
+
authorizer: StaticRouteAuthorizer,
|
|
229
|
+
method: string,
|
|
230
|
+
path: string,
|
|
231
|
+
): Promise<Response | undefined> {
|
|
232
|
+
const denied = await authorizer.authorize(
|
|
233
|
+
context.guardContext,
|
|
234
|
+
method,
|
|
235
|
+
path,
|
|
236
|
+
{ restrictedModeAccess: "admin-admission" },
|
|
237
|
+
);
|
|
238
|
+
return denied ? noStore(denied) : undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function authorizeUser(
|
|
242
|
+
context: PairingRouteContext,
|
|
243
|
+
authorizer: StaticRouteAuthorizer,
|
|
244
|
+
method: string,
|
|
245
|
+
path: string,
|
|
246
|
+
): Promise<Response | undefined> {
|
|
247
|
+
if (context.guardContext.actor.type !== "user") {
|
|
248
|
+
return json({ error: "UNAUTHORIZED" }, 401);
|
|
249
|
+
}
|
|
250
|
+
const denied = await authorizer.authorize(
|
|
251
|
+
context.guardContext,
|
|
252
|
+
method,
|
|
253
|
+
path,
|
|
254
|
+
{ restrictedModeAccess: "admin-admission" },
|
|
255
|
+
);
|
|
256
|
+
return denied ? noStore(denied) : undefined;
|
|
257
|
+
}
|