@openship/protocol 0.0.2 → 0.1.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/src/index.js CHANGED
@@ -179,24 +179,43 @@ export function validateSources(manifestValue, bundleValue, options = {}) {
179
179
 
180
180
  export function validateDiscovery(value) {
181
181
  const discovery = envelope(value, "discovery");
182
+ const absoluteUrl = (value, path) => {
183
+ const raw = string(value, path);
184
+ let url;
185
+ try { url = new URL(raw); } catch { fail(path, "must be an absolute HTTPS URL"); }
186
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
187
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) fail(path, "must be an absolute HTTPS URL outside local development");
188
+ return raw;
189
+ };
182
190
  const project = object(discovery.project, "$.project");
183
191
  string(project.name, "$.project.name");
184
192
  string(project.description, "$.project.description");
193
+ const agent = object(discovery.agent, "$.agent");
194
+ string(agent.summary, "$.agent.summary");
195
+ string(agent.instructions, "$.agent.instructions");
196
+ absoluteUrl(agent.skill, "$.agent.skill");
197
+ if (discovery.page !== undefined) absoluteUrl(discovery.page, "$.page");
185
198
  const capabilities = object(discovery.capabilities, "$.capabilities");
186
199
  const sources = object(capabilities.sources, "$.capabilities.sources");
187
- const absoluteUrl = (value, path) => {
188
- const url = string(value, path);
189
- try { new URL(url); } catch { fail(path, "must be an absolute URL"); }
190
- return url;
191
- };
200
+ string(sources.description, "$.capabilities.sources.description");
192
201
  for (const key of ["manifest", "bundle"]) {
193
- const url = string(sources[key], `$.capabilities.sources.${key}`);
194
- try { new URL(url); } catch { fail(`$.capabilities.sources.${key}`, "must be an absolute URL"); }
202
+ absoluteUrl(sources[key], `$.capabilities.sources.${key}`);
203
+ }
204
+ for (const key of ["mcp", "archive", "instructions"]) {
205
+ if (sources[key] !== undefined) absoluteUrl(sources[key], `$.capabilities.sources.${key}`);
206
+ }
207
+ if (sources.file !== undefined) {
208
+ absoluteUrl(sources.file, "$.capabilities.sources.file");
209
+ if (!String(sources.file).includes("{path}")) fail("$.capabilities.sources.file", "must contain {path}");
210
+ }
211
+ if (capabilities.systems) {
212
+ const systems = object(capabilities.systems, "$.capabilities.systems");
213
+ string(systems.description, "$.capabilities.systems.description");
214
+ absoluteUrl(systems.document, "$.capabilities.systems.document");
195
215
  }
196
- if (sources.mcp !== undefined) absoluteUrl(sources.mcp, "$.capabilities.sources.mcp");
197
- if (capabilities.systems) absoluteUrl(object(capabilities.systems, "$.capabilities.systems").document, "$.capabilities.systems.document");
198
216
  if (capabilities.changes) {
199
217
  const changes = object(capabilities.changes, "$.capabilities.changes");
218
+ string(changes.description, "$.capabilities.changes.description");
200
219
  for (const key of ["policy", "submit", "status"]) absoluteUrl(changes[key], `$.capabilities.changes.${key}`);
201
220
  if (!String(changes.status).includes("{changeId}")) fail("$.capabilities.changes.status", "must contain {changeId}");
202
221
  }
@@ -231,54 +250,132 @@ export function validateSystems(value, options = {}) {
231
250
  const system = object(payload.system, "$.system");
232
251
  string(system.id, "$.system.id");
233
252
  string(system.name, "$.system.name");
234
- const rootNodeId = string(system.rootNodeId, "$.system.rootNodeId");
235
- const nodes = array(system.nodes, "$.system.nodes");
236
- const nodeById = new Map();
237
- for (const [index, raw] of nodes.entries()) {
238
- const node = object(raw, `$.system.nodes[${index}]`);
239
- const id = string(node.id, `$.system.nodes[${index}].id`);
240
- if (!idPattern.test(id)) fail(`$.system.nodes[${index}].id`, "has an invalid identifier");
241
- if (nodeById.has(id)) fail(`$.system.nodes[${index}].id`, "must be unique");
242
- if (!["Root", "Host", "Container", "Process", "Library"].includes(node.kind)) fail(`$.system.nodes[${index}].kind`, "is not a v1 node kind");
243
- string(node.name, `$.system.nodes[${index}].name`);
244
- const metadata = object(node.metadata, `$.system.nodes[${index}].metadata`);
245
- if (!["first_party", "third_party"].includes(metadata.ownership)) fail(`$.system.nodes[${index}].metadata.ownership`, "must be first_party or third_party");
246
- nodeById.set(id, node);
253
+ if (payload.systemsVersion !== "2.0") fail("$.systemsVersion", "requires layered Systems 2.0; legacy Systems is unsupported", "unsupported_version");
254
+ for (const key of ["nodes", "edges", "rootNodeId"]) if (key in system) fail(`$.system.${key}`, "legacy graph members are unsupported");
255
+ const layers = array(system.layers, "$.system.layers");
256
+ if (!layers.length) fail("$.system.layers", "requires at least one layer");
257
+ const identifier = (value, path) => {
258
+ if (!idPattern.test(string(value, path))) fail(path, "has an invalid identifier");
259
+ return value;
260
+ };
261
+ identifier(system.id, "$.system.id");
262
+ const jsonValue = (value, path, ancestors = new Set()) => {
263
+ if (value === null || typeof value === "string" || typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value))) return;
264
+ if (typeof value !== "object" || ancestors.has(value) || (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value)))) fail(path, "must be a JSON value");
265
+ ancestors.add(value);
266
+ for (const item of Array.isArray(value) ? value : Object.values(value)) jsonValue(item, path, ancestors);
267
+ ancestors.delete(value);
268
+ };
269
+ const configuration = (entries, path) => {
270
+ if (entries === undefined) return;
271
+ const names = [];
272
+ for (const [index, raw] of array(entries, path).entries()) {
273
+ const at = `${path}[${index}]`, entry = object(raw, at);
274
+ names.push(string(entry.name, `${at}.name`));
275
+ string(entry.description, `${at}.description`);
276
+ if (typeof entry.required !== "boolean") fail(`${at}.required`, "must be boolean");
277
+ if (entry.sensitive !== undefined && typeof entry.sensitive !== "boolean") fail(`${at}.sensitive`, "must be boolean");
278
+ if ("value" in entry) jsonValue(entry.value, `${at}.value`);
279
+ if ("value" in entry && (entry.sensitive === true || entry.secretRef !== undefined)) fail(at, "secret configuration cannot contain a literal value");
280
+ if (entry.secretRef !== undefined) {
281
+ const ref = object(entry.secretRef, `${at}.secretRef`);
282
+ identifier(ref.nodeId, `${at}.secretRef.nodeId`);
283
+ string(ref.key, `${at}.secretRef.key`);
284
+ }
285
+ }
286
+ unique(names, path);
287
+ };
288
+ const nodeById = new Map(), layerByNode = new Map(), layerById = new Map();
289
+ for (const [index, raw] of layers.entries()) {
290
+ const at = `$.system.layers[${index}]`, layer = object(raw, at);
291
+ identifier(layer.id, `${at}.id`);
292
+ if (layerById.has(layer.id)) fail(`${at}.id`, "must be unique");
293
+ layerById.set(layer.id, layer);
294
+ string(layer.name, `${at}.name`);
295
+ if (!["logical", "technical", "provider", "custom"].includes(layer.role)) fail(`${at}.role`, "invalid layer role");
296
+ const rootNodeId = identifier(layer.rootNodeId, `${at}.rootNodeId`);
297
+ const nodes = array(layer.nodes, `${at}.nodes`), local = new Map();
298
+ for (const [i, rawNode] of nodes.entries()) {
299
+ const path = `${at}.nodes[${i}]`, node = object(rawNode, path);
300
+ identifier(node.id, `${path}.id`);
301
+ if (nodeById.has(node.id)) fail(`${path}.id`, "must be globally unique");
302
+ if (!["Root", "Block", "Store", "Host", "Container", "Process", "Library"].includes(node.kind)) fail(`${path}.kind`, "invalid node kind");
303
+ string(node.name, `${path}.name`);
304
+ const metadata = object(node.metadata, `${path}.metadata`);
305
+ if (!["first_party", "third_party"].includes(metadata.ownership)) fail(`${path}.metadata.ownership`, "must be first_party or third_party");
306
+ configuration(node.configuration, `${path}.configuration`);
307
+ if (node.sourceSelectors !== undefined) {
308
+ unique(array(node.sourceSelectors, `${path}.sourceSelectors`), `${path}.sourceSelectors`);
309
+ for (const selector of node.sourceSelectors) {
310
+ string(selector, `${path}.sourceSelectors`);
311
+ if (!source.manifest.files.some((file) => matchOpenShipPattern(selector, file.path))) fail(`${path}.sourceSelectors`, `${selector} matches no source path`);
312
+ }
313
+ }
314
+ nodeById.set(node.id, node); local.set(node.id, node); layerByNode.set(node.id, index);
315
+ }
316
+ const roots = nodes.filter((node) => node.kind === "Root");
317
+ if (roots.length !== 1 || roots[0].id !== rootNodeId || roots[0].parentId !== undefined) fail(`${at}.rootNodeId`, "must identify the one parentless Root");
318
+ for (const node of nodes) if (node.id !== rootNodeId && !local.has(node.parentId)) fail(`${at}.nodes.${node.id}.parentId`, "must reference a parent in this layer");
319
+ assertAcyclic([...local.keys()], nodes.filter((node) => node.parentId).map((node) => [node.parentId, node.id]), `${at}.nodes`);
320
+ const edges = array(layer.edges, `${at}.edges`);
321
+ unique(edges.map((edge) => identifier(object(edge, `${at}.edges`).id, `${at}.edges.id`)), `${at}.edges`);
322
+ for (const edge of edges) {
323
+ const from = local.get(edge.fromNodeId), to = local.get(edge.toNodeId);
324
+ if (!from || !to || from.kind === "Root" || to.kind === "Root") fail(`${at}.edges.${edge.id}`, "endpoints must be non-root nodes in this layer");
325
+ if (!["Runtime", "Dataflow", "Dependency"].includes(edge.type)) fail(`${at}.edges.${edge.id}.type`, "invalid edge type");
326
+ }
327
+ for (const type of ["Dataflow", "Dependency"]) assertAcyclic([...local.keys()], edges.filter((edge) => edge.type === type).map((edge) => [edge.fromNodeId, edge.toNodeId]), `${at}.edges[${type}]`);
328
+ }
329
+ const refinements = array(system.refinements, "$.system.refinements");
330
+ unique(refinements.map((ref) => identifier(object(ref, "$.system.refinements").id, "$.system.refinements.id")), "$.system.refinements");
331
+ for (const ref of refinements) {
332
+ if (!nodeById.has(ref.fromNodeId) || !nodeById.has(ref.toNodeId) || layerByNode.get(ref.fromNodeId) <= layerByNode.get(ref.toNodeId)) fail(`$.system.refinements.${ref.id}`, "must connect a concrete node to a node in an earlier layer");
247
333
  }
