drops-mcp 0.1.4 → 0.2.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.
Files changed (3) hide show
  1. package/drop.mjs +267 -14
  2. package/mcp.mjs +47 -0
  3. package/package.json +1 -1
package/drop.mjs CHANGED
@@ -111,6 +111,27 @@ function genPassword() {
111
111
  return `${ADJ[buf[0] % ADJ.length]}-${NOUN[buf[1] % NOUN.length]}-${10 + (buf[2] % 90)}`;
112
112
  }
113
113
 
114
+ function luhn(s) {
115
+ let sum = 0, alt = false;
116
+ for (let i = s.length - 1; i >= 0; i--) { let n = +s[i]; if (alt) { n *= 2; if (n > 9) n -= 9; } sum += n; alt = !alt; }
117
+ return s.length >= 13 && sum % 10 === 0;
118
+ }
119
+ // Client-side secret/PII scan run BEFORE encryption+upload. High-signal patterns only
120
+ // (the real risk for AI artifacts is an embedded credential); emails warn only in bulk.
121
+ function scanPII(text) {
122
+ const hits = [];
123
+ const add = (label, re) => { if (re.test(text)) hits.push(label); };
124
+ add("AWS access key", /\bAKIA[0-9A-Z]{16}\b/);
125
+ add("private key block", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/);
126
+ add("API token/secret", /\b(?:sk-[A-Za-z0-9]{20,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|vercel_blob_rw_[A-Za-z0-9_]{20,})\b/);
127
+ add("JWT", /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/);
128
+ const cc = (text.match(/\b(?:\d[ -]?){13,16}\b/g) || []).map((c) => c.replace(/[ -]/g, ""));
129
+ if (cc.some(luhn)) hits.push("possible card number");
130
+ const emails = [...new Set(text.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g) || [])];
131
+ if (emails.length >= 5) hits.push(`${emails.length} email addresses`);
132
+ return [...new Set(hits)];
133
+ }
134
+
114
135
  async function dataUri(path) {
115
136
  const ext = extname(path).toLowerCase();
116
137
  const mime = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png"
@@ -299,16 +320,33 @@ async function upload(key, body, contentType, token) {
299
320
 
300
321
  // Managed (zero-setup) publish: POST the already-branded/encrypted bytes to the host's
301
322
  // managed endpoint. No token, no Vercel setup — the server assigns a `u/` slug.
302
- async function managedPublish(body, contentType) {
323
+ async function managedPublish(body, contentType, { burn } = {}) {
303
324
  const endpoint = `${ORIGIN}/api/publish`;
325
+ const headers = { "content-type": "application/octet-stream", "x-drop-content-type": contentType };
326
+ if (burn) headers["x-drop-burn"] = "1";
304
327
  let r;
305
328
  try {
306
- r = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/octet-stream", "x-drop-content-type": contentType }, body });
329
+ r = await fetch(endpoint, { method: "POST", headers, body });
307
330
  } catch (e) { die(`managed publish failed: ${e.message}`); }
308
331
  if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(`managed publish failed: ${m || r.status}`); }
309
332
  return r.json();
310
333
  }
311
334
 
335
+ // Hosted (logged-in free tier) publish: same client-side branded/encrypted bytes, but
336
+ // authenticated with the API key so the drop lands persistently at <handle>/<slug>.
337
+ async function hostedPublish(body, contentType, slug, locked, emailGate) {
338
+ const key = getApiKey();
339
+ const headers = { "content-type": "application/octet-stream", "x-drop-content-type": contentType, authorization: `Bearer ${key}` };
340
+ if (slug) headers["x-drop-slug"] = slug;
341
+ if (locked) headers["x-drop-locked"] = "1";
342
+ if (emailGate) headers["x-drop-email-gate"] = String(emailGate).replace(/^@/, "").toLowerCase();
343
+ let r;
344
+ try { r = await fetch(`${ORIGIN}/api/publish`, { method: "POST", headers, body }); }
345
+ catch (e) { die(`hosted publish failed: ${e.message}`); }
346
+ if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(`hosted publish failed: ${m || r.status}`); }
347
+ return r.json();
348
+ }
349
+
312
350
  // Import an npm dep, auto-installing into the skill dir on first use (gitignored).
313
351
  async function ensureDep(name) {
314
352
  try { return await import(name); }
@@ -324,8 +362,13 @@ async function ensureDep(name) {
324
362
  async function cmdDrop(file, opts) {
325
363
  if (!file) die("usage: drop <file> [-p password] [-s slug] [--page] [--no-lock]");
326
364
  if (!existsSync(file)) die(`no such file: ${file}`);
365
+
366
+ // Tier selection: explicit --managed wins; then self-host if a Blob token is set
367
+ // (you brought your own infra); then hosted if you're logged in; else nudge.
327
368
  const token = opts.managed ? null : getToken();
328
- if (!opts.managed && !token) die("BLOB_READ_WRITE_TOKEN not set (env or ~/.drop/.env). Run 'drop setup' first — or use --managed for the zero-setup tier.");
369
+ const apiKey = getApiKey();
370
+ const mode = opts.managed ? "managed" : token ? "selfhost" : apiKey ? "hosted" : null;
371
+ if (!mode) die("not set up yet.\n Free hosted: drop login\n Self-host: drop setup (needs a Vercel Blob token)\n One-off 24h: drop <file> --managed");
329
372
 
330
373
  const ext = extname(file).toLowerCase();
331
374
  const isMd = ext === ".md" || ext === ".markdown";
@@ -337,7 +380,7 @@ async function cmdDrop(file, opts) {
337
380
  const expiresAt = parseExpiry(opts.expire);
338
381
  if (opts.expire && !expiresAt) die(`bad --expire value: ${opts.expire} (use 7d, 24h, 30m, 2w, or a date)`);
339
382
 
340
- if (opts.managed && (opts.page || !isHtml)) die("--managed supports HTML/markdown only. Self-host ('drop deploy') for files, --page, and zip sites.");
383
+ if ((mode === "managed" || mode === "hosted") && (opts.page || !isHtml)) die(`the ${mode} tier supports HTML/markdown only. Self-host ('drop deploy') for files, --page, and zip sites.`);
341
384
 
342
385
  const manifest = await loadManifest();
343
386
  const now = new Date().toISOString();
@@ -383,6 +426,18 @@ async function cmdDrop(file, opts) {
383
426
  const title = opts.title || basename(file, ext).replace(/[-_]+/g, " ");
384
427
  let html = await readFile(file, "utf8");
385
428
  if (isMd) html = await mdToHtml(html, title);
429
+
430
+ // PII / secret scan on the RAW content (before brand + encrypt). Warn by default
431
+ // (agent-friendly); --block-pii refuses; --no-pii-check silences. Skipped for
432
+ // password-locked drops only when explicitly told to (locking already protects them).
433
+ if (!opts.noPiiCheck) {
434
+ const pii = scanPII(html);
435
+ if (pii.length) {
436
+ const msg = `possible sensitive data in ${basename(file)}: ${pii.join(", ")}`;
437
+ if (opts.blockPii) die(`⚠ ${msg}\n refusing to publish (--block-pii). Remove it, or drop --no-pii-check to publish anyway.`);
438
+ if (!JSON_OUT) console.error(`\x1b[33m⚠ ${msg}\x1b[0m\n \x1b[2mpublishing anyway — use --block-pii to refuse, --no-pii-check to silence${opts.noLock ? ", and note this drop is PUBLIC (unlocked)" : ""}\x1b[0m`);
439
+ }
440
+ }
386
441
  html = await brandHtml(html, { title, url, logoUri, ogImage, expiresAt });
387
442
 
388
443
  let password = null, body = html;
@@ -394,11 +449,18 @@ async function cmdDrop(file, opts) {
394
449
  body = await encrypt(inPath, { password, title, logoUri, faviconUri, ogImage, url });
395
450
  await rm(tmp, { recursive: true, force: true });
396
451
  }
397
- if (opts.managed) {
398
- const m = await managedPublish(body, "text/html; charset=utf-8");
399
- manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, managed: true, createdAt: now });
452
+ if (mode === "managed") {
453
+ const m = await managedPublish(body, "text/html; charset=utf-8", { burn: opts.burn });
454
+ manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, managed: true, burn: !!opts.burn, createdAt: now });
455
+ await saveManifest(manifest);
456
+ report({ url: m.url, password, locked: !opts.noLock, extra: opts.burn ? "managed · burns on first view" : "managed · auto-expires in 24h" });
457
+ return;
458
+ }
459
+ if (mode === "hosted") {
460
+ const m = await hostedPublish(body, "text/html; charset=utf-8", slug, !opts.noLock, opts.emailGate);
461
+ manifest.unshift({ slug: m.slug, url: m.url, type: "html", locked: !opts.noLock, password, file: basename(file), size: stat, hosted: true, handle: m.handle, createdAt: now });
400
462
  await saveManifest(manifest);
401
- report({ url: m.url, password, locked: !opts.noLock, extra: "managed · auto-expires in 24h" });
463
+ report({ url: m.url, password, locked: !opts.noLock, extra: `hosted · ${m.handle}/${m.slug}${opts.emailGate ? ` · @${String(opts.emailGate).replace(/^@/, "")} only` : ""}` });
402
464
  return;
403
465
  }
404
466
  const res = await upload(slug, body, "text/html; charset=utf-8", token);
@@ -469,9 +531,38 @@ function report({ url, password, locked, extra }) {
469
531
  console.log("");
470
532
  }
471
533
 
534
+ // Logged-in hosted account → list/remove via the API (not the BYO Blob store).
535
+ const useHosted = () => getApiKey() && !getToken();
536
+
537
+ async function hostedList() {
538
+ const key = getApiKey();
539
+ let r; try { r = await fetch(`${ORIGIN}/api/sites`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
540
+ if (!r.ok) die("could not list drops (key invalid? run `drop login`)");
541
+ const { sites } = await r.json();
542
+ const local = await loadManifest();
543
+ const pw = new Map(); for (const d of local) if (d.password) pw.set(d.slug, d.password);
544
+ if (JSON_OUT) { console.log(JSON.stringify(sites.map((s) => ({ ...s, password: pw.get(s.slug) || null })))); return; }
545
+ if (!sites.length) return console.log("no drops yet.");
546
+ for (const s of sites) {
547
+ const known = pw.get(s.slug);
548
+ const lock = known ? `🔒 ${known}` : (s.locked ? "🔒 locked" : "public");
549
+ console.log(`${s.url}\n ${humanSize(s.size)} · ${lock} · ${String(s.created_at).slice(0, 10)}`);
550
+ }
551
+ }
552
+
553
+ async function hostedRm(slug) {
554
+ const key = getApiKey();
555
+ let r; try { r = await fetch(`${ORIGIN}/api/sites?slug=${encodeURIComponent(slug)}`, { method: "DELETE", headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
556
+ if (!r.ok) { let m; try { m = (await r.json()).error; } catch {} die(m || "remove failed"); }
557
+ const m = await loadManifest(); const kept = m.filter((d) => d.slug !== slug); if (kept.length !== m.length) await saveManifest(kept);
558
+ if (JSON_OUT) { console.log(JSON.stringify({ removed: slug })); return; }
559
+ ok(`removed ${slug}`);
560
+ }
561
+
472
562
  async function cmdList() {
563
+ if (useHosted()) return hostedList();
473
564
  const token = getToken();
474
- if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
565
+ if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
475
566
  const { list } = await loadBlob();
476
567
  const { blobs } = await list({ token });
477
568
  // overlay local passwords (kept on this machine only) keyed by pathname/slug
@@ -498,8 +589,9 @@ async function cmdList() {
498
589
 
499
590
  async function cmdRm(slug) {
500
591
  if (!slug) die("usage: drop rm <slug>");
592
+ if (useHosted()) return hostedRm(slug);
501
593
  const token = getToken();
502
- if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup");
594
+ if (!token) die("BLOB_READ_WRITE_TOKEN not set. Run: drop setup (self-host) or drop login (hosted)");
503
595
  const { list, del } = await loadBlob();
504
596
  const { blobs } = await list({ token });
505
597
  // match the page/file key (slug), a sibling like <slug>.<ext>, or a whole site under <slug>/
@@ -572,9 +664,13 @@ async function cmdDeploy(opts) {
572
664
  await del(probe.url, { token }).catch(() => {});
573
665
  ok(`blob host: ${blobHost}`);
574
666
 
575
- // 3. patch middleware.js (BLOB const) + vercel.json (fallback rewrite) idempotently
667
+ // 3. patch middleware.js fallback host + vercel.json (fallback rewrite) idempotently.
668
+ // (Middleware derives the host from BLOB_READ_WRITE_TOKEN at runtime, so this only
669
+ // pins the fallback for explicit BYO-host setups.)
576
670
  let mw = await readFile(mwPath, "utf8");
577
- const newMw = mw.replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
671
+ const newMw = mw
672
+ .replace(/const DEFAULT_BLOB_HOST = "[^"]+";/, `const DEFAULT_BLOB_HOST = "${blobHost}";`)
673
+ .replace(/const BLOB = "https:\/\/[^"]+";/, `const BLOB = "https://${blobHost}";`);
578
674
  let vj = await readFile(vjPath, "utf8");
579
675
  const newVj = vj.replace(/https:\/\/[a-z0-9]+\.public\.blob\.vercel-storage\.com/g, `https://${blobHost}`);
580
676
  const mwChanged = newMw !== mw, vjChanged = newVj !== vj;
@@ -698,6 +794,134 @@ async function pullTokenFromVercel() {
698
794
  finally { await rm(tmp, { recursive: true, force: true }); }
699
795
  }
700
796
 
797
+ // ---------- hosted accounts (drop login / whoami / logout) ----------
798
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
799
+ function getApiKey() {
800
+ if (process.env.DROP_API_KEY) return process.env.DROP_API_KEY;
801
+ try { return JSON.parse(readFileSync(CONFIG_FILE, "utf8")).apiKey || null; } catch { return null; }
802
+ }
803
+ async function saveApiKey(key) {
804
+ let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
805
+ cfg.apiKey = key;
806
+ await mkdir(DROP_HOME, { recursive: true });
807
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
808
+ }
809
+ function openBrowser(url) {
810
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
811
+ try { spawnSync(cmd, [url], { stdio: "ignore", shell: process.platform === "win32" }); } catch {}
812
+ }
813
+ async function cmdLogin(opts) {
814
+ if (opts.token && opts.token.startsWith("drop_")) { await saveApiKey(opts.token); ok("API key saved"); return cmdWhoami(); }
815
+ let start;
816
+ try { start = await (await fetch(`${ORIGIN}/api/auth/device/start`, { method: "POST" })).json(); }
817
+ catch (e) { die(`could not reach ${ORIGIN}: ${e.message}`); }
818
+ if (!start?.code) die("login unavailable (hosted tier not configured yet)");
819
+ console.log(`\n opening ${start.verify_url}`);
820
+ console.log(` \x1b[2mif it doesn't open, paste that URL into your browser\x1b[0m\n`);
821
+ openBrowser(start.verify_url);
822
+ const deadline = Date.now() + 5 * 60 * 1000;
823
+ process.stdout.write(" waiting for you to approve");
824
+ while (Date.now() < deadline) {
825
+ await sleep((start.poll_interval || 2) * 1000);
826
+ process.stdout.write(".");
827
+ let p; try { p = await (await fetch(`${ORIGIN}/api/auth/device/poll?code=${start.code}`)).json(); } catch { continue; }
828
+ if (p.status === "approved" && p.api_key) { console.log(""); await saveApiKey(p.api_key); ok(`logged in — key saved to ${CONFIG_FILE}`); return cmdWhoami(); }
829
+ if (p.status === "denied") { console.log(""); die("authorization denied"); }
830
+ if (p.status === "expired") { console.log(""); die("code expired — run `drop login` again"); }
831
+ }
832
+ console.log(""); die("login timed out");
833
+ }
834
+ async function cmdWhoami() {
835
+ const key = getApiKey(); if (!key) die("not logged in. Run: drop login");
836
+ let r; try { r = await fetch(`${ORIGIN}/api/me`, { headers: { authorization: `Bearer ${key}` } }); } catch (e) { die(e.message); }
837
+ if (!r.ok) die("not authenticated (key invalid or revoked). Run: drop login");
838
+ const d = await r.json();
839
+ if (JSON_OUT) { console.log(JSON.stringify(d)); return; }
840
+ ok(`${d.email || "logged in"}${d.handle ? ` · handle: ${d.handle}` : ""}`);
841
+ }
842
+ async function cmdLogout() {
843
+ let cfg = {}; try { cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8")); } catch {}
844
+ delete cfg.apiKey;
845
+ await mkdir(DROP_HOME, { recursive: true });
846
+ await writeFile(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
847
+ ok("logged out");
848
+ }
849
+
850
+ // ---------- hosted-drop management (Phase 2: set-* / claim / share / migrate) ----------
851
+ // Thin authenticated calls against the hosted API. Each needs `drop login` first.
852
+ async function apiCall(path, init = {}) {
853
+ const key = getApiKey(); if (!key) die("not logged in — run: drop login");
854
+ let r; try { r = await fetch(`${ORIGIN}${path}`, { ...init, headers: { authorization: `Bearer ${key}`, ...(init.headers || {}) } }); }
855
+ catch (e) { die(`request failed: ${e.message}`); }
856
+ let d = {}; try { d = await r.json(); } catch {}
857
+ if (!r.ok) die(d.error || `request failed (${r.status})`);
858
+ return d;
859
+ }
860
+ const patchSite = (slug, patch) => apiCall(`/api/sites?slug=${encodeURIComponent(slug)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(patch) });
861
+
862
+ async function cmdSetExpiry(slug, when) {
863
+ if (!slug || !when) die("usage: drop set-expiry <slug> <7d|24h|2w|date|off>");
864
+ const iso = when === "off" ? null : (() => { const at = parseExpiry(when); if (!at) die(`bad expiry: ${when}`); return new Date(at).toISOString(); })();
865
+ await patchSite(slug, { expires_at: iso });
866
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, expires_at: iso }));
867
+ ok(iso ? `expiry set on ${slug} → ${iso.slice(0, 16).replace("T", " ")}` : `expiry cleared on ${slug}`);
868
+ }
869
+ async function cmdSetEmailGate(slug, domain) {
870
+ if (!slug) die("usage: drop set-email-gate <slug> <domain|off> (e.g. acme.com)");
871
+ const val = !domain || domain === "off" ? null : domain.replace(/^@/, "").toLowerCase();
872
+ await patchSite(slug, { email_gate: val });
873
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, email_gate: val }));
874
+ ok(val ? `email-gate on ${slug} → only @${val} may open it` : `email-gate cleared on ${slug}`);
875
+ }
876
+ async function cmdSetFeedback(slug, state) {
877
+ if (!slug) die("usage: drop set-feedback <slug> [on|off]");
878
+ const on = state !== "off";
879
+ await patchSite(slug, { feedback: on });
880
+ if (JSON_OUT) return console.log(JSON.stringify({ slug, feedback: on }));
881
+ ok(`feedback ${on ? "enabled" : "disabled"} on ${slug}`);
882
+ }
883
+ async function cmdSetPassword(slug, file) {
884
+ // Zero-knowledge: changing the password re-encrypts, which needs the source again.
885
+ if (!slug || !file) die("usage: drop set-password <slug> <file.html> [-p <password>]\n (zero-knowledge: a password change re-encrypts, so the source file is required)");
886
+ if (!existsSync(file)) die(`no such file: ${file}`);
887
+ return cmdDrop(file, { ...opts, slug, noLock: false, password: opts.password || genPassword() });
888
+ }
889
+ async function cmdClaim(target) {
890
+ if (!target) die("usage: drop claim <url|slug> (move an anonymous /u/ drop into your account)");
891
+ if (!getApiKey()) die("not logged in — run: drop login");
892
+ const url = /^https?:/.test(target) ? target : `${ORIGIN}/u/${target.replace(/^u\//, "")}`;
893
+ let body; try { const r = await fetch(url); if (!r.ok) die(`could not fetch ${url} (${r.status})`); body = Buffer.from(await r.arrayBuffer()); }
894
+ catch (e) { die(`could not fetch ${url}: ${e.message}`); }
895
+ const slug = opts.slug || ("claimed-" + rand(4));
896
+ const m = await hostedPublish(body, "text/html; charset=utf-8", slug, false);
897
+ if (JSON_OUT) return console.log(JSON.stringify(m));
898
+ report({ url: m.url, password: null, locked: false, extra: `claimed → ${m.handle}/${m.slug}` });
899
+ }
900
+ async function cmdShare(slug) {
901
+ if (!slug) die("usage: drop share <slug> [--revoke]");
902
+ const d = await apiCall(`/api/share`, { method: opts.revoke ? "DELETE" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ slug }) });
903
+ if (JSON_OUT) return console.log(JSON.stringify(d));
904
+ if (opts.revoke) return ok(`share token revoked for ${slug}`);
905
+ ok(`revocable share link → ${d.url}\n revoke anytime: drop share ${slug} --revoke`);
906
+ }
907
+ async function cmdMigrate() {
908
+ // Re-home your hosted drops onto your own Vercel Blob, then rewrite config to self-host.
909
+ const sites = (await apiCall("/api/sites")).sites || [];
910
+ const token = getToken();
911
+ if (opts.dryRun || !token) {
912
+ const tgt = token ? "your Vercel Blob" : "(no blob token yet — run `drop setup` first)";
913
+ if (JSON_OUT) return console.log(JSON.stringify({ dryRun: true, count: sites.length, target: tgt }));
914
+ return console.log(`drop migrate (dry-run): would re-upload ${sites.length} hosted drop(s) to ${tgt}, then rewrite ~/.drop/config.json to self-host on your own domain.`);
915
+ }
916
+ let n = 0;
917
+ for (const s of sites) {
918
+ try { const body = Buffer.from(await (await fetch(s.url)).arrayBuffer()); await upload(`${s.slug}`, body, s.content_type || "text/html; charset=utf-8", token); n++; }
919
+ catch (e) { console.error(` skip ${s.slug}: ${e.message}`); }
920
+ }
921
+ if (JSON_OUT) return console.log(JSON.stringify({ migrated: n, of: sites.length }));
922
+ ok(`migrated ${n}/${sites.length} drops to your own blob. Run 'drop deploy' to finish self-hosting.`);
923
+ }
924
+
701
925
  // ---------- arg parsing ----------
702
926
  const { values, positionals } = parseArgs({
703
927
  allowPositionals: true,
@@ -717,12 +941,18 @@ const { values, positionals } = parseArgs({
717
941
  "dry-run": { type: "boolean" },
718
942
  "no-deploy": { type: "boolean" },
719
943
  managed: { type: "boolean" },
944
+ burn: { type: "boolean" },
945
+ "email-gate": { type: "string" },
946
+ revoke: { type: "boolean" },
947
+ "no-pii-check": { type: "boolean" },
948
+ "block-pii": { type: "boolean" },
949
+ help: { type: "boolean", short: "h" },
720
950
  },
721
951
  });
722
952
  const JSON_OUT = !!values.json;
723
953
 
724
954
  const [cmd, ...rest] = positionals;
725
- const opts = { password: values.password, slug: values.slug, title: values.title, page: values.page, noLock: values["no-lock"], token: values.token, domain: values.domain, blobHost: values["blob-host"], project: values.project, org: values.org, expire: values.expire, dryRun: values["dry-run"], noDeploy: values["no-deploy"], managed: values.managed };
955
+ const opts = { password: values.password, slug: values.slug, title: values.title, page: values.page, noLock: values["no-lock"], token: values.token, domain: values.domain, blobHost: values["blob-host"], project: values.project, org: values.org, expire: values.expire, dryRun: values["dry-run"], noDeploy: values["no-deploy"], managed: values.managed, burn: values.burn, emailGate: values["email-gate"], revoke: values.revoke, noPiiCheck: values["no-pii-check"], blockPii: values["block-pii"] };
726
956
 
727
957
  try {
728
958
  if (cmd === "init") await cmdInit(opts);
@@ -731,7 +961,17 @@ try {
731
961
  else if (cmd === "rm") await cmdRm(rest[0]);
732
962
  else if (cmd === "gc") await cmdGc();
733
963
  else if (cmd === "deploy") await cmdDeploy(opts);
734
- else if (cmd === "help" || cmd === "--help" || !cmd) {
964
+ else if (cmd === "login") await cmdLogin(opts);
965
+ else if (cmd === "logout") await cmdLogout();
966
+ else if (cmd === "whoami") await cmdWhoami();
967
+ else if (cmd === "claim") await cmdClaim(rest[0]);
968
+ else if (cmd === "share") await cmdShare(rest[0]);
969
+ else if (cmd === "migrate") await cmdMigrate();
970
+ else if (cmd === "set-expiry") await cmdSetExpiry(rest[0], rest[1]);
971
+ else if (cmd === "set-email-gate") await cmdSetEmailGate(rest[0], rest[1]);
972
+ else if (cmd === "set-feedback") await cmdSetFeedback(rest[0], rest[1]);
973
+ else if (cmd === "set-password") await cmdSetPassword(rest[0], rest[1]);
974
+ else if (values.help || cmd === "help" || cmd === "--help" || !cmd) {
735
975
  console.log(`drop — branded password-protected sharing on ${DOMAIN}
736
976
 
737
977
  drop <file.html|.md> brand + lock (auto password) + upload → clean URL
@@ -739,6 +979,9 @@ try {
739
979
  drop <file> -p secret use your own password
740
980
  drop <file> --no-lock brand only, no password (renders for anyone)
741
981
  drop <file> --expire 7d auto-expire (7d/24h/30m/2w/date); enforce with 'drop gc'
982
+ drop <file> --burn burn-after-read: the drop self-destructs on first view
983
+ drop <file> --email-gate co.com only viewers with that email domain may open it
984
+ drop <file> --block-pii refuse to publish if a secret/credential is detected
742
985
  drop <file> --page branded download page wrapping the file
743
986
  drop <file> --page -p secret password-protect the download page
744
987
  drop site.zip multi-file static site → /slug/ (public)
@@ -748,6 +991,16 @@ try {
748
991
  drop rm <slug> delete a drop
749
992
  drop gc delete drops whose --expire has passed (cron-friendly)
750
993
  drop init --domain ... point drop at your own domain + Vercel Blob (BYO)
994
+ drop login sign in (magic-link) to host on drops + persist your drops
995
+ drop whoami show your hosted account (email + handle)
996
+ drop logout clear your hosted account (forget the API key)
997
+ drop claim <url|slug> move an anonymous /u/ drop into your account
998
+ drop share <slug> [--revoke] mint (or revoke) a revocable guest share link
999
+ drop migrate [--dry-run] re-home your hosted drops to your own blob (self-host)
1000
+ drop set-expiry <slug> <when> change a hosted drop's expiry (7d/24h/date/off)
1001
+ drop set-email-gate <slug> <domain> restrict a hosted drop to one email domain
1002
+ drop set-password <slug> <file> re-encrypt a hosted drop with a new password
1003
+ drop set-feedback <slug> [on|off] toggle the feedback widget on a hosted drop
751
1004
  drop setup [--token <tok>] provision a machine (deps + blob token)
752
1005
  drop deploy [--domain ...] wire backend (blob host → middleware/vercel.json) + deploy`);
753
1006
  } else {
package/mcp.mjs CHANGED
@@ -141,5 +141,52 @@ server.tool(
141
141
  }
142
142
  );
143
143
 
144
+ server.tool(
145
+ "set_password",
146
+ "Change the password gate on a hosted drop. Zero-knowledge means re-keying re-encrypts, so re-supply the HTML; returns the new URL + password.",
147
+ {
148
+ slug: z.string().describe("The slug to re-key."),
149
+ html: z.string().describe("The drop's HTML (required — content is re-encrypted under the new password)."),
150
+ password: z.string().optional().describe("New password (auto-generated if omitted)."),
151
+ },
152
+ async ({ slug, html, password }) => {
153
+ try {
154
+ const args = ["-s", slug];
155
+ if (password) args.push("-p", password);
156
+ return textResult(await withTempFile(slug + ".html", html, (p) => runDrop([p, ...args])));
157
+ } catch (e) { return errResult(e); }
158
+ }
159
+ );
160
+
161
+ server.tool(
162
+ "set_expiry",
163
+ "Set (or clear) the auto-expiry on a hosted drop. The host's gc cron deletes it after the deadline.",
164
+ { slug: z.string(), expire: z.string().describe("'7d' | '24h' | '2w' | a date | 'off' to clear.") },
165
+ async ({ slug, expire }) => {
166
+ try { return textResult(await runDrop(["set-expiry", slug, expire])); }
167
+ catch (e) { return errResult(e); }
168
+ }
169
+ );
170
+
171
+ server.tool(
172
+ "set_email_gate",
173
+ "Restrict a hosted drop so only viewers with a given email domain can open it (e.g. 'acme.com'); pass 'off' to remove.",
174
+ { slug: z.string(), domain: z.string().describe("Allowed email domain, or 'off'.") },
175
+ async ({ slug, domain }) => {
176
+ try { return textResult(await runDrop(["set-email-gate", slug, domain])); }
177
+ catch (e) { return errResult(e); }
178
+ }
179
+ );
180
+
181
+ server.tool(
182
+ "set_feedback",
183
+ "Toggle the viewer feedback widget on a hosted drop (collect reactions/comments from recipients).",
184
+ { slug: z.string(), enabled: z.boolean().optional().describe("true to enable (default), false to disable.") },
185
+ async ({ slug, enabled }) => {
186
+ try { return textResult(await runDrop(["set-feedback", slug, enabled === false ? "off" : "on"])); }
187
+ catch (e) { return errResult(e); }
188
+ }
189
+ );
190
+
144
191
  const transport = new StdioServerTransport();
145
192
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drops-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Open-source artifact sharing — publish HTML/Markdown/files as branded, password-protected, zero-knowledge links on your own domain, from any AI agent. CLI + MCP server. The open-source, self-hosted Stacktree alternative.",
5
5
  "type": "module",
6
6
  "bin": {