@wxxb789/anc 0.0.2 → 0.0.3

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.
@@ -166,7 +166,28 @@ export const NEAR_MISS_NAMES = [
166
166
  ...['yaml', 'yml'].flatMap((extension) => [`publish-config.${extension}`, `publish.conf.${extension}`]),
167
167
  ];
168
168
  /** Every key the file may carry. The order is the order they are documented in. */
169
- const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language'];
169
+ const KNOWN_KEYS = ['title', 'origin', 'exclude', 'language', 'media'];
170
+ /**
171
+ * Every key a `media:` table may carry.
172
+ *
173
+ * One provider and one shape, deliberately: Goal 0013 authorizes Cloudflare R2
174
+ * and nothing else, so this is a validated configuration table rather than a
175
+ * provider framework. The two credential entries are *names* of environment
176
+ * variables; a value written here would be a secret in the user's repository
177
+ * and is refused by the unknown-value rule of the shape itself.
178
+ */
179
+ const MEDIA_KEYS = [
180
+ 'provider',
181
+ 'bucket',
182
+ 'endpoint',
183
+ 'public_base_url',
184
+ 'access_key_id_env',
185
+ 'secret_access_key_env',
186
+ ];
187
+ /** The longest bucket name the S3 API and R2 both accept. */
188
+ const BUCKET_NAME = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/;
189
+ /** The shape of a name that can address an environment variable. */
190
+ const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
170
191
  /**
171
192
  * The site name a repository with no configuration gets.
172
193
  *
@@ -193,6 +214,7 @@ export const DEFAULTS = Object.freeze({
193
214
  origin: undefined,
194
215
  exclude: Object.freeze([]),
195
216
  language: undefined,
217
+ media: undefined,
196
218
  });
197
219
  /** The defaults, as a value a caller may keep and a later caller cannot see. */
