Skill Tracer
Skills › Archify › archify › update-contract.mjs

update-contract.mjs

187 lines · archify/scripts/update-contract.mjs at a406594

1export const SKILL_ID = 'archify';
2export const EXPECTED_REPOSITORY = 'https://github.com/tt-a1i/archify';
3export const DEFAULT_MANIFEST_URL = 'https://tt-a1i.github.io/archify/skill-updates/archify/stable.json';
4
5const CONTROL_OR_BIDI = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
6const HEX_40 = /^[a-f0-9]{40}$/;
7const HEX_64 = /^[a-f0-9]{64}$/;
8const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
9const UTC_SECONDS = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
11export class UpdateContractError extends Error {
12 constructor(message) {
13 super(message);
14 this.name = 'UpdateContractError';
15 }
18function isPlainObject(value) {
19 return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
22function hasExactKeys(value, expected) {
23 return isPlainObject(value)
24 && Object.keys(value).sort().join('\0') === [...expected].sort().join('\0');
27export function parseSemver(value) {
28 if (typeof value !== 'string' || value.length > 128) {
29 throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
30 }
31 const match = SEMVER.exec(value);
32 if (!match) throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
33 const prerelease = match[4]?.split('.') ?? null;
34 if (prerelease?.some((identifier) => /^\d+$/.test(identifier)
35 && identifier.length > 1 && identifier.startsWith('0'))) {
36 throw new UpdateContractError(`invalid SemVer: ${JSON.stringify(value)}`);
37 }
38 return {
39 core: match.slice(1, 4),
40 prerelease,
41 build: match[5]?.split('.') ?? null,
42 };
45function compareNumericIdentifiers(left, right) {
46 if (left.length !== right.length) return left.length < right.length ? -1 : 1;
47 if (left === right) return 0;
48 return left < right ? -1 : 1;
51function comparePrerelease(left, right) {
52 if (left === null && right === null) return 0;
53 if (left === null) return 1;
54 if (right === null) return -1;
55 const length = Math.max(left.length, right.length);
56 for (let index = 0; index < length; index += 1) {
57 if (left[index] === undefined) return -1;
58 if (right[index] === undefined) return 1;
59 if (left[index] === right[index]) continue;
60 const leftNumeric = /^\d+$/.test(left[index]);
61 const rightNumeric = /^\d+$/.test(right[index]);
62 if (leftNumeric && rightNumeric) return compareNumericIdentifiers(left[index], right[index]);
63 if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
64 return left[index] < right[index] ? -1 : 1;
65 }
66 return 0;
69export function compareSemver(leftValue, rightValue) {
70 const left = parseSemver(leftValue);
71 const right = parseSemver(rightValue);
72 for (let index = 0; index < left.core.length; index += 1) {
73 const comparison = compareNumericIdentifiers(left.core[index], right.core[index]);
74 if (comparison !== 0) return comparison;
75 }
76 return comparePrerelease(left.prerelease, right.prerelease);
79export function releaseChannelForVersion(value) {
80 return parseSemver(value).prerelease ? 'development' : 'stable';
83export function isStableCoreVersion(value) {
84 try {
85 const parsed = parseSemver(value);
86 return parsed.prerelease === null && parsed.build === null;
87 } catch {
88 return false;
89 }
92export function validateCanonicalUtcTimestamp(value) {
93 if (typeof value !== 'string' || !UTC_SECONDS.test(value)) {
94 throw new UpdateContractError('publication time must use YYYY-MM-DDTHH:mm:ssZ');
95 }
96 const timestamp = Date.parse(value);
97 if (!Number.isFinite(timestamp)
98 || new Date(timestamp).toISOString().replace('.000Z', 'Z') !== value) {
99 throw new UpdateContractError('publication time is not a real UTC calendar instant');
100 }
101 return value;
104export function validateLocalRelease(value) {
105 if (!hasExactKeys(value, [
106 'schemaVersion', 'skillId', 'channel', 'version', 'source', 'updateManifestUrl',
107 ])
108 || value.schemaVersion !== 1
109 || value.skillId !== SKILL_ID
110 || !hasExactKeys(value.source, ['repository'])
111 || value.source.repository !== EXPECTED_REPOSITORY
112 || value.updateManifestUrl !== DEFAULT_MANIFEST_URL) {
113 throw new UpdateContractError('invalid local release identity');
114 }
115 const expectedChannel = releaseChannelForVersion(value.version);
116 if (value.channel !== expectedChannel) {
117 throw new UpdateContractError('local release channel does not match its version');
118 }
119 return {
120 schemaVersion: value.schemaVersion,
121 skillId: value.skillId,
122 channel: value.channel,
123 version: value.version,
124 source: { repository: value.source.repository },
125 updateManifestUrl: value.updateManifestUrl,
126 };
129export function validateReleaseNotesUrl(value, version) {
130 if (!isStableCoreVersion(version)) {
131 throw new UpdateContractError('release notes require a stable core version');
132 }
133 const expected = `https://github.com/tt-a1i/archify/releases/tag/v${version}`;
134 if (value !== expected) {
135 throw new UpdateContractError('release notes URL is outside the exact trusted release path');
136 }
137 return value;
140export function validateReleaseSummary(value) {
141 if (typeof value !== 'string' || value.length < 1 || value.length > 160
142 || CONTROL_OR_BIDI.test(value)) {
143 throw new UpdateContractError('invalid release summary');
144 }
145 return value;
148export function validateStableUpdateManifest(value) {
149 if (!hasExactKeys(value, [
150 'schemaVersion', 'skillId', 'channel', 'version', 'publishedAt', 'source',
151 'artifact', 'summary', 'releaseNotes', 'severity',
152 ])
153 || value.schemaVersion !== 1
154 || value.skillId !== SKILL_ID
155 || value.channel !== 'stable'
156 || !isStableCoreVersion(value.version)
157 || !hasExactKeys(value.source, ['repository', 'ref', 'treeSha'])
158 || value.source.repository !== EXPECTED_REPOSITORY
159 || value.source.ref !== `v${value.version}`
160 || !HEX_40.test(value.source.treeSha)
161 || !hasExactKeys(value.artifact, ['sha256'])
162 || !HEX_64.test(value.artifact.sha256)) {
163 throw new UpdateContractError('invalid immutable stable release identity');
164 }
165 validateCanonicalUtcTimestamp(value.publishedAt);
166 validateReleaseSummary(value.summary);
167 validateReleaseNotesUrl(value.releaseNotes, value.version);
168 if (!['normal', 'security'].includes(value.severity)) {
169 throw new UpdateContractError('invalid update severity');
170 }
171 return {
172 schemaVersion: value.schemaVersion,
173 skillId: value.skillId,
174 channel: value.channel,
175 version: value.version,
176 publishedAt: value.publishedAt,
177 source: {
178 repository: value.source.repository,
179 ref: value.source.ref,
180 treeSha: value.source.treeSha,
181 },
182 artifact: { sha256: value.artifact.sha256 },
183 summary: value.summary,
184 releaseNotes: value.releaseNotes,
185 severity: value.severity,
186 };