@ctrl-spc/cs 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { createServer as createHttpServer } from 'node:http';
@@ -9,6 +9,7 @@ import { z } from 'zod';
9
9
  import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
10
10
  import { agentPath } from './agents.js';
11
11
  import { mcpToken, readSession } from './config.js';
12
+ import { readPngScreenshot, } from './screenshots.js';
12
13
  export function attributionFromClientName(name) {
13
14
  if (!name)
14
15
  return null;
@@ -279,6 +280,297 @@ async function createArtifactHandler(client, userId, currentSession, args) {
279
280
  return errorResult(`create_artifact failed: ${err.message}`);
280
281
  }
281
282
  }
283
+ const SCREENSHOT_PLATFORM_LABEL = {
284
+ web: 'Web',
285
+ ios: 'iOS',
286
+ android: 'Android',
287
+ };
288
+ function attachScreenshotFailure(title, reason) {
289
+ return errorResult(`Couldn’t attach "${title}": ${reason}. No artifact was created.`);
290
+ }
291
+ function screenshotArtifactId(taskId, title, platform, target, bytes) {
292
+ const digest = createHash('sha256')
293
+ .update('ctrl-spc:screenshot:v1\0')
294
+ .update(taskId)
295
+ .update('\0')
296
+ .update(title)
297
+ .update('\0')
298
+ .update(platform)
299
+ .update('\0')
300
+ .update(target)
301
+ .update('\0')
302
+ .update(bytes)
303
+ .digest()
304
+ .subarray(0, 16);
305
+ // RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
306
+ // a retry after Storage succeeded but Postgres failed converge on the same
307
+ // object instead of leaking one new object per retry.
308
+ digest[6] = (digest[6] & 0x0f) | 0x50;
309
+ digest[8] = (digest[8] & 0x3f) | 0x80;
310
+ const hex = digest.toString('hex');
311
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
312
+ }
313
+ function isDuplicateStorageObject(error) {
314
+ return /duplicate|already exists|resource exists/i.test(`${error.message} ${error.error ?? ''} ${error.name ?? ''}`);
315
+ }
316
+ function screenshotArtifactMatches(row, expected) {
317
+ return (row.id === expected.id &&
318
+ row.task_id === expected.taskId &&
319
+ row.type === 'image' &&
320
+ row.format === 'png' &&
321
+ row.title === expected.title &&
322
+ row.content === expected.content &&
323
+ row.storage_path === expected.storagePath &&
324
+ row.created_by === expected.userId &&
325
+ row.deleted_at === null);
326
+ }
327
+ async function fetchScreenshotArtifact(client, artifactId) {
328
+ return must(client
329
+ .from('artifacts')
330
+ .select('id,task_id,type,format,title,content,storage_path,created_by,deleted_at')
331
+ .eq('id', artifactId)
332
+ .maybeSingle());
333
+ }
334
+ async function downloadScreenshotObject(client, storagePath) {
335
+ const { data, error } = await client.storage.from('artifacts').download(storagePath);
336
+ if (error)
337
+ throw new Error(error.message);
338
+ if (!data)
339
+ throw new Error('Storage returned no file data');
340
+ const arrayBuffer = await data.arrayBuffer();
341
+ return Buffer.from(arrayBuffer);
342
+ }
343
+ async function ensureScreenshotAttribution(client, userId, sessionId, artifactId) {
344
+ try {
345
+ await must(client.from('cliv2_agent_outputs').insert({
346
+ user_id: userId,
347
+ session_id: sessionId,
348
+ kind: 'artifact',
349
+ product_id: artifactId,
350
+ }));
351
+ return undefined;
352
+ }
353
+ catch (err) {
354
+ // A completed call whose response was lost already has this unique
355
+ // attribution row. Treat it as converged rather than warning or duplicating.
356
+ try {
357
+ const existing = await must(client
358
+ .from('cliv2_agent_outputs')
359
+ .select('user_id,session_id,product_id')
360
+ .eq('kind', 'artifact')
361
+ .eq('product_id', artifactId)
362
+ .maybeSingle());
363
+ if (existing?.user_id === userId && existing.product_id === artifactId)
364
+ return undefined;
365
+ }
366
+ catch {
367
+ // Preserve the original attribution failure below.
368
+ }
369
+ return `Screenshot attached, but recording session attribution failed: ${err.message}`;
370
+ }
371
+ }
372
+ function attachedScreenshotResult(title, platform, target, artifactId, attributionWarning) {
373
+ const success = `✓ Attached "${title}" to this work item.\n` +
374
+ ` ${SCREENSHOT_PLATFORM_LABEL[platform]} · ${target} · image artifact ${artifactId}`;
375
+ return attributionWarning
376
+ ? { content: [{ type: 'text', text: `${success}\n Warning: ${attributionWarning}` }] }
377
+ : { content: [{ type: 'text', text: success }] };
378
+ }
379
+ /**
380
+ * Attach one already-captured PNG to this connection's open work session.
381
+ *
382
+ * The local file is fully validated before the first hosted request. The
383
+ * Storage upload intentionally precedes the artifact INSERT: the private bucket
384
+ * has no DELETE policy (objects follow the artifact soft-delete posture), so an
385
+ * upload failure must never leave a live broken card. There is no transaction
386
+ * across Supabase Storage and Postgres; if the upload succeeds but the row
387
+ * insert fails, the stable object key is reported for maintenance cleanup.
388
+ */
389
+ export async function attachScreenshotHandler(client, userId, currentSession, args, deps = {}) {
390
+ const title = args.title?.trim() ?? '';
391
+ if (!currentSession) {
392
+ return errorResult('attach_screenshot needs an open work session — call begin_work first. No artifact was created.');
393
+ }
394
+ if (!title) {
395
+ return attachScreenshotFailure('Untitled screenshot', 'title must be non-empty');
396
+ }
397
+ const target = args.target?.trim() ?? '';
398
+ if (!target) {
399
+ return attachScreenshotFailure(title, 'target must be non-empty');
400
+ }
401
+ if (!['web', 'ios', 'android'].includes(args.platform)) {
402
+ return attachScreenshotFailure(title, "platform must be 'web', 'ios', or 'android'");
403
+ }
404
+ let screenshot;
405
+ try {
406
+ screenshot = await (deps.readScreenshot ?? readPngScreenshot)(args.path);
407
+ }
408
+ catch (err) {
409
+ return attachScreenshotFailure(title, err.message);
410
+ }
411
+ try {
412
+ // Resolve only the open session's task. There is deliberately no task_id
413
+ // argument, so one MCP connection cannot attach a screenshot to some other
414
+ // work item while it is working this one.
415
+ const task = await must(client
416
+ .from('tasks')
417
+ .select('id,project_id')
418
+ .eq('id', currentSession.taskId)
419
+ .is('archived_at', null)
420
+ .maybeSingle());
421
+ if (!task) {
422
+ return attachScreenshotFailure(title, 'the open work item was not found or is archived');
423
+ }
424
+ const project = await must(client
425
+ .from('projects')
426
+ .select('id,org_id')
427
+ .eq('id', task.project_id)
428
+ .is('archived_at', null)
429
+ .maybeSingle());
430
+ if (!project) {
431
+ return attachScreenshotFailure(title, 'the work item project was not found or is not accessible');
432
+ }
433
+ const artifactId = deps.artifactId?.() ??
434
+ screenshotArtifactId(task.id, title, args.platform, target, screenshot.bytes);
435
+ const storagePath = `${project.org_id}/${task.project_id}/${task.id}/${artifactId}.png`;
436
+ const metadata = {
437
+ platform: args.platform,
438
+ target,
439
+ width: screenshot.width,
440
+ height: screenshot.height,
441
+ size_bytes: screenshot.sizeBytes,
442
+ };
443
+ const content = JSON.stringify(metadata);
444
+ const expectedArtifact = {
445
+ id: artifactId,
446
+ taskId: task.id,
447
+ title,
448
+ content,
449
+ storagePath,
450
+ userId,
451
+ };
452
+ // Supabase Storage documents that upload needs INSERT RLS. upsert:false is
453
+ // intentional: this call never replaces an existing screenshot and
454
+ // therefore does not require SELECT + UPDATE permissions.
455
+ const { error: uploadError } = await client.storage
456
+ .from('artifacts')
457
+ .upload(storagePath, screenshot.bytes, {
458
+ contentType: 'image/png',
459
+ upsert: false,
460
+ });
461
+ if (uploadError && !isDuplicateStorageObject(uploadError)) {
462
+ return attachScreenshotFailure(title, `the PNG could not be uploaded: ${uploadError.message}`);
463
+ }
464
+ if (uploadError) {
465
+ let existing;
466
+ try {
467
+ existing = await fetchScreenshotArtifact(client, artifactId);
468
+ }
469
+ catch (err) {
470
+ return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not confirm ` +
471
+ `whether artifact ${artifactId} is complete: ${err.message}. The existing object and any ` +
472
+ 'artifact record were left unchanged; do not retry until the artifact can be checked.');
473
+ }
474
+ if (existing) {
475
+ if (!screenshotArtifactMatches(existing, expectedArtifact)) {
476
+ return errorResult(`Couldn’t attach "${title}": the retry key is already linked to a different artifact record. ` +
477
+ `Existing artifact ${artifactId} was left unchanged; no new artifact was created.`);
478
+ }
479
+ const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
480
+ return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
481
+ }
482
+ // No artifact row means this may be an orphan from an interrupted call.
483
+ // Verify the private object's exact bytes before linking it; a key
484
+ // collision must never turn someone else's object into this evidence.
485
+ let existingBytes;
486
+ try {
487
+ existingBytes = await downloadScreenshotObject(client, storagePath);
488
+ }
489
+ catch (err) {
490
+ return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not verify ` +
491
+ `its bytes: ${err.message}. The existing object was left unchanged; no artifact was created.`);
492
+ }
493
+ if (!existingBytes.equals(screenshot.bytes)) {
494
+ return errorResult(`Couldn’t attach "${title}": the retry key points to an existing screenshot with different bytes. ` +
495
+ `The existing object was left unchanged; no artifact was created.`);
496
+ }
497
+ // The exact object is an orphan from a previous call that failed at
498
+ // Postgres. Continue with the same deterministic id/path to finish it.
499
+ }
500
+ let row;
501
+ try {
502
+ row = await must(client
503
+ .from('artifacts')
504
+ .insert({
505
+ id: artifactId,
506
+ task_id: task.id,
507
+ type: 'image',
508
+ format: 'png',
509
+ title,
510
+ content,
511
+ storage_path: storagePath,
512
+ created_by: userId,
513
+ from_agent: null,
514
+ agent_run_id: null,
515
+ })
516
+ .select(ARTIFACT_COLUMNS)
517
+ .single());
518
+ }
519
+ catch (err) {
520
+ // The insert response may have been lost after Postgres committed, or a
521
+ // concurrent retry may have won. Re-read before describing an orphan.
522
+ let existing;
523
+ try {
524
+ existing = await fetchScreenshotArtifact(client, artifactId);
525
+ }
526
+ catch (readErr) {
527
+ return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
528
+ `${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
529
+ `"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
530
+ }
531
+ if (existing) {
532
+ if (!screenshotArtifactMatches(existing, expectedArtifact)) {
533
+ return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
534
+ 'The existing artifact was left unchanged; no new artifact was created.');
535
+ }
536
+ const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
537
+ return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
538
+ }
539
+ // The bucket intentionally has no DELETE policy. Removing an object by
540
+ // writing storage.objects directly is unsupported by Supabase, so do not
541
+ // pretend this cross-service boundary is atomic.
542
+ return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record could not be created: ` +
543
+ `${err.message}. No artifact was created. The unlinked private object at ` +
544
+ `"${storagePath}" may require maintenance cleanup.`);
545
+ }
546
+ if (!row) {
547
+ let existing;
548
+ try {
549
+ existing = await fetchScreenshotArtifact(client, artifactId);
550
+ }
551
+ catch (readErr) {
552
+ return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
553
+ `${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
554
+ `"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
555
+ }
556
+ if (existing) {
557
+ if (!screenshotArtifactMatches(existing, expectedArtifact)) {
558
+ return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
559
+ 'The existing artifact was left unchanged; no new artifact was created.');
560
+ }
561
+ const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
562
+ return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
563
+ }
564
+ return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record returned no row. ` +
565
+ `No artifact was created. The unlinked private object at "${storagePath}" may require maintenance cleanup.`);
566
+ }
567
+ const attributionWarning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
568
+ return attachedScreenshotResult(title, args.platform, target, artifactId, attributionWarning);
569
+ }
570
+ catch (err) {
571
+ return attachScreenshotFailure(title, err.message);
572
+ }
573
+ }
282
574
  /** Re-fetch one task with the SAME select get_task uses (`*` + the tag join),
283
575
  * returning the parsed task object ({ ...row, tags }, task_tags dropped) or null.
284
576
  * Shared by update_task and create_task so their result shape matches get_task's
@@ -1414,12 +1706,13 @@ async function getCredentialHandler(client, args) {
1414
1706
  }
1415
1707
  }
1416
1708
  // ---------------------------------------------------------------------------
1417
- // The twenty-two tools this server exposes. Exported for the self-check.
1709
+ // The twenty-three tools this server exposes. Exported for the self-check.
1418
1710
  // ---------------------------------------------------------------------------
1419
1711
  export const TOOL_NAMES = [
1420
1712
  'list_tasks',
1421
1713
  'get_task',
1422
1714
  'create_artifact',
1715
+ 'attach_screenshot',
1423
1716
  'update_task',
1424
1717
  'update_artifact',
1425
1718
  'set_task_role_slugs',
@@ -1440,7 +1733,7 @@ export const TOOL_NAMES = [
1440
1733
  'list_credentials',
1441
1734
  'get_credential',
1442
1735
  ];
1443
- /** Build a per-session McpServer with the twenty-two tools. A fresh instance per
1736
+ /** Build a per-session McpServer with the twenty-three tools. A fresh instance per
1444
1737
  * session is what makes `server.server.getClientVersion()` (populated during
1445
1738
  * that session's `initialize`) the right source for attribution — mirroring
1446
1739
  * v1's per-session `buildServer`. `connectionId` is this connection's key into
@@ -1496,6 +1789,21 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
1496
1789
  touchSession(connectionId);
1497
1790
  return createArtifactHandler(client, userId, openSessions.get(connectionId) ?? null, args);
1498
1791
  });
1792
+ server.registerTool('attach_screenshot', {
1793
+ description: 'Attach one real Web, iOS, or Android PNG screenshot to the work item in your open session. ' +
1794
+ 'Playwright or Maestro captures the file first; pass its absolute local path, a clear title, the ' +
1795
+ 'platform, and the exact browser/simulator/emulator target. The local path is never uploaded or stored. ' +
1796
+ 'Requires an open session (begin_work).',
1797
+ inputSchema: {
1798
+ path: z.string().describe('Absolute local path to one existing PNG'),
1799
+ title: z.string().describe('Non-empty title shown on the work item'),
1800
+ platform: z.enum(['web', 'ios', 'android']),
1801
+ target: z.string().describe('Non-empty exact browser, simulator, or emulator name'),
1802
+ },
1803
+ }, async (args) => {
1804
+ touchSession(connectionId);
1805
+ return attachScreenshotHandler(client, userId, openSessions.get(connectionId) ?? null, args);
1806
+ });
1499
1807
  server.registerTool('update_task', {
1500
1808
  description: 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
1501
1809
  'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
@@ -0,0 +1,196 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { isAbsolute } from 'node:path';
3
+ import { inflateSync } from 'node:zlib';
4
+ export const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
5
+ const MAX_DECODED_SCREENSHOT_BYTES = 256 * 1024 * 1024;
6
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+ const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
8
+ let crc = value;
9
+ for (let bit = 0; bit < 8; bit += 1) {
10
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
11
+ }
12
+ return crc >>> 0;
13
+ });
14
+ function crc32(bytes) {
15
+ let crc = 0xffffffff;
16
+ for (const byte of bytes) {
17
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
18
+ }
19
+ return (crc ^ 0xffffffff) >>> 0;
20
+ }
21
+ /**
22
+ * Read and validate the local screenshot before any hosted request is made.
23
+ *
24
+ * This validates the PNG framing and decoded scanline envelope without
25
+ * rewriting the user's original bytes. Interlaced images are rejected because
26
+ * browser and mobile screenshot tools produce standard non-interlaced PNGs,
27
+ * and validating Adam7 safely would add complexity without helping Phase 1.
28
+ */
29
+ export async function readPngScreenshot(localPath) {
30
+ if (!localPath || !isAbsolute(localPath)) {
31
+ throw new Error('path must be an absolute local path');
32
+ }
33
+ let stat;
34
+ try {
35
+ stat = await lstat(localPath);
36
+ }
37
+ catch (err) {
38
+ const code = err.code;
39
+ if (code === 'ENOENT')
40
+ throw new Error(`the PNG was not found at ${localPath}`);
41
+ throw new Error(`the PNG could not be inspected at ${localPath}: ${err.message}`);
42
+ }
43
+ if (!stat.isFile()) {
44
+ throw new Error(`the path is not a regular file: ${localPath}`);
45
+ }
46
+ if (stat.size === 0) {
47
+ throw new Error('the PNG is empty');
48
+ }
49
+ if (stat.size > MAX_SCREENSHOT_BYTES) {
50
+ throw new Error(`the PNG is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit`);
51
+ }
52
+ let bytes;
53
+ try {
54
+ bytes = await readFile(localPath);
55
+ }
56
+ catch (err) {
57
+ throw new Error(`the PNG could not be read at ${localPath}: ${err.message}`);
58
+ }
59
+ if (bytes.length !== stat.size) {
60
+ throw new Error('the PNG changed while it was being read; capture it again and retry');
61
+ }
62
+ if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
63
+ throw new Error('the file is not a readable PNG (invalid PNG signature)');
64
+ }
65
+ let offset = 8;
66
+ let width = 0;
67
+ let height = 0;
68
+ let sawHeader = false;
69
+ let sawImageData = false;
70
+ let sawEnd = false;
71
+ let sawPalette = false;
72
+ let endedImageData = false;
73
+ let bitDepth = 0;
74
+ let colorType = 0;
75
+ const imageDataChunks = [];
76
+ while (offset < bytes.length) {
77
+ if (bytes.length - offset < 12) {
78
+ throw new Error('the file is not a readable PNG (truncated chunk header)');
79
+ }
80
+ const length = bytes.readUInt32BE(offset);
81
+ const chunkEnd = offset + 12 + length;
82
+ if (chunkEnd > bytes.length || chunkEnd < offset) {
83
+ throw new Error('the file is not a readable PNG (truncated chunk data)');
84
+ }
85
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
86
+ const type = typeBytes.toString('ascii');
87
+ if (!/^[A-Za-z]{4}$/.test(type)) {
88
+ throw new Error('the file is not a readable PNG (invalid chunk type)');
89
+ }
90
+ const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
91
+ const actualCrc = crc32(bytes.subarray(offset + 4, offset + 8 + length));
92
+ if (actualCrc !== expectedCrc) {
93
+ throw new Error(`the file is not a readable PNG (${type} chunk CRC mismatch)`);
94
+ }
95
+ if (!sawHeader) {
96
+ if (type !== 'IHDR' || length !== 13) {
97
+ throw new Error('the file is not a readable PNG (missing the required IHDR header)');
98
+ }
99
+ width = bytes.readUInt32BE(offset + 8);
100
+ height = bytes.readUInt32BE(offset + 12);
101
+ bitDepth = bytes[offset + 16];
102
+ colorType = bytes[offset + 17];
103
+ const compression = bytes[offset + 18];
104
+ const filter = bytes[offset + 19];
105
+ const interlace = bytes[offset + 20];
106
+ const legalDepths = {
107
+ 0: [1, 2, 4, 8, 16],
108
+ 2: [8, 16],
109
+ 3: [1, 2, 4, 8],
110
+ 4: [8, 16],
111
+ 6: [8, 16],
112
+ };
113
+ if (!legalDepths[colorType]?.includes(bitDepth)) {
114
+ throw new Error('the file is not a readable PNG (unsupported IHDR color type or bit depth)');
115
+ }
116
+ if (compression !== 0 || filter !== 0) {
117
+ throw new Error('the file is not a readable PNG (unsupported compression or filter method)');
118
+ }
119
+ if (interlace !== 0) {
120
+ throw new Error('the file is not a readable PNG (interlaced PNGs are not supported)');
121
+ }
122
+ sawHeader = true;
123
+ }
124
+ else if (type === 'IHDR') {
125
+ throw new Error('the file is not a readable PNG (duplicate IHDR header)');
126
+ }
127
+ if (type === 'PLTE') {
128
+ if (sawImageData || length === 0 || length % 3 !== 0 || length > 768) {
129
+ throw new Error('the file is not a readable PNG (invalid PLTE chunk)');
130
+ }
131
+ sawPalette = true;
132
+ }
133
+ if (type === 'IDAT') {
134
+ if (endedImageData) {
135
+ throw new Error('the file is not a readable PNG (IDAT chunks must be consecutive)');
136
+ }
137
+ sawImageData = true;
138
+ imageDataChunks.push(bytes.subarray(offset + 8, offset + 8 + length));
139
+ }
140
+ else if (type === 'IEND') {
141
+ if (length !== 0) {
142
+ throw new Error('the file is not a readable PNG (invalid IEND chunk)');
143
+ }
144
+ sawEnd = true;
145
+ offset = chunkEnd;
146
+ break;
147
+ }
148
+ else if (sawImageData) {
149
+ endedImageData = true;
150
+ }
151
+ if (/^[A-Z]/.test(type) && !['IHDR', 'PLTE', 'IDAT', 'IEND'].includes(type)) {
152
+ throw new Error(`the file is not a readable PNG (unsupported critical chunk ${type})`);
153
+ }
154
+ offset = chunkEnd;
155
+ }
156
+ if (!sawImageData) {
157
+ throw new Error('the file is not a readable PNG (missing image data)');
158
+ }
159
+ if (!sawEnd) {
160
+ throw new Error('the file is not a readable PNG (missing IEND chunk)');
161
+ }
162
+ if (offset !== bytes.length) {
163
+ throw new Error('the file is not a readable PNG (trailing data after IEND)');
164
+ }
165
+ if (width < 1 || height < 1) {
166
+ throw new Error('the file is not a readable PNG (width and height must be positive)');
167
+ }
168
+ if (colorType === 3 && !sawPalette) {
169
+ throw new Error('the file is not a readable PNG (indexed color requires a PLTE chunk)');
170
+ }
171
+ const channels = colorType === 0 || colorType === 3 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4;
172
+ const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
173
+ const scanlineBytes = rowBytes + 1;
174
+ const expectedDecodedBytes = scanlineBytes * height;
175
+ if (!Number.isSafeInteger(expectedDecodedBytes) || expectedDecodedBytes > MAX_DECODED_SCREENSHOT_BYTES) {
176
+ throw new Error('the PNG expands beyond the 256 MB decoded screenshot limit');
177
+ }
178
+ let decoded;
179
+ try {
180
+ decoded = inflateSync(Buffer.concat(imageDataChunks), {
181
+ maxOutputLength: expectedDecodedBytes + 1,
182
+ });
183
+ }
184
+ catch (err) {
185
+ throw new Error(`the file is not a readable PNG (invalid compressed image data: ${err.message})`);
186
+ }
187
+ if (decoded.length !== expectedDecodedBytes) {
188
+ throw new Error(`the file is not a readable PNG (decoded scanline size is ${decoded.length}, expected ${expectedDecodedBytes})`);
189
+ }
190
+ for (let row = 0; row < height; row += 1) {
191
+ if (decoded[row * scanlineBytes] > 4) {
192
+ throw new Error(`the file is not a readable PNG (invalid filter byte on scanline ${row + 1})`);
193
+ }
194
+ }
195
+ return { bytes, width, height, sizeBytes: bytes.length };
196
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctrl-spc/cs",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "CTRL+SPC — minimal, reliable per-machine agent presence. Sign-in, auto-start, agent detection, heartbeat presence, and ping acknowledgement.",
5
5
  "engines": {
6
6
  "node": ">=22"