@warpgogol/werkstatt-shared 0.8.0 → 0.8.2
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/package.json +19 -4
- package/src/checks/index.ts +1 -0
- package/src/checks/result-helpers.ts +60 -6
- package/src/checks/suppressions-config.ts +26 -1
- package/src/content/system-manifest.ts +8 -0
- package/src/ontology/archetypes/index.json +17 -1
- package/src/ontology/schemas/page-entry.ts +5 -7
- package/src/ontology/schemas/system/manifest.ts +8 -0
- package/src/ontology/schemas/system/verification.ts +40 -0
- package/src/passport/sign.ts +20 -14
- package/src/share/middleware/access-protection.ts +174 -0
- package/src/share/middleware/tests/access-protection.test.ts +123 -0
- package/src/share/page.ts +4 -2
- package/src/share/routes/template-filter.ts +36 -0
- package/src/share/routes/tests/template-filter.test.ts +40 -0
- package/src/share/scripts/lenis.ts +1 -0
- package/src/share/semantic/block-extractors/index.ts +24 -0
- package/src/share/semantic/build-page.ts +39 -1
- package/src/share/semantic/extract.ts +85 -11
- package/src/share/semantic/ids.ts +13 -0
- package/src/share/semantic/jsonld/organization.ts +20 -0
- package/src/share/semantic/jsonld/video.ts +56 -0
- package/src/share/semantic/jsonld/webpage.ts +1 -1
- package/src/share/semantic/jsonld.ts +5 -0
- package/src/share/semantic/models.ts +9 -0
- package/src/share/semantic/organization-profile.ts +3 -2
- package/src/share/semantic/page-utils.ts +4 -10
- package/src/share/semantic/tests/split-sentences.test.ts +97 -0
- package/src/share/slug/heading-slugger.ts +26 -0
- package/src/share/slug/index.ts +15 -0
- package/src/share/slug/slug-id.ts +22 -0
- package/src/share/slug/slug-url.ts +24 -0
- package/src/share/slug/strategies.ts +68 -0
- package/src/share/slug/tests/slug.test.ts +84 -0
- package/src/share/tests/build-page-price-markers.test.ts +3 -0
- package/src/share/tests/jsonld-video.test.ts +143 -0
- package/src/share/tests/jsonld-webpage.test.ts +69 -0
- package/src/share/tests/organization-jsonld.test.ts +52 -0
- package/src/share/types/axiom-study.d.ts +10 -2
- package/src/share/utility-registry.yaml +36 -0
- package/src/surface/blueprint-schema.ts +1 -1
- package/src/surface/blueprint-types.ts +1 -1
- package/src/share/tests/create-dev-props-validator.test.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpgogol/werkstatt-shared",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -250,6 +250,10 @@
|
|
|
250
250
|
"types": "./src/share/middleware/language-redirect.ts",
|
|
251
251
|
"default": "./src/share/middleware/language-redirect.ts"
|
|
252
252
|
},
|
|
253
|
+
"./share/middleware/access-protection": {
|
|
254
|
+
"types": "./src/share/middleware/access-protection.ts",
|
|
255
|
+
"default": "./src/share/middleware/access-protection.ts"
|
|
256
|
+
},
|
|
253
257
|
"./share/offer-capacity": {
|
|
254
258
|
"types": "./src/share/offer-capacity.ts",
|
|
255
259
|
"default": "./src/share/offer-capacity.ts"
|
|
@@ -318,6 +322,14 @@
|
|
|
318
322
|
"types": "./src/share/shared-context.ts",
|
|
319
323
|
"default": "./src/share/shared-context.ts"
|
|
320
324
|
},
|
|
325
|
+
"./share/slug": {
|
|
326
|
+
"types": "./src/share/slug/index.ts",
|
|
327
|
+
"default": "./src/share/slug/index.ts"
|
|
328
|
+
},
|
|
329
|
+
"./share/routes/template-filter": {
|
|
330
|
+
"types": "./src/share/routes/template-filter.ts",
|
|
331
|
+
"default": "./src/share/routes/template-filter.ts"
|
|
332
|
+
},
|
|
321
333
|
"./share/string-utils": {
|
|
322
334
|
"types": "./src/share/string-utils.ts",
|
|
323
335
|
"default": "./src/share/string-utils.ts"
|
|
@@ -1349,14 +1361,17 @@
|
|
|
1349
1361
|
],
|
|
1350
1362
|
"dependencies": {
|
|
1351
1363
|
"@lordicon/element": "^2.3.1",
|
|
1352
|
-
"@
|
|
1364
|
+
"@warpgogol/werkstatt": "*",
|
|
1353
1365
|
"ajv": "^8.20.0",
|
|
1354
1366
|
"bs58": "^6.0.0",
|
|
1367
|
+
"@sindresorhus/slugify": "^3.0.0",
|
|
1368
|
+
"cyrillic-to-translit-js": "^3.2.1",
|
|
1355
1369
|
"expr-eval": "^2.0.2",
|
|
1356
|
-
"
|
|
1370
|
+
"github-slugger": "^2.0.0",
|
|
1357
1371
|
"hls.js": "^1.6.16",
|
|
1358
1372
|
"lenis": "^1.3.25",
|
|
1359
1373
|
"plyr": "^3.8.4",
|
|
1374
|
+
"gray-matter": "^4.0.3",
|
|
1360
1375
|
"yaml": "^2.9.0",
|
|
1361
1376
|
"zod": "^4.4.3"
|
|
1362
1377
|
},
|
|
@@ -1367,7 +1382,7 @@
|
|
|
1367
1382
|
"@astrojs/markdown-remark": "^7.0.0",
|
|
1368
1383
|
"@cloudflare/workers-types": "^5.20260801.1",
|
|
1369
1384
|
"@types/node": "^24.0.0",
|
|
1370
|
-
"@warpgogol/werkstatt": "
|
|
1385
|
+
"@warpgogol/werkstatt": "*",
|
|
1371
1386
|
"astro": "^7.2.0",
|
|
1372
1387
|
"eslint": "^10.8.0",
|
|
1373
1388
|
"fast-check": "^4.9.0",
|
package/src/checks/index.ts
CHANGED
|
@@ -16,16 +16,55 @@ ruleId = command; diagnosticsResult takes rich Diagnostic[] with registered ids.
|
|
|
16
16
|
</CHANGE_SUMMARY>
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type {
|
|
19
|
+
import type {
|
|
20
|
+
CheckResult,
|
|
21
|
+
Diagnostic,
|
|
22
|
+
KernelCommandResult,
|
|
23
|
+
KernelNextStep,
|
|
24
|
+
} from "@warpgogol/werkstatt/kernel";
|
|
25
|
+
|
|
26
|
+
function defaultFailNextSteps(command: string): KernelNextStep[] {
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
action: `Fix the error diagnostics reported by ${command} above, then re-run the pipeline`,
|
|
30
|
+
kind: "required",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format error/warning counts with proper pluralization and zero-omission.
|
|
37
|
+
*
|
|
38
|
+
* - `0, 0` → `""` (empty string — caller should omit)
|
|
39
|
+
* - `1, 0` → `"1 error"`
|
|
40
|
+
* - `0, 1` → `"1 warning"`
|
|
41
|
+
* - `2, 0` → `"2 errors"`
|
|
42
|
+
* - `2, 1` → `"2 errors, 1 warning"`
|
|
43
|
+
*/
|
|
44
|
+
export function formatCounts(errors: number, warnings: number): string {
|
|
45
|
+
const parts: string[] = [];
|
|
46
|
+
if (errors > 0) {
|
|
47
|
+
parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
|
|
48
|
+
}
|
|
49
|
+
if (warnings > 0) {
|
|
50
|
+
parts.push(`${warnings} warning${warnings === 1 ? "" : "s"}`);
|
|
51
|
+
}
|
|
52
|
+
return parts.join(", ");
|
|
53
|
+
}
|
|
20
54
|
|
|
21
55
|
/**
|
|
22
56
|
* RFC-0203: canonical result builder. Migrated checks emit `Diagnostic[]` with
|
|
23
57
|
* registered rule ids instead of bare strings. Exit code is 1 when any
|
|
24
58
|
* diagnostic is an error; warnings and info do not fail the pipeline.
|
|
59
|
+
*
|
|
60
|
+
* When `nextSteps` are not provided and the result has errors, a default
|
|
61
|
+
* agent-actionable nextStep is emitted: "Fix the error diagnostics … then
|
|
62
|
+
* re-run the pipeline".
|
|
25
63
|
*/
|
|
26
64
|
export function diagnosticsResult(
|
|
27
65
|
command: string,
|
|
28
66
|
diagnostics: Diagnostic[],
|
|
67
|
+
nextSteps?: KernelNextStep[],
|
|
29
68
|
): KernelCommandResult<CheckResult> {
|
|
30
69
|
const summary = {
|
|
31
70
|
error: diagnostics.filter((d) => d.severity === "error").length,
|
|
@@ -34,10 +73,14 @@ export function diagnosticsResult(
|
|
|
34
73
|
};
|
|
35
74
|
const status: CheckResult["status"] =
|
|
36
75
|
summary.error > 0 ? "fail" : summary.warning > 0 ? "warn" : "pass";
|
|
76
|
+
const resolvedNextSteps =
|
|
77
|
+
nextSteps ?? (summary.error > 0 ? defaultFailNextSteps(command) : undefined);
|
|
78
|
+
const counts = formatCounts(summary.error, summary.warning);
|
|
37
79
|
return {
|
|
38
80
|
data: { command, status, diagnostics, summary },
|
|
39
81
|
exitCode: summary.error > 0 ? 1 : 0,
|
|
40
|
-
summary:
|
|
82
|
+
summary: counts ? `[${command}] ${counts}` : `[${command}]`,
|
|
83
|
+
nextSteps: resolvedNextSteps,
|
|
41
84
|
};
|
|
42
85
|
}
|
|
43
86
|
|
|
@@ -58,11 +101,16 @@ function emptySummary(): CheckResult["summary"] {
|
|
|
58
101
|
* Canonical success result. RFC-0203: emits an empty `CheckResult` (no
|
|
59
102
|
* diagnostics) rather than the legacy `{ command, status, violations }` shape.
|
|
60
103
|
*/
|
|
61
|
-
export function passResult(
|
|
104
|
+
export function passResult(
|
|
105
|
+
command: string,
|
|
106
|
+
summary?: string,
|
|
107
|
+
nextSteps?: KernelNextStep[],
|
|
108
|
+
): KernelCommandResult<CheckResult> {
|
|
62
109
|
return {
|
|
63
110
|
data: { command, status: "pass", diagnostics: [], summary: emptySummary() },
|
|
64
111
|
exitCode: 0,
|
|
65
|
-
summary: summary ??
|
|
112
|
+
summary: summary ?? `[${command}] OK`,
|
|
113
|
+
nextSteps,
|
|
66
114
|
};
|
|
67
115
|
}
|
|
68
116
|
|
|
@@ -70,10 +118,14 @@ export function passResult(command: string, summary?: string): KernelCommandResu
|
|
|
70
118
|
* Canonical failure result — always exits 1 (preserving the historical
|
|
71
119
|
* failResult contract). RFC-0203: violation strings become error Diagnostics
|
|
72
120
|
* keyed by the command name.
|
|
121
|
+
*
|
|
122
|
+
* When `nextSteps` are not provided, a default agent-actionable nextStep is
|
|
123
|
+
* emitted: "Fix the error diagnostics … then re-run the pipeline".
|
|
73
124
|
*/
|
|
74
125
|
export function failResult(
|
|
75
126
|
command: string,
|
|
76
127
|
violations: string[],
|
|
128
|
+
nextSteps?: KernelNextStep[],
|
|
77
129
|
): KernelCommandResult<CheckResult> {
|
|
78
130
|
const diagnostics = violationsToDiagnostics(command, violations);
|
|
79
131
|
return {
|
|
@@ -84,7 +136,8 @@ export function failResult(
|
|
|
84
136
|
summary: { error: diagnostics.length, warning: 0, info: 0 },
|
|
85
137
|
},
|
|
86
138
|
exitCode: 1,
|
|
87
|
-
summary:
|
|
139
|
+
summary: `[${command}] ${violations.length} violation${violations.length === 1 ? "" : "s"}`,
|
|
140
|
+
nextSteps: nextSteps ?? defaultFailNextSteps(command),
|
|
88
141
|
};
|
|
89
142
|
}
|
|
90
143
|
|
|
@@ -97,6 +150,7 @@ export function failResult(
|
|
|
97
150
|
export function resultFromViolations(
|
|
98
151
|
command: string,
|
|
99
152
|
violations: string[],
|
|
153
|
+
nextSteps?: KernelNextStep[],
|
|
100
154
|
): KernelCommandResult<CheckResult> {
|
|
101
|
-
return violations.length > 0 ? failResult(command, violations) : passResult(command);
|
|
155
|
+
return violations.length > 0 ? failResult(command, violations, nextSteps) : passResult(command);
|
|
102
156
|
}
|
|
@@ -17,7 +17,32 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { parse as parseYaml } from "yaml";
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal Finding shape matching @syrokomskyi/axiom-study's Finding interface.
|
|
23
|
+
* Defined locally so the package typechecks without axiom-study installed.
|
|
24
|
+
* See share/types/axiom-study.d.ts for the full canonical declaration.
|
|
25
|
+
*/
|
|
26
|
+
interface Finding {
|
|
27
|
+
findingId: string;
|
|
28
|
+
semanticFingerprint: { algorithm: "sha256"; digest: string; size: number; mediaType: string };
|
|
29
|
+
methodologyId: string;
|
|
30
|
+
ruleId: string;
|
|
31
|
+
affectedSubjectId: string;
|
|
32
|
+
title: string;
|
|
33
|
+
severity: "info" | "low" | "medium" | "high" | "critical";
|
|
34
|
+
evidence: Array<{
|
|
35
|
+
evidenceRef: {
|
|
36
|
+
artifactId: string;
|
|
37
|
+
rootDigest: { algorithm: "sha256"; digest: string; size: number; mediaType: string };
|
|
38
|
+
schema: string;
|
|
39
|
+
};
|
|
40
|
+
selector: string;
|
|
41
|
+
evidenceClass: string;
|
|
42
|
+
}>;
|
|
43
|
+
uncertainty: unknown[];
|
|
44
|
+
extension: Record<string, unknown>;
|
|
45
|
+
}
|
|
21
46
|
|
|
22
47
|
export const suppressionRuleSchema = z.object({
|
|
23
48
|
ruleId: z.string().min(1),
|
|
@@ -9,6 +9,7 @@ Stack-agnostic utility used by both engine and site plugin (RFC-0868).</purpose>
|
|
|
9
9
|
</MODULE_CONTRACT>
|
|
10
10
|
<CHANGE_SUMMARY>
|
|
11
11
|
<item>RFC-0868: extracted from werkstatt-site/src/content/system-manifest.ts.</item>
|
|
12
|
+
<item>RFC-0911: add seo?.anchorText?.extraStopPhrases for anchor-text stop-list extension.</item>
|
|
12
13
|
</CHANGE_SUMMARY>
|
|
13
14
|
*/
|
|
14
15
|
|
|
@@ -101,6 +102,13 @@ export interface SystemManifest {
|
|
|
101
102
|
swapOrder?: boolean;
|
|
102
103
|
};
|
|
103
104
|
};
|
|
105
|
+
/** RFC-0911: SEO validator configuration extension point. */
|
|
106
|
+
seo?: {
|
|
107
|
+
anchorText?: {
|
|
108
|
+
/** Added to the built-in de/uk stop-list. */
|
|
109
|
+
extraStopPhrases?: Record<string, string[]>;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
export interface SystemManifestLoadResult {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": "1.0.0",
|
|
3
|
-
"totalCount":
|
|
3
|
+
"totalCount": 59,
|
|
4
4
|
"entries": [
|
|
5
5
|
{
|
|
6
6
|
"id": "approach",
|
|
@@ -218,6 +218,18 @@
|
|
|
218
218
|
"sourceFile": "packages/werkstatt-site/src/domain/ontology/archetypes/components/footer-promo.yaml",
|
|
219
219
|
"layer": "component"
|
|
220
220
|
},
|
|
221
|
+
{
|
|
222
|
+
"id": "gratitude",
|
|
223
|
+
"displayName": "Gratitude",
|
|
224
|
+
"semanticRole": "client-gratitude",
|
|
225
|
+
"version": "1.0.0",
|
|
226
|
+
"layoutHint": "card-grid",
|
|
227
|
+
"acceptedCosmicNames": [
|
|
228
|
+
"Gonggong"
|
|
229
|
+
],
|
|
230
|
+
"sourceFile": "packages/werkstatt-site/src/domain/ontology/archetypes/sections/gratitude.yaml",
|
|
231
|
+
"layer": "section"
|
|
232
|
+
},
|
|
221
233
|
{
|
|
222
234
|
"id": "hero",
|
|
223
235
|
"displayName": "Hero",
|
|
@@ -704,6 +716,7 @@
|
|
|
704
716
|
"article-index",
|
|
705
717
|
"audience-self-identification",
|
|
706
718
|
"breadcrumbs",
|
|
719
|
+
"client-gratitude",
|
|
707
720
|
"client-ownership-disclosure",
|
|
708
721
|
"consent-gated-chat-launcher",
|
|
709
722
|
"credits-gallery",
|
|
@@ -777,6 +790,7 @@
|
|
|
777
790
|
"Elara": "@warpgogol/werkstatt-site/ui/sections/dynamic-status-block",
|
|
778
791
|
"Atlas": "@warpgogol/werkstatt-site/ui/sections/faq-list",
|
|
779
792
|
"Dione": "@warpgogol/werkstatt-site/ui/sections/final-cta",
|
|
793
|
+
"Gonggong": "@warpgogol/werkstatt-site/ui/sections/gratitude",
|
|
780
794
|
"Europa": "@warpgogol/werkstatt-site/ui/sections/hero",
|
|
781
795
|
"Phobos": "@warpgogol/werkstatt-site/ui/sections/hero-decision-card",
|
|
782
796
|
"Ganymede": "@warpgogol/werkstatt-site/ui/sections/impact",
|
|
@@ -854,6 +868,7 @@
|
|
|
854
868
|
"dynamic-status-block": "Elara",
|
|
855
869
|
"faq-list": "Atlas",
|
|
856
870
|
"final-cta": "Dione",
|
|
871
|
+
"gratitude": "Gonggong",
|
|
857
872
|
"hero": "Europa",
|
|
858
873
|
"hero-decision-card": "Phobos",
|
|
859
874
|
"impact": "Ganymede",
|
|
@@ -935,6 +950,7 @@
|
|
|
935
950
|
"Elara": "data-driven-status-indicator",
|
|
936
951
|
"Atlas": "approach",
|
|
937
952
|
"Dione": "final-cta",
|
|
953
|
+
"Gonggong": "client-gratitude",
|
|
938
954
|
"Europa": "hero",
|
|
939
955
|
"Phobos": "hero",
|
|
940
956
|
"Ganymede": "impact",
|
|
@@ -66,14 +66,12 @@ const visibilityExprSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
|
66
66
|
export const BlockEntrySchema = z
|
|
67
67
|
.object({
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
* block-level attribution. Must be kebab-case
|
|
71
|
-
* page.block.validate enforces uniqueness within a page.
|
|
69
|
+
* Mandatory stable block identifier for analytics, anchor links, and
|
|
70
|
+
* block-level attribution. Must be kebab-case. RFC-0914 makes this required.
|
|
71
|
+
* page.block.validate enforces uniqueness within a page (B-05).
|
|
72
|
+
* page.blocks.extract.validate enforces presence and format (BLOCK-ID-MISSING, BLOCK-ID-INVALID).
|
|
72
73
|
*/
|
|
73
|
-
id: z
|
|
74
|
-
.string()
|
|
75
|
-
.regex(/^[a-z0-9-]+$/, "Block id must be kebab-case")
|
|
76
|
-
.optional(),
|
|
74
|
+
id: z.string().regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, "Block id must be kebab-case"),
|
|
77
75
|
|
|
78
76
|
/**
|
|
79
77
|
* Cosmic name identifying the section/component for this block.
|
|
@@ -17,6 +17,7 @@ import { systemSharedContextSchema, systemGrowthSchema } from "./growth.ts";
|
|
|
17
17
|
import { systemReleaseSchema } from "./release.ts";
|
|
18
18
|
import { systemTextSchema } from "./text.ts";
|
|
19
19
|
import { systemIntegrationsSchema } from "./integrations.ts";
|
|
20
|
+
import { systemVerificationSchema } from "./verification.ts";
|
|
20
21
|
import { semanticPageTypeSchema, articleMetadataSchema, pageOutputSchema } from "./page-output.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -482,6 +483,13 @@ export const systemManifestSchema = z.object({
|
|
|
482
483
|
.optional(),
|
|
483
484
|
})
|
|
484
485
|
.optional(),
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Search engine verification config (RFC-0909).
|
|
489
|
+
* Declares per-engine verification method and token.
|
|
490
|
+
* When absent, search.verification.validate emits SEARCH-VERIFY-01.
|
|
491
|
+
*/
|
|
492
|
+
verification: systemVerificationSchema.optional(),
|
|
485
493
|
});
|
|
486
494
|
|
|
487
495
|
export type SystemManifest = z.infer<typeof systemManifestSchema>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Verification block schema (RFC-0909) for the system manifest.
|
|
4
|
+
Declares per-search-engine verification method and token for Google Search Console.</purpose>
|
|
5
|
+
<non-goals>
|
|
6
|
+
<item>Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.</item>
|
|
7
|
+
</non-goals>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
<CHANGE_SUMMARY>
|
|
10
|
+
<item>RFC-0909: initial verification schema for search engine verification surface.</item>
|
|
11
|
+
</CHANGE_SUMMARY>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `verification:` block in src/content/system.md. Declares per-search-engine
|
|
18
|
+
* verification method and token so that search.verification.validate can enforce
|
|
19
|
+
* the declaration and the layout can emit the google-site-verification meta tag.
|
|
20
|
+
*
|
|
21
|
+
* Example src/content/system.md verification block:
|
|
22
|
+
* verification:
|
|
23
|
+
* google:
|
|
24
|
+
* method: dns-txt
|
|
25
|
+
* token: google-site-verification=abc123...
|
|
26
|
+
*/
|
|
27
|
+
export const systemVerificationSchema = z.object({
|
|
28
|
+
google: z
|
|
29
|
+
.object({
|
|
30
|
+
method: z.enum(["dns-txt", "meta-tag"]),
|
|
31
|
+
/**
|
|
32
|
+
* dns-txt: full TXT value "google-site-verification=...".
|
|
33
|
+
* meta-tag: content attribute of the google-site-verification meta tag.
|
|
34
|
+
*/
|
|
35
|
+
token: z.string().min(1),
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export type SystemVerification = z.infer<typeof systemVerificationSchema>;
|
package/src/passport/sign.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* <MODULE_CONTRACT>
|
|
2
|
-
<purpose>Facilitates Ed25519 key generation, signing, and verification for verifiable credentials.</purpose>
|
|
2
|
+
<purpose>Facilitates Ed25519 key generation, signing, and verification for verifiable credentials via shared signing core (RFC-0921).</purpose>
|
|
3
3
|
<non-goals>
|
|
4
4
|
<item>Do not handle raw key storage or management.</item>
|
|
5
5
|
<item>Do not perform network operations or external API calls.</item>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
</MODULE_CONTRACT>
|
|
9
9
|
<CHANGE_SUMMARY>
|
|
10
10
|
<item>Tidied by compass.changesummary.tidy; see git history for prior entries.</item>
|
|
11
|
+
<item>RFC-0921: delegate Ed25519 sign/verify/keygen to shared signing core (@warpgogol/werkstatt/signing). Remove @noble/ed25519 import.</item>
|
|
11
12
|
</CHANGE_SUMMARY> */
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -24,8 +25,14 @@
|
|
|
24
25
|
* - This module never logs, writes, or returns private key material.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
|
-
import * as ed from "@noble/ed25519";
|
|
28
28
|
import bs58 from "bs58";
|
|
29
|
+
import {
|
|
30
|
+
generateKeyPair as signingGenerateKeyPair,
|
|
31
|
+
signBytes as signingSignBytes,
|
|
32
|
+
verifyBytes as signingVerifyBytes,
|
|
33
|
+
fromHex,
|
|
34
|
+
toHex,
|
|
35
|
+
} from "@warpgogol/werkstatt/signing";
|
|
29
36
|
import type { VCProof, VerifiableCredential } from "./schema.ts";
|
|
30
37
|
|
|
31
38
|
// ---------------------------------------------------------------------------
|
|
@@ -90,12 +97,11 @@ export async function generateKeypair(): Promise<{
|
|
|
90
97
|
publicKeyBytes: Uint8Array;
|
|
91
98
|
publicKeyMultibase: string;
|
|
92
99
|
}> {
|
|
93
|
-
const
|
|
94
|
-
const publicKeyBytes = await ed.getPublicKeyAsync(privateKeyBytes);
|
|
100
|
+
const keyPair = await signingGenerateKeyPair();
|
|
95
101
|
return {
|
|
96
|
-
privateKeyHex:
|
|
97
|
-
publicKeyBytes,
|
|
98
|
-
publicKeyMultibase: toMultibase(
|
|
102
|
+
privateKeyHex: toHex(keyPair.privateKey),
|
|
103
|
+
publicKeyBytes: keyPair.publicKey,
|
|
104
|
+
publicKeyMultibase: toMultibase(keyPair.publicKey),
|
|
99
105
|
};
|
|
100
106
|
}
|
|
101
107
|
|
|
@@ -110,7 +116,7 @@ export async function generateKeypair(): Promise<{
|
|
|
110
116
|
* @param privateKeyHex — 32-byte Ed25519 private key as hex (from env secret)
|
|
111
117
|
* @param verificationMethod — DID key reference (e.g. "did:web:example.org#key-v1")
|
|
112
118
|
*/
|
|
113
|
-
// @ai-invariant: signCredential uses
|
|
119
|
+
// @ai-invariant: signCredential uses the shared signing core (RFC-0921). Private keys arrive
|
|
114
120
|
// exclusively as hex env vars — never logged, written to disk, or returned.
|
|
115
121
|
// The signed output is a W3C VC with Ed25519Signature2020 proof.
|
|
116
122
|
|
|
@@ -119,9 +125,9 @@ export async function signCredential(
|
|
|
119
125
|
privateKeyHex: string,
|
|
120
126
|
verificationMethod: string,
|
|
121
127
|
): Promise<VCProof> {
|
|
122
|
-
const privateKeyBytes =
|
|
128
|
+
const privateKeyBytes = fromHex(privateKeyHex);
|
|
123
129
|
const message = credentialBytes(subject);
|
|
124
|
-
const signatureBytes = await
|
|
130
|
+
const signatureBytes = await signingSignBytes(privateKeyBytes, message);
|
|
125
131
|
|
|
126
132
|
return {
|
|
127
133
|
type: "Ed25519Signature2020",
|
|
@@ -153,7 +159,7 @@ export async function verifyCredential(
|
|
|
153
159
|
const publicKeyBytes = fromMultibase(publicKeyMultibase);
|
|
154
160
|
const signatureBytes = fromMultibase(proof.proofValue);
|
|
155
161
|
const message = credentialBytes(subject);
|
|
156
|
-
return await
|
|
162
|
+
return await signingVerifyBytes(publicKeyBytes, message, signatureBytes);
|
|
157
163
|
} catch {
|
|
158
164
|
return false;
|
|
159
165
|
}
|
|
@@ -173,8 +179,8 @@ export async function verifyCredential(
|
|
|
173
179
|
* @param message — canonical bytes to sign
|
|
174
180
|
*/
|
|
175
181
|
export async function signBytes(privateKeyHex: string, message: Uint8Array): Promise<string> {
|
|
176
|
-
const privateKeyBytes =
|
|
177
|
-
const signatureBytes = await
|
|
182
|
+
const privateKeyBytes = fromHex(privateKeyHex);
|
|
183
|
+
const signatureBytes = await signingSignBytes(privateKeyBytes, message);
|
|
178
184
|
return toMultibase(signatureBytes);
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -190,7 +196,7 @@ export async function verifyBytes(
|
|
|
190
196
|
try {
|
|
191
197
|
const publicKeyBytes = fromMultibase(publicKeyMultibase);
|
|
192
198
|
const signatureBytes = fromMultibase(signatureMultibase);
|
|
193
|
-
return await
|
|
199
|
+
return await signingVerifyBytes(publicKeyBytes, message, signatureBytes);
|
|
194
200
|
} catch {
|
|
195
201
|
return false;
|
|
196
202
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0899: Runtime access protection middleware for dev/alt subdomains. Checks Host header and requires Basic Auth with a 4-digit PIN for dev.* and alt.* hosts. Sets X-Robots-Tag headers to prevent indexing.</purpose>
|
|
4
|
+
<keywords>middleware, access-protection, basic-auth, pin, dev, alt, RFC-0899</keywords>
|
|
5
|
+
<responsibilities>
|
|
6
|
+
<item>Check Host header against dev.* and alt.* patterns — pass through for main domain.</item>
|
|
7
|
+
<item>Require Basic Auth (username: warp, password: ACCESS_PIN env var) for dev/alt hosts.</item>
|
|
8
|
+
<item>Set X-Robots-Tag: noindex, nofollow, noai, noimageai on ALL dev/alt responses (including 401).</item>
|
|
9
|
+
<item>Use constant-time string comparison for auth check to prevent timing attacks.</item>
|
|
10
|
+
<item>Pass through when ACCESS_PIN is unset (allows new sites before protection is configured).</item>
|
|
11
|
+
</responsibilities>
|
|
12
|
+
<non-goals>
|
|
13
|
+
<item>Do not modify the HTML response body — only headers and access gating.</item>
|
|
14
|
+
<item>Do not activate for the main/production domain, even if the PIN secret is set on the main Worker.</item>
|
|
15
|
+
<item>Do not use Node.js-specific APIs (Buffer, crypto.timingSafeEqual) — runs in Cloudflare Workers runtime.</item>
|
|
16
|
+
</non-goals>
|
|
17
|
+
</MODULE_CONTRACT>
|
|
18
|
+
<CHANGE_SUMMARY>
|
|
19
|
+
<item>RFC-0899: Initial access protection middleware for dev/alt subdomains.</item>
|
|
20
|
+
</CHANGE_SUMMARY>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { defineMiddleware } from "astro:middleware";
|
|
24
|
+
|
|
25
|
+
const NOINDEX_HEADER = "noindex, nofollow, noai, noimageai";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
29
|
+
* Returns true if both strings are equal, false otherwise.
|
|
30
|
+
* Always processes the full length of both strings regardless of match status.
|
|
31
|
+
*/
|
|
32
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
33
|
+
if (a.length !== b.length) return false;
|
|
34
|
+
let diff = 0;
|
|
35
|
+
for (let i = 0; i < a.length; i++) {
|
|
36
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
37
|
+
}
|
|
38
|
+
return diff === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a host is a dev or alt subdomain.
|
|
43
|
+
*/
|
|
44
|
+
function isDevOrAltHost(host: string): boolean {
|
|
45
|
+
return host.startsWith("dev.") || host.startsWith("alt.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* RFC-0899: Check access protection for a request. Called from the Worker entry point
|
|
50
|
+
* (worker.ts) before passing to the Astro handler. This is necessary because Astro
|
|
51
|
+
* middleware does not run for prerendered static pages (output: "static") — the
|
|
52
|
+
* Cloudflare adapter serves them from the ASSETS binding directly.
|
|
53
|
+
*
|
|
54
|
+
* Returns a 401 Response if access is denied, or null if the request should pass through.
|
|
55
|
+
* For dev/alt hosts with no PIN set, returns null (pass through) — caller should add
|
|
56
|
+
* X-Robots-Tag header to the response.
|
|
57
|
+
*
|
|
58
|
+
* @param request - The incoming Request
|
|
59
|
+
* @param env - The Worker env object (contains ACCESS_PIN)
|
|
60
|
+
* @returns 401 Response if denied, null if pass through
|
|
61
|
+
*/
|
|
62
|
+
export function checkAccessProtection(
|
|
63
|
+
request: Request,
|
|
64
|
+
env: Record<string, unknown>,
|
|
65
|
+
): Response | null {
|
|
66
|
+
const host = request.headers.get("host") ?? "";
|
|
67
|
+
if (!isDevOrAltHost(host)) return null;
|
|
68
|
+
|
|
69
|
+
const pin = (env.ACCESS_PIN as string | undefined) ?? undefined;
|
|
70
|
+
|
|
71
|
+
// No PIN set — allow access (caller should add X-Robots-Tag)
|
|
72
|
+
if (!pin) return null;
|
|
73
|
+
|
|
74
|
+
// Check Basic Auth
|
|
75
|
+
const auth = request.headers.get("authorization") ?? "";
|
|
76
|
+
const expected = `Basic ${btoa(`warp:${pin}`)}`;
|
|
77
|
+
|
|
78
|
+
if (auth && constantTimeEqual(auth, expected)) {
|
|
79
|
+
return null; // Authenticated — pass through
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Not authenticated — challenge
|
|
83
|
+
return new Response("Authentication required", {
|
|
84
|
+
status: 401,
|
|
85
|
+
headers: {
|
|
86
|
+
"WWW-Authenticate": 'Basic realm="Staging Access"',
|
|
87
|
+
"X-Robots-Tag": NOINDEX_HEADER,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RFC-0899: Add X-Robots-Tag header to a response for dev/alt subdomains.
|
|
94
|
+
* Called after the Astro handler returns a response, if the host is dev/alt.
|
|
95
|
+
*
|
|
96
|
+
* @param response - The response to modify
|
|
97
|
+
* @returns A new response with X-Robots-Tag added (if dev/alt host)
|
|
98
|
+
*/
|
|
99
|
+
export function addNoIndexHeaderIfNeeded(request: Request, response: Response): Response {
|
|
100
|
+
const host = request.headers.get("host") ?? "";
|
|
101
|
+
if (!isDevOrAltHost(host)) return response;
|
|
102
|
+
|
|
103
|
+
const headers = new Headers(response.headers);
|
|
104
|
+
headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
105
|
+
return new Response(response.body, {
|
|
106
|
+
status: response.status,
|
|
107
|
+
statusText: response.statusText,
|
|
108
|
+
headers,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// cloudflare:workers is only available in the Cloudflare Workers runtime.
|
|
113
|
+
// In Astro build (Node.js) the static import fails — resolve lazily so the
|
|
114
|
+
// middleware still loads; ACCESS_PIN is undefined in build (no runtime binding).
|
|
115
|
+
async function resolveAccessPin(): Promise<string | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
const { env } = await import("cloudflare:workers");
|
|
118
|
+
return (env.ACCESS_PIN as string | undefined) ?? undefined;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* RFC-0899: Access protection middleware for dev/alt subdomains.
|
|
126
|
+
*
|
|
127
|
+
* At runtime, checks the Host header:
|
|
128
|
+
* - dev.* or alt.* → require Basic Auth with ACCESS_PIN env var
|
|
129
|
+
* - main.* or production domain → no protection, pass through
|
|
130
|
+
* - No PIN configured → pass through but still set X-Robots-Tag
|
|
131
|
+
*
|
|
132
|
+
* The auth check happens BEFORE next() to short-circuit unauthorized access.
|
|
133
|
+
* X-Robots-Tag is set on ALL dev/alt responses including 401 challenges.
|
|
134
|
+
*
|
|
135
|
+
* Uses `cloudflare:workers` env import (Astro v6 removed `context.locals.runtime.env`).
|
|
136
|
+
* Uses `btoa()` (Workers runtime), not Node.js Buffer.
|
|
137
|
+
*/
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
139
|
+
export const accessProtectionMiddleware = defineMiddleware(async (context: any, next: any) => {
|
|
140
|
+
const host = context.request.headers.get("host") ?? "";
|
|
141
|
+
const isDevOrAlt = host.startsWith("dev.") || host.startsWith("alt.");
|
|
142
|
+
|
|
143
|
+
if (!isDevOrAlt) {
|
|
144
|
+
return next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pin = await resolveAccessPin();
|
|
148
|
+
|
|
149
|
+
// No PIN set — allow access but still set noindex headers
|
|
150
|
+
if (!pin) {
|
|
151
|
+
const response = await next();
|
|
152
|
+
response.headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
153
|
+
return response;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check Basic Auth BEFORE calling next()
|
|
157
|
+
const auth = context.request.headers.get("authorization") ?? "";
|
|
158
|
+
const expected = `Basic ${btoa(`warp:${pin}`)}`;
|
|
159
|
+
|
|
160
|
+
if (auth && constantTimeEqual(auth, expected)) {
|
|
161
|
+
const response = await next();
|
|
162
|
+
response.headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Not authenticated — challenge with noindex headers
|
|
167
|
+
return new Response("Authentication required", {
|
|
168
|
+
status: 401,
|
|
169
|
+
headers: {
|
|
170
|
+
"WWW-Authenticate": 'Basic realm="Staging Access"',
|
|
171
|
+
"X-Robots-Tag": NOINDEX_HEADER,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|