@runeditrun/rer 0.2.0 → 0.2.2

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.
Files changed (112) hide show
  1. package/README.md +15 -4
  2. package/cli/README.md +4 -2
  3. package/cli/auth-copy.mjs +25 -18
  4. package/cli/bounded.mjs +13 -4
  5. package/cli/contract.mjs +3 -3
  6. package/cli/deploy-inputs.mjs +33 -0
  7. package/cli/deploy-owner.mjs +61 -0
  8. package/cli/deploy.mjs +95 -9
  9. package/cli/detached-server.mjs +51 -0
  10. package/cli/door.mjs +1 -1
  11. package/cli/hosted-output.mjs +2 -2
  12. package/cli/hydration/check.mjs +2 -2
  13. package/cli/inspect.mjs +24 -32
  14. package/cli/lifecycle.mjs +14 -4
  15. package/cli/main.mjs +1 -0
  16. package/cli/managed-server.mjs +12 -5
  17. package/cli/parse.mjs +5 -2
  18. package/cli/pnpm-probe.mjs +14 -0
  19. package/cli/pnpm-runtime.mjs +22 -22
  20. package/cli/portability-policy.mjs +55 -0
  21. package/cli/portability-worker.mjs +8 -4
  22. package/cli/portability.mjs +20 -4
  23. package/cli/project-pnpm.mjs +67 -0
  24. package/cli/release.mjs +36 -0
  25. package/cli/server-registry.mjs +2 -2
  26. package/cli/site-bindings.mjs +291 -0
  27. package/cli/site-browser-objects.mjs +36 -0
  28. package/cli/site-check.mjs +55 -31
  29. package/cli/site-client.mjs +50 -0
  30. package/cli/site-data.mjs +55 -0
  31. package/cli/site-entities.json +1 -0
  32. package/cli/site-input-assets.mjs +58 -0
  33. package/cli/site-inputs.mjs +79 -0
  34. package/cli/site-markup.mjs +102 -0
  35. package/cli/site-private.mjs +36 -0
  36. package/cli/site-references.mjs +243 -0
  37. package/cli/site-render.mjs +7 -5
  38. package/cli/site-syntax.mjs +21 -0
  39. package/cli/site.mjs +32 -11
  40. package/global/CHANGELOG.md +39 -0
  41. package/global/README.md +15 -4
  42. package/global/cli/install.mjs +26 -9
  43. package/global/cli/path-link.mjs +30 -0
  44. package/global/cli/status.mjs +1 -1
  45. package/global/cli/uninstall.mjs +5 -1
  46. package/global/launch/install-pinned.mjs +4 -0
  47. package/global/main.mjs +30 -3
  48. package/global/output.mjs +12 -0
  49. package/global/registry.mjs +2 -2
  50. package/global/rer.mjs +3 -0
  51. package/modules/container/deploy-input.mjs +5 -9
  52. package/modules/dependencies/auth.mjs +2 -1
  53. package/modules/deploy-kit/README.md +25 -7
  54. package/modules/deploy-kit/d1-import.mjs +35 -0
  55. package/modules/deploy-kit/deploy.mjs +9 -5
  56. package/modules/deploy-kit/diagnostics.mjs +65 -0
  57. package/modules/deploy-kit/env-file.mjs +26 -0
  58. package/modules/deploy-kit/health-request.mjs +6 -1
  59. package/modules/deploy-kit/health.mjs +35 -15
  60. package/modules/deploy-kit/migration-ledger.mjs +48 -0
  61. package/modules/deploy-kit/migrations.mjs +11 -15
  62. package/modules/deploy-kit/owner-cli.mjs +29 -10
  63. package/modules/deploy-kit/owner-remove.mjs +105 -0
  64. package/modules/deploy-kit/owner-secret.mjs +49 -0
  65. package/modules/deploy-kit/owner.mjs +71 -10
  66. package/modules/deploy-kit/provider.mjs +59 -7
  67. package/modules/job-queue/config.mjs +4 -2
  68. package/modules/operator-auth/accounts/native.mjs +1 -1
  69. package/modules/operator-auth/dev/badge.mjs +4 -1
  70. package/modules/operator-auth/identity/oidc/README.md +18 -0
  71. package/modules/operator-auth/identity/oidc/config.mjs +4 -2
  72. package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
  73. package/modules/operator-auth/identity/oidc/index.mjs +28 -9
  74. package/modules/operator-auth/identity/oidc/network.mjs +9 -4
  75. package/modules/operator-auth/identity/oidc/store.mjs +19 -3
  76. package/modules/operator-auth/index.d.mts +6 -9
  77. package/modules/operator-auth/page.mjs +1 -1
  78. package/modules/operator-auth/routes.mjs +1 -1
  79. package/modules/operator-auth/service.mjs +1 -0
  80. package/modules/operator-auth/signin/index.d.mts +11 -0
  81. package/modules/operator-auth/signin/index.mjs +10 -7
  82. package/modules/portability/README.md +28 -0
  83. package/modules/portability/bridge.mjs +10 -3
  84. package/modules/portability/import.mjs +4 -2
  85. package/modules/release/README.md +63 -0
  86. package/modules/release/keygen.mjs +18 -0
  87. package/modules/release/produce.mjs +9 -0
  88. package/modules/release/sign.mjs +44 -0
  89. package/modules/release/signature.mjs +336 -0
  90. package/modules/release/signing-cli.mjs +56 -0
  91. package/modules/release/signing-io.mjs +61 -0
  92. package/modules/release/signing-key.mjs +22 -0
  93. package/modules/release/statement.mjs +80 -0
  94. package/modules/release/vectors/generate.mjs +381 -0
  95. package/modules/release/vectors/release-signature-vectors.json +2305 -0
  96. package/modules/release/verify.mjs +27 -0
  97. package/onboarding/commands.mjs +3 -1
  98. package/onboarding/production-assets.mjs +23 -0
  99. package/onboarding/production-runtime.mjs +8 -4
  100. package/package.json +2 -1
  101. package/scripts/publish-check/README.md +27 -17
  102. package/scripts/publish-check/content_identities.py +14 -233
  103. package/scripts/publish-check/git_history.py +2 -2
  104. package/scripts/publish-check/gitleaks.toml +1 -1
  105. package/scripts/publish-check/history_scan.py +6 -4
  106. package/scripts/publish-check/hygiene_config.py +3 -1
  107. package/scripts/publish-check/public_hygiene.py +29 -5
  108. package/scripts/publish-check/publication-config.json +9 -1
  109. package/scripts/publish-check/source_metadata.py +62 -0
  110. package/scripts/publish-check/source_shapes.py +91 -0
  111. package/scripts/publish-check/tree_scan.py +2 -2
  112. package/scripts/publish-check/source_hashes.mjs +0 -6
