@officexapp/vidfarm-devcli 0.21.58 → 0.21.59

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.
@@ -0,0 +1,465 @@
1
+ // `vidfarm gigs verify-proof` — the sanity check that runs BEFORE a proof is sent.
2
+ //
3
+ // A pending proof CAN be pulled back — `gigs withdraw` returns it to a draft —
4
+ // but only until the buyer rules on it, and they open it days later in a swipe
5
+ // deck, so in practice the first thing they see is the thing that decides it.
6
+ // The two ways a finished video loses the payout are both mechanical, both land
7
+ // before any undo would, and both are checkable from here:
8
+ //
9
+ // 1. THE LINK DOES NOT LOAD FOR THEM. It 403s because the s3 object was never
10
+ // made public, it answers an AccessDenied XML body with a 200-looking url,
11
+ // it is a presigned link whose read token expires in an hour, it is a
12
+ // Drive/Dropbox page behind a login, or it is http://localhost:8000 on the
13
+ // worker's own machine. Every one of those reads to a buyer as "no proof".
14
+ // 2. THE TWO HALVES ARE SWAPPED. `proofs[]` is public — the buyer downloads it
15
+ // and so can anyone they forward it to. `private_note` is escrowed until a
16
+ // rollup stamps `paid_out_at`. Putting the clean master in `proofs[]` while
17
+ // the note holds nothing means the buyer has everything before paying.
18
+ //
19
+ // Check 1 is a HARD FAIL: there is no reading of the brief under which a dead
20
+ // link is correct. Check 2 is a WARNING with an acknowledgement flag, because
21
+ // handing the final cut over in the proof itself is a legitimate choice — it is
22
+ // just one the worker has to make on purpose rather than by accident.
23
+ //
24
+ // Split shape-only rules (pure, no network) from the probe (one ranged GET per
25
+ // url) so the rules stay unit-testable and an offline caller still gets most of
26
+ // the value.
27
+ //
28
+ // Backend-free (Node built-ins + fetch) so it ships in the public cloud-only CLI.
29
+ import { statSync } from "node:fs";
30
+ const MEDIA_EXTENSIONS = new Set([
31
+ "mp4", "mov", "webm", "m4v", "mkv", "avi",
32
+ "mp3", "wav", "m4a", "aac", "ogg",
33
+ "png", "jpg", "jpeg", "webp", "gif", "avif"
34
+ ]);
35
+ /** Hosts that answer a stranger with a sign-in page, not the file. */
36
+ const LOGIN_WALLED = {
37
+ "drive.google.com": "Google Drive",
38
+ "docs.google.com": "Google Docs",
39
+ "www.dropbox.com": "Dropbox (a /scl or ?dl=0 link is a preview page)",
40
+ "dropbox.com": "Dropbox (a /scl or ?dl=0 link is a preview page)",
41
+ "wetransfer.com": "WeTransfer (and it expires)",
42
+ "we.tl": "WeTransfer (and it expires)",
43
+ "www.notion.so": "Notion",
44
+ "notion.so": "Notion",
45
+ "onedrive.live.com": "OneDrive",
46
+ "1drv.ms": "OneDrive",
47
+ "mega.nz": "MEGA",
48
+ "www.icloud.com": "iCloud"
49
+ };
50
+ /** Filenames that say this is the unwatermarked file. */
51
+ const MASTER_NAME = /(clean|master|no[-_.]?water|unwatermark|nomark|raw[-_.]?final|final[-_.]?raw)/i;
52
+ /**
53
+ * Pure url inspection: everything that can be decided from the string itself.
54
+ * No network, no side effects — this is the half that is unit-tested.
55
+ */
56
+ export function inspectProofUrl(raw) {
57
+ const value = (raw ?? "").trim();
58
+ const empty = {
59
+ raw: value, valid: false, scheme: "", host: "", filename: "",
60
+ expiring: null, login_wall: null, private_host: false, looks_like_master: false
61
+ };
62
+ let url;
63
+ try {
64
+ url = new URL(value);
65
+ }
66
+ catch {
67
+ return empty;
68
+ }
69
+ const scheme = url.protocol.replace(":", "").toLowerCase();
70
+ const host = url.hostname.toLowerCase();
71
+ const filename = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
72
+ return {
73
+ raw: value,
74
+ valid: scheme === "http" || scheme === "https",
75
+ scheme,
76
+ host,
77
+ filename,
78
+ expiring: expiringToken(url),
79
+ login_wall: LOGIN_WALLED[host] ?? null,
80
+ private_host: isPrivateHost(host),
81
+ looks_like_master: MASTER_NAME.test(filename)
82
+ };
83
+ }
84
+ function isPrivateHost(host) {
85
+ if (host === "localhost" || host === "::1" || host.endsWith(".local") || host.endsWith(".localhost"))
86
+ return true;
87
+ if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host))
88
+ return true;
89
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
90
+ return true;
91
+ return false;
92
+ }
93
+ /**
94
+ * Signed, time-limited read access. The whole point of a proof url is that it
95
+ * still plays when the buyer gets round to the deck, so ANY of these is a fail
96
+ * even while it is still valid today.
97
+ */
98
+ function expiringToken(url) {
99
+ const q = url.searchParams;
100
+ const has = (name) => q.has(name) || q.has(name.toLowerCase());
101
+ if (has("X-Amz-Signature") || has("X-Amz-Credential")) {
102
+ return { kind: "aws-sigv4 presigned", expires_in_seconds: amzRemaining(q) };
103
+ }
104
+ if (has("Key-Pair-Id") || (has("Signature") && has("Expires"))) {
105
+ const expires = Number(q.get("Expires") ?? q.get("expires"));
106
+ const remaining = Number.isFinite(expires) ? Math.round(expires - Date.now() / 1000) : null;
107
+ return { kind: "CloudFront signed", expires_in_seconds: remaining };
108
+ }
109
+ if (has("sig") && has("se"))
110
+ return { kind: "Azure SAS", expires_in_seconds: null };
111
+ if (has("GoogleAccessId") || has("X-Goog-Signature"))
112
+ return { kind: "GCS signed", expires_in_seconds: null };
113
+ return null;
114
+ }
115
+ /** `X-Amz-Date=20260825T120000Z` + `X-Amz-Expires=3600` → seconds left. */
116
+ function amzRemaining(q) {
117
+ const date = q.get("X-Amz-Date") ?? q.get("x-amz-date");
118
+ const expires = Number(q.get("X-Amz-Expires") ?? q.get("x-amz-expires"));
119
+ if (!date || !Number.isFinite(expires))
120
+ return null;
121
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/.exec(date);
122
+ if (!m)
123
+ return null;
124
+ const signedAt = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]));
125
+ return Math.round((signedAt + expires * 1000 - Date.now()) / 1000);
126
+ }
127
+ /**
128
+ * Shape rules for one `proofs[]` url — the offline half of the check.
129
+ */
130
+ export function shapeChecks(raw) {
131
+ const shape = inspectProofUrl(raw);
132
+ const checks = [];
133
+ const target = shape.raw || String(raw);
134
+ if (!shape.valid) {
135
+ checks.push({
136
+ level: "fail",
137
+ code: "not_a_url",
138
+ target,
139
+ message: "This is not an http(s) url, so the buyer's deck has nothing to play.",
140
+ hint: "Upload the file and submit the url it hands back: vidfarm gigs upload ./final-watermarked.mp4"
141
+ });
142
+ return checks;
143
+ }
144
+ if (shape.scheme === "http") {
145
+ checks.push({
146
+ level: "fail",
147
+ code: "insecure_scheme",
148
+ target,
149
+ message: "http:// is blocked as mixed content inside the review deck — the card stays blank.",
150
+ hint: "Serve the same file over https."
151
+ });
152
+ }
153
+ if (shape.private_host) {
154
+ checks.push({
155
+ level: "fail",
156
+ code: "private_host",
157
+ target,
158
+ message: `${shape.host} only exists on your own machine — nobody else can open it.`,
159
+ hint: "vidfarm gigs upload ./final-watermarked.mp4 puts it on a durable public url."
160
+ });
161
+ }
162
+ if (shape.login_wall) {
163
+ checks.push({
164
+ level: "fail",
165
+ code: "login_wall",
166
+ target,
167
+ message: `${shape.login_wall} serves a sign-in page to a stranger, not a playable file.`,
168
+ hint: "A proof must play inline with no account. Re-host it: vidfarm gigs upload <file>"
169
+ });
170
+ }
171
+ if (shape.expiring) {
172
+ const left = shape.expiring.expires_in_seconds;
173
+ const when = left === null ? "it expires on a clock" : left <= 0 ? "it has ALREADY expired" : `about ${Math.round(left / 3600)}h of read access is left`;
174
+ checks.push({
175
+ level: "fail",
176
+ code: "expiring_link",
177
+ target,
178
+ message: `${shape.expiring.kind} url — ${when}. Buyers review days later, so this dies before it is watched.`,
179
+ hint: "Submit a durable public url instead. A presigned url is for the UPLOAD, never for the proof."
180
+ });
181
+ }
182
+ return checks;
183
+ }
184
+ /**
185
+ * Container sniffing from the first bytes. The header lies often enough to be
186
+ * worth ignoring: a misconfigured bucket happily returns `video/mp4` on an
187
+ * AccessDenied XML body, and that is the exact failure this catches.
188
+ */
189
+ export function sniffMagic(bytes) {
190
+ const at = (i) => bytes[i] ?? -1;
191
+ const ascii = (start, length) => String.fromCharCode(...Array.from(bytes.slice(start, start + length)));
192
+ if (bytes.length < 4)
193
+ return null;
194
+ if (ascii(4, 4) === "ftyp")
195
+ return "mp4";
196
+ if (at(0) === 0x1a && at(1) === 0x45 && at(2) === 0xdf && at(3) === 0xa3)
197
+ return "webm";
198
+ if (ascii(0, 4) === "RIFF")
199
+ return ascii(8, 4) === "WEBP" ? "webp" : "riff";
200
+ if (at(0) === 0x89 && ascii(1, 3) === "PNG")
201
+ return "png";
202
+ if (at(0) === 0xff && at(1) === 0xd8 && at(2) === 0xff)
203
+ return "jpeg";
204
+ if (ascii(0, 3) === "GIF")
205
+ return "gif";
206
+ if (ascii(0, 3) === "ID3")
207
+ return "mp3";
208
+ if (at(0) === 0xff && (at(1) & 0xe0) === 0xe0)
209
+ return "mp3";
210
+ if (ascii(0, 4) === "OggS")
211
+ return "ogg";
212
+ const head = ascii(0, Math.min(64, bytes.length)).trimStart().toLowerCase();
213
+ if (head.startsWith("<?xml") || head.startsWith("<html") || head.startsWith("<!doctype"))
214
+ return "markup";
215
+ return null;
216
+ }
217
+ /** Read at most `limit` bytes off a response, then drop the rest on the floor. */
218
+ async function firstBytes(response, limit = 2048) {
219
+ if (!response.body)
220
+ return new Uint8Array(0);
221
+ const reader = response.body.getReader();
222
+ const chunks = [];
223
+ let total = 0;
224
+ while (total < limit) {
225
+ const { done, value } = await reader.read();
226
+ if (done || !value)
227
+ break;
228
+ chunks.push(value);
229
+ total += value.length;
230
+ }
231
+ await reader.cancel().catch(() => { });
232
+ const out = new Uint8Array(total);
233
+ let offset = 0;
234
+ for (const chunk of chunks) {
235
+ out.set(chunk, offset);
236
+ offset += chunk.length;
237
+ }
238
+ return out.slice(0, limit);
239
+ }
240
+ /**
241
+ * One ranged GET, exactly as a stranger's browser would do it: no key, no
242
+ * cookie, follow redirects. A HEAD is not enough — plenty of buckets answer 200
243
+ * to a HEAD and an error body to a GET.
244
+ */
245
+ export async function probeUrl(url, timeoutMs = 20000) {
246
+ const result = { url, ok: false, status: null, content_type: null, bytes: null, magic: null, error: null };
247
+ try {
248
+ const response = await fetch(url, {
249
+ method: "GET",
250
+ headers: { range: "bytes=0-2047" },
251
+ redirect: "follow",
252
+ signal: AbortSignal.timeout(timeoutMs)
253
+ });
254
+ result.status = response.status;
255
+ result.content_type = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() || null;
256
+ const range = response.headers.get("content-range");
257
+ const total = range ? Number(range.split("/").pop()) : Number(response.headers.get("content-length"));
258
+ result.bytes = Number.isFinite(total) ? total : null;
259
+ const head = await firstBytes(response);
260
+ result.magic = sniffMagic(head);
261
+ result.ok = response.ok;
262
+ }
263
+ catch (error) {
264
+ result.error = error?.name === "TimeoutError" ? "timed out after 20s" : String(error?.message ?? error);
265
+ }
266
+ return result;
267
+ }
268
+ /** Turn one probe into checks. Pure, so the verdict logic is testable. */
269
+ export function probeChecks(probe) {
270
+ const checks = [];
271
+ const target = probe.url;
272
+ if (probe.error) {
273
+ checks.push({
274
+ level: "fail",
275
+ code: "unreachable",
276
+ target,
277
+ message: `The link did not load: ${probe.error}`,
278
+ hint: "Open it in a private browser window. If it needs your session, the buyer cannot watch it."
279
+ });
280
+ return checks;
281
+ }
282
+ if (probe.status !== null && !probe.ok) {
283
+ const why = probe.status === 403
284
+ ? "403 — uploaded, but the object is not readable by the public."
285
+ : probe.status === 404
286
+ ? "404 — nothing is at that key. The upload did not land where you think."
287
+ : `HTTP ${probe.status}.`;
288
+ checks.push({
289
+ level: "fail",
290
+ code: "http_status",
291
+ target,
292
+ message: `The buyer gets ${why}`,
293
+ hint: "vidfarm gigs upload <file> returns a url that is public by construction."
294
+ });
295
+ return checks;
296
+ }
297
+ if (probe.magic === "markup" || (probe.content_type ?? "").startsWith("text/html")) {
298
+ checks.push({
299
+ level: "fail",
300
+ code: "html_body",
301
+ target,
302
+ message: "That url answers a page, not a file — usually an S3 AccessDenied body or a sign-in wall.",
303
+ hint: "A deck card cannot play a web page. Submit the direct file url."
304
+ });
305
+ return checks;
306
+ }
307
+ if (probe.bytes === 0) {
308
+ checks.push({ level: "fail", code: "empty_body", target, message: "The file is 0 bytes — the upload did not finish." });
309
+ return checks;
310
+ }
311
+ const typeIsMedia = /^(video|audio|image)\//.test(probe.content_type ?? "");
312
+ if (!probe.magic && !typeIsMedia) {
313
+ checks.push({
314
+ level: "fail",
315
+ code: "not_media",
316
+ target,
317
+ message: `Neither the bytes nor the content-type (${probe.content_type ?? "none"}) look like a video, image or audio file.`,
318
+ hint: "The buyer swipes a deck and the card plays the first url inline."
319
+ });
320
+ return checks;
321
+ }
322
+ if (!typeIsMedia) {
323
+ checks.push({
324
+ level: "warn",
325
+ code: "octet_stream",
326
+ target,
327
+ message: `The bytes are a ${probe.magic} file but the server sends content-type "${probe.content_type ?? "none"}".`,
328
+ hint: "Some players refuse to inline that. Re-upload with the right content type."
329
+ });
330
+ }
331
+ if (probe.bytes !== null && probe.bytes > 0 && probe.bytes < 50_000) {
332
+ checks.push({
333
+ level: "warn",
334
+ code: "tiny_file",
335
+ target,
336
+ message: `Only ${probe.bytes} bytes. That is too small to be a finished cut.`
337
+ });
338
+ }
339
+ if (checks.length === 0) {
340
+ const size = probe.bytes === null ? "" : ` · ${(probe.bytes / 1_000_000).toFixed(1)} MB`;
341
+ checks.push({
342
+ level: "ok",
343
+ code: "playable",
344
+ target,
345
+ message: `Loads for a stranger: ${probe.magic ?? probe.content_type}${size}`
346
+ });
347
+ }
348
+ return checks;
349
+ }
350
+ /**
351
+ * The half that is a recommendation, not a law: watermark what is public, seal
352
+ * the master in the note. Warnings here, plus two genuine mistakes that fail.
353
+ */
354
+ export function usageChecks(bundle) {
355
+ const checks = [];
356
+ const note = (bundle.privateNote ?? "").trim();
357
+ const hasMaster = Boolean(bundle.cleanMaster);
358
+ const noteUrls = note.match(/https?:\/\/\S+/g) ?? [];
359
+ const proofSet = new Set(bundle.proofs.map((p) => p.trim()));
360
+ if (bundle.cleanMaster) {
361
+ let size = -1;
362
+ try {
363
+ size = statSync(bundle.cleanMaster).size;
364
+ }
365
+ catch {
366
+ size = -1;
367
+ }
368
+ if (size < 0) {
369
+ checks.push({ level: "fail", code: "clean_master_missing", target: bundle.cleanMaster, message: "That clean-master file does not exist on disk." });
370
+ }
371
+ else if (size === 0) {
372
+ checks.push({ level: "fail", code: "clean_master_empty", target: bundle.cleanMaster, message: "The clean-master file is 0 bytes." });
373
+ }
374
+ else {
375
+ const ext = bundle.cleanMaster.toLowerCase().split(".").pop() ?? "";
376
+ if (!MEDIA_EXTENSIONS.has(ext)) {
377
+ checks.push({ level: "warn", code: "clean_master_not_media", target: bundle.cleanMaster, message: `".${ext}" is not a media extension — check you are sealing the video, not a note file.` });
378
+ }
379
+ }
380
+ }
381
+ // The sealed half points at the file you already published: the escrow is
382
+ // empty and the buyer has everything before paying.
383
+ const leaked = noteUrls.filter((u) => proofSet.has(u.replace(/[.,)]+$/, "")));
384
+ if (leaked.length) {
385
+ checks.push({
386
+ level: "fail",
387
+ code: "same_url_both_sides",
388
+ target: leaked[0],
389
+ message: "The private note points at a url you also submitted publicly, so the sealed half hands over nothing new.",
390
+ hint: "Seal the CLEAN master in the note and publish the WATERMARKED cut in --proof."
391
+ });
392
+ }
393
+ const duplicates = bundle.proofs.length - new Set(bundle.proofs).size;
394
+ if (duplicates > 0) {
395
+ checks.push({ level: "warn", code: "duplicate_proof_url", target: bundle.proofs[0] ?? "", message: "The same url is submitted more than once." });
396
+ }
397
+ if (!note && !hasMaster) {
398
+ if (bundle.cleanInProof) {
399
+ checks.push({
400
+ level: "ok",
401
+ code: "clean_in_proof_acknowledged",
402
+ target: "private_note",
403
+ message: "No private note, by your own choice — the proof url IS the final deliverable and the buyer holds it before payout."
404
+ });
405
+ }
406
+ else {
407
+ checks.push({
408
+ level: "warn",
409
+ code: "no_private_note",
410
+ target: "private_note",
411
+ message: "No private note. If that proof url is your unwatermarked final, the buyer has the whole job before paying for it.",
412
+ hint: "Recommended: --proof <watermarked url> --clean-master ./final-clean.mp4. Meant to hand it over? Pass --clean-in-proof."
413
+ });
414
+ }
415
+ }
416
+ else if (note && !hasMaster && noteUrls.length === 0) {
417
+ checks.push({
418
+ level: "warn",
419
+ code: "note_without_file",
420
+ target: "private_note",
421
+ message: "The private note is text only — it unseals on payment and the buyer receives no file from it.",
422
+ hint: "Put the clean master in it: --clean-master ./final-clean.mp4"
423
+ });
424
+ }
425
+ for (const url of bundle.proofs) {
426
+ const shape = inspectProofUrl(url);
427
+ if (shape.looks_like_master && (note || hasMaster)) {
428
+ checks.push({
429
+ level: "warn",
430
+ code: "master_name_in_proof",
431
+ target: url,
432
+ message: `"${shape.filename}" is named like the clean master, but you are also sealing one in the note.`,
433
+ hint: "Check you did not publish the unwatermarked cut by mistake — a proof url is public forever."
434
+ });
435
+ }
436
+ }
437
+ return checks;
438
+ }
439
+ /**
440
+ * Shape rules → one probe per url → the proof/note usage rules. `ok` is false
441
+ * whenever anything failed, which is what makes `verify-proof && gigs submit …`
442
+ * a real gate rather than a reminder.
443
+ */
444
+ export async function verifyProof(bundle, options = {}) {
445
+ const checks = [];
446
+ const probes = [];
447
+ for (const url of bundle.proofs) {
448
+ const shape = shapeChecks(url);
449
+ checks.push(...shape);
450
+ // A url that is already condemned by its shape is not worth a round trip —
451
+ // and a localhost url would hang the check for the timeout.
452
+ if (shape.some((c) => c.level === "fail"))
453
+ continue;
454
+ if (options.probe === false)
455
+ continue;
456
+ const probe = await probeUrl(url, options.timeoutMs);
457
+ probes.push(probe);
458
+ checks.push(...probeChecks(probe));
459
+ }
460
+ checks.push(...usageChecks(bundle));
461
+ const failures = checks.filter((c) => c.level === "fail");
462
+ const warnings = checks.filter((c) => c.level === "warn");
463
+ return { ok: failures.length === 0, failures, warnings, checks, probes };
464
+ }
465
+ //# sourceMappingURL=proof-verify.js.map