@casomoltd/tooling 0.35.0 → 0.36.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.
@@ -12,19 +12,25 @@
12
12
  * buttondown emit <dir> write each long field out as rendered
13
13
  * buttondown draft <file.md> create or replace a draft
14
14
  * buttondown upload <image> upload an image, print its URL
15
- * buttondown api <METHOD> <path> [json]
16
- * any call, for setting up a manual test
15
+ * buttondown api <METHOD> <path> [json] [--send]
16
+ * any call, for setting up a manual test;
17
+ * one that sends or schedules an email
18
+ * is refused without --send
17
19
  *
18
20
  * `diff`, `apply` and `check` exit 1 on drift, a refusal or a problem, so a
19
21
  * script can gate on them.
20
22
  */
21
23
  import {
22
- apply, assertApiPath, client, draft, drift, emit, loadSettings, readKey,
23
- runCheck, upload, writeTokens,
24
+ apply, assertApiPath, assertNotSending, client, draft, drift, emit,
25
+ loadSettings, readKey, runCheck, upload, writeTokens,
24
26
  } from "../buttondown/engine.mjs";
25
27
 
28
+ /** The one flag: without it, `api` refuses any call that could send an
29
+ * email, and no other command takes it. */
30
+ const SEND_FLAG = "--send";
31
+
26
32
  const USAGE = "usage: buttondown [diff|apply|check|tokens|emit <dir>|"
27
- + "draft <file.md>|upload <image>|api <METHOD> <path> [json]]";
33
+ + `draft <file.md>|upload <image>|api <METHOD> <path> [json] [${SEND_FLAG}]]`;
28
34
 
29
35
  /** Each command with the fewest and most arguments it takes. */
