create-windy 0.3.4 → 0.3.5
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 +582 -109
- 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,293 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GovernedExportRenderedArtifact,
|
|
3
|
+
GovernedExportWatermarkContext,
|
|
4
|
+
ModuleGovernedExportRendererRegistration,
|
|
5
|
+
} from "@southwind-ai/server-sdk";
|
|
6
|
+
import type { JsonValue } from "@southwind-ai/shared";
|
|
7
|
+
import type { GovernedExportSource } from "./source.js";
|
|
8
|
+
import { createWatermarkedNdjsonStream } from "./ndjson-stream.js";
|
|
9
|
+
import type {
|
|
10
|
+
GovernedExportRendererSnapshot,
|
|
11
|
+
GovernedExportRequest,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
export interface GovernedExportRendererBinding {
|
|
15
|
+
readonly resourceType: string;
|
|
16
|
+
readonly renderer: ModuleGovernedExportRendererRegistration;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RenderContext {
|
|
20
|
+
readonly request: GovernedExportRequest;
|
|
21
|
+
readonly source: GovernedExportSource;
|
|
22
|
+
readonly jobId: string;
|
|
23
|
+
readonly generatedAt: string;
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
readonly heartbeat: () => Promise<void>;
|
|
26
|
+
readonly beforePage: () => Promise<void>;
|
|
27
|
+
readonly pageCompleted: (completed: number) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GovernedExportRenderSession {
|
|
31
|
+
readonly body: ReadableStream<Uint8Array>;
|
|
32
|
+
readonly contentMetadata?: Readonly<Record<string, JsonValue>>;
|
|
33
|
+
assertRecordsConsumed(): void;
|
|
34
|
+
dispose(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createGovernedExportRenderSession(
|
|
38
|
+
input: RenderContext,
|
|
39
|
+
bindings: readonly GovernedExportRendererBinding[],
|
|
40
|
+
): Promise<GovernedExportRenderSession> {
|
|
41
|
+
const watermark = watermarkOf(input);
|
|
42
|
+
const binding = bindings.find(
|
|
43
|
+
({ resourceType, renderer }) =>
|
|
44
|
+
resourceType === input.request.resourceType &&
|
|
45
|
+
renderer.formatKey === input.request.renderer.formatKey,
|
|
46
|
+
);
|
|
47
|
+
if (!binding) {
|
|
48
|
+
if (!isLegacyNdjson(input.request.renderer)) {
|
|
49
|
+
throw new Error("EXPORT_RENDERER_NOT_REGISTERED");
|
|
50
|
+
}
|
|
51
|
+
const body = createWatermarkedNdjsonStream({
|
|
52
|
+
...input,
|
|
53
|
+
watermark,
|
|
54
|
+
maxRecords: input.request.renderer.maxRecords,
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
body: limitOutput(
|
|
58
|
+
body,
|
|
59
|
+
input.request.renderer.maxOutputBytes,
|
|
60
|
+
input.signal,
|
|
61
|
+
),
|
|
62
|
+
assertRecordsConsumed() {},
|
|
63
|
+
dispose() {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
assertRendererSnapshot(binding.renderer, input.request.renderer);
|
|
67
|
+
const abort = linkedAbort(
|
|
68
|
+
input.signal,
|
|
69
|
+
binding.renderer.limits.timeoutSeconds,
|
|
70
|
+
);
|
|
71
|
+
const records = governedRecords(
|
|
72
|
+
input,
|
|
73
|
+
binding.renderer.limits.maxRecords,
|
|
74
|
+
abort.signal,
|
|
75
|
+
);
|
|
76
|
+
let artifact: GovernedExportRenderedArtifact;
|
|
77
|
+
let body: ReadableStream<Uint8Array>;
|
|
78
|
+
try {
|
|
79
|
+
artifact = await raceAbort(
|
|
80
|
+
Promise.resolve(
|
|
81
|
+
binding.renderer.render(
|
|
82
|
+
{ records: records.iterable, watermark },
|
|
83
|
+
{ signal: abort.signal, heartbeat: input.heartbeat },
|
|
84
|
+
),
|
|
85
|
+
),
|
|
86
|
+
abort.signal,
|
|
87
|
+
);
|
|
88
|
+
validateContentMetadata(artifact.contentMetadata);
|
|
89
|
+
body = limitOutput(
|
|
90
|
+
artifact.body,
|
|
91
|
+
binding.renderer.limits.maxOutputBytes,
|
|
92
|
+
abort.signal,
|
|
93
|
+
);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
abort.dispose();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
body,
|
|
100
|
+
contentMetadata: artifact.contentMetadata,
|
|
101
|
+
assertRecordsConsumed() {
|
|
102
|
+
if (!records.consumed()) throw new Error("EXPORT_RECORDS_NOT_CONSUMED");
|
|
103
|
+
},
|
|
104
|
+
dispose: abort.dispose,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function governedRecords(
|
|
109
|
+
input: RenderContext,
|
|
110
|
+
maxRecords: number,
|
|
111
|
+
signal: AbortSignal,
|
|
112
|
+
) {
|
|
113
|
+
let completed = 0;
|
|
114
|
+
let finished = false;
|
|
115
|
+
async function* stream() {
|
|
116
|
+
const pageStream = input.source.pages(input.request, signal);
|
|
117
|
+
const pages = pageStream[Symbol.asyncIterator]();
|
|
118
|
+
try {
|
|
119
|
+
while (true) {
|
|
120
|
+
throwIfAborted(signal);
|
|
121
|
+
await input.beforePage();
|
|
122
|
+
const page = await raceAbort(pages.next(), signal);
|
|
123
|
+
if (page.done) {
|
|
124
|
+
finished = true;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const record of page.value) {
|
|
128
|
+
completed += 1;
|
|
129
|
+
if (completed > maxRecords) {
|
|
130
|
+
throw new Error("EXPORT_RECORD_LIMIT_EXCEEDED");
|
|
131
|
+
}
|
|
132
|
+
yield record as Readonly<Record<string, JsonValue>>;
|
|
133
|
+
}
|
|
134
|
+
await input.pageCompleted(completed);
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
await pages.return?.();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { iterable: stream(), consumed: () => finished };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function limitOutput(
|
|
144
|
+
body: ReadableStream<Uint8Array>,
|
|
145
|
+
maxBytes: number,
|
|
146
|
+
signal: AbortSignal,
|
|
147
|
+
) {
|
|
148
|
+
const reader = body.getReader();
|
|
149
|
+
let bytes = 0;
|
|
150
|
+
return new ReadableStream<Uint8Array>({
|
|
151
|
+
async pull(controller) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await raceAbort(reader.read(), signal);
|
|
154
|
+
if (result.done) return controller.close();
|
|
155
|
+
bytes += result.value.byteLength;
|
|
156
|
+
if (bytes > maxBytes) throw new Error("EXPORT_OUTPUT_LIMIT_EXCEEDED");
|
|
157
|
+
controller.enqueue(result.value);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
await reader.cancel(error).catch(() => undefined);
|
|
160
|
+
controller.error(error);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
cancel(reason) {
|
|
164
|
+
return reader.cancel(reason);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function linkedAbort(parent: AbortSignal, timeoutSeconds: number) {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const onAbort = () => controller.abort(parent.reason ?? "cancelled");
|
|
172
|
+
parent.addEventListener("abort", onAbort, { once: true });
|
|
173
|
+
if (parent.aborted) onAbort();
|
|
174
|
+
const timeout = setTimeout(
|
|
175
|
+
() => controller.abort("renderer-timeout"),
|
|
176
|
+
timeoutSeconds * 1_000,
|
|
177
|
+
);
|
|
178
|
+
return {
|
|
179
|
+
signal: controller.signal,
|
|
180
|
+
dispose() {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
parent.removeEventListener("abort", onAbort);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
188
|
+
throwIfAborted(signal);
|
|
189
|
+
return new Promise<T>((resolve, reject) => {
|
|
190
|
+
const aborted = () => reject(new Error("EXPORT_RENDERER_ABORTED"));
|
|
191
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
192
|
+
promise.then(
|
|
193
|
+
(value) => {
|
|
194
|
+
signal.removeEventListener("abort", aborted);
|
|
195
|
+
resolve(value);
|
|
196
|
+
},
|
|
197
|
+
(error) => {
|
|
198
|
+
signal.removeEventListener("abort", aborted);
|
|
199
|
+
reject(error);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function throwIfAborted(signal: AbortSignal) {
|
|
206
|
+
if (signal.aborted) throw new Error("EXPORT_RENDERER_ABORTED");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function assertRendererSnapshot(
|
|
210
|
+
renderer: ModuleGovernedExportRendererRegistration,
|
|
211
|
+
snapshot: GovernedExportRendererSnapshot,
|
|
212
|
+
) {
|
|
213
|
+
if (
|
|
214
|
+
snapshot.formatKey !== renderer.formatKey ||
|
|
215
|
+
snapshot.templateKey !== renderer.templateKey ||
|
|
216
|
+
snapshot.templateVersion !== renderer.templateVersion ||
|
|
217
|
+
snapshot.inputSchemaKey !== renderer.inputSchemaKey ||
|
|
218
|
+
snapshot.contentType !== renderer.contentType ||
|
|
219
|
+
snapshot.filenameExtension !== renderer.filenameExtension ||
|
|
220
|
+
snapshot.maxRecords !== renderer.limits.maxRecords ||
|
|
221
|
+
snapshot.maxOutputBytes !== renderer.limits.maxOutputBytes ||
|
|
222
|
+
snapshot.timeoutSeconds !== renderer.limits.timeoutSeconds
|
|
223
|
+
) {
|
|
224
|
+
throw new Error("EXPORT_RENDERER_VERSION_DRIFT");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isLegacyNdjson(snapshot: GovernedExportRendererSnapshot) {
|
|
229
|
+
return (
|
|
230
|
+
snapshot.formatKey === "ndjson" &&
|
|
231
|
+
snapshot.templateKey === "system.governed-export.ndjson" &&
|
|
232
|
+
snapshot.templateVersion === "1" &&
|
|
233
|
+
snapshot.inputSchemaKey === "system.governed-export.record.v1" &&
|
|
234
|
+
snapshot.contentType === "application/x-ndjson" &&
|
|
235
|
+
snapshot.filenameExtension === "ndjson" &&
|
|
236
|
+
snapshot.maxRecords === 50_000 &&
|
|
237
|
+
snapshot.maxOutputBytes === 10 * 1024 * 1024 &&
|
|
238
|
+
snapshot.timeoutSeconds === 900
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function validateContentMetadata(
|
|
243
|
+
metadata: Readonly<Record<string, JsonValue>> | undefined,
|
|
244
|
+
) {
|
|
245
|
+
if (
|
|
246
|
+
metadata &&
|
|
247
|
+
(!isJsonObject(metadata) || JSON.stringify(metadata).length > 16_000)
|
|
248
|
+
) {
|
|
249
|
+
throw new Error("EXPORT_CONTENT_METADATA_TOO_LARGE");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isJsonObject(value: unknown, depth = 0): boolean {
|
|
254
|
+
if (
|
|
255
|
+
depth > 8 ||
|
|
256
|
+
!value ||
|
|
257
|
+
Array.isArray(value) ||
|
|
258
|
+
typeof value !== "object"
|
|
259
|
+
) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
return Object.entries(value).every(
|
|
263
|
+
([key, item]) => key.length <= 160 && isJson(item, depth + 1),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isJson(value: unknown, depth: number): boolean {
|
|
268
|
+
if (depth > 8) return false;
|
|
269
|
+
if (
|
|
270
|
+
value === null ||
|
|
271
|
+
typeof value === "string" ||
|
|
272
|
+
typeof value === "boolean"
|
|
273
|
+
) {
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
277
|
+
if (Array.isArray(value)) {
|
|
278
|
+
return (
|
|
279
|
+
value.length <= 1_000 && value.every((item) => isJson(item, depth + 1))
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return isJsonObject(value, depth);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function watermarkOf(input: RenderContext): GovernedExportWatermarkContext {
|
|
286
|
+
return {
|
|
287
|
+
actorId: input.request.requesterId,
|
|
288
|
+
generatedAt: input.generatedAt,
|
|
289
|
+
scopeHash: input.request.scopeHash,
|
|
290
|
+
jobId: input.jobId,
|
|
291
|
+
requestId: input.request.id,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
GovernedExportServiceError,
|
|
4
|
+
legacyNdjsonRenderer,
|
|
5
|
+
selectRenderer,
|
|
6
|
+
type CreateGovernedExportInput,
|
|
7
|
+
type GovernedExportTarget,
|
|
8
|
+
} from "./service-support.js";
|
|
9
|
+
|
|
10
|
+
const input: CreateGovernedExportInput = {
|
|
11
|
+
idempotencyKey: "request-1",
|
|
12
|
+
reason: "生成正式治理报告",
|
|
13
|
+
query: {},
|
|
14
|
+
};
|
|
15
|
+
const target: GovernedExportTarget = {
|
|
16
|
+
resourceType: "reports.official",
|
|
17
|
+
policyKey: "reports.official.fields",
|
|
18
|
+
policyVersion: "1",
|
|
19
|
+
fieldKeys: ["content"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("governed export renderer selection", () => {
|
|
23
|
+
test("未声明 Renderer 的资源保持 NDJSON 兼容", () => {
|
|
24
|
+
expect(selectRenderer(input, target)).toEqual(legacyNdjsonRenderer);
|
|
25
|
+
expect(
|
|
26
|
+
selectRenderer({ ...input, formatKey: "ndjson" }, target),
|
|
27
|
+
).toEqual(legacyNdjsonRenderer);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("声明 Renderer 后必须显式选择已注册格式", () => {
|
|
31
|
+
const configured = {
|
|
32
|
+
...target,
|
|
33
|
+
renderers: [
|
|
34
|
+
{
|
|
35
|
+
formatKey: "pdf",
|
|
36
|
+
templateKey: "reports.official.pdf",
|
|
37
|
+
templateVersion: "1",
|
|
38
|
+
inputSchemaKey: "reports.official.input.v1",
|
|
39
|
+
contentType: "application/pdf",
|
|
40
|
+
filenameExtension: "pdf",
|
|
41
|
+
maxRecords: 100,
|
|
42
|
+
maxOutputBytes: 1_000_000,
|
|
43
|
+
timeoutSeconds: 60,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
expectSelectionError(configured, undefined, "EXPORT_FORMAT_REQUIRED");
|
|
48
|
+
expectSelectionError(configured, "docx", "EXPORT_FORMAT_NOT_REGISTERED");
|
|
49
|
+
expect(selectRenderer({ ...input, formatKey: "pdf" }, configured)).toEqual(
|
|
50
|
+
configured.renderers[0],
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function expectSelectionError(
|
|
56
|
+
configured: GovernedExportTarget,
|
|
57
|
+
formatKey: string | undefined,
|
|
58
|
+
code: string,
|
|
59
|
+
) {
|
|
60
|
+
try {
|
|
61
|
+
selectRenderer({ ...input, formatKey }, configured);
|
|
62
|
+
throw new Error("应拒绝 Renderer 选择");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
expect(error).toBeInstanceOf(GovernedExportServiceError);
|
|
65
|
+
expect((error as GovernedExportServiceError).code).toBe(code);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { InMemoryGovernedExportRepository } from "./repository.js";
|
|
3
|
+
import { legacyGovernedExportRendererSnapshot } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const now = new Date("2026-07-16T01:00:00.000Z");
|
|
5
6
|
|
|
@@ -25,6 +26,7 @@ describe("governed export repository", () => {
|
|
|
25
26
|
},
|
|
26
27
|
scopeHash: "a".repeat(64),
|
|
27
28
|
querySnapshot: { includeDeleted: false },
|
|
29
|
+
renderer: legacyGovernedExportRendererSnapshot,
|
|
28
30
|
approvalRequired: true,
|
|
29
31
|
expiresAt: "2026-07-17T01:00:00.000Z",
|
|
30
32
|
} satisfies Parameters<typeof repository.create>[0];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { fingerprintGovernedExportRequest } from "./request-fingerprint.js";
|
|
3
|
+
import { legacyGovernedExportRendererSnapshot } from "./types.js";
|
|
3
4
|
|
|
4
5
|
const request = {
|
|
5
6
|
resourceType: "system.user",
|
|
@@ -10,6 +11,7 @@ const request = {
|
|
|
10
11
|
reason: "合规复核用户字段",
|
|
11
12
|
scopeHash: "a".repeat(64),
|
|
12
13
|
querySnapshot: { includeDeleted: false, q: "审计" },
|
|
14
|
+
renderer: legacyGovernedExportRendererSnapshot,
|
|
13
15
|
approvalRequired: true,
|
|
14
16
|
};
|
|
15
17
|
|
|
@@ -29,6 +31,17 @@ describe("governed export request fingerprint", () => {
|
|
|
29
31
|
reason: "另一项合规复核用途",
|
|
30
32
|
}),
|
|
31
33
|
).not.toBe(first);
|
|
34
|
+
expect(
|
|
35
|
+
fingerprintGovernedExportRequest({
|
|
36
|
+
...request,
|
|
37
|
+
renderer: {
|
|
38
|
+
...legacyGovernedExportRendererSnapshot,
|
|
39
|
+
formatKey: "pdf",
|
|
40
|
+
contentType: "application/pdf",
|
|
41
|
+
filenameExtension: "pdf",
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
).not.toBe(first);
|
|
32
45
|
});
|
|
33
46
|
|
|
34
47
|
test("数据库标识和到期时间不参与同一业务请求的摘要", () => {
|
|
@@ -11,6 +11,7 @@ type FingerprintedRequest = Pick<
|
|
|
11
11
|
| "reason"
|
|
12
12
|
| "scopeHash"
|
|
13
13
|
| "querySnapshot"
|
|
14
|
+
| "renderer"
|
|
14
15
|
| "approvalRequired"
|
|
15
16
|
>;
|
|
16
17
|
|
|
@@ -26,6 +27,7 @@ export function fingerprintGovernedExportRequest(
|
|
|
26
27
|
reason: request.reason,
|
|
27
28
|
scopeHash: request.scopeHash,
|
|
28
29
|
querySnapshot: request.querySnapshot,
|
|
30
|
+
renderer: request.renderer,
|
|
29
31
|
approvalRequired: request.approvalRequired,
|
|
30
32
|
};
|
|
31
33
|
return createHash("sha256").update(stableJson(payload)).digest("hex");
|
|
@@ -112,7 +112,12 @@ async function guarded<T>(
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
function parseCreate(body: unknown) {
|
|
115
|
-
const value = strictObject(body, [
|
|
115
|
+
const value = strictObject(body, [
|
|
116
|
+
"idempotencyKey",
|
|
117
|
+
"reason",
|
|
118
|
+
"query",
|
|
119
|
+
"formatKey",
|
|
120
|
+
]);
|
|
116
121
|
const query = strictObject(value.query || {}, [
|
|
117
122
|
"q",
|
|
118
123
|
"status",
|
|
@@ -126,9 +131,11 @@ function parseCreate(body: unknown) {
|
|
|
126
131
|
) {
|
|
127
132
|
throw new GovernedExportServiceError("EXPORT_QUERY_INVALID", 400);
|
|
128
133
|
}
|
|
134
|
+
const formatKey = optionalString(value.formatKey);
|
|
129
135
|
return {
|
|
130
136
|
idempotencyKey: requiredString(value.idempotencyKey),
|
|
131
137
|
reason: requiredString(value.reason),
|
|
138
|
+
...(formatKey === undefined ? {} : { formatKey }),
|
|
132
139
|
query: {
|
|
133
140
|
q: optionalString(query.q),
|
|
134
141
|
status: status as LifecycleStatus | undefined,
|
|
@@ -200,6 +207,9 @@ function project(request: GovernedExportRequest) {
|
|
|
200
207
|
expiresAt: request.expiresAt,
|
|
201
208
|
createdAt: request.createdAt,
|
|
202
209
|
updatedAt: request.updatedAt,
|
|
210
|
+
formatKey: request.renderer.formatKey,
|
|
211
|
+
templateKey: request.renderer.templateKey,
|
|
212
|
+
templateVersion: request.renderer.templateVersion,
|
|
203
213
|
};
|
|
204
214
|
}
|
|
205
215
|
|
|
@@ -2,12 +2,17 @@ import type { GovernedExportQuerySnapshot } from "@southwind-ai/database";
|
|
|
2
2
|
import type { AuditEvent } from "@southwind-ai/shared";
|
|
3
3
|
import { auditActor, type RequestGuardContext } from "../../guards.js";
|
|
4
4
|
import type { DurableArtifactService } from "../../jobs/artifact-service.js";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
legacyGovernedExportRendererSnapshot,
|
|
7
|
+
type GovernedExportRequest,
|
|
8
|
+
type GovernedExportStatus,
|
|
9
|
+
} from "./types.js";
|
|
6
10
|
|
|
7
11
|
export interface CreateGovernedExportInput {
|
|
8
12
|
idempotencyKey: string;
|
|
9
13
|
reason: string;
|
|
10
14
|
query: GovernedExportQuerySnapshot;
|
|
15
|
+
formatKey?: string;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
export interface GovernedExportPolicy {
|
|
@@ -22,8 +27,25 @@ export interface GovernedExportTarget {
|
|
|
22
27
|
readonly fieldKeys: readonly string[];
|
|
23
28
|
readonly approvalRequired?: boolean;
|
|
24
29
|
readonly retentionSeconds?: number;
|
|
30
|
+
readonly renderers?: readonly GovernedExportRendererTarget[];
|
|
25
31
|
}
|
|
26
32
|
|
|
33
|
+
export interface GovernedExportRendererTarget {
|
|
34
|
+
readonly formatKey: string;
|
|
35
|
+
readonly templateKey: string;
|
|
36
|
+
readonly templateVersion: string;
|
|
37
|
+
readonly inputSchemaKey: string;
|
|
38
|
+
readonly contentType: string;
|
|
39
|
+
readonly filenameExtension: string;
|
|
40
|
+
readonly maxRecords: number;
|
|
41
|
+
readonly maxOutputBytes: number;
|
|
42
|
+
readonly timeoutSeconds: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const legacyNdjsonRenderer: GovernedExportRendererTarget = {
|
|
46
|
+
...legacyGovernedExportRendererSnapshot,
|
|
47
|
+
};
|
|
48
|
+
|
|
27
49
|
export const governedUserTarget: GovernedExportTarget = {
|
|
28
50
|
resourceType: "system.user",
|
|
29
51
|
policyKey: "system.user.fields",
|
|
@@ -50,6 +72,29 @@ export function validateCreate(input: CreateGovernedExportInput) {
|
|
|
50
72
|
}
|
|
51
73
|
}
|
|
52
74
|
|
|
75
|
+
export function selectRenderer(
|
|
76
|
+
input: CreateGovernedExportInput,
|
|
77
|
+
target: GovernedExportTarget,
|
|
78
|
+
): GovernedExportRendererTarget {
|
|
79
|
+
const renderers = target.renderers;
|
|
80
|
+
if (!renderers?.length) {
|
|
81
|
+
if (input.formatKey !== undefined && input.formatKey !== "ndjson") {
|
|
82
|
+
throw new GovernedExportServiceError("EXPORT_FORMAT_NOT_REGISTERED", 400);
|
|
83
|
+
}
|
|
84
|
+
return legacyNdjsonRenderer;
|
|
85
|
+
}
|
|
86
|
+
if (!input.formatKey?.trim()) {
|
|
87
|
+
throw new GovernedExportServiceError("EXPORT_FORMAT_REQUIRED", 400);
|
|
88
|
+
}
|
|
89
|
+
const renderer = renderers.find(
|
|
90
|
+
({ formatKey }) => formatKey === input.formatKey,
|
|
91
|
+
);
|
|
92
|
+
if (!renderer) {
|
|
93
|
+
throw new GovernedExportServiceError("EXPORT_FORMAT_NOT_REGISTERED", 400);
|
|
94
|
+
}
|
|
95
|
+
return renderer;
|
|
96
|
+
}
|
|
97
|
+
|
|
53
98
|
export function validateTarget(target: GovernedExportTarget) {
|
|
54
99
|
if (
|
|
55
100
|
!target.resourceType.trim() ||
|
|
@@ -83,8 +128,14 @@ export function assertArtifactMetadata(
|
|
|
83
128
|
if (
|
|
84
129
|
artifact.jobId !== request.durableJobId ||
|
|
85
130
|
artifact.ownerId !== request.requesterId ||
|
|
86
|
-
artifact.contentType !==
|
|
131
|
+
artifact.contentType !== request.renderer.contentType ||
|
|
132
|
+
!artifact.filename.endsWith(`.${request.renderer.filenameExtension}`) ||
|
|
133
|
+
artifact.metadata?.formatKey !== request.renderer.formatKey ||
|
|
134
|
+
artifact.metadata?.templateKey !== request.renderer.templateKey ||
|
|
135
|
+
artifact.metadata?.templateVersion !== request.renderer.templateVersion ||
|
|
136
|
+
artifact.metadata?.inputSchemaKey !== request.renderer.inputSchemaKey ||
|
|
87
137
|
artifact.byteSize < 0 ||
|
|
138
|
+
artifact.byteSize > request.renderer.maxOutputBytes ||
|
|
88
139
|
!/^[a-f0-9]{64}$/.test(artifact.sha256)
|
|
89
140
|
) {
|
|
90
141
|
throw new GovernedExportServiceError("EXPORT_ARTIFACT_INVALID", 409);
|
|
@@ -113,7 +164,7 @@ export function exportEvent(
|
|
|
113
164
|
export function downloadEvent(
|
|
114
165
|
context: RequestGuardContext,
|
|
115
166
|
request: GovernedExportRequest,
|
|
116
|
-
|
|
167
|
+
artifact: Awaited<ReturnType<DurableArtifactService["inspect"]>>,
|
|
117
168
|
phase: "intent" | "authorized",
|
|
118
169
|
) {
|
|
119
170
|
return exportEvent(
|
|
@@ -124,7 +175,13 @@ export function downloadEvent(
|
|
|
124
175
|
phase,
|
|
125
176
|
scopeHash: request.scopeHash,
|
|
126
177
|
jobId: request.durableJobId,
|
|
127
|
-
sha256,
|
|
178
|
+
sha256: artifact.sha256,
|
|
179
|
+
byteSize: artifact.byteSize,
|
|
180
|
+
filename: artifact.filename,
|
|
181
|
+
contentType: artifact.contentType,
|
|
182
|
+
formatKey: request.renderer.formatKey,
|
|
183
|
+
templateKey: request.renderer.templateKey,
|
|
184
|
+
templateVersion: request.renderer.templateVersion,
|
|
128
185
|
},
|
|
129
186
|
request.resourceType,
|
|
130
187
|
);
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
exportEvent,
|
|
20
20
|
GovernedExportServiceError,
|
|
21
21
|
governedUserTarget,
|
|
22
|
+
selectRenderer,
|
|
22
23
|
validateCreate,
|
|
23
24
|
validateTarget,
|
|
24
25
|
type CreateGovernedExportInput,
|
|
@@ -54,6 +55,7 @@ export class GovernedExportService {
|
|
|
54
55
|
) {
|
|
55
56
|
validateCreate(input);
|
|
56
57
|
validateTarget(target);
|
|
58
|
+
const renderer = selectRenderer(input, target);
|
|
57
59
|
const scope = captureGovernedExportScope(context.actor.scopeContext);
|
|
58
60
|
const id = Bun.randomUUIDv7();
|
|
59
61
|
const retentionSeconds = Math.min(
|
|
@@ -76,6 +78,7 @@ export class GovernedExportService {
|
|
|
76
78
|
scopeSnapshot: scope.snapshot,
|
|
77
79
|
scopeHash: scope.hash,
|
|
78
80
|
querySnapshot: structuredClone(input.query),
|
|
81
|
+
renderer: structuredClone(renderer),
|
|
79
82
|
approvalRequired: target.approvalRequired ?? this.policy.approvalRequired,
|
|
80
83
|
expiresAt,
|
|
81
84
|
};
|
|
@@ -99,6 +102,9 @@ export class GovernedExportService {
|
|
|
99
102
|
policyVersion: created.request.policyVersion,
|
|
100
103
|
scopeHash: created.request.scopeHash,
|
|
101
104
|
requestFingerprint: created.request.requestFingerprint,
|
|
105
|
+
formatKey: created.request.renderer.formatKey,
|
|
106
|
+
templateKey: created.request.renderer.templateKey,
|
|
107
|
+
templateVersion: created.request.renderer.templateVersion,
|
|
102
108
|
replayed: !created.created,
|
|
103
109
|
},
|
|
104
110
|
created.request.resourceType,
|
|
@@ -173,12 +179,12 @@ export class GovernedExportService {
|
|
|
173
179
|
);
|
|
174
180
|
assertArtifactMetadata(artifact, request);
|
|
175
181
|
await this.audit.recordCriticalEvent(
|
|
176
|
-
downloadEvent(context, request, artifact
|
|
182
|
+
downloadEvent(context, request, artifact, "intent"),
|
|
177
183
|
);
|
|
178
184
|
const opened = await this.artifacts.openInspected(artifact);
|
|
179
185
|
try {
|
|
180
186
|
await this.audit.recordCriticalEvent(
|
|
181
|
-
downloadEvent(context, request, artifact
|
|
187
|
+
downloadEvent(context, request, artifact, "authorized"),
|
|
182
188
|
);
|
|
183
189
|
} catch (error) {
|
|
184
190
|
await opened.body.cancel().catch(() => undefined);
|
|
@@ -26,6 +26,7 @@ export interface GovernedExportRequest {
|
|
|
26
26
|
scopeSnapshot: GovernedExportScopeSnapshot;
|
|
27
27
|
scopeHash: string;
|
|
28
28
|
querySnapshot: GovernedExportQuerySnapshot;
|
|
29
|
+
renderer: GovernedExportRendererSnapshot;
|
|
29
30
|
status: GovernedExportStatus;
|
|
30
31
|
approvalRequired: boolean;
|
|
31
32
|
approvalId?: string;
|
|
@@ -49,7 +50,33 @@ export interface CreateGovernedExportRequest {
|
|
|
49
50
|
scopeSnapshot: GovernedExportScopeSnapshot;
|
|
50
51
|
scopeHash: string;
|
|
51
52
|
querySnapshot: GovernedExportQuerySnapshot;
|
|
53
|
+
renderer: GovernedExportRendererSnapshot;
|
|
52
54
|
approvalRequired: boolean;
|
|
53
55
|
approvalId?: string;
|
|
54
56
|
expiresAt: string;
|
|
55
57
|
}
|
|
58
|
+
|
|
59
|
+
export interface GovernedExportRendererSnapshot {
|
|
60
|
+
formatKey: string;
|
|
61
|
+
templateKey: string;
|
|
62
|
+
templateVersion: string;
|
|
63
|
+
inputSchemaKey: string;
|
|
64
|
+
contentType: string;
|
|
65
|
+
filenameExtension: string;
|
|
66
|
+
maxRecords: number;
|
|
67
|
+
maxOutputBytes: number;
|
|
68
|
+
timeoutSeconds: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const legacyGovernedExportRendererSnapshot: GovernedExportRendererSnapshot =
|
|
72
|
+
{
|
|
73
|
+
formatKey: "ndjson",
|
|
74
|
+
templateKey: "system.governed-export.ndjson",
|
|
75
|
+
templateVersion: "1",
|
|
76
|
+
inputSchemaKey: "system.governed-export.record.v1",
|
|
77
|
+
contentType: "application/x-ndjson",
|
|
78
|
+
filenameExtension: "ndjson",
|
|
79
|
+
maxRecords: 50_000,
|
|
80
|
+
maxOutputBytes: 10 * 1024 * 1024,
|
|
81
|
+
timeoutSeconds: 900,
|
|
82
|
+
};
|
|
@@ -307,6 +307,7 @@ installModuleHttpRuntime(moduleHosts, app, {
|
|
|
307
307
|
features,
|
|
308
308
|
// @windy-module system.scheduler begin
|
|
309
309
|
jobs: durableJobRuntime.service,
|
|
310
|
+
jobWorker: durableJobRuntime.worker,
|
|
310
311
|
// @windy-module system.scheduler end
|
|
311
312
|
// @windy-module system.storage begin
|
|
312
313
|
storage: storageRuntime,
|