@ixo/domain.md 0.3.0
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/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +30 -0
- package/assets/domain-md.schema.json +1103 -0
- package/assets/oracle-capsule-jcs-vectors.json +57 -0
- package/assets/oracle-capsule-manifest-contract.md +72 -0
- package/assets/oracle-capsule-source-lock.schema.json +45 -0
- package/assets/oracle-capsule.schema.json +265 -0
- package/assets/rules.json +275 -0
- package/assets/spec.md +1434 -0
- package/assets/template-manifest.schema.json +57 -0
- package/dist/capsule/index.d.ts +75 -0
- package/dist/capsule/index.js +20 -0
- package/dist/capsule/index.js.map +1 -0
- package/dist/chunk-BYBC2TSS.js +597 -0
- package/dist/chunk-BYBC2TSS.js.map +1 -0
- package/dist/chunk-G7PZJYAJ.js +31826 -0
- package/dist/chunk-G7PZJYAJ.js.map +1 -0
- package/dist/chunk-R6QZRNEX.js +164 -0
- package/dist/chunk-R6QZRNEX.js.map +1 -0
- package/dist/chunk-T3STBWXP.js +46 -0
- package/dist/chunk-T3STBWXP.js.map +1 -0
- package/dist/chunk-W7MRQP7I.js +862 -0
- package/dist/chunk-W7MRQP7I.js.map +1 -0
- package/dist/chunk-XDIWMZVF.js +1150 -0
- package/dist/chunk-XDIWMZVF.js.map +1 -0
- package/dist/cli-KcUIQEiE.d.ts +171 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +400 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +83 -0
- package/dist/index.js.map +1 -0
- package/dist/resolver-B3mwnjJq.d.ts +96 -0
- package/dist/spec.d.ts +20 -0
- package/dist/spec.js +23 -0
- package/dist/spec.js.map +1 -0
- package/dist/templates/index.d.ts +7 -0
- package/dist/templates/index.js +24 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/workers-Dr8B8a2O.d.ts +19 -0
- package/dist/workers.d.ts +3 -0
- package/dist/workers.js +31 -0
- package/dist/workers.js.map +1 -0
- package/package.json +97 -0
- package/src/capsule/canonical.ts +74 -0
- package/src/capsule/index.ts +10 -0
- package/src/capsule/json.ts +373 -0
- package/src/capsule/types.ts +63 -0
- package/src/capsule/validate.ts +551 -0
- package/src/cli.ts +405 -0
- package/src/compiled-validators.d.ts +7 -0
- package/src/compiled-validators.js +32512 -0
- package/src/constants.ts +250 -0
- package/src/diff.ts +111 -0
- package/src/export.ts +28 -0
- package/src/index.ts +36 -0
- package/src/lint.ts +42 -0
- package/src/parser.ts +174 -0
- package/src/sarif.ts +49 -0
- package/src/semantic.ts +1156 -0
- package/src/spec.ts +48 -0
- package/src/templates/errors.ts +3 -0
- package/src/templates/index.ts +11 -0
- package/src/templates/manifest.ts +94 -0
- package/src/templates/render.ts +435 -0
- package/src/templates/resolver.ts +200 -0
- package/src/templates/types.ts +98 -0
- package/src/types.ts +195 -0
- package/src/workers.ts +6 -0
package/src/spec.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import type { DomainRecord } from './types.js';
|
|
4
|
+
|
|
5
|
+
const assets = new URL('../assets/', import.meta.url);
|
|
6
|
+
|
|
7
|
+
function readAsset(name: string): string {
|
|
8
|
+
return readFileSync(new URL(name, assets), 'utf8');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSpecification(): string {
|
|
12
|
+
return readAsset('spec.md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getSchema(): DomainRecord {
|
|
16
|
+
return JSON.parse(getSchemaSource()) as DomainRecord;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getSchemaSource(): string {
|
|
20
|
+
return readAsset('domain-md.schema.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getTemplateManifestSchema(): DomainRecord {
|
|
24
|
+
return JSON.parse(readAsset('template-manifest.schema.json')) as DomainRecord;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getOracleCapsuleSchema(): DomainRecord {
|
|
28
|
+
return JSON.parse(readAsset('oracle-capsule.schema.json')) as DomainRecord;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getOracleCapsuleSourceLockSchema(): DomainRecord {
|
|
32
|
+
return JSON.parse(readAsset('oracle-capsule-source-lock.schema.json')) as DomainRecord;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getOracleCapsuleContract(): string {
|
|
36
|
+
return readAsset('oracle-capsule-manifest-contract.md');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getOracleCapsuleJcsVectors(): unknown {
|
|
40
|
+
return JSON.parse(readAsset('oracle-capsule-jcs-vectors.json')) as unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getRules(): {
|
|
44
|
+
specVersion: string;
|
|
45
|
+
rules: Array<{ code: string; severity: string; description: string }>;
|
|
46
|
+
} {
|
|
47
|
+
return JSON.parse(readAsset('rules.json')) as ReturnType<typeof getRules>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { parseTemplateManifest } from './manifest.js';
|
|
2
|
+
export { TemplateValidationError } from './errors.js';
|
|
3
|
+
export { renderTemplateBundle } from './render.js';
|
|
4
|
+
export {
|
|
5
|
+
FileTemplateResolver,
|
|
6
|
+
HttpsTemplateResolver,
|
|
7
|
+
IpfsTemplateResolver,
|
|
8
|
+
relativeTemplateUri,
|
|
9
|
+
validateHttpsTarget,
|
|
10
|
+
} from './resolver.js';
|
|
11
|
+
export type * from './types.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Ajv as AjvCore, Options } from 'ajv';
|
|
2
|
+
import Ajv2020Import from 'ajv/dist/2020.js';
|
|
3
|
+
import addFormatsImport from 'ajv-formats';
|
|
4
|
+
import { isAlias, parseDocument, visit as visitYaml } from 'yaml';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MAX_LINKED_BYTES,
|
|
8
|
+
MAX_SCALAR_LENGTH,
|
|
9
|
+
MAX_YAML_DEPTH,
|
|
10
|
+
MAX_YAML_NODES,
|
|
11
|
+
} from '../constants.js';
|
|
12
|
+
import { getTemplateManifestSchema } from '../spec.js';
|
|
13
|
+
import type { TemplateManifest } from './types.js';
|
|
14
|
+
|
|
15
|
+
const Ajv2020 = Ajv2020Import as unknown as new (options?: Options) => AjvCore;
|
|
16
|
+
const addFormats = addFormatsImport as unknown as (ajv: AjvCore) => AjvCore;
|
|
17
|
+
const ajv = new Ajv2020({ allErrors: true, strict: true, strictRequired: false });
|
|
18
|
+
addFormats(ajv);
|
|
19
|
+
const validate = ajv.compile(getTemplateManifestSchema());
|
|
20
|
+
|
|
21
|
+
export function parseTemplateManifest(bytes: Uint8Array): TemplateManifest {
|
|
22
|
+
if (bytes.byteLength > MAX_LINKED_BYTES)
|
|
23
|
+
throw new Error(`Template manifest exceeds ${MAX_LINKED_BYTES} bytes.`);
|
|
24
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
25
|
+
const document = parseDocument(source, { prettyErrors: true, uniqueKeys: true, version: '1.2' });
|
|
26
|
+
if (document.errors.length > 0 || document.warnings.length > 0) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
[...document.errors, ...document.warnings].map((item) => item.message).join('; '),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const unsafe = new Set<string>();
|
|
32
|
+
let nodes = 0;
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let scalarTooLong = false;
|
|
35
|
+
visitYaml(document, {
|
|
36
|
+
Node(_key, node, path) {
|
|
37
|
+
const candidate = node as { anchor?: string; tag?: string; value?: unknown };
|
|
38
|
+
nodes += 1;
|
|
39
|
+
depth = Math.max(depth, path.length + 1);
|
|
40
|
+
if (typeof candidate.value === 'string' && candidate.value.length > MAX_SCALAR_LENGTH)
|
|
41
|
+
scalarTooLong = true;
|
|
42
|
+
if (isAlias(node)) unsafe.add('alias');
|
|
43
|
+
if (candidate.anchor) unsafe.add('anchor');
|
|
44
|
+
if (candidate.tag?.startsWith('!')) unsafe.add('custom-tag');
|
|
45
|
+
},
|
|
46
|
+
Pair(_key, pair) {
|
|
47
|
+
const key = pair.key as { value?: unknown } | null;
|
|
48
|
+
if (key?.value === '<<') unsafe.add('merge-key');
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (nodes > MAX_YAML_NODES || depth > MAX_YAML_DEPTH || scalarTooLong) {
|
|
52
|
+
throw new Error('Template manifest exceeds YAML node, depth, or scalar limits.');
|
|
53
|
+
}
|
|
54
|
+
if (unsafe.size > 0)
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Template manifest contains forbidden YAML features: ${[...unsafe].sort().join(', ')}.`,
|
|
57
|
+
);
|
|
58
|
+
const value: unknown = document.toJS({ maxAliasCount: 0 });
|
|
59
|
+
if (!validate(value)) {
|
|
60
|
+
const message = (validate.errors ?? [])
|
|
61
|
+
.map((error) => `${error.instancePath || '$'} ${error.message ?? 'is invalid'}`)
|
|
62
|
+
.join('; ');
|
|
63
|
+
throw new Error(`Invalid template manifest: ${message}`);
|
|
64
|
+
}
|
|
65
|
+
const manifest = value as TemplateManifest;
|
|
66
|
+
const derivedTypes = new Set<string>();
|
|
67
|
+
for (const bundle of manifest.bundles) {
|
|
68
|
+
if (derivedTypes.has(bundle.derived_type))
|
|
69
|
+
throw new Error(`Duplicate template bundle ${bundle.derived_type}.`);
|
|
70
|
+
derivedTypes.add(bundle.derived_type);
|
|
71
|
+
const paths = new Set<string>();
|
|
72
|
+
for (const file of bundle.files) {
|
|
73
|
+
if (!file.path.startsWith(`templates/${bundle.derived_type}/`)) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Template path ${file.path} does not match bundle type ${bundle.derived_type}.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (paths.has(file.path)) throw new Error(`Duplicate template path ${file.path}.`);
|
|
79
|
+
paths.add(file.path);
|
|
80
|
+
}
|
|
81
|
+
const domainFiles = bundle.files.filter((file) => file.role === 'domain.md');
|
|
82
|
+
if (
|
|
83
|
+
domainFiles.length !== 1 ||
|
|
84
|
+
domainFiles[0]?.media_type !== 'text/markdown' ||
|
|
85
|
+
domainFiles[0].required !== true ||
|
|
86
|
+
domainFiles[0].path !== `templates/${bundle.derived_type}/domain.md.tmpl`
|
|
87
|
+
) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Template bundle ${bundle.derived_type} must contain exactly one required Markdown domain.md.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return manifest;
|
|
94
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { CID } from 'multiformats/cid';
|
|
5
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
6
|
+
import { isAlias, parseDocument, stringify, visit as visitYaml } from 'yaml';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
MAX_LINKED_BYTES,
|
|
10
|
+
MAX_SCALAR_LENGTH,
|
|
11
|
+
MAX_YAML_DEPTH,
|
|
12
|
+
MAX_YAML_NODES,
|
|
13
|
+
SPEC_VERSION,
|
|
14
|
+
} from '../constants.js';
|
|
15
|
+
import { lint } from '../lint.js';
|
|
16
|
+
import type { DomainRecord } from '../types.js';
|
|
17
|
+
import { TemplateValidationError } from './errors.js';
|
|
18
|
+
import { parseTemplateManifest } from './manifest.js';
|
|
19
|
+
import {
|
|
20
|
+
FileTemplateResolver,
|
|
21
|
+
HttpsTemplateResolver,
|
|
22
|
+
IpfsTemplateResolver,
|
|
23
|
+
relativeTemplateUri,
|
|
24
|
+
} from './resolver.js';
|
|
25
|
+
import type {
|
|
26
|
+
RenderedFile,
|
|
27
|
+
RenderTemplateRequest,
|
|
28
|
+
RenderTemplateResult,
|
|
29
|
+
ResolvedTemplateResource,
|
|
30
|
+
TemplateFileRecord,
|
|
31
|
+
TemplateResolver,
|
|
32
|
+
} from './types.js';
|
|
33
|
+
|
|
34
|
+
function sha(bytes: Uint8Array): string {
|
|
35
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function canonical(value: unknown): string {
|
|
39
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
40
|
+
if (isRecord(value)) {
|
|
41
|
+
return `{${Object.entries(value)
|
|
42
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
43
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
|
|
44
|
+
.join(',')}}`;
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deterministicUuid(seed: string): string {
|
|
50
|
+
const bytes = createHash('sha256').update(seed).digest().subarray(0, 16);
|
|
51
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x50;
|
|
52
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
53
|
+
const hex = bytes.toString('hex');
|
|
54
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function verifyIdentity(
|
|
58
|
+
resource: ResolvedTemplateResource,
|
|
59
|
+
expectedSha?: string | null,
|
|
60
|
+
expectedCid?: string | null,
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
if (expectedSha && sha(resource.bytes) !== expectedSha.toLowerCase())
|
|
63
|
+
throw new TemplateValidationError(`SHA-256 mismatch for ${resource.uri}.`);
|
|
64
|
+
if (expectedCid) {
|
|
65
|
+
let cid: CID;
|
|
66
|
+
try {
|
|
67
|
+
cid = CID.parse(expectedCid);
|
|
68
|
+
} catch {
|
|
69
|
+
throw new TemplateValidationError(`Invalid CID ${expectedCid}.`);
|
|
70
|
+
}
|
|
71
|
+
if (cid.code !== 0x55 || cid.multihash.code !== sha256.code) {
|
|
72
|
+
throw new TemplateValidationError(
|
|
73
|
+
`CID verification supports raw sha2-256 CIDs only: ${expectedCid}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const digest = await sha256.digest(resource.bytes);
|
|
77
|
+
if (!Buffer.from(digest.digest).equals(Buffer.from(cid.multihash.digest)))
|
|
78
|
+
throw new TemplateValidationError(`CID mismatch for ${resource.uri}.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function chooseResolver(uri: string, resolvers: TemplateResolver[]): TemplateResolver {
|
|
83
|
+
const resolver = resolvers.find((candidate) => candidate.supports(uri));
|
|
84
|
+
if (!resolver) throw new Error(`No template resolver supports ${uri}.`);
|
|
85
|
+
return resolver;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is DomainRecord {
|
|
89
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface ParameterDefinition {
|
|
93
|
+
name: string;
|
|
94
|
+
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
|
95
|
+
required: boolean;
|
|
96
|
+
fill_at: 'author';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function parameterDefinitions(marker: DomainRecord): ParameterDefinition[] {
|
|
100
|
+
if (!Array.isArray(marker.parameters))
|
|
101
|
+
throw new TemplateValidationError('Template marker parameters must be an array.');
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
return marker.parameters.map((value) => {
|
|
104
|
+
if (
|
|
105
|
+
!isRecord(value) ||
|
|
106
|
+
typeof value.name !== 'string' ||
|
|
107
|
+
!['string', 'number', 'boolean', 'object', 'array'].includes(String(value.type)) ||
|
|
108
|
+
typeof value.required !== 'boolean' ||
|
|
109
|
+
value.fill_at !== 'author'
|
|
110
|
+
) {
|
|
111
|
+
throw new TemplateValidationError(
|
|
112
|
+
'Every template parameter must declare name, supported type, required, and fill_at: author.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (seen.has(value.name))
|
|
116
|
+
throw new TemplateValidationError(`Duplicate template parameter ${value.name}.`);
|
|
117
|
+
seen.add(value.name);
|
|
118
|
+
return {
|
|
119
|
+
name: value.name,
|
|
120
|
+
type: value.type as ParameterDefinition['type'],
|
|
121
|
+
required: value.required === true,
|
|
122
|
+
fill_at: 'author',
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function valueMatches(value: unknown, type: ParameterDefinition['type']): boolean {
|
|
128
|
+
if (type === 'array') return Array.isArray(value);
|
|
129
|
+
if (type === 'object') return isRecord(value);
|
|
130
|
+
return typeof value === type;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function replaceExactString(value: unknown, before: string, after: string): unknown {
|
|
134
|
+
if (value === before) return after;
|
|
135
|
+
if (Array.isArray(value)) return value.map((item) => replaceExactString(item, before, after));
|
|
136
|
+
if (!isRecord(value)) return value;
|
|
137
|
+
return Object.fromEntries(
|
|
138
|
+
Object.entries(value).map(([key, item]) => [key, replaceExactString(item, before, after)]),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function substitute(
|
|
143
|
+
value: unknown,
|
|
144
|
+
parameters: Record<string, unknown>,
|
|
145
|
+
allowed: Map<string, ParameterDefinition>,
|
|
146
|
+
used: Set<string>,
|
|
147
|
+
): unknown {
|
|
148
|
+
if (Array.isArray(value))
|
|
149
|
+
return value.map((child) => substitute(child, parameters, allowed, used));
|
|
150
|
+
if (!isRecord(value)) return value;
|
|
151
|
+
if (Object.keys(value).length === 1 && typeof value.$param === 'string') {
|
|
152
|
+
const definition = allowed.get(value.$param);
|
|
153
|
+
if (!definition)
|
|
154
|
+
throw new TemplateValidationError(`Unknown structured parameter ${value.$param}.`);
|
|
155
|
+
const resolved = parameters[value.$param];
|
|
156
|
+
if (resolved === undefined)
|
|
157
|
+
throw new TemplateValidationError(
|
|
158
|
+
`Required structured parameter ${value.$param} is unresolved.`,
|
|
159
|
+
);
|
|
160
|
+
if (!valueMatches(resolved, definition.type))
|
|
161
|
+
throw new TemplateValidationError(`Parameter ${value.$param} must be ${definition.type}.`);
|
|
162
|
+
used.add(value.$param);
|
|
163
|
+
return resolved;
|
|
164
|
+
}
|
|
165
|
+
return Object.fromEntries(
|
|
166
|
+
Object.entries(value).map(([key, child]) => [
|
|
167
|
+
key,
|
|
168
|
+
substitute(child, parameters, allowed, used),
|
|
169
|
+
]),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function markdownEscape(value: unknown): string {
|
|
174
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
|
175
|
+
throw new TemplateValidationError('Markdown parameters must be strings, numbers, or booleans.');
|
|
176
|
+
}
|
|
177
|
+
return String(value)
|
|
178
|
+
.replace(/[\\`*_[\]<>]/g, '\\$&')
|
|
179
|
+
.replace(/&/g, '&');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderMarkdownTemplate(
|
|
183
|
+
source: string,
|
|
184
|
+
file: TemplateFileRecord,
|
|
185
|
+
expectedProtocol: string,
|
|
186
|
+
derivedType: string,
|
|
187
|
+
parameters: Record<string, unknown>,
|
|
188
|
+
draftId: string,
|
|
189
|
+
): Uint8Array {
|
|
190
|
+
const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(source);
|
|
191
|
+
if (!match?.[1])
|
|
192
|
+
throw new TemplateValidationError(`Template ${file.path} requires YAML frontmatter.`);
|
|
193
|
+
const yaml = parseDocument(match[1], { prettyErrors: true, uniqueKeys: true, version: '1.2' });
|
|
194
|
+
if (yaml.errors.length > 0)
|
|
195
|
+
throw new TemplateValidationError(yaml.errors.map((error) => error.message).join('; '));
|
|
196
|
+
const unsafe = new Set<string>();
|
|
197
|
+
let nodes = 0;
|
|
198
|
+
let depth = 0;
|
|
199
|
+
let scalarTooLong = false;
|
|
200
|
+
visitYaml(yaml, {
|
|
201
|
+
Node(_key, node, path) {
|
|
202
|
+
const candidate = node as { anchor?: string; tag?: string; value?: unknown };
|
|
203
|
+
nodes += 1;
|
|
204
|
+
depth = Math.max(depth, path.length + 1);
|
|
205
|
+
if (typeof candidate.value === 'string' && candidate.value.length > MAX_SCALAR_LENGTH)
|
|
206
|
+
scalarTooLong = true;
|
|
207
|
+
if (isAlias(node)) unsafe.add('alias');
|
|
208
|
+
if (candidate.anchor) unsafe.add('anchor');
|
|
209
|
+
if (candidate.tag?.startsWith('!')) unsafe.add('custom-tag');
|
|
210
|
+
},
|
|
211
|
+
Pair(_key, pair) {
|
|
212
|
+
const key = pair.key as { value?: unknown } | null;
|
|
213
|
+
if (key?.value === '<<') unsafe.add('merge-key');
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
if (nodes > MAX_YAML_NODES || depth > MAX_YAML_DEPTH || scalarTooLong || unsafe.size > 0) {
|
|
217
|
+
throw new TemplateValidationError(`Template ${file.path} contains unsafe or excessive YAML.`);
|
|
218
|
+
}
|
|
219
|
+
const frontmatter = yaml.toJS({ maxAliasCount: 0 }) as DomainRecord;
|
|
220
|
+
const marker = frontmatter['x-template'];
|
|
221
|
+
const markerKeys = new Set([
|
|
222
|
+
'is_template',
|
|
223
|
+
'instantiates_type',
|
|
224
|
+
'protocol',
|
|
225
|
+
'template_version',
|
|
226
|
+
'parameters',
|
|
227
|
+
]);
|
|
228
|
+
if (
|
|
229
|
+
!isRecord(marker) ||
|
|
230
|
+
marker.is_template !== true ||
|
|
231
|
+
marker.protocol !== expectedProtocol ||
|
|
232
|
+
marker.instantiates_type !== derivedType ||
|
|
233
|
+
typeof marker.template_version !== 'string' ||
|
|
234
|
+
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(marker.template_version) ||
|
|
235
|
+
Object.keys(marker).some((key) => !markerKeys.has(key) && !key.startsWith('x-'))
|
|
236
|
+
) {
|
|
237
|
+
throw new TemplateValidationError(`Template marker mismatch in ${file.path}.`);
|
|
238
|
+
}
|
|
239
|
+
const definitions = parameterDefinitions(marker);
|
|
240
|
+
const allowed = new Map(definitions.map((definition) => [definition.name, definition]));
|
|
241
|
+
for (const key of Object.keys(parameters))
|
|
242
|
+
if (!allowed.has(key)) throw new TemplateValidationError(`Undeclared parameter ${key}.`);
|
|
243
|
+
for (const definition of definitions) {
|
|
244
|
+
if (definition.required && parameters[definition.name] === undefined)
|
|
245
|
+
throw new TemplateValidationError(`Required parameter ${definition.name} is unresolved.`);
|
|
246
|
+
if (
|
|
247
|
+
parameters[definition.name] !== undefined &&
|
|
248
|
+
!valueMatches(parameters[definition.name], definition.type)
|
|
249
|
+
)
|
|
250
|
+
throw new TemplateValidationError(`Parameter ${definition.name} must be ${definition.type}.`);
|
|
251
|
+
}
|
|
252
|
+
delete frontmatter['x-template'];
|
|
253
|
+
const used = new Set<string>();
|
|
254
|
+
const substitutedFrontmatter = substitute(frontmatter, parameters, allowed, used) as DomainRecord;
|
|
255
|
+
const originalSubject =
|
|
256
|
+
isRecord(substitutedFrontmatter.domain) && typeof substitutedFrontmatter.domain.id === 'string'
|
|
257
|
+
? substitutedFrontmatter.domain.id
|
|
258
|
+
: undefined;
|
|
259
|
+
const draftSubject = `urn:uuid:${draftId}`;
|
|
260
|
+
const renderedFrontmatter =
|
|
261
|
+
originalSubject === undefined
|
|
262
|
+
? substitutedFrontmatter
|
|
263
|
+
: (replaceExactString(substitutedFrontmatter, originalSubject, draftSubject) as DomainRecord);
|
|
264
|
+
if (isRecord(renderedFrontmatter.domain)) {
|
|
265
|
+
renderedFrontmatter.domain.id = draftSubject;
|
|
266
|
+
renderedFrontmatter.domain.iid = null;
|
|
267
|
+
renderedFrontmatter.domain.type = derivedType;
|
|
268
|
+
renderedFrontmatter.domain.class = expectedProtocol;
|
|
269
|
+
renderedFrontmatter.domain.status = 'draft';
|
|
270
|
+
}
|
|
271
|
+
if (isRecord(renderedFrontmatter.constitution))
|
|
272
|
+
renderedFrontmatter.constitution.subject = draftSubject;
|
|
273
|
+
renderedFrontmatter.version = SPEC_VERSION;
|
|
274
|
+
if (isRecord(renderedFrontmatter.conformance))
|
|
275
|
+
renderedFrontmatter.conformance.profile = 'authoring_draft';
|
|
276
|
+
|
|
277
|
+
const body = source
|
|
278
|
+
.slice(match[0].length)
|
|
279
|
+
.replace(/\{\{([A-Za-z0-9_.-]+)\}\}/g, (_whole, name: string) => {
|
|
280
|
+
const definition = allowed.get(name);
|
|
281
|
+
if (!definition) throw new TemplateValidationError(`Unknown Markdown parameter ${name}.`);
|
|
282
|
+
const value = parameters[name];
|
|
283
|
+
if (value === undefined)
|
|
284
|
+
throw new TemplateValidationError(`Markdown parameter ${name} is unresolved.`);
|
|
285
|
+
used.add(name);
|
|
286
|
+
return markdownEscape(value);
|
|
287
|
+
});
|
|
288
|
+
for (const definition of definitions) {
|
|
289
|
+
if (definition.required && !used.has(definition.name))
|
|
290
|
+
throw new TemplateValidationError(
|
|
291
|
+
`Required parameter ${definition.name} was declared but unused.`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (/\{\{[^{}\n]+\}\}/.test(body))
|
|
295
|
+
throw new TemplateValidationError(`Unresolved placeholder remains in ${file.path}.`);
|
|
296
|
+
return new TextEncoder().encode(
|
|
297
|
+
`---\n${stringify(renderedFrontmatter, { lineWidth: 0 }).trimEnd()}\n---\n${body.replace(/^\n/, '')}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function resolveRelative(manifestUri: string, path: string): Promise<string> {
|
|
302
|
+
return manifestUri.startsWith('file:')
|
|
303
|
+
? FileTemplateResolver.resolveRelative(manifestUri, path)
|
|
304
|
+
: relativeTemplateUri(manifestUri, path);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function renderTemplateBundle(
|
|
308
|
+
request: RenderTemplateRequest,
|
|
309
|
+
): Promise<RenderTemplateResult> {
|
|
310
|
+
const resolvers = request.resolvers ?? [
|
|
311
|
+
new FileTemplateResolver(),
|
|
312
|
+
new HttpsTemplateResolver(),
|
|
313
|
+
...(request.ipfsGateway ? [new IpfsTemplateResolver(request.ipfsGateway)] : []),
|
|
314
|
+
];
|
|
315
|
+
if (request.manifestUri.startsWith('https:') && !request.manifestSha256 && !request.manifestCid) {
|
|
316
|
+
throw new Error('HTTPS manifests require --manifest-sha256 or --manifest-cid.');
|
|
317
|
+
}
|
|
318
|
+
if (request.manifestUri.startsWith('ipfs:') && !request.ipfsGateway) {
|
|
319
|
+
throw new Error('IPFS templates require an explicit HTTPS gateway.');
|
|
320
|
+
}
|
|
321
|
+
const context = { maxBytes: MAX_LINKED_BYTES, timeoutMs: request.timeoutMs ?? 10_000 };
|
|
322
|
+
const manifestResource = await chooseResolver(request.manifestUri, resolvers).resolve(
|
|
323
|
+
request.manifestUri,
|
|
324
|
+
context,
|
|
325
|
+
);
|
|
326
|
+
const ipfsManifest = request.manifestUri.startsWith('ipfs:')
|
|
327
|
+
? new URL(request.manifestUri)
|
|
328
|
+
: undefined;
|
|
329
|
+
if (
|
|
330
|
+
ipfsManifest &&
|
|
331
|
+
ipfsManifest.pathname !== '' &&
|
|
332
|
+
ipfsManifest.pathname !== '/' &&
|
|
333
|
+
!request.manifestSha256
|
|
334
|
+
) {
|
|
335
|
+
throw new Error('IPFS manifests below a root path require an explicit SHA-256 digest.');
|
|
336
|
+
}
|
|
337
|
+
const impliedCid =
|
|
338
|
+
ipfsManifest && (ipfsManifest.pathname === '' || ipfsManifest.pathname === '/')
|
|
339
|
+
? ipfsManifest.hostname
|
|
340
|
+
: undefined;
|
|
341
|
+
await verifyIdentity(manifestResource, request.manifestSha256, request.manifestCid ?? impliedCid);
|
|
342
|
+
let manifest;
|
|
343
|
+
try {
|
|
344
|
+
manifest = parseTemplateManifest(manifestResource.bytes);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw new TemplateValidationError(
|
|
347
|
+
`Template manifest is invalid: ${error instanceof Error ? error.message : String(error)}`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (manifest.protocol !== request.expectedProtocol)
|
|
351
|
+
throw new TemplateValidationError(
|
|
352
|
+
'Template manifest protocol does not match expectedProtocol.',
|
|
353
|
+
);
|
|
354
|
+
const bundle = manifest.bundles.find(
|
|
355
|
+
(candidate) => candidate.derived_type === request.derivedType,
|
|
356
|
+
);
|
|
357
|
+
if (!bundle)
|
|
358
|
+
throw new TemplateValidationError(`Template manifest has no ${request.derivedType} bundle.`);
|
|
359
|
+
const parameterDigest = sha(new TextEncoder().encode(canonical(request.parameters)));
|
|
360
|
+
const draftId = deterministicUuid(
|
|
361
|
+
`${manifest.protocol}:${bundle.bundle_version}:${sha(manifestResource.bytes)}:${parameterDigest}`,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const rendered: RenderedFile[] = [];
|
|
365
|
+
for (const file of bundle.files) {
|
|
366
|
+
const uri = file.uri ?? (await resolveRelative(manifestResource.uri, file.path));
|
|
367
|
+
const resource = await chooseResolver(uri, resolvers).resolve(uri, context);
|
|
368
|
+
if (!manifestResource.uri.startsWith('file:') && resource.uri.startsWith('file:')) {
|
|
369
|
+
throw new TemplateValidationError('Remote manifests cannot resolve local file resources.');
|
|
370
|
+
}
|
|
371
|
+
if (resource.bytes.byteLength > file.max_bytes) {
|
|
372
|
+
throw new TemplateValidationError(
|
|
373
|
+
`Template ${file.path} exceeds its declared max_bytes limit.`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
await verifyIdentity(resource, file.sha256, file.cid);
|
|
377
|
+
let source: string;
|
|
378
|
+
try {
|
|
379
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(resource.bytes);
|
|
380
|
+
} catch {
|
|
381
|
+
throw new TemplateValidationError(`Template ${file.path} is not valid UTF-8.`);
|
|
382
|
+
}
|
|
383
|
+
const bytes =
|
|
384
|
+
file.media_type === 'text/markdown'
|
|
385
|
+
? renderMarkdownTemplate(
|
|
386
|
+
source,
|
|
387
|
+
file,
|
|
388
|
+
request.expectedProtocol,
|
|
389
|
+
request.derivedType,
|
|
390
|
+
request.parameters,
|
|
391
|
+
draftId,
|
|
392
|
+
)
|
|
393
|
+
: resource.bytes;
|
|
394
|
+
rendered.push({
|
|
395
|
+
role: file.role,
|
|
396
|
+
path: file.path.replace(/^templates\/[a-z0-9_-]+\//, '').replace(/\.tmpl$/, ''),
|
|
397
|
+
bytes,
|
|
398
|
+
sha256: sha(bytes),
|
|
399
|
+
sourceUri: resource.uri,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
const domain = rendered.find((file) => basename(file.path) === 'domain.md');
|
|
403
|
+
if (!domain) throw new TemplateValidationError('Rendered bundle must contain domain.md.');
|
|
404
|
+
const domainText = new TextDecoder().decode(domain.bytes);
|
|
405
|
+
const report = lint(domainText, { sourceName: domain.path, expectedProfile: 'authoring_draft' });
|
|
406
|
+
if (!report.ok)
|
|
407
|
+
throw new TemplateValidationError(
|
|
408
|
+
`Rendered domain.md failed validation: ${report.findings
|
|
409
|
+
.filter((item) => item.severity === 'error')
|
|
410
|
+
.map((item) => `${item.code}: ${item.message}`)
|
|
411
|
+
.join('; ')}`,
|
|
412
|
+
);
|
|
413
|
+
const provenance = stringify(
|
|
414
|
+
{
|
|
415
|
+
provenance: {
|
|
416
|
+
state: 'authoring_draft',
|
|
417
|
+
specification_version: SPEC_VERSION,
|
|
418
|
+
protocol: manifest.protocol,
|
|
419
|
+
protocol_version: manifest.protocol_version,
|
|
420
|
+
manifest: { uri: manifestResource.uri, sha256: sha(manifestResource.bytes) },
|
|
421
|
+
bundle: { derived_type: bundle.derived_type, version: bundle.bundle_version },
|
|
422
|
+
draft_domain_id: `urn:uuid:${draftId}`,
|
|
423
|
+
files: rendered.map((file) => ({
|
|
424
|
+
role: file.role,
|
|
425
|
+
path: file.path,
|
|
426
|
+
source_uri: file.sourceUri,
|
|
427
|
+
sha256: file.sha256,
|
|
428
|
+
})),
|
|
429
|
+
parameters_digest: parameterDigest,
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
{ lineWidth: 0 },
|
|
433
|
+
);
|
|
434
|
+
return { files: rendered, provenance, report, manifest };
|
|
435
|
+
}
|