@phnx-labs/agents-cli 1.22.62 → 1.22.64

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.
@@ -251,21 +251,70 @@ export default {
251
251
  // segments deep (an unsupported shape outside the CLI) landing on the same
252
252
  // literal revision key.
253
253
  const noRevision = !!request.headers.get('x-share-no-revision');
254
- if (!noRevision) {
255
- const existing = await env.BUCKET.get(path);
256
- if (existing) {
257
- const existingHeaders = new Headers();
258
- if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
259
- const existingContentType = existingHeaders.get('content-type');
260
- const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
261
- await env.BUCKET.put(revKey, existing.body, {
262
- httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
263
- customMetadata: existing.customMetadata || {},
264
- });
254
+ // PHNX-3542: per-user storage quota + object count + per-file size cap +
255
+ // publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
256
+ // WRITE_TOKEN publish writes to the operator's OWN bucket at their own
257
+ // cost, so it skips all four — a deliberate, documented policy, NOT a
258
+ // silent no-op. The current object is needed for BOTH the revision copy and
259
+ // the charge math, so read it once here; a BYO no-revision publish still
260
+ // skips the read entirely (nothing consumes it).
261
+ const needExisting = !noRevision || auth.kind === 'phoenix';
262
+ const existing = needExisting ? await env.BUCKET.get(path) : null;
263
+ // Enforcement measures the REAL request body, never a client-declared size.
264
+ // A spoofed-low content-length must NOT (a) slip an oversized body past the
265
+ // per-file cap, (b) let real bytes exceed the total quota, or — most
266
+ // dangerously — (c) reach the destructive revision-copy + canonical
267
+ // overwrite before the size is known and DESTROY the existing page. So for a
268
+ // Phoenix write we buffer the body bounded by the plan's per-file cap and
269
+ // reject on the REAL size BEFORE any write; only then do we copy the
270
+ // revision and store the buffered bytes. BYO streams unbuffered (uncapped,
271
+ // its own bucket).
272
+ let putBody = request.body;
273
+ if (auth.kind === 'phoenix') {
274
+ const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
275
+ // Fast-reject an HONEST oversized content-length without reading the body.
276
+ // A dishonest (absent or lied-low) length falls through to the bounded
277
+ // read below, which measures the truth.
278
+ const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
279
+ if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
280
+ return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
281
+ }
282
+ // readBodyBounded aborts the moment it passes the cap, so a chunked/
283
+ // streaming body can never buffer more than the cap (+ one chunk).
284
+ const read = await readBodyBounded(request, limits.maxFileBytes);
285
+ if (read.oversize) {
286
+ return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
265
287
  }
288
+ const realBytes = read.size;
289
+ const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
290
+ const newCanonical = !existing;
291
+ // Keeping a revision retains the old canonical bytes AND adds the new
292
+ // ones, so storage grows by the full new size. A no-revision or first
293
+ // publish grows by new minus the bytes it replaces (may be negative on a
294
+ // shrink; the ledger clamps at >= 0).
295
+ const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
296
+ const charged = await chargeShareWrite(env, auth, {
297
+ charge: charge,
298
+ newCanonical: newCanonical,
299
+ fileBytes: realBytes,
300
+ countRate: true,
301
+ });
302
+ if (charged.error) return charged.error; // rejected BEFORE any destructive write
303
+ putBody = read.bytes;
266
304
  }
267
305
 
268
- await env.BUCKET.put(path, request.body, {
306
+ if (!noRevision && existing) {
307
+ const existingHeaders = new Headers();
308
+ if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
309
+ const existingContentType = existingHeaders.get('content-type');
310
+ const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
311
+ await env.BUCKET.put(revKey, existing.body, {
312
+ httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
313
+ customMetadata: existing.customMetadata || {},
314
+ });
315
+ }
316
+
317
+ await env.BUCKET.put(path, putBody, {
269
318
  httpMetadata: { contentType },
270
319
  customMetadata,
271
320
  });
@@ -508,6 +557,22 @@ export default {
508
557
  const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
509
558
  if (expiresAt && Date.now() > Date.parse(expiresAt)) {
510
559
  await env.BUCKET.delete(path);
560
+ // PHNX-3542: expiry is a lazy delete with no auth context. Refund the
561
+ // stamped owner's quota (bytes + object count) best-effort — a refund
562
+ // failure must never break serving the 410. A canonical page is 2
563
+ // segments; covers are excluded (never charged) so refund is safe here
564
+ // because only canonical pages ever carry an expires-at.
565
+ const expiredOwner = obj.customMetadata && obj.customMetadata['owner'];
566
+ if (expiredOwner && !path.endsWith('.png')) {
567
+ try {
568
+ await refundShareWrite(env, expiredOwner, {
569
+ refund: typeof obj.size === 'number' ? obj.size : 0,
570
+ freeCanonical: path.split('/').filter(Boolean).length === 2,
571
+ });
572
+ } catch (e) {
573
+ // best-effort: serving the expiry 410 must never depend on the refund
574
+ }
575
+ }
511
576
  return new Response('gone — this link has expired', { status: 410, headers: { 'content-type': 'text/plain' } });
512
577
  }
513
578
  // Resolve the viewer ONCE — needed both to gate me/org reads AND to decide
@@ -615,7 +680,24 @@ export default {
615
680
  return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
616
681
  }
617
682
  }
683
+ // PHNX-3542: capture the object's size BEFORE deleting so a managed
684
+ // Phoenix owner's quota can be refunded. A canonical page (2 segments, not
685
+ // a .png cover) refunds both bytes and the object count; a retained
686
+ // revision (3+ segments) refunds bytes only; a server-generated .png cover
687
+ // was never charged, so it is skipped entirely.
688
+ let doomed = null;
689
+ const isCover = path.endsWith('.png');
690
+ if (auth.kind === 'phoenix' && !isCover && !firstSeg.startsWith('__')) {
691
+ doomed = typeof env.BUCKET.head === 'function' ? await env.BUCKET.head(path) : await env.BUCKET.get(path);
692
+ }
618
693
  await env.BUCKET.delete(path);
694
+ if (doomed) {
695
+ const delSegs = path.split('/').filter(Boolean);
696
+ await refundShareWrite(env, auth.owner, {
697
+ refund: typeof doomed.size === 'number' ? doomed.size : 0,
698
+ freeCanonical: delSegs.length === 2,
699
+ });
700
+ }
619
701
  return json({ ok: true, deleted: path }, 200);
620
702
  }
621
703
 
@@ -819,6 +901,23 @@ var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotma
819
901
  var SHARE_COOKIE = '__Host-phoenix_share';
820
902
  var SHARE_COOKIE_MAX_AGE = 604800;
821
903
 
904
+ // PHNX-3542 — per-user storage limits for the MANAGED share endpoint. The plan
905
+ // map is the seam for future paid tiers: only 'free' is defined today, and a
906
+ // ledger with no plan (or an unknown one) resolves to it via planLimits(). Paid
907
+ // tiers and the write path that sets a user's plan arrive with billing (follow-up
908
+ // PHNX-3569); this is a legitimately-deferred seam, not a stub — 'free' IS
909
+ // enforced. Covers/views are server-generated overhead and excluded from the
910
+ // quota, which counts canonical pages + their retained revisions only.
911
+ var MiB = 1024 * 1024;
912
+ var SHARE_PLANS = {
913
+ free: { maxBytes: 200 * MiB, maxObjects: 150, maxFileBytes: 20 * MiB, ratePerHour: 60 },
914
+ };
915
+ var DEFAULT_SHARE_PLAN = 'free';
916
+ var RATE_WINDOW_MS = 3600 * 1000; // fixed 1h publish-rate window
917
+ function planLimits(plan) { return SHARE_PLANS[plan] || SHARE_PLANS[DEFAULT_SHARE_PLAN]; }
918
+ function freshUsage() { return { bytes: 0, count: 0, plan: DEFAULT_SHARE_PLAN, rlStart: 0, rlUsed: 0 }; }
919
+ function usageNumber(v, fallback) { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback; }
920
+
822
921
  // Everything in customMetadata that ISN'T one of the reserved provenance/label
823
922
  // keys above — i.e. the caller's own \`--meta key=value\` entries. Surfaced on
824
923
  // every read route (listing, revisions) so a value stored with \`--meta
@@ -1313,6 +1412,163 @@ async function writeViews(env, path, count) {
1313
1412
  }
1314
1413
  }
1315
1414
 
1415
+ // --- PHNX-3542 per-user usage ledger (R2 conditional-put CAS) ---------------
1416
+ // The ledger is a single R2 object at __usage/<owner> holding the running
1417
+ // { bytes, count, plan, rlStart, rlUsed } for one user. It mirrors the __views /
1418
+ // __handles precedent: a __-prefixed key, GET-blocked and outside every gallery/
1419
+ // listing/revision prefix. R2 has no atomic increment, so mutations use a
1420
+ // read → mutate → conditional-put loop (onlyIf.etagMatches), the same primitive
1421
+ // the PATCH metadata-edit path already relies on.
1422
+ function usageKey(owner) { return '__usage/' + sanitizeNamespace(owner); }
1423
+
1424
+ async function readUsage(env, owner) {
1425
+ const obj = await env.BUCKET.get(usageKey(owner));
1426
+ if (!obj) return { etag: null, usage: freshUsage() };
1427
+ let usage = freshUsage();
1428
+ try {
1429
+ const raw = typeof obj.text === 'function' ? await obj.text() : '';
1430
+ const parsed = JSON.parse(raw);
1431
+ if (parsed && typeof parsed === 'object') {
1432
+ usage = {
1433
+ bytes: usageNumber(parsed.bytes, 0),
1434
+ count: usageNumber(parsed.count, 0),
1435
+ plan: typeof parsed.plan === 'string' ? parsed.plan : DEFAULT_SHARE_PLAN,
1436
+ rlStart: usageNumber(parsed.rlStart, 0),
1437
+ rlUsed: usageNumber(parsed.rlUsed, 0),
1438
+ };
1439
+ }
1440
+ } catch (e) {
1441
+ // Malformed ledger — treat as fresh zero but KEEP the etag so the next CAS
1442
+ // put overwrites the corrupt object rather than looping against it forever.
1443
+ }
1444
+ return { etag: obj.etag || null, usage: usage };
1445
+ }
1446
+
1447
+ async function writeUsageCas(env, owner, prevEtag, usage) {
1448
+ const body = JSON.stringify(usage);
1449
+ const opts = { httpMetadata: { contentType: 'application/json' } };
1450
+ if (prevEtag) {
1451
+ // Existing object: conditional put. R2 returns null when the etag no longer
1452
+ // matches (a concurrent writer won the race) → report failure so the caller
1453
+ // re-reads and retries.
1454
+ opts.onlyIf = { etagMatches: prevEtag };
1455
+ const res = await env.BUCKET.put(usageKey(owner), body, opts);
1456
+ return res !== null;
1457
+ }
1458
+ // Fresh key: plain create. Neither R2 nor the test harness exposes a
1459
+ // conditional-create predicate, so the only race is two simultaneous
1460
+ // first-creates of the same owner's ledger — a benign, one-time bounded loss.
1461
+ await env.BUCKET.put(usageKey(owner), body, opts);
1462
+ return true;
1463
+ }
1464
+
1465
+ // CAS retry loop. fn(usage) returns { reject: Response } to fail loud without
1466
+ // committing, or { commit: usage, result } to persist and return result. On CAS
1467
+ // contention it re-reads and retries; exhausting the retries fails loud with 503.
1468
+ async function withUsage(env, owner, fn) {
1469
+ for (let attempt = 0; attempt < 6; attempt++) {
1470
+ const state = await readUsage(env, owner);
1471
+ const outcome = fn(state.usage);
1472
+ if (outcome.reject) return outcome.reject;
1473
+ const ok = await writeUsageCas(env, owner, state.etag, outcome.commit);
1474
+ if (ok) return outcome.result;
1475
+ }
1476
+ return json({ error: 'usage ledger contended, retry' }, 503);
1477
+ }
1478
+
1479
+ function rateLimited(retryAfterSec, ratePerHour) {
1480
+ return new Response(
1481
+ JSON.stringify({ error: 'rate limit: too many publishes, retry later', retryAfterSec: retryAfterSec, ratePerHour: ratePerHour }),
1482
+ { status: 429, headers: { 'content-type': 'application/json', 'retry-after': String(retryAfterSec) } },
1483
+ );
1484
+ }
1485
+
1486
+ // Charge one authed PUT against the owner's ledger. Rate → per-file cap → object
1487
+ // count → byte quota, each failing loud (429 / 413) before anything is written.
1488
+ // Returns { error: Response } on any rejection, else { limits } (the resolved
1489
+ // plan limits, reused by the post-write real-size reconcile). Managed only: the
1490
+ // caller guards on auth.kind === 'phoenix'.
1491
+ async function chargeShareWrite(env, auth, params) {
1492
+ const now = Date.now();
1493
+ const out = await withUsage(env, auth.owner, function (usage) {
1494
+ const limits = planLimits(usage.plan);
1495
+ // Rate limit — only a user-initiated page PUT counts (countRate). A fixed 1h
1496
+ // window: reset when the window has rolled over, otherwise reject at the cap.
1497
+ if (params.countRate) {
1498
+ if (now - usage.rlStart >= RATE_WINDOW_MS) { usage.rlStart = now; usage.rlUsed = 0; }
1499
+ if (usage.rlUsed >= limits.ratePerHour) {
1500
+ const retryAfterSec = Math.max(1, Math.ceil((usage.rlStart + RATE_WINDOW_MS - now) / 1000));
1501
+ return { reject: rateLimited(retryAfterSec, limits.ratePerHour) };
1502
+ }
1503
+ usage.rlUsed += 1;
1504
+ }
1505
+ // Per-file size cap.
1506
+ if (typeof params.fileBytes === 'number' && params.fileBytes > limits.maxFileBytes) {
1507
+ return { reject: json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: params.fileBytes }, 413) };
1508
+ }
1509
+ // Object (canonical page) count.
1510
+ if (params.newCanonical) {
1511
+ if (usage.count + 1 > limits.maxObjects) {
1512
+ return { reject: json({ error: 'artifact limit reached', maxObjects: limits.maxObjects }, 413) };
1513
+ }
1514
+ usage.count += 1;
1515
+ }
1516
+ // Total byte quota. charge may be negative on a shrink — clamp at >= 0.
1517
+ if (usage.bytes + params.charge > limits.maxBytes) {
1518
+ return { reject: json({ error: 'storage limit reached', maxBytes: limits.maxBytes, usedBytes: usage.bytes }, 413) };
1519
+ }
1520
+ usage.bytes = Math.max(0, usage.bytes + params.charge);
1521
+ return { commit: usage, result: { limits: limits } };
1522
+ });
1523
+ if (out instanceof Response) return { error: out };
1524
+ return { limits: out.limits };
1525
+ }
1526
+
1527
+ // Read a request body fully into memory, BOUNDED: abort the moment it exceeds
1528
+ // maxBytes, so a chunked/streaming body can never buffer more than the cap (plus
1529
+ // one in-flight chunk) and OOM the Worker. Returns { oversize: true, size } once
1530
+ // the cap is passed (size is a lower bound, >= cap), else { bytes, size } with
1531
+ // the exact bytes. A body-less request yields an empty buffer. This is what lets
1532
+ // enforcement key on the REAL size instead of a spoofable declared header.
1533
+ async function readBodyBounded(request, maxBytes) {
1534
+ if (!request.body || typeof request.body.getReader !== 'function') {
1535
+ const buf = await request.arrayBuffer();
1536
+ const bytes = new Uint8Array(buf);
1537
+ if (bytes.byteLength > maxBytes) return { oversize: true, size: bytes.byteLength };
1538
+ return { bytes: bytes, size: bytes.byteLength };
1539
+ }
1540
+ const reader = request.body.getReader();
1541
+ const chunks = [];
1542
+ let size = 0;
1543
+ while (true) {
1544
+ const step = await reader.read();
1545
+ if (step.done) break;
1546
+ const chunk = step.value;
1547
+ size += chunk.byteLength;
1548
+ if (size > maxBytes) {
1549
+ try { await reader.cancel(); } catch (e) { /* best-effort */ }
1550
+ return { oversize: true, size: size };
1551
+ }
1552
+ chunks.push(chunk);
1553
+ }
1554
+ const out = new Uint8Array(size);
1555
+ let offset = 0;
1556
+ for (let i = 0; i < chunks.length; i++) { out.set(chunks[i], offset); offset += chunks[i].byteLength; }
1557
+ return { bytes: out, size: size };
1558
+ }
1559
+
1560
+ // Refund on DELETE / expiry. Never rejects, never creates a ledger: if the owner
1561
+ // was never charged (no __usage object) it is a pure no-op.
1562
+ async function refundShareWrite(env, owner, params) {
1563
+ for (let attempt = 0; attempt < 6; attempt++) {
1564
+ const state = await readUsage(env, owner);
1565
+ if (!state.etag) return; // no ledger — owner never charged, nothing to refund
1566
+ state.usage.bytes = Math.max(0, state.usage.bytes - (params.refund || 0));
1567
+ if (params.freeCanonical) state.usage.count = Math.max(0, state.usage.count - 1);
1568
+ if (await writeUsageCas(env, owner, state.etag, state.usage)) return;
1569
+ }
1570
+ }
1571
+
1316
1572
  function viewerMayRead(visibility, meta, identity) {
1317
1573
  if (visibility === 'me') {
1318
1574
  const owner = meta && meta.owner;
@@ -929,7 +929,9 @@ export interface Meta {
929
929
  /**
930
930
  * `agents feed post` fan-out. `broadcast` maps a sink name to either an argv
931
931
  * template (`command:`, run for each post) or an in-process channel delivery
932
- * (`channel:`, the same registry `agents send`/`agents notify` use), so
932
+ * (`channel:`, the same registry `agents send`/`agents notify` use). Channel
933
+ * sinks may set `message:` with feed placeholders; a missing placeholder
934
+ * skips that sink, so `{ticket}` cleanly gates a tracker-specific channel. Thus
933
935
  * mirroring to a tracker, a messaging CLI, or a channel provider is the
934
936
  * operator's config rather than an integration compiled into this CLI. When
935
937
  * this is unset/empty, an important-level post falls back to `notify.owner`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.62",
3
+ "version": "1.22.64",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",