@@ -0,0 +1,336 @@
1
+ // Shared release authentication: dependency-free, with one WebCrypto code path.
2
+ export const PAYLOAD_TYPE = 'application/vnd.runeditrun.release-statement.v1+json';
3
+ export const ENROLMENT_TYPE = 'application/vnd.runeditrun.key-enrolment.v1+json';
4
+ export const LIMITS = Object.freeze({ envelopeBytes: 65536, files: 256, fileBytes: 1073741824 });
5
+ const encoder = new TextEncoder();
6
+ const trusted = new WeakMap();
7
+ const codes = new Set(('SIGNATURE_MISSING INPUT_TOO_LARGE ENVELOPE_INVALID KEY_TEST_REFUSED KEY_UNKNOWN SIGNATURE_INVALID STATEMENT_NOT_CANONICAL STATEMENT_INVALID PUBLISHER_MISMATCH KEY_OUTSIDE_VALIDITY KEY_RETIRED KEY_REVOKED ISSUED_IN_FUTURE SUBJECT_MISMATCH FILE_MISSING FILE_UNDECLARED DIGEST_MISMATCH PATH_UNSAFE VERSION_ROLLBACK VERSION_REUSED ANCHORS_INVALID ANCHORS_STALE').split(' '));
8
+ export class Refusal extends Error {
9
+ constructor(code) { const safe = codes.has(code) ? code : 'STATEMENT_INVALID'; super(safe); this.name = 'Refusal'; this.code = safe; }
10
+ }
11
+ const refuse = code => { throw new Refusal(code); };
12
+ function sanitized(error, code) { refuse(error instanceof Refusal && codes.has(error.code) ? error.code : code); }
13
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
14
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
15
+ const matches = (value, pattern) => typeof value === 'string' && pattern.test(value);
16
+ function shape(value, required, optional = []) {
17
+ if (!record(value)) return false;
18
+ const keys = Reflect.ownKeys(value);
19
+ return required.every(key => Object.hasOwn(value, key)) && keys.every(key => typeof key === 'string'
20
+ && (required.includes(key) || optional.includes(key)) && Object.getOwnPropertyDescriptor(value, key)?.value !== undefined);
21
+ }
22
+ function ascii(value) { return typeof value === 'string' && value.length > 0 && !/[^\x20-\x7e]/.test(value); }
23
+ const namespace = /^[a-z0-9][a-z0-9-]{0,38}$/;
24
+ // Names are literal printable ASCII components, not URLs. Do not case-fold them.
25
+ export function validRelativePath(path) {
26
+ return ascii(path) && !path.includes('\\') && path.split('/').every(part => part && part !== '.' && part !== '..');
27
+ }
28
+ export function validProject(value) {
29
+ if (!validRelativePath(value)) return false;
30
+ const parts = value.split('/');
31
+ return parts.length === 2 && namespace.test(parts[0]);
32
+ }
33
+ const repositoryPattern = /^https:\/\/github\.com\/[a-z0-9][a-z0-9-]{0,38}\/[a-z0-9._-]{1,100}$/;
34
+ const hex40 = /^[a-f0-9]{40}$/, hex64 = /^[a-f0-9]{64}$/;
35
+ const keyPattern = /^(rer|test)-ed25519-[a-f0-9]{32}$/;
36
+ function version(value) {
37
+ return matches(value, /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/)
38
+ && value.split('.').every(part => Number.isSafeInteger(Number(part)));
39
+ }
40
+ function instant(value) {
41
+ return matches(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
42
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value.replace('Z', '.000Z');
43
+ }
44
+ function bytes(value, code) { if (!(value instanceof Uint8Array)) refuse(code); return value; }
45
+ function serialize(value, ancestors) {
46
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
47
+ if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value);
48
+ if ((!Array.isArray(value) && !record(value)) || ancestors.has(value)) refuse('STATEMENT_INVALID');
49
+ ancestors.add(value);
50
+ let result;
51
+ if (Array.isArray(value)) {
52
+ if (Reflect.ownKeys(value).length !== value.length + 1) refuse('STATEMENT_INVALID');
53
+ result = '[' + Array.from({ length: value.length }, (_, i) => {
54
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(i));
55
+ if (!descriptor || !('value' in descriptor)) refuse('STATEMENT_INVALID');
56
+ return serialize(descriptor.value, ancestors);
57
+ }).join(',') + ']';
58
+ } else {
59
+ const keys = Reflect.ownKeys(value);
60
+ if (keys.some(key => typeof key !== 'string')) refuse('STATEMENT_INVALID');
61
+ result = '{' + keys.sort().map(key => {
62
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
63
+ if (!descriptor.enumerable || !('value' in descriptor)) refuse('STATEMENT_INVALID');
64
+ return JSON.stringify(key) + ':' + serialize(descriptor.value, ancestors);
65
+ }).join(',') + '}';
66
+ }
67
+ ancestors.delete(value);
68
+ return result;
69
+ }
70
+ export function canonical(value) {
71
+ try { return serialize(value, new Set()); } catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
72
+ }
73
+ export const canonicalBytes = value => encoder.encode(canonical(value));
74
+ export function pae(type, body) {
75
+ try {
76
+ if (typeof type !== 'string') refuse('ENVELOPE_INVALID');
77
+ bytes(body, 'ENVELOPE_INVALID');
78
+ const prefix = encoder.encode(`DSSEv1 ${encoder.encode(type).length} ${type} ${body.length} `);
79
+ const result = new Uint8Array(prefix.length + body.length);
80
+ result.set(prefix); result.set(body, prefix.length); return result;
81
+ } catch (error) { sanitized(error, 'ENVELOPE_INVALID'); }
82
+ }
83
+ export function b64(value) {
84
+ try { bytes(value, 'ENVELOPE_INVALID'); let text = ''; for (const byte of value) text += String.fromCharCode(byte); return btoa(text); }
85
+ catch (error) { sanitized(error, 'ENVELOPE_INVALID'); }
86
+ }
87
+ function unb64(value) {
88
+ if (!matches(value, /^[A-Za-z0-9+/]*={0,2}$/)) refuse('ENVELOPE_INVALID');
89
+ let result;
90
+ try { result = Uint8Array.from(atob(value), char => char.charCodeAt(0)); } catch { refuse('ENVELOPE_INVALID'); }
91
+ if (b64(result) !== value) refuse('ENVELOPE_INVALID');
92
+ return result;
93
+ }
94
+ export async function sha256(value) {
95
+ try { return [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(value, 'STATEMENT_INVALID')))].map(byte => byte.toString(16).padStart(2, '0')).join(''); }
96
+ catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
97
+ }
98
+ export async function keyIdFor(scope, raw) {
99
+ if (!['rer', 'test'].includes(scope) || !(raw instanceof Uint8Array) || raw.length !== 32) refuse('ANCHORS_INVALID');
100
+ return `${scope}-ed25519-${(await sha256(raw)).slice(0, 32)}`;
101
+ }
102
+ export function compareVersion(a, b) {
103
+ if (!version(a) || !version(b)) refuse('STATEMENT_INVALID');
104
+ const x = a.split('.').map(Number), y = b.split('.').map(Number);
105
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
106
+ return 0;
107
+ }
108
+ const requiredFiles = ['public-source.tar', 'release-receipt.json', 'publish-check.json'];
109
+ const filePattern = /^(public-source\.tar|release-receipt\.json|publish-check\.json|site\/[a-z0-9]+(?:-[a-z0-9]+)*\/[^/]+\/(?:release\.json|documents\/.+))$/;
110
+ function safePath(path) {
111
+ return validRelativePath(path) && matches(path, filePattern);
112
+ }
113
+ export function validateStatement(s) {
114
+ try {
115
+ if (!shape(s, ['schemaVersion', 'publisher', 'project', 'version', 'tag', 'repository', 'publicCommit', 'publicTree', 'tagObject', 'files', 'issuedAt', 'keyId'])
116
+ || s.schemaVersion !== 1 || !matches(s.publisher, namespace) || !validProject(s.project)
117
+ || !s.project.startsWith(s.publisher + '/') || !version(s.version) || s.tag !== 'v' + s.version
118
+ || !matches(s.repository, repositoryPattern) || ['.', '..'].includes(s.repository.split('/')[4])
119
+ || !matches(s.publicCommit, hex40) || !matches(s.publicTree, hex40) || !matches(s.tagObject, hex40)
120
+ || !instant(s.issuedAt) || !matches(s.keyId, keyPattern) || !Array.isArray(s.files)) refuse('STATEMENT_INVALID');
121
+ if (s.files.length > LIMITS.files) refuse('INPUT_TOO_LARGE');
122
+ if (s.files.length < 4) refuse('STATEMENT_INVALID');
123
+ let previous = '';
124
+ for (const f of s.files) {
125
+ if (!shape(f, ['path', 'sha256', 'size']) || !safePath(f.path) || !matches(f.sha256, hex64)
126
+ || !Number.isSafeInteger(f.size) || f.size < 0 || f.path <= previous) refuse('STATEMENT_INVALID');
127
+ if (f.size > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
128
+ previous = f.path;
129
+ }
130
+ const releases = s.files.filter(f => /^site\/[^/]+\/[^/]+\/release\.json$/.test(f.path));
131
+ if (releases.length !== 1 || releases[0].path.split('/')[2] !== s.version) refuse('STATEMENT_INVALID');
132
+ const prefix = releases[0].path.slice(0, -'release.json'.length);
133
+ if (requiredFiles.some(path => !s.files.some(f => f.path === path))
134
+ || s.files.some(f => f.path.startsWith('site/') && !f.path.startsWith(prefix))) refuse('STATEMENT_INVALID');
135
+ } catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
136
+ }
137
+ function parseJSON(value, code) {
138
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(value)); } catch { refuse(code); }
139
+ }
140
+ function envelopeParts(envelope, type) {
141
+ if (!shape(envelope, ['payloadType', 'payload', 'signatures']) || envelope.payloadType !== type
142
+ || !Array.isArray(envelope.signatures) || envelope.signatures.length !== 1
143
+ || !shape(envelope.signatures[0], ['keyid', 'sig']) || !matches(envelope.signatures[0].keyid, keyPattern)) refuse('ENVELOPE_INVALID');
144
+ const payload = unb64(envelope.payload), signature = unb64(envelope.signatures[0].sig);
145
+ if (signature.length !== 64) refuse('ENVELOPE_INVALID');
146
+ return { keyid: envelope.signatures[0].keyid, payload, signature };
147
+ }
148
+ function canonicalPayload(payload) {
149
+ const statement = parseJSON(payload, 'STATEMENT_NOT_CANONICAL');
150
+ let again;
151
+ try { again = canonicalBytes(statement); } catch { refuse('STATEMENT_NOT_CANONICAL'); }
152
+ if (again.length !== payload.length || again.some((byte, index) => byte !== payload[index])) refuse('STATEMENT_NOT_CANONICAL');
153
+ return statement;
154
+ }
155
+ async function checkSignature(key, type, payload, signature) {
156
+ try { if (await crypto.subtle.verify('Ed25519', key, signature, pae(type, payload))) return; } catch { /* Sanitized below. */ }
157
+ refuse('SIGNATURE_INVALID');
158
+ }
159
+ function freeze(value) {
160
+ if (value && typeof value === 'object') { for (const item of Object.values(value)) freeze(item); Object.freeze(value); }
161
+ return value;
162
+ }
163
+ function floors(value, publisher) {
164
+ return record(value) && Object.entries(value).every(([project, maximum]) => validProject(project)
165
+ && (!publisher || project.startsWith(publisher + '/')) && version(maximum));
166
+ }
167
+ // Returns a separate immutable snapshot. Only this module can create trusted anchors.
168
+ // minimumSerial is the last accepted serial: equal and older imports are stale.
169
+ export async function loadAnchors(raw, environment = 'production', options = {}) {
170
+ try {
171
+ if (!['production', 'test'].includes(environment) || !shape(options, [], ['minimumSerial'])
172
+ || (Object.hasOwn(options, 'minimumSerial') && (!Number.isSafeInteger(options.minimumSerial) || options.minimumSerial < 0))) refuse('ANCHORS_INVALID');
173
+ const anchors = raw instanceof Uint8Array ? parseJSON(raw, 'ANCHORS_INVALID')
174
+ : typeof raw === 'string' ? JSON.parse(raw) : JSON.parse(canonical(raw));
175
+ if (!shape(anchors, ['schemaVersion', 'environment', 'serial', 'legacyUnsigned', 'keys']) || anchors.schemaVersion !== 1
176
+ || anchors.environment !== environment || !Number.isSafeInteger(anchors.serial) || anchors.serial < 1
177
+ || !Array.isArray(anchors.keys) || !floors(anchors.legacyUnsigned)) refuse('ANCHORS_INVALID');
178
+ const lookup = new Map(), owners = new Map();
179
+ for (const k of anchors.keys) {
180
+ if (!shape(k, ['keyId', 'publicKey', 'publisher', 'githubOwner', 'notBefore', 'notAfter', 'status', 'enrolment', 'review'], ['retiredAt', 'compromisedAt', 'keepStatements'])
181
+ || !matches(k.keyId, keyPattern) || lookup.has(k.keyId) || !matches(k.publisher, namespace)
182
+ || !shape(k.githubOwner, ['login', 'id']) || !matches(k.githubOwner.login, namespace)
183
+ || !Number.isSafeInteger(k.githubOwner.id) || k.githubOwner.id <= 0 || !ascii(k.review)
184
+ || !instant(k.notBefore) || !instant(k.notAfter) || k.notBefore >= k.notAfter
185
+ || !['active', 'retired', 'compromised'].includes(k.status)) refuse('ANCHORS_INVALID');
186
+ const maximum = new Date(k.notBefore); maximum.setUTCFullYear(maximum.getUTCFullYear() + 2);
187
+ if (Date.parse(k.notAfter) > maximum.getTime()) refuse('ANCHORS_INVALID');
188
+ if ((k.status === 'retired' && !Object.hasOwn(k, 'retiredAt'))
189
+ || (k.status === 'active' && Object.hasOwn(k, 'retiredAt'))
190
+ || (k.status === 'compromised') !== Object.hasOwn(k, 'compromisedAt')
191
+ || (Object.hasOwn(k, 'retiredAt') && !instant(k.retiredAt))
192
+ || (Object.hasOwn(k, 'compromisedAt') && !instant(k.compromisedAt))
193
+ || (Object.hasOwn(k, 'keepStatements') && (k.status !== 'compromised' || !Array.isArray(k.keepStatements)
194
+ || k.keepStatements.some(hash => !matches(hash, hex64)) || new Set(k.keepStatements).size !== k.keepStatements.length))) refuse('ANCHORS_INVALID');
195
+ const owner = canonical(k.githubOwner);
196
+ if (owners.has(k.publisher) && owners.get(k.publisher) !== owner) refuse('ANCHORS_INVALID');
197
+ owners.set(k.publisher, owner);
198
+ const scope = k.keyId.split('-')[0], publicBytes = unb64(k.publicKey);
199
+ if ((environment === 'production' && scope !== 'rer') || publicBytes.length !== 32
200
+ || k.keyId !== await keyIdFor(scope, publicBytes)) refuse('ANCHORS_INVALID');
201
+ if (canonicalBytes(k.enrolment).length > LIMITS.envelopeBytes) refuse('ANCHORS_INVALID');
202
+ const { keyid, payload, signature } = envelopeParts(k.enrolment, ENROLMENT_TYPE);
203
+ if (keyid !== k.keyId) refuse('ANCHORS_INVALID');
204
+ const publicKey = await crypto.subtle.importKey('raw', publicBytes, 'Ed25519', false, ['verify']);
205
+ await checkSignature(publicKey, ENROLMENT_TYPE, payload, signature);
206
+ const claim = canonicalPayload(payload);
207
+ const claimFields = ['schemaVersion', 'keyId', 'publicKey', 'publisher', 'githubOwner', 'notBefore', 'notAfter', 'issuedAt', 'unsignedThrough'];
208
+ if (!shape(claim, claimFields) || claim.schemaVersion !== 1 || !instant(claim.issuedAt) || !floors(claim.unsignedThrough, k.publisher)
209
+ || ['keyId', 'publicKey', 'publisher', 'notBefore', 'notAfter'].some(field => claim[field] !== k[field])
210
+ || canonical(claim.githubOwner) !== owner) refuse('ANCHORS_INVALID');
211
+ k.unsignedThrough = claim.unsignedThrough;
212
+ lookup.set(k.keyId, { anchor: k, publicKey });
213
+ }
214
+ if (Object.keys(anchors.legacyUnsigned).some(project => owners.has(project.split('/')[0]))) refuse('ANCHORS_INVALID');
215
+ if (options.minimumSerial !== undefined && anchors.serial <= options.minimumSerial) refuse('ANCHORS_STALE');
216
+ freeze(anchors); trusted.set(anchors, lookup); return anchors;
217
+ } catch (error) { if (error instanceof Refusal && error.code === 'ANCHORS_STALE') throw error; refuse('ANCHORS_INVALID'); }
218
+ }
219
+ function contextRecord(context) {
220
+ if (!record(context)) refuse('STATEMENT_INVALID');
221
+ const allowed = ['environment', 'now', 'expect', 'files', 'checkPaths', 'highest', 'unsigned'];
222
+ if (Reflect.ownKeys(context).some(key => typeof key !== 'string' || !allowed.includes(key)
223
+ || !('value' in Object.getOwnPropertyDescriptor(context, key)))) refuse('STATEMENT_INVALID');
224
+ return { ...context };
225
+ }
226
+ function expectedSubject(expect, statement) {
227
+ if (expect === undefined) return;
228
+ const fields = ['publisher', 'project', 'version', 'repository', 'tag', 'publicCommit', 'publicTree', 'tagObject'];
229
+ const valid = (field, value) => field === 'publisher' ? matches(value, namespace)
230
+ : field === 'project' ? validProject(value) : field === 'version' ? version(value)
231
+ : field === 'repository' ? matches(value, repositoryPattern) : field === 'tag' ? typeof value === 'string' && value.startsWith('v') && version(value.slice(1))
232
+ : matches(value, hex40);
233
+ if (!shape(expect, [], fields) || Object.entries(expect).some(([field, value]) => !valid(field, value)
234
+ || (statement !== undefined && statement[field] !== value))) refuse('SUBJECT_MISMATCH');
235
+ }
236
+ function versionOrder(highest, versionValue, digest) {
237
+ if (highest === undefined) return;
238
+ if (!shape(highest, ['version', 'statementSha256']) || !version(highest.version) || !matches(highest.statementSha256, hex64)) refuse('STATEMENT_INVALID');
239
+ const order = compareVersion(versionValue, highest.version);
240
+ if (order < 0) refuse('VERSION_ROLLBACK');
241
+ if (order === 0 && digest !== highest.statementSha256) refuse('VERSION_REUSED');
242
+ }
243
+ async function checkFiles(statement, context) {
244
+ if (context.checkPaths !== undefined && typeof context.checkPaths !== 'function') refuse('PATH_UNSAFE');
245
+ if (context.files === undefined) {
246
+ return;
247
+ }
248
+ if (!(context.files instanceof Map)) refuse('PATH_UNSAFE');
249
+ if (context.files.size > LIMITS.files) refuse('INPUT_TOO_LARGE');
250
+ const files = new Map(context.files), selected = new Map();
251
+ const select = path => {
252
+ if (!selected.has(path)) {
253
+ let answer;
254
+ try { answer = context.checkPaths === undefined ? true : context.checkPaths(path); } catch { refuse('PATH_UNSAFE'); }
255
+ if (typeof answer !== 'boolean') refuse('PATH_UNSAFE');
256
+ selected.set(path, answer);
257
+ }
258
+ return selected.get(path);
259
+ };
260
+ for (const [path, value] of files) {
261
+ if (!safePath(path) || !(value instanceof Uint8Array)) refuse('PATH_UNSAFE');
262
+ if (value.byteLength > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
263
+ }
264
+ for (const entry of statement.files) {
265
+ if (!select(entry.path)) continue;
266
+ if (!files.has(entry.path)) refuse('FILE_MISSING');
267
+ const value = files.get(entry.path);
268
+ if (value.byteLength !== entry.size || await sha256(value) !== entry.sha256) refuse('DIGEST_MISMATCH');
269
+ }
270
+ const declared = new Set(statement.files.map(entry => entry.path));
271
+ for (const path of files.keys()) if (select(path) && !declared.has(path)) refuse('FILE_UNDECLARED');
272
+ }
273
+ // Context omits files for service admission, or selects site/ paths for a site
274
+ // build. Base passes all artifact files. Input failures never expose contents.
275
+ export async function verifyRelease(envelopeBytes, loadedAnchors, input = {}) {
276
+ try {
277
+ // Release failures follow the spec's order: size, envelope, scope, lookup,
278
+ // signature, canonical bytes, schema, identity, time, subject, files, version.
279
+ let parts;
280
+ if (envelopeBytes !== null) {
281
+ bytes(envelopeBytes, 'ENVELOPE_INVALID');
282
+ if (envelopeBytes.byteLength > LIMITS.envelopeBytes) refuse('INPUT_TOO_LARGE');
283
+ parts = envelopeParts(parseJSON(envelopeBytes, 'ENVELOPE_INVALID'), PAYLOAD_TYPE);
284
+ }
285
+ const context = contextRecord(input);
286
+ const environment = context.environment === undefined ? 'production' : context.environment;
287
+ if (!['production', 'test'].includes(environment)) refuse('ANCHORS_INVALID');
288
+ if (parts && environment === 'production' && parts.keyid.startsWith('test-')) refuse('KEY_TEST_REFUSED');
289
+ if (!trusted.has(loadedAnchors) || loadedAnchors.environment !== environment) refuse('ANCHORS_INVALID');
290
+ if (!parts) {
291
+ const unsigned = context.unsigned;
292
+ if (unsigned === undefined) refuse('SIGNATURE_MISSING');
293
+ if (!shape(unsigned, ['project', 'version']) || !validProject(unsigned.project) || !version(unsigned.version)) refuse('STATEMENT_INVALID');
294
+ // A legacy floor authenticates no bytes or Git subjects. Callers retain
295
+ // their existing pins; these optional inputs still must be well formed.
296
+ expectedSubject(context.expect);
297
+ if (context.files !== undefined || context.checkPaths !== undefined) {
298
+ // Bytes are not authenticated by a legacy floor, but malformed inputs still fail closed.
299
+ if ((context.files !== undefined && !(context.files instanceof Map)) || (context.checkPaths !== undefined && typeof context.checkPaths !== 'function')) refuse('PATH_UNSAFE');
300
+ if (context.files?.size > LIMITS.files) refuse('INPUT_TOO_LARGE');
301
+ for (const [path, value] of context.files ?? []) {
302
+ if (!safePath(path) || !(value instanceof Uint8Array)) refuse('PATH_UNSAFE');
303
+ if (value.byteLength > LIMITS.fileBytes) refuse('INPUT_TOO_LARGE');
304
+ }
305
+ }
306
+ if (context.now !== undefined && (!Number.isSafeInteger(context.now) || Math.abs(context.now) > 8640000000000000)) refuse('STATEMENT_INVALID');
307
+ const keys = loadedAnchors.keys.filter(key => key.publisher === unsigned.project.split('/')[0]);
308
+ const ceilings = keys.length ? keys.map(key => key.unsignedThrough[unsigned.project]) : [loadedAnchors.legacyUnsigned[unsigned.project]];
309
+ if (!ceilings.some(ceiling => ceiling !== undefined && compareVersion(unsigned.version, ceiling) <= 0)) refuse('SIGNATURE_MISSING');
310
+ versionOrder(context.highest, unsigned.version, undefined);
311
+ return { verdict: 'UNSIGNED_LEGACY', project: unsigned.project, version: unsigned.version };
312
+ }
313
+ const found = trusted.get(loadedAnchors).get(parts.keyid);
314
+ if (!found) refuse('KEY_UNKNOWN');
315
+ await checkSignature(found.publicKey, PAYLOAD_TYPE, parts.payload, parts.signature);
316
+ const statement = canonicalPayload(parts.payload);
317
+ validateStatement(statement);
318
+ if (statement.keyId !== parts.keyid) refuse('STATEMENT_INVALID');
319
+ const anchor = found.anchor;
320
+ if (statement.publisher !== anchor.publisher || statement.repository.split('/')[3] !== anchor.githubOwner.login) refuse('PUBLISHER_MISMATCH');
321
+ const statementSha256 = await sha256(parts.payload);
322
+ const now = context.now === undefined ? Date.now() : context.now;
323
+ if (!Number.isSafeInteger(now) || Math.abs(now) > 8640000000000000) refuse('STATEMENT_INVALID');
324
+ const issued = Date.parse(statement.issuedAt);
325
+ if (issued > now + 300000) refuse('ISSUED_IN_FUTURE');
326
+ if (issued < Date.parse(anchor.notBefore) || issued >= Date.parse(anchor.notAfter)) refuse('KEY_OUTSIDE_VALIDITY');
327
+ if (anchor.status === 'retired' && issued >= Date.parse(anchor.retiredAt)) refuse('KEY_RETIRED');
328
+ if (anchor.status === 'compromised' && !(anchor.keepStatements ?? []).includes(statementSha256)) refuse('KEY_REVOKED');
329
+ if (context.unsigned !== undefined && (!shape(context.unsigned, ['project', 'version'])
330
+ || context.unsigned.project !== statement.project || context.unsigned.version !== statement.version)) refuse('SUBJECT_MISMATCH');
331
+ expectedSubject(context.expect, statement);
332
+ await checkFiles(statement, context);
333
+ versionOrder(context.highest, statement.version, statementSha256);
334
+ return { verdict: 'VERIFIED', statementSha256, keyId: parts.keyid, publisher: statement.publisher, project: statement.project, version: statement.version };
335
+ } catch (error) { sanitized(error, 'STATEMENT_INVALID'); }
336
+ }
@@ -0,0 +1,56 @@
1
+ import { Refusal, validProject } from './signature.mjs';
2
+
3
+ const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)';
4
+ const versionOK = value => new RegExp(`^${semver}$`).test(value) && value.split('.').every(part => Number.isSafeInteger(Number(part)));
5
+ const dateOK = value => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value.replace('Z', '.000Z');
6
+ function parse(verb, args) {
7
+ const fields = { sign: ['artifact', 'key-file', 'scope'], receipt: ['artifact', 'anchors', 'environment', 'after'],
8
+ keygen: ['scope', 'publisher', 'github-owner', 'github-owner-id', 'anchor-out', 'not-before', 'not-after', 'unsigned-through'] }[verb];
9
+ const options = { unsignedThrough: {} };
10
+ const invalid = () => { throw new TypeError('ARGUMENT_INVALID'); };
11
+ for (let index = 0; index < args.length; index++) {
12
+ const flag = args[index];
13
+ if (verb === 'sign' && flag === '--key-stdin' && !options['key-stdin']) { options['key-stdin'] = true; continue; }
14
+ if (!flag.startsWith('--') || !fields.includes(flag.slice(2))) invalid();
15
+ const name = flag.slice(2), value = args[++index];
16
+ if (!value || value.startsWith('--') || (name !== 'unsigned-through' && options[name] !== undefined)) invalid();
17
+ if (name === 'unsigned-through') {
18
+ const match = new RegExp(`^(.+):(${semver})$`).exec(value);
19
+ if (!match || !validProject(match[1]) || !versionOK(match[2]) || Object.hasOwn(options.unsignedThrough, match[1])) invalid();
20
+ options.unsignedThrough[match[1]] = match[2];
21
+ } else options[name] = value;
22
+ }
23
+ if (options.scope !== undefined && !['test', 'rer'].includes(options.scope)) invalid();
24
+ if (verb === 'receipt' && (!options.artifact || !options.anchors || (options.environment !== undefined && options.environment !== 'test') || (options.after && (!new RegExp(`^${semver}:[a-f0-9]{64}$`).test(options.after) || !versionOK(options.after.split(':')[0]))))) invalid();
25
+ if (verb === 'sign' && (!options.artifact || Boolean(options['key-file']) === Boolean(options['key-stdin']))) invalid();
26
+ if (verb === 'keygen') {
27
+ if (!options.scope || !options['anchor-out'] || !/^[a-z0-9][a-z0-9-]{0,38}$/.test(options.publisher ?? '') || !/^[a-z0-9][a-z0-9-]{0,38}$/.test(options['github-owner'] ?? '') || !/^[1-9]\d*$/.test(options['github-owner-id'] ?? '') || !Number.isSafeInteger(Number(options['github-owner-id']))) invalid();
28
+ for (const name of ['not-before', 'not-after']) if (options[name] !== undefined && !dateOK(options[name])) invalid();
29
+ const start = new Date(options['not-before'] ?? Date.now()), maximum = new Date(start);
30
+ maximum.setUTCFullYear(maximum.getUTCFullYear() + 2);
31
+ if (options['not-after'] && (Date.parse(options['not-after']) <= start.getTime() || Date.parse(options['not-after']) > maximum.getTime())) invalid();
32
+ if (Object.keys(options.unsignedThrough).some(project => !project.startsWith(options.publisher + '/'))) invalid();
33
+ if (process.stdout.isTTY) invalid();
34
+ }
35
+ return options;
36
+ }
37
+
38
+ export async function dispatchSigning(verb, args) {
39
+ let options;
40
+ try { options = parse(verb, args); }
41
+ catch { console.log(JSON.stringify({ verdict: 'REFUSED', code: 'ARGUMENT_INVALID' })); return 2; }
42
+ if (verb === 'sign' && options['key-stdin'] && process.stdin.isTTY) {
43
+ console.log(JSON.stringify({ verdict: 'REFUSED', code: 'ARGUMENT_INVALID', message: 'Pipe the private key from a vault or file; --key-stdin does not accept terminal input.' }));
44
+ return 2;
45
+ }
46
+ try {
47
+ if (verb === 'sign') console.log(await (await import('./sign.mjs')).signArtifact(options));
48
+ else if (verb === 'keygen') await (await import('./keygen.mjs')).keygen(options);
49
+ else { await (await import('./verify.mjs')).receipt(options); console.log(JSON.stringify({ verdict: 'VERIFIED' })); }
50
+ return 0;
51
+ } catch (error) {
52
+ const code = error instanceof Refusal ? error.code : 'STATEMENT_INVALID';
53
+ console.log(JSON.stringify({ verdict: 'REFUSED', code }));
54
+ return 1;
55
+ }
56
+ }
@@ -0,0 +1,61 @@
1
+ import { constants, lstatSync, openSync, closeSync, readSync, writeFileSync, fstatSync } from 'node:fs';
2
+ import { resolve, dirname, parse } from 'node:path';
3
+ import { Refusal } from './signature.mjs';
4
+
5
+ export const SIGNATURE_FILE = 'rer-release.dsse.json';
6
+ export const FILE_LIMIT = 1024 ** 3;
7
+ export function refuse(code) { throw new Refusal(code); }
8
+
9
+ // Check every parent too: O_NOFOLLOW alone only protects the final component.
10
+ export function safePath(path, { missing = false } = {}) {
11
+ const absolute = resolve(path), root = parse(absolute).root;
12
+ let current = root;
13
+ for (const part of absolute.slice(root.length).split('/').filter(Boolean)) {
14
+ current = resolve(current, part);
15
+ let stat;
16
+ try { stat = lstatSync(current); }
17
+ catch (error) {
18
+ if (error.code === 'ENOENT' && missing && current === absolute) return absolute;
19
+ refuse('FILE_MISSING');
20
+ }
21
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile()) || (current !== absolute && !stat.isDirectory())) refuse('PATH_UNSAFE');
22
+ }
23
+ return absolute;
24
+ }
25
+
26
+ export function readBounded(path, limit = FILE_LIMIT) {
27
+ const absolute = safePath(path);
28
+ let fd;
29
+ try {
30
+ const before = lstatSync(absolute);
31
+ if (!before.isFile()) refuse('PATH_UNSAFE');
32
+ if (before.size > limit) refuse('INPUT_TOO_LARGE');
33
+ fd = openSync(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
34
+ const stat = fstatSync(fd);
35
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) refuse('PATH_UNSAFE');
36
+ if (stat.size > limit) refuse('INPUT_TOO_LARGE');
37
+ // A bounded read also prevents a concurrently growing file from exhausting memory.
38
+ const bytes = Buffer.alloc(stat.size);
39
+ let offset = 0;
40
+ while (offset < bytes.length) {
41
+ const count = readSync(fd, bytes, offset, bytes.length - offset, offset);
42
+ if (!count) refuse('DIGEST_MISMATCH');
43
+ offset += count;
44
+ }
45
+ const after = fstatSync(fd);
46
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) refuse('DIGEST_MISMATCH');
47
+ return bytes;
48
+ } finally { if (fd !== undefined) closeSync(fd); }
49
+ }
50
+
51
+ export function writeNew(path, bytes) {
52
+ safePath(dirname(resolve(path)));
53
+ safePath(path, { missing: true });
54
+ // Exclusive creation never replaces another receipt, key entry or symlink.
55
+ try { writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 }); }
56
+ catch { refuse('PATH_UNSAFE'); }
57
+ }
58
+
59
+ export function parseJSON(bytes, code = 'STATEMENT_INVALID') {
60
+ try { return JSON.parse(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)); } catch { refuse(code); }
61
+ }
@@ -0,0 +1,22 @@
1
+ import { createPublicKey, sign } from 'node:crypto';
2
+ import { b64, canonicalBytes, pae, keyIdFor, ENROLMENT_TYPE } from './signature.mjs';
3
+
4
+ export const timestamp = date => date.toISOString().replace(/\.\d{3}Z$/, 'Z');
5
+ export function validity(now = new Date()) {
6
+ const start = new Date(timestamp(now)), end = new Date(start);
7
+ end.setUTCFullYear(end.getUTCFullYear() + 2);
8
+ return { notBefore: timestamp(start), notAfter: timestamp(end) };
9
+ }
10
+ export function envelope(privateKey, keyId, type, claim) {
11
+ const payload = canonicalBytes(claim);
12
+ return { payloadType: type, payload: b64(payload), signatures: [{ keyid: keyId, sig: b64(sign(null, pae(type, payload), privateKey)) }] };
13
+ }
14
+ export async function publicEntry(privateKey, { scope, publisher, githubOwner, notBefore, notAfter, unsignedThrough = {}, issuedAt = timestamp(new Date()) }) {
15
+ const jwk = createPublicKey(privateKey).export({ format: 'jwk' });
16
+ const raw = Buffer.from(jwk.x, 'base64url');
17
+ const keyId = await keyIdFor(scope, raw), publicKey = b64(raw);
18
+ const claim = { schemaVersion: 1, keyId, publicKey, publisher, githubOwner, notBefore, notAfter, issuedAt, unsignedThrough };
19
+ return { keyId, publicKey, publisher, githubOwner, notBefore, notAfter, status: 'active',
20
+ enrolment: envelope(privateKey, keyId, ENROLMENT_TYPE, claim), review: 'pending' };
21
+ }
22
+ export const anchorSet = (entry, scope) => ({ schemaVersion: 1, environment: scope === 'test' ? 'test' : 'production', serial: 1, legacyUnsigned: {}, keys: [entry] });
@@ -0,0 +1,80 @@
1
+ import { lstatSync, readdirSync } from 'node:fs';
2
+ import { resolve, posix } from 'node:path';
3
+ import { sha256, validateStatement, validRelativePath as pathOK } from './signature.mjs';
4
+ import { FILE_LIMIT, SIGNATURE_FILE, safePath, readBounded, parseJSON, refuse } from './signing-io.mjs';
5
+
6
+ const required = ['public-source.tar', 'release-receipt.json', 'publish-check.json'];
7
+ export function repositoryURL(value) {
8
+ if (typeof value !== 'string' || !/^https:\/\/github\.com\/[a-z0-9-]+\/[a-z0-9_.-]+(?:\.git)?$/i.test(value)) refuse('SUBJECT_MISMATCH');
9
+ return value.toLowerCase().replace(/\.git$/, '');
10
+ }
11
+
12
+ export async function readArtifact(directory) {
13
+ const root = safePath(directory);
14
+ if (!lstatSync(root).isDirectory()) refuse('PATH_UNSAFE');
15
+ const receiptBytes = readBounded(resolve(root, 'release-receipt.json'));
16
+ const receipt = parseJSON(receiptBytes);
17
+ const manifestPath = receipt?.siteRelease?.path;
18
+ if (!pathOK(manifestPath) || !/^site\/[a-z0-9]+(?:-[a-z0-9]+)*\/(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\/release\.json$/.test(manifestPath)) refuse('PATH_UNSAFE');
19
+ const base = posix.dirname(manifestPath);
20
+ const selected = new Map();
21
+ const walk = (relative = '') => {
22
+ for (const name of readdirSync(resolve(root, relative)).sort()) {
23
+ const path = relative ? `${relative}/${name}` : name;
24
+ const ancillary = path === 'public-git' || path.startsWith('public-git/');
25
+ // Unsigned source names may be Unicode; only the root Git metadata may
26
+ // use .git. Signed paths retain the statement's printable ASCII rules.
27
+ if (ancillary) {
28
+ if (path.includes('\0') || path.includes('\\') || path.split('/').some((part, index) =>
29
+ !part || part === '.' || part === '..' || (part === '.git' && index !== 1))) refuse('PATH_UNSAFE');
30
+ } else if (!pathOK(path)) refuse('PATH_UNSAFE');
31
+ const stat = lstatSync(resolve(root, path));
32
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) refuse('PATH_UNSAFE');
33
+ if (stat.isFile() && stat.size > FILE_LIMIT) refuse('INPUT_TOO_LARGE');
34
+ if (stat.isDirectory()) {
35
+ if (!ancillary && path !== 'site' && path !== posix.dirname(base) && path !== base && path !== `${base}/documents` && !path.startsWith(`${base}/documents/`)) refuse('FILE_UNDECLARED');
36
+ walk(path);
37
+ } else if (required.includes(path) || path === manifestPath || path.startsWith(`${base}/documents/`)) {
38
+ selected.set(path, stat.size);
39
+ if (selected.size > 256) refuse('INPUT_TOO_LARGE');
40
+ } else if (!ancillary && !['PUBLISH.md', 'site/registry.json', SIGNATURE_FILE].includes(path)) refuse('FILE_UNDECLARED');
41
+ }
42
+ };
43
+ walk();
44
+ for (const path of [...required, manifestPath]) if (!selected.has(path)) refuse('FILE_MISSING');
45
+ const files = new Map();
46
+ for (const path of [...selected.keys()].sort()) files.set(path, path === 'release-receipt.json' ? receiptBytes : readBounded(resolve(root, path)));
47
+ const manifest = parseJSON(files.get(manifestPath));
48
+ const publicExport = receipt.publicExport;
49
+ if (!publicExport || publicExport.path !== 'public-source.tar' || !manifest?.provenance || !manifest?.listing) refuse('SUBJECT_MISMATCH');
50
+ const subject = { publisher: manifest.project?.split('/')[0], project: manifest.project, version: manifest.release,
51
+ repository: repositoryURL(manifest.provenance.repository), tag: publicExport.tag,
52
+ publicCommit: publicExport.commit, publicTree: publicExport.tree, tagObject: publicExport.tagObject };
53
+ if (manifestPath !== `site/${manifest.listing.id}/${subject.version}/release.json` || manifest.listing.version !== subject.version || repositoryURL(manifest.listing.repository) !== subject.repository ||
54
+ manifest.provenance.revision !== subject.publicCommit || manifest.provenance.tree !== subject.publicTree || manifest.provenance.tag !== subject.tag || receipt.tag?.name !== subject.tag) refuse('SUBJECT_MISMATCH');
55
+ const entries = [];
56
+ for (const [path, bytes] of files) entries.push({ path, sha256: await sha256(bytes), size: bytes.length });
57
+ const digests = new Map(entries.map(entry => [entry.path, entry.sha256]));
58
+ const match = (path, digest) => { if (typeof digest !== 'string' || digests.get(path) !== digest) refuse('DIGEST_MISMATCH'); };
59
+ match('public-source.tar', publicExport.sha256);
60
+ match('publish-check.json', receipt.gates?.publishCheck?.reportSha256);
61
+ match(manifestPath, receipt.siteRelease.sha256);
62
+ if (!Array.isArray(manifest.artifacts)) refuse('STATEMENT_INVALID');
63
+ const declared = new Set();
64
+ for (const artifact of manifest.artifacts) {
65
+ if (!pathOK(artifact?.path) || !artifact.path.startsWith('documents/')) refuse('PATH_UNSAFE');
66
+ const path = `${base}/${artifact.path}`;
67
+ if (declared.has(path)) refuse('STATEMENT_INVALID');
68
+ declared.add(path);
69
+ if (!files.has(path)) refuse('FILE_MISSING');
70
+ match(path, artifact.sha256);
71
+ }
72
+ for (const path of files.keys()) if (path.startsWith(`${base}/documents/`) && !declared.has(path)) refuse('FILE_UNDECLARED');
73
+ return { root, receipt, subject, files, entries };
74
+ }
75
+
76
+ export function statementFor(artifact, keyId, issuedAt) {
77
+ const statement = { schemaVersion: 1, ...artifact.subject, files: artifact.entries, issuedAt, keyId };
78
+ validateStatement(statement);
79
+ return statement;
80
+ }