@revyl/verify 0.0.0-stage → 0.1.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/CHANGELOG.md +15 -0
- package/LICENSE +201 -0
- package/README.md +100 -2
- package/SECURITY.md +13 -0
- package/dist/api/client.d.ts +63 -0
- package/dist/api/client.js +201 -0
- package/dist/api/types.d.ts +216 -0
- package/dist/api/types.js +1 -0
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +65 -0
- package/dist/cleanup.d.ts +13 -0
- package/dist/cleanup.js +72 -0
- package/dist/cli/init.d.ts +14 -0
- package/dist/cli/init.js +116 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +127 -0
- package/dist/computer-session.d.ts +37 -0
- package/dist/computer-session.js +322 -0
- package/dist/config.d.ts +88 -0
- package/dist/config.js +212 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +3 -0
- package/dist/open.d.ts +13 -0
- package/dist/open.js +36 -0
- package/dist/session.d.ts +144 -0
- package/dist/session.js +411 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +6 -0
- package/docs/README.md +18 -0
- package/docs/agents.md +38 -0
- package/docs/api.md +108 -0
- package/docs/ci.md +70 -0
- package/docs/computers.md +73 -0
- package/docs/configuration.md +91 -0
- package/docs/errors.md +38 -0
- package/docs/getting-started.md +79 -0
- package/docs/linux-computers-contract.md +107 -0
- package/docs/llms.txt +18 -0
- package/docs/mobile.md +43 -0
- package/docs/web.md +43 -0
- package/package.json +68 -4
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RevylClient } from './api/client.js';
|
|
2
|
+
import type { ResolvedComputerTarget } from './config.js';
|
|
3
|
+
import { type ComputerProcess, type ExecOptions, type ExecResult, type FileStat, type OperationOptions, type Session, type SessionOptions, type StepResult } from './session.js';
|
|
4
|
+
export declare class ComputerSession implements Session {
|
|
5
|
+
private readonly client;
|
|
6
|
+
private readonly options;
|
|
7
|
+
readonly platform: "mac";
|
|
8
|
+
readonly target: string;
|
|
9
|
+
readonly id: `${string}-${string}-${string}-${string}-${string}`;
|
|
10
|
+
readonly viewerUrl: string;
|
|
11
|
+
readonly steps: StepResult[];
|
|
12
|
+
private readonly lifetime;
|
|
13
|
+
private closePromise;
|
|
14
|
+
private constructor();
|
|
15
|
+
static start(client: RevylClient, target: ResolvedComputerTarget, options: SessionOptions): Promise<ComputerSession>;
|
|
16
|
+
get closed(): boolean;
|
|
17
|
+
instruction(): Promise<never>;
|
|
18
|
+
validation(): Promise<never>;
|
|
19
|
+
screenshot(options?: {
|
|
20
|
+
path?: string;
|
|
21
|
+
}): Promise<Uint8Array>;
|
|
22
|
+
exec(argv: string[], options?: ExecOptions): Promise<ExecResult>;
|
|
23
|
+
readFile(path: string, options?: OperationOptions): Promise<Buffer>;
|
|
24
|
+
writeFile(path: string, data: string | Uint8Array, options?: OperationOptions): Promise<void>;
|
|
25
|
+
stat(path: string, options?: OperationOptions): Promise<FileStat>;
|
|
26
|
+
processes(options?: OperationOptions): Promise<ComputerProcess[]>;
|
|
27
|
+
terminate(processId: string, options?: OperationOptions): Promise<void>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
30
|
+
private operation;
|
|
31
|
+
private request;
|
|
32
|
+
private report;
|
|
33
|
+
private assertOpen;
|
|
34
|
+
private unavailable;
|
|
35
|
+
private log;
|
|
36
|
+
}
|
|
37
|
+
export declare function computerOperationId(sessionId: string, requestId: string): string;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { responseDetail, sessionViewerUrl } from './api/client.js';
|
|
6
|
+
import { trackSession, untrackSession } from './cleanup.js';
|
|
7
|
+
import { VerifyError, isVerifyError } from './errors.js';
|
|
8
|
+
import { pngSize } from './session.js';
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
|
+
const STATES = new Set(['starting', 'active', 'closing', 'stopped', 'failed', 'owner_lost']);
|
|
11
|
+
const MAX_OUTPUT_BYTES = 1_048_576;
|
|
12
|
+
const MAC_AVAILABILITY = 'Mac computers are not yet available in Revyl production. Early access works only on Revyl staging for enabled organizations. See https://github.com/RevylAI/verify/blob/main/packages/verify/docs/computers.md#availability-and-errors';
|
|
13
|
+
const ERROR_CODES = new Set(['computer_not_enabled', 'run_closed', 'inaccessible', 'invalid_handle', 'operation_not_found', 'owner_unavailable', 'invalid_request', 'unauthenticated', 'forbidden', 'runtime_unavailable', 'start_failed', 'computer_limit', 'request_conflict', 'run_conflict', 'evidence_conflict', 'evidence_unavailable', 'authentication_unavailable', 'operation_limit', 'screen_starting', 'screen_unavailable']);
|
|
14
|
+
export class ComputerSession {
|
|
15
|
+
client;
|
|
16
|
+
options;
|
|
17
|
+
platform = 'mac';
|
|
18
|
+
target;
|
|
19
|
+
id = randomUUID();
|
|
20
|
+
viewerUrl;
|
|
21
|
+
steps = [];
|
|
22
|
+
lifetime = new AbortController();
|
|
23
|
+
closePromise;
|
|
24
|
+
constructor(client, target, options) {
|
|
25
|
+
this.client = client;
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.target = target.name;
|
|
28
|
+
this.viewerUrl = sessionViewerUrl(this.id, 'computer');
|
|
29
|
+
}
|
|
30
|
+
static async start(client, target, options) {
|
|
31
|
+
if (target.platform !== 'mac')
|
|
32
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', 'Linux computers are planned, but are not available in Verify yet.');
|
|
33
|
+
const session = new ComputerSession(client, target, options);
|
|
34
|
+
const signal = AbortSignal.any([AbortSignal.timeout(target.startTimeout), session.lifetime.signal]);
|
|
35
|
+
trackSession(session);
|
|
36
|
+
try {
|
|
37
|
+
session.log(`starting mac computer ${session.viewerUrl}. ${MAC_AVAILABILITY}`);
|
|
38
|
+
let report = session.report(await session.request('POST', '', { computer_session_id: session.id, os: 'macos' }, signal));
|
|
39
|
+
for (;;) {
|
|
40
|
+
if (report.state === 'active')
|
|
41
|
+
break;
|
|
42
|
+
if (report.state !== 'starting')
|
|
43
|
+
throw new VerifyError('SESSION_START_FAILED', `The ${session.target} computer ended before it was ready (${report.state}). See ${session.viewerUrl}`);
|
|
44
|
+
await delay(2_000, undefined, { signal });
|
|
45
|
+
report = session.report(await session.request('GET', '/report', undefined, signal));
|
|
46
|
+
}
|
|
47
|
+
session.log('ready');
|
|
48
|
+
return session;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const timedOut = signal.aborted;
|
|
52
|
+
if (isVerifyError(error) && ['AUTH_REJECTED', 'NOT_AVAILABLE_ON_PLATFORM', 'INVALID_ARGUMENT'].includes(error.code)) {
|
|
53
|
+
session.lifetime.abort();
|
|
54
|
+
untrackSession(session);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
await session.close().catch(() => undefined);
|
|
58
|
+
}
|
|
59
|
+
if (timedOut)
|
|
60
|
+
throw new VerifyError('TIMEOUT', `The ${session.target} computer was not ready within ${target.startTimeout} ms. A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}. Inspect ${session.viewerUrl}`);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
get closed() {
|
|
65
|
+
return this.closePromise !== undefined;
|
|
66
|
+
}
|
|
67
|
+
instruction() {
|
|
68
|
+
return this.unavailable('instruction');
|
|
69
|
+
}
|
|
70
|
+
validation() {
|
|
71
|
+
return this.unavailable('validation');
|
|
72
|
+
}
|
|
73
|
+
async screenshot(options = {}) {
|
|
74
|
+
this.assertOpen();
|
|
75
|
+
const response = await this.request('GET', '/screen/screenshot', undefined, this.lifetime.signal);
|
|
76
|
+
const raw = parseComputerJson(response);
|
|
77
|
+
const bytes = decodeBytes(raw?.png, 128 * MAX_OUTPUT_BYTES);
|
|
78
|
+
const size = pngSize(bytes);
|
|
79
|
+
if (!size || size.width > 4095 || size.height > 4095 || size.width !== raw.width || size.height !== raw.height)
|
|
80
|
+
throw invalidResponse();
|
|
81
|
+
if (options.path) {
|
|
82
|
+
const path = resolve(options.path);
|
|
83
|
+
await mkdir(dirname(path), { recursive: true });
|
|
84
|
+
await writeFile(path, bytes);
|
|
85
|
+
}
|
|
86
|
+
return bytes;
|
|
87
|
+
}
|
|
88
|
+
async exec(argv, options = {}) {
|
|
89
|
+
const stdin = options.stdin === undefined ? Buffer.alloc(0) : inputBytes(options.stdin);
|
|
90
|
+
if (!Array.isArray(argv) || argv.length < 1 || argv.length > 64 || !argv[0] || argv.some((arg) => typeof arg !== 'string' || arg.includes('\0')) || argv.reduce((sum, arg) => sum + Buffer.byteLength(arg), 0) + stdin.length > 16_384) {
|
|
91
|
+
throw new VerifyError('INVALID_ARGUMENT', 'exec() takes 1-64 string arguments without NUL bytes; argv and stdin together must fit in 16384 bytes.');
|
|
92
|
+
}
|
|
93
|
+
const result = await this.operation({ kind: 'exec', argv, stdin: stdin.toString('base64'), timeout_ms: operationTimeout(options) }, options.check === false);
|
|
94
|
+
if (result.kind !== 'exec' || !Number.isInteger(result.exit_code) || result.exit_code < -255 || result.exit_code > 255)
|
|
95
|
+
throw invalidResponse();
|
|
96
|
+
const stdout = decodeBytes(result.stdout ?? '', MAX_OUTPUT_BYTES);
|
|
97
|
+
const stderr = decodeBytes(result.stderr ?? '', MAX_OUTPUT_BYTES);
|
|
98
|
+
if (stdout.length + stderr.length > MAX_OUTPUT_BYTES)
|
|
99
|
+
throw invalidResponse();
|
|
100
|
+
return { exitCode: result.exit_code, stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8') };
|
|
101
|
+
}
|
|
102
|
+
async readFile(path, options = {}) {
|
|
103
|
+
const result = await this.operation({ kind: 'file_read', path: guestPath(path), timeout_ms: operationTimeout(options) });
|
|
104
|
+
if (result.kind !== 'file_read')
|
|
105
|
+
throw invalidResponse();
|
|
106
|
+
return decodeBytes(result.data, MAX_OUTPUT_BYTES);
|
|
107
|
+
}
|
|
108
|
+
async writeFile(path, data, options = {}) {
|
|
109
|
+
const bytes = inputBytes(data);
|
|
110
|
+
if (bytes.length > 65_536)
|
|
111
|
+
throw new VerifyError('INVALID_ARGUMENT', 'writeFile() takes at most 65536 bytes.');
|
|
112
|
+
const result = await this.operation({ kind: 'file_write', path: guestPath(path), data: bytes.toString('base64'), timeout_ms: operationTimeout(options) });
|
|
113
|
+
if (result.kind !== 'file_write' || result.byte_count !== bytes.length)
|
|
114
|
+
throw invalidResponse();
|
|
115
|
+
}
|
|
116
|
+
async stat(path, options = {}) {
|
|
117
|
+
const result = await this.operation({ kind: 'file_stat', path: guestPath(path), timeout_ms: operationTimeout(options) });
|
|
118
|
+
if (result.kind !== 'file_stat' || !['file', 'directory'].includes(result.file_kind) || !Number.isSafeInteger(result.size_bytes) || result.size_bytes < 0)
|
|
119
|
+
throw invalidResponse();
|
|
120
|
+
return { kind: result.file_kind, size: result.size_bytes };
|
|
121
|
+
}
|
|
122
|
+
async processes(options = {}) {
|
|
123
|
+
const result = await this.operation({ kind: 'process_list', timeout_ms: operationTimeout(options) });
|
|
124
|
+
if (result.kind !== 'process_list' || !Array.isArray(result.processes) || result.processes.length > 64)
|
|
125
|
+
throw invalidResponse();
|
|
126
|
+
const ids = new Set();
|
|
127
|
+
return result.processes.map((process) => {
|
|
128
|
+
if (!process || process.handle?.computer_session_id !== this.id || !UUID.test(process.handle.handle_id) || !['running', 'exited'].includes(process.state) || (process.exit_code != null && (!Number.isInteger(process.exit_code) || process.exit_code < -255 || process.exit_code > 255)) || (process.state === 'running' && process.exit_code != null) || ids.has(process.handle.handle_id))
|
|
129
|
+
throw invalidResponse();
|
|
130
|
+
ids.add(process.handle.handle_id);
|
|
131
|
+
return { id: process.handle.handle_id, state: process.state, ...(process.exit_code != null ? { exitCode: process.exit_code } : {}) };
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async terminate(processId, options = {}) {
|
|
135
|
+
if (typeof processId !== 'string' || !UUID.test(processId))
|
|
136
|
+
throw new VerifyError('INVALID_ARGUMENT', 'terminate() takes a process handle id from processes(), not an OS PID.');
|
|
137
|
+
const result = await this.operation({ kind: 'process_terminate', handle: { computer_session_id: this.id, handle_id: processId }, timeout_ms: operationTimeout(options) });
|
|
138
|
+
if (result.kind !== 'process_terminate' || result.termination !== 'confirmed')
|
|
139
|
+
throw invalidResponse();
|
|
140
|
+
}
|
|
141
|
+
close() {
|
|
142
|
+
this.closePromise ??= (async () => {
|
|
143
|
+
this.lifetime.abort();
|
|
144
|
+
try {
|
|
145
|
+
const report = this.report(await this.request('POST', '/stop', undefined, AbortSignal.timeout(310_000)));
|
|
146
|
+
if (report.release !== 'confirmed') {
|
|
147
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer stop was requested, but physical release is unconfirmed. Inspect ${this.viewerUrl}`);
|
|
148
|
+
}
|
|
149
|
+
if (!['stopped', 'failed', 'owner_lost'].includes(report.state))
|
|
150
|
+
throw invalidResponse();
|
|
151
|
+
this.log('closed');
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (isVerifyError(error) && error.code === 'ACTION_UNCERTAIN') {
|
|
155
|
+
const message = `${error.message} A possible cause is that no Mac computer could be assigned. ${MAC_AVAILABILITY}`;
|
|
156
|
+
this.log(`close failed (ACTION_UNCERTAIN); ${message}`);
|
|
157
|
+
throw new VerifyError('ACTION_UNCERTAIN', message, { cause: error });
|
|
158
|
+
}
|
|
159
|
+
this.log(`close failed (${isVerifyError(error) ? error.code : 'API_ERROR'}); inspect ${this.viewerUrl}`);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
untrackSession(this);
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
return this.closePromise;
|
|
167
|
+
}
|
|
168
|
+
[Symbol.asyncDispose]() {
|
|
169
|
+
return this.close();
|
|
170
|
+
}
|
|
171
|
+
async operation(command, allowNonzero = false) {
|
|
172
|
+
this.assertOpen();
|
|
173
|
+
const requestId = randomUUID();
|
|
174
|
+
const operationId = computerOperationId(this.id, requestId);
|
|
175
|
+
const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(310_000)]);
|
|
176
|
+
try {
|
|
177
|
+
let response = await this.request('POST', '/operations', { computer_session_id: this.id, request_id: requestId, command }, signal);
|
|
178
|
+
for (;;) {
|
|
179
|
+
if (response.status !== 202) {
|
|
180
|
+
const body = parseComputerJson(response);
|
|
181
|
+
if (body?.computer_session_id !== this.id || body.operation_id !== operationId || !['recorded', 'pending', 'failed'].includes(body.evidence) || !body.execution)
|
|
182
|
+
throw invalidResponse();
|
|
183
|
+
const execution = body.execution;
|
|
184
|
+
if (execution.outcome === 'succeeded' || (execution.outcome === 'failed' && execution.error_code === 'exec_nonzero' && allowNonzero)) {
|
|
185
|
+
const result = execution.result;
|
|
186
|
+
if (!result || result.kind !== command.kind || (execution.outcome === 'succeeded' && result.kind === 'exec' && result.exit_code !== 0) || (execution.outcome === 'failed' && result.kind === 'exec' && result.exit_code === 0))
|
|
187
|
+
throw invalidResponse();
|
|
188
|
+
if (body.evidence !== 'recorded')
|
|
189
|
+
this.log(`operation ${operationId}: execution observed, report evidence ${body.evidence}`);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
if (execution.outcome === 'failed') {
|
|
193
|
+
if (execution.error_code === 'exec_nonzero' && (execution.result?.kind !== 'exec' || !Number.isInteger(execution.result.exit_code) || execution.result.exit_code === 0 || command.kind !== 'exec'))
|
|
194
|
+
throw invalidResponse();
|
|
195
|
+
const code = { exec_nonzero: 'COMMAND_FAILED', not_found: 'NOT_FOUND', access_denied: 'ACCESS_DENIED' }[execution.error_code];
|
|
196
|
+
if (!code)
|
|
197
|
+
throw invalidResponse();
|
|
198
|
+
throw new VerifyError(code, `Computer ${command.kind} failed (${execution.error_code}${execution.result ? `, exit code ${execution.result.exit_code}` : ''}). See ${this.viewerUrl}`);
|
|
199
|
+
}
|
|
200
|
+
if (execution.outcome === 'timed_out') {
|
|
201
|
+
if (execution.termination === 'unknown')
|
|
202
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer ${command.kind} timed out; termination is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
|
|
203
|
+
if (execution.termination !== 'confirmed')
|
|
204
|
+
throw invalidResponse();
|
|
205
|
+
throw new VerifyError('TIMEOUT', `Computer ${command.kind} timed out; termination was confirmed. See ${this.viewerUrl}`);
|
|
206
|
+
}
|
|
207
|
+
if (execution.outcome !== 'unknown')
|
|
208
|
+
throw invalidResponse();
|
|
209
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer ${command.kind} outcome is unconfirmed. Verify will not repeat it. Inspect operation ${operationId} at ${this.viewerUrl}`);
|
|
210
|
+
}
|
|
211
|
+
if (parseComputerJson(response) !== null)
|
|
212
|
+
throw invalidResponse();
|
|
213
|
+
await delay(2_000, undefined, { signal });
|
|
214
|
+
response = await this.request('GET', `/operations/${operationId}`, undefined, signal);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (signal.aborted)
|
|
219
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer operation ${operationId} stopped being observed; its outcome is unconfirmed. See ${this.viewerUrl}`);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async request(method, suffix, body, signal) {
|
|
224
|
+
const path = `/api/v1/computer-runs${suffix === '' ? '' : `/${this.id}${suffix}`}`;
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
response = await this.client.send(method, path, { body, signal, timeoutMs: suffix === '/stop' || suffix.startsWith('/operations') ? 310_000 : 30_000, retry: method === 'GET', headers: suffix === '/operations' ? { Prefer: 'respond-async' } : undefined });
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (method === 'POST' || suffix.startsWith('/operations/'))
|
|
231
|
+
throw new VerifyError('ACTION_UNCERTAIN', `Computer request outcome is unconfirmed. Verify will not repeat it. See ${this.viewerUrl}`);
|
|
232
|
+
if (isVerifyError(error))
|
|
233
|
+
throw new VerifyError(error.code, `Could not read the computer state. See ${this.viewerUrl}`);
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
if (response.status >= 200 && response.status < 300)
|
|
237
|
+
return response;
|
|
238
|
+
const detail = responseDetail(response);
|
|
239
|
+
if (detail === 'computer_not_enabled' || (method === 'POST' && suffix === '' && response.status === 503 && detail === 'runtime_unavailable')) {
|
|
240
|
+
throw new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `Revyl refused the computer request (${detail}). ${MAC_AVAILABILITY}. Ask support@revyl.ai about staging organization enablement. See ${this.viewerUrl}`);
|
|
241
|
+
}
|
|
242
|
+
let code = 'API_ERROR';
|
|
243
|
+
if (detail === 'run_closed')
|
|
244
|
+
code = 'SESSION_ENDED';
|
|
245
|
+
else if (detail === 'inaccessible' || detail === 'invalid_handle')
|
|
246
|
+
code = 'NOT_FOUND';
|
|
247
|
+
else if (detail === 'operation_not_found' || detail === 'owner_unavailable')
|
|
248
|
+
code = 'ACTION_UNCERTAIN';
|
|
249
|
+
else if (detail === 'invalid_request' || response.status === 422)
|
|
250
|
+
code = 'INVALID_ARGUMENT';
|
|
251
|
+
else if (response.status === 401 || response.status === 403)
|
|
252
|
+
code = 'AUTH_REJECTED';
|
|
253
|
+
else if (detail === 'runtime_unavailable' || detail === 'start_failed' || detail === 'computer_limit')
|
|
254
|
+
code = 'SESSION_START_FAILED';
|
|
255
|
+
else if (response.status === 409 || detail === 'operation_limit')
|
|
256
|
+
code = 'SESSION_BUSY';
|
|
257
|
+
else if (suffix.startsWith('/operations/') && response.status >= 500)
|
|
258
|
+
code = 'ACTION_UNCERTAIN';
|
|
259
|
+
else if (method === 'POST' && (response.status >= 500 || response.status < 400))
|
|
260
|
+
code = 'ACTION_UNCERTAIN';
|
|
261
|
+
const reason = detail && ERROR_CODES.has(detail) ? detail : `HTTP ${response.status}`;
|
|
262
|
+
throw new VerifyError(code, `Revyl refused the computer request (${reason}). See ${this.viewerUrl}`);
|
|
263
|
+
}
|
|
264
|
+
report(response) {
|
|
265
|
+
const report = parseComputerJson(response);
|
|
266
|
+
if (!report || report.computer_session_id !== this.id || !STATES.has(report.state))
|
|
267
|
+
throw invalidResponse();
|
|
268
|
+
return report;
|
|
269
|
+
}
|
|
270
|
+
assertOpen() {
|
|
271
|
+
if (this.closed)
|
|
272
|
+
throw new VerifyError('SESSION_ENDED', `The ${this.target} computer was closed.`);
|
|
273
|
+
}
|
|
274
|
+
unavailable(method) {
|
|
275
|
+
return Promise.reject(new VerifyError('NOT_AVAILABLE_ON_PLATFORM', `${method}() is not available on mac computers; use exec() and file operations. See ${this.viewerUrl}`));
|
|
276
|
+
}
|
|
277
|
+
log(line) {
|
|
278
|
+
this.options.log?.(`${this.target}: ${line}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function computerOperationId(sessionId, requestId) {
|
|
282
|
+
const hash = createHash('sha1').update(Buffer.from(sessionId.replaceAll('-', ''), 'hex')).update(requestId).digest();
|
|
283
|
+
hash[6] = (hash[6] & 0x0f) | 0x50;
|
|
284
|
+
hash[8] = (hash[8] & 0x3f) | 0x80;
|
|
285
|
+
const hex = hash.subarray(0, 16).toString('hex');
|
|
286
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
287
|
+
}
|
|
288
|
+
function operationTimeout(options) {
|
|
289
|
+
const timeout = options.timeout ?? 30_000;
|
|
290
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 300_000)
|
|
291
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer operation timeout must be an integer from 1 to 300000 ms.');
|
|
292
|
+
return timeout;
|
|
293
|
+
}
|
|
294
|
+
function guestPath(path) {
|
|
295
|
+
if (typeof path !== 'string' || !path || Buffer.byteLength(path) > 1024 || path.split('/').some((part) => ['', '.', '..'].includes(part)) || /[\x00-\x1f\x7f\\:]/.test(path))
|
|
296
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer paths must be workspace-relative, without empty, dot, parent, backslash, colon, or control-character components.');
|
|
297
|
+
return path;
|
|
298
|
+
}
|
|
299
|
+
function inputBytes(value) {
|
|
300
|
+
if (typeof value !== 'string' && !(value instanceof Uint8Array))
|
|
301
|
+
throw new VerifyError('INVALID_ARGUMENT', 'Computer input must be a string or Uint8Array.');
|
|
302
|
+
return Buffer.from(value);
|
|
303
|
+
}
|
|
304
|
+
function decodeBytes(value, maxBytes) {
|
|
305
|
+
if (typeof value !== 'string' || value.length > Math.ceil(maxBytes / 3) * 4 || value.length % 4 !== 0 || !/^[a-zA-Z0-9+/_-]*={0,2}$/.test(value))
|
|
306
|
+
throw invalidResponse();
|
|
307
|
+
const bytes = Buffer.from(value, 'base64');
|
|
308
|
+
if (bytes.length > maxBytes || bytes.toString('base64') !== value.replaceAll('-', '+').replaceAll('_', '/'))
|
|
309
|
+
throw invalidResponse();
|
|
310
|
+
return bytes;
|
|
311
|
+
}
|
|
312
|
+
function invalidResponse() {
|
|
313
|
+
return new VerifyError('API_ERROR', 'Revyl returned an invalid computer response.');
|
|
314
|
+
}
|
|
315
|
+
function parseComputerJson(response) {
|
|
316
|
+
try {
|
|
317
|
+
return JSON.parse(new TextDecoder().decode(response.body));
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw invalidResponse();
|
|
321
|
+
}
|
|
322
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ComputerPlatform, MobilePlatform } from './api/types.js';
|
|
2
|
+
export interface WebAppConfig {
|
|
3
|
+
/** The Revyl web app this target belongs to. */
|
|
4
|
+
appId: string;
|
|
5
|
+
/** A public HTTPS URL to open, such as a preview deployment. Registered as a build each time a session opens. */
|
|
6
|
+
url?: string;
|
|
7
|
+
/** An exact Revyl build to open instead of a URL. */
|
|
8
|
+
buildId?: string;
|
|
9
|
+
/** Revyl branch to register the URL on, or to take the newest build from. */
|
|
10
|
+
branch?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface WebTargetConfig {
|
|
13
|
+
platform: 'web';
|
|
14
|
+
app: WebAppConfig;
|
|
15
|
+
viewport?: {
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
deviceScaleFactor?: number;
|
|
19
|
+
};
|
|
20
|
+
/** A test-only sign-in route Revyl opens before the session starts. See docs/web.md. */
|
|
21
|
+
signIn?: {
|
|
22
|
+
path: string;
|
|
23
|
+
secret: string;
|
|
24
|
+
persona?: string;
|
|
25
|
+
};
|
|
26
|
+
startTimeout?: number;
|
|
27
|
+
idleTimeoutSeconds?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MobileAppConfig {
|
|
30
|
+
/** A Revyl app; the session installs its newest build. */
|
|
31
|
+
appId?: string;
|
|
32
|
+
/** An exact Revyl build to install. */
|
|
33
|
+
buildId?: string;
|
|
34
|
+
/** A downloadable .apk, .app.zip or .ipa to install. */
|
|
35
|
+
artifactUrl?: string;
|
|
36
|
+
/** An app already on the device to launch instead of installing one, such as a system app. */
|
|
37
|
+
bundleId?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface MobileTargetConfig {
|
|
40
|
+
platform: MobilePlatform;
|
|
41
|
+
app: MobileAppConfig;
|
|
42
|
+
device?: {
|
|
43
|
+
model?: string;
|
|
44
|
+
osVersion?: string;
|
|
45
|
+
};
|
|
46
|
+
startTimeout?: number;
|
|
47
|
+
idleTimeoutSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
/** A Revyl computer: a cloud macOS machine that runs commands and files instead of agent steps. See docs/computers.md. */
|
|
50
|
+
export interface ComputerTargetConfig {
|
|
51
|
+
platform: ComputerPlatform;
|
|
52
|
+
startTimeout?: number;
|
|
53
|
+
}
|
|
54
|
+
export type TargetConfig = WebTargetConfig | MobileTargetConfig | ComputerTargetConfig;
|
|
55
|
+
export interface VerifyConfig {
|
|
56
|
+
/** Named targets that open() takes by name, such as open('web'). */
|
|
57
|
+
targets: Record<string, TargetConfig>;
|
|
58
|
+
/** Milliseconds one instruction or validation may run. Defaults to 180000. */
|
|
59
|
+
stepTimeout?: number;
|
|
60
|
+
}
|
|
61
|
+
type Resolved<T> = T & {
|
|
62
|
+
name: string;
|
|
63
|
+
startTimeout: number;
|
|
64
|
+
};
|
|
65
|
+
export type ResolvedDeviceTarget = Resolved<(WebTargetConfig | MobileTargetConfig) & {
|
|
66
|
+
idleTimeoutSeconds: number;
|
|
67
|
+
}>;
|
|
68
|
+
export type ResolvedComputerTarget = Resolved<ComputerTargetConfig & ({
|
|
69
|
+
platform: 'mac';
|
|
70
|
+
} | {
|
|
71
|
+
platform: 'linux';
|
|
72
|
+
})>;
|
|
73
|
+
export type ResolvedTarget = ResolvedDeviceTarget | ResolvedComputerTarget;
|
|
74
|
+
export interface ResolvedConfig {
|
|
75
|
+
configFile: string;
|
|
76
|
+
targets: Record<string, ResolvedTarget>;
|
|
77
|
+
stepTimeout: number;
|
|
78
|
+
}
|
|
79
|
+
export declare const CONFIG_FILE_NAMES: string[];
|
|
80
|
+
export declare const DEFAULT_STEP_TIMEOUT_MS = 180000;
|
|
81
|
+
export declare function defineConfig(config: VerifyConfig): VerifyConfig;
|
|
82
|
+
/** The nearest verify.config file in the folder or any folder above it. */
|
|
83
|
+
export declare function findConfigFile(startDir: string): string | undefined;
|
|
84
|
+
export declare function loadConfig(cwd: string, explicitPath?: string): Promise<ResolvedConfig>;
|
|
85
|
+
export declare function validateConfig(raw: unknown, configFile: string): ResolvedConfig;
|
|
86
|
+
/** Validates a target passed straight to open(); it is named after its platform. */
|
|
87
|
+
export declare function validateTarget(raw: unknown): ResolvedTarget;
|
|
88
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { createJiti } from 'jiti';
|
|
4
|
+
import { VerifyError } from './errors.js';
|
|
5
|
+
import { PACKAGE_NAME } from './version.js';
|
|
6
|
+
export const CONFIG_FILE_NAMES = ['verify.config.ts', 'verify.config.mts', 'verify.config.js', 'verify.config.mjs'];
|
|
7
|
+
export const DEFAULT_STEP_TIMEOUT_MS = 180_000;
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const WEB_URL = /^https:\/\/[^/?#@\s]+(\/[^?#\s]*)?$/;
|
|
10
|
+
const TARGET_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
11
|
+
const PLATFORMS = ['web', 'ios', 'android', 'mac', 'linux'];
|
|
12
|
+
export function defineConfig(config) {
|
|
13
|
+
return config;
|
|
14
|
+
}
|
|
15
|
+
/** The nearest verify.config file in the folder or any folder above it. */
|
|
16
|
+
export function findConfigFile(startDir) {
|
|
17
|
+
for (let dir = resolve(startDir);; dir = dirname(dir)) {
|
|
18
|
+
for (const name of CONFIG_FILE_NAMES) {
|
|
19
|
+
const candidate = resolve(dir, name);
|
|
20
|
+
if (existsSync(candidate))
|
|
21
|
+
return candidate;
|
|
22
|
+
}
|
|
23
|
+
if (dirname(dir) === dir)
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function loadConfig(cwd, explicitPath) {
|
|
28
|
+
const configFile = explicitPath ? resolve(cwd, explicitPath) : findConfigFile(cwd);
|
|
29
|
+
if (!configFile || !existsSync(configFile)) {
|
|
30
|
+
throw new VerifyError('CONFIG_NOT_FOUND', explicitPath
|
|
31
|
+
? `Config file not found: ${explicitPath}`
|
|
32
|
+
: `No verify.config.ts in ${cwd} or any folder above it. Run 'npx ${PACKAGE_NAME} init' to create one, or pass a target object to open().`);
|
|
33
|
+
}
|
|
34
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false, interopDefault: true });
|
|
35
|
+
let loaded;
|
|
36
|
+
try {
|
|
37
|
+
loaded = await jiti.import(configFile, { default: true });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new VerifyError('CONFIG_INVALID', `Could not load ${configFile}: ${error.message}`, { cause: error });
|
|
41
|
+
}
|
|
42
|
+
return validateConfig(loaded, configFile);
|
|
43
|
+
}
|
|
44
|
+
export function validateConfig(raw, configFile) {
|
|
45
|
+
const fail = (path, problem) => {
|
|
46
|
+
throw new VerifyError('CONFIG_INVALID', `${configFile}: ${path} ${problem}`);
|
|
47
|
+
};
|
|
48
|
+
const config = asRecord(raw, 'the default export', fail);
|
|
49
|
+
rejectUnknownKeys(config, ['targets', 'stepTimeout'], '', fail);
|
|
50
|
+
const rawTargets = asRecord(config.targets, 'targets', fail);
|
|
51
|
+
const names = Object.keys(rawTargets);
|
|
52
|
+
if (names.length === 0)
|
|
53
|
+
fail('targets', 'must name at least one target, such as { web: { platform: \'web\', ... } }.');
|
|
54
|
+
const targets = {};
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!TARGET_NAME.test(name))
|
|
57
|
+
fail(`targets.${name}`, 'must be named with 1-64 letters, digits, dots, dashes, or underscores.');
|
|
58
|
+
targets[name] = parseTarget(rawTargets[name], name, `targets.${name}`, fail);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
configFile,
|
|
62
|
+
targets,
|
|
63
|
+
stepTimeout: integer(config.stepTimeout, 'stepTimeout', 1_000, 3_600_000, DEFAULT_STEP_TIMEOUT_MS, fail),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Validates a target passed straight to open(); it is named after its platform. */
|
|
67
|
+
export function validateTarget(raw) {
|
|
68
|
+
const fail = (path, problem) => {
|
|
69
|
+
throw new VerifyError('INVALID_ARGUMENT', `open(): ${path} ${problem}`);
|
|
70
|
+
};
|
|
71
|
+
const platform = asRecord(raw, 'target', fail).platform;
|
|
72
|
+
return parseTarget(raw, typeof platform === 'string' ? platform : 'target', 'target', fail);
|
|
73
|
+
}
|
|
74
|
+
function parseTarget(raw, name, path, fail) {
|
|
75
|
+
const target = asRecord(raw, path, fail);
|
|
76
|
+
const platform = target.platform;
|
|
77
|
+
if (typeof platform !== 'string' || !PLATFORMS.includes(platform)) {
|
|
78
|
+
fail(`${path}.platform`, "must be 'web', 'ios', 'android', 'mac', or 'linux'.");
|
|
79
|
+
}
|
|
80
|
+
const startTimeout = integer(target.startTimeout, `${path}.startTimeout`, 10_000, 1_800_000, 600_000, fail);
|
|
81
|
+
if (platform === 'mac' || platform === 'linux') {
|
|
82
|
+
rejectUnknownKeys(target, ['platform', 'startTimeout'], path, fail);
|
|
83
|
+
return { name, startTimeout, platform };
|
|
84
|
+
}
|
|
85
|
+
const common = {
|
|
86
|
+
name,
|
|
87
|
+
startTimeout,
|
|
88
|
+
idleTimeoutSeconds: integer(target.idleTimeoutSeconds, `${path}.idleTimeoutSeconds`, 60, 3_600, 600, fail),
|
|
89
|
+
};
|
|
90
|
+
const app = asRecord(target.app, `${path}.app`, fail);
|
|
91
|
+
if (platform === 'web') {
|
|
92
|
+
rejectUnknownKeys(target, ['platform', 'app', 'viewport', 'signIn', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
93
|
+
rejectUnknownKeys(app, ['appId', 'url', 'buildId', 'branch'], `${path}.app`, fail);
|
|
94
|
+
uuid(app.appId, `${path}.app.appId`, true, fail);
|
|
95
|
+
uuid(app.buildId, `${path}.app.buildId`, false, fail);
|
|
96
|
+
if (app.url !== undefined && (typeof app.url !== 'string' || app.url.length > 2048 || !WEB_URL.test(app.url))) {
|
|
97
|
+
fail(`${path}.app.url`, 'must be a public HTTPS URL without credentials, query, or fragment. Revyl cannot reach localhost.');
|
|
98
|
+
}
|
|
99
|
+
if (app.url !== undefined && app.buildId !== undefined)
|
|
100
|
+
fail(`${path}.app`, 'takes url or buildId, not both.');
|
|
101
|
+
optionalString(app.branch, `${path}.app.branch`, 255, fail);
|
|
102
|
+
if (app.branch !== undefined && app.buildId !== undefined)
|
|
103
|
+
fail(`${path}.app`, 'takes branch or buildId, not both.');
|
|
104
|
+
let viewport;
|
|
105
|
+
if (target.viewport !== undefined) {
|
|
106
|
+
const raw = asRecord(target.viewport, `${path}.viewport`, fail);
|
|
107
|
+
rejectUnknownKeys(raw, ['width', 'height', 'deviceScaleFactor'], `${path}.viewport`, fail);
|
|
108
|
+
viewport = {
|
|
109
|
+
width: integer(raw.width, `${path}.viewport.width`, 320, 3840, undefined, fail),
|
|
110
|
+
height: integer(raw.height, `${path}.viewport.height`, 240, 2160, undefined, fail),
|
|
111
|
+
};
|
|
112
|
+
if (raw.deviceScaleFactor !== undefined) {
|
|
113
|
+
const factor = raw.deviceScaleFactor;
|
|
114
|
+
if (typeof factor !== 'number' || !(factor >= 1 && factor <= 3))
|
|
115
|
+
fail(`${path}.viewport.deviceScaleFactor`, 'must be from 1 to 3.');
|
|
116
|
+
viewport.deviceScaleFactor = factor;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let signIn;
|
|
120
|
+
if (target.signIn !== undefined) {
|
|
121
|
+
const raw = asRecord(target.signIn, `${path}.signIn`, fail);
|
|
122
|
+
rejectUnknownKeys(raw, ['path', 'secret', 'persona'], `${path}.signIn`, fail);
|
|
123
|
+
if (typeof raw.path !== 'string' || !/^\/(?!\/)[^?#\\\s]{0,511}$/.test(raw.path)) {
|
|
124
|
+
fail(`${path}.signIn.path`, 'must be a path starting with one / and no query, fragment, or whitespace.');
|
|
125
|
+
}
|
|
126
|
+
if (typeof raw.secret !== 'string' || raw.secret.trim() === '') {
|
|
127
|
+
fail(`${path}.signIn.secret`, 'must name an organization secret launch variable (its name, never its value).');
|
|
128
|
+
}
|
|
129
|
+
if (raw.persona !== undefined && (typeof raw.persona !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(raw.persona))) {
|
|
130
|
+
fail(`${path}.signIn.persona`, 'must match [a-z0-9_-]{1,32}.');
|
|
131
|
+
}
|
|
132
|
+
signIn = { path: raw.path, secret: raw.secret, ...(raw.persona ? { persona: raw.persona } : {}) };
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
...common,
|
|
136
|
+
platform: 'web',
|
|
137
|
+
app: {
|
|
138
|
+
appId: app.appId,
|
|
139
|
+
...(app.url !== undefined ? { url: app.url } : {}),
|
|
140
|
+
...(app.buildId !== undefined ? { buildId: app.buildId } : {}),
|
|
141
|
+
...(app.branch !== undefined ? { branch: app.branch } : {}),
|
|
142
|
+
},
|
|
143
|
+
...(viewport ? { viewport } : {}),
|
|
144
|
+
...(signIn ? { signIn } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
rejectUnknownKeys(target, ['platform', 'app', 'device', 'startTimeout', 'idleTimeoutSeconds'], path, fail);
|
|
148
|
+
rejectUnknownKeys(app, ['appId', 'buildId', 'artifactUrl', 'bundleId'], `${path}.app`, fail);
|
|
149
|
+
uuid(app.appId, `${path}.app.appId`, false, fail);
|
|
150
|
+
uuid(app.buildId, `${path}.app.buildId`, false, fail);
|
|
151
|
+
optionalString(app.bundleId, `${path}.app.bundleId`, 255, fail);
|
|
152
|
+
if (app.artifactUrl !== undefined && (typeof app.artifactUrl !== 'string' || !/^https:\/\//.test(app.artifactUrl))) {
|
|
153
|
+
fail(`${path}.app.artifactUrl`, 'must be an HTTPS URL.');
|
|
154
|
+
}
|
|
155
|
+
const sources = [app.appId, app.buildId, app.artifactUrl, app.bundleId].filter((value) => value !== undefined).length;
|
|
156
|
+
if (sources !== 1) {
|
|
157
|
+
fail(`${path}.app`, 'takes exactly one of appId, buildId, or artifactUrl to install an app, or bundleId to open one already on the device.');
|
|
158
|
+
}
|
|
159
|
+
let device;
|
|
160
|
+
if (target.device !== undefined) {
|
|
161
|
+
const raw = asRecord(target.device, `${path}.device`, fail);
|
|
162
|
+
rejectUnknownKeys(raw, ['model', 'osVersion'], `${path}.device`, fail);
|
|
163
|
+
optionalString(raw.model, `${path}.device.model`, 128, fail);
|
|
164
|
+
optionalString(raw.osVersion, `${path}.device.osVersion`, 64, fail);
|
|
165
|
+
device = {
|
|
166
|
+
...(raw.model !== undefined ? { model: raw.model } : {}),
|
|
167
|
+
...(raw.osVersion !== undefined ? { osVersion: raw.osVersion } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...common,
|
|
172
|
+
platform: platform,
|
|
173
|
+
app: {
|
|
174
|
+
...(app.appId !== undefined ? { appId: app.appId } : {}),
|
|
175
|
+
...(app.buildId !== undefined ? { buildId: app.buildId } : {}),
|
|
176
|
+
...(app.artifactUrl !== undefined ? { artifactUrl: app.artifactUrl } : {}),
|
|
177
|
+
...(app.bundleId !== undefined ? { bundleId: app.bundleId } : {}),
|
|
178
|
+
},
|
|
179
|
+
...(device ? { device } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function asRecord(raw, path, fail) {
|
|
183
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
184
|
+
fail(path, 'must be an object.');
|
|
185
|
+
return raw;
|
|
186
|
+
}
|
|
187
|
+
function rejectUnknownKeys(record, allowed, path, fail) {
|
|
188
|
+
for (const key of Object.keys(record)) {
|
|
189
|
+
if (!allowed.includes(key))
|
|
190
|
+
fail(path ? `${path}.${key}` : key, `is not a known option. Known: ${allowed.join(', ')}.`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function integer(raw, path, min, max, fallback, fail) {
|
|
194
|
+
if (raw === undefined && fallback !== undefined)
|
|
195
|
+
return fallback;
|
|
196
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < min || raw > max)
|
|
197
|
+
fail(path, `must be an integer from ${min} to ${max}.`);
|
|
198
|
+
return raw;
|
|
199
|
+
}
|
|
200
|
+
function uuid(raw, path, required, fail) {
|
|
201
|
+
if (raw === undefined && !required)
|
|
202
|
+
return;
|
|
203
|
+
if (typeof raw !== 'string' || !UUID.test(raw)) {
|
|
204
|
+
fail(path, `${required ? 'is required and ' : ''}must be a Revyl id (a UUID); copy it from the app's page in Revyl.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function optionalString(raw, path, maxLength, fail) {
|
|
208
|
+
if (raw === undefined)
|
|
209
|
+
return;
|
|
210
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > maxLength)
|
|
211
|
+
fail(path, `must be a non-empty string of at most ${maxLength} characters.`);
|
|
212
|
+
}
|