@mpgd/cli 0.13.0 → 0.15.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/dist/index.d.ts +3 -0
- package/dist/index.js +500 -11
- package/dist/microsoft-store-package-acceptance.d.ts +54 -0
- package/dist/microsoft-store-package-acceptance.js +787 -0
- package/dist/microsoft-store-package-generation-contract.d.ts +129 -0
- package/dist/microsoft-store-package-generation-contract.js +3 -0
- package/dist/microsoft-store-package-generation-download.d.ts +22 -0
- package/dist/microsoft-store-package-generation-download.js +453 -0
- package/dist/microsoft-store-package-generation-input.d.ts +3 -0
- package/dist/microsoft-store-package-generation-input.js +384 -0
- package/dist/microsoft-store-package-generation-integrity.d.ts +5 -0
- package/dist/microsoft-store-package-generation-integrity.js +100 -0
- package/dist/microsoft-store-package-generation-manifest.d.ts +3 -0
- package/dist/microsoft-store-package-generation-manifest.js +67 -0
- package/dist/microsoft-store-package-generation-zip.d.ts +1 -0
- package/dist/microsoft-store-package-generation-zip.js +180 -0
- package/dist/microsoft-store-package-generation.d.ts +24 -0
- package/dist/microsoft-store-package-generation.js +430 -0
- package/dist/microsoft-store-starter.d.ts +28 -0
- package/dist/microsoft-store-starter.js +655 -0
- package/dist/microsoft-store-submission.d.ts +79 -0
- package/dist/microsoft-store-submission.js +952 -0
- package/dist/microsoft-store-supported-locales.d.ts +1 -0
- package/dist/microsoft-store-supported-locales.js +33 -0
- package/dist/production-target-readiness.js +3 -1
- package/package.json +9 -8
- package/templates/phaser-game/.agents/skills/release-microsoft-store/SKILL.md +60 -0
- package/templates/phaser-game/.agents/skills/release-microsoft-store/agents/openai.yaml +4 -0
- package/templates/phaser-game/.agents/skills/use-mpgd-kit/SKILL.md +45 -0
- package/templates/phaser-game/.agents/skills/use-mpgd-kit/agents/openai.yaml +4 -0
- package/templates/phaser-game/AGENTS.md +34 -0
- package/templates/phaser-game/README.md +88 -25
- package/templates/phaser-game/agent/acceptance.md +8 -0
- package/templates/phaser-game/agent/brief.md +13 -1
- package/templates/phaser-game/agent/game-manifest.json +7 -0
- package/templates/phaser-game/apps/target-ait/granite.config.ts +35 -0
- package/templates/phaser-game/apps/target-ait/index.html +15 -0
- package/templates/phaser-game/apps/target-ait/package.json +33 -0
- package/templates/phaser-game/apps/target-ait/src/main.ts +34 -0
- package/templates/phaser-game/apps/target-ait/src/styles.css +33 -0
- package/templates/phaser-game/apps/target-ait/tsconfig.json +8 -0
- package/templates/phaser-game/apps/target-ait/vite.config.ts +53 -0
- package/templates/phaser-game/docs/MPGD_KIT_WORKFLOWS.md +128 -0
- package/templates/phaser-game/gitignore +7 -0
- package/templates/phaser-game/mpgd.microsoft-store.json +29 -0
- package/templates/phaser-game/mpgd.targets.json +9 -2
- package/templates/phaser-game/package.json +11 -9
- package/templates/phaser-game/vite.shared.ts +13 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { TextDecoder } from 'node:util';
|
|
5
|
+
import { formatError } from './evidence-io.js';
|
|
6
|
+
import { assertMicrosoftStoreSnapshotUnchanged, hashMicrosoftStoreBytes, hashMicrosoftStoreFileSnapshot, readBoundedMicrosoftStoreFileBytes, } from './microsoft-store-package-generation-integrity.js';
|
|
7
|
+
import { assertMicrosoftStorePwaUrlInsideManifestScope, readHashVerifiedMicrosoftStoreManifest, } from './microsoft-store-package-generation-manifest.js';
|
|
8
|
+
const maximumSubmissionEvidenceBytes = 4 * 1024 * 1024;
|
|
9
|
+
const maximumManifestIcons = 32;
|
|
10
|
+
const maximumManifestIconBytes = 2 * 1024 * 1024;
|
|
11
|
+
export function prepareMicrosoftStorePackageGenerationInput(input) {
|
|
12
|
+
const gameRoot = readCanonicalDirectory(input.gameRoot, 'game root');
|
|
13
|
+
const submissionEvidenceFile = readCanonicalFileInside(gameRoot, input.submissionEvidenceFile, 'Microsoft Store submission evidence');
|
|
14
|
+
const submissionBefore = hashMicrosoftStoreFileSnapshot(submissionEvidenceFile, 'Microsoft Store submission evidence');
|
|
15
|
+
const submissionEvidence = readSubmissionEvidence(submissionEvidenceFile, gameRoot, submissionBefore);
|
|
16
|
+
const manifestBefore = hashMicrosoftStoreFileSnapshot(submissionEvidence.manifestFile, 'Microsoft Store web app manifest');
|
|
17
|
+
if (manifestBefore.sha256 !== submissionEvidence.manifestSha256) {
|
|
18
|
+
throw new Error(`Microsoft Store web app manifest SHA-256 must match submission evidence: expected ${submissionEvidence.manifestSha256}, received ${manifestBefore.sha256}.`);
|
|
19
|
+
}
|
|
20
|
+
const manifest = readHashVerifiedMicrosoftStoreManifest(submissionEvidence.manifestFile, manifestBefore);
|
|
21
|
+
const pwaUrl = requirePublicHttpsUrl(input.pwaUrl, 'Microsoft Store PWA URL');
|
|
22
|
+
const manifestUrl = requirePublicHttpsUrl(input.manifestUrl, 'Microsoft Store manifest URL');
|
|
23
|
+
const submission = {
|
|
24
|
+
...submissionEvidence,
|
|
25
|
+
manifest,
|
|
26
|
+
manifestIcons: prepareManifestIcons(gameRoot, manifestUrl, manifest, submissionEvidence.manifestIcons),
|
|
27
|
+
};
|
|
28
|
+
assertMicrosoftStorePwaUrlInsideManifestScope(pwaUrl, manifestUrl, submission.manifest);
|
|
29
|
+
const modernVersion = requireStorePackageVersion(input.modernVersion, 'modern package version');
|
|
30
|
+
const classicVersion = requireStorePackageVersion(input.classicVersion, 'classic package version');
|
|
31
|
+
if (compareVersions(classicVersion, modernVersion) >= 0) {
|
|
32
|
+
throw new Error('Microsoft Store classic package version must be lower than modern version.');
|
|
33
|
+
}
|
|
34
|
+
const outputFile = prepareMissingOutputFile(gameRoot, input.outputFile, 'package ZIP');
|
|
35
|
+
const jsonFile = resolveEvidenceFile(gameRoot, input.jsonFile, 'package generation JSON');
|
|
36
|
+
const markdownFile = resolveEvidenceFile(gameRoot, input.markdownFile, 'package generation Markdown');
|
|
37
|
+
const filesThatMustAlwaysBeDistinct = [
|
|
38
|
+
{ file: outputFile, label: 'package ZIP' },
|
|
39
|
+
{ file: jsonFile, label: 'package generation JSON' },
|
|
40
|
+
{ file: markdownFile, label: 'package generation Markdown' },
|
|
41
|
+
{ file: submissionEvidenceFile, label: 'submission evidence' },
|
|
42
|
+
{ file: submission.manifestFile, label: 'web app manifest' },
|
|
43
|
+
];
|
|
44
|
+
assertDistinctFiles(filesThatMustAlwaysBeDistinct);
|
|
45
|
+
for (const [index, icon] of submission.manifestIcons.entries()) {
|
|
46
|
+
assertDistinctFiles([
|
|
47
|
+
...filesThatMustAlwaysBeDistinct,
|
|
48
|
+
{ file: icon.file, label: `web app manifest icon[${index}]` },
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
gameRoot,
|
|
53
|
+
submissionEvidenceFile,
|
|
54
|
+
submissionBefore,
|
|
55
|
+
submission,
|
|
56
|
+
manifestBefore,
|
|
57
|
+
pwaUrl,
|
|
58
|
+
manifestUrl,
|
|
59
|
+
modernVersion,
|
|
60
|
+
classicVersion,
|
|
61
|
+
outputFile,
|
|
62
|
+
jsonFile,
|
|
63
|
+
markdownFile,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export function assertMicrosoftStorePackageGenerationInputUnchanged(input) {
|
|
67
|
+
assertMicrosoftStoreSnapshotUnchanged(input.submissionEvidenceFile, input.submissionBefore, 'Microsoft Store submission evidence changed during package generation');
|
|
68
|
+
assertMicrosoftStoreSnapshotUnchanged(input.submission.manifestFile, input.manifestBefore, 'Microsoft Store web app manifest changed during package generation');
|
|
69
|
+
for (const [index, icon] of input.submission.manifestIcons.entries()) {
|
|
70
|
+
assertMicrosoftStoreSnapshotUnchanged(icon.file, icon.snapshot, `Microsoft Store web app manifest icon[${index}] changed during package generation`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function readSubmissionEvidence(file, gameRoot, expected) {
|
|
74
|
+
const bytes = readBoundedMicrosoftStoreFileBytes(file, 'Microsoft Store submission evidence', maximumSubmissionEvidenceBytes);
|
|
75
|
+
if (bytes === null) {
|
|
76
|
+
throw new Error('Microsoft Store submission evidence is too large.');
|
|
77
|
+
}
|
|
78
|
+
if (bytes.length !== expected.sizeBytes || hashMicrosoftStoreBytes(bytes) !== expected.sha256) {
|
|
79
|
+
throw new Error(`Microsoft Store submission evidence changed while it was read: ${file}`);
|
|
80
|
+
}
|
|
81
|
+
assertMicrosoftStoreSnapshotUnchanged(file, expected, 'Microsoft Store submission evidence changed while it was parsed');
|
|
82
|
+
let source;
|
|
83
|
+
try {
|
|
84
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Microsoft Store submission evidence must use valid UTF-8: ${formatError(error)}`);
|
|
88
|
+
}
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(source);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new Error(`Failed to parse Microsoft Store submission evidence: ${formatError(error)}`);
|
|
95
|
+
}
|
|
96
|
+
const root = requireRecord(parsed, 'Microsoft Store submission evidence');
|
|
97
|
+
if (root.schemaVersion !== 1 || root.target !== 'microsoft-store') {
|
|
98
|
+
throw new Error('Microsoft Store submission evidence must use schemaVersion 1 and target microsoft-store.');
|
|
99
|
+
}
|
|
100
|
+
const identity = requireRecord(root.productIdentity, 'Microsoft Store product identity');
|
|
101
|
+
const manifest = requireRecord(root.manifest, 'Microsoft Store manifest evidence');
|
|
102
|
+
const manifestIcons = requireArray(manifest.icons, 'Microsoft Store manifest icon evidence');
|
|
103
|
+
const manifestIconCount = requirePositiveInteger(manifest.iconCount, 'Microsoft Store manifest icon evidence count');
|
|
104
|
+
const listing = requireRecord(root.listing, 'Microsoft Store listing evidence');
|
|
105
|
+
const locales = requireRecord(listing.locales, 'Microsoft Store listing locales');
|
|
106
|
+
const resourceLanguages = Object.keys(locales).sort(compareCodeUnits);
|
|
107
|
+
if (resourceLanguages.length === 0) {
|
|
108
|
+
throw new Error('Microsoft Store submission evidence must contain a listing locale.');
|
|
109
|
+
}
|
|
110
|
+
if (manifestIcons.length === 0
|
|
111
|
+
|| manifestIcons.length > maximumManifestIcons
|
|
112
|
+
|| manifestIconCount !== manifestIcons.length) {
|
|
113
|
+
throw new Error(`Microsoft Store manifest icon evidence must contain 1-${maximumManifestIcons} entries and match iconCount.`);
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
identity: {
|
|
117
|
+
packageId: requireNonEmptyString(identity.packageId, 'Microsoft Store package ID'),
|
|
118
|
+
publisherId: requireNonEmptyString(identity.publisherId, 'Microsoft Store publisher ID'),
|
|
119
|
+
publisherDisplayName: requireNonEmptyString(identity.publisherDisplayName, 'Microsoft Store publisher display name'),
|
|
120
|
+
reservedName: requireNonEmptyString(identity.reservedName, 'Microsoft Store reserved name'),
|
|
121
|
+
},
|
|
122
|
+
manifestFile: readCanonicalFileInside(gameRoot, path.resolve(gameRoot, requireNonEmptyString(manifest.file, 'manifest evidence file')), 'Microsoft Store web app manifest'),
|
|
123
|
+
manifestSha256: requireSha256(manifest.sha256, 'manifest evidence SHA-256'),
|
|
124
|
+
manifestIcons: manifestIcons.map((icon, index) => {
|
|
125
|
+
const record = requireRecord(icon, `Microsoft Store manifest icon evidence[${index}]`);
|
|
126
|
+
const iconFile = readCanonicalFileInside(gameRoot, path.resolve(gameRoot, requireNonEmptyString(record.file, `Microsoft Store manifest icon evidence[${index}].file`)), `Microsoft Store web app manifest icon[${index}]`);
|
|
127
|
+
const snapshot = hashMicrosoftStoreFileSnapshot(iconFile, `Microsoft Store web app manifest icon[${index}]`);
|
|
128
|
+
const expectedSha256 = requireSha256(record.sha256, `Microsoft Store manifest icon evidence[${index}].sha256`);
|
|
129
|
+
if (snapshot.sizeBytes > maximumManifestIconBytes) {
|
|
130
|
+
throw new Error(`Microsoft Store web app manifest icon[${index}] exceeds the ${maximumManifestIconBytes}-byte size limit.`);
|
|
131
|
+
}
|
|
132
|
+
if (snapshot.sha256 !== expectedSha256) {
|
|
133
|
+
throw new Error(`Microsoft Store web app manifest icon[${index}] SHA-256 must match submission evidence: expected ${expectedSha256}, received ${snapshot.sha256}.`);
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
file: iconFile,
|
|
137
|
+
url: '',
|
|
138
|
+
snapshot,
|
|
139
|
+
width: requirePositiveInteger(record.width, `Microsoft Store manifest icon evidence[${index}].width`),
|
|
140
|
+
height: requirePositiveInteger(record.height, `Microsoft Store manifest icon evidence[${index}].height`),
|
|
141
|
+
};
|
|
142
|
+
}),
|
|
143
|
+
resourceLanguage: resourceLanguages.join(','),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function prepareManifestIcons(gameRoot, manifestUrl, manifest, evidenceIcons) {
|
|
147
|
+
const manifestIcons = requireArray(manifest.icons, 'Microsoft Store web app manifest icons');
|
|
148
|
+
if (manifestIcons.length !== evidenceIcons.length) {
|
|
149
|
+
throw new Error('Microsoft Store web app manifest icons must match submission evidence by index.');
|
|
150
|
+
}
|
|
151
|
+
return manifestIcons.map((icon, index) => {
|
|
152
|
+
const record = requireRecord(icon, `Microsoft Store web app manifest icons[${index}]`);
|
|
153
|
+
const src = requireNonEmptyString(record.src, `Microsoft Store web app manifest icons[${index}].src`);
|
|
154
|
+
let resolvedUrl;
|
|
155
|
+
try {
|
|
156
|
+
resolvedUrl = new URL(src, manifestUrl).href;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
throw new Error(`Microsoft Store web app manifest icons[${index}].src must resolve against the deployed manifest URL.`);
|
|
160
|
+
}
|
|
161
|
+
const evidence = evidenceIcons[index];
|
|
162
|
+
if (evidence === undefined) {
|
|
163
|
+
throw new Error(`Microsoft Store manifest icon evidence[${index}] is missing.`);
|
|
164
|
+
}
|
|
165
|
+
assertInside(gameRoot, evidence.file, `Microsoft Store web app manifest icon[${index}]`);
|
|
166
|
+
const dimensions = requireManifestIconDimensions(record.sizes, `Microsoft Store web app manifest icons[${index}].sizes`);
|
|
167
|
+
if (evidence.width !== dimensions.width || evidence.height !== dimensions.height) {
|
|
168
|
+
throw new Error(`Microsoft Store manifest icon evidence[${index}] dimensions must match the hash-verified web app manifest: expected ${dimensions.width}x${dimensions.height}, received ${evidence.width}x${evidence.height}.`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...evidence,
|
|
172
|
+
url: requirePublicHttpsUrl(resolvedUrl, `Microsoft Store deployed manifest icon[${index}] URL`),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function requirePublicHttpsUrl(input, label) {
|
|
177
|
+
const value = requireNonEmptyString(input, label);
|
|
178
|
+
let url;
|
|
179
|
+
try {
|
|
180
|
+
url = new URL(value);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
throw new Error(`${label} must be a public HTTPS URL.`);
|
|
184
|
+
}
|
|
185
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/u, '');
|
|
186
|
+
const unbracketedHostname = hostname.replace(/^\[|\]$/gu, '');
|
|
187
|
+
if (url.protocol !== 'https:'
|
|
188
|
+
|| url.username.length > 0
|
|
189
|
+
|| url.password.length > 0
|
|
190
|
+
|| url.hash.length > 0
|
|
191
|
+
|| hostname === 'localhost'
|
|
192
|
+
|| hostname.endsWith('.localhost')
|
|
193
|
+
|| hostname.endsWith('.local')
|
|
194
|
+
|| ['invalid', 'test', 'example'].some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
|
|
195
|
+
|| isIP(unbracketedHostname) !== 0) {
|
|
196
|
+
throw new Error(`${label} must be a public HTTPS URL without credentials or a fragment.`);
|
|
197
|
+
}
|
|
198
|
+
return url.href;
|
|
199
|
+
}
|
|
200
|
+
function requireStorePackageVersion(input, label) {
|
|
201
|
+
const value = requireNonEmptyString(input, label);
|
|
202
|
+
const parts = value.split('.');
|
|
203
|
+
if ((parts.length !== 3 && parts.length !== 4)
|
|
204
|
+
|| parts.some((part) => !/^(?:0|[1-9][0-9]{0,4})$/u.test(part))) {
|
|
205
|
+
throw new Error(`${label} must use three numeric parts or four parts ending in .0.`);
|
|
206
|
+
}
|
|
207
|
+
const numbers = parts.map(Number);
|
|
208
|
+
if (numbers[0] === 0
|
|
209
|
+
|| numbers.some((part) => part > 65535)
|
|
210
|
+
|| (numbers[3] ?? 0) !== 0) {
|
|
211
|
+
throw new Error(`${label} must start from 1, keep every part at or below 65535, and end in .0.`);
|
|
212
|
+
}
|
|
213
|
+
return parts.length === 3 ? `${value}.0` : value;
|
|
214
|
+
}
|
|
215
|
+
function compareVersions(left, right) {
|
|
216
|
+
const leftParts = left.split('.').map(Number);
|
|
217
|
+
const rightParts = right.split('.').map(Number);
|
|
218
|
+
for (let index = 0; index < 4; index += 1) {
|
|
219
|
+
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
|
220
|
+
if (difference !== 0) {
|
|
221
|
+
return difference;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
function prepareMissingOutputFile(root, input, label) {
|
|
227
|
+
const resolved = resolvePotentiallyMissingPath(input);
|
|
228
|
+
assertInside(root, resolved, label);
|
|
229
|
+
if (path.extname(resolved).toLowerCase() !== '.zip') {
|
|
230
|
+
throw new Error(`${label} must use a .zip extension.`);
|
|
231
|
+
}
|
|
232
|
+
if (existsSync(resolved)) {
|
|
233
|
+
throw new Error(`${label} must not already exist: ${resolved}`);
|
|
234
|
+
}
|
|
235
|
+
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
236
|
+
const parent = realpathSync(path.dirname(resolved));
|
|
237
|
+
assertInside(root, parent, `${label} directory`);
|
|
238
|
+
return path.join(parent, path.basename(resolved));
|
|
239
|
+
}
|
|
240
|
+
function resolveEvidenceFile(root, input, label) {
|
|
241
|
+
const resolved = resolvePotentiallyMissingPath(input);
|
|
242
|
+
assertInside(root, resolved, label);
|
|
243
|
+
const metadata = lstatIfExists(resolved, label);
|
|
244
|
+
if (metadata !== undefined && (metadata.isSymbolicLink() || !metadata.isFile())) {
|
|
245
|
+
throw new Error(`${label} must be a regular file when it already exists: ${resolved}`);
|
|
246
|
+
}
|
|
247
|
+
return resolved;
|
|
248
|
+
}
|
|
249
|
+
function lstatIfExists(file, label) {
|
|
250
|
+
try {
|
|
251
|
+
return lstatSync(file);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
if (hasErrorCode(error, 'ENOENT')) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`Failed to inspect ${label}: ${file} (${formatError(error)})`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function hasErrorCode(error, code) {
|
|
261
|
+
return error instanceof Error && 'code' in error && error.code === code;
|
|
262
|
+
}
|
|
263
|
+
function resolvePotentiallyMissingPath(input) {
|
|
264
|
+
const resolved = path.resolve(input);
|
|
265
|
+
let ancestor = resolved;
|
|
266
|
+
while (!existsSync(ancestor)) {
|
|
267
|
+
const parent = path.dirname(ancestor);
|
|
268
|
+
if (parent === ancestor) {
|
|
269
|
+
throw new Error(`Could not find an existing ancestor for output path: ${resolved}`);
|
|
270
|
+
}
|
|
271
|
+
ancestor = parent;
|
|
272
|
+
}
|
|
273
|
+
return path.resolve(realpathSync(ancestor), path.relative(ancestor, resolved));
|
|
274
|
+
}
|
|
275
|
+
function readCanonicalDirectory(input, label) {
|
|
276
|
+
let canonical;
|
|
277
|
+
try {
|
|
278
|
+
canonical = realpathSync(input);
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
throw new Error(`${label} must exist: ${input} (${formatError(error)})`);
|
|
282
|
+
}
|
|
283
|
+
if (!lstatSync(canonical).isDirectory()) {
|
|
284
|
+
throw new Error(`${label} must be a directory: ${canonical}`);
|
|
285
|
+
}
|
|
286
|
+
return canonical;
|
|
287
|
+
}
|
|
288
|
+
function readCanonicalFile(input, label) {
|
|
289
|
+
let canonical;
|
|
290
|
+
try {
|
|
291
|
+
canonical = realpathSync(input);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
throw new Error(`${label} must exist: ${input} (${formatError(error)})`);
|
|
295
|
+
}
|
|
296
|
+
if (!lstatSync(canonical).isFile()) {
|
|
297
|
+
throw new Error(`${label} must be a regular file: ${canonical}`);
|
|
298
|
+
}
|
|
299
|
+
return canonical;
|
|
300
|
+
}
|
|
301
|
+
function readCanonicalFileInside(root, input, label) {
|
|
302
|
+
const canonical = readCanonicalFile(input, label);
|
|
303
|
+
assertInside(root, canonical, label);
|
|
304
|
+
return canonical;
|
|
305
|
+
}
|
|
306
|
+
function assertDistinctFiles(files) {
|
|
307
|
+
for (const [index, file] of files.entries()) {
|
|
308
|
+
for (const candidate of files.slice(index + 1)) {
|
|
309
|
+
if (path.relative(file.file, candidate.file).length === 0) {
|
|
310
|
+
throw new Error(`${file.label} must not alias ${candidate.label}: ${file.file}`);
|
|
311
|
+
}
|
|
312
|
+
if (existsSync(file.file) && existsSync(candidate.file)) {
|
|
313
|
+
const fileMetadata = statSync(file.file);
|
|
314
|
+
const candidateMetadata = statSync(candidate.file);
|
|
315
|
+
if (
|
|
316
|
+
// Windows may report ino=0, which means file identity is unavailable rather than equal.
|
|
317
|
+
fileMetadata.ino !== 0
|
|
318
|
+
&& fileMetadata.dev === candidateMetadata.dev
|
|
319
|
+
&& fileMetadata.ino === candidateMetadata.ino) {
|
|
320
|
+
throw new Error(`${file.label} must not alias ${candidate.label}: ${file.file}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function assertInside(root, candidate, label) {
|
|
327
|
+
const relative = path.relative(root, candidate);
|
|
328
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
329
|
+
throw new Error(`${label} must stay inside the game root: ${root}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function requireRecord(input, label) {
|
|
333
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
334
|
+
throw new Error(`${label} must be an object.`);
|
|
335
|
+
}
|
|
336
|
+
return input;
|
|
337
|
+
}
|
|
338
|
+
function requireArray(input, label) {
|
|
339
|
+
if (!Array.isArray(input)) {
|
|
340
|
+
throw new Error(`${label} must be an array.`);
|
|
341
|
+
}
|
|
342
|
+
return input;
|
|
343
|
+
}
|
|
344
|
+
function requireNonEmptyString(input, label) {
|
|
345
|
+
if (typeof input !== 'string' || input.length === 0 || input.trim() !== input) {
|
|
346
|
+
throw new Error(`${label} must be a non-empty string without surrounding whitespace.`);
|
|
347
|
+
}
|
|
348
|
+
return input;
|
|
349
|
+
}
|
|
350
|
+
function requireSha256(input, label) {
|
|
351
|
+
const value = requireNonEmptyString(input, label).toLowerCase();
|
|
352
|
+
if (!/^[0-9a-f]{64}$/u.test(value)) {
|
|
353
|
+
throw new Error(`${label} must contain 64 hexadecimal characters.`);
|
|
354
|
+
}
|
|
355
|
+
return value;
|
|
356
|
+
}
|
|
357
|
+
function requirePositiveInteger(input, label) {
|
|
358
|
+
if (typeof input !== 'number' || !Number.isSafeInteger(input) || input <= 0) {
|
|
359
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
360
|
+
}
|
|
361
|
+
return input;
|
|
362
|
+
}
|
|
363
|
+
function requireManifestIconDimensions(input, label) {
|
|
364
|
+
const value = requireNonEmptyString(input, label);
|
|
365
|
+
const match = /^(\d+)x(\d+)$/u.exec(value);
|
|
366
|
+
if (match === null) {
|
|
367
|
+
throw new Error(`${label} must declare one width and height in pixels.`);
|
|
368
|
+
}
|
|
369
|
+
const width = Number(match[1]);
|
|
370
|
+
const height = Number(match[2]);
|
|
371
|
+
if (!Number.isSafeInteger(width) || width <= 0 || !Number.isSafeInteger(height) || height <= 0) {
|
|
372
|
+
throw new Error(`${label} must declare positive safe-integer dimensions.`);
|
|
373
|
+
}
|
|
374
|
+
return { width, height };
|
|
375
|
+
}
|
|
376
|
+
function compareCodeUnits(left, right) {
|
|
377
|
+
if (left < right) {
|
|
378
|
+
return -1;
|
|
379
|
+
}
|
|
380
|
+
if (left > right) {
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type MicrosoftStoreFileSnapshot } from './microsoft-store-package-generation-contract.js';
|
|
2
|
+
export declare function hashMicrosoftStoreFileSnapshot(file: string, label: string): MicrosoftStoreFileSnapshot;
|
|
3
|
+
export declare function readBoundedMicrosoftStoreFileBytes(file: string, label: string, maximumBytes: number): Buffer | null;
|
|
4
|
+
export declare function assertMicrosoftStoreSnapshotUnchanged(file: string, expected: MicrosoftStoreFileSnapshot, message: string): void;
|
|
5
|
+
export declare function hashMicrosoftStoreBytes(input: Uint8Array): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync, statSync } from 'node:fs';
|
|
3
|
+
import { formatError } from './evidence-io.js';
|
|
4
|
+
export function hashMicrosoftStoreFileSnapshot(file, label) {
|
|
5
|
+
const descriptor = openMicrosoftStoreFile(file, label);
|
|
6
|
+
try {
|
|
7
|
+
const pathBefore = statSync(file);
|
|
8
|
+
const openedBefore = fstatSync(descriptor);
|
|
9
|
+
if (pathBefore.dev !== openedBefore.dev
|
|
10
|
+
|| pathBefore.ino !== openedBefore.ino
|
|
11
|
+
|| !openedBefore.isFile()) {
|
|
12
|
+
throw new Error(`${label} changed while it was opened: ${file}`);
|
|
13
|
+
}
|
|
14
|
+
const firstSnapshot = hashMicrosoftStoreOpenFile(descriptor);
|
|
15
|
+
const openedBetween = fstatSync(descriptor);
|
|
16
|
+
const secondSnapshot = hashMicrosoftStoreOpenFile(descriptor);
|
|
17
|
+
const openedAfter = fstatSync(descriptor);
|
|
18
|
+
const pathAfter = statSync(file);
|
|
19
|
+
if (firstSnapshot.sizeBytes !== openedBefore.size
|
|
20
|
+
|| secondSnapshot.sizeBytes !== openedBefore.size
|
|
21
|
+
|| !equalMicrosoftStoreHashes(firstSnapshot.sha256, secondSnapshot.sha256)
|
|
22
|
+
|| openedBetween.size !== openedBefore.size
|
|
23
|
+
|| openedBetween.mtimeMs !== openedBefore.mtimeMs
|
|
24
|
+
|| openedBetween.ctimeMs !== openedBefore.ctimeMs
|
|
25
|
+
|| openedAfter.size !== openedBefore.size
|
|
26
|
+
|| openedAfter.mtimeMs !== openedBefore.mtimeMs
|
|
27
|
+
|| openedAfter.ctimeMs !== openedBefore.ctimeMs
|
|
28
|
+
|| pathAfter.dev !== openedBefore.dev
|
|
29
|
+
|| pathAfter.ino !== openedBefore.ino
|
|
30
|
+
|| pathAfter.size !== openedBefore.size
|
|
31
|
+
|| pathAfter.mtimeMs !== openedBefore.mtimeMs
|
|
32
|
+
|| pathAfter.ctimeMs !== openedBefore.ctimeMs) {
|
|
33
|
+
throw new Error(`${label} changed while it was hashed: ${file}`);
|
|
34
|
+
}
|
|
35
|
+
return secondSnapshot;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
closeSync(descriptor);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function readBoundedMicrosoftStoreFileBytes(file, label, maximumBytes) {
|
|
42
|
+
const descriptor = openMicrosoftStoreFile(file, label);
|
|
43
|
+
const buffer = Buffer.allocUnsafe(maximumBytes + 1);
|
|
44
|
+
let offset = 0;
|
|
45
|
+
try {
|
|
46
|
+
while (offset < buffer.length) {
|
|
47
|
+
const bytesRead = readSync(descriptor, buffer, offset, buffer.length - offset, null);
|
|
48
|
+
if (bytesRead === 0) {
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
offset += bytesRead;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
throw new Error(`Failed to read ${label}: ${file} (${formatError(error)})`, { cause: error });
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
closeSync(descriptor);
|
|
59
|
+
}
|
|
60
|
+
return offset > maximumBytes ? null : buffer.subarray(0, offset);
|
|
61
|
+
}
|
|
62
|
+
export function assertMicrosoftStoreSnapshotUnchanged(file, expected, message) {
|
|
63
|
+
const actual = hashMicrosoftStoreFileSnapshot(file, message);
|
|
64
|
+
if (actual.sizeBytes !== expected.sizeBytes
|
|
65
|
+
|| !equalMicrosoftStoreHashes(actual.sha256, expected.sha256)) {
|
|
66
|
+
throw new Error(`${message}: ${file}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function hashMicrosoftStoreBytes(input) {
|
|
70
|
+
return createHash('sha256').update(input).digest('hex');
|
|
71
|
+
}
|
|
72
|
+
function openMicrosoftStoreFile(file, label) {
|
|
73
|
+
try {
|
|
74
|
+
return openSync(file, 'r');
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new Error(`Failed to open ${label}: ${file} (${formatError(error)})`, { cause: error });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function hashMicrosoftStoreOpenFile(descriptor) {
|
|
81
|
+
const hash = createHash('sha256');
|
|
82
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
83
|
+
let sizeBytes = 0;
|
|
84
|
+
while (true) {
|
|
85
|
+
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, sizeBytes);
|
|
86
|
+
if (bytesRead === 0) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
90
|
+
sizeBytes += bytesRead;
|
|
91
|
+
}
|
|
92
|
+
return { sizeBytes, sha256: hash.digest('hex') };
|
|
93
|
+
}
|
|
94
|
+
function equalMicrosoftStoreHashes(actual, expected) {
|
|
95
|
+
const actualBytes = Buffer.from(actual, 'hex');
|
|
96
|
+
const expectedBytes = Buffer.from(expected, 'hex');
|
|
97
|
+
return actualBytes.length === expectedBytes.length
|
|
98
|
+
&& actualBytes.length === 32
|
|
99
|
+
&& timingSafeEqual(actualBytes, expectedBytes);
|
|
100
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type MicrosoftStoreFileSnapshot } from './microsoft-store-package-generation-contract.js';
|
|
2
|
+
export declare function readHashVerifiedMicrosoftStoreManifest(file: string, expected: MicrosoftStoreFileSnapshot): Readonly<Record<string, unknown>>;
|
|
3
|
+
export declare function assertMicrosoftStorePwaUrlInsideManifestScope(pwaUrl: string, manifestUrl: string, manifest: Readonly<Record<string, unknown>>): void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { TextDecoder } from 'node:util';
|
|
2
|
+
import { formatError } from './evidence-io.js';
|
|
3
|
+
import { assertMicrosoftStoreSnapshotUnchanged, hashMicrosoftStoreBytes, readBoundedMicrosoftStoreFileBytes, } from './microsoft-store-package-generation-integrity.js';
|
|
4
|
+
const maximumManifestBytes = 1024 * 1024;
|
|
5
|
+
export function readHashVerifiedMicrosoftStoreManifest(file, expected) {
|
|
6
|
+
const bytes = readBoundedMicrosoftStoreFileBytes(file, 'Microsoft Store web app manifest', maximumManifestBytes);
|
|
7
|
+
if (bytes === null) {
|
|
8
|
+
throw new Error('Microsoft Store web app manifest is too large.');
|
|
9
|
+
}
|
|
10
|
+
if (bytes.length !== expected.sizeBytes
|
|
11
|
+
|| hashMicrosoftStoreBytes(bytes) !== expected.sha256) {
|
|
12
|
+
throw new Error(`Microsoft Store web app manifest changed while it was read: ${file}`);
|
|
13
|
+
}
|
|
14
|
+
assertMicrosoftStoreSnapshotUnchanged(file, expected, 'Microsoft Store web app manifest changed while it was parsed');
|
|
15
|
+
let source;
|
|
16
|
+
try {
|
|
17
|
+
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
throw new Error(`Microsoft Store web app manifest must use valid UTF-8: ${formatError(error)}`);
|
|
21
|
+
}
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(source);
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new Error(`Failed to parse Microsoft Store web app manifest: ${formatError(error)}`);
|
|
28
|
+
}
|
|
29
|
+
return requireRecord(parsed, 'Microsoft Store web app manifest');
|
|
30
|
+
}
|
|
31
|
+
export function assertMicrosoftStorePwaUrlInsideManifestScope(pwaUrl, manifestUrl, manifest) {
|
|
32
|
+
const scopeInput = requireNonEmptyString(manifest.scope, 'web app manifest scope');
|
|
33
|
+
const parsedManifestUrl = new URL(manifestUrl);
|
|
34
|
+
let scopeUrl;
|
|
35
|
+
try {
|
|
36
|
+
scopeUrl = new URL(scopeInput, parsedManifestUrl);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error('Web app manifest scope must resolve against the deployed manifest URL.');
|
|
40
|
+
}
|
|
41
|
+
let parsedPwaUrl;
|
|
42
|
+
try {
|
|
43
|
+
parsedPwaUrl = new URL(pwaUrl);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('Microsoft Store PWA URL must be an absolute URL.');
|
|
47
|
+
}
|
|
48
|
+
const scopePath = scopeUrl.pathname.endsWith('/') ? scopeUrl.pathname : `${scopeUrl.pathname}/`;
|
|
49
|
+
if (scopeUrl.origin !== parsedManifestUrl.origin
|
|
50
|
+
|| parsedPwaUrl.origin !== scopeUrl.origin
|
|
51
|
+
|| (parsedPwaUrl.pathname !== scopeUrl.pathname
|
|
52
|
+
&& !parsedPwaUrl.pathname.startsWith(scopePath))) {
|
|
53
|
+
throw new Error('Microsoft Store PWA URL must stay within the hash-verified web app manifest scope.');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function requireRecord(input, label) {
|
|
57
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
58
|
+
throw new Error(`${label} must be an object.`);
|
|
59
|
+
}
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
function requireNonEmptyString(input, label) {
|
|
63
|
+
if (typeof input !== 'string' || input.length === 0 || input.trim() !== input) {
|
|
64
|
+
throw new Error(`${label} must be a non-empty string without surrounding whitespace.`);
|
|
65
|
+
}
|
|
66
|
+
return input;
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function assertMicrosoftStorePackageZip(file: string, sizeBytes: number): void;
|