30
36
  const ARGS = {
@@ -32,9 +38,13 @@ const ARGS = {
32
38
  emit: [1, 1], draft: [1, 1], upload: [1, 1], api: [2, 3],
33
39
  };
34
40
 
35
- const [command = "diff", ...args] = process.argv.slice(2);
41
+ const argv = process.argv.slice(2);
42
+ const send = argv.includes(SEND_FLAG);
43
+ const [command = "diff", ...args] = argv.filter((a) => a !== SEND_FLAG);
36
44
  const [fewest, most] = ARGS[command] ?? [];
37
- if (fewest === undefined || args.length < fewest || args.length > most) {
45
+ const badUsage = fewest === undefined || args.length < fewest
46
+ || args.length > most || (send && command !== "api");
47
+ if (badUsage) {
38
48
  console.error(USAGE);
39
49
  process.exit(2);
40
50
  }
@@ -85,9 +95,16 @@ switch (command) {
85
95
  case "api": {
86
96
  const [method, url, body] = args;
87
97
  assertApiPath(settings, method, url);
88
- const res = await (await api()).call(
89
- method.toUpperCase(), url, body && JSON.parse(body),
90
- );
98
+ const payload = body && JSON.parse(body);
99
+ if (!send) {
100
+ try {
101
+ assertNotSending(method, url, payload);
102
+ } catch (e) {
103
+ console.error(`buttondown: ${e.message}; add ${SEND_FLAG} to send it`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+ const res = await (await api()).call(method.toUpperCase(), url, payload);
91
108
  console.log(res.status, res.data ? JSON.stringify(res.data, null, 2) : "");
92
109
  process.exit(res.ok ? 0 : 1);
93
110
  }
@@ -87,6 +87,19 @@ const API_PATH = new URL(API).pathname;
87
87
  /** The status of an email not yet sent. */
88
88
  const DRAFT = "draft";
89
89
 
90
+ /**
91
+ * The api routes that can mail someone, judged on a `requestPath`. A write
92
+ * to the emails collection or to one email may carry a status that sends
93
+ * it, so it passes only as a draft; any action on an email (publish,
94
+ * send-draft and whatever Buttondown adds) or on a subscriber (a reminder,
95
+ * an email to one reader) is refused outright, so a new route fails closed.
96
+ * Creating an unactivated subscriber also mails a confirmation, and stays
97
+ * allowed: it is how a manual test stands in for a reader signing up, to an
98
+ * address the tester owns.
99
+ */
100
+ const EMAIL_WRITE = new RegExp(`^${API_PATH}/emails(/[^/]+)?/?$`);
101
+ const SENDING_ACTION = new RegExp(`^${API_PATH}/(emails|subscribers)/[^/]+/.`);
102
+
90
103
  const FRONT_MATTER = /^---\n([\s\S]*?)\n---\n/;
91
104
 
92
105
  /** The Buttondown fields holding the two skins. */
@@ -387,7 +400,7 @@ export async function readKey(env = process.env) {
387
400
  export function client(settings, key) {
388
401
  const {newsletter} = settings;
389
402
  async function call(method, url, body, {form} = {}) {
390
- const res = await fetch(new URL(API + url), {
403
+ const res = await fetch(requestUrl(url), {
391
404
  method,
392
405
  headers: {
393
406
  Authorization: `Token ${key}`,
@@ -445,25 +458,37 @@ export function client(settings, key) {
445
458
 
446
459
  /**
447
460
  * Refuses an `api` call that writes newsletter settings anywhere but this
448
- * newsletter. Judged on the path the server routes: percent-decoded, then
449
- * normalised, so `/%6eewsletters/<other>`, `/./newsletters/<other>` and
450
- * `<own>/../<other>` are seen for what they are. A path still encoded after
451
- * one decoding is refused outright rather than guessed at.
461
+ * newsletter, judged on the path the server routes (`requestPath`).
452
462
  */
453
463
  export function assertApiPath(settings, method, url) {
454
464
  if (method.toUpperCase() === "GET") return;
455
- const decoded = decodeURIComponent(new URL(API + url).pathname);
456
- if (decoded.includes("%")) {
457
- throw new Error(`api refuses an encoded path: ${url}`);
458
- }
459
- const {pathname} = new URL(decoded, API);
460
- const own = API_PATH + newsletterPath(settings.newsletter);
465
+ const pathname = requestPath(url);
466
+ const own = (API_PATH + newsletterPath(settings.newsletter)).toLowerCase();
461
467
  if (pathname.startsWith(`${API_PATH}/newsletters`)
462
468
  && pathname !== own && !pathname.startsWith(`${own}/`)) {
463
469
  throw new Error(`api writes under /newsletters only to ${own}`);
464
470
  }
465
471
  }
466
472
 
473
+ /**
474
+ * Throws if an api call could send or schedule an email (`EMAIL_WRITE`,
475
+ * `SENDING_ACTION`). Sending reaches real inboxes and cannot be recalled, so
476
+ * the caller lets a call through only on a person's explicit say-so.
477
+ */
478
+ export function assertNotSending(method, url, body) {
479
+ const cannotSend = ["GET", "DELETE"].includes(method.toUpperCase());
480
+ if (cannotSend) return;
481
+ const pathname = requestPath(url);
482
+ const status = body && typeof body === "object" ? body.status : undefined;
483
+ const creates = pathname.replace(/\/$/, "") === `${API_PATH}/emails`;
484
+ const draftOnly = status === DRAFT || (status === undefined && !creates);
485
+ if (SENDING_ACTION.test(pathname)
486
+ || (EMAIL_WRITE.test(pathname) && !draftOnly)) {
487
+ throw new Error(`api refuses a call that could send an email: `
488
+ + `${method.toUpperCase()} ${url}`);
489
+ }
490
+ }
491
+
467
492
  /** Whether a live value already says what the repo wants. */
468
493
  export function same(field, got, want) {
469
494
  if (IMAGE_FIELDS.has(field)) {
@@ -693,6 +718,26 @@ export function stripComments(text) {
693
718
  }
694
719
  }
695
720
 
721
+ /** The URL `call` fetches for an api path, and the one the guards judge. */
722
+ function requestUrl(url) {
723
+ return new URL(API + url);
724
+ }
725
+
726
+ /**
727
+ * The path the server routes for an `api` url, from `requestUrl`,
728
+ * then percent-decoded, slash-collapsed and normalised, so
729
+ * `/%6eewsletters/<other>`, `//emails/<id>/publish`, `/./newsletters/<other>`
730
+ * and `<own>/../<other>` are seen for what they are. A path still encoded
731
+ * after one decoding is refused outright rather than guessed at.
732
+ */
733
+ function requestPath(url) {
734
+ const decoded = decodeURIComponent(requestUrl(url).pathname);
735
+ if (decoded.includes("%")) {
736
+ throw new Error(`api refuses an encoded path: ${url}`);
737
+ }
738
+ return new URL(decoded.replace(/\/{2,}/g, "/"), API).pathname.toLowerCase();
739
+ }
740
+
696
741
  /** Throws unless an image field holds an absolute URL, since Buttondown
697
742
  * stores a relative one as given and the image then never loads. */
698
743
  function assertAbsoluteUrl(field, value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@casomoltd/tooling",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
4
4
  "description": "Casomo's shared dev config: ESLint, Prettier, commitlint, knip & jscpd presets and dev CLIs (git hooks, readability, screenshots, design specs)",
5
5
  "license": "MIT",
6
6
  "repository": {