@legionworks/facet 1.9.0 → 1.10.1
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 +50 -21
- package/dist/gallery/{chunk-c6s9k9ty.js → chunk-0by8qn4x.js} +86 -3
- package/dist/gallery/{chunk-4g8rem85.css → chunk-2dge4gwb.css} +22 -1
- package/dist/gallery/frame/artifact.css +3 -0
- package/dist/gallery/frame/chunks/{markdown-fvfncadf.js → markdown-g3asgaw3.js} +8 -0
- package/dist/gallery/frame/frame.css +11 -24
- package/dist/gallery/frame/runtime/chart.js +24 -2
- package/dist/gallery/frame/runtime/html.js +1 -1
- package/dist/gallery/frame/runtime/markdown.js +1 -1
- package/dist/gallery/frame/runtime/mermaid.js +1 -1
- package/dist/gallery/frame/runtime/svg.js +1 -1
- package/dist/gallery/frame/runtime/tsx.js +1 -1
- package/dist/gallery/index.html +4 -2
- package/docs/reference/cli.md +30 -2
- package/docs/reference/export.md +4 -0
- package/docs/reference/html.md +3 -3
- package/docs/reference/mcp.md +29 -8
- package/docs/reference/security.md +7 -0
- package/docs/reference/storage.md +6 -1
- package/docs/reference/tsx.md +16 -0
- package/docs/reference/validation.md +34 -6
- package/package.json +4 -4
- package/skills/facet/SKILL.md +24 -2
- package/src/cli/commands/promote.ts +1 -0
- package/src/cli/commands/templates.ts +25 -0
- package/src/cli/main.ts +4 -0
- package/src/cli/parser.ts +4 -0
- package/src/cli/presenter.ts +48 -0
- package/src/gallery-web/app.ts +65 -2
- package/src/gallery-web/favicon.ts +1 -0
- package/src/gallery-web/frame/renderers/chart.ts +31 -1
- package/src/gallery-web/frame/runtime.ts +7 -1
- package/src/gallery-web/frame/styles/artifact.css +3 -0
- package/src/gallery-web/frame/styles/frame.css +11 -24
- package/src/gallery-web/frame-html.ts +7 -2
- package/src/gallery-web/index.html +3 -1
- package/src/gallery-web/styles/verdict.css +19 -4
- package/src/harness-adapters/mcp/cli-bridge.ts +11 -1
- package/src/harness-adapters/mcp/main.ts +2 -0
- package/src/harness-adapters/mcp/server.ts +92 -69
- package/src/harness-adapters/mcp/tool-schemas.ts +7 -0
- package/src/service/dispatcher.ts +22 -0
- package/src/service/router-guards.ts +2 -0
- package/src/service/store/migrations.ts +12 -2
- package/src/service/store/repository-lifecycle.ts +91 -19
- package/src/service/store/repository.ts +60 -2
- package/src/service/store/schema.ts +4 -0
- package/src/service/stored-verdict.ts +3 -0
- package/src/shared/contracts/artifact.ts +1 -0
- package/src/shared/contracts/commands/index.ts +10 -0
- package/src/shared/contracts/commands/names.ts +2 -0
- package/src/shared/contracts/commands/requests.ts +7 -0
- package/src/shared/contracts/commands/results.ts +20 -1
- package/src/shared/contracts/promotion.ts +16 -0
- package/src/shared/contracts/validation.ts +7 -0
- package/src/shared/errors/facet-error.ts +2 -0
- package/src/shared/errors/store-error.ts +4 -0
- package/src/shared/html/artifact-main.ts +3 -0
- package/src/shared/html/style-vocabulary.ts +1 -0
- package/src/shared/storage-version.ts +1 -1
- package/src/validation/tier0/dom-shim.ts +3 -18
- package/src/validation/tier0/markdown.ts +28 -5
- package/src/validation/tier0/mermaid.ts +17 -8
- package/src/validation/tier0/worker-dispatch.ts +1 -1
- package/src/validation/tier1/entries/tsx.ts +1 -0
- package/src/validation/tier1/harness.ts +2 -1
- package/src/validation/tier1/isolated-probe.ts +4 -0
- package/src/validation/tier1/protocol-probe.ts +37 -0
- package/src/validation/tier1/runner.ts +21 -4
- package/src/validation/tier1/verdict.ts +24 -39
- package/templates/capacity-report.tsx +1 -1
- package/templates/fleet-dashboard.html +1 -1
- package/templates/html-release-ledger.html +1 -1
- package/templates/html-status-report.html +1 -1
- package/templates/incident-console.tsx +1 -1
- package/templates/tsx-interactive-counter.tsx +1 -1
- package/templates/tsx-status-report.tsx +1 -1
|
@@ -2,6 +2,11 @@ import type { Database } from "bun:sqlite";
|
|
|
2
2
|
|
|
3
3
|
import { type Template, TemplateSchema } from "../../shared/contracts/artifact";
|
|
4
4
|
import { now } from "../../shared/util/time";
|
|
5
|
+
import { FacetError } from "../../shared/errors/facet-error";
|
|
6
|
+
import { PROMOTION_GATE } from "../../shared/contracts/promotion";
|
|
7
|
+
import type { RenderStatus } from "../../shared/contracts/validation";
|
|
8
|
+
import { verdictFromStoredRun } from "../stored-verdict";
|
|
9
|
+
import type { ArtifactRepository } from "./repository";
|
|
5
10
|
import { asStoreError, FacetStoreError } from "./database";
|
|
6
11
|
|
|
7
12
|
export interface TemplateInput {
|
|
@@ -11,6 +16,7 @@ export interface TemplateInput {
|
|
|
11
16
|
readonly description?: string | null;
|
|
12
17
|
readonly promotedBy: string;
|
|
13
18
|
readonly promotedAt?: string;
|
|
19
|
+
readonly promotionOverride?: string | null;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
export interface PromoteRevisionInput {
|
|
@@ -20,6 +26,7 @@ export interface PromoteRevisionInput {
|
|
|
20
26
|
readonly description?: string | null;
|
|
21
27
|
readonly promotedBy: string;
|
|
22
28
|
readonly promotedAt?: string;
|
|
29
|
+
readonly allowUnverified?: boolean;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export function evictRevisions(
|
|
@@ -59,23 +66,75 @@ export function evictRevisions(
|
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
export function promoteRevision(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
69
|
+
export function promoteRevision(
|
|
70
|
+
db: Database,
|
|
71
|
+
repository: ArtifactRepository,
|
|
72
|
+
input: PromoteRevisionInput,
|
|
73
|
+
): Template {
|
|
74
|
+
return db
|
|
75
|
+
.transaction(() => {
|
|
76
|
+
const owner = db
|
|
77
|
+
.query("SELECT artifact_id FROM revisions WHERE id = ?")
|
|
78
|
+
.get(input.revisionId) as { artifact_id: string } | null;
|
|
79
|
+
if (!owner)
|
|
80
|
+
throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
|
|
81
|
+
// The template table has independent foreign keys on artifact_id and
|
|
82
|
+
// revision_id, not a composite ownership constraint, so an explicit
|
|
83
|
+
// artifactId that disagrees with the revision's real owner would
|
|
84
|
+
// otherwise insert silently — instantiation would then copy the wrong
|
|
85
|
+
// artifact's bytes under the caller-supplied artifact's identity.
|
|
86
|
+
if (input.artifactId !== undefined && input.artifactId !== owner.artifact_id) {
|
|
87
|
+
throw new FacetStoreError(
|
|
88
|
+
"foreign_key",
|
|
89
|
+
`Revision ${input.revisionId} belongs to artifact ${owner.artifact_id}, not ${input.artifactId}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
const revision = repository.getRevisionById(input.revisionId);
|
|
93
|
+
if (revision === null)
|
|
94
|
+
throw new FacetStoreError("foreign_key", `Revision not found: ${input.revisionId}`);
|
|
95
|
+
const tier1 = repository.listRenderRuns({ revisionId: revision.id, tier: 1 })[0];
|
|
96
|
+
const tier0 = repository.listRenderRuns({ revisionId: revision.id, tier: 0 })[0];
|
|
97
|
+
// Read the marker from the reconstructed verdict object rather than
|
|
98
|
+
// the raw `insecure_json` column — `verdictFromStoredRun` is the single
|
|
99
|
+
// source of truth for cross-boundary reconstruction and keeps the
|
|
100
|
+
// precedence between status, tier, and marker consistent with the
|
|
101
|
+
// dispatcher's wire form.
|
|
102
|
+
const tier1Verdict = tier1 === undefined ? null : verdictFromStoredRun(revision, tier1);
|
|
103
|
+
const tier0Verdict = tier0 === undefined ? null : verdictFromStoredRun(revision, tier0);
|
|
104
|
+
const tier1Status = tier1Verdict === null ? null : tier1Verdict.status;
|
|
105
|
+
const tier0Status = tier0Verdict === null ? null : tier0Verdict.status;
|
|
106
|
+
// Level 3 opts out of the validation contract (publish skips Tier 0). A
|
|
107
|
+
// visual read-back still runs the browser, but a result carrying
|
|
108
|
+
// `insecure.level === 3` is not trusted verification for a template, so
|
|
109
|
+
// refuse even when the status alone would allow. Levels 1 and 2 keep the
|
|
110
|
+
// status decision: they run real validators under relaxed sandboxing.
|
|
111
|
+
const insecureLevel3 = tier1Verdict !== null && tier1Verdict.insecure?.level === 3;
|
|
112
|
+
const reason =
|
|
113
|
+
tier1Status === null
|
|
114
|
+
? "no_visual_verification"
|
|
115
|
+
: tier0Status === "error"
|
|
116
|
+
? "error"
|
|
117
|
+
: insecureLevel3
|
|
118
|
+
? "insecure:unvalidated"
|
|
119
|
+
: PROMOTION_GATE[tier1Status as RenderStatus] === "allow"
|
|
120
|
+
? null
|
|
121
|
+
: tier1Status;
|
|
122
|
+
if (reason !== null && input.allowUnverified !== true) {
|
|
123
|
+
throw new FacetError(
|
|
124
|
+
"promotion_refused",
|
|
125
|
+
`Promotion refused (${reason}). Run facet read-back --artifact-id ${owner.artifact_id} --tier visual on revision ${input.revisionId}, or pass --allow-unverified to record an override.`,
|
|
126
|
+
{
|
|
127
|
+
details: { revisionId: input.revisionId, reason, tier1Status, tier0Status },
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return createTemplate(db, {
|
|
132
|
+
...input,
|
|
133
|
+
artifactId: owner.artifact_id,
|
|
134
|
+
promotionOverride: reason,
|
|
135
|
+
});
|
|
136
|
+
})
|
|
137
|
+
.immediate();
|
|
79
138
|
}
|
|
80
139
|
|
|
81
140
|
export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
@@ -88,10 +147,11 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
|
88
147
|
description: input.description ?? null,
|
|
89
148
|
promotedBy: input.promotedBy,
|
|
90
149
|
promotedAt,
|
|
150
|
+
promotionOverride: input.promotionOverride ?? null,
|
|
91
151
|
};
|
|
92
152
|
try {
|
|
93
153
|
db.query(
|
|
94
|
-
"INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
154
|
+
"INSERT INTO templates(id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
95
155
|
).run(
|
|
96
156
|
value.id,
|
|
97
157
|
value.artifactId,
|
|
@@ -100,10 +160,22 @@ export function createTemplate(db: Database, input: TemplateInput): Template {
|
|
|
100
160
|
value.description,
|
|
101
161
|
value.promotedBy,
|
|
102
162
|
value.promotedAt,
|
|
163
|
+
value.promotionOverride,
|
|
103
164
|
);
|
|
104
165
|
return TemplateSchema.parse(value);
|
|
105
166
|
} catch (error) {
|
|
106
|
-
|
|
167
|
+
const mapped = asStoreError(error);
|
|
168
|
+
if (mapped.code === "template_name_taken") {
|
|
169
|
+
throw new FacetStoreError(
|
|
170
|
+
"template_name_taken",
|
|
171
|
+
`Template name already exists: ${input.name}`,
|
|
172
|
+
{
|
|
173
|
+
cause: error,
|
|
174
|
+
details: { name: input.name },
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
throw mapped;
|
|
107
179
|
}
|
|
108
180
|
}
|
|
109
181
|
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type TemplateInput,
|
|
33
33
|
} from "./repository-lifecycle";
|
|
34
34
|
import { enforceEvidenceRetention, removeUnreferencedEvidence } from "./evidence-retention";
|
|
35
|
+
import { latestStoredVerdict } from "../stored-verdict";
|
|
35
36
|
|
|
36
37
|
interface ProjectInput {
|
|
37
38
|
readonly projectRoot: string;
|
|
@@ -407,7 +408,7 @@ export class ArtifactRepository {
|
|
|
407
408
|
try {
|
|
408
409
|
const row = this.db
|
|
409
410
|
.query(
|
|
410
|
-
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
|
|
411
|
+
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates WHERE name = ? ORDER BY promoted_at DESC LIMIT 1",
|
|
411
412
|
)
|
|
412
413
|
.get(name) as {
|
|
413
414
|
id: string;
|
|
@@ -417,6 +418,7 @@ export class ArtifactRepository {
|
|
|
417
418
|
description: string | null;
|
|
418
419
|
promoted_by: string;
|
|
419
420
|
promoted_at: string;
|
|
421
|
+
promotion_override: string | null;
|
|
420
422
|
} | null;
|
|
421
423
|
if (!row) return null;
|
|
422
424
|
return TemplateSchema.parse({
|
|
@@ -427,12 +429,68 @@ export class ArtifactRepository {
|
|
|
427
429
|
description: row.description,
|
|
428
430
|
promotedBy: row.promoted_by,
|
|
429
431
|
promotedAt: row.promoted_at,
|
|
432
|
+
promotionOverride: row.promotion_override,
|
|
430
433
|
});
|
|
431
434
|
} catch (error) {
|
|
432
435
|
throw asStoreError(error);
|
|
433
436
|
}
|
|
434
437
|
}
|
|
435
438
|
|
|
439
|
+
listTemplates(limit = DEFAULT_LIST_LIMIT): Array<{
|
|
440
|
+
name: string;
|
|
441
|
+
artifactId: string;
|
|
442
|
+
revisionId: string;
|
|
443
|
+
revisionSha: string;
|
|
444
|
+
promotedBy: string;
|
|
445
|
+
promotedAt: string;
|
|
446
|
+
promotionOverride: string | null;
|
|
447
|
+
sourceVerdict: {
|
|
448
|
+
status: import("../../shared/contracts/validation").RenderStatus;
|
|
449
|
+
tier: 0 | 1;
|
|
450
|
+
} | null;
|
|
451
|
+
}> {
|
|
452
|
+
const rows = this.db
|
|
453
|
+
.query(
|
|
454
|
+
"SELECT id, artifact_id, revision_id, name, description, promoted_by, promoted_at, promotion_override FROM templates ORDER BY promoted_at DESC, id DESC LIMIT ?",
|
|
455
|
+
)
|
|
456
|
+
.all(limit) as Array<{
|
|
457
|
+
id: string;
|
|
458
|
+
artifact_id: string;
|
|
459
|
+
revision_id: string;
|
|
460
|
+
name: string;
|
|
461
|
+
description: string | null;
|
|
462
|
+
promoted_by: string;
|
|
463
|
+
promoted_at: string;
|
|
464
|
+
promotion_override: string | null;
|
|
465
|
+
}>;
|
|
466
|
+
return rows.map((row) => {
|
|
467
|
+
const revision = this.getRevisionById(row.revision_id);
|
|
468
|
+
if (revision === null)
|
|
469
|
+
throw new FacetStoreError("foreign_key", `Template revision not found: ${row.revision_id}`);
|
|
470
|
+
const template = TemplateSchema.parse({
|
|
471
|
+
id: row.id,
|
|
472
|
+
artifactId: row.artifact_id,
|
|
473
|
+
revisionId: row.revision_id,
|
|
474
|
+
name: row.name,
|
|
475
|
+
description: row.description,
|
|
476
|
+
promotedBy: row.promoted_by,
|
|
477
|
+
promotedAt: row.promoted_at,
|
|
478
|
+
promotionOverride: row.promotion_override,
|
|
479
|
+
});
|
|
480
|
+
const verdict = latestStoredVerdict(this, revision);
|
|
481
|
+
return {
|
|
482
|
+
name: template.name,
|
|
483
|
+
artifactId: template.artifactId,
|
|
484
|
+
revisionId: template.revisionId,
|
|
485
|
+
revisionSha: revision.sha256,
|
|
486
|
+
promotedBy: template.promotedBy,
|
|
487
|
+
promotedAt: template.promotedAt,
|
|
488
|
+
promotionOverride: template.promotionOverride,
|
|
489
|
+
sourceVerdict: verdict === null ? null : { status: verdict.status, tier: verdict.tier },
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
436
494
|
createArtifact(input: ArtifactInput): Artifact {
|
|
437
495
|
const createdAt = now();
|
|
438
496
|
const value = { id: crypto.randomUUID(), ...input, createdAt, updatedAt: createdAt };
|
|
@@ -715,7 +773,7 @@ export class ArtifactRepository {
|
|
|
715
773
|
}
|
|
716
774
|
|
|
717
775
|
promoteRevision(input: PromoteRevisionInput): Template {
|
|
718
|
-
return promoteLifecycleRevision(this.db, input);
|
|
776
|
+
return promoteLifecycleRevision(this.db, this, input);
|
|
719
777
|
}
|
|
720
778
|
|
|
721
779
|
instantiateTemplate(input: TemplateInput): Template {
|
|
@@ -216,3 +216,7 @@ export const V9_SCHEMA_FRAGMENT = `
|
|
|
216
216
|
ALTER TABLE render_runs ADD COLUMN screenshot_format TEXT
|
|
217
217
|
CHECK(screenshot_format IN ('png','webp'));
|
|
218
218
|
`;
|
|
219
|
+
|
|
220
|
+
export const V10_SCHEMA_FRAGMENT = `
|
|
221
|
+
ALTER TABLE templates ADD COLUMN promotion_override TEXT;
|
|
222
|
+
`;
|
|
@@ -39,6 +39,9 @@ export function withTolerantObserved(parsed: unknown): VerdictObserved {
|
|
|
39
39
|
visibleSvgCount: num("visibleSvgCount", 0),
|
|
40
40
|
opaqueRegionCount: num("opaqueRegionCount", 0),
|
|
41
41
|
externalImageCount: num("externalImageCount", 0),
|
|
42
|
+
...(typeof observed.emptyRendererRoot === "boolean"
|
|
43
|
+
? { emptyRendererRoot: observed.emptyRendererRoot }
|
|
44
|
+
: {}),
|
|
42
45
|
// errorCount is required on the schema; pre-arc rows that never
|
|
43
46
|
// recorded it default to 0.
|
|
44
47
|
errorCount: num("errorCount", 0),
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
CreateRequestSchema,
|
|
34
34
|
InstantiateRequestSchema,
|
|
35
35
|
ListRequestSchema,
|
|
36
|
+
TemplatesRequestSchema,
|
|
36
37
|
OpenRequestSchema,
|
|
37
38
|
PinRequestSchema,
|
|
38
39
|
PromoteRequestSchema,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
type CreateRequest,
|
|
46
47
|
type InstantiateRequest,
|
|
47
48
|
type ListRequest,
|
|
49
|
+
type TemplatesRequest,
|
|
48
50
|
type OpenRequest,
|
|
49
51
|
type PinRequest,
|
|
50
52
|
type PromoteRequest,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
CreateResultSchema,
|
|
59
61
|
InstantiateResultSchema,
|
|
60
62
|
ListResultSchema,
|
|
63
|
+
TemplatesResultSchema,
|
|
61
64
|
OpenResultSchema,
|
|
62
65
|
PinResultSchema,
|
|
63
66
|
PromoteResultSchema,
|
|
@@ -72,6 +75,7 @@ import {
|
|
|
72
75
|
type CreateResult,
|
|
73
76
|
type InstantiateResult,
|
|
74
77
|
type ListResult,
|
|
78
|
+
type TemplatesResult,
|
|
75
79
|
type OpenResult,
|
|
76
80
|
type PinResult,
|
|
77
81
|
type PromoteResult,
|
|
@@ -98,6 +102,7 @@ export const CommandRequestSchema = z.discriminatedUnion("command", [
|
|
|
98
102
|
CreateRequestSchema,
|
|
99
103
|
PublishRequestSchema,
|
|
100
104
|
ListRequestSchema,
|
|
105
|
+
TemplatesRequestSchema,
|
|
101
106
|
ReadBackRequestSchema,
|
|
102
107
|
StatusRequestSchema,
|
|
103
108
|
OpenRequestSchema,
|
|
@@ -112,6 +117,7 @@ export const CommandResultSchema = z.discriminatedUnion("command", [
|
|
|
112
117
|
CreateResultSchema,
|
|
113
118
|
PublishResultSchema,
|
|
114
119
|
ListResultSchema,
|
|
120
|
+
TemplatesResultSchema,
|
|
115
121
|
ReadBackResultSchema,
|
|
116
122
|
StatusResultSchema,
|
|
117
123
|
OpenResultSchema,
|
|
@@ -145,6 +151,7 @@ export {
|
|
|
145
151
|
PublishRequestSchema,
|
|
146
152
|
PublishArtifactTypeSchema,
|
|
147
153
|
ListRequestSchema,
|
|
154
|
+
TemplatesRequestSchema,
|
|
148
155
|
ReadBackRequestSchema,
|
|
149
156
|
StatusRequestSchema,
|
|
150
157
|
OpenRequestSchema,
|
|
@@ -157,6 +164,7 @@ export {
|
|
|
157
164
|
CreateResultSchema,
|
|
158
165
|
PublishResultSchema,
|
|
159
166
|
ListResultSchema,
|
|
167
|
+
TemplatesResultSchema,
|
|
160
168
|
ReadBackResultSchema,
|
|
161
169
|
StatusResultSchema,
|
|
162
170
|
OpenResultSchema,
|
|
@@ -180,6 +188,7 @@ export type {
|
|
|
180
188
|
CreateRequest,
|
|
181
189
|
PublishRequest,
|
|
182
190
|
ListRequest,
|
|
191
|
+
TemplatesRequest,
|
|
183
192
|
ReadBackRequest,
|
|
184
193
|
StatusRequest,
|
|
185
194
|
OpenRequest,
|
|
@@ -191,6 +200,7 @@ export type {
|
|
|
191
200
|
CreateResult,
|
|
192
201
|
PublishResult,
|
|
193
202
|
ListResult,
|
|
203
|
+
TemplatesResult,
|
|
194
204
|
ReadBackResult,
|
|
195
205
|
StatusResult,
|
|
196
206
|
DoctorResult,
|
|
@@ -9,6 +9,7 @@ export const CommandNameSchema = z.enum([
|
|
|
9
9
|
"create",
|
|
10
10
|
"publish",
|
|
11
11
|
"list",
|
|
12
|
+
"templates",
|
|
12
13
|
"readBack",
|
|
13
14
|
"status",
|
|
14
15
|
"open",
|
|
@@ -23,6 +24,7 @@ export const IMPLEMENTED_COMMANDS: readonly CommandName[] = [
|
|
|
23
24
|
"create",
|
|
24
25
|
"publish",
|
|
25
26
|
"list",
|
|
27
|
+
"templates",
|
|
26
28
|
"readBack",
|
|
27
29
|
"status",
|
|
28
30
|
"open",
|
|
@@ -82,6 +82,12 @@ export const ListRequestSchema = BaseRequestSchema.extend({
|
|
|
82
82
|
});
|
|
83
83
|
export type ListRequest = z.infer<typeof ListRequestSchema>;
|
|
84
84
|
|
|
85
|
+
export const TemplatesRequestSchema = BaseRequestSchema.extend({
|
|
86
|
+
command: z.literal("templates"),
|
|
87
|
+
limit: z.number().int().positive().max(MAX_LIST_LIMIT).optional(),
|
|
88
|
+
});
|
|
89
|
+
export type TemplatesRequest = z.infer<typeof TemplatesRequestSchema>;
|
|
90
|
+
|
|
85
91
|
export const ReadBackRequestSchema = BaseRequestSchema.extend({
|
|
86
92
|
command: z.literal("readBack"),
|
|
87
93
|
artifactId: z.string().min(1),
|
|
@@ -116,6 +122,7 @@ export const PromoteRequestSchema = BaseRequestSchema.extend({
|
|
|
116
122
|
name: z.string().min(1),
|
|
117
123
|
description: z.string().nullable().optional(),
|
|
118
124
|
promotedBy: z.string().min(1),
|
|
125
|
+
allowUnverified: z.boolean().optional(),
|
|
119
126
|
});
|
|
120
127
|
export type PromoteRequest = z.infer<typeof PromoteRequestSchema>;
|
|
121
128
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { Tier1ResultSchema, VerdictSchema } from "../validation";
|
|
3
|
+
import { Tier1ResultSchema, VerdictSchema, RenderStatusSchema } from "../validation";
|
|
4
4
|
import { ArtifactTypeSchema, RendererSchema } from "../artifact";
|
|
5
5
|
import { EvidenceImageFormatSchema } from "../../evidence-image";
|
|
6
6
|
import { ExportFormatSchema } from "./requests";
|
|
@@ -64,6 +64,25 @@ export const ListResultSchema = BaseResultSchema.extend({
|
|
|
64
64
|
});
|
|
65
65
|
export type ListResult = z.infer<typeof ListResultSchema>;
|
|
66
66
|
|
|
67
|
+
export const TemplatesResultSchema = BaseResultSchema.extend({
|
|
68
|
+
command: z.literal("templates"),
|
|
69
|
+
templates: z.array(
|
|
70
|
+
z.object({
|
|
71
|
+
name: z.string().min(1),
|
|
72
|
+
artifactId: z.string().min(1),
|
|
73
|
+
revisionId: z.string().min(1),
|
|
74
|
+
revisionSha: z.string().regex(/^[a-f0-9]{64}$/),
|
|
75
|
+
promotedBy: z.string().min(1),
|
|
76
|
+
promotedAt: z.string().datetime({ offset: true }),
|
|
77
|
+
promotionOverride: z.string().nullable(),
|
|
78
|
+
sourceVerdict: z
|
|
79
|
+
.object({ status: RenderStatusSchema, tier: z.union([z.literal(0), z.literal(1)]) })
|
|
80
|
+
.nullable(),
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
export type TemplatesResult = z.infer<typeof TemplatesResultSchema>;
|
|
85
|
+
|
|
67
86
|
/**
|
|
68
87
|
* Read-back result embeds the canonical `VerdictSchema` from
|
|
69
88
|
* `validation.ts`. The two definitions are the SAME object so a
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RenderStatus } from "./validation";
|
|
2
|
+
|
|
3
|
+
export const PROMOTION_GATE = {
|
|
4
|
+
ok: "allow",
|
|
5
|
+
error: "refuse",
|
|
6
|
+
"partial:layout_unverified": "allow",
|
|
7
|
+
"partial:opaque_content": "allow",
|
|
8
|
+
"partial:external_resources": "allow",
|
|
9
|
+
"partial:unstable": "allow",
|
|
10
|
+
"partial:empty_render": "refuse",
|
|
11
|
+
tampered: "refuse",
|
|
12
|
+
timeout: "refuse",
|
|
13
|
+
shim_only: "refuse",
|
|
14
|
+
probe_only: "refuse",
|
|
15
|
+
"insecure:unvalidated": "refuse",
|
|
16
|
+
} as const satisfies Record<RenderStatus, "allow" | "refuse">;
|
|
@@ -36,6 +36,7 @@ export const RenderStatusSchema = z.enum([
|
|
|
36
36
|
"partial:opaque_content",
|
|
37
37
|
"partial:external_resources",
|
|
38
38
|
"partial:unstable",
|
|
39
|
+
"partial:empty_render",
|
|
39
40
|
"tampered",
|
|
40
41
|
"timeout",
|
|
41
42
|
"shim_only",
|
|
@@ -134,9 +135,14 @@ export type LexicalCounters = z.infer<typeof LexicalCountersSchema>;
|
|
|
134
135
|
const ObservedCountShape = Object.fromEntries(
|
|
135
136
|
OBSERVED_COUNT_KEYS.map((key) => [key, z.number().int().nonnegative()]),
|
|
136
137
|
) as Record<(typeof OBSERVED_COUNT_KEYS)[number], z.ZodNumber>;
|
|
138
|
+
const RenderedContentShape = {
|
|
139
|
+
// Legacy stored observations lack this TSX-only probe; absence never proves an empty render.
|
|
140
|
+
emptyRendererRoot: z.boolean().optional(),
|
|
141
|
+
};
|
|
137
142
|
|
|
138
143
|
export const VerdictObservedSchema = z.object({
|
|
139
144
|
...ObservedCountShape,
|
|
145
|
+
...RenderedContentShape,
|
|
140
146
|
html: HtmlStructureCountsSchema.optional(),
|
|
141
147
|
viewBoxes: z.array(z.string()).optional(),
|
|
142
148
|
errorCount: z.number().int().nonnegative(),
|
|
@@ -288,6 +294,7 @@ export type Tier1Result = z.infer<typeof Tier1ResultSchema>;
|
|
|
288
294
|
*/
|
|
289
295
|
export const ProtocolObservationSchema = z.object({
|
|
290
296
|
...ObservedCountShape,
|
|
297
|
+
...RenderedContentShape,
|
|
291
298
|
html: HtmlStructureCountsSchema.optional(),
|
|
292
299
|
viewBoxes: z.array(z.string()),
|
|
293
300
|
errorCount: z.number().int().nonnegative(),
|
|
@@ -12,6 +12,7 @@ export const FacetErrorCodes = {
|
|
|
12
12
|
database_busy: true,
|
|
13
13
|
disk_full: true,
|
|
14
14
|
duplicate_revision: true,
|
|
15
|
+
template_name_taken: true,
|
|
15
16
|
foreign_key: true,
|
|
16
17
|
immutable_revision: true,
|
|
17
18
|
migration_failed: true,
|
|
@@ -27,6 +28,7 @@ export const FacetErrorCodes = {
|
|
|
27
28
|
artifact_not_found: true,
|
|
28
29
|
revision_not_found: true,
|
|
29
30
|
template_not_found: true,
|
|
31
|
+
promotion_refused: true,
|
|
30
32
|
evidence_unavailable: true,
|
|
31
33
|
output_unwritable: true,
|
|
32
34
|
revision_capacity_pinned: true,
|
|
@@ -18,6 +18,7 @@ export type StoreErrorCode = Extract<
|
|
|
18
18
|
| "database_busy"
|
|
19
19
|
| "disk_full"
|
|
20
20
|
| "duplicate_revision"
|
|
21
|
+
| "template_name_taken"
|
|
21
22
|
| "foreign_key"
|
|
22
23
|
| "immutable_revision"
|
|
23
24
|
| "migration_failed"
|
|
@@ -62,6 +63,9 @@ export function asStoreError(error: unknown): FacetStoreError {
|
|
|
62
63
|
return new FacetStoreError("foreign_key", message, { cause: error });
|
|
63
64
|
}
|
|
64
65
|
if (lower.includes("unique constraint")) {
|
|
66
|
+
if (lower.includes("templates.name")) {
|
|
67
|
+
return new FacetStoreError("template_name_taken", message, { cause: error });
|
|
68
|
+
}
|
|
65
69
|
return new FacetStoreError("duplicate_revision", message, { cause: error });
|
|
66
70
|
}
|
|
67
71
|
return new FacetStoreError("constraint", message, { cause: error });
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Latest schema migration applied by the service. */
|
|
2
|
-
export const CURRENT_STORAGE_VERSION =
|
|
2
|
+
export const CURRENT_STORAGE_VERSION = 10 as const;
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* linkedom-based DOM shim for the Tier 0 worker.
|
|
3
3
|
*
|
|
4
|
-
* Mermaid
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* none of these by default, so a naive `import mermaid from "mermaid"`
|
|
8
|
-
* throws `DOMPurify.addHook is not a function` (or its equivalent)
|
|
9
|
-
* before the parser ever sees source.
|
|
4
|
+
* Mermaid needs a minimal DOM for import and parsing in the worker.
|
|
5
|
+
* Tier 0 does not render labels; their sanitization is a render concern
|
|
6
|
+
* verified by Tier 1.
|
|
10
7
|
*
|
|
11
8
|
* This module installs the shim at IMPORT TIME (top-level) so any
|
|
12
9
|
* subsequent `import "mermaid"` sees a DOM. It is structural only —
|
|
@@ -24,17 +21,6 @@ function installDomShim(): void {
|
|
|
24
21
|
installed = true;
|
|
25
22
|
const { document, window } = parseHTML("<!DOCTYPE html><html><body></body></html>");
|
|
26
23
|
domShimDocument = document as unknown as Document;
|
|
27
|
-
// DOMPurify's isSupported check looks for `implementation.createHTMLDocument`.
|
|
28
|
-
// linkedom ships without an implementation object; we attach a minimal shim
|
|
29
|
-
// that returns a freshly-parsed document. This is structural only —
|
|
30
|
-
// we never serialize against this DOM.
|
|
31
|
-
const fakeImpl = {
|
|
32
|
-
createHTMLDocument: (html: string): Document => {
|
|
33
|
-
const r = parseHTML(html);
|
|
34
|
-
return r.document as unknown as Document;
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
Object.defineProperty(document, "implementation", { value: fakeImpl, configurable: true });
|
|
38
24
|
const g = globalThis as unknown as Record<string, unknown>;
|
|
39
25
|
g["document"] = document;
|
|
40
26
|
g["window"] = window;
|
|
@@ -43,7 +29,6 @@ function installDomShim(): void {
|
|
|
43
29
|
g["Node"] = window.Node;
|
|
44
30
|
g["DocumentFragment"] = window.DocumentFragment;
|
|
45
31
|
g["HTMLTemplateElement"] = window.HTMLTemplateElement;
|
|
46
|
-
g["NodeFilter"] = window.NodeFilter;
|
|
47
32
|
}
|
|
48
33
|
|
|
49
34
|
// Top-level install — runs synchronously when this module is first
|
|
@@ -30,6 +30,7 @@ import { Lexer, type Token, type Tokens } from "marked";
|
|
|
30
30
|
|
|
31
31
|
import type { DiscriminativeError, VerdictObserved } from "../../shared/contracts/validation";
|
|
32
32
|
import { countMermaidNodeDeclarations } from "../../shared/util/mermaid-nodes";
|
|
33
|
+
import { parseMermaidText } from "./mermaid";
|
|
33
34
|
|
|
34
35
|
export interface MarkdownParseOk {
|
|
35
36
|
readonly status: "ok";
|
|
@@ -54,6 +55,7 @@ interface MarkdownCounts {
|
|
|
54
55
|
hasScript: boolean;
|
|
55
56
|
hasOnHandler: boolean;
|
|
56
57
|
hasExternalRef: boolean;
|
|
58
|
+
mermaidBodies: string[];
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function isExternalHttpsUrl(value: string): boolean {
|
|
@@ -102,6 +104,7 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
|
102
104
|
counts.totalFenced += 1;
|
|
103
105
|
const lang = (code.lang ?? "").trim().toLowerCase();
|
|
104
106
|
if (lang === "mermaid") {
|
|
107
|
+
counts.mermaidBodies.push(code.text);
|
|
105
108
|
counts.mermaidFenced += 1;
|
|
106
109
|
counts.rendererRoots += 1;
|
|
107
110
|
const nodes = countMermaidNodeDeclarations(code.text);
|
|
@@ -161,12 +164,11 @@ function walkTokens(tokens: Token[], counts: MarkdownCounts): void {
|
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
* one) but never interpreted.
|
|
167
|
+
* Tokenize Markdown and grammar-check Mermaid fences without rendering
|
|
168
|
+
* or executing source. Raw HTML is counted and checked for structural
|
|
169
|
+
* red flags, but never interpreted.
|
|
168
170
|
*/
|
|
169
|
-
export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
171
|
+
export async function parseMarkdown(bytes: Uint8Array): Promise<MarkdownParseResult> {
|
|
170
172
|
const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
171
173
|
const counts: MarkdownCounts = {
|
|
172
174
|
totalFenced: 0,
|
|
@@ -178,6 +180,7 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
|
178
180
|
hasScript: false,
|
|
179
181
|
hasOnHandler: false,
|
|
180
182
|
hasExternalRef: false,
|
|
183
|
+
mermaidBodies: [],
|
|
181
184
|
};
|
|
182
185
|
let lexError: unknown = null;
|
|
183
186
|
let tokens: Token[] = [];
|
|
@@ -189,6 +192,26 @@ export function parseMarkdown(bytes: Uint8Array): MarkdownParseResult {
|
|
|
189
192
|
lexError = error;
|
|
190
193
|
}
|
|
191
194
|
|
|
195
|
+
for (let i = 0; i < counts.mermaidBodies.length; i += 1) {
|
|
196
|
+
const message = await parseMermaidText(counts.mermaidBodies[i]!);
|
|
197
|
+
if (message !== null) {
|
|
198
|
+
// Fence grammar errors take precedence over lexer and hostile-HTML errors.
|
|
199
|
+
return {
|
|
200
|
+
status: "error",
|
|
201
|
+
observed: {
|
|
202
|
+
rendererRootSvgCount: counts.rendererRoots,
|
|
203
|
+
graphCount: counts.mermaidFenced,
|
|
204
|
+
mermaidNodeCount: counts.mermaidNodeCount ?? 0,
|
|
205
|
+
visibleSvgCount: 0,
|
|
206
|
+
externalImageCount: counts.externalImageCount,
|
|
207
|
+
errorCount: 1,
|
|
208
|
+
opaqueRegionCount: 0,
|
|
209
|
+
},
|
|
210
|
+
errors: [{ code: "mermaid_parse_error", message, location: `mermaid fence ${i}` }],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
192
215
|
if (lexError !== null) {
|
|
193
216
|
const message = lexError instanceof Error ? lexError.message : String(lexError);
|
|
194
217
|
return {
|