248
- const roots = nodes.filter((node) => node.kind === "Root");
249
- if (roots.length !== 1 || roots[0].id !== rootNodeId) fail("$.system.rootNodeId", "must identify the one Root node");
250
- if (roots[0].parentId !== undefined) fail("$.system.nodes", "the Root must not have a parent");
251
- for (const node of nodes) {
252
- const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
253
- if (node.kind === "Host" && parent?.kind !== "Root") fail(`$.system.nodes.${node.id}.parentId`, "Host must have the Root parent");
254
- if (node.kind === "Container" && parent?.kind !== "Host") fail(`$.system.nodes.${node.id}.parentId`, "Container must have a Host parent");
255
- if (node.kind === "Process" && parent?.kind !== "Host" && parent?.kind !== "Container") fail(`$.system.nodes.${node.id}.parentId`, "Process must have a Host or Container parent");
256
- if (node.kind === "Library" && node.parentId !== undefined) fail(`$.system.nodes.${node.id}.parentId`, "Library must not have a parent");
257
- for (const selector of node.sourceSelectors ?? []) {
258
- if (!source.manifest.files.some((file) => matchOpenShipPattern(selector, file.path))) fail(`$.system.nodes.${node.id}.sourceSelectors`, `${selector} matches no source path`);
334
+ const instances = array(system.instances ?? [], "$.system.instances");
335
+ unique(instances.map((instance) => identifier(object(instance, "$.system.instances").id, "$.system.instances.id")), "$.system.instances");
336
+ for (const instance of instances) {
337
+ const at = `$.system.instances.${instance.id}`;
338
+ string(instance.name, `${at}.name`); string(instance.environment, `${at}.environment`);
339
+ const layer = layerById.get(instance.layerId);
340
+ if (!layer) fail(`${at}.layerId`, "must reference a layer");
341
+ const bindings = array(instance.bindings, `${at}.bindings`);
342
+ unique(bindings.map((binding) => object(binding, `${at}.bindings`).nodeId), `${at}.bindings`);
343
+ for (const binding of bindings) {
344
+ const node = nodeById.get(binding.nodeId);
345
+ if (!node || !layer.nodes.includes(node) || node.kind === "Root") fail(`${at}.bindings`, "must bind non-root nodes in the instance layer");
346
+ if (binding.resourceId !== undefined) string(binding.resourceId, `${at}.resourceId`);
347
+ configuration(binding.configuration, `${at}.configuration`);
348
+ if (binding.state !== undefined) {
349
+ if (node.kind !== "Store") fail(`${at}.state`, "database state requires a Store node");
350
+ const state = object(binding.state, `${at}.state`);
351
+ if (state.appliedMigration !== undefined) string(state.appliedMigration, `${at}.state.appliedMigration`);
352
+ if (state.snapshot !== undefined) {
353
+ const snapshot = object(state.snapshot, `${at}.state.snapshot`);
354
+ string(snapshot.ref, `${at}.state.snapshot.ref`);
355
+ if (typeof snapshot.capturedAt !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(snapshot.capturedAt) || !Number.isFinite(Date.parse(snapshot.capturedAt))) fail(`${at}.state.snapshot.capturedAt`, "must be an RFC3339 timestamp");
356
+ const [year, month, day] = snapshot.capturedAt.slice(0, 10).split("-").map(Number);
357
+ if (day < 1 || day > new Date(Date.UTC(year, month, 0)).getUTCDate()) fail(`${at}.state.snapshot.capturedAt`, "must be a valid calendar date");
358
+ if (snapshot.digest !== undefined && !digestPattern.test(snapshot.digest)) fail(`${at}.state.snapshot.digest`, "must be a sha256 digest");
359
+ }
360
+ }
259
361
  }
260
362
  }
261
- assertAcyclic(nodes.map((node) => node.id), nodes.filter((node) => node.parentId).map((node) => [node.parentId, node.id]), "$.system.nodes");
262
- const edges = array(system.edges, "$.system.edges");
263
- unique(edges.map((edge) => edge.id), "$.system.edges[].id");
264
- for (const edge of edges) {
265
- const from = nodeById.get(edge.fromNodeId);
266
- const to = nodeById.get(edge.toNodeId);
267
- if (!from || !to) fail(`$.system.edges.${edge.id}`, "references a missing node");
268
- if (from.kind !== "Process") fail(`$.system.edges.${edge.id}.fromNodeId`, "must reference a Process");
269
- if (edge.type === "Dependency" && to.kind !== "Library") fail(`$.system.edges.${edge.id}.toNodeId`, "Dependency must target a Library");
270
- 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");
271
- if (!["Runtime", "Dataflow", "Dependency"].includes(edge.type)) fail(`$.system.edges.${edge.id}.type`, "is not a v1 edge type");
363
+ for (const entries of [...nodeById.values()].map((node) => node.configuration).concat(instances.flatMap((instance) => instance.bindings.map((binding) => binding.configuration)))) {
364
+ for (const entry of entries ?? []) if (entry.secretRef && (!nodeById.has(entry.secretRef.nodeId) || nodeById.get(entry.secretRef.nodeId).kind === "Root")) fail("$.system.configuration.secretRef.nodeId", "must reference a component");
272
365
  }
273
- const ids = nodes.map((node) => node.id);
274
- assertAcyclic(ids, edges.filter((edge) => edge.type === "Dataflow").map((edge) => [edge.fromNodeId, edge.toNodeId]), "$.system.edges[Dataflow]");
275
- assertAcyclic(ids, edges.filter((edge) => edge.type === "Dependency").map((edge) => [edge.fromNodeId, edge.toNodeId]), "$.system.edges[Dependency]");
276
366
  const context = system.context;
277
367
  if (!context) return payload;
278
368
  object(context, "$.system.context");
369
+ for (const key of ["concerns", "documents", "matrix", "artifacts", "systemPromptRefs"]) if (context[key] !== undefined) array(context[key], `$.system.context.${key}`);
370
+ unique(context.concerns ?? [], "$.system.context.concerns");
371
+ for (const concern of context.concerns ?? []) string(concern, "$.system.context.concerns");
279
372
  const concerns = new Set(context.concerns ?? []);
280
373
  const documents = new Map();
281
374
  for (const document of context.documents ?? []) {
375
+ object(document, "$.system.context.documents");
376
+ string(document.title, "$.system.context.documents.title");
377
+ string(document.language, "$.system.context.documents.language");
378
+ if (typeof document.text !== "string") fail("$.system.context.documents.text", "must be a string");
282
379
  if (!["Document", "Skill", "Prompt"].includes(document.kind)) fail("$.system.context.documents", "contains an invalid document kind");
283
380
  const expected = `sha256:${sha256Hex(`${document.kind}\n${document.title}\n${document.language}\n${document.text}`)}`;
284
381
  if (document.hash !== expected) fail(`$.system.context.documents.${document.hash}`, "hash does not match canonical document content");
@@ -287,6 +384,8 @@ export function validateSystems(value, options = {}) {
287
384
  }
288
385
  assertAcyclic([...documents.keys()], [...documents.values()].filter((doc) => doc.supersedes && documents.has(doc.supersedes)).map((doc) => [doc.hash, doc.supersedes]), "$.system.context.documents[].supersedes");
289
386
  for (const cell of context.matrix ?? []) {
387
+ object(cell, "$.system.context.matrix");
388
+ for (const key of ["documentRefs", "skillRefs"]) if (cell[key] !== undefined) unique(array(cell[key], `$.system.context.matrix.${key}`), `$.system.context.matrix.${key}`);
290
389
  if (!nodeById.has(cell.nodeId)) fail("$.system.context.matrix", `references missing node ${cell.nodeId}`);
291
390
  if (!concerns.has(cell.concern)) fail("$.system.context.matrix", `references undeclared concern ${cell.concern}`);
292
391
  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`);
@@ -296,7 +395,8 @@ export function validateSystems(value, options = {}) {
296
395
  const artifactIds = [];
297
396
  const sourcePaths = new Set(source.manifest.files.map((file) => file.path));
298
397
  for (const artifact of context.artifacts ?? []) {
299
- artifactIds.push(artifact.id);
398
+ object(artifact, "$.system.context.artifacts");
399
+ artifactIds.push(identifier(artifact.id, "$.system.context.artifacts.id"));
300
400
  if (!nodeById.has(artifact.nodeId)) fail("$.system.context.artifacts", `references missing node ${artifact.nodeId}`);
301
401
  if (!concerns.has(artifact.concern)) fail("$.system.context.artifacts", `references undeclared concern ${artifact.concern}`);
302
402
  if (artifact.type === "Code") {