ravensight-playtest 0.5.1 → 0.5.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.
- package/README.md +48 -0
- package/package.json +1 -1
- package/src/api/client.js +2 -1
- package/src/api/http.js +17 -7
- package/src/cli.js +44 -0
- package/src/commands/build.js +81 -0
- package/src/commands/hosted.js +39 -0
- package/src/hosted/archivePreflight.js +240 -0
- package/src/hosted/buildUpload.js +118 -0
- package/src/hosted/recordedRun.js +191 -0
package/README.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# ravensight-playtest
|
|
2
2
|
|
|
3
|
+
Hosted comparisons use a separate command from the local `run`:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --transcript --output hosted-quote.json
|
|
7
|
+
ravensight-playtest hosted run hosted-quote.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The quote names the full Standard then Jev price, prepaid amount, evidence
|
|
11
|
+
options, expiry and refund rules. It charges nothing. `hosted run` confirms
|
|
12
|
+
that saved quote before one admission request. Its durable journal blocks an
|
|
13
|
+
automatic second POST after an uncertain outcome; preserve it for reconciliation.
|
|
14
|
+
A charged-blocked acknowledgement does not prove gameplay or a report. This
|
|
15
|
+
command does not top up funds, change local run/report files or ask for model
|
|
16
|
+
proxy permission on the CLI credential.
|
|
17
|
+
|
|
18
|
+
On a server that advertises it as ready, `hosted quote --standard-provider openai`
|
|
19
|
+
selects OpenAI for Standard. Explicit `anthropic` works the same way. The returned
|
|
20
|
+
provider and policy hash must match and remain in the saved consent; `hosted run`
|
|
21
|
+
has no provider override. Omitting the flag keeps the original default request
|
|
22
|
+
and consent wire. No choice falls back to another provider, and a CLI declaration
|
|
23
|
+
does not establish deployed provider or hosted engine readiness. Existing `run`
|
|
24
|
+
continues to use its local runner and original API contract.
|
|
25
|
+
|
|
26
|
+
The hosted build command source adds a local check before a paid upload:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
ravensight-playtest build preflight ./game.zip --format web --entrypoint index.html
|
|
30
|
+
ravensight-playtest build upload ./game.zip --game GAME --profile READY_PROFILE --entrypoint index.html --dry-run
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`preflight` seeks through bounded ZIP metadata; it does not load a multi-GB
|
|
34
|
+
archive into memory. A saved server profile can be supplied with `--capacity`
|
|
35
|
+
for its declared limits. Upload selects a currently advertised ready profile,
|
|
36
|
+
checks metadata and a streamed checksum, then asks for the separate $0.49
|
|
37
|
+
validation hold before requesting it. The server’s isolated validator remains
|
|
38
|
+
authoritative. A passing local check does not establish engine, GPU or license
|
|
39
|
+
compatibility.
|
|
40
|
+
|
|
41
|
+
The current upload protocol is one streamed presigned PUT. Multipart upload
|
|
42
|
+
and byte-range resume are not advertised. The original upload journal is kept
|
|
43
|
+
after an uncertain response, and repeating that upload cannot silently request
|
|
44
|
+
a second reservation. A different journal does not cancel or refund an earlier
|
|
45
|
+
upload. Reports and existing run-upload journals are left intact. Larger limits
|
|
46
|
+
and native executable formats are usable only when the server advertises a
|
|
47
|
+
ready profile and the corresponding upload and runtime support exists. This
|
|
48
|
+
source addition does not claim an accepted AAA engine run or a published CLI
|
|
49
|
+
release.
|
|
50
|
+
|
|
3
51
|
AI personas play your game, then file a report you can read, mark up and argue
|
|
4
52
|
with. The personas run on your machine, against your build; Ravensight stores
|
|
5
53
|
the reports and renders them.
|
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -51,7 +51,7 @@ export function createClient(options = {}) {
|
|
|
51
51
|
* @param {string} method
|
|
52
52
|
* @param {string} path
|
|
53
53
|
* @param {{query?: Object, body?: unknown, headers?: Object, idempotent?: boolean,
|
|
54
|
-
* raw?: boolean, anonymous?: boolean}} [opts]
|
|
54
|
+
* raw?: boolean, anonymous?: boolean, onResponseRaw?: Function}} [opts]
|
|
55
55
|
* @returns {Promise<{status: number, headers: Headers, body: unknown}>}
|
|
56
56
|
*/
|
|
57
57
|
async function request(method, path, opts = {}) {
|
|
@@ -86,6 +86,7 @@ export function createClient(options = {}) {
|
|
|
86
86
|
bodyText,
|
|
87
87
|
idempotent: Boolean(opts.idempotent),
|
|
88
88
|
raw: Boolean(opts.raw),
|
|
89
|
+
onResponseRaw: opts.onResponseRaw,
|
|
89
90
|
...transport
|
|
90
91
|
});
|
|
91
92
|
}
|
package/src/api/http.js
CHANGED
|
@@ -43,18 +43,26 @@ export function buildUrl(apiUrl, path, query) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Read a response body without
|
|
46
|
+
* Read a response body without throwing for body or JSON decoding errors. A 502 from a load balancer is
|
|
47
47
|
* HTML, a 204 is empty, and a JSON parse failure on the error path would
|
|
48
48
|
* replace a useful status code with a useless SyntaxError.
|
|
49
49
|
* @param {Response} response
|
|
50
50
|
* @returns {Promise<{json: unknown, text: string}>}
|
|
51
51
|
*/
|
|
52
|
-
async function readBody(response) {
|
|
52
|
+
async function readBody(response, onResponseRaw) {
|
|
53
53
|
let text = '';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
if (onResponseRaw) {
|
|
55
|
+
let bytes = Buffer.alloc(0), bodyComplete = false;
|
|
56
|
+
try { bytes = Buffer.from(await response.arrayBuffer()); bodyComplete = true; }
|
|
57
|
+
catch { /* Preserve a read-failure observation, never invent complete bytes. */ }
|
|
58
|
+
// Opt-in capture runs on every HTTP status BEFORE JSON parsing or an
|
|
59
|
+
// ApiError. Capture failures propagate and cannot trigger a request retry.
|
|
60
|
+
await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
|
|
61
|
+
if (!bodyComplete) return { json: null, text: '' };
|
|
62
|
+
text = new TextDecoder().decode(bytes);
|
|
63
|
+
} else {
|
|
64
|
+
try { text = await response.text(); }
|
|
65
|
+
catch { return { json: null, text: '' }; }
|
|
58
66
|
}
|
|
59
67
|
if (!text) return { json: null, text: '' };
|
|
60
68
|
try {
|
|
@@ -102,6 +110,7 @@ function errorFrom(response, parsed, { method, path }) {
|
|
|
102
110
|
* @param {string} [options.bodyText]
|
|
103
111
|
* @param {boolean} [options.idempotent]
|
|
104
112
|
* @param {boolean} [options.raw] - resolve the body as text, not JSON
|
|
113
|
+
* @param {Function} [options.onResponseRaw] - await exact response-byte capture before parsing, including non-2xx
|
|
105
114
|
* @param {number} [options.retries]
|
|
106
115
|
* @param {number} [options.timeoutMs]
|
|
107
116
|
* @param {typeof fetch} [options.fetchImpl]
|
|
@@ -119,6 +128,7 @@ export async function requestWithRetry(options) {
|
|
|
119
128
|
bodyText,
|
|
120
129
|
idempotent = false,
|
|
121
130
|
raw = false,
|
|
131
|
+
onResponseRaw,
|
|
122
132
|
retries = DEFAULT_RETRIES,
|
|
123
133
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
124
134
|
fetchImpl = globalThis.fetch,
|
|
@@ -162,7 +172,7 @@ export async function requestWithRetry(options) {
|
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
if (response) {
|
|
165
|
-
const parsed = await readBody(response);
|
|
175
|
+
const parsed = await readBody(response, onResponseRaw);
|
|
166
176
|
if (response.ok || response.status === 304) {
|
|
167
177
|
return {
|
|
168
178
|
status: response.status,
|
package/src/cli.js
CHANGED
|
@@ -143,6 +143,50 @@ function withoutFirst(values, value) {
|
|
|
143
143
|
export function buildProgram() {
|
|
144
144
|
const program = new Command();
|
|
145
145
|
const slot = { code: ExitCode.OK };
|
|
146
|
+
const hosted = common(program.command('hosted').description('quote and admit a server-hosted Standard/Jev comparison'));
|
|
147
|
+
hosted.command('quote')
|
|
148
|
+
.description('save a full hosted quote without charging or admitting a run')
|
|
149
|
+
.requiredOption('--build <id>', 'an existing validated hosted build')
|
|
150
|
+
.requiredOption('--profile <version>', 'one currently advertised ready hosted profile')
|
|
151
|
+
.requiredOption('--persona <slug>', 'one available persona for both separate playthroughs')
|
|
152
|
+
.requiredOption('--output <file>', 'save the original quote and immutable consent here')
|
|
153
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
154
|
+
.addOption(new Option('--standard-provider <name>', 'explicit Standard provider; unavailable choices are refused').choices(['anthropic', 'openai']))
|
|
155
|
+
.option('--video', 'include hosted gameplay video in this quote')
|
|
156
|
+
.option('--transcript', 'include the hosted transcript in this quote')
|
|
157
|
+
.action(capture(slot, async (options, command) => {
|
|
158
|
+
const { hostedQuote } = await import('./commands/hosted.js');
|
|
159
|
+
return hostedQuote(flagsFrom(command.optsWithGlobals(), { build: options.build, profile: options.profile,
|
|
160
|
+
persona: options.persona, output: options.output, game: options.game, standardProvider: options.standardProvider,
|
|
161
|
+
video: options.video, transcript: options.transcript }));
|
|
162
|
+
}));
|
|
163
|
+
hosted.command('run <quote-file>')
|
|
164
|
+
.description('confirm the full saved quote and request one hosted admission; never retry an unknown outcome')
|
|
165
|
+
.action(capture(slot, async (filename, _options, command) => {
|
|
166
|
+
const { hostedRun } = await import('./commands/hosted.js');
|
|
167
|
+
return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
|
|
168
|
+
}));
|
|
169
|
+
const build = common(program.command('build').description('check or upload a self-contained hosted game archive'));
|
|
170
|
+
build.command('preflight <archive>')
|
|
171
|
+
.description('check bounded ZIP metadata locally before any paid upload')
|
|
172
|
+
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
173
|
+
.option('--format <name>', 'web or godot-project without a selected capacity profile', 'web')
|
|
174
|
+
.option('--capacity <file>', 'saved profile JSON for a local capacity check; does not establish server readiness')
|
|
175
|
+
.action(capture(slot, async (archive, options, command) => {
|
|
176
|
+
const { buildPreflight } = await import('./commands/build.js');
|
|
177
|
+
return buildPreflight(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, format: options.format, capacity: options.capacity }));
|
|
178
|
+
}));
|
|
179
|
+
build.command('upload <archive>')
|
|
180
|
+
.description('preflight and stream one private build upload after validation-hold consent')
|
|
181
|
+
.requiredOption('--profile <version>', 'one currently advertised ready server profile')
|
|
182
|
+
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
183
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
184
|
+
.option('--journal <file>', 'preserve this original upload intent at the specified path')
|
|
185
|
+
.option('--dry-run', 'inspect and hash the archive without requesting a reservation')
|
|
186
|
+
.action(capture(slot, async (archive, options, command) => {
|
|
187
|
+
const { buildUpload } = await import('./commands/build.js');
|
|
188
|
+
return buildUpload(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, profile: options.profile, game: options.game, journal: options.journal, dryRun: options.dryRun }));
|
|
189
|
+
}));
|
|
146
190
|
program.exitCodeSlot = slot;
|
|
147
191
|
program
|
|
148
192
|
.name('ravensight-playtest')
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { createContext } from '../api/index.js';
|
|
3
|
+
import { resolveGameId } from '../config.js';
|
|
4
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
5
|
+
import { preflightBuildArchive, limitsFromProfile } from '../hosted/archivePreflight.js';
|
|
6
|
+
import { uploadHostedBuild } from '../hosted/buildUpload.js';
|
|
7
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
8
|
+
import { ui } from '../ui/index.js';
|
|
9
|
+
|
|
10
|
+
// TTY animation stays live while I/O awaits. Piped logs receive at most one
|
|
11
|
+
// byte update per second, plus phase boundaries and the final byte count.
|
|
12
|
+
export function buildUploadProgress({ json = false, now = Date.now } = {}) {
|
|
13
|
+
let spin, phase, last = 0, finished = false;
|
|
14
|
+
const labels = { capabilities: 'Checking available build profiles', preflight: 'Checking ZIP metadata',
|
|
15
|
+
hash: 'Hashing archive', reservation: 'Requesting the confirmed validation hold',
|
|
16
|
+
upload: 'Streaming archive bytes', completion: 'Submitting the original upload for validation' };
|
|
17
|
+
return {
|
|
18
|
+
update(event) {
|
|
19
|
+
if (json || ui.isQuiet()) return;
|
|
20
|
+
const at = now(), changed = phase !== event.phase;
|
|
21
|
+
const complete = event.totalBytes !== undefined && event.completedBytes === event.totalBytes;
|
|
22
|
+
if (!changed && at - last < 1000 && (!complete || finished)) return;
|
|
23
|
+
const label = labels[event.phase] + (event.totalBytes === undefined ? ''
|
|
24
|
+
: `: ${ui.bytes(event.completedBytes)} / ${ui.bytes(event.totalBytes)}`);
|
|
25
|
+
if (changed) { spin?.stop(); spin = ui.spinner(label); phase = event.phase; }
|
|
26
|
+
else spin.update(label);
|
|
27
|
+
last = at; finished = complete;
|
|
28
|
+
},
|
|
29
|
+
stop() { spin?.stop(); spin = null; phase = null; finished = false; }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function buildPreflight(filename, flags = {}) {
|
|
34
|
+
let profile;
|
|
35
|
+
if (flags.capacity) {
|
|
36
|
+
const info = await stat(flags.capacity);
|
|
37
|
+
if (!info.isFile() || info.size > 65536) throw new CliError('Choose a capacity profile JSON file no larger than 64 KiB.');
|
|
38
|
+
profile = JSON.parse(await readFile(flags.capacity, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
const result = await preflightBuildArchive(filename, { format: profile?.format || flags.format,
|
|
41
|
+
entrypoint: flags.entrypoint, limits: profile ? limitsFromProfile(profile) : undefined,
|
|
42
|
+
requirements: profile?.capacity?.requirements });
|
|
43
|
+
if (flags.json) ui.json(result);
|
|
44
|
+
else {
|
|
45
|
+
ui.ok(`ZIP metadata passed: ${result.entries} entries; ${ui.bytes(result.archiveBytes)} uploaded, ${ui.bytes(result.expandedBytes)} expanded.`);
|
|
46
|
+
ui.info('No wallet charge or upload was requested. Server validation and runtime compatibility checks are still required.');
|
|
47
|
+
}
|
|
48
|
+
return ExitCode.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
52
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
53
|
+
return withAuthHandling(context, async () => {
|
|
54
|
+
const progress = buildUploadProgress({ json: Boolean(flags.json) });
|
|
55
|
+
try {
|
|
56
|
+
const result = await uploadHostedBuild({ filename, gameId: resolveGameId(flags, context.config), profileVersion: flags.profile,
|
|
57
|
+
entrypoint: flags.entrypoint, repoRoot: context.repoRoot, api: context.api, apiUrl: context.api.apiUrl,
|
|
58
|
+
journalPath: flags.journal, dryRun: Boolean(flags.dryRun), fetchImpl: deps.fetch,
|
|
59
|
+
confirm: deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) })),
|
|
60
|
+
onProgress: event => progress.update(event),
|
|
61
|
+
onPrepared: prepared => {
|
|
62
|
+
progress.stop();
|
|
63
|
+
if (flags.json) return;
|
|
64
|
+
ui.info(`ZIP metadata checked: ${prepared.entries} entries; ${ui.bytes(prepared.archiveBytes)} archive, ${ui.bytes(prepared.expandedBytes)} expanded.`);
|
|
65
|
+
const limit = prepared.limits;
|
|
66
|
+
ui.info(`Selected limits: archive ${ui.bytes(limit.archiveBytes)}, expanded ${ui.bytes(limit.expandedBytes)}, file ${ui.bytes(limit.fileBytes)}, ${limit.files} entries, depth ${limit.depth}, ratio ${limit.ratio}:1.`);
|
|
67
|
+
const required = prepared.capacity?.requirements;
|
|
68
|
+
if (required) ui.info(`Server profile: ${required.os}/${required.architecture}, ${required.cpuVcpus} CPU, ${ui.bytes(required.memoryBytes)} RAM. Runtime and license readiness must come from the server.`);
|
|
69
|
+
if (Number.isSafeInteger(prepared.funding?.minimumFundingCents)) ui.info(`Server prepaid funding minimum: $${(prepared.funding.minimumFundingCents / 100).toFixed(2)}; the separate gameplay price is quoted after validation.`);
|
|
70
|
+
ui.info('Static validation hold: $0.49 from prepaid funds, returned after safe cleanup. A gameplay quote is separate. No top-up is performed.');
|
|
71
|
+
ui.info('Passing this local check does not establish GPU, licensing or game runtime compatibility.');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
progress.stop();
|
|
75
|
+
if (flags.json) ui.json(result);
|
|
76
|
+
else if (result.state === 'awaiting_validation') ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
|
|
77
|
+
else ui.info(result.state === 'declined' ? 'Upload declined; no reservation requested.' : 'Preflight finished; no reservation requested.');
|
|
78
|
+
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
79
|
+
} finally { progress.stop(); }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createContext } from '../api/index.js';
|
|
2
|
+
import { resolveGameId } from '../config.js';
|
|
3
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
4
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
5
|
+
import { ui } from '../ui/index.js';
|
|
6
|
+
import { quoteHostedRun, runHostedQuote, hostedQuoteLines } from '../hosted/recordedRun.js';
|
|
7
|
+
|
|
8
|
+
export async function hostedQuote(flags = {}, deps = {}) {
|
|
9
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
10
|
+
return withAuthHandling(context, async () => {
|
|
11
|
+
const result = await quoteHostedRun({ api: context.api, apiUrl: context.api.apiUrl,
|
|
12
|
+
gameId: resolveGameId(flags, context.config), buildId: flags.build, profileVersion: flags.profile,
|
|
13
|
+
persona: flags.persona, standardProvider: flags.standardProvider, includeVideo: Boolean(flags.video),
|
|
14
|
+
includeTranscript: Boolean(flags.transcript), output: flags.output });
|
|
15
|
+
if (flags.json) ui.json(result);
|
|
16
|
+
else {
|
|
17
|
+
hostedQuoteLines(result).forEach(line => ui.info(line));
|
|
18
|
+
ui.ok(`Saved ${result.quoteFile}. No admission or charge was requested.`);
|
|
19
|
+
}
|
|
20
|
+
return ExitCode.OK;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function hostedRun(filename, flags = {}, deps = {}) {
|
|
25
|
+
if (flags.json && !flags.yes && !deps.confirm) throw new CliError('Inspect the saved quote, then use --yes with --json. No admission was requested.');
|
|
26
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
27
|
+
return withAuthHandling(context, async () => {
|
|
28
|
+
const result = await runHostedQuote({ filename, api: context.api, apiUrl: context.api.apiUrl, repoRoot: context.repoRoot,
|
|
29
|
+
confirm: deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) })),
|
|
30
|
+
onPrepared: offer => { if (!flags.json) hostedQuoteLines(offer).forEach(line => ui.info(line)); } });
|
|
31
|
+
if (flags.json) ui.json(result);
|
|
32
|
+
else if (result.state === 'declined') ui.info('Hosted quote declined. No admission was requested.');
|
|
33
|
+
else {
|
|
34
|
+
ui.ok(`Admission ${result.admissionId}; job ${result.jobId}. Saved ${result.journal}.`);
|
|
35
|
+
ui.info('The server acknowledged charged_blocked. This does not prove a worker started or produced gameplay or reports.');
|
|
36
|
+
}
|
|
37
|
+
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { createInflateRaw } from 'node:zlib';
|
|
6
|
+
import { CliError } from '../errors.js';
|
|
7
|
+
|
|
8
|
+
export const LEGACY_BUILD_LIMITS = Object.freeze({ archiveBytes: 64 * 1024 ** 2,
|
|
9
|
+
expandedBytes: 512 * 1024 ** 2, fileBytes: 64 * 1024 ** 2, files: 1000, depth: 16, ratio: 100,
|
|
10
|
+
archiveFormats: Object.freeze(['zip']), zip64: false });
|
|
11
|
+
// These parser bounds cap metadata/memory even when an operator declares a
|
|
12
|
+
// much larger byte budget. They do not advertise server or engine capacity.
|
|
13
|
+
const MAX_DIRECTORY_BYTES = 64 * 1024 ** 2;
|
|
14
|
+
const MAX_ENTRIES = 1_000_000;
|
|
15
|
+
const MAX_PATH_BYTES = 1024;
|
|
16
|
+
const reject = reason => { throw new CliError(`Build preflight failed: ${reason}. No upload reservation was requested.`); };
|
|
17
|
+
const integer = value => Number.isSafeInteger(value) && value > 0;
|
|
18
|
+
const same = (a, b) => ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every(key => a[key] === b[key]);
|
|
19
|
+
|
|
20
|
+
export function buildLimits(value = LEGACY_BUILD_LIMITS) {
|
|
21
|
+
const keys = ['archiveBytes', 'expandedBytes', 'fileBytes', 'files', 'depth', 'ratio'];
|
|
22
|
+
if (!value || keys.some(key => !integer(value[key]))) reject('the selected build limits are incomplete');
|
|
23
|
+
if (value.files > MAX_ENTRIES || value.depth > 128 || value.ratio > 10000) reject('the selected limits exceed this client’s bounded metadata parser');
|
|
24
|
+
if (value.archiveFormats !== undefined && (!Array.isArray(value.archiveFormats) || value.archiveFormats.length !== 1 || value.archiveFormats[0] !== 'zip')
|
|
25
|
+
|| value.zip64 !== undefined && typeof value.zip64 !== 'boolean') reject('the selected archive policy is unsupported');
|
|
26
|
+
return { ...Object.fromEntries(keys.map(key => [key, value[key]])), archiveFormats: ['zip'], zip64: value.zip64 === true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function limitsFromProfile(profile) {
|
|
30
|
+
if (profile?.capacity?.version !== 1) reject('the selected profile does not declare a supported capacity schema');
|
|
31
|
+
return buildLimits(profile.capacity.uploadLimits);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safePath(value, depth, directory = false) {
|
|
35
|
+
if (!value || Buffer.byteLength(value) > MAX_PATH_BYTES || /[\\\x00-\x1f\x7f:]/.test(value) || value.startsWith('/')) reject('an archive path is unsafe');
|
|
36
|
+
const name = directory && value.endsWith('/') ? value.slice(0, -1) : value;
|
|
37
|
+
const parts = name.split('/');
|
|
38
|
+
if (parts.length > depth || parts.some(part => !part || part === '.' || part === '..' || part.length > 255
|
|
39
|
+
|| /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
|
|
40
|
+
|| !/^[A-Za-z0-9_./ +@()=-]+$/.test(name)) reject('an archive path is not portable or exceeds the selected depth');
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function number64(bytes, offset) {
|
|
45
|
+
const value = bytes.readBigUInt64LE(offset);
|
|
46
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) reject('a ZIP64 offset or size cannot be represented safely');
|
|
47
|
+
return Number(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A descriptor owns one open file throughout preflight, hash and upload. The
|
|
51
|
+
// pathname is never reopened for transfer after consent. No file-size buffer.
|
|
52
|
+
export async function openBuildArchive(filename, { signal, onRead } = {}) {
|
|
53
|
+
const before = await lstat(filename, { bigint: true });
|
|
54
|
+
if (!before.isFile() || before.isSymbolicLink()) reject('choose a regular ZIP file');
|
|
55
|
+
const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
56
|
+
try {
|
|
57
|
+
const stat = await handle.stat({ bigint: true });
|
|
58
|
+
if (!same(before, stat) || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) reject('the archive changed while opening');
|
|
59
|
+
let closed = false;
|
|
60
|
+
const check = async () => {
|
|
61
|
+
if (closed || signal?.aborted) reject('the archive operation was stopped');
|
|
62
|
+
if (!same(stat, await handle.stat({ bigint: true }))) reject('the archive changed; run preflight again');
|
|
63
|
+
};
|
|
64
|
+
const read = async (offset, length) => {
|
|
65
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0
|
|
66
|
+
|| length > 65557 || offset + length > Number(stat.size)) reject('an archive record extends outside the file');
|
|
67
|
+
await check();
|
|
68
|
+
const bytes = Buffer.alloc(length); let done = 0;
|
|
69
|
+
while (done < length) {
|
|
70
|
+
const result = await handle.read(bytes, done, length - done, offset + done);
|
|
71
|
+
if (!result.bytesRead) reject('the archive is truncated');
|
|
72
|
+
done += result.bytesRead;
|
|
73
|
+
}
|
|
74
|
+
onRead?.(length); return bytes;
|
|
75
|
+
};
|
|
76
|
+
return { size: Number(stat.size), read, check,
|
|
77
|
+
async sha256({ onProgress } = {}) {
|
|
78
|
+
const hash = createHash('sha256');
|
|
79
|
+
for (let at = 0; at < Number(stat.size); at += 65536) {
|
|
80
|
+
const chunk = await read(at, Math.min(65536, Number(stat.size) - at));
|
|
81
|
+
hash.update(chunk); onProgress?.(at + chunk.length, Number(stat.size));
|
|
82
|
+
}
|
|
83
|
+
await check(); return hash.digest('hex');
|
|
84
|
+
},
|
|
85
|
+
async *chunks({ start = 0, end = Number(stat.size), onProgress } = {}) {
|
|
86
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > Number(stat.size)) reject('the upload range is invalid');
|
|
87
|
+
for (let at = start; at < end; at += 65536) {
|
|
88
|
+
const chunk = await read(at, Math.min(65536, end - at));
|
|
89
|
+
yield chunk; onProgress?.(at + chunk.length - start, end - start);
|
|
90
|
+
}
|
|
91
|
+
await check();
|
|
92
|
+
},
|
|
93
|
+
async close() { if (!closed) { closed = true; await handle.close(); } }
|
|
94
|
+
};
|
|
95
|
+
} catch (error) { await handle.close(); throw error; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function directory(archive, limits) {
|
|
99
|
+
if (archive.size < 22 || archive.size > limits.archiveBytes) reject('the ZIP exceeds the selected upload size or is truncated');
|
|
100
|
+
const tailStart = Math.max(0, archive.size - 65557), tail = await archive.read(tailStart, archive.size - tailStart);
|
|
101
|
+
let eocd = -1;
|
|
102
|
+
for (let at = tail.length - 22; at >= 0; at--) {
|
|
103
|
+
if (tail.readUInt32LE(at) === 0x06054b50 && at + 22 + tail.readUInt16LE(at + 20) === tail.length) { eocd = at; break; }
|
|
104
|
+
}
|
|
105
|
+
if (eocd < 0) reject('the ZIP directory is missing');
|
|
106
|
+
const record = tail.subarray(eocd), end = tailStart + eocd;
|
|
107
|
+
if (record.readUInt16LE(4) || record.readUInt16LE(6) || record.readUInt16LE(8) !== record.readUInt16LE(10)) reject('split ZIP archives are unsupported');
|
|
108
|
+
let count = record.readUInt16LE(10), size = record.readUInt32LE(12), offset = record.readUInt32LE(16), boundary = end;
|
|
109
|
+
const locator = end >= 20 ? await archive.read(end - 20, 20) : null;
|
|
110
|
+
const zip64 = locator?.readUInt32LE(0) === 0x07064b50;
|
|
111
|
+
if (zip64 && !limits.zip64) reject('the selected profile does not accept ZIP64');
|
|
112
|
+
if (zip64) {
|
|
113
|
+
if (locator.readUInt32LE(4) || locator.readUInt32LE(16) !== 1) reject('split ZIP64 archives are unsupported');
|
|
114
|
+
boundary = number64(locator, 8);
|
|
115
|
+
const expanded = await archive.read(boundary, 56);
|
|
116
|
+
if (expanded.readUInt32LE(0) !== 0x06064b50 || number64(expanded, 4) < 44
|
|
117
|
+
|| number64(expanded, 4) > 1024 || boundary + 12 + number64(expanded, 4) !== end - 20
|
|
118
|
+
|| expanded.readUInt32LE(16) || expanded.readUInt32LE(20) || number64(expanded, 24) !== number64(expanded, 32)) reject('the ZIP64 directory is invalid');
|
|
119
|
+
const values = [number64(expanded, 32), number64(expanded, 40), number64(expanded, 48)];
|
|
120
|
+
if ([count, size, offset].some((value, i) => value !== (i ? 0xffffffff : 0xffff) && value !== values[i])) reject('ZIP and ZIP64 directories disagree');
|
|
121
|
+
[count, size, offset] = values;
|
|
122
|
+
} else if (count === 0xffff || size === 0xffffffff || offset === 0xffffffff) reject('the ZIP64 locator is missing');
|
|
123
|
+
if (!count || count > limits.files || count > MAX_ENTRIES) reject('the ZIP entry count exceeds the selected limit');
|
|
124
|
+
if (size > MAX_DIRECTORY_BYTES || size < count * 46 || offset + size !== boundary) reject('the ZIP directory exceeds the bounded metadata budget or is inconsistent');
|
|
125
|
+
return { count, size, offset, zip64 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sizes(header, extra) {
|
|
129
|
+
let expanded = header.readUInt32LE(24), compressed = header.readUInt32LE(20), offset = header.readUInt32LE(42), disk = header.readUInt16LE(34);
|
|
130
|
+
let zip64;
|
|
131
|
+
for (let at = 0; at < extra.length;) {
|
|
132
|
+
if (at + 4 > extra.length) reject('a ZIP extra field is truncated');
|
|
133
|
+
const kind = extra.readUInt16LE(at), size = extra.readUInt16LE(at + 2); at += 4;
|
|
134
|
+
if (at + size > extra.length) reject('a ZIP extra field is truncated');
|
|
135
|
+
if (kind === 1) { if (zip64) reject('duplicate ZIP64 extra fields'); zip64 = extra.subarray(at, at + size); }
|
|
136
|
+
at += size;
|
|
137
|
+
}
|
|
138
|
+
let at = 0;
|
|
139
|
+
const next = () => { if (!zip64 || at + 8 > zip64.length) reject('a ZIP64 file size is missing'); const value = number64(zip64, at); at += 8; return value; };
|
|
140
|
+
if (expanded === 0xffffffff) expanded = next();
|
|
141
|
+
if (compressed === 0xffffffff) compressed = next();
|
|
142
|
+
if (offset === 0xffffffff) offset = next();
|
|
143
|
+
if (disk === 0xffff) { if (!zip64 || at + 4 > zip64.length) reject('a ZIP64 disk field is missing'); disk = zip64.readUInt32LE(at); }
|
|
144
|
+
if (disk) reject('split ZIP archives are unsupported');
|
|
145
|
+
return { expanded, compressed, offset };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function prefix(archive, file, length) {
|
|
149
|
+
if (length > 1024 ** 2 || length > file.expanded) reject('the native executable header is outside the bounded metadata window');
|
|
150
|
+
const source = Readable.from(archive.chunks({ start: file.dataOffset, end: file.dataEnd }), { objectMode: false, highWaterMark: 65536 });
|
|
151
|
+
const output = file.method === 8 ? source.pipe(createInflateRaw({ chunkSize: 16384 })) : source;
|
|
152
|
+
const parts = []; let total = 0;
|
|
153
|
+
const timer = setTimeout(() => { source.destroy(Error('header timeout')); output.destroy(Error('header timeout')); }, 5000);
|
|
154
|
+
source.on('error', error => output.destroy(error));
|
|
155
|
+
try {
|
|
156
|
+
for await (const chunk of output) {
|
|
157
|
+
const take = chunk.subarray(0, length - total); parts.push(take); total += take.length;
|
|
158
|
+
if (total === length) return Buffer.concat(parts, total);
|
|
159
|
+
}
|
|
160
|
+
reject('the native executable header is truncated');
|
|
161
|
+
} catch { reject('the native executable header could not be inspected within its bound'); }
|
|
162
|
+
finally { clearTimeout(timer); source.destroy(); output.destroy(); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function nativeHeader(archive, file, format, requirements) {
|
|
166
|
+
const os = format === 'linux-executable' ? 'linux' : 'windows';
|
|
167
|
+
if (requirements?.os !== os || !['arm64', 'x86_64'].includes(requirements.architecture)) reject('the native format does not match the selected profile requirements');
|
|
168
|
+
const header = await prefix(archive, file, 64); let architecture, binaryFormat;
|
|
169
|
+
if (os === 'linux') {
|
|
170
|
+
if (header.subarray(0, 4).toString('hex') !== '7f454c46' || header[4] !== 2 || header[5] !== 1 || header[6] !== 1
|
|
171
|
+
|| ![2, 3].includes(header.readUInt16LE(16))) reject('the Linux entrypoint must be a little-endian ELF64 executable');
|
|
172
|
+
architecture = { 62: 'x86_64', 183: 'arm64' }[header.readUInt16LE(18)]; binaryFormat = 'ELF64';
|
|
173
|
+
} else {
|
|
174
|
+
if (header.subarray(0, 2).toString() !== 'MZ') reject('the Windows entrypoint must be a PE executable');
|
|
175
|
+
const at = header.readUInt32LE(60);
|
|
176
|
+
if (at < 64 || at > 1024 ** 2 - 26) reject('the PE header offset exceeds the bounded metadata window');
|
|
177
|
+
const pe = await prefix(archive, file, at + 26);
|
|
178
|
+
if (pe.readUInt32LE(at) !== 0x00004550 || pe.readUInt16LE(at + 24) !== 0x20b) reject('the Windows entrypoint must be a PE32+ executable');
|
|
179
|
+
architecture = { 0x8664: 'x86_64', 0xaa64: 'arm64' }[pe.readUInt16LE(at + 4)]; binaryFormat = 'PE32+';
|
|
180
|
+
}
|
|
181
|
+
if (architecture !== requirements.architecture) reject('the executable architecture does not match the selected profile');
|
|
182
|
+
return { os, architecture, binaryFormat, runtimeCompatibilityProven: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function inspectBuildArchive(archive, { limits: supplied, format, entrypoint, requirements } = {}) {
|
|
186
|
+
const limits = buildLimits(supplied);
|
|
187
|
+
if (!['web', 'godot-project', 'linux-executable', 'windows-executable'].includes(format)) reject('the selected runtime format is unsupported');
|
|
188
|
+
if (typeof entrypoint !== 'string' || entrypoint.length > 240 || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(entrypoint)) reject('the entrypoint does not match the current server path policy');
|
|
189
|
+
const wanted = safePath(entrypoint, limits.depth);
|
|
190
|
+
if (format === 'web' && !wanted.endsWith('.html') || format === 'godot-project' && wanted.split('/').at(-1) !== 'project.godot'
|
|
191
|
+
|| format === 'windows-executable' && !wanted.toLowerCase().endsWith('.exe')) reject('the entrypoint does not match the selected format');
|
|
192
|
+
const dir = await directory(archive, limits), paths = new Map(), explicit = new Set(), ranges = [];
|
|
193
|
+
let cursor = dir.offset, expandedBytes = 0, files = 0, found = false, pathBytes = 0, nativeFile;
|
|
194
|
+
for (let index = 0; index < dir.count; index++) {
|
|
195
|
+
const h = await archive.read(cursor, 46);
|
|
196
|
+
if (h.readUInt32LE(0) !== 0x02014b50) reject('a ZIP directory entry is invalid');
|
|
197
|
+
const flags = h.readUInt16LE(8), method = h.readUInt16LE(10), nameBytes = h.readUInt16LE(28), extraBytes = h.readUInt16LE(30), commentBytes = h.readUInt16LE(32);
|
|
198
|
+
if (flags & 0x41 || ![0, 8].includes(method)) reject('encrypted or unsupported ZIP compression');
|
|
199
|
+
if (!nameBytes || nameBytes > MAX_PATH_BYTES || cursor + 46 + nameBytes + extraBytes + commentBytes > dir.offset + dir.size) reject('a ZIP directory entry is oversized');
|
|
200
|
+
const nameBuffer = await archive.read(cursor + 46, nameBytes), nameText = nameBuffer.toString('utf8');
|
|
201
|
+
const isDirectory = nameText.endsWith('/'), name = safePath(nameText, limits.depth, isDirectory), folded = name.toLowerCase();
|
|
202
|
+
if (explicit.has(folded)) reject('duplicate ZIP paths'); explicit.add(folded);
|
|
203
|
+
const parts = name.split('/');
|
|
204
|
+
for (let n = 1; n <= parts.length; n++) {
|
|
205
|
+
const prefix = parts.slice(0, n).join('/'), key = prefix.toLowerCase(), kind = n < parts.length || isDirectory ? 'directory' : 'file', prior = paths.get(key);
|
|
206
|
+
if (prior && (prior.name !== prefix || prior.kind !== kind)) reject('colliding ZIP paths');
|
|
207
|
+
if (!prior) { pathBytes += Buffer.byteLength(prefix); if (pathBytes > MAX_DIRECTORY_BYTES) reject('the ZIP path index exceeds the bounded metadata budget'); }
|
|
208
|
+
paths.set(key, { name: prefix, kind });
|
|
209
|
+
}
|
|
210
|
+
const mode = h.readUInt32LE(38) >>> 16, type = mode & 0xf000;
|
|
211
|
+
if (type && type !== (isDirectory ? 0x4000 : 0x8000) || mode & 0x0e00 || !isDirectory && h.readUInt32LE(38) & 0x10) reject('links, devices or privileged ZIP entries are unsupported');
|
|
212
|
+
const extra = await archive.read(cursor + 46 + nameBytes, extraBytes), size = sizes(h, extra);
|
|
213
|
+
if (size.expanded > limits.fileBytes || BigInt(size.expanded) > BigInt(Math.max(1, size.compressed)) * BigInt(limits.ratio)) reject('a file exceeds the selected size or compression-ratio limit');
|
|
214
|
+
if (isDirectory && (size.expanded || size.compressed)) reject('a ZIP directory contains file data');
|
|
215
|
+
expandedBytes += size.expanded;
|
|
216
|
+
if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.expandedBytes) reject('the expanded build exceeds the selected limit');
|
|
217
|
+
const local = await archive.read(size.offset, 30);
|
|
218
|
+
if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
|
|
219
|
+
|| local.readUInt16LE(26) !== nameBytes) reject('local and central ZIP headers disagree');
|
|
220
|
+
if (!(await archive.read(size.offset + 30, nameBytes)).equals(nameBuffer)) reject('local and central ZIP filenames disagree');
|
|
221
|
+
const dataEnd = size.offset + 30 + nameBytes + local.readUInt16LE(28) + size.compressed;
|
|
222
|
+
if (!Number.isSafeInteger(dataEnd) || dataEnd > dir.offset) reject('file data overlaps the ZIP directory');
|
|
223
|
+
ranges.push([size.offset, dataEnd]);
|
|
224
|
+
if (!isDirectory) { files++; if (name === wanted) { if (!size.expanded) reject('the entrypoint is empty'); found = true;
|
|
225
|
+
nativeFile = { ...size, method, dataOffset: size.offset + 30 + nameBytes + local.readUInt16LE(28), dataEnd }; } }
|
|
226
|
+
cursor += 46 + nameBytes + extraBytes + commentBytes;
|
|
227
|
+
}
|
|
228
|
+
if (cursor !== dir.offset + dir.size || !found) reject('the declared entrypoint is absent or the directory is inconsistent');
|
|
229
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
230
|
+
for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) reject('ZIP file data overlaps');
|
|
231
|
+
await archive.check();
|
|
232
|
+
const nativeBinaryMetadata = format.endsWith('-executable') ? await nativeHeader(archive, nativeFile, format, requirements) : null;
|
|
233
|
+
return { archiveBytes: archive.size, expandedBytes, entries: dir.count, files, format, entrypoint: wanted,
|
|
234
|
+
zip64: dir.zip64, metadataVerified: true, nativeBinaryMetadata, contentsVerified: false, serverValidationRequired: true, runtimeCompatibilityProven: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function preflightBuildArchive(filename, options) {
|
|
238
|
+
const archive = await openBuildArchive(filename, options);
|
|
239
|
+
try { return await inspectBuildArchive(archive, options); } finally { await archive.close(); }
|
|
240
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { CliError } from '../errors.js';
|
|
6
|
+
import { ApiError } from '../api/errors.js';
|
|
7
|
+
import { writeJson } from '../fsutil.js';
|
|
8
|
+
import { openBuildArchive, inspectBuildArchive, buildLimits, limitsFromProfile } from './archivePreflight.js';
|
|
9
|
+
|
|
10
|
+
const fail = message => { throw new CliError(message); };
|
|
11
|
+
const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
|
|
12
|
+
const body = answer => answer.body;
|
|
13
|
+
const money = cents => `$${(cents / 100).toFixed(2)}`;
|
|
14
|
+
|
|
15
|
+
export function singlePut(upload, { size, sha256, uploadExpiresAt }, now = Date.now()) {
|
|
16
|
+
const url = new URL(upload?.url), headers = upload?.headers;
|
|
17
|
+
if (upload.method !== 'PUT' || url.protocol !== 'https:' || url.username || url.password || url.hash
|
|
18
|
+
|| !/^[a-z0-9.-]+\.s3\.[a-z0-9-]+\.amazonaws\.com$/.test(url.hostname)) fail('The server did not return a supported private S3 upload destination. Preserve the original upload journal.');
|
|
19
|
+
const expected = { 'content-type': 'application/zip', 'if-none-match': '*', 'x-amz-checksum-sha256': Buffer.from(sha256, 'hex').toString('base64'), 'x-amz-server-side-encryption': 'AES256' };
|
|
20
|
+
if (!headers || Object.keys(headers).sort().join(',') !== Object.keys(expected).sort().join(',')
|
|
21
|
+
|| Object.entries(expected).some(([key, value]) => headers[key] !== value)) fail('The upload headers do not match the inspected archive. Preserve the original upload journal.');
|
|
22
|
+
const signed = url.searchParams.get('X-Amz-Date'), seconds = Number(url.searchParams.get('X-Amz-Expires'));
|
|
23
|
+
if (!/^\d{8}T\d{6}Z$/.test(signed || '') || !Number.isInteger(seconds) || seconds < 1 || seconds > 604800) fail('The upload URL has an invalid expiry.');
|
|
24
|
+
const at = Date.parse(`${signed.slice(0, 4)}-${signed.slice(4, 6)}-${signed.slice(6, 8)}T${signed.slice(9, 11)}:${signed.slice(11, 13)}:${signed.slice(13, 15)}Z`);
|
|
25
|
+
const until = Math.min(at + seconds * 1000, Date.parse(uploadExpiresAt));
|
|
26
|
+
if (!Number.isFinite(until) || until <= now || !Number.isSafeInteger(size) || size < 1) fail('The original upload URL has expired. Do not create a replacement reservation automatically.');
|
|
27
|
+
return { url: url.href, headers: { ...expected, 'content-length': String(size) }, expiresAt: until };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function putArchive(archive, transfer, { fetchImpl = globalThis.fetch, signal, onProgress } = {}) {
|
|
31
|
+
await archive.check();
|
|
32
|
+
const remaining = transfer.expiresAt - Date.now();
|
|
33
|
+
if (remaining <= 0) fail('The original upload URL has expired.');
|
|
34
|
+
const deadline = AbortSignal.timeout(Math.min(remaining, 2147483647));
|
|
35
|
+
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
36
|
+
const stream = Readable.from(archive.chunks({ onProgress }), { objectMode: false, highWaterMark: 65536 });
|
|
37
|
+
try {
|
|
38
|
+
const response = await fetchImpl(transfer.url, { method: 'PUT', headers: transfer.headers, body: stream,
|
|
39
|
+
duplex: 'half', redirect: 'error', signal: combined });
|
|
40
|
+
if (!response.ok) fail(`The original archive upload returned HTTP ${response.status}. Preserve the journal; no replacement reservation was requested.`);
|
|
41
|
+
await archive.check();
|
|
42
|
+
} finally { stream.destroy(); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function journalOnce(filename, value) {
|
|
46
|
+
await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
47
|
+
let handle;
|
|
48
|
+
try { handle = await open(filename, 'wx', 0o600); }
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'EEXIST') fail(`An original build upload already has this journal: ${filename}. Reconcile it before starting another paid upload.`);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); }
|
|
54
|
+
finally { await handle.close(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// All metadata checks and a streamed checksum precede the confirmation and
|
|
58
|
+
// the only wallet-mutating POST. No model or gameplay API is called here.
|
|
59
|
+
export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
|
|
60
|
+
apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
|
|
61
|
+
if (!gameId || !profileVersion || typeof confirm !== 'function') fail('Choose a game, ready profile and upload confirmation.');
|
|
62
|
+
onProgress({ phase: 'capabilities' });
|
|
63
|
+
const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
|
|
64
|
+
const profiles = caps?.recorded?.profiles;
|
|
65
|
+
if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
|
|
66
|
+
const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
|
|
67
|
+
if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
|
|
68
|
+
const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
|
|
69
|
+
const offer = profile.reservationOffer;
|
|
70
|
+
if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
|
|
71
|
+
|| offer.refund !== 'after_cleanup' || !/^[a-f0-9]{64}$/.test(offer.version || '')) fail('The server did not provide the supported validation-hold offer.');
|
|
72
|
+
if (caps.funding?.eligible === false) fail('The server reports insufficient prepaid funding. No reservation or top-up was requested.');
|
|
73
|
+
const archive = await openBuildArchive(filename, { signal });
|
|
74
|
+
try {
|
|
75
|
+
onProgress({ phase: 'preflight' });
|
|
76
|
+
const measurement = await inspectBuildArchive(archive, { limits, format: profile.format, entrypoint, requirements: profile.capacity?.requirements });
|
|
77
|
+
onProgress({ phase: 'hash', completedBytes: 0, totalBytes: archive.size });
|
|
78
|
+
const sha256 = await archive.sha256({ onProgress: (completedBytes, totalBytes) => onProgress({ phase: 'hash', completedBytes, totalBytes }) });
|
|
79
|
+
const prepared = { ...measurement, sha256, profileVersion, limits, capacity: profile.capacity || null,
|
|
80
|
+
validationHoldCents: offer.amountCents, funding: caps.funding || null, gameplayQuoteRequired: true };
|
|
81
|
+
await onPrepared(prepared);
|
|
82
|
+
if (dryRun) return { state: 'preflight_only', ...prepared, walletMutationRequested: false };
|
|
83
|
+
if (!await confirm(`Reserve ${money(offer.amountCents)} from prepaid funds for this build’s static validation? It is returned after safe cleanup and is separate from the gameplay quote.`)) {
|
|
84
|
+
return { state: 'declined', walletMutationRequested: false };
|
|
85
|
+
}
|
|
86
|
+
await archive.check();
|
|
87
|
+
const input = { sha256, bytes: archive.size, entrypoint, args: [], reservation: { amountCents: offer.amountCents, version: offer.version },
|
|
88
|
+
validation: { kind: 'recorded_graphical', format: profile.format, profileVersion, selfContained: true } };
|
|
89
|
+
const identity = createHash('sha256').update(JSON.stringify({ apiUrl, gameId, sha256, profileVersion, entrypoint })).digest('hex');
|
|
90
|
+
const journal = journalPath || path.join(repoRoot, '.ravensight', 'hosted-uploads', identity + '.json');
|
|
91
|
+
const state = { version: 1, apiUrl, gameId, profileVersion, archive: path.resolve(filename), measurement, sha256,
|
|
92
|
+
idempotencyKey: randomUUID(), input, phase: 'reservation_unknown', createdAt: new Date().toISOString() };
|
|
93
|
+
await journalOnce(journal, state); // durable intent BEFORE the paid POST
|
|
94
|
+
try {
|
|
95
|
+
onProgress({ phase: 'reservation' });
|
|
96
|
+
const answer = body(await api.request('POST', `${base(gameId)}/builds`, { body: input, headers: { 'Idempotency-Key': state.idempotencyKey }, idempotent: false }));
|
|
97
|
+
if (!/^pt_build_[a-f0-9-]{1,100}$/.test(answer?.build?.buildId || '')) fail('The upload reservation response needs reconciliation.');
|
|
98
|
+
state.buildId = answer.build.buildId; state.phase = 'reserved'; await writeJson(journal, state, { mode: 0o600 });
|
|
99
|
+
const transfer = singlePut(answer.upload, { size: archive.size, sha256, uploadExpiresAt: answer.uploadExpiresAt });
|
|
100
|
+
state.phase = 'upload_unknown'; await writeJson(journal, state, { mode: 0o600 });
|
|
101
|
+
onProgress({ phase: 'upload', completedBytes: 0, totalBytes: archive.size });
|
|
102
|
+
await putArchive(archive, transfer, { fetchImpl, signal,
|
|
103
|
+
onProgress: (completedBytes, totalBytes) => onProgress({ phase: 'upload', completedBytes, totalBytes }) });
|
|
104
|
+
state.phase = 'uploaded'; await writeJson(journal, state, { mode: 0o600 });
|
|
105
|
+
onProgress({ phase: 'completion' });
|
|
106
|
+
const completed = body(await api.request('POST', `${base(gameId)}/builds/${encodeURIComponent(state.buildId)}/complete`, { idempotent: true }));
|
|
107
|
+
state.phase = 'awaiting_validation'; state.serverBuild = completed; await writeJson(journal, state, { mode: 0o600 });
|
|
108
|
+
return { state: state.phase, buildId: state.buildId, journal, walletMutationRequested: true, validationHoldCents: offer.amountCents,
|
|
109
|
+
multipartSupported: false, uploadResumeSupported: false, gameplayStarted: false };
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error instanceof ApiError && [401, 403].includes(error.status)) {
|
|
112
|
+
error.message += ` Original upload journal: ${journal}; no replacement reservation was requested.`;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
throw new CliError(`The original build upload needs review. Preserve ${journal}; its phase is ${state.phase}. No new reservation, multipart restart or gameplay request was issued.`, 1, { cause: error });
|
|
116
|
+
}
|
|
117
|
+
} finally { await archive.close(); }
|
|
118
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, access, rename } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CliError } from '../errors.js';
|
|
5
|
+
import { ApiError } from '../api/errors.js';
|
|
6
|
+
import { stableStringify } from '../fsutil.js';
|
|
7
|
+
|
|
8
|
+
const MAX_QUOTE_BYTES = 1024 * 1024;
|
|
9
|
+
const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
|
|
10
|
+
'totalPriceCents', 'dueNowCents', 'priceVersion', 'policyVersion'];
|
|
11
|
+
const PROVIDERS = ['anthropic', 'openai'];
|
|
12
|
+
const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
|
|
13
|
+
const digest = value => createHash('sha256').update(stableStringify(value)).digest('hex');
|
|
14
|
+
const fail = message => { throw new CliError(message); };
|
|
15
|
+
const id = value => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,128}$/.test(value);
|
|
16
|
+
const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
17
|
+
const cents = value => Number.isSafeInteger(value) && value >= 0;
|
|
18
|
+
const money = value => `$${(value / 100).toFixed(2)}`;
|
|
19
|
+
const same = (a, b) => stableStringify(a) === stableStringify(b);
|
|
20
|
+
|
|
21
|
+
function origin(value) {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) fail('Choose a supported API URL.');
|
|
24
|
+
return url.href.replace(/\/+$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function exclusiveJson(filename, value) {
|
|
28
|
+
await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
29
|
+
let handle;
|
|
30
|
+
try { handle = await open(filename, 'wx', 0o600); }
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === 'EEXIST') fail(`An original hosted record already exists at ${filename}. Preserve it and reconcile it; no replacement request was sent.`);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); }
|
|
36
|
+
finally { await handle.close(); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function readHostedQuote(filename) {
|
|
40
|
+
const handle = await open(filename, 'r');
|
|
41
|
+
try {
|
|
42
|
+
const stat = await handle.stat();
|
|
43
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > MAX_QUOTE_BYTES) fail('Choose a saved hosted quote file no larger than 1 MiB.');
|
|
44
|
+
const buffer = Buffer.alloc(stat.size + 1);
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
while (bytes < buffer.length) {
|
|
47
|
+
const row = await handle.read(buffer, bytes, buffer.length - bytes, bytes);
|
|
48
|
+
if (!row.bytesRead) break;
|
|
49
|
+
bytes += row.bytesRead;
|
|
50
|
+
}
|
|
51
|
+
if (bytes !== stat.size) fail('The saved quote changed while it was read. Nothing was charged.');
|
|
52
|
+
try { return JSON.parse(buffer.subarray(0, bytes).toString('utf8')); }
|
|
53
|
+
catch { fail('The saved hosted quote is not valid JSON. Nothing was charged.'); }
|
|
54
|
+
} finally { await handle.close(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requestFor({ buildId, profileVersion, persona, standardProvider, includeVideo = false, includeTranscript = false }, profile) {
|
|
58
|
+
if (!id(buildId) || !id(profileVersion) || typeof persona !== 'string' || persona.length > 48
|
|
59
|
+
|| !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(persona)) fail('Choose a validated build, ready profile and one persona.');
|
|
60
|
+
if (standardProvider !== undefined && !PROVIDERS.includes(standardProvider)) fail('Choose anthropic or openai for Standard.');
|
|
61
|
+
if (typeof includeVideo !== 'boolean' || typeof includeTranscript !== 'boolean') fail('Choose explicit video and transcript options.');
|
|
62
|
+
return { kind: 'recorded_graphical', buildId, personas: [persona], controllers: ['standard', 'jev'],
|
|
63
|
+
driver: profile.driver, runLength: 'standard', profileVersion, includeVideo, includeTranscript,
|
|
64
|
+
...(standardProvider === undefined ? {} : { standardProvider }) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function available(api, gameId, profileVersion, standardProvider) {
|
|
68
|
+
const answer = await api.request('GET', `${base(gameId)}/run-capabilities`, { idempotent: true });
|
|
69
|
+
const caps = answer.body, recorded = caps?.recorded;
|
|
70
|
+
if (!recorded?.available || recorded.intakeEnabled !== true || !Array.isArray(recorded.profiles)
|
|
71
|
+
|| !Array.isArray(recorded.controllers) || !['standard', 'jev'].every(value => recorded.controllers.includes(value))) {
|
|
72
|
+
fail('This server does not currently advertise ready recorded Standard/Jev runs. Nothing was charged.');
|
|
73
|
+
}
|
|
74
|
+
const matches = recorded.profiles.filter(value => value.profileVersion === profileVersion);
|
|
75
|
+
if (matches.length !== 1 || !['playwright_web', 'godot_driver'].includes(matches[0].driver)) fail('Choose exactly one currently advertised ready hosted profile. Nothing was charged.');
|
|
76
|
+
if (standardProvider !== undefined && (!PROVIDERS.includes(standardProvider)
|
|
77
|
+
|| !Array.isArray(recorded.standardProviders) || !recorded.standardProviders.includes(standardProvider))) {
|
|
78
|
+
fail(`This server does not advertise ready Standard ${standardProvider}. No provider fallback or charge was requested.`);
|
|
79
|
+
}
|
|
80
|
+
return { profile: matches[0], funding: caps.funding };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sum(rows, value) {
|
|
84
|
+
const total = rows.reduce((n, row) => n + value(row), 0n);
|
|
85
|
+
if (total > BigInt(Number.MAX_SAFE_INTEGER)) fail('The server quote has an unsupported price. Nothing was charged.');
|
|
86
|
+
return Number(total);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function hostedConsent(quote, request, now = Date.now()) {
|
|
90
|
+
if (!quote || quote.kind !== 'recorded_graphical' || !/^hq_[a-f0-9-]{36}$/.test(quote.quoteId || '') || !hex(quote.planHash)
|
|
91
|
+
|| quote.buildId !== request.buildId || quote.profileVersion !== request.profileVersion || quote.driver !== request.driver
|
|
92
|
+
|| !same(quote.controllers, ['standard', 'jev']) || quote.includeVideo !== request.includeVideo || quote.includeTranscript !== request.includeTranscript
|
|
93
|
+
|| quote.currency !== 'USD' || !Number.isSafeInteger(quote.expiresAt) || quote.expiresAt <= now
|
|
94
|
+
|| !['localPriceCents', 'computePriceCents', 'totalPriceCents', 'dueNowCents', 'alreadyReservedCents'].every(key => cents(quote[key]))
|
|
95
|
+
|| quote.totalPriceCents < 1 || BigInt(quote.localPriceCents) + BigInt(quote.computePriceCents) !== BigInt(quote.totalPriceCents)
|
|
96
|
+
|| BigInt(quote.dueNowCents) + BigInt(quote.alreadyReservedCents) !== BigInt(quote.totalPriceCents)
|
|
97
|
+
|| !id(quote.priceVersion) || !id(quote.policyVersion) || !['refund', 'retain'].includes(quote.customerFailure)
|
|
98
|
+
|| !['refund', 'retain'].includes(quote.cancelAfterStart)) fail('The server quote changed or expired. Nothing was charged; request a fresh quote.');
|
|
99
|
+
if (!Array.isArray(quote.localBreakdown) || !quote.localBreakdown.length || !quote.localBreakdown.every(row => id(row.unit) && cents(row.unitCents) && Number.isSafeInteger(row.count) && row.count > 0)
|
|
100
|
+
|| sum(quote.localBreakdown, row => BigInt(row.count) * BigInt(row.unitCents)) !== quote.localPriceCents
|
|
101
|
+
|| !Array.isArray(quote.computeBreakdown) || quote.computeBreakdown.length !== 2
|
|
102
|
+
|| !quote.computeBreakdown.every((row, index) => row.controller === request.controllers[index] && row.profileVersion === request.profileVersion && cents(row.priceCents))
|
|
103
|
+
|| sum(quote.computeBreakdown, row => BigInt(row.priceCents)) !== quote.computePriceCents) fail('The server did not quote every Standard/Jev price. Nothing was charged.');
|
|
104
|
+
const explicit = request.standardProvider !== undefined;
|
|
105
|
+
if (explicit ? quote.standardProvider !== request.standardProvider || !hex(quote.standardProviderPolicyHash)
|
|
106
|
+
: Object.hasOwn(quote, 'standardProvider') || Object.hasOwn(quote, 'standardProviderPolicyHash')) {
|
|
107
|
+
fail('The quoted Standard provider policy does not match the requested choice. Nothing was charged.');
|
|
108
|
+
}
|
|
109
|
+
return Object.fromEntries([...CONSENT_KEYS, ...(explicit ? ['standardProvider', 'standardProviderPolicyHash'] : [])].map(key => [key, quote[key]]));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function validateHostedOffer(offer, apiUrl, now = Date.now()) {
|
|
113
|
+
if (offer?.schema !== 'ravensight-hosted-recorded-quote/v1' || !offer.request || !offer.quote || !id(offer.gameId) || offer.apiUrl !== origin(apiUrl)
|
|
114
|
+
|| offer.integrity !== digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, request: offer.request, quote: offer.quote, consent: offer.consent })) fail('The saved quote does not match this API or its original contents. Nothing was charged.');
|
|
115
|
+
const request = requestFor({ ...offer.request, persona: offer.request.personas?.[0] }, { driver: offer.request.driver });
|
|
116
|
+
if (!same(request, offer.request) || !same(hostedConsent(offer.quote, request, now), offer.consent)) fail('The saved quote request or consent changed. Nothing was charged.');
|
|
117
|
+
return offer;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function quoteHostedRun({ api, apiUrl, gameId, output, now = Date.now, ...options }) {
|
|
121
|
+
if (!id(gameId) || !output) fail('Choose a game and an output file for the original quote.');
|
|
122
|
+
try { await access(output); fail('That saved quote already exists. Choose a new output path; the original was preserved.'); }
|
|
123
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
124
|
+
const { profile } = await available(api, gameId, options.profileVersion, options.standardProvider);
|
|
125
|
+
const request = requestFor(options, profile);
|
|
126
|
+
const response = await api.request('POST', `${base(gameId)}/quotes`, { body: request, idempotent: false });
|
|
127
|
+
if (response.status !== 200) fail('The server did not return a hosted quote. No admission was requested.');
|
|
128
|
+
const quote = response.body, consent = hostedConsent(quote, request, now());
|
|
129
|
+
const value = { apiUrl: origin(apiUrl), gameId, request, quote, consent };
|
|
130
|
+
const offer = { schema: 'ravensight-hosted-recorded-quote/v1', ...value, integrity: digest(value) };
|
|
131
|
+
await exclusiveJson(path.resolve(output), offer);
|
|
132
|
+
return { state: 'quoted', quoteFile: path.resolve(output), ...offer, walletMutationRequested: false };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function hostedQuoteLines(offer) {
|
|
136
|
+
const { quote: q, request: r } = offer;
|
|
137
|
+
return [`Game ${offer.gameId}; build ${r.buildId}; persona ${r.personas[0]}; profile ${r.profileVersion}.`,
|
|
138
|
+
`Two separate playthroughs: Standard (${r.standardProvider || 'server default Anthropic'}) then Jev.`,
|
|
139
|
+
...(r.standardProvider ? [`Standard provider policy: ${q.standardProviderPolicyHash}.`] : []),
|
|
140
|
+
...q.localBreakdown.map(row => `${row.count} ${row.unit} at ${money(row.unitCents)} each: ${money(row.count * row.unitCents)}.`),
|
|
141
|
+
...q.computeBreakdown.map(row => `${row.controller} hosted compute: ${money(row.priceCents)}.`),
|
|
142
|
+
`Total ${money(q.totalPriceCents)}; already reserved ${money(q.alreadyReservedCents)}; due now ${money(q.dueNowCents)} from prepaid funds. No top-up is performed.`,
|
|
143
|
+
`Video: ${r.includeVideo ? 'included' : 'off'}; transcript: ${r.includeTranscript ? 'included' : 'off'}.`,
|
|
144
|
+
`Customer failure: ${q.customerFailure}; cancellation after start: ${q.cancelAfterStart}. Price ${q.priceVersion}; refund policy ${q.policyVersion}.`,
|
|
145
|
+
`Quote expires ${new Date(q.expiresAt).toISOString()}.`];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A saved durable intent is never retried, even with the same quote or a new
|
|
149
|
+
// capability response. The server also receives a stable key for this plan.
|
|
150
|
+
export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm, onPrepared = () => {}, now = Date.now }) {
|
|
151
|
+
const offer = validateHostedOffer(await readHostedQuote(filename), apiUrl, now());
|
|
152
|
+
const identity = digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, planHash: offer.quote.planHash });
|
|
153
|
+
const journal = path.join(repoRoot, '.ravensight', 'hosted-runs', `${identity}.json`);
|
|
154
|
+
try { await access(journal); fail(`This hosted quote already has an admission intent at ${journal}. Reconcile the original outcome; no POST was repeated.`); }
|
|
155
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
156
|
+
const { profile, funding } = await available(api, offer.gameId, offer.request.profileVersion, offer.request.standardProvider);
|
|
157
|
+
if (profile.driver !== offer.request.driver || funding?.eligible === false || offer.quote.funding?.eligible === false) fail('The server no longer advertises this profile or sufficient prepaid funding. Nothing was charged.');
|
|
158
|
+
await onPrepared(offer);
|
|
159
|
+
if (typeof confirm !== 'function') fail('The full hosted quote must be confirmed before admission.');
|
|
160
|
+
if (!await confirm(`Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`)) return { state: 'declined', walletMutationRequested: false };
|
|
161
|
+
validateHostedOffer(offer, apiUrl, now());
|
|
162
|
+
const state = { schema: 'ravensight-hosted-admission-journal/v1', apiUrl: offer.apiUrl, gameId: offer.gameId,
|
|
163
|
+
quoteId: offer.quote.quoteId, planHash: offer.quote.planHash, consent: offer.consent,
|
|
164
|
+
idempotencyKey: `hosted-${offer.quote.planHash}`, phase: 'admission_unknown', createdAt: new Date(now()).toISOString() };
|
|
165
|
+
await exclusiveJson(journal, state); // fsync BEFORE the only wallet-mutating POST
|
|
166
|
+
try {
|
|
167
|
+
let responseCaptured = false;
|
|
168
|
+
const answer = await api.request('POST', `${base(offer.gameId)}/runs`, { body: offer.consent,
|
|
169
|
+
headers: { 'Idempotency-Key': state.idempotencyKey }, idempotent: false, raw: true,
|
|
170
|
+
onResponseRaw: async ({ status, contentType, bytes, bodyComplete }) => {
|
|
171
|
+
await exclusiveJson(journal + '.response.json', { status, contentType, bodyComplete,
|
|
172
|
+
bodyBase64: bytes.toString('base64'), bodyBytes: bytes.length,
|
|
173
|
+
bodySha256: createHash('sha256').update(bytes).digest('hex') });
|
|
174
|
+
responseCaptured = true;
|
|
175
|
+
} });
|
|
176
|
+
if (!responseCaptured) fail('The admission response was not captured. Preserve the original intent.');
|
|
177
|
+
if (answer.status !== 202 || answer.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json'
|
|
178
|
+
|| typeof answer.body !== 'string' || Buffer.byteLength(answer.body) > MAX_QUOTE_BYTES) fail('The admission acknowledgement needs reconciliation.');
|
|
179
|
+
const ack = JSON.parse(answer.body);
|
|
180
|
+
if (!same(Object.keys(ack).sort(), ['admissionId', 'dispatchAllowed', 'jobId', 'state', 'totalPriceCents'])
|
|
181
|
+
|| ack.state !== 'charged_blocked' || ack.dispatchAllowed !== false || ack.totalPriceCents !== offer.quote.totalPriceCents
|
|
182
|
+
|| !/^ha_[a-f0-9-]{36}$/.test(ack.admissionId || '') || !/^pt_job_[a-f0-9]{20}$/.test(ack.jobId || '')) fail('The admission acknowledgement needs reconciliation.');
|
|
183
|
+
state.phase = 'charged_blocked'; state.admission = ack;
|
|
184
|
+
await exclusiveJson(journal + '.acknowledged.json', state);
|
|
185
|
+
await rename(journal + '.acknowledged.json', journal);
|
|
186
|
+
return { state: 'charged_blocked', ...ack, journal, replayAuthorized: false, gameplayProven: false };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error instanceof ApiError && error.status === 401) throw error;
|
|
189
|
+
fail(`The original admission outcome needs reconciliation. Preserve ${journal}; no retry or replacement admission was requested.`);
|
|
190
|
+
}
|
|
191
|
+
}
|