198
220
  function freshDefaults() {
@@ -307,17 +329,17 @@ function editDistance(from, to) {
307
329
  * is true of any substring: measured, a key of `e` was suggested as `title`,
308
330
  * `x` as `exclude`, and `o` as `origin`. A confidently wrong suggestion tells
309
331
  * the user to rename a key they did not mean to write, which is worse than
310
- * listing the three.
332
+ * listing the keys.
311
333
  */
312
- function suggestionFor(key) {
334
+ function suggestionFor(key, candidates = KNOWN_KEYS) {
313
335
  const folded = key.toLowerCase().replace(/[^a-z0-9]/g, '');
314
336
  if (folded === '')
315
337
  return undefined;
316
- for (const known of KNOWN_KEYS) {
338
+ for (const known of candidates) {
317
339
  if (folded.length >= 3 && (folded.includes(known) || known.includes(folded)))
318
340
  return known;
319
341
  }
320
- for (const known of KNOWN_KEYS) {
342
+ for (const known of candidates) {
321
343
  if (editDistance(folded, known) <= 2)
322
344
  return known;
323
345
  }
@@ -500,6 +522,231 @@ function checkLanguage(value, line, issues, details) {
500
522
  details.push(`language = ${JSON.stringify(value)}`);
501
523
  }
502
524
  }
525
+ /**
526
+ * A bare HTTPS origin for the R2 storage endpoint or its public custom domain.
527
+ *
528
+ * The two checks that are not obvious, and the reason they are here rather
529
+ * than in the publisher:
530
+ *
531
+ * - **HTTPS only.** Credentials travel on every signed request, so a plain
532
+ * `http:` endpoint would put them on the wire. The public base is also where
533
+ * a reader's browser fetches images, and mixed content would break the page
534
+ * under the site's own CSP.
535
+ * - **No `r2.dev`.** That hostname is Cloudflare's development-only, rate
536
+ * limited endpoint with no normal CDN caching. A published site that used it
537
+ * would work in a test and degrade under real traffic, so it is refused at
538
+ * configuration time rather than discovered later.
539
+ *
540
+ * A path, query, fragment, username or password is refused for the same class
541
+ * of reason `checkOrigin` gives: the publisher appends the object path, so a
542
+ * path here would silently move every object and a credential would appear in
543
+ * every signed request line.
544
+ */
545
+ function checkMediaOrigin(value, at, issues, details) {
546
+ if (typeof value !== 'string') {
547
+ issues.push(`${at}: must be a string holding an HTTPS origin, found ${typeName(value)}`);
548
+ details.push(`${at} = ${JSON.stringify(value)}`);
549
+ return undefined;
550
+ }
551
+ if (value !== value.trim() || /\s/.test(value)) {
552
+ issues.push(`${at}: must not contain whitespace`);
553
+ details.push(`${at} = ${JSON.stringify(value)}`);
554
+ return undefined;
555
+ }
556
+ let url;
557
+ try {
558
+ url = new URL(value);
559
+ }
560
+ catch {
561
+ issues.push(`${at}: must be an absolute HTTPS URL with a host, such as https://example.com/`);
562
+ details.push(`${at} = ${JSON.stringify(value)}`);
563
+ return undefined;
564
+ }
565
+ if (url.protocol !== 'https:') {
566
+ issues.push(`${at}: must use the https scheme`);
567
+ details.push(`${at} = ${JSON.stringify(value)}`);
568
+ return undefined;
569
+ }
570
+ if (url.username !== '' || url.password !== '') {
571
+ issues.push(`${at}: must not carry a username or password — credentials are read from the environment`);
572
+ // The value itself is the one string here that may be a secret.
573
+ details.push(`${at} carried credentials`);
574
+ return undefined;
575
+ }
576
+ const hostname = url.hostname.toLowerCase();
577
+ if (hostname === 'r2.dev' || hostname.endsWith('.r2.dev')) {
578
+ issues.push(`${at}: must not use r2.dev — it is Cloudflare's development-only, rate-limited ` +
579
+ 'hostname with no normal CDN caching; connect a custom domain to the bucket instead');
580
+ details.push(`${at} named an r2.dev host`);
581
+ return undefined;
582
+ }
583
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
584
+ issues.push(`${at}: must be a bare origin with no path, query, or fragment — ` +
585
+ 'the object path is appended to it');
586
+ details.push(`${at} = ${JSON.stringify(value)}`);
587
+ return undefined;
588
+ }
589
+ return url.origin;
590
+ }
591
+ /**
592
+ * The `media:` table: one provider, one shape, and every required key present.
593
+ *
594
+ * Returns the normalized configuration only when this table contributed no
595
+ * issue. The caller still refuses the file if any other key did, which is why
596
+ * this does not throw: the loader accumulates every mistake in one pass.
597
+ *
598
+ * `bucket` is validated against the narrower of the S3 and R2 naming rules
599
+ * because both are addressed by the same value: 3–63 lowercase alphanumerics
600
+ * or hyphens, starting and ending with an alphanumeric. Rejecting a bucket the
601
+ * service would reject is cheaper than a 404 from a publisher that cannot say
602
+ * which of the two was wrong.
603
+ */
604
+ function checkMedia(value, line, memberLines, tagged, issues, details) {
605
+ const at = line === undefined ? 'media' : `media (line ${line})`;
606
+ if (!isPlainObject(value)) {
607
+ issues.push(`${at}: must be a table, found ${typeName(value)}`);
608
+ details.push(`media = ${JSON.stringify(value)}`);
609
+ return undefined;
610
+ }
611
+ const before = issues.length;
612
+ const where = (key) => {
613
+ const memberLine = memberLines.get(key);
614
+ return memberLine === undefined ? `media.${key}` : `media.${key} (line ${memberLine})`;
615
+ };
616
+ // A nested key is user-authored text exactly as a top-level one is: a pasted
617
+ // path or credential can be a valid YAML key, and this message reaches the
618
+ // public stderr stream. The line is where the fix goes; the bytes go to the
619
+ // private report. `where()` remains for the schema's own key names below.
620
+ for (const key of tagged) {
621
+ const memberLine = memberLines.get(key);
622
+ issues.push(`${memberLine === undefined ? 'media' : `media (line ${memberLine})`}: the value carries a ` +
623
+ '"!" tag, which YAML discards — remove it, or quote the value if the "!" is part of it');
624
+ details.push(`media.${key} carried an eaten "!" tag`);
625
+ }
626
+ for (const key of Object.keys(value)) {
627
+ if (MEDIA_KEYS.includes(key))
628
+ continue;
629
+ const memberLine = memberLines.get(key);
630
+ const suggestion = suggestionFor(key, MEDIA_KEYS);
631
+ issues.push(`${memberLine === undefined ? 'media' : `media (line ${memberLine})`}: unknown key is not allowed` +
632
+ (suggestion === undefined
633
+ ? `. The keys are ${MEDIA_KEYS.join(', ')}`
634
+ : `. Did you mean "${suggestion}"?`));
635
+ details.push(`unknown media key ${JSON.stringify(key)}`);
636
+ }
637
+ for (const key of MEDIA_KEYS) {
638
+ if (key in value)
639
+ continue;
640
+ issues.push(`${where(key)}: is required`);
641
+ }
642
+ if ('provider' in value && value['provider'] !== 'r2') {
643
+ issues.push(`${where('provider')}: must be "r2"; it is the only supported provider`);
644
+ details.push(`media.provider = ${JSON.stringify(value['provider'])}`);
645
+ }
646
+ if ('bucket' in value) {
647
+ const bucket = value['bucket'];
648
+ if (typeof bucket !== 'string') {
649
+ issues.push(`${where('bucket')}: must be a string, found ${typeName(bucket)}`);
650
+ details.push(`media.bucket = ${JSON.stringify(bucket)}`);
651
+ }
652
+ else if (bucket !== bucket.trim() || /\s/.test(bucket)) {
653
+ issues.push(`${where('bucket')}: must not contain whitespace`);
654
+ details.push(`media.bucket = ${JSON.stringify(bucket)}`);
655
+ }
656
+ else if (!BUCKET_NAME.test(bucket)) {
657
+ issues.push(`${where('bucket')}: must be 3–63 lowercase letters, digits, or hyphens, ` +
658
+ 'starting and ending with a letter or digit');
659
+ details.push(`media.bucket = ${JSON.stringify(bucket)}`);
660
+ }
661
+ }
662
+ const endpoint = 'endpoint' in value ? checkMediaOrigin(value['endpoint'], where('endpoint'), issues, details) : undefined;
663
+ const publicBaseUrl = 'public_base_url' in value
664
+ ? checkMediaOrigin(value['public_base_url'], where('public_base_url'), issues, details)
665
+ : undefined;
666
+ const envNames = new Map();
667
+ for (const key of ['access_key_id_env', 'secret_access_key_env']) {
668
+ if (!(key in value))
669
+ continue;
670
+ const name = value[key];
671
+ if (typeof name !== 'string') {
672
+ issues.push(`${where(key)}: must be a string naming an environment variable, found ${typeName(name)}`);
673
+ details.push(`media.${key} = ${JSON.stringify(name)}`);
674
+ continue;
675
+ }
676
+ if (!ENVIRONMENT_NAME.test(name) || name.length > 128) {
677
+ issues.push(`${where(key)}: must be an environment variable name — letters, digits, and ` +
678
+ 'underscores, not starting with a digit');
679
+ details.push(`media.${key} = ${JSON.stringify(name)}`);
680
+ continue;
681
+ }
682
+ envNames.set(key, name);
683
+ }
684
+ const accessKeyIdEnv = envNames.get('access_key_id_env');
685
+ const secretAccessKeyEnv = envNames.get('secret_access_key_env');
686
+ if (accessKeyIdEnv !== undefined && accessKeyIdEnv === secretAccessKeyEnv) {
687
+ issues.push(`${where('secret_access_key_env')}: must name a different environment variable from ` +
688
+ 'media.access_key_id_env — one value cannot be both the key ID and the secret');
689
+ details.push('media.access_key_id_env and media.secret_access_key_env named the same variable');
690
+ }
691
+ if (issues.length !== before)
692
+ return undefined;
693
+ return {
694
+ provider: 'r2',
695
+ bucket: value['bucket'],
696
+ endpoint: endpoint,
697
+ publicBaseUrl: publicBaseUrl,
698
+ accessKeyIdEnv: accessKeyIdEnv,
699
+ secretAccessKeyEnv: secretAccessKeyEnv,
700
+ };
701
+ }
702
+ /**
703
+ * The nested `media:` members whose YAML value carried an eaten `!` tag.
704
+ *
705
+ * The top-level loop cannot see these: a tagged scalar inside a mapping is a
706
+ * different node, and `bucket: ! mybucket` would otherwise be accepted with the
707
+ * tag silently discarded. Same rule, one level down.
708
+ */
709
+ function mediaEatenTags(document) {
710
+ const eaten = new Set();
711
+ const node = document.get('media', true);
712
+ const items = node !== null && typeof node === 'object' && 'items' in node ? node.items : undefined;
713
+ if (!Array.isArray(items))
714
+ return eaten;
715
+ for (const item of items) {
716
+ const pair = item;
717
+ const key = pair.key;
718
+ if (key === undefined || typeof key.value !== 'string')
719
+ continue;
720
+ if (carriesEatenTag(pair.value, document))
721
+ eaten.add(key.value);
722
+ }
723
+ return eaten;
724
+ }
725
+ /**
726
+ * The 1-indexed line each `media:` member was written on.
727
+ *
728
+ * Per member, not per table: the entire disclosure design rests on the user
729
+ * opening their own file at that line and reading their own text there, so a
730
+ * line that points at `media:` for an error in `bucket` is worse than none.
731
+ */
732
+ function mediaLines(document, lineCounter) {
733
+ const lines = new Map();
734
+ const node = document.get('media', true);
735
+ const items = node !== null && typeof node === 'object' && 'items' in node ? node.items : undefined;
736
+ if (!Array.isArray(items))
737
+ return lines;
738
+ for (const item of items) {
739
+ const pair = item;
740
+ const key = pair.key;
741
+ if (key === undefined || typeof key.value !== 'string')
742
+ continue;
743
+ const range = key.range;
744
+ if (!Array.isArray(range) || typeof range[0] !== 'number')
745
+ continue;
746
+ lines.set(key.value, lineCounter.linePos(range[0]).line);
747
+ }
748
+ return lines;
749
+ }
503
750
  /**
504
751
  * A list of gitignore-style patterns, every member checked.
505
752
  *
@@ -694,6 +941,8 @@ function excludeLines(document, lineCounter) {
694
941
  export function parseConfig(text) {
695
942
  const issues = [];
696
943
  const details = [];
944
+ /** Filled only by a fully valid `media:` table; validation failures throw below. */
945
+ let media;
697
946
  // A leading BOM is what Notepad writes, and it is not YAML. Stripped rather
698
947
  // than refused: the user did not type it and cannot see it.
699
948
  //
@@ -832,6 +1081,9 @@ export function parseConfig(text) {
832
1081
  checkOrigin(table['origin'], lines.get('origin'), issues, details);
833
1082
  if ('language' in table)
834
1083
  checkLanguage(table['language'], lines.get('language'), issues, details);
1084
+ if ('media' in table) {
1085
+ media = checkMedia(table['media'], lines.get('media'), mediaLines(document, lineCounter), mediaEatenTags(document), issues, details);
1086
+ }
835
1087
  if ('exclude' in table) {
836
1088
  checkExclude(table['exclude'], lines.get('exclude'), excludeLines(document, lineCounter), taggedExcludeMembers(document), issues, details);
837
1089
  }
@@ -845,6 +1097,7 @@ export function parseConfig(text) {
845
1097
  origin: 'origin' in table ? table['origin'] : DEFAULTS.origin,
846
1098
  exclude: 'exclude' in table ? [...table['exclude']] : [],
847
1099
  language: 'language' in table ? table['language'] : DEFAULTS.language,
1100
+ media,
848
1101
  };
849
1102
  }
850
1103
  /**