@openship/protocol 0.0.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 +12 -0
- package/bin/openship.mjs +96 -0
- package/dist/package-meta.json +7 -0
- package/dist/skill/SKILL.md +18 -0
- package/dist/skill/references/examples/invalid/changes-submission.json +9 -0
- package/dist/skill/references/examples/invalid/changes-violation.json +7 -0
- package/dist/skill/references/examples/invalid/discovery.json +13 -0
- package/dist/skill/references/examples/invalid/sources-manifest.json +11 -0
- package/dist/skill/references/examples/invalid/systems-ownership.json +29 -0
- package/dist/skill/references/examples/invalid/systems.json +34 -0
- package/dist/skill/references/examples/valid/changes-accepted.json +13 -0
- package/dist/skill/references/examples/valid/changes-policy.json +12 -0
- package/dist/skill/references/examples/valid/changes-status.json +12 -0
- package/dist/skill/references/examples/valid/changes-submission.json +11 -0
- package/dist/skill/references/examples/valid/changes-violation.json +16 -0
- package/dist/skill/references/examples/valid/discovery.json +20 -0
- package/dist/skill/references/examples/valid/sources-bundle.json +10 -0
- package/dist/skill/references/examples/valid/sources-manifest.json +27 -0
- package/dist/skill/references/examples/valid/systems.json +55 -0
- package/dist/skill/references/openship-changes.md +135 -0
- package/dist/skill/references/openship-sources.md +143 -0
- package/dist/skill/references/openship-systems.md +165 -0
- package/dist/skill/references/openship.md +125 -0
- package/dist/skill/references/schemas/changes-accepted.schema.json +17 -0
- package/dist/skill/references/schemas/changes-policy.schema.json +44 -0
- package/dist/skill/references/schemas/changes-status.schema.json +22 -0
- package/dist/skill/references/schemas/changes-submission.schema.json +34 -0
- package/dist/skill/references/schemas/changes-violation.schema.json +32 -0
- package/dist/skill/references/schemas/discovery.schema.json +61 -0
- package/dist/skill/references/schemas/sources-bundle.schema.json +27 -0
- package/dist/skill/references/schemas/sources-manifest.schema.json +79 -0
- package/dist/skill/references/schemas/systems.schema.json +121 -0
- package/package.json +52 -0
- package/src/index.d.ts +36 -0
- package/src/index.js +454 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const digestPattern = /^sha256:[0-9a-f]{64}$/;
|
|
6
|
+
const hexPattern = /^[0-9a-f]{64}$/;
|
|
7
|
+
const idPattern = /^[A-Za-z0-9._:-]+$/;
|
|
8
|
+
|
|
9
|
+
export class OpenShipValidationError extends Error {
|
|
10
|
+
constructor(path, message, code = "invalid_openship") {
|
|
11
|
+
super(`${path}: ${message}`);
|
|
12
|
+
this.name = "OpenShipValidationError";
|
|
13
|
+
this.path = path;
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fail = (path, message, code) => { throw new OpenShipValidationError(path, message, code); };
|
|
19
|
+
const object = (value, path) => {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail(path, "must be an object");
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
const string = (value, path) => {
|
|
24
|
+
if (typeof value !== "string" || value.length === 0) fail(path, "must be a non-empty string");
|
|
25
|
+
return value;
|
|
26
|
+
};
|
|
27
|
+
const array = (value, path) => {
|
|
28
|
+
if (!Array.isArray(value)) fail(path, "must be an array");
|
|
29
|
+
return value;
|
|
30
|
+
};
|
|
31
|
+
const unique = (values, path) => {
|
|
32
|
+
if (new Set(values).size !== values.length) fail(path, "must contain unique values");
|
|
33
|
+
};
|
|
34
|
+
const envelope = (value, capability) => {
|
|
35
|
+
const payload = object(value, "$");
|
|
36
|
+
if (payload.openship !== "1.0") fail("$.openship", "unsupported major version", "unsupported_version");
|
|
37
|
+
if (payload.capability !== capability) fail("$.capability", `must equal ${capability}`);
|
|
38
|
+
return payload;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function sha256Hex(value) {
|
|
42
|
+
return bytesToHex(sha256(typeof value === "string" ? encoder.encode(value) : value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function compareUtf8(left, right) {
|
|
46
|
+
const a = encoder.encode(left);
|
|
47
|
+
const b = encoder.encode(right);
|
|
48
|
+
const count = Math.min(a.length, b.length);
|
|
49
|
+
for (let index = 0; index < count; index += 1) {
|
|
50
|
+
if (a[index] !== b[index]) return a[index] - b[index];
|
|
51
|
+
}
|
|
52
|
+
return a.length - b.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function assertSafePath(value, path = "path") {
|
|
56
|
+
string(value, path);
|
|
57
|
+
if (value !== value.normalize("NFC")) fail(path, "must be NFC normalized");
|
|
58
|
+
if (encoder.encode(value).length > 512) fail(path, "exceeds 512 UTF-8 bytes");
|
|
59
|
+
if (value.startsWith("/") || value.includes("\\") || value.includes("\0")) fail(path, "must be repository-relative");
|
|
60
|
+
if (value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) fail(path, "contains an unsafe segment");
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function matchOpenShipPattern(pattern, path) {
|
|
65
|
+
assertSafePath(pattern, "pattern");
|
|
66
|
+
assertSafePath(path, "path");
|
|
67
|
+
if (!pattern.endsWith("/**")) return pattern === path;
|
|
68
|
+
const prefix = pattern.slice(0, -3);
|
|
69
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function computeSourcesDigest(files) {
|
|
73
|
+
return `sha256:${sha256Hex(files.map((file) => `${file.path}\0${file.sha256}\n`).join(""))}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodeOpenShipBase64(content, path = "base64") {
|
|
77
|
+
if (typeof content !== "string") fail(path, "must be a string");
|
|
78
|
+
const compact = content.replace(/\s+/g, "");
|
|
79
|
+
let binary;
|
|
80
|
+
try {
|
|
81
|
+
if (typeof Buffer !== "undefined") {
|
|
82
|
+
const bytes = Uint8Array.from(Buffer.from(compact, "base64"));
|
|
83
|
+
const canonical = Buffer.from(bytes).toString("base64");
|
|
84
|
+
if (compact !== canonical && compact !== canonical.replace(/=+$/, "")) fail(path, "must be canonical base64");
|
|
85
|
+
return bytes;
|
|
86
|
+
}
|
|
87
|
+
binary = atob(compact);
|
|
88
|
+
} catch {
|
|
89
|
+
fail(path, "is not valid base64");
|
|
90
|
+
}
|
|
91
|
+
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
92
|
+
const canonical = encodeOpenShipBase64(bytes);
|
|
93
|
+
if (compact !== canonical && compact !== canonical.replace(/=+$/, "")) fail(path, "must be canonical base64");
|
|
94
|
+
return bytes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function encodeOpenShipBase64(bytes) {
|
|
98
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
99
|
+
let binary = "";
|
|
100
|
+
for (const value of bytes) binary += String.fromCharCode(value);
|
|
101
|
+
return btoa(binary);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function decodeBundleEntry(entry, path) {
|
|
105
|
+
const payload = object(entry, path);
|
|
106
|
+
const content = typeof payload.content === "string" ? payload.content : fail(`${path}.content`, "must be a string");
|
|
107
|
+
if (payload.encoding === "utf-8") {
|
|
108
|
+
const bytes = encoder.encode(content);
|
|
109
|
+
try {
|
|
110
|
+
if (new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== content) fail(`${path}.content`, "does not round-trip as UTF-8");
|
|
111
|
+
} catch {
|
|
112
|
+
fail(`${path}.content`, "does not round-trip as UTF-8");
|
|
113
|
+
}
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|
|
116
|
+
if (payload.encoding !== "base64") fail(`${path}.encoding`, "must be utf-8 or base64");
|
|
117
|
+
const compact = content.replace(/\s+/g, "");
|
|
118
|
+
const bytes = decodeOpenShipBase64(content, `${path}.content`);
|
|
119
|
+
const canonical = encodeOpenShipBase64(bytes);
|
|
120
|
+
if (compact !== canonical && compact !== canonical.replace(/=+$/, "")) fail(`${path}.content`, "must be canonical base64");
|
|
121
|
+
return bytes;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateManifest(value) {
|
|
125
|
+
const manifest = envelope(value, "sources");
|
|
126
|
+
if (!digestPattern.test(manifest.digest)) fail("$.digest", "must be a sha256 digest");
|
|
127
|
+
const project = object(manifest.project, "$.project");
|
|
128
|
+
string(project.name, "$.project.name");
|
|
129
|
+
string(project.description, "$.project.description");
|
|
130
|
+
const totals = object(manifest.totals, "$.totals");
|
|
131
|
+
if (!Number.isInteger(totals.files) || totals.files < 0) fail("$.totals.files", "must be a non-negative integer");
|
|
132
|
+
if (!Number.isInteger(totals.bytes) || totals.bytes < 0) fail("$.totals.bytes", "must be a non-negative integer");
|
|
133
|
+
const files = array(manifest.files, "$.files");
|
|
134
|
+
const paths = [];
|
|
135
|
+
files.forEach((raw, index) => {
|
|
136
|
+
const file = object(raw, `$.files[${index}]`);
|
|
137
|
+
paths.push(assertSafePath(file.path, `$.files[${index}].path`));
|
|
138
|
+
if (!Number.isInteger(file.size) || file.size < 0) fail(`$.files[${index}].size`, "must be a non-negative integer");
|
|
139
|
+
if (!hexPattern.test(file.sha256)) fail(`$.files[${index}].sha256`, "must be 64 lowercase hexadecimal characters");
|
|
140
|
+
if (file.encoding !== "utf-8" && file.encoding !== "base64") fail(`$.files[${index}].encoding`, "must be utf-8 or base64");
|
|
141
|
+
string(file.mediaType, `$.files[${index}].mediaType`);
|
|
142
|
+
if (file.type !== "file" && file.type !== "symlink") fail(`$.files[${index}].type`, "must be file or symlink");
|
|
143
|
+
if (file.type === "symlink") assertSafePath(file.target, `$.files[${index}].target`);
|
|
144
|
+
});
|
|
145
|
+
unique(paths, "$.files[].path");
|
|
146
|
+
if (!paths.every((path, index) => index === 0 || compareUtf8(paths[index - 1], path) < 0)) fail("$.files", "paths must be sorted by ascending UTF-8 bytes");
|
|
147
|
+
if (totals.files !== files.length) fail("$.totals.files", "does not equal the file count");
|
|
148
|
+
if (totals.bytes !== files.reduce((sum, file) => sum + file.size, 0)) fail("$.totals.bytes", "does not equal the sum of file sizes");
|
|
149
|
+
if (manifest.digest !== computeSourcesDigest(files)) fail("$.digest", "does not match the manifest file metadata");
|
|
150
|
+
return manifest;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function validateSources(manifestValue, bundleValue, options = {}) {
|
|
154
|
+
const manifest = validateManifest(manifestValue);
|
|
155
|
+
const bundle = envelope(bundleValue, "sources");
|
|
156
|
+
if (bundle.digest !== manifest.digest) fail("$.bundle.digest", "does not match the Manifest digest");
|
|
157
|
+
const bundleFiles = object(bundle.files, "$.bundle.files");
|
|
158
|
+
const bundlePaths = Object.keys(bundleFiles).sort(compareUtf8);
|
|
159
|
+
const manifestPaths = manifest.files.map((file) => file.path);
|
|
160
|
+
if (JSON.stringify(bundlePaths) !== JSON.stringify(manifestPaths)) fail("$.bundle.files", "keys must exactly equal the Manifest paths");
|
|
161
|
+
const files = [];
|
|
162
|
+
let decodedBytes = 0;
|
|
163
|
+
for (const metadata of manifest.files) {
|
|
164
|
+
const entry = object(bundleFiles[metadata.path], `$.bundle.files[${JSON.stringify(metadata.path)}]`);
|
|
165
|
+
if (entry.encoding !== metadata.encoding) fail(`$.bundle.files[${JSON.stringify(metadata.path)}].encoding`, "does not match the Manifest");
|
|
166
|
+
const bytes = decodeBundleEntry(entry, `$.bundle.files[${JSON.stringify(metadata.path)}]`);
|
|
167
|
+
if (bytes.length !== metadata.size) fail(`$.bundle.files[${JSON.stringify(metadata.path)}].content`, "decoded size does not match the Manifest");
|
|
168
|
+
if (sha256Hex(bytes) !== metadata.sha256) fail(`$.bundle.files[${JSON.stringify(metadata.path)}].content`, "SHA-256 does not match the Manifest");
|
|
169
|
+
decodedBytes += bytes.length;
|
|
170
|
+
if (decodedBytes > (options.maxDecodedBytes ?? Number.POSITIVE_INFINITY)) fail("$.bundle.files", "decoded source exceeds the consumer limit", "source_too_large");
|
|
171
|
+
files.push({ metadata, bytes });
|
|
172
|
+
}
|
|
173
|
+
return { manifest, bundle, files, decodedBytes };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function validateDiscovery(value) {
|
|
177
|
+
const discovery = envelope(value, "discovery");
|
|
178
|
+
const project = object(discovery.project, "$.project");
|
|
179
|
+
string(project.name, "$.project.name");
|
|
180
|
+
string(project.description, "$.project.description");
|
|
181
|
+
const capabilities = object(discovery.capabilities, "$.capabilities");
|
|
182
|
+
const sources = object(capabilities.sources, "$.capabilities.sources");
|
|
183
|
+
const absoluteUrl = (value, path) => {
|
|
184
|
+
const url = string(value, path);
|
|
185
|
+
try { new URL(url); } catch { fail(path, "must be an absolute URL"); }
|
|
186
|
+
return url;
|
|
187
|
+
};
|
|
188
|
+
for (const key of ["manifest", "bundle"]) {
|
|
189
|
+
const url = string(sources[key], `$.capabilities.sources.${key}`);
|
|
190
|
+
try { new URL(url); } catch { fail(`$.capabilities.sources.${key}`, "must be an absolute URL"); }
|
|
191
|
+
}
|
|
192
|
+
if (capabilities.systems) absoluteUrl(object(capabilities.systems, "$.capabilities.systems").document, "$.capabilities.systems.document");
|
|
193
|
+
if (capabilities.changes) {
|
|
194
|
+
const changes = object(capabilities.changes, "$.capabilities.changes");
|
|
195
|
+
for (const key of ["policy", "submit", "status"]) absoluteUrl(changes[key], `$.capabilities.changes.${key}`);
|
|
196
|
+
if (!String(changes.status).includes("{changeId}")) fail("$.capabilities.changes.status", "must contain {changeId}");
|
|
197
|
+
}
|
|
198
|
+
return discovery;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assertAcyclic(ids, pairs, path) {
|
|
202
|
+
const outgoing = new Map(ids.map((id) => [id, []]));
|
|
203
|
+
const indegree = new Map(ids.map((id) => [id, 0]));
|
|
204
|
+
for (const [from, to] of pairs) {
|
|
205
|
+
if (!outgoing.has(from) || !outgoing.has(to)) continue;
|
|
206
|
+
outgoing.get(from).push(to);
|
|
207
|
+
indegree.set(to, indegree.get(to) + 1);
|
|
208
|
+
}
|
|
209
|
+
const queue = ids.filter((id) => indegree.get(id) === 0);
|
|
210
|
+
let seen = 0;
|
|
211
|
+
while (queue.length > 0) {
|
|
212
|
+
const current = queue.shift();
|
|
213
|
+
seen += 1;
|
|
214
|
+
for (const next of outgoing.get(current)) {
|
|
215
|
+
indegree.set(next, indegree.get(next) - 1);
|
|
216
|
+
if (indegree.get(next) === 0) queue.push(next);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (seen !== ids.length) fail(path, "must be acyclic");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function validateSystems(value, options = {}) {
|
|
223
|
+
const payload = envelope(value, "systems");
|
|
224
|
+
const source = object(payload.source, "$.source");
|
|
225
|
+
validateSources(source.manifest, source.bundle, options);
|
|
226
|
+
const system = object(payload.system, "$.system");
|
|
227
|
+
string(system.id, "$.system.id");
|
|
228
|
+
string(system.name, "$.system.name");
|
|
229
|
+
const rootNodeId = string(system.rootNodeId, "$.system.rootNodeId");
|
|
230
|
+
const nodes = array(system.nodes, "$.system.nodes");
|
|
231
|
+
const nodeById = new Map();
|
|
232
|
+
for (const [index, raw] of nodes.entries()) {
|
|
233
|
+
const node = object(raw, `$.system.nodes[${index}]`);
|
|
234
|
+
const id = string(node.id, `$.system.nodes[${index}].id`);
|
|
235
|
+
if (!idPattern.test(id)) fail(`$.system.nodes[${index}].id`, "has an invalid identifier");
|
|
236
|
+
if (nodeById.has(id)) fail(`$.system.nodes[${index}].id`, "must be unique");
|
|
237
|
+
if (!["Root", "Host", "Container", "Process", "Library"].includes(node.kind)) fail(`$.system.nodes[${index}].kind`, "is not a v1 node kind");
|
|
238
|
+
string(node.name, `$.system.nodes[${index}].name`);
|
|
239
|
+
const metadata = object(node.metadata, `$.system.nodes[${index}].metadata`);
|
|
240
|
+
if (!["first_party", "third_party"].includes(metadata.ownership)) fail(`$.system.nodes[${index}].metadata.ownership`, "must be first_party or third_party");
|
|
241
|
+
nodeById.set(id, node);
|
|
242
|
+
}
|
|
243
|
+
const roots = nodes.filter((node) => node.kind === "Root");
|
|
244
|
+
if (roots.length !== 1 || roots[0].id !== rootNodeId) fail("$.system.rootNodeId", "must identify the one Root node");
|
|
245
|
+
if (roots[0].parentId !== undefined) fail("$.system.nodes", "the Root must not have a parent");
|
|
246
|
+
for (const node of nodes) {
|
|
247
|
+
const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
|
|
248
|
+
if (node.kind === "Host" && parent?.kind !== "Root") fail(`$.system.nodes.${node.id}.parentId`, "Host must have the Root parent");
|
|
249
|
+
if (node.kind === "Container" && parent?.kind !== "Host") fail(`$.system.nodes.${node.id}.parentId`, "Container must have a Host parent");
|
|
250
|
+
if (node.kind === "Process" && parent?.kind !== "Host" && parent?.kind !== "Container") fail(`$.system.nodes.${node.id}.parentId`, "Process must have a Host or Container parent");
|
|
251
|
+
if (node.kind === "Library" && node.parentId !== undefined) fail(`$.system.nodes.${node.id}.parentId`, "Library must not have a parent");
|
|
252
|
+
for (const selector of node.sourceSelectors ?? []) {
|
|
253
|
+
if (!source.manifest.files.some((file) => matchOpenShipPattern(selector, file.path))) fail(`$.system.nodes.${node.id}.sourceSelectors`, `${selector} matches no source path`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
assertAcyclic(nodes.map((node) => node.id), nodes.filter((node) => node.parentId).map((node) => [node.parentId, node.id]), "$.system.nodes");
|
|
257
|
+
const edges = array(system.edges, "$.system.edges");
|
|
258
|
+
unique(edges.map((edge) => edge.id), "$.system.edges[].id");
|
|
259
|
+
for (const edge of edges) {
|
|
260
|
+
const from = nodeById.get(edge.fromNodeId);
|
|
261
|
+
const to = nodeById.get(edge.toNodeId);
|
|
262
|
+
if (!from || !to) fail(`$.system.edges.${edge.id}`, "references a missing node");
|
|
263
|
+
if (from.kind !== "Process") fail(`$.system.edges.${edge.id}.fromNodeId`, "must reference a Process");
|
|
264
|
+
if (edge.type === "Dependency" && to.kind !== "Library") fail(`$.system.edges.${edge.id}.toNodeId`, "Dependency must target a Library");
|
|
265
|
+
if ((edge.type === "Runtime" || edge.type === "Dataflow") && to.kind !== "Process" && to.kind !== "Container") fail(`$.system.edges.${edge.id}.toNodeId`, "must target a Process or Container");
|
|
266
|
+
if (!["Runtime", "Dataflow", "Dependency"].includes(edge.type)) fail(`$.system.edges.${edge.id}.type`, "is not a v1 edge type");
|
|
267
|
+
}
|
|
268
|
+
const ids = nodes.map((node) => node.id);
|
|
269
|
+
assertAcyclic(ids, edges.filter((edge) => edge.type === "Dataflow").map((edge) => [edge.fromNodeId, edge.toNodeId]), "$.system.edges[Dataflow]");
|
|
270
|
+
assertAcyclic(ids, edges.filter((edge) => edge.type === "Dependency").map((edge) => [edge.fromNodeId, edge.toNodeId]), "$.system.edges[Dependency]");
|
|
271
|
+
const context = system.context;
|
|
272
|
+
if (!context) return payload;
|
|
273
|
+
object(context, "$.system.context");
|
|
274
|
+
const concerns = new Set(context.concerns ?? []);
|
|
275
|
+
const documents = new Map();
|
|
276
|
+
for (const document of context.documents ?? []) {
|
|
277
|
+
if (!["Document", "Skill", "Prompt"].includes(document.kind)) fail("$.system.context.documents", "contains an invalid document kind");
|
|
278
|
+
const expected = `sha256:${sha256Hex(`${document.kind}\n${document.title}\n${document.language}\n${document.text}`)}`;
|
|
279
|
+
if (document.hash !== expected) fail(`$.system.context.documents.${document.hash}`, "hash does not match canonical document content");
|
|
280
|
+
if (documents.has(document.hash)) fail("$.system.context.documents", `duplicates ${document.hash}`);
|
|
281
|
+
documents.set(document.hash, document);
|
|
282
|
+
}
|
|
283
|
+
assertAcyclic([...documents.keys()], [...documents.values()].filter((doc) => doc.supersedes && documents.has(doc.supersedes)).map((doc) => [doc.hash, doc.supersedes]), "$.system.context.documents[].supersedes");
|
|
284
|
+
for (const cell of context.matrix ?? []) {
|
|
285
|
+
if (!nodeById.has(cell.nodeId)) fail("$.system.context.matrix", `references missing node ${cell.nodeId}`);
|
|
286
|
+
if (!concerns.has(cell.concern)) fail("$.system.context.matrix", `references undeclared concern ${cell.concern}`);
|
|
287
|
+
for (const hash of cell.documentRefs ?? []) if (documents.get(hash)?.kind !== "Document") fail("$.system.context.matrix", `Document reference ${hash} is missing or has the wrong kind`);
|
|
288
|
+
for (const hash of cell.skillRefs ?? []) if (documents.get(hash)?.kind !== "Skill") fail("$.system.context.matrix", `Skill reference ${hash} is missing or has the wrong kind`);
|
|
289
|
+
}
|
|
290
|
+
for (const hash of context.systemPromptRefs ?? []) if (documents.get(hash)?.kind !== "Prompt") fail("$.system.context.systemPromptRefs", `${hash} is missing or has the wrong kind`);
|
|
291
|
+
const artifactIds = [];
|
|
292
|
+
const sourcePaths = new Set(source.manifest.files.map((file) => file.path));
|
|
293
|
+
for (const artifact of context.artifacts ?? []) {
|
|
294
|
+
artifactIds.push(artifact.id);
|
|
295
|
+
if (!nodeById.has(artifact.nodeId)) fail("$.system.context.artifacts", `references missing node ${artifact.nodeId}`);
|
|
296
|
+
if (!concerns.has(artifact.concern)) fail("$.system.context.artifacts", `references undeclared concern ${artifact.concern}`);
|
|
297
|
+
if (artifact.type === "Code") {
|
|
298
|
+
if (!Array.isArray(artifact.sourcePaths) || artifact.sourcePaths.length === 0) fail(`$.system.context.artifacts.${artifact.id}.sourcePaths`, "is required for Code");
|
|
299
|
+
for (const path of artifact.sourcePaths) if (!sourcePaths.has(path)) fail(`$.system.context.artifacts.${artifact.id}.sourcePaths`, `references missing source ${path}`);
|
|
300
|
+
if (artifact.text !== undefined) fail(`$.system.context.artifacts.${artifact.id}.text`, "Code must not duplicate source content");
|
|
301
|
+
} else if ((artifact.type === "Summary" || artifact.type === "Docs") && typeof artifact.text !== "string") fail(`$.system.context.artifacts.${artifact.id}.text`, "is required");
|
|
302
|
+
else if (!["Summary", "Docs", "Code"].includes(artifact.type)) fail(`$.system.context.artifacts.${artifact.id}.type`, "is not a v1 artifact type");
|
|
303
|
+
}
|
|
304
|
+
unique(artifactIds, "$.system.context.artifacts[].id");
|
|
305
|
+
return payload;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function validateChangesDocument(value) {
|
|
309
|
+
const payload = envelope(value, "changes");
|
|
310
|
+
if (payload.base !== undefined && !digestPattern.test(payload.base)) fail("$.base", "must be a sha256 digest");
|
|
311
|
+
if (payload.digest !== undefined && !digestPattern.test(payload.digest)) fail("$.digest", "must be a sha256 digest");
|
|
312
|
+
if (payload.files !== undefined) {
|
|
313
|
+
const files = object(payload.files, "$.files");
|
|
314
|
+
for (const [path, entry] of Object.entries(files)) {
|
|
315
|
+
assertSafePath(path, `$.files.${path}`);
|
|
316
|
+
if (entry !== null) decodeBundleEntry(entry, `$.files.${path}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return payload;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function validateChangesSubmission(value) {
|
|
323
|
+
const payload = validateChangesDocument(value);
|
|
324
|
+
if (!digestPattern.test(payload.base)) fail("$.base", "must be a sha256 digest");
|
|
325
|
+
string(payload.title, "$.title");
|
|
326
|
+
string(payload.intent, "$.intent");
|
|
327
|
+
const files = object(payload.files, "$.files");
|
|
328
|
+
if (Object.keys(files).length === 0) fail("$.files", "must contain at least one replacement or deletion");
|
|
329
|
+
return payload;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function validateChangesLifecycle(value, requireStatusUrl) {
|
|
333
|
+
const payload = validateChangesDocument(value);
|
|
334
|
+
string(payload.changeId, "$.changeId");
|
|
335
|
+
if (!digestPattern.test(payload.base)) fail("$.base", "must be a sha256 digest");
|
|
336
|
+
if (!digestPattern.test(payload.digest)) fail("$.digest", "must be a sha256 digest");
|
|
337
|
+
if (!["pending", "processing", "ready", "rejected", "failed"].includes(payload.status)) fail("$.status", "is not a Changes lifecycle status");
|
|
338
|
+
const candidate = string(payload.candidateOrigin, "$.candidateOrigin");
|
|
339
|
+
try { new URL(candidate); } catch { fail("$.candidateOrigin", "must be an absolute URL"); }
|
|
340
|
+
if (requireStatusUrl) {
|
|
341
|
+
const statusUrl = string(payload.statusUrl, "$.statusUrl");
|
|
342
|
+
try { new URL(statusUrl); } catch { fail("$.statusUrl", "must be an absolute URL"); }
|
|
343
|
+
}
|
|
344
|
+
return payload;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function validateChangesAccepted(value) {
|
|
348
|
+
return validateChangesLifecycle(value, true);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function validateChangesStatus(value) {
|
|
352
|
+
return validateChangesLifecycle(value, false);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function validateChangesPolicy(value) {
|
|
356
|
+
const payload = validateChangesDocument(value);
|
|
357
|
+
for (const [key, patterns] of [["writable", payload.writable], ["protected", payload.protected]]) {
|
|
358
|
+
const values = array(patterns, `$.${key}`);
|
|
359
|
+
for (const [index, pattern] of values.entries()) assertSafePath(pattern, `$.${key}[${index}]`);
|
|
360
|
+
unique(values, `$.${key}`);
|
|
361
|
+
}
|
|
362
|
+
object(payload.limits, "$.limits");
|
|
363
|
+
return payload;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function validateChangesViolation(value) {
|
|
367
|
+
const payload = validateChangesDocument(value);
|
|
368
|
+
string(payload.error, "$.error");
|
|
369
|
+
string(payload.message, "$.message");
|
|
370
|
+
array(payload.violations, "$.violations");
|
|
371
|
+
return payload;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function normalizeOpenShipOrigin(origin, options = {}) {
|
|
375
|
+
let url;
|
|
376
|
+
try { url = new URL(origin); } catch { fail("origin", "must be an absolute URL", "invalid_origin"); }
|
|
377
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
378
|
+
url.search = "";
|
|
379
|
+
url.hash = "";
|
|
380
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
381
|
+
if (url.protocol !== "https:" && !(options.allowLoopbackHttp && url.protocol === "http:" && loopback)) fail("origin", "must use HTTPS outside loopback development", "invalid_origin");
|
|
382
|
+
return url.toString().replace(/\/$/, "");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function fetchJson(fetcher, url, path) {
|
|
386
|
+
const response = await fetcher(url, { headers: { Accept: "application/json" }, credentials: "omit" });
|
|
387
|
+
if (!response.ok) throw new OpenShipValidationError(path, `GET ${url} returned ${response.status}`, "fetch_failed");
|
|
388
|
+
try { return await response.json(); } catch { throw new OpenShipValidationError(path, `GET ${url} did not return JSON`, "fetch_failed"); }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export async function fetchOpenShip(origin, options = {}) {
|
|
392
|
+
const normalized = normalizeOpenShipOrigin(origin, options);
|
|
393
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
394
|
+
if (!fetcher) fail("fetch", "is not available", "fetch_failed");
|
|
395
|
+
const discovery = validateDiscovery(await fetchJson(fetcher, `${normalized}/.well-known/openship.json`, "discovery"));
|
|
396
|
+
if (options.preferSystems !== false && discovery.capabilities.systems) {
|
|
397
|
+
const document = await fetchJson(fetcher, discovery.capabilities.systems.document, "systems");
|
|
398
|
+
validateSystems(document, options);
|
|
399
|
+
return { origin: normalized, discovery, snapshot: { kind: "systems", document }, verified: validateSources(document.source.manifest, document.source.bundle, options) };
|
|
400
|
+
}
|
|
401
|
+
const [manifest, bundle] = await Promise.all([
|
|
402
|
+
fetchJson(fetcher, discovery.capabilities.sources.manifest, "manifest"),
|
|
403
|
+
fetchJson(fetcher, discovery.capabilities.sources.bundle, "bundle"),
|
|
404
|
+
]);
|
|
405
|
+
const verified = validateSources(manifest, bundle, options);
|
|
406
|
+
return { origin: normalized, discovery, snapshot: { kind: "sources", manifest, bundle }, verified };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function bytesEqual(left, right) {
|
|
410
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function diffSources(base, current) {
|
|
414
|
+
if (!base?.files || !current?.files) fail("snapshots", "must be verified Sources values");
|
|
415
|
+
const baseByPath = new Map(base.files.map((file) => [file.metadata.path, file]));
|
|
416
|
+
const currentByPath = new Map(current.files.map((file) => [file.metadata.path, file]));
|
|
417
|
+
return [...new Set([...baseByPath.keys(), ...currentByPath.keys()])].sort(compareUtf8).flatMap((path) => {
|
|
418
|
+
const before = baseByPath.get(path);
|
|
419
|
+
const after = currentByPath.get(path);
|
|
420
|
+
if (!after) return [{ path, operation: "delete", before, after: null }];
|
|
421
|
+
if (!before) return [{ path, operation: "create", before: null, after }];
|
|
422
|
+
if (!bytesEqual(before.bytes, after.bytes) || JSON.stringify(before.metadata) !== JSON.stringify(after.metadata)) {
|
|
423
|
+
return [{ path, operation: "replace", before, after }];
|
|
424
|
+
}
|
|
425
|
+
return [];
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function composeChangesSubmission(base, current, input) {
|
|
430
|
+
if (!base?.manifest || !current?.manifest) fail("snapshots", "must be verified Sources values");
|
|
431
|
+
const baseByPath = new Map(base.files.map((file) => [file.metadata.path, file]));
|
|
432
|
+
const currentByPath = new Map(current.files.map((file) => [file.metadata.path, file]));
|
|
433
|
+
const paths = [...new Set([...baseByPath.keys(), ...currentByPath.keys()])].sort(compareUtf8);
|
|
434
|
+
const files = {};
|
|
435
|
+
for (const path of paths) {
|
|
436
|
+
const before = baseByPath.get(path);
|
|
437
|
+
const after = currentByPath.get(path);
|
|
438
|
+
if (!after) files[path] = null;
|
|
439
|
+
else if (!before || !bytesEqual(before.bytes, after.bytes) || before.metadata.type !== after.metadata.type || before.metadata.target !== after.metadata.target) {
|
|
440
|
+
files[path] = {
|
|
441
|
+
encoding: after.metadata.encoding,
|
|
442
|
+
content: after.metadata.encoding === "base64" ? encodeOpenShipBase64(after.bytes) : new TextDecoder().decode(after.bytes),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return validateChangesSubmission({
|
|
447
|
+
openship: "1.0",
|
|
448
|
+
capability: "changes",
|
|
449
|
+
base: base.manifest.digest,
|
|
450
|
+
title: string(input.title, "title"),
|
|
451
|
+
intent: string(input.intent, "intent"),
|
|
452
|
+
files,
|
|
453
|
+
});
|
|
454
|
+
}
|