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.
Files changed (101) hide show
  1. package/README.md +20 -3
  2. package/dist/cli.js +585 -111
  3. package/package.json +1 -1
  4. package/template/.windy-template.json +2 -2
  5. package/template/AGENTS.md +4 -0
  6. package/template/README.md +18 -11
  7. package/template/apps/server/src/application-services.ts +16 -0
  8. package/template/apps/server/src/data-governance/export/authorization-port.test.ts +5 -1
  9. package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
  10. package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
  11. package/template/apps/server/src/data-governance/export/job-runtime.test.ts +3 -1
  12. package/template/apps/server/src/data-governance/export/job-runtime.ts +53 -14
  13. package/template/apps/server/src/data-governance/export/ndjson-stream.ts +8 -0
  14. package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +2 -0
  15. package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
  16. package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
  17. package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
  18. package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
  19. package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
  20. package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
  21. package/template/apps/server/src/data-governance/export/routes.ts +11 -1
  22. package/template/apps/server/src/data-governance/export/service-support.ts +61 -4
  23. package/template/apps/server/src/data-governance/export/service.ts +8 -2
  24. package/template/apps/server/src/data-governance/export/types.ts +27 -0
  25. package/template/apps/server/src/index.ts +1 -0
  26. package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
  27. package/template/apps/server/src/jobs/artifact-service.ts +10 -0
  28. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
  29. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
  30. package/template/apps/server/src/jobs/drizzle-mappers.ts +3 -1
  31. package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
  32. package/template/apps/server/src/jobs/execution-repository.ts +17 -0
  33. package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
  34. package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
  35. package/template/apps/server/src/jobs/worker.ts +27 -3
  36. package/template/apps/server/src/module-host/governed-export-route-support.ts +24 -5
  37. package/template/apps/server/src/module-host/governed-export-routes.test.ts +5 -1
  38. package/template/apps/server/src/module-host/governed-export-routes.ts +1 -2
  39. package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
  40. package/template/apps/server/src/module-host/governed-resources.test.ts +1 -0
  41. package/template/apps/server/src/module-host/governed-resources.ts +56 -0
  42. package/template/apps/server/src/module-host/http-runtime.ts +3 -0
  43. package/template/apps/server/src/module-host/job-port.test.ts +262 -0
  44. package/template/apps/server/src/module-host/job-port.ts +334 -0
  45. package/template/apps/server/src/module-host/request-context.ts +14 -26
  46. package/template/apps/server/src/module-host/route-installer.ts +11 -0
  47. package/template/apps/web/src/app/business-routes.ts +22 -0
  48. package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
  49. package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
  50. package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
  51. package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
  52. package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
  53. package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
  54. package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
  55. package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
  56. package/template/apps/web/src/layout/AppShell.vue +28 -25
  57. package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
  58. package/template/apps/web/src/router/business-routes.ts +68 -0
  59. package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
  60. package/template/apps/web/src/router/index.ts +7 -0
  61. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  62. package/template/apps/web/src/services/access-api.ts +5 -1
  63. package/template/apps/web/src/services/access-api.webtest.ts +35 -0
  64. package/template/docs/architecture/ai-egress.md +12 -9
  65. package/template/docs/architecture/durable-jobs.md +7 -1
  66. package/template/docs/architecture/foundation-infrastructure.md +163 -0
  67. package/template/docs/architecture/module-host.md +115 -0
  68. package/template/docs/architecture/server-module-interface.md +39 -0
  69. package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
  70. package/template/docs/development/business-web-routes.md +66 -0
  71. package/template/docs/development/custom-login-page.md +3 -3
  72. package/template/docs/development/module-lifecycle.md +71 -0
  73. package/template/docs/operations/observability.md +5 -1
  74. package/template/docs/platform/agent-runtime.md +6 -6
  75. package/template/docs/platform/agent-tool-sandbox.md +58 -0
  76. package/template/docs/platform/document-ai-runtime.md +204 -0
  77. package/template/docs/platform/governed-export-renderers.md +132 -0
  78. package/template/docs/platform/navigation-registry.md +5 -0
  79. package/template/docs/platform/organization-membership.md +2 -1
  80. package/template/docs/platform/speech-ai-runtime.md +156 -0
  81. package/template/docs/platform/system-crud-api.md +7 -1
  82. package/template/docs/platform/vision-ai-runtime.md +97 -0
  83. package/template/docs/platform/web-system-crud.md +6 -0
  84. package/template/packages/config/package.json +1 -1
  85. package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
  86. package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
  87. package/template/packages/database/drizzle/meta/_journal.json +7 -0
  88. package/template/packages/database/package.json +1 -1
  89. package/template/packages/database/src/schema/durable-jobs.ts +1 -0
  90. package/template/packages/database/src/schema/governed-exports.ts +15 -0
  91. package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
  92. package/template/packages/jobs/package.json +1 -1
  93. package/template/packages/jobs/src/handlers.ts +1 -0
  94. package/template/packages/jobs/src/types.ts +1 -0
  95. package/template/packages/modules/package.json +1 -1
  96. package/template/packages/server-sdk/index.ts +11 -0
  97. package/template/packages/server-sdk/package.json +1 -1
  98. package/template/packages/server-sdk/src/data-governance-registration.ts +46 -0
  99. package/template/packages/server-sdk/src/jobs-port.ts +69 -0
  100. package/template/packages/server-sdk/src/request-context.ts +2 -2
  101. 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: createModuleJobSubmitter(input),
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
+ }