create-windy 0.3.4 → 0.3.6
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 +20 -3
- package/dist/cli.js +585 -111
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/AGENTS.md +4 -0
- package/template/README.md +18 -11
- package/template/apps/server/src/application-services.ts +16 -0
- package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
- package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
- package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
- package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
- package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
- package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
- package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
- package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
- package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
- package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
- package/template/apps/server/src/data-governance/export/routes.ts +11 -1
- package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
- package/template/apps/server/src/data-governance/export/service.ts +8 -2
- package/template/apps/server/src/data-governance/export/types.ts +27 -0
- package/template/apps/server/src/index.ts +1 -0
- package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
- package/template/apps/server/src/jobs/artifact-service.ts +10 -0
- package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
- package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
- package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
- package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
- package/template/apps/server/src/jobs/execution-repository.ts +17 -0
- package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
- package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
- package/template/apps/server/src/jobs/worker.ts +27 -3
- package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
- package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
- package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
- package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
- package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
- package/template/apps/server/src/module-host/governed-resources.ts +56 -0
- package/template/apps/server/src/module-host/http-runtime.ts +3 -0
- package/template/apps/server/src/module-host/job-port.test.ts +262 -0
- package/template/apps/server/src/module-host/job-port.ts +334 -0
- package/template/apps/server/src/module-host/request-context.ts +14 -26
- package/template/apps/server/src/module-host/route-installer.ts +11 -0
- package/template/apps/web/src/app/business-routes.ts +22 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
- package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
- package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
- package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
- package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
- package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
- package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
- package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
- package/template/apps/web/src/layout/AppShell.vue +28 -25
- package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
- package/template/apps/web/src/router/business-routes.ts +68 -0
- package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
- package/template/apps/web/src/router/index.ts +7 -0
- package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
- package/template/apps/web/src/services/access-api.ts +5 -1
- package/template/apps/web/src/services/access-api.webtest.ts +35 -0
- package/template/docs/architecture/ai-egress.md +12 -9
- package/template/docs/architecture/durable-jobs.md +7 -1
- package/template/docs/architecture/foundation-infrastructure.md +163 -0
- package/template/docs/architecture/module-host.md +115 -0
- package/template/docs/architecture/server-module-interface.md +39 -0
- package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
- package/template/docs/development/business-web-routes.md +66 -0
- package/template/docs/development/custom-login-page.md +3 -3
- package/template/docs/development/module-lifecycle.md +71 -0
- package/template/docs/operations/observability.md +5 -1
- package/template/docs/platform/agent-runtime.md +6 -6
- package/template/docs/platform/agent-tool-sandbox.md +58 -0
- package/template/docs/platform/document-ai-runtime.md +204 -0
- package/template/docs/platform/governed-export-renderers.md +132 -0
- package/template/docs/platform/navigation-registry.md +5 -0
- package/template/docs/platform/organization-membership.md +2 -1
- package/template/docs/platform/speech-ai-runtime.md +156 -0
- package/template/docs/platform/system-crud-api.md +7 -1
- package/template/docs/platform/vision-ai-runtime.md +97 -0
- package/template/docs/platform/web-system-crud.md +6 -0
- package/template/packages/config/package.json +1 -1
- package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
- package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
- package/template/packages/database/drizzle/meta/_journal.json +7 -0
- package/template/packages/database/package.json +1 -1
- package/template/packages/database/src/schema/durable-jobs.ts +1 -0
- package/template/packages/database/src/schema/governed-exports.ts +15 -0
- package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
- package/template/packages/jobs/package.json +1 -1
- package/template/packages/jobs/src/handlers.ts +1 -0
- package/template/packages/jobs/src/types.ts +1 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
- package/template/packages/server-sdk/src/jobs-port.ts +69 -0
- package/template/packages/server-sdk/src/request-context.ts +2 -2
- package/template/packages/shared/package.json +1 -1
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { randomUUIDv7 } from "bun";
|
|
3
|
+
import type { DurableJobSnapshot, DurableJobs } from "@southwind-ai/jobs";
|
|
4
|
+
import type {
|
|
5
|
+
ModuleJobPort,
|
|
6
|
+
ModuleJobView,
|
|
7
|
+
} from "@southwind-ai/server-sdk";
|
|
8
|
+
import {
|
|
9
|
+
ModuleJobControlError,
|
|
10
|
+
requireModuleNamespace,
|
|
11
|
+
} from "@southwind-ai/server-sdk";
|
|
12
|
+
import type { AuditEvent } from "@southwind-ai/shared";
|
|
13
|
+
import { auditActor, type RequestGuardContext } from "../guards.js";
|
|
14
|
+
import type { createServerRuntime } from "../runtime.js";
|
|
15
|
+
import {
|
|
16
|
+
captureAuthorizationScope,
|
|
17
|
+
hashAuthorizationScope,
|
|
18
|
+
} from "../data-access/scope-snapshot.js";
|
|
19
|
+
|
|
20
|
+
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
21
|
+
|
|
22
|
+
export interface ModuleJobControlRuntime {
|
|
23
|
+
readonly worker: {
|
|
24
|
+
controlCancel(input: {
|
|
25
|
+
jobId: string;
|
|
26
|
+
expectedStatus?: ModuleJobView["status"];
|
|
27
|
+
expectedVersion?: string;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
outcome: "changed" | "unchanged" | "not-found" | "conflict";
|
|
30
|
+
}>;
|
|
31
|
+
recover(input: {
|
|
32
|
+
jobId: string;
|
|
33
|
+
requestedBy: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
idempotencyKey: string;
|
|
36
|
+
expectedAttempt: number;
|
|
37
|
+
expectedStatus: ModuleJobView["status"];
|
|
38
|
+
expectedVersion: string;
|
|
39
|
+
}): Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModuleJobPortInput {
|
|
44
|
+
readonly moduleName: string;
|
|
45
|
+
readonly guardContext: RequestGuardContext;
|
|
46
|
+
readonly runtime: ServerRuntime;
|
|
47
|
+
readonly jobs?: DurableJobs;
|
|
48
|
+
readonly control?: ModuleJobControlRuntime;
|
|
49
|
+
readonly reauthorize?: () => Promise<boolean>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createModuleJobPort(input: ModuleJobPortInput): ModuleJobPort {
|
|
53
|
+
return {
|
|
54
|
+
async submit(job) {
|
|
55
|
+
requireModuleNamespace("任务 handler", job.handlerKey, input.moduleName);
|
|
56
|
+
const jobs = requiredJobs(input);
|
|
57
|
+
const result = await jobs.submit({
|
|
58
|
+
...job,
|
|
59
|
+
queue: input.moduleName,
|
|
60
|
+
requestedBy: input.guardContext.actor.id,
|
|
61
|
+
authorizationScope: currentScope(input),
|
|
62
|
+
});
|
|
63
|
+
return { jobId: result.job.id, created: result.created };
|
|
64
|
+
},
|
|
65
|
+
async get(jobId) {
|
|
66
|
+
return toAuthorizedView(input, jobId);
|
|
67
|
+
},
|
|
68
|
+
async cancel(request) {
|
|
69
|
+
const reason = sanitizeReason(request.reason);
|
|
70
|
+
const current = await requireAuthorizedSnapshot(input, request.jobId);
|
|
71
|
+
const control = requiredControl(input);
|
|
72
|
+
const result = await control.worker.controlCancel({
|
|
73
|
+
jobId: current.job.id,
|
|
74
|
+
expectedStatus: request.expectedStatus,
|
|
75
|
+
expectedVersion: request.expectedVersion,
|
|
76
|
+
});
|
|
77
|
+
if (result.outcome === "conflict" || result.outcome === "not-found") {
|
|
78
|
+
await auditControl(
|
|
79
|
+
input,
|
|
80
|
+
"cancel",
|
|
81
|
+
"failure",
|
|
82
|
+
current,
|
|
83
|
+
reason,
|
|
84
|
+
result.outcome,
|
|
85
|
+
);
|
|
86
|
+
throw conflict();
|
|
87
|
+
}
|
|
88
|
+
const updated = await requireAuthorizedSnapshot(input, request.jobId);
|
|
89
|
+
await auditControl(
|
|
90
|
+
input,
|
|
91
|
+
"cancel",
|
|
92
|
+
"success",
|
|
93
|
+
updated,
|
|
94
|
+
reason,
|
|
95
|
+
result.outcome,
|
|
96
|
+
);
|
|
97
|
+
return toView(updated);
|
|
98
|
+
},
|
|
99
|
+
async recover(request) {
|
|
100
|
+
const reason = sanitizeReason(request.reason);
|
|
101
|
+
const current = await requireAuthorizedSnapshot(input, request.jobId);
|
|
102
|
+
const control = requiredControl(input);
|
|
103
|
+
try {
|
|
104
|
+
await control.worker.recover({
|
|
105
|
+
jobId: current.job.id,
|
|
106
|
+
requestedBy: input.guardContext.actor.id,
|
|
107
|
+
reason,
|
|
108
|
+
idempotencyKey: recoveryKey(input, request),
|
|
109
|
+
expectedAttempt: request.expectedAttempt,
|
|
110
|
+
expectedStatus: request.expectedStatus,
|
|
111
|
+
expectedVersion: request.expectedVersion,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
await auditControl(
|
|
115
|
+
input,
|
|
116
|
+
"recover",
|
|
117
|
+
"failure",
|
|
118
|
+
current,
|
|
119
|
+
reason,
|
|
120
|
+
"conflict",
|
|
121
|
+
);
|
|
122
|
+
throw conflict();
|
|
123
|
+
}
|
|
124
|
+
const updated = await requireAuthorizedSnapshot(input, request.jobId);
|
|
125
|
+
await auditControl(input, "recover", "success", updated, reason, "changed");
|
|
126
|
+
return toView(updated);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function toAuthorizedView(
|
|
132
|
+
input: ModuleJobPortInput,
|
|
133
|
+
jobId: string,
|
|
134
|
+
): Promise<ModuleJobView | undefined> {
|
|
135
|
+
if (input.reauthorize && !(await input.reauthorize())) return undefined;
|
|
136
|
+
const snapshot = await requiredJobs(input).get(jobId);
|
|
137
|
+
return snapshot && authorized(input, snapshot) ? toView(snapshot) : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function requireAuthorizedSnapshot(
|
|
141
|
+
input: ModuleJobPortInput,
|
|
142
|
+
jobId: string,
|
|
143
|
+
) {
|
|
144
|
+
const snapshot = await toAuthorizedSnapshot(input, jobId);
|
|
145
|
+
if (snapshot) return snapshot;
|
|
146
|
+
await auditUnavailable(input, jobId);
|
|
147
|
+
throw new ModuleJobControlError("JOB_NOT_AVAILABLE", "任务不可用");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function toAuthorizedSnapshot(
|
|
151
|
+
input: ModuleJobPortInput,
|
|
152
|
+
jobId: string,
|
|
153
|
+
) {
|
|
154
|
+
if (input.reauthorize && !(await input.reauthorize())) return undefined;
|
|
155
|
+
const snapshot = await requiredJobs(input).get(jobId);
|
|
156
|
+
return snapshot && authorized(input, snapshot) ? snapshot : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function authorized(input: ModuleJobPortInput, snapshot: DurableJobSnapshot) {
|
|
160
|
+
const job = snapshot.job;
|
|
161
|
+
const submittedScope = job.authorizationScope;
|
|
162
|
+
return (
|
|
163
|
+
job.queue === input.moduleName &&
|
|
164
|
+
job.handlerKey.startsWith(`${input.moduleName}.`) &&
|
|
165
|
+
job.requestedBy === input.guardContext.actor.id &&
|
|
166
|
+
Boolean(submittedScope) &&
|
|
167
|
+
hashAuthorizationScope(submittedScope!) ===
|
|
168
|
+
hashAuthorizationScope(currentScope(input))
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function toView(snapshot: DurableJobSnapshot): ModuleJobView {
|
|
173
|
+
const attempt = Math.max(
|
|
174
|
+
0,
|
|
175
|
+
...snapshot.stages.map((stage) => stage.currentAttempt),
|
|
176
|
+
);
|
|
177
|
+
const latestProgress = snapshot.progress
|
|
178
|
+
.slice()
|
|
179
|
+
.sort(
|
|
180
|
+
(left, right) =>
|
|
181
|
+
left.recordedAt.localeCompare(right.recordedAt) ||
|
|
182
|
+
left.sequence - right.sequence,
|
|
183
|
+
)
|
|
184
|
+
.at(-1);
|
|
185
|
+
const latestFailure = snapshot.failures.at(-1);
|
|
186
|
+
const activeLease = snapshot.leases.some((lease) => lease.status === "active");
|
|
187
|
+
return {
|
|
188
|
+
jobId: snapshot.job.id,
|
|
189
|
+
handlerKey: snapshot.job.handlerKey,
|
|
190
|
+
status: snapshot.job.status,
|
|
191
|
+
progress: latestProgress
|
|
192
|
+
? {
|
|
193
|
+
completedUnits: latestProgress.completedUnits,
|
|
194
|
+
totalUnits: latestProgress.totalUnits,
|
|
195
|
+
}
|
|
196
|
+
: undefined,
|
|
197
|
+
attempt,
|
|
198
|
+
maxAttempts: snapshot.job.maxAttempts,
|
|
199
|
+
createdAt: snapshot.job.createdAt,
|
|
200
|
+
startedAt: snapshot.job.startedAt,
|
|
201
|
+
finishedAt: snapshot.job.finishedAt,
|
|
202
|
+
failureCode: safeFailureCode(latestFailure?.code),
|
|
203
|
+
canCancel:
|
|
204
|
+
["queued", "running"].includes(snapshot.job.status) &&
|
|
205
|
+
!snapshot.job.cancelRequestedAt,
|
|
206
|
+
canRecover:
|
|
207
|
+
["failed", "cancelled"].includes(snapshot.job.status) && !activeLease,
|
|
208
|
+
version: snapshot.job.updatedAt,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sanitizeReason(value: string) {
|
|
213
|
+
const reason = value
|
|
214
|
+
.trim()
|
|
215
|
+
.replace(/\s+/g, " ")
|
|
216
|
+
.replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, "[邮箱]")
|
|
217
|
+
.replace(/\b\d{7,}\b/g, "[号码]")
|
|
218
|
+
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, "[敏感内容]")
|
|
219
|
+
.slice(0, 200);
|
|
220
|
+
if (reason.length < 5) {
|
|
221
|
+
throw new ModuleJobControlError(
|
|
222
|
+
"JOB_REASON_INVALID",
|
|
223
|
+
"任务控制理由至少 5 个字符",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return reason;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function safeFailureCode(code: string | undefined) {
|
|
230
|
+
if (!code) return undefined;
|
|
231
|
+
return /^[A-Z][A-Z0-9_.-]{0,79}$/.test(code) ? code : "JOB_FAILED";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function currentScope(input: ModuleJobPortInput) {
|
|
235
|
+
return captureAuthorizationScope(
|
|
236
|
+
input.guardContext.actor.scopeContext,
|
|
237
|
+
).snapshot;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function requiredJobs(input: ModuleJobPortInput) {
|
|
241
|
+
if (input.jobs) return input.jobs;
|
|
242
|
+
throw unavailable();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function requiredControl(input: ModuleJobPortInput) {
|
|
246
|
+
if (input.control) return input.control;
|
|
247
|
+
throw unavailable();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function unavailable() {
|
|
251
|
+
return new ModuleJobControlError(
|
|
252
|
+
"JOB_CONTROL_UNAVAILABLE",
|
|
253
|
+
"Durable Jobs 运行时未挂载",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function conflict() {
|
|
258
|
+
return new ModuleJobControlError("JOB_STATE_CONFLICT", "任务状态已变化");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function recoveryKey(
|
|
262
|
+
input: ModuleJobPortInput,
|
|
263
|
+
request: {
|
|
264
|
+
jobId: string;
|
|
265
|
+
expectedAttempt: number;
|
|
266
|
+
expectedStatus: string;
|
|
267
|
+
expectedVersion: string;
|
|
268
|
+
},
|
|
269
|
+
) {
|
|
270
|
+
return createHash("sha256")
|
|
271
|
+
.update(
|
|
272
|
+
[
|
|
273
|
+
input.moduleName,
|
|
274
|
+
input.guardContext.actor.id,
|
|
275
|
+
request.jobId,
|
|
276
|
+
request.expectedAttempt,
|
|
277
|
+
request.expectedStatus,
|
|
278
|
+
request.expectedVersion,
|
|
279
|
+
].join(":"),
|
|
280
|
+
)
|
|
281
|
+
.digest("hex");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function auditUnavailable(input: ModuleJobPortInput, jobId: string) {
|
|
285
|
+
await input.runtime.auditSink.recordCriticalEvent(
|
|
286
|
+
auditEvent(input, "access", "denied", jobId),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function auditControl(
|
|
291
|
+
input: ModuleJobPortInput,
|
|
292
|
+
operation: "cancel" | "recover",
|
|
293
|
+
outcome: "success" | "failure",
|
|
294
|
+
snapshot: DurableJobSnapshot,
|
|
295
|
+
reason: string,
|
|
296
|
+
result: string,
|
|
297
|
+
) {
|
|
298
|
+
await input.runtime.auditSink.recordCriticalEvent({
|
|
299
|
+
...auditEvent(input, operation, outcome, snapshot.job.id),
|
|
300
|
+
metadata: {
|
|
301
|
+
handlerKey: snapshot.job.handlerKey,
|
|
302
|
+
status: snapshot.job.status,
|
|
303
|
+
attempt: Math.max(
|
|
304
|
+
0,
|
|
305
|
+
...snapshot.stages.map((stage) => stage.currentAttempt),
|
|
306
|
+
),
|
|
307
|
+
attemptCount: snapshot.attempts.length,
|
|
308
|
+
failureCount: snapshot.failures.length,
|
|
309
|
+
failureCode: safeFailureCode(snapshot.failures.at(-1)?.code),
|
|
310
|
+
recoveryCount: snapshot.recoveries.length,
|
|
311
|
+
version: snapshot.job.updatedAt,
|
|
312
|
+
reason,
|
|
313
|
+
result,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function auditEvent(
|
|
319
|
+
input: ModuleJobPortInput,
|
|
320
|
+
operation: string,
|
|
321
|
+
outcome: AuditEvent["outcome"],
|
|
322
|
+
jobId: string,
|
|
323
|
+
): AuditEvent {
|
|
324
|
+
return {
|
|
325
|
+
id: randomUUIDv7(),
|
|
326
|
+
action: `module.job.${operation}`,
|
|
327
|
+
outcome,
|
|
328
|
+
actor: auditActor(input.guardContext),
|
|
329
|
+
target: { type: "durable-job", id: jobId },
|
|
330
|
+
module: input.moduleName,
|
|
331
|
+
requestId: input.guardContext.requestId,
|
|
332
|
+
occurredAt: new Date().toISOString(),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
ModuleAiOperationPort,
|
|
7
7
|
ModuleAuditEmitInput,
|
|
8
8
|
ModuleAuditEmitter,
|
|
9
|
-
ModuleJobSubmitter,
|
|
10
9
|
ModuleRequestContext,
|
|
11
10
|
ModuleStoragePort,
|
|
12
11
|
ModuleUserDirectoryPort,
|
|
@@ -15,7 +14,6 @@ import {
|
|
|
15
14
|
ModuleAiArtifactError,
|
|
16
15
|
ModuleAiOperationError,
|
|
17
16
|
ModuleUserDirectoryError,
|
|
18
|
-
requireModuleNamespace,
|
|
19
17
|
} from "@southwind-ai/server-sdk";
|
|
20
18
|
import type { AuditEvent } from "@southwind-ai/shared";
|
|
21
19
|
import type { ModuleManifest } from "@southwind-ai/modules";
|
|
@@ -25,8 +23,11 @@ import {
|
|
|
25
23
|
type RequestGuardContext,
|
|
26
24
|
} from "../guards.js";
|
|
27
25
|
import type { createServerRuntime } from "../runtime.js";
|
|
28
|
-
import { captureAuthorizationScope } from "../data-access/scope-snapshot.js";
|
|
29
26
|
import { unavailableModuleStoragePort } from "./storage-port.js";
|
|
27
|
+
import {
|
|
28
|
+
createModuleJobPort,
|
|
29
|
+
type ModuleJobControlRuntime,
|
|
30
|
+
} from "./job-port.js";
|
|
30
31
|
|
|
31
32
|
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
32
33
|
|
|
@@ -71,6 +72,8 @@ export interface ModuleRequestContextInput {
|
|
|
71
72
|
body?: unknown;
|
|
72
73
|
runtime: ServerRuntime;
|
|
73
74
|
jobs?: DurableJobs;
|
|
75
|
+
jobControl?: ModuleJobControlRuntime;
|
|
76
|
+
reauthorizeJobs?: () => Promise<boolean>;
|
|
74
77
|
storage?: ModuleStoragePort;
|
|
75
78
|
userDirectory?: ModuleUserDirectoryPort;
|
|
76
79
|
aiArtifacts?: ModuleAiArtifactPort;
|
|
@@ -91,7 +94,14 @@ export function buildModuleRequestContext(
|
|
|
91
94
|
input.aiArtifacts ?? unavailableModuleAiArtifactPort(input.moduleName),
|
|
92
95
|
aiOperations:
|
|
93
96
|
input.aiOperations ?? unavailableModuleAiOperationPort(input.moduleName),
|
|
94
|
-
jobs:
|
|
97
|
+
jobs: createModuleJobPort({
|
|
98
|
+
moduleName: input.moduleName,
|
|
99
|
+
guardContext: input.guardContext,
|
|
100
|
+
runtime: input.runtime,
|
|
101
|
+
jobs: input.jobs,
|
|
102
|
+
control: input.jobControl,
|
|
103
|
+
reauthorize: input.reauthorizeJobs,
|
|
104
|
+
}),
|
|
95
105
|
storage: input.storage ?? unavailableModuleStoragePort(input.moduleName),
|
|
96
106
|
userDirectory:
|
|
97
107
|
input.userDirectory ?? unavailableModuleUserDirectory(input.moduleName),
|
|
@@ -184,25 +194,3 @@ function buildAuditEvent(
|
|
|
184
194
|
occurredAt: new Date().toISOString(),
|
|
185
195
|
};
|
|
186
196
|
}
|
|
187
|
-
|
|
188
|
-
function createModuleJobSubmitter(
|
|
189
|
-
input: ModuleRequestContextInput,
|
|
190
|
-
): ModuleJobSubmitter {
|
|
191
|
-
return {
|
|
192
|
-
async submit(job) {
|
|
193
|
-
requireModuleNamespace("任务 handler", job.handlerKey, input.moduleName);
|
|
194
|
-
if (!input.jobs) {
|
|
195
|
-
throw new Error("Durable Jobs 运行时未挂载");
|
|
196
|
-
}
|
|
197
|
-
const result = await input.jobs.submit({
|
|
198
|
-
...job,
|
|
199
|
-
queue: input.moduleName,
|
|
200
|
-
requestedBy: input.guardContext.actor.id,
|
|
201
|
-
authorizationScope: captureAuthorizationScope(
|
|
202
|
-
input.guardContext.actor.scopeContext,
|
|
203
|
-
).snapshot,
|
|
204
|
-
});
|
|
205
|
-
return { jobId: result.job.id, created: result.created };
|
|
206
|
-
},
|
|
207
|
-
};
|
|
208
|
-
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type ModuleUserDirectoryServices,
|
|
20
20
|
} from "./user-directory-port.js";
|
|
21
21
|
import { createModuleStorageScope } from "./storage-scope.js";
|
|
22
|
+
import type { ModuleJobControlRuntime } from "./job-port.js";
|
|
22
23
|
|
|
23
24
|
type ServerRuntime = ReturnType<typeof createServerRuntime>;
|
|
24
25
|
|
|
@@ -28,6 +29,7 @@ export interface ModuleRouteInstallContext {
|
|
|
28
29
|
runtime: ServerRuntime;
|
|
29
30
|
features: readonly FeatureFlagDefinition[];
|
|
30
31
|
jobs?: DurableJobs;
|
|
32
|
+
jobControl?: ModuleJobControlRuntime;
|
|
31
33
|
/** 平台存储服务;提供后模块获得受限 Upload/BlobRead Port。 */
|
|
32
34
|
storage?: ModuleStorageServices;
|
|
33
35
|
/** 平台身份目录;Adapter 会绑定当前请求 Actor 与服务端数据范围。 */
|
|
@@ -98,6 +100,15 @@ export function installModuleRoutes(
|
|
|
98
100
|
route.request.signal,
|
|
99
101
|
),
|
|
100
102
|
jobs: context.jobs,
|
|
103
|
+
jobControl: context.jobControl,
|
|
104
|
+
reauthorizeJobs: async () =>
|
|
105
|
+
(
|
|
106
|
+
await evaluateRouteGuards(
|
|
107
|
+
context.runtime,
|
|
108
|
+
route.guardContext,
|
|
109
|
+
{ permission: binding, feature },
|
|
110
|
+
)
|
|
111
|
+
).allowed,
|
|
101
112
|
storage: context.storage
|
|
102
113
|
? createModuleStoragePort(context.storage, {
|
|
103
114
|
...createModuleStorageScope({
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Component } from "vue";
|
|
2
|
+
import type { RouteRecordRaw } from "vue-router";
|
|
3
|
+
|
|
4
|
+
export interface BusinessAdminRouteRegistration {
|
|
5
|
+
readonly key: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly component: Component;
|
|
9
|
+
readonly featureKey: string;
|
|
10
|
+
readonly permissionKey: string;
|
|
11
|
+
readonly name?: string;
|
|
12
|
+
readonly props?: RouteRecordRaw["props"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 客户拥有的 Web 业务路由注册表。
|
|
17
|
+
*
|
|
18
|
+
* 导航标题、路径、Feature 和 Permission 必须与业务 Module Manifest 保持一致。
|
|
19
|
+
* 平台 Router 会统一接入访问快照 Guard,业务项目不需要修改 shared-scaffold。
|
|
20
|
+
*/
|
|
21
|
+
export const installedBusinessAdminRoutes =
|
|
22
|
+
[] satisfies readonly BusinessAdminRouteRegistration[];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, useTemplateRef, watch } from "vue";
|
|
3
|
+
import { useRoute, useRouter } from "vue-router";
|
|
4
|
+
import { useAccessSnapshot } from "@/composables/useAccessSnapshot";
|
|
5
|
+
import { useAccessSnapshotMonitor } from "@/composables/useAccessSnapshotMonitor";
|
|
6
|
+
import { evaluateWebAccess } from "@/layout/navigation";
|
|
7
|
+
import AccessSnapshotNotice from "./AccessSnapshotNotice.vue";
|
|
8
|
+
|
|
9
|
+
const route = useRoute();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const access = useAccessSnapshot();
|
|
12
|
+
const blocker = useTemplateRef<HTMLDivElement>("blocker");
|
|
13
|
+
useAccessSnapshotMonitor();
|
|
14
|
+
|
|
15
|
+
const routeRevoked = computed(() => {
|
|
16
|
+
const snapshot = access.snapshot.value;
|
|
17
|
+
if (!snapshot || route.meta.layout !== "admin") return false;
|
|
18
|
+
return !evaluateWebAccess(snapshot, {
|
|
19
|
+
permissionKey:
|
|
20
|
+
typeof route.meta.permissionKey === "string"
|
|
21
|
+
? route.meta.permissionKey
|
|
22
|
+
: undefined,
|
|
23
|
+
featureKey:
|
|
24
|
+
typeof route.meta.featureKey === "string"
|
|
25
|
+
? route.meta.featureKey
|
|
26
|
+
: undefined,
|
|
27
|
+
}).allowed;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const blockedReason = computed(() => {
|
|
31
|
+
if (routeRevoked.value) return "route-revoked" as const;
|
|
32
|
+
if (access.error.value) {
|
|
33
|
+
return access.failureReason.value || ("snapshot-unavailable" as const);
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const blocked = computed(() => Boolean(blockedReason.value));
|
|
39
|
+
const visibleChange = computed(() =>
|
|
40
|
+
blocked.value ? undefined : access.change.value,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
watch(blocked, async (isBlocked) => {
|
|
44
|
+
if (!isBlocked) return;
|
|
45
|
+
await nextTick();
|
|
46
|
+
blocker.value?.focus();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function retry(): Promise<void> {
|
|
50
|
+
try {
|
|
51
|
+
await access.refresh();
|
|
52
|
+
} catch {
|
|
53
|
+
// 失败详情保留在 access.error,由本边界继续安全降级。
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function reload(): void {
|
|
58
|
+
window.location.reload();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function returnHome(): Promise<void> {
|
|
62
|
+
await router.push({ name: "home" });
|
|
63
|
+
}
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<template>
|
|
67
|
+
<div class="contents">
|
|
68
|
+
<!-- 页面节点保留以维持表单基线、dirty 状态与 beforeunload 保护。 -->
|
|
69
|
+
<div
|
|
70
|
+
:inert="blocked"
|
|
71
|
+
:aria-hidden="blocked ? 'true' : undefined"
|
|
72
|
+
:class="{ 'pointer-events-none invisible': blocked }"
|
|
73
|
+
data-testid="access-protected-content"
|
|
74
|
+
>
|
|
75
|
+
<AccessSnapshotNotice
|
|
76
|
+
v-if="visibleChange"
|
|
77
|
+
class="fixed top-4 right-4 z-40 w-[min(32rem,calc(100vw-2rem))] bg-background shadow-lg"
|
|
78
|
+
:blocking="false"
|
|
79
|
+
:change-kind="visibleChange.kind"
|
|
80
|
+
:loading="access.loading.value"
|
|
81
|
+
:fingerprint="access.fingerprint.value"
|
|
82
|
+
@acknowledge="access.acknowledgeChange"
|
|
83
|
+
@reload="reload"
|
|
84
|
+
@retry="retry"
|
|
85
|
+
@return-home="returnHome"
|
|
86
|
+
/>
|
|
87
|
+
<slot />
|
|
88
|
+
</div>
|
|
89
|
+
<div
|
|
90
|
+
v-if="blockedReason"
|
|
91
|
+
ref="blocker"
|
|
92
|
+
role="alertdialog"
|
|
93
|
+
aria-modal="true"
|
|
94
|
+
aria-label="访问状态已变化"
|
|
95
|
+
tabindex="-1"
|
|
96
|
+
class="fixed inset-0 z-40 flex items-start justify-center bg-background/95 p-6 pt-[15vh] outline-none backdrop-blur-sm"
|
|
97
|
+
>
|
|
98
|
+
<div class="w-full max-w-2xl">
|
|
99
|
+
<AccessSnapshotNotice
|
|
100
|
+
:blocking="true"
|
|
101
|
+
:reason="blockedReason"
|
|
102
|
+
:loading="access.loading.value"
|
|
103
|
+
:fingerprint="access.fingerprint.value"
|
|
104
|
+
@acknowledge="access.acknowledgeChange"
|
|
105
|
+
@reload="reload"
|
|
106
|
+
@retry="retry"
|
|
107
|
+
@return-home="returnHome"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { nextTick } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
createMemoryHistory,
|
|
5
|
+
createRouter,
|
|
6
|
+
type RouteRecordRaw,
|
|
7
|
+
} from "vue-router";
|
|
8
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
9
|
+
import type { WebAccessSnapshot } from "@/layout/navigation";
|
|
10
|
+
import AccessSnapshotBoundary from "./AccessSnapshotBoundary.vue";
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({ loadAccessSnapshot: vi.fn() }));
|
|
13
|
+
vi.mock("@/services/access-api", () => mocks);
|
|
14
|
+
vi.mock("@/composables/useAccessSnapshotMonitor", () => ({
|
|
15
|
+
useAccessSnapshotMonitor: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
resetAccessSnapshot,
|
|
20
|
+
useAccessSnapshot,
|
|
21
|
+
} from "@/composables/useAccessSnapshot";
|
|
22
|
+
|
|
23
|
+
describe("运行中权限快照边界", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
resetAccessSnapshot();
|
|
26
|
+
mocks.loadAccessSnapshot.mockReset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("当前路由权限撤销后隐藏但不卸载未保存页面", async () => {
|
|
30
|
+
mocks.loadAccessSnapshot
|
|
31
|
+
.mockResolvedValueOnce(snapshot(["system.user.read"]))
|
|
32
|
+
.mockResolvedValueOnce(snapshot([]));
|
|
33
|
+
const access = useAccessSnapshot();
|
|
34
|
+
await access.ensureLoaded();
|
|
35
|
+
const router = await testRouter();
|
|
36
|
+
const wrapper = mount(AccessSnapshotBoundary, {
|
|
37
|
+
slots: {
|
|
38
|
+
default:
|
|
39
|
+
'<input data-testid="draft" value="尚未保存的编辑内容" />',
|
|
40
|
+
},
|
|
41
|
+
global: { plugins: [router] },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await access.refresh();
|
|
45
|
+
await nextTick();
|
|
46
|
+
|
|
47
|
+
expect(wrapper.get('[data-testid="access-snapshot-blocker"]').text()).toContain(
|
|
48
|
+
"当前页面权限已被撤销",
|
|
49
|
+
);
|
|
50
|
+
expect(
|
|
51
|
+
wrapper.get('[data-testid="access-protected-content"]').classes(),
|
|
52
|
+
).toContain("invisible");
|
|
53
|
+
expect(
|
|
54
|
+
wrapper.get<HTMLInputElement>('[data-testid="draft"]').element.value,
|
|
55
|
+
).toBe("尚未保存的编辑内容");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("刷新失败时 fail closed,成功重试后恢复原页面", async () => {
|
|
59
|
+
mocks.loadAccessSnapshot
|
|
60
|
+
.mockResolvedValueOnce(snapshot(["system.user.read"]))
|
|
61
|
+
.mockRejectedValueOnce(new Error("network unavailable"))
|
|
62
|
+
.mockResolvedValueOnce(snapshot(["system.user.read"]));
|
|
63
|
+
const access = useAccessSnapshot();
|
|
64
|
+
await access.ensureLoaded();
|
|
65
|
+
await expect(access.refresh()).rejects.toThrow("network unavailable");
|
|
66
|
+
const router = await testRouter();
|
|
67
|
+
const wrapper = mount(AccessSnapshotBoundary, {
|
|
68
|
+
slots: { default: '<p data-testid="content">用户列表</p>' },
|
|
69
|
+
global: { plugins: [router] },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await wrapper
|
|
73
|
+
.get('[data-testid="access-snapshot-blocker"] button')
|
|
74
|
+
.trigger("click");
|
|
75
|
+
await nextTick();
|
|
76
|
+
|
|
77
|
+
expect(mocks.loadAccessSnapshot).toHaveBeenCalledTimes(3);
|
|
78
|
+
expect(
|
|
79
|
+
wrapper.find('[data-testid="access-snapshot-blocker"]').exists(),
|
|
80
|
+
).toBe(false);
|
|
81
|
+
expect(wrapper.get('[data-testid="content"]').text()).toBe("用户列表");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
async function testRouter() {
|
|
86
|
+
const routes: RouteRecordRaw[] = [
|
|
87
|
+
{
|
|
88
|
+
path: "/users",
|
|
89
|
+
component: { template: "<div />" },
|
|
90
|
+
meta: {
|
|
91
|
+
layout: "admin",
|
|
92
|
+
permissionKey: "system.user.read",
|
|
93
|
+
featureKey: "system.user",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
path: "/",
|
|
98
|
+
name: "home",
|
|
99
|
+
component: { template: "<div />" },
|
|
100
|
+
meta: { layout: "app" },
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
const router = createRouter({ history: createMemoryHistory(), routes });
|
|
104
|
+
await router.push("/users");
|
|
105
|
+
await router.isReady();
|
|
106
|
+
return router;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function snapshot(permissionKeys: string[]): WebAccessSnapshot {
|
|
110
|
+
return {
|
|
111
|
+
actor: {
|
|
112
|
+
id: "user_1",
|
|
113
|
+
type: "user",
|
|
114
|
+
name: "平台用户",
|
|
115
|
+
roleCodes: ["member"],
|
|
116
|
+
permissionKeys,
|
|
117
|
+
},
|
|
118
|
+
menus: [],
|
|
119
|
+
permissions: [],
|
|
120
|
+
features: [
|
|
121
|
+
{
|
|
122
|
+
key: "system.user",
|
|
123
|
+
enabled: true,
|
|
124
|
+
visible: "visible",
|
|
125
|
+
allowed: true,
|
|
126
|
+
reason: "allowed",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|