@protoboardai/cloud 0.0.0-stage → 0.2.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.
@@ -0,0 +1,607 @@
1
+ /**
2
+ * Part revision envelope, `protoboard.part-revision/v1`: the unit the parts
3
+ * service stores and serves (docs/specs/tool-integrations.md § 6).
4
+ *
5
+ * One envelope is one immutable revision of one part: the UHD definition, the
6
+ * exact revisions of the definitions it references, the files that come with
7
+ * it (artifacts and evidence, by content digest), and where it came from.
8
+ * Supplier offers are not technical facts and are not part of it.
9
+ *
10
+ * Pure: shared by the server, the client and the publishing scripts. Uses only
11
+ * node:crypto, so the stack can import it without extra dependencies.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+
15
+ export const PART_REVISION_SCHEMA = "protoboard.part-revision/v1";
16
+
17
+ /**
18
+ * The envelope identifier of the open UHD library protocol
19
+ * (`uhd-library/v1`, UHD docs/library-protocol.md § 4). The format is the
20
+ * same as PART_REVISION_SCHEMA's, member for member and digest for digest.
21
+ * `schema` is inside the digest and revisions never change, so the revisions
22
+ * already published keep PART_REVISION_SCHEMA, and so do new ones until the
23
+ * catalog moves over deliberately (it would change every envelope digest
24
+ * that projects pin). The service accepts and serves both, and lists both in
25
+ * its discovery document as the protocol's § 4.4 allows.
26
+ */
27
+ export const UHD_PART_REVISION_SCHEMA = "uhd.part-revision/v1";
28
+
29
+ /** Envelope identifiers accepted for publishing and reading. */
30
+ export const ENVELOPE_SCHEMAS = [PART_REVISION_SCHEMA, UHD_PART_REVISION_SCHEMA] as const;
31
+ export type EnvelopeSchema = (typeof ENVELOPE_SCHEMAS)[number];
32
+
33
+ /**
34
+ * UHD schema versions this service accepts: the `@deltarobotics/uhd` minor
35
+ * line a definition is written against (`uhdSchemaVersion`: a library built
36
+ * with UHD 0.2.1 records 0.2.0). Before 1.0 a minor version may break the
37
+ * schema, so the range names each minor line it reads. 0.3.0 only widens
38
+ * the schema (net ports, the board marker, piezo and charge vocabulary): a
39
+ * definition written for 0.2 is a valid 0.3 definition, so this release,
40
+ * built against 0.3.0, reads both lines. A client before it reads 0.2 only
41
+ * and refuses a revision recorded as 0.3.0.
42
+ */
43
+ export const SUPPORTED_UHD_SCHEMA: UhdSchemaRange = { min: "0.2.0", below: "0.4.0" };
44
+
45
+ export interface UhdSchemaRange {
46
+ /** Lowest accepted version, inclusive. */
47
+ min: string;
48
+ /** First version no longer accepted. */
49
+ below: string;
50
+ }
51
+
52
+ /**
53
+ * The parts of a UHD ModuleDef this service reads. The definition is stored
54
+ * and served unchanged; everything else in it is opaque here, so the service
55
+ * does not pin a UHD release at the type level.
56
+ */
57
+ export interface UhdModuleDef {
58
+ id: string;
59
+ name: string;
60
+ description?: string;
61
+ version?: string;
62
+ manufacturer?: string;
63
+ part_number?: string;
64
+ tags?: string[];
65
+ categories?: string[];
66
+ interfaces: { id: string; domain: string; exposed?: boolean; protocols?: { type: string; [key: string]: unknown }[]; [key: string]: unknown }[];
67
+ children?: { id: string; moduleDefId: string; [key: string]: unknown }[];
68
+ harnesses?: { id: string; artifacts?: UhdArtifactDef[] }[];
69
+ artifacts?: UhdArtifactDef[];
70
+ domains?: { domain: string }[];
71
+ [key: string]: unknown;
72
+ }
73
+
74
+ export interface UhdArtifactDef {
75
+ id: string;
76
+ name?: string;
77
+ type?: string;
78
+ url?: string;
79
+ filePath?: string;
80
+ mimeType?: string;
81
+ format?: string;
82
+ role?: string;
83
+ interfaceId?: string;
84
+ [key: string]: unknown;
85
+ }
86
+
87
+ /** An exact revision of a part. */
88
+ export interface PartRef {
89
+ partId: string;
90
+ revision: number;
91
+ }
92
+
93
+ /**
94
+ * What a file is for. The first four are UHD `ArtifactDef.role`; `thumbnail`
95
+ * is the library's preview image, `license` the terms a vendor file ships
96
+ * under, `attachment` anything else the definition references.
97
+ */
98
+ export const ARTIFACT_ROLES = ["body", "interface", "source", "drawing", "thumbnail", "license", "attachment"] as const;
99
+ export type ArtifactRole = (typeof ARTIFACT_ROLES)[number];
100
+
101
+ /**
102
+ * Who may receive a file (UHD docs/library-protocol.md § 4.5):
103
+ * `redistributable`, its terms allow passing it on; `internal`, its terms
104
+ * restrict use or redistribution, so it stays with the team; `unknown`, no
105
+ * terms could be established (unverified). Only `redistributable` files
106
+ * leave the team.
107
+ */
108
+ export const DISTRIBUTION_CLASSES = ["redistributable", "internal", "unknown"] as const;
109
+ export type Distribution = (typeof DISTRIBUTION_CLASSES)[number];
110
+
111
+ /** The terms a file comes under (uhd.part-revision/v1 `terms`, protocol § 4.5). Inside the digest. */
112
+ export interface FileTerms {
113
+ distribution: Distribution;
114
+ /** SPDX licence id, or a `LicenseRef-…` id or plain name. */
115
+ license?: string;
116
+ licenseUrl?: string;
117
+ /** A file of the same revision holding the licence text. */
118
+ licensePath?: string;
119
+ /** The notice redistribution must carry. */
120
+ attribution?: string;
121
+ summary?: string;
122
+ /** Where the file (or the archive it was taken from) was downloaded. */
123
+ sourceUrl?: string;
124
+ /** SHA-256 of the bytes as downloaded, when they differ from the file's. */
125
+ sourceSha256?: string;
126
+ /** ISO 8601 date or date-time the file and its terms were retrieved. */
127
+ retrieved?: string;
128
+ }
129
+
130
+ export const TERMS_FIELDS = ["distribution", "license", "licenseUrl", "licensePath", "attribution", "summary", "sourceUrl", "sourceSha256", "retrieved"] as const;
131
+
132
+ export interface EnvelopeArtifact {
133
+ /**
134
+ * Path as the definition references it (`ArtifactDef.filePath`), relative
135
+ * to the revision's artifact base. Normalised: no `.`/`..` segments, no
136
+ * leading slash, forward slashes.
137
+ */
138
+ path: string;
139
+ /** Lower-case hex SHA-256 of the file: its blob address. */
140
+ sha256: string;
141
+ size: number;
142
+ mediaType: string;
143
+ role: ArtifactRole;
144
+ /** The `ArtifactDef.id` this file backs, when the definition references it. */
145
+ artifactId?: string;
146
+ /** Licence and distribution of the file; absent means unknown. */
147
+ terms?: FileTerms;
148
+ }
149
+
150
+ /**
151
+ * A file the definition references that this revision does not carry, and
152
+ * why. Recorded so a consumer reports it instead of finding out later.
153
+ */
154
+ export interface MissingArtifact {
155
+ path: string;
156
+ artifactId: string;
157
+ reason: string;
158
+ }
159
+
160
+ export const EVIDENCE_KINDS = ["sources", "verification", "datasheet", "document", "other"] as const;
161
+ export type EvidenceKind = (typeof EVIDENCE_KINDS)[number];
162
+
163
+ /**
164
+ * Evidence behind the definition's facts: a file carried by the revision
165
+ * (path + sha256 + size + mediaType) or an external reference (url).
166
+ */
167
+ export interface EvidenceRef {
168
+ kind: EvidenceKind;
169
+ title?: string;
170
+ path?: string;
171
+ sha256?: string;
172
+ size?: number;
173
+ mediaType?: string;
174
+ url?: string;
175
+ /** For a carried file: its licence and distribution. */
176
+ terms?: FileTerms;
177
+ }
178
+
179
+ /**
180
+ * Where the definition was authored, for traceability (not lineage: see
181
+ * `derivedFrom`). Part of the publish record, not the content: a new commit
182
+ * in the source repository that leaves the part alone is not a new revision.
183
+ */
184
+ export interface RevisionSource {
185
+ /** Repository or system it came from, e.g. "uhd library". */
186
+ repository: string;
187
+ /** Commit or version of that source. */
188
+ commit?: string;
189
+ /** File inside it. */
190
+ path?: string;
191
+ /** The source checkout had uncommitted changes when published. */
192
+ dirty?: boolean;
193
+ /**
194
+ * Definition source (UHD library protocol § 4.6, PB-910): the module the
195
+ * definition is evaluated from, one of `files` (`<part-id>.uhd.ts`).
196
+ */
197
+ entry?: string;
198
+ /** The entry's export holding the definition. */
199
+ export?: string;
200
+ /** The source closure: the entry and every library module it imports, as blobs. */
201
+ files?: SourceFile[];
202
+ /** Version ranges the source needs: `@deltarobotics/uhd` (like a peerDependency) and TypeScript. */
203
+ requires?: SourceRequirements;
204
+ }
205
+
206
+ /** A file of the definition source: a blob, at a path in the revision's file namespace. */
207
+ export interface SourceFile {
208
+ path: string;
209
+ sha256: string;
210
+ size: number;
211
+ mediaType: string;
212
+ }
213
+
214
+ export interface SourceRequirements {
215
+ uhd: string;
216
+ typescript: string;
217
+ }
218
+
219
+ /** The media type of a source file. */
220
+ export const SOURCE_MEDIA_TYPE = "text/typescript";
221
+
222
+ /** Whether a revision ships definition source. */
223
+ export const hasSourceFiles = (env: { source?: RevisionSource }): boolean => Array.isArray(env.source?.files) && env.source!.files!.length > 0;
224
+
225
+ /**
226
+ * The record of § 4.6.1 as far as it can be judged without the bytes: entry
227
+ * among the files, paths normalised, `.ts`, unique and apart from the
228
+ * revision's other files, each a blob address, and both requirements given.
229
+ * The lint (what the code may do) needs the bytes: UHD's `lintSource`.
230
+ */
231
+ export function sourceRecordProblems(env: Pick<PartRevisionDraft, "artifacts" | "evidence" | "source">): Problem[] {
232
+ const out: Problem[] = [];
233
+ const s = env.source as unknown;
234
+ if (!isObject(s) || s.files === undefined) {
235
+ if (isObject(s)) for (const k of ["entry", "export", "requires"]) if (s[k] !== undefined) out.push({ code: "SOURCE_INVALID", message: `source.${k} without source.files`, at: `source.${k}` });
236
+ return out;
237
+ }
238
+ const add = (message: string, at: string) => out.push({ code: "SOURCE_INVALID", message, at });
239
+ if (!Array.isArray(s.files) || s.files.length === 0) return [{ code: "SOURCE_INVALID", message: "source.files must be a non-empty array", at: "source.files" }];
240
+ const others = new Set([...(env.artifacts ?? []).map((a) => a?.path), ...(env.evidence ?? []).flatMap((e) => (e?.path !== undefined ? [e.path] : []))]);
241
+ const seen = new Set<string>();
242
+ (s.files as unknown[]).forEach((f, i) => {
243
+ const at = `source.files[${i}]`;
244
+ if (!isObject(f)) return add("source file must be an object", at);
245
+ for (const k of Object.keys(f)) if (!["path", "sha256", "size", "mediaType"].includes(k)) add(`${k} is not a source file field`, `${at}.${k}`);
246
+ if (typeof f.path !== "string" || !isSafePath(f.path) || !/\.ts$/.test(f.path)) add("path must be a normalised relative .ts path", `${at}.path`);
247
+ else if (seen.has(f.path) || others.has(f.path)) add(`${f.path} is listed twice among the revision's files`, `${at}.path`);
248
+ else seen.add(f.path);
249
+ if (typeof f.sha256 !== "string" || !HEX64.test(f.sha256)) add("sha256 must be 64 lower-case hex digits", `${at}.sha256`);
250
+ if (!Number.isInteger(f.size) || (f.size as number) < 0) add("size must be a non-negative integer", `${at}.size`);
251
+ if (typeof f.mediaType !== "string" || f.mediaType === "") add("mediaType is required", `${at}.mediaType`);
252
+ });
253
+ if (typeof s.entry !== "string" || !seen.has(s.entry)) add("source.entry must be one of source.files", "source.entry");
254
+ if (typeof s.export !== "string" || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s.export)) add("source.export must be an export name", "source.export");
255
+ const req = s.requires;
256
+ if (!isObject(req) || typeof req.uhd !== "string" || req.uhd === "" || typeof req.typescript !== "string" || req.typescript === "") add("source.requires must be { uhd, typescript } version ranges", "source.requires");
257
+ return out;
258
+ }
259
+
260
+ /** What a publisher sends. */
261
+ export interface PartRevisionDraft {
262
+ schema: EnvelopeSchema;
263
+ uhdSchema: string;
264
+ partId: string;
265
+ revision: number;
266
+ definition: UhdModuleDef;
267
+ /** `sha256:` digest of `stableStringify(definition)`: the vendored file's bytes. */
268
+ definitionDigest: string;
269
+ /** Exact revisions of every definition this one references (its children). */
270
+ dependencies: PartRef[];
271
+ artifacts: EnvelopeArtifact[];
272
+ missingArtifacts: MissingArtifact[];
273
+ evidence: EvidenceRef[];
274
+ /**
275
+ * The revision this one was made from: the previous revision of the same
276
+ * part for an update, or another part's revision for a variant. Null only
277
+ * for a first revision authored from scratch.
278
+ */
279
+ derivedFrom: PartRef | null;
280
+ /** Kept as sent with the first publish; outside the digest. */
281
+ source?: RevisionSource;
282
+ /** `sha256:` digest over the canonical JSON of the content fields (see `envelopeDigest`). */
283
+ digest: string;
284
+ }
285
+
286
+ /** What the service stores and serves: the draft plus the publish record. */
287
+ export interface PartRevisionEnvelope extends PartRevisionDraft {
288
+ publisher: { name: string };
289
+ createdAt: string;
290
+ }
291
+
292
+ /** Fields that record the publish rather than the content; outside the digest. */
293
+ const RECORD_FIELDS = new Set(["digest", "source", "publisher", "createdAt"]);
294
+
295
+ const sortedValue = (v: unknown): unknown => {
296
+ if (Array.isArray(v)) return v.map(sortedValue);
297
+ if (v && typeof v === "object") {
298
+ return Object.fromEntries(
299
+ Object.keys(v as object)
300
+ .sort()
301
+ .filter((k) => (v as Record<string, unknown>)[k] !== undefined)
302
+ .map((k) => [k, sortedValue((v as Record<string, unknown>)[k])]),
303
+ );
304
+ }
305
+ return v;
306
+ };
307
+
308
+ /**
309
+ * Canonical JSON: keys sorted by UTF-16 code unit, undefined members dropped,
310
+ * no whitespace. For the JSON this service accepts (no non-finite numbers)
311
+ * this is RFC 8785 (JCS).
312
+ */
313
+ export function canonicalJson(value: unknown): string {
314
+ return JSON.stringify(sortedValue(value));
315
+ }
316
+
317
+ /**
318
+ * The bytes the stack writes for a vendored definition
319
+ * (packages/core/src/build.ts `stableStringify`): sorted keys, two-space
320
+ * indent, trailing newline. Its digest is what parts/origins.json pins.
321
+ */
322
+ export function stableStringify(value: unknown): string {
323
+ return JSON.stringify(sortedValue(value), null, 2) + "\n";
324
+ }
325
+
326
+ export const sha256Hex = (bytes: Uint8Array | string): string => createHash("sha256").update(bytes).digest("hex");
327
+ export const sha256Digest = (bytes: Uint8Array | string): string => "sha256:" + sha256Hex(bytes);
328
+
329
+ export const definitionDigest = (definition: UhdModuleDef): string => sha256Digest(stableStringify(definition));
330
+
331
+ /** Digest of an envelope or draft: every field except `digest`, `source`, `publisher` and `createdAt`. */
332
+ export function envelopeDigest(envelope: PartRevisionDraft & { publisher?: unknown; createdAt?: unknown }): string {
333
+ const content = Object.fromEntries(Object.entries(envelope).filter(([k]) => !RECORD_FIELDS.has(k)));
334
+ return sha256Digest(canonicalJson(content));
335
+ }
336
+
337
+ /**
338
+ * What a revision says, apart from its number and lineage: the definition,
339
+ * the UHD schema, pinned dependencies and every file. Two revisions of a part
340
+ * with the same content are the same part; publishing the second would only
341
+ * mint a number.
342
+ */
343
+ export function sameContent(a: PartRevisionDraft, b: PartRevisionDraft): boolean {
344
+ const content = (d: PartRevisionDraft) =>
345
+ canonicalJson({ partId: d.partId, uhdSchema: d.uhdSchema, definitionDigest: d.definitionDigest, dependencies: d.dependencies, artifacts: d.artifacts, missingArtifacts: d.missingArtifacts, evidence: d.evidence });
346
+ return content(a) === content(b);
347
+ }
348
+
349
+ /** Fill in `definitionDigest` and `digest`. */
350
+ export function sealDraft(draft: Omit<PartRevisionDraft, "definitionDigest" | "digest">): PartRevisionDraft {
351
+ const withDef = { ...draft, definitionDigest: definitionDigest(draft.definition) } as PartRevisionDraft;
352
+ return { ...withDef, digest: envelopeDigest(withDef) };
353
+ }
354
+
355
+ /** Every file path the definition references, with the artifact that references it. */
356
+ export function referencedFiles(definition: UhdModuleDef): { path: string; artifact: UhdArtifactDef }[] {
357
+ const all = [...(definition.artifacts ?? []), ...(definition.harnesses ?? []).flatMap((h) => h.artifacts ?? [])];
358
+ return all.filter((a) => typeof a.filePath === "string" && a.filePath !== "").map((a) => ({ path: a.filePath as string, artifact: a }));
359
+ }
360
+
361
+ /** Ids of the definitions this one references (its children), sorted. */
362
+ export function referencedDefinitions(definition: UhdModuleDef): string[] {
363
+ return [...new Set((definition.children ?? []).map((c) => c.moduleDefId))].sort();
364
+ }
365
+
366
+ /** Search facets, computed as the UHD library manifest does (uhd scripts/build-library.ts). */
367
+ export function facets(definition: UhdModuleDef): { tags: string[]; protocols: string[]; domains: string[] } {
368
+ const uniq = (v: string[]) => [...new Set(v)].sort();
369
+ return {
370
+ tags: uniq([...(definition.tags ?? []), ...(definition.categories ?? [])]),
371
+ protocols: uniq(definition.interfaces.filter((i) => i.exposed).flatMap((i) => (i.protocols ?? []).map((p) => p.type))),
372
+ domains: uniq([...definition.interfaces.map((i) => i.domain), ...(definition.domains ?? []).map((d) => d.domain)]),
373
+ };
374
+ }
375
+
376
+ /** Whether a file's terms let it leave the team; no terms is unknown. */
377
+ export const isRedistributable = (file: { terms?: { distribution?: string } }): boolean => file.terms?.distribution === "redistributable";
378
+
379
+ /**
380
+ * Whether a revision can be given out as it is (protocol § 4.5): every file
381
+ * that backs an artifact of the definition is redistributable. `blocking`
382
+ * lists those that are not; `withheld` the optional files (thumbnail,
383
+ * licence texts, attachments, evidence) a redistributable copy leaves out.
384
+ * Dependencies are the caller's to check.
385
+ */
386
+ export function revisionDistribution(env: Pick<PartRevisionDraft, "artifacts" | "evidence">): { redistributable: boolean; blocking: string[]; withheld: string[] } {
387
+ const blocking = env.artifacts.filter((a) => a.artifactId !== undefined && !isRedistributable(a)).map((a) => a.path);
388
+ const withheld = [...env.artifacts.filter((a) => a.artifactId === undefined), ...env.evidence.filter((e) => e.path !== undefined)].filter((f) => !isRedistributable(f)).map((f) => f.path!);
389
+ return { redistributable: blocking.length === 0, blocking, withheld };
390
+ }
391
+
392
+ /** Problems with one `terms` object; `paths` are the revision's files (for `licensePath`). */
393
+ export function termsProblems(terms: unknown, at: string, paths?: Set<string>): Problem[] {
394
+ const out: Problem[] = [];
395
+ const add = (field: string, message: string) => out.push({ code: "TERMS_INVALID", message, at: `${at}.${field}` });
396
+ if (!isObject(terms)) return [{ code: "TERMS_INVALID", message: "terms must be an object", at }];
397
+ for (const k of Object.keys(terms)) if (!(TERMS_FIELDS as readonly string[]).includes(k)) add(k, `${k} is not a terms field`);
398
+ if (!(DISTRIBUTION_CLASSES as readonly unknown[]).includes(terms.distribution)) add("distribution", `distribution must be one of ${DISTRIBUTION_CLASSES.join(", ")}`);
399
+ for (const k of ["license", "licensePath", "retrieved"]) if (terms[k] !== undefined && (typeof terms[k] !== "string" || terms[k] === "")) add(k, `${k} must be a non-empty string`);
400
+ for (const k of ["attribution", "summary"]) if (terms[k] !== undefined && typeof terms[k] !== "string") add(k, `${k} must be a string`);
401
+ for (const k of ["licenseUrl", "sourceUrl"]) if (terms[k] !== undefined && !(typeof terms[k] === "string" && /^https?:\/\//.test(terms[k] as string))) add(k, `${k} must be an http(s) URL`);
402
+ if (terms.sourceSha256 !== undefined && !(typeof terms.sourceSha256 === "string" && HEX64.test(terms.sourceSha256))) add("sourceSha256", "sourceSha256 must be 64 lower-case hex digits");
403
+ if (paths && typeof terms.licensePath === "string" && terms.licensePath !== "" && !paths.has(terms.licensePath)) add("licensePath", `${terms.licensePath} is not a file of this revision`);
404
+ return out;
405
+ }
406
+
407
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/;
408
+
409
+ function compareVersions(a: string, b: string): number {
410
+ const pa = SEMVER.exec(a)!.slice(1, 4).map(Number);
411
+ const pb = SEMVER.exec(b)!.slice(1, 4).map(Number);
412
+ for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
413
+ return 0;
414
+ }
415
+
416
+ /**
417
+ * The UHD schema version a `@deltarobotics/uhd` package version carries: the
418
+ * first release of its minor line (0.2.1 and 0.2.7-dev.3 carry schema 0.2.0).
419
+ * A patch release fixes UHD without changing the schema, so a definition
420
+ * built with any 0.2.x is written against 0.2.0 and reads under every 0.2.x.
421
+ * This is what a library records as `uhdSchema` (inside the envelope digest:
422
+ * recording the package version instead would make every part of an
423
+ * unchanged library a different revision after each UHD patch release) and
424
+ * what its source declares as `requires.uhd` (`^<uhdSchema>`).
425
+ */
426
+ export function uhdSchemaVersion(packageVersion: string): string {
427
+ const m = SEMVER.exec(packageVersion);
428
+ if (!m) throw new Error(`${JSON.stringify(packageVersion)} is not a version such as 0.2.1`);
429
+ return `${m[1]}.${m[2]}.0`;
430
+ }
431
+
432
+ export function uhdSchemaSupported(version: string, range: UhdSchemaRange = SUPPORTED_UHD_SCHEMA): boolean {
433
+ return SEMVER.test(version) && compareVersions(version, range.min) >= 0 && compareVersions(version, range.below) < 0;
434
+ }
435
+
436
+ export const PART_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
437
+ const HEX64 = /^[0-9a-f]{64}$/;
438
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
439
+
440
+ /** A normalised relative path: forward slashes, no empty, `.` or `..` segments. */
441
+ export function isSafePath(path: string): boolean {
442
+ if (path === "" || path.startsWith("/") || path.includes("\\") || path.includes("\0")) return false;
443
+ return path.split("/").every((s) => s !== "" && s !== "." && s !== "..");
444
+ }
445
+
446
+ export interface Problem {
447
+ /** Stable code, e.g. UHD_SCHEMA_UNSUPPORTED. */
448
+ code: string;
449
+ message: string;
450
+ /** Where in the envelope, e.g. "artifacts[3].path". */
451
+ at?: string;
452
+ }
453
+
454
+ const DRAFT_FIELDS = new Set([
455
+ "schema", "uhdSchema", "partId", "revision", "definition", "definitionDigest", "dependencies",
456
+ "artifacts", "missingArtifacts", "evidence", "derivedFrom", "source", "digest",
457
+ ]);
458
+
459
+ const SOURCE_FIELDS = new Set(["repository", "commit", "path", "dirty", "entry", "export", "files", "requires"]);
460
+
461
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
462
+
463
+ export const isRef = (v: unknown): v is PartRef =>
464
+ !!v && typeof v === "object" && typeof (v as PartRef).partId === "string" && Number.isInteger((v as PartRef).revision) && (v as PartRef).revision >= 1;
465
+
466
+ /**
467
+ * The shape of the ModuleDef fields this service reads (search facets,
468
+ * referenced files, children). Anything else in the definition is UHD's to
469
+ * define and is carried as sent.
470
+ */
471
+ function definitionShape(def: Record<string, unknown>): Problem[] {
472
+ const out: Problem[] = [];
473
+ const add = (at: string, message: string) => out.push({ code: "DEFINITION_INVALID", message, at: `definition.${at}` });
474
+ const optString = (v: unknown) => v === undefined || typeof v === "string";
475
+ const optArray = (v: unknown, at: string): unknown[] => {
476
+ if (v === undefined) return [];
477
+ if (Array.isArray(v)) return v;
478
+ add(at, `${at} must be an array`);
479
+ return [];
480
+ };
481
+ for (const k of ["description", "version", "manufacturer", "part_number"]) if (!optString(def[k])) add(k, `${k} must be a string`);
482
+ for (const k of ["tags", "categories"]) optArray(def[k], k).forEach((v, i) => typeof v !== "string" && add(`${k}[${i}]`, "must be a string"));
483
+ (def.interfaces as unknown[]).forEach((itf, i) => {
484
+ if (!isObject(itf) || typeof itf.id !== "string" || typeof itf.domain !== "string") return add(`interfaces[${i}]`, "interface needs id and domain");
485
+ optArray(itf.protocols, `interfaces[${i}].protocols`).forEach((p, j) => (!isObject(p) || typeof p.type !== "string") && add(`interfaces[${i}].protocols[${j}]`, "protocol needs a type"));
486
+ });
487
+ optArray(def.domains, "domains").forEach((d, i) => (!isObject(d) || typeof d.domain !== "string") && add(`domains[${i}]`, "needs a domain"));
488
+ optArray(def.children, "children").forEach((c, i) => (!isObject(c) || typeof c.moduleDefId !== "string") && add(`children[${i}]`, "child needs a moduleDefId"));
489
+ const artifacts = (list: unknown, at: string) =>
490
+ optArray(list, at).forEach((a, i) => (!isObject(a) || typeof a.id !== "string" || !optString(a.filePath)) && add(`${at}[${i}]`, "artifact needs an id; filePath is a string"));
491
+ artifacts(def.artifacts, "artifacts");
492
+ optArray(def.harnesses, "harnesses").forEach((h, i) => (isObject(h) ? artifacts(h.artifacts, `harnesses[${i}].artifacts`) : add(`harnesses[${i}]`, "harness must be an object")));
493
+ return out;
494
+ }
495
+
496
+ /**
497
+ * Structural checks on a draft: everything that can be judged from the
498
+ * draft alone. The server adds the checks that need its store (dependencies
499
+ * and derivations exist, blobs present, revision order).
500
+ */
501
+ export function validateDraft(draft: PartRevisionDraft, range: UhdSchemaRange = SUPPORTED_UHD_SCHEMA): Problem[] {
502
+ const out: Problem[] = [];
503
+ const add = (code: string, message: string, at?: string) => out.push({ code, message, ...(at ? { at } : {}) });
504
+ if (!draft || typeof draft !== "object") return [{ code: "ENVELOPE_INVALID", message: "body is not an object" }];
505
+ for (const k of Object.keys(draft)) if (!DRAFT_FIELDS.has(k)) add("FIELD_UNKNOWN", `${k} is not a ${PART_REVISION_SCHEMA} field`, k);
506
+ if (!(ENVELOPE_SCHEMAS as readonly string[]).includes(draft.schema)) add("ENVELOPE_SCHEMA", `schema must be ${ENVELOPE_SCHEMAS.map((x) => `"${x}"`).join(" or ")}`, "schema");
507
+ if (typeof draft.uhdSchema !== "string" || !SEMVER.test(draft.uhdSchema)) add("UHD_SCHEMA_INVALID", "uhdSchema must be a version such as 0.2.0", "uhdSchema");
508
+ else if (!uhdSchemaSupported(draft.uhdSchema, range))
509
+ add("UHD_SCHEMA_UNSUPPORTED", `uhdSchema ${draft.uhdSchema} is outside the supported range >=${range.min} <${range.below}`, "uhdSchema");
510
+ if (typeof draft.partId !== "string" || !PART_ID.test(draft.partId)) add("PART_ID_INVALID", "partId must be lower-case letters, digits, '.', '_' or '-'", "partId");
511
+ if (!Number.isInteger(draft.revision) || draft.revision < 1) add("REVISION_INVALID", "revision must be an integer >= 1", "revision");
512
+ const def = draft.definition;
513
+ if (!isObject(def) || typeof def.id !== "string" || typeof def.name !== "string" || !Array.isArray(def.interfaces)) {
514
+ add("DEFINITION_INVALID", "definition must be a UHD ModuleDef (id, name, interfaces[])", "definition");
515
+ return out;
516
+ }
517
+ const shape = definitionShape(def);
518
+ if (shape.length) return [...out, ...shape];
519
+ if (def.id !== draft.partId) add("DEFINITION_ID", `definition.id "${def.id}" differs from partId "${draft.partId}"`, "definition.id");
520
+ if (draft.definitionDigest !== definitionDigest(def)) add("DEFINITION_DIGEST", "definitionDigest does not match the definition", "definitionDigest");
521
+
522
+ // dependencies: exactly the definitions it references, once each
523
+ if (!Array.isArray(draft.dependencies)) add("DEPENDENCIES_INVALID", "dependencies must be an array", "dependencies");
524
+ else {
525
+ const ids = draft.dependencies.map((d) => d?.partId);
526
+ draft.dependencies.forEach((d, i) => {
527
+ if (!isRef(d)) add("DEPENDENCY_INVALID", "dependency must be { partId, revision >= 1 }", `dependencies[${i}]`);
528
+ else if (d.partId === draft.partId) add("DEPENDENCY_SELF", "a part cannot depend on itself", `dependencies[${i}]`);
529
+ });
530
+ if (new Set(ids).size !== ids.length) add("DEPENDENCY_DUPLICATE", "each dependency appears once", "dependencies");
531
+ const want = referencedDefinitions(def);
532
+ const missing = want.filter((id) => !ids.includes(id));
533
+ const extra = ids.filter((id) => typeof id === "string" && !want.includes(id));
534
+ if (missing.length) add("DEPENDENCY_MISSING", `children reference ${missing.join(", ")} with no pinned revision`, "dependencies");
535
+ if (extra.length) add("DEPENDENCY_UNUSED", `${extra.join(", ")} not referenced by the definition`, "dependencies");
536
+ }
537
+
538
+ // files
539
+ const paths = new Set<string>();
540
+ const checkFile = (f: { path?: string; sha256?: string; size?: number; mediaType?: string }, at: string) => {
541
+ if (typeof f.path !== "string" || !isSafePath(f.path)) add("PATH_INVALID", "path must be relative and normalised", `${at}.path`);
542
+ else if (paths.has(f.path)) add("PATH_DUPLICATE", `${f.path} listed twice`, `${at}.path`);
543
+ else paths.add(f.path);
544
+ if (typeof f.sha256 !== "string" || !HEX64.test(f.sha256)) add("SHA256_INVALID", "sha256 must be 64 lower-case hex digits", `${at}.sha256`);
545
+ if (!Number.isInteger(f.size) || (f.size as number) < 0) add("SIZE_INVALID", "size must be a non-negative integer", `${at}.size`);
546
+ if (typeof f.mediaType !== "string" || f.mediaType === "") add("MEDIA_TYPE_INVALID", "mediaType is required", `${at}.mediaType`);
547
+ };
548
+ if (!Array.isArray(draft.artifacts)) add("ARTIFACTS_INVALID", "artifacts must be an array", "artifacts");
549
+ else
550
+ draft.artifacts.forEach((a, i) => {
551
+ if (!isObject(a)) return add("ARTIFACT_INVALID", "artifact must be an object", `artifacts[${i}]`);
552
+ checkFile(a, `artifacts[${i}]`);
553
+ if (!ARTIFACT_ROLES.includes(a.role)) add("ROLE_INVALID", `role must be one of ${ARTIFACT_ROLES.join(", ")}`, `artifacts[${i}].role`);
554
+ });
555
+ if (!Array.isArray(draft.evidence)) add("EVIDENCE_INVALID", "evidence must be an array", "evidence");
556
+ else
557
+ draft.evidence.forEach((e, i) => {
558
+ if (!isObject(e)) return add("EVIDENCE_INVALID", "evidence entry must be an object", `evidence[${i}]`);
559
+ if (!EVIDENCE_KINDS.includes(e.kind)) add("EVIDENCE_KIND", `kind must be one of ${EVIDENCE_KINDS.join(", ")}`, `evidence[${i}].kind`);
560
+ if (e.path !== undefined) checkFile(e, `evidence[${i}]`);
561
+ else if (typeof e.url !== "string" || !/^https?:\/\//.test(e.url)) add("EVIDENCE_REF", "evidence needs a file (path, sha256, size, mediaType) or an http(s) url", `evidence[${i}]`);
562
+ });
563
+ if (!Array.isArray(draft.missingArtifacts)) add("MISSING_ARTIFACTS_INVALID", "missingArtifacts must be an array", "missingArtifacts");
564
+ // terms (protocol § 4.5): optional on every carried file
565
+ const termed: [unknown, string][] = [
566
+ ...(Array.isArray(draft.artifacts) ? draft.artifacts.map((a, i) => [isObject(a) ? a.terms : undefined, `artifacts[${i}].terms`] as [unknown, string]) : []),
567
+ ...(Array.isArray(draft.evidence) ? draft.evidence.map((e, i) => [isObject(e) ? e.terms : undefined, `evidence[${i}].terms`] as [unknown, string]) : []),
568
+ ];
569
+ for (const [terms, at] of termed) if (terms !== undefined) out.push(...termsProblems(terms, at, paths));
570
+ if (Array.isArray(draft.evidence)) draft.evidence.forEach((e, i) => isObject(e) && e.terms !== undefined && e.path === undefined && add("TERMS_INVALID", "terms belong to a carried file, not a url", `evidence[${i}].terms`));
571
+
572
+ // every file the definition references is carried or declared missing
573
+ if (Array.isArray(draft.artifacts) && Array.isArray(draft.missingArtifacts)) {
574
+ const carried = new Set(draft.artifacts.map((a) => a?.path));
575
+ const declared = new Set(draft.missingArtifacts.map((m) => m?.path));
576
+ for (const { path, artifact } of referencedFiles(def)) {
577
+ if (!carried.has(path) && !declared.has(path)) add("ARTIFACT_UNACCOUNTED", `artifact ${artifact.id} references ${path}, which is neither carried nor listed in missingArtifacts`, "artifacts");
578
+ }
579
+ draft.missingArtifacts.forEach((m, i) => {
580
+ if (!m || typeof m.path !== "string" || typeof m.reason !== "string" || m.reason === "") add("MISSING_ARTIFACT_INVALID", "missing artifact needs path and reason", `missingArtifacts[${i}]`);
581
+ else if (carried.has(m.path)) add("MISSING_ARTIFACT_CARRIED", `${m.path} is carried and listed missing`, `missingArtifacts[${i}]`);
582
+ });
583
+ }
584
+
585
+ if (draft.source !== undefined) {
586
+ const src = draft.source as unknown;
587
+ const ok =
588
+ isObject(src) &&
589
+ typeof src.repository === "string" &&
590
+ (src.commit === undefined || typeof src.commit === "string") &&
591
+ (src.path === undefined || typeof src.path === "string") &&
592
+ (src.dirty === undefined || typeof src.dirty === "boolean") &&
593
+ Object.keys(src).every((k) => SOURCE_FIELDS.has(k));
594
+ if (!ok) add("SOURCE_INVALID", "source must be { repository, commit?, path?, dirty?, entry?, export?, files?, requires? }", "source");
595
+ else out.push(...sourceRecordProblems(draft));
596
+ }
597
+
598
+ if (draft.derivedFrom !== null && !isRef(draft.derivedFrom)) add("DERIVED_FROM_INVALID", "derivedFrom must be { partId, revision } or null", "derivedFrom");
599
+ else if (draft.derivedFrom && draft.derivedFrom.partId === draft.partId && draft.derivedFrom.revision >= draft.revision)
600
+ add("DERIVED_FROM_ORDER", "a revision derives from an earlier revision of its part", "derivedFrom");
601
+ else if (Number.isInteger(draft.revision) && draft.revision > 1 && !draft.derivedFrom)
602
+ add("DERIVED_FROM_REQUIRED", "a revision after the first names the revision it was derived from", "derivedFrom");
603
+
604
+ if (typeof draft.digest !== "string" || !DIGEST.test(draft.digest)) add("DIGEST_INVALID", "digest must be sha256:<hex>", "digest");
605
+ else if (out.length === 0 && draft.digest !== envelopeDigest(draft)) add("DIGEST_MISMATCH", `digest does not match the content (expected ${envelopeDigest(draft)})`, "digest");
606
+ return out;
607
+ }