@hraness/slopcamera 3.3.4 → 3.3.5

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,818 @@
1
+ import { constants } from "node:fs";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ open,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ } from "node:fs/promises";
10
+ import { hostname } from "node:os";
11
+ import { join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+
14
+ import { CliError } from "./errors";
15
+ import { ensurePrivateDirectory } from "./paths";
16
+
17
+ /**
18
+ * Hraness Credits public contract (v1) plus the hosted Slopcamera tool call.
19
+ * Both endpoints are fixed; no credential travels in argv, receipts, or logs.
20
+ * The claim secret and device token are persisted only below the private CLI
21
+ * state root, and the device token is also accepted from the
22
+ * SLOPCAMERA_CREDITS_TOKEN environment variable for agent environments.
23
+ */
24
+ export const CREDITS_BASE_URL = "https://credits.hraness.com";
25
+ export const SLOPCAMERA_API_BASE_URL = "https://api.slopcamera.com";
26
+ export const CREDITS_PRODUCT_ID = "slopcamera";
27
+ export const CREDITS_TOKEN_ENV = "SLOPCAMERA_CREDITS_TOKEN";
28
+
29
+ const CREDITS_RESPONSE_MAX_BYTES = 64 * 1024;
30
+ const HOSTED_RESPONSE_MAX_BYTES = 64 * 1024;
31
+ export const HOSTED_ARTIFACT_MAX_BYTES = 32 * 1024 * 1024;
32
+ const DEVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
33
+ const CLAIM_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/u;
34
+ const DEVICE_TOKEN_PATTERN = /^cr_dev_[A-Za-z0-9_-]{8,256}$/u;
35
+ const CLAIM_SECRET_PATTERN = /^cr_clm_[A-Za-z0-9_-]{8,256}$/u;
36
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/u;
37
+
38
+ export interface CreditsPack {
39
+ readonly id: string;
40
+ readonly usd: number;
41
+ readonly credits: number;
42
+ readonly bonusCredits: number;
43
+ readonly label: string;
44
+ }
45
+
46
+ export interface CreditsMoney {
47
+ readonly microUsd: number;
48
+ readonly credits: number;
49
+ readonly usd: string;
50
+ }
51
+
52
+ export interface CreditsTopup {
53
+ readonly url: string;
54
+ readonly expiresAt?: string;
55
+ readonly packs: readonly CreditsPack[];
56
+ readonly suggestedPackId: string | null;
57
+ }
58
+
59
+ export interface CreditsClaim {
60
+ readonly claimId: string;
61
+ readonly claimSecret: string;
62
+ readonly url: string;
63
+ readonly expiresAt: string;
64
+ readonly packs: readonly CreditsPack[];
65
+ readonly suggestedPackId: string | null;
66
+ }
67
+
68
+ export interface CreditsClaimStatus {
69
+ readonly claimId: string;
70
+ readonly state: "pending" | "paid" | "consumed" | "expired";
71
+ readonly expiresAt: string;
72
+ readonly paidAt?: string;
73
+ /** Present exactly once: the first `paid` read carrying the claim secret. */
74
+ readonly deviceToken?: string;
75
+ }
76
+
77
+ export interface CreditsBalance {
78
+ readonly product: { readonly id: string; readonly name: string };
79
+ readonly balance: CreditsMoney;
80
+ readonly heldMicroUsd: number;
81
+ readonly lowBalance: boolean;
82
+ readonly topup: CreditsTopup | null;
83
+ }
84
+
85
+ export interface HostedArtifact {
86
+ readonly id: string;
87
+ readonly name: string;
88
+ readonly bytes: number;
89
+ readonly sha256: string;
90
+ readonly contentType: string;
91
+ readonly contentUrl: string;
92
+ readonly url: string;
93
+ readonly expiresAt: string;
94
+ }
95
+
96
+ export interface HostedCallResult {
97
+ readonly ok: boolean;
98
+ readonly artifacts: readonly HostedArtifact[];
99
+ }
100
+
101
+ export interface CreditsShortfall {
102
+ readonly required?: { readonly microUsd: number; readonly usd: string };
103
+ readonly balance?: { readonly microUsd: number; readonly usd: string };
104
+ readonly topup?: { readonly url: string };
105
+ }
106
+
107
+ export class CreditsRequestError extends Error {
108
+ readonly status: number;
109
+ readonly errorCode: string;
110
+ readonly shortfall: CreditsShortfall | undefined;
111
+
112
+ constructor(status: number, errorCode: string, message: string, shortfall?: CreditsShortfall) {
113
+ super(message);
114
+ this.name = "CreditsRequestError";
115
+ this.status = status;
116
+ this.errorCode = errorCode;
117
+ this.shortfall = shortfall;
118
+ }
119
+ }
120
+
121
+ export type CreditsFetch = (
122
+ input: RequestInfo | URL,
123
+ init?: RequestInit,
124
+ ) => Promise<Response>;
125
+
126
+ function isRecord(value: unknown): value is Record<string, unknown> {
127
+ return typeof value === "object" && value !== null && !Array.isArray(value);
128
+ }
129
+
130
+ function readString(value: unknown): string | undefined {
131
+ return typeof value === "string" && value.length > 0 ? value : undefined;
132
+ }
133
+
134
+ function readInteger(value: unknown): number | undefined {
135
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
136
+ ? value
137
+ : undefined;
138
+ }
139
+
140
+ function readTimestamp(value: unknown): string | undefined {
141
+ const text = readString(value);
142
+ return text !== undefined && ISO_TIMESTAMP_PATTERN.test(text) ? text : undefined;
143
+ }
144
+
145
+ function parsePack(value: unknown): CreditsPack | undefined {
146
+ if (!isRecord(value)) return undefined;
147
+ const id = readString(value.id);
148
+ const usd = readInteger(value.usd);
149
+ const credits = readInteger(value.credits);
150
+ const bonusCredits = readInteger(value.bonusCredits);
151
+ const label = readString(value.label);
152
+ if (
153
+ id === undefined || usd === undefined || credits === undefined
154
+ || bonusCredits === undefined || label === undefined
155
+ ) {
156
+ return undefined;
157
+ }
158
+ return { id, usd, credits, bonusCredits, label };
159
+ }
160
+
161
+ function parsePacks(value: unknown): readonly CreditsPack[] {
162
+ if (!Array.isArray(value) || value.length > 32) return [];
163
+ const packs: CreditsPack[] = [];
164
+ for (const entry of value) {
165
+ const pack = parsePack(entry);
166
+ if (pack === undefined) return [];
167
+ packs.push(pack);
168
+ }
169
+ return packs;
170
+ }
171
+
172
+ function parseMoney(value: unknown): CreditsMoney | undefined {
173
+ if (!isRecord(value)) return undefined;
174
+ const microUsd = readInteger(value.microUsd);
175
+ const credits = readInteger(value.credits);
176
+ const usd = readString(value.usd);
177
+ if (microUsd === undefined || credits === undefined || usd === undefined) {
178
+ return undefined;
179
+ }
180
+ return { microUsd, credits, usd };
181
+ }
182
+
183
+ function parseTopup(value: unknown): CreditsTopup | undefined {
184
+ if (!isRecord(value)) return undefined;
185
+ const url = readString(value.url);
186
+ if (url === undefined || !url.startsWith("https://")) return undefined;
187
+ const expiresAt = readTimestamp(value.expiresAt);
188
+ return {
189
+ url,
190
+ ...(expiresAt === undefined ? {} : { expiresAt }),
191
+ packs: parsePacks(value.packs),
192
+ suggestedPackId: readString(value.suggestedPackId) ?? null,
193
+ };
194
+ }
195
+
196
+ export function parseClaimResponse(value: unknown): CreditsClaim {
197
+ if (!isRecord(value)) throw new CliError("invalid-data", "Credits claim response was not an object.");
198
+ const claimId = readString(value.claimId);
199
+ const claimSecret = readString(value.claimSecret);
200
+ const url = readString(value.url);
201
+ const expiresAt = readTimestamp(value.expiresAt);
202
+ if (
203
+ claimId === undefined || !CLAIM_ID_PATTERN.test(claimId)
204
+ || claimSecret === undefined || !CLAIM_SECRET_PATTERN.test(claimSecret)
205
+ || url === undefined || !url.startsWith("https://")
206
+ || expiresAt === undefined
207
+ ) {
208
+ throw new CliError("invalid-data", "Credits claim response was malformed.");
209
+ }
210
+ return {
211
+ claimId,
212
+ claimSecret,
213
+ url,
214
+ expiresAt,
215
+ packs: parsePacks(value.packs),
216
+ suggestedPackId: readString(value.suggestedPackId) ?? null,
217
+ };
218
+ }
219
+
220
+ export function parseClaimStatus(value: unknown): CreditsClaimStatus {
221
+ if (!isRecord(value)) throw new CliError("invalid-data", "Credits claim status was not an object.");
222
+ const claimId = readString(value.claimId);
223
+ const expiresAt = readTimestamp(value.expiresAt);
224
+ const state = value.state;
225
+ if (
226
+ claimId === undefined
227
+ || expiresAt === undefined
228
+ || (state !== "pending" && state !== "paid" && state !== "consumed" && state !== "expired")
229
+ ) {
230
+ throw new CliError("invalid-data", "Credits claim status was malformed.");
231
+ }
232
+ const token = value.token;
233
+ if (token !== undefined && (typeof token !== "string" || !DEVICE_TOKEN_PATTERN.test(token))) {
234
+ throw new CliError("invalid-data", "Credits claim returned a malformed device token.");
235
+ }
236
+ const deviceToken = typeof token === "string" ? token : undefined;
237
+ const paidAt = readTimestamp(value.paidAt);
238
+ return {
239
+ claimId,
240
+ state,
241
+ expiresAt,
242
+ ...(paidAt === undefined ? {} : { paidAt }),
243
+ ...(deviceToken === undefined ? {} : { deviceToken }),
244
+ };
245
+ }
246
+
247
+ export function parseBalance(value: unknown): CreditsBalance {
248
+ if (!isRecord(value)) throw new CliError("invalid-data", "Credits balance response was not an object.");
249
+ const product = isRecord(value.product) ? value.product : undefined;
250
+ const productId = product === undefined ? undefined : readString(product.id);
251
+ const productName = product === undefined ? undefined : readString(product.name);
252
+ const balance = parseMoney(value.balance);
253
+ const held = isRecord(value.held) ? readInteger(value.held.microUsd) : undefined;
254
+ if (productId === undefined || productName === undefined || balance === undefined || held === undefined) {
255
+ throw new CliError("invalid-data", "Credits balance response was malformed.");
256
+ }
257
+ return {
258
+ product: { id: productId, name: productName },
259
+ balance,
260
+ heldMicroUsd: held,
261
+ lowBalance: value.lowBalance === true,
262
+ topup: parseTopup(value.topup) ?? null,
263
+ };
264
+ }
265
+
266
+ export function parseHostedCallResult(value: unknown): HostedCallResult {
267
+ if (!isRecord(value)) throw new CliError("invalid-data", "Hosted call response was not an object.");
268
+ const artifactsValue = value.artifacts;
269
+ const artifacts: HostedArtifact[] = [];
270
+ if (artifactsValue !== undefined) {
271
+ if (!Array.isArray(artifactsValue) || artifactsValue.length > 16) {
272
+ throw new CliError("invalid-data", "Hosted call artifacts were malformed.");
273
+ }
274
+ for (const entry of artifactsValue) {
275
+ if (!isRecord(entry)) throw new CliError("invalid-data", "Hosted call artifact was not an object.");
276
+ const id = readString(entry.id);
277
+ const name = readString(entry.name);
278
+ const bytes = readInteger(entry.bytes);
279
+ const sha256 = readString(entry.sha256);
280
+ const contentType = readString(entry.contentType);
281
+ const contentUrl = readString(entry.contentUrl);
282
+ const url = readString(entry.url);
283
+ const expiresAt = readTimestamp(entry.expiresAt);
284
+ if (
285
+ id === undefined || name === undefined || bytes === undefined
286
+ || sha256 === undefined || contentType === undefined
287
+ || contentUrl === undefined || !contentUrl.startsWith("https://")
288
+ || url === undefined || !url.startsWith("https://")
289
+ || expiresAt === undefined
290
+ ) {
291
+ throw new CliError("invalid-data", "Hosted call artifact was malformed.");
292
+ }
293
+ artifacts.push({ id, name, bytes, sha256, contentType, contentUrl, url, expiresAt });
294
+ }
295
+ }
296
+ const ok = value.ok === true;
297
+ if (!ok && value.ok !== false) {
298
+ throw new CliError("invalid-data", "Hosted call response was malformed.");
299
+ }
300
+ return { ok, artifacts };
301
+ }
302
+
303
+ async function readBoundedJson(
304
+ response: Response,
305
+ maximumBytes: number,
306
+ ): Promise<unknown> {
307
+ const declared = response.headers.get("content-length");
308
+ if (declared !== null) {
309
+ const bytes = Number(declared);
310
+ if (Number.isFinite(bytes) && bytes > maximumBytes) {
311
+ throw new CliError("invalid-data", "Credits response exceeded the byte bound.");
312
+ }
313
+ }
314
+ const text = await response.text();
315
+ if (new TextEncoder().encode(text).byteLength > maximumBytes) {
316
+ throw new CliError("invalid-data", "Credits response exceeded the byte bound.");
317
+ }
318
+ try {
319
+ return JSON.parse(text) as unknown;
320
+ } catch {
321
+ throw new CliError("invalid-data", "Credits response was not JSON.");
322
+ }
323
+ }
324
+
325
+ function shortfallFrom(value: unknown): CreditsShortfall | undefined {
326
+ if (!isRecord(value)) return undefined;
327
+ const shortfall: {
328
+ required?: { microUsd: number; usd: string };
329
+ balance?: { microUsd: number; usd: string };
330
+ topup?: { url: string };
331
+ } = {};
332
+ if (isRecord(value.required)) {
333
+ const microUsd = readInteger(value.required.microUsd);
334
+ const usd = readString(value.required.usd);
335
+ if (microUsd !== undefined && usd !== undefined) {
336
+ shortfall.required = { microUsd, usd };
337
+ }
338
+ }
339
+ if (isRecord(value.balance)) {
340
+ const microUsd = readInteger(value.balance.microUsd);
341
+ const usd = readString(value.balance.usd);
342
+ if (microUsd !== undefined && usd !== undefined) {
343
+ shortfall.balance = { microUsd, usd };
344
+ }
345
+ }
346
+ if (isRecord(value.topup)) {
347
+ const url = readString(value.topup.url);
348
+ if (url !== undefined && url.startsWith("https://")) shortfall.topup = { url };
349
+ }
350
+ return Object.keys(shortfall).length === 0 ? undefined : shortfall;
351
+ }
352
+
353
+ export class CreditsClient {
354
+ readonly #fetch: CreditsFetch;
355
+ readonly #creditsBaseUrl: string;
356
+ readonly #apiBaseUrl: string;
357
+
358
+ constructor(options: Readonly<{
359
+ fetch: CreditsFetch;
360
+ creditsBaseUrl?: string;
361
+ apiBaseUrl?: string;
362
+ }>) {
363
+ this.#fetch = options.fetch;
364
+ this.#creditsBaseUrl = options.creditsBaseUrl ?? CREDITS_BASE_URL;
365
+ this.#apiBaseUrl = options.apiBaseUrl ?? SLOPCAMERA_API_BASE_URL;
366
+ }
367
+
368
+ async #request(
369
+ baseUrl: string,
370
+ path: string,
371
+ init: RequestInit,
372
+ maximumBytes: number,
373
+ bearerToken?: string,
374
+ ): Promise<unknown> {
375
+ let response: Response;
376
+ try {
377
+ response = await this.#fetch(`${baseUrl}${path}`, {
378
+ ...init,
379
+ headers: {
380
+ accept: "application/json",
381
+ ...(init.method === "POST" ? { "content-type": "application/json" } : {}),
382
+ ...(bearerToken === undefined
383
+ ? {}
384
+ : { authorization: `Bearer ${bearerToken}` }),
385
+ ...(init.headers ?? {}),
386
+ },
387
+ });
388
+ } catch (error) {
389
+ throw new CliError(
390
+ "unavailable",
391
+ `The hosted credits service is unreachable: ${error instanceof Error ? error.message : "request failed"}`,
392
+ );
393
+ }
394
+ const body = await readBoundedJson(response, maximumBytes);
395
+ if (!response.ok) {
396
+ const record = isRecord(body) ? body : {};
397
+ const code = readString(record.error) ?? "unavailable";
398
+ const message = readString(record.message)
399
+ ?? `The request failed with status ${response.status}.`;
400
+ throw new CreditsRequestError(
401
+ response.status,
402
+ code,
403
+ message,
404
+ shortfallFrom(record),
405
+ );
406
+ }
407
+ return body;
408
+ }
409
+
410
+ createClaim(input: Readonly<{
411
+ deviceId: string;
412
+ deviceLabel: string;
413
+ email?: string;
414
+ packId?: string;
415
+ resumeArgv?: readonly string[];
416
+ }>): Promise<CreditsClaim> {
417
+ return this.#request(
418
+ this.#creditsBaseUrl,
419
+ "/v1/claims",
420
+ {
421
+ method: "POST",
422
+ body: JSON.stringify({
423
+ product: CREDITS_PRODUCT_ID,
424
+ device: { id: input.deviceId, label: input.deviceLabel },
425
+ ...(input.email === undefined ? {} : { email: input.email }),
426
+ ...(input.packId === undefined ? {} : { packId: input.packId }),
427
+ ...(input.resumeArgv === undefined
428
+ ? {}
429
+ : { resume: { argv: [...input.resumeArgv] } }),
430
+ }),
431
+ },
432
+ CREDITS_RESPONSE_MAX_BYTES,
433
+ ).then(parseClaimResponse);
434
+ }
435
+
436
+ claimStatus(claimId: string, claimSecret: string): Promise<CreditsClaimStatus> {
437
+ return this.#request(
438
+ this.#creditsBaseUrl,
439
+ `/v1/claims/${encodeURIComponent(claimId)}`,
440
+ { method: "GET" },
441
+ CREDITS_RESPONSE_MAX_BYTES,
442
+ claimSecret,
443
+ ).then(parseClaimStatus);
444
+ }
445
+
446
+ balance(deviceToken: string): Promise<CreditsBalance> {
447
+ return this.#request(
448
+ this.#creditsBaseUrl,
449
+ "/v1/balance",
450
+ { method: "GET" },
451
+ CREDITS_RESPONSE_MAX_BYTES,
452
+ deviceToken,
453
+ ).then(parseBalance);
454
+ }
455
+
456
+ executeHostedImage(input: Readonly<{
457
+ deviceToken: string;
458
+ idempotencyKey: string;
459
+ model: string;
460
+ prompt: string;
461
+ outputName: string;
462
+ }>): Promise<HostedCallResult> {
463
+ return this.#request(
464
+ this.#apiBaseUrl,
465
+ "/v1/tools/execute_slopcamera/call",
466
+ {
467
+ method: "POST",
468
+ body: JSON.stringify({
469
+ arguments: {
470
+ operation: "slopcamera.image.generate",
471
+ input: {
472
+ model: input.model,
473
+ prompt: input.prompt,
474
+ outputPath: input.outputName,
475
+ },
476
+ },
477
+ idempotencyKey: input.idempotencyKey,
478
+ }),
479
+ },
480
+ HOSTED_RESPONSE_MAX_BYTES,
481
+ input.deviceToken,
482
+ ).then(parseHostedCallResult);
483
+ }
484
+
485
+ async downloadArtifact(artifact: HostedArtifact): Promise<Uint8Array> {
486
+ if (artifact.bytes > HOSTED_ARTIFACT_MAX_BYTES) {
487
+ throw new CliError(
488
+ "invalid-data",
489
+ `Hosted artifact ${artifact.name} exceeds the ${HOSTED_ARTIFACT_MAX_BYTES}-byte bound.`,
490
+ );
491
+ }
492
+ let response: Response;
493
+ try {
494
+ response = await this.#fetch(artifact.contentUrl, {
495
+ headers: { accept: artifact.contentType },
496
+ });
497
+ } catch (error) {
498
+ throw new CliError(
499
+ "unavailable",
500
+ `The hosted artifact download failed: ${error instanceof Error ? error.message : "request failed"}`,
501
+ );
502
+ }
503
+ if (!response.ok) {
504
+ throw new CliError(
505
+ "unavailable",
506
+ `The hosted artifact download failed with status ${response.status}.`,
507
+ );
508
+ }
509
+ const declared = response.headers.get("content-length");
510
+ if (declared !== null) {
511
+ const bytes = Number(declared);
512
+ if (Number.isFinite(bytes) && bytes > HOSTED_ARTIFACT_MAX_BYTES) {
513
+ throw new CliError("invalid-data", "Hosted artifact download exceeded the byte bound.");
514
+ }
515
+ }
516
+ if (response.body === null) {
517
+ throw new CliError("invalid-data", "Hosted artifact download had no body.");
518
+ }
519
+ const reader = response.body.getReader();
520
+ const chunks: Uint8Array[] = [];
521
+ let length = 0;
522
+ try {
523
+ while (true) {
524
+ const next = await reader.read();
525
+ if (next.done) break;
526
+ length += next.value.byteLength;
527
+ if (length > HOSTED_ARTIFACT_MAX_BYTES) {
528
+ await reader.cancel().catch(() => undefined);
529
+ throw new CliError("invalid-data", "Hosted artifact download exceeded the byte bound.");
530
+ }
531
+ chunks.push(next.value);
532
+ }
533
+ } finally {
534
+ reader.releaseLock();
535
+ }
536
+ const bytes = new Uint8Array(length);
537
+ let offset = 0;
538
+ for (const chunk of chunks) {
539
+ bytes.set(chunk, offset);
540
+ offset += chunk.byteLength;
541
+ }
542
+ return bytes;
543
+ }
544
+ }
545
+
546
+ export interface CreditsDeviceIdentity {
547
+ readonly deviceId: string;
548
+ readonly label: string;
549
+ }
550
+
551
+ export interface PendingCreditsClaim {
552
+ readonly claimId: string;
553
+ readonly claimSecret: string;
554
+ readonly url: string;
555
+ readonly expiresAt: string;
556
+ readonly packs: readonly CreditsPack[];
557
+ readonly suggestedPackId: string | null;
558
+ readonly createdAt: string;
559
+ }
560
+
561
+ /**
562
+ * Private state below the machine-local CLI state root. The claim secret lets
563
+ * a later `credits wait` resume polling; the device token is written 0600 and
564
+ * never copied into receipts, logs, or argv.
565
+ */
566
+ export class CreditsStore {
567
+ readonly #root: string;
568
+
569
+ constructor(stateRoot: string) {
570
+ this.#root = join(stateRoot, "credits");
571
+ }
572
+
573
+ get directory(): string {
574
+ return this.#root;
575
+ }
576
+
577
+ get tokenPath(): string {
578
+ return join(this.#root, "device-token");
579
+ }
580
+
581
+ get pendingClaimPath(): string {
582
+ return join(this.#root, "pending-claim.json");
583
+ }
584
+
585
+ get devicePath(): string {
586
+ return join(this.#root, "device.json");
587
+ }
588
+
589
+ async #ensureRoot(): Promise<void> {
590
+ await ensurePrivateDirectory(this.#root);
591
+ }
592
+
593
+ async #writePrivateFile(path: string, contents: string): Promise<void> {
594
+ await this.#ensureRoot();
595
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
596
+ const handle = await open(
597
+ temporaryPath,
598
+ constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY
599
+ | (constants.O_NOFOLLOW ?? 0),
600
+ 0o600,
601
+ );
602
+ try {
603
+ await handle.writeFile(contents, "utf8");
604
+ await handle.sync();
605
+ } finally {
606
+ await handle.close();
607
+ }
608
+ await rename(temporaryPath, path);
609
+ await chmod(path, 0o600).catch(() => undefined);
610
+ }
611
+
612
+ async #readFile(path: string): Promise<string | undefined> {
613
+ try {
614
+ const details = await lstat(path);
615
+ if (details.isSymbolicLink() || !details.isFile()) {
616
+ throw new CliError("unsafe-path", `Refusing to read non-file credits state: ${path}`);
617
+ }
618
+ const contents = await readFile(path, "utf8");
619
+ return contents.length > 64 * 1024
620
+ ? (() => {
621
+ throw new CliError("invalid-data", `Credits state file is too large: ${path}`);
622
+ })()
623
+ : contents;
624
+ } catch (error) {
625
+ if (error instanceof CliError) throw error;
626
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
627
+ return undefined;
628
+ }
629
+ throw error;
630
+ }
631
+ }
632
+
633
+ async deviceIdentity(): Promise<CreditsDeviceIdentity> {
634
+ const existing = await this.#readFile(this.devicePath);
635
+ if (existing !== undefined) {
636
+ try {
637
+ const parsed: unknown = JSON.parse(existing);
638
+ if (isRecord(parsed) && typeof parsed.deviceId === "string"
639
+ && DEVICE_ID_PATTERN.test(parsed.deviceId)) {
640
+ return {
641
+ deviceId: parsed.deviceId,
642
+ label: typeof parsed.label === "string" && parsed.label.length <= 64
643
+ ? parsed.label
644
+ : "slopcamera-cli",
645
+ };
646
+ }
647
+ } catch (error) {
648
+ if (error instanceof CliError) throw error;
649
+ }
650
+ }
651
+ const identity: CreditsDeviceIdentity = {
652
+ deviceId: randomUUID(),
653
+ label: `slopcamera-cli (${hostname()})`.slice(0, 64),
654
+ };
655
+ await this.#writePrivateFile(this.devicePath, `${JSON.stringify(identity)}\n`);
656
+ return identity;
657
+ }
658
+
659
+ async readDeviceToken(): Promise<string | undefined> {
660
+ const contents = await this.#readFile(this.tokenPath);
661
+ if (contents === undefined) return undefined;
662
+ const token = contents.trim();
663
+ if (!DEVICE_TOKEN_PATTERN.test(token)) {
664
+ throw new CliError("invalid-data", `Stored credits device token is malformed: ${this.tokenPath}`);
665
+ }
666
+ return token;
667
+ }
668
+
669
+ async writeDeviceToken(token: string): Promise<void> {
670
+ if (!DEVICE_TOKEN_PATTERN.test(token)) {
671
+ throw new CliError("invalid-data", "Refusing to store a malformed device token.");
672
+ }
673
+ await this.#writePrivateFile(this.tokenPath, `${token}\n`);
674
+ }
675
+
676
+ async readPendingClaim(): Promise<PendingCreditsClaim | undefined> {
677
+ const contents = await this.#readFile(this.pendingClaimPath);
678
+ if (contents === undefined) return undefined;
679
+ let parsed: unknown;
680
+ try {
681
+ parsed = JSON.parse(contents);
682
+ } catch {
683
+ throw new CliError("invalid-data", `Pending credits claim is not JSON: ${this.pendingClaimPath}`);
684
+ }
685
+ if (!isRecord(parsed)) {
686
+ throw new CliError("invalid-data", `Pending credits claim is malformed: ${this.pendingClaimPath}`);
687
+ }
688
+ const claimId = readString(parsed.claimId);
689
+ const claimSecret = readString(parsed.claimSecret);
690
+ const url = readString(parsed.url);
691
+ const expiresAt = readTimestamp(parsed.expiresAt);
692
+ const createdAt = readTimestamp(parsed.createdAt);
693
+ if (
694
+ claimId === undefined || !CLAIM_ID_PATTERN.test(claimId)
695
+ || claimSecret === undefined || !CLAIM_SECRET_PATTERN.test(claimSecret)
696
+ || url === undefined || !url.startsWith("https://")
697
+ || expiresAt === undefined || createdAt === undefined
698
+ ) {
699
+ throw new CliError("invalid-data", `Pending credits claim is malformed: ${this.pendingClaimPath}`);
700
+ }
701
+ return {
702
+ claimId,
703
+ claimSecret,
704
+ url,
705
+ expiresAt,
706
+ packs: parsePacks(parsed.packs),
707
+ suggestedPackId: readString(parsed.suggestedPackId) ?? null,
708
+ createdAt,
709
+ };
710
+ }
711
+
712
+ async writePendingClaim(claim: PendingCreditsClaim): Promise<void> {
713
+ await this.#writePrivateFile(
714
+ this.pendingClaimPath,
715
+ `${JSON.stringify(claim)}\n`,
716
+ );
717
+ }
718
+
719
+ async clearPendingClaim(): Promise<boolean> {
720
+ try {
721
+ const details = await lstat(this.pendingClaimPath);
722
+ if (details.isSymbolicLink() || !details.isFile()) return false;
723
+ await rm(this.pendingClaimPath, { force: true });
724
+ return true;
725
+ } catch (error) {
726
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
727
+ return false;
728
+ }
729
+ throw error;
730
+ }
731
+ }
732
+
733
+ async clearDeviceToken(): Promise<boolean> {
734
+ try {
735
+ const details = await lstat(this.tokenPath);
736
+ if (details.isSymbolicLink() || !details.isFile()) return false;
737
+ await rm(this.tokenPath, { force: true });
738
+ return true;
739
+ } catch (error) {
740
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
741
+ return false;
742
+ }
743
+ throw error;
744
+ }
745
+ }
746
+ }
747
+
748
+ /**
749
+ * Resolve the credits device credential without ever reading argv: the
750
+ * environment wins (agent environments), then the private state file.
751
+ */
752
+ export async function loadCreditsToken(
753
+ env: Readonly<Record<string, string | undefined>>,
754
+ store: CreditsStore,
755
+ ): Promise<Readonly<{ token: string; source: "env" | "state" }> | undefined> {
756
+ const envToken = env[CREDITS_TOKEN_ENV];
757
+ if (envToken !== undefined) {
758
+ const token = envToken.trim();
759
+ if (!DEVICE_TOKEN_PATTERN.test(token)) {
760
+ throw new CliError(
761
+ "invalid-data",
762
+ `${CREDITS_TOKEN_ENV} is set but is not a valid cr_dev_ token.`,
763
+ );
764
+ }
765
+ return { token, source: "env" };
766
+ }
767
+ const stored = await store.readDeviceToken();
768
+ return stored === undefined ? undefined : { token: stored, source: "state" };
769
+ }
770
+
771
+ export function creditsErrorToCli(error: unknown): CliError {
772
+ if (error instanceof CreditsRequestError) {
773
+ const details: Record<string, unknown> = {};
774
+ if (error.shortfall?.required !== undefined) details.required = error.shortfall.required;
775
+ if (error.shortfall?.balance !== undefined) details.balance = error.shortfall.balance;
776
+ if (error.shortfall?.topup !== undefined) details.topup = error.shortfall.topup;
777
+ const topupHint = error.shortfall?.topup === undefined
778
+ ? ""
779
+ : ` Top up at ${error.shortfall.topup.url} or run \`slopcamera credits topup\`.`;
780
+ switch (error.status) {
781
+ case 401:
782
+ case 403:
783
+ return new CliError(
784
+ "authorization-required",
785
+ `The hosted credits credential was rejected. Run \`slopcamera credits status\` to inspect the configured wallet.${topupHint}`,
786
+ Object.keys(details).length === 0 ? undefined : details,
787
+ );
788
+ case 402:
789
+ return new CliError(
790
+ "unavailable",
791
+ `Insufficient hosted credits.${topupHint || " Run `slopcamera credits topup`."}`,
792
+ Object.keys(details).length === 0 ? undefined : details,
793
+ );
794
+ case 404:
795
+ return new CliError("not-found", error.message);
796
+ case 409:
797
+ return new CliError("conflict", error.message);
798
+ case 410:
799
+ return new CliError(
800
+ "cancelled",
801
+ `${error.message} Start a new checkout with \`slopcamera credits topup\`.`,
802
+ );
803
+ case 429:
804
+ return new CliError("unavailable", `Rate limited by the credits service. ${error.message}`);
805
+ default:
806
+ return new CliError(
807
+ "unavailable",
808
+ `Credits request failed (${error.status}): ${error.message}`,
809
+ Object.keys(details).length === 0 ? undefined : details,
810
+ );
811
+ }
812
+ }
813
+ if (error instanceof CliError) return error;
814
+ return new CliError(
815
+ "unavailable",
816
+ `Credits request failed: ${error instanceof Error ? error.message : "unknown error"}`,
817
+ );
818
+ }