@buildinternet/uploads 0.19.0 → 0.22.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.
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Canonical metadata promoted from an image's own EXIF, read *before* the
3
+ * optimizer strips it from the bytes. `--keep-exif` is orthogonal: it governs
4
+ * whether the uploaded bytes retain EXIF, not whether we promote these keys.
5
+ *
6
+ * Promotion is allowlist-only. Anything not named here is discarded, and the
7
+ * denials (GPS, serials, personal names, free-form comments) are load-bearing:
8
+ * promoted values render on the public /f/ page.
9
+ *
10
+ * Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
11
+ */
12
+ import exifReader from "exif-reader";
13
+ import sharp from "sharp";
14
+ import { dropUnsafeMetaValues } from "./metadata.js";
15
+ import { formatViewport } from "./metadata-vocab.js";
16
+ /** Below this, the image is a 1:1 photo rather than a scaled screen capture. */
17
+ const SCREEN_CAPTURE_MIN_DENSITY = 72;
18
+ function asString(value) {
19
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
20
+ }
21
+ /** EXIF's `YYYY:MM:DD HH:MM:SS` (or a parsed Date) → ISO 8601, zone-honest. */
22
+ function formatCaptured(raw, offset) {
23
+ let stamp;
24
+ if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
25
+ // exif-reader builds this Date from the EXIF wall-clock digits as if UTC,
26
+ // so the ISO prefix reproduces those digits exactly.
27
+ stamp = raw.toISOString().slice(0, 19);
28
+ }
29
+ else {
30
+ const text = asString(raw);
31
+ const match = text && /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}:\d{2}:\d{2})$/.exec(text);
32
+ if (match)
33
+ stamp = `${match[1]}-${match[2]}-${match[3]}T${match[4]}`;
34
+ }
35
+ if (!stamp)
36
+ return undefined;
37
+ // Only claim a zone when EXIF actually carried one. Never append a bare "Z".
38
+ const zone = offset && /^[+-]\d{2}:\d{2}$/.test(offset) ? offset : "";
39
+ return `${stamp}${zone}`;
40
+ }
41
+ /** Combine Make + Model without repeating the make (`Canon` + `Canon EOS R5`). */
42
+ function formatDevice(make, model) {
43
+ if (!model)
44
+ return make;
45
+ if (!make)
46
+ return model;
47
+ return model.toLowerCase().startsWith(make.toLowerCase()) ? model : `${make} ${model}`;
48
+ }
49
+ /**
50
+ * Map exif-reader's parsed tags onto canonical keys. Pure and total: junk
51
+ * input yields `{}`. Only `device`, `software` and `captured` are ever read —
52
+ * every other tag, including all of GPSInfo, is ignored by construction.
53
+ */
54
+ export function factsFromExifTags(tags) {
55
+ const facts = {};
56
+ if (!tags || typeof tags !== "object")
57
+ return facts;
58
+ const root = tags;
59
+ const image = (root.Image ?? {});
60
+ const photo = (root.Photo ?? {});
61
+ const device = formatDevice(asString(image.Make), asString(image.Model));
62
+ if (device)
63
+ facts.device = device;
64
+ const software = asString(image.Software);
65
+ if (software)
66
+ facts.software = software;
67
+ const captured = formatCaptured(photo.DateTimeOriginal, asString(photo.OffsetTimeOriginal));
68
+ if (captured)
69
+ facts.captured = captured;
70
+ // Derived values must satisfy the metadata contract or be dropped silently —
71
+ // same posture as the existing best-effort gh.title.
72
+ return dropUnsafeMetaValues(facts);
73
+ }
74
+ /**
75
+ * Read canonical facts from image bytes. Best-effort by contract: any failure
76
+ * (not an image, corrupt EXIF, unsupported format) yields `{}` and must never
77
+ * fail the upload.
78
+ */
79
+ export async function imageFactsFromBytes(bytes) {
80
+ if (bytes.byteLength === 0)
81
+ return {};
82
+ let meta;
83
+ try {
84
+ meta = await sharp(bytes, { failOn: "none" }).metadata();
85
+ }
86
+ catch {
87
+ return {};
88
+ }
89
+ if (!meta.format)
90
+ return {};
91
+ const facts = {};
92
+ // A density above 72dpi means a scaled screen capture: recover the logical
93
+ // size the user actually saw. Camera photos report 72 and are skipped.
94
+ const { width, height, density } = meta;
95
+ if (width && height && density && density > SCREEN_CAPTURE_MIN_DENSITY) {
96
+ const scale = density / SCREEN_CAPTURE_MIN_DENSITY;
97
+ facts.viewport = formatViewport(width / scale, height / scale, scale);
98
+ }
99
+ if (meta.exif) {
100
+ try {
101
+ Object.assign(facts, factsFromExifTags(exifReader(meta.exif)));
102
+ }
103
+ catch {
104
+ // Unparseable EXIF is not an error — keep whatever we already derived.
105
+ }
106
+ }
107
+ return facts;
108
+ }
@@ -1,6 +1,8 @@
1
1
  export type ToolArgs = Record<string, unknown>;
2
2
  export declare function usage(msg: string): never;
3
3
  export declare function optString(args: ToolArgs, name: string): string | undefined;
4
+ /** A boolean flag argument; missing/null reads as `false`. */
5
+ export declare function optBool(args: ToolArgs, name: string): boolean;
4
6
  export declare function optPosInt(args: ToolArgs, name: string): number | undefined;
5
7
  /** A JSON-object argument of string→string pairs (e.g. a `metadata` or `filters` param). */
6
8
  export declare function optStringRecord(args: ToolArgs, name: string): Record<string, string> | undefined;
@@ -11,7 +13,7 @@ export declare function optStringArray(args: ToolArgs, name: string): string[] |
11
13
  * `filters` params across the CLI/local MCP (put/attach/set_metadata/
12
14
  * find_files) and the remote MCP worker (set_metadata/find_files).
13
15
  */
14
- export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Suggested keys: app, url, page, device, resolution, commit, branch. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
16
+ export declare const METADATA_DESCRIPTION = "Queryable custom metadata (key\u2192value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Canonical keys, which uploads.sh derives automatically where it can: url, path, env, theme, viewport, device, software, captured. Use `path` for the route (e.g. /settings) \u2014 that is the key `find_files` searches by, so spell it `path` and not route/page/screen. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
15
17
  export declare const metadataProp: {
16
18
  type: string;
17
19
  additionalProperties: {
@@ -19,3 +21,24 @@ export declare const metadataProp: {
19
21
  };
20
22
  description: string;
21
23
  };
24
+ export declare const stateProp: {
25
+ type: string;
26
+ enum: ("before" | "after" | "empty" | "error" | "loading")[];
27
+ description: string;
28
+ };
29
+ export declare const appProp: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ /**
34
+ * Canonical `state`/`app` pairs from their dedicated tool params. The schema
35
+ * enum already constrains `state` for well-behaved clients; re-validate here
36
+ * because a schema is a hint, not an enforcement boundary.
37
+ */
38
+ export declare function canonicalMetaFromArgs(args: ToolArgs): Record<string, string>;
39
+ /**
40
+ * The `metadata` tool arg merged with the canonical `state`/`app` params.
41
+ * `undefined` (leave stored metadata untouched) is preserved only when the
42
+ * caller supplied none of the three.
43
+ */
44
+ export declare function metadataArgWithCanonical(args: ToolArgs): Record<string, string> | undefined;
package/dist/mcp/args.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Workers as well as Node.
5
5
  */
6
6
  import { UploadsError } from "../errors.js";
7
+ import { META_STATE_VALUES, validateStateValue } from "../metadata-vocab.js";
7
8
  export function usage(msg) {
8
9
  throw new UploadsError(msg, "USAGE");
9
10
  }
@@ -15,6 +16,15 @@ export function optString(args, name) {
15
16
  usage(`${name} must be a string`);
16
17
  return v;
17
18
  }
19
+ /** A boolean flag argument; missing/null reads as `false`. */
20
+ export function optBool(args, name) {
21
+ const v = args[name];
22
+ if (v === undefined || v === null)
23
+ return false;
24
+ if (typeof v !== "boolean")
25
+ usage(`${name} must be a boolean`);
26
+ return v;
27
+ }
18
28
  export function optPosInt(args, name) {
19
29
  const v = args[name];
20
30
  if (v === undefined || v === null)
@@ -60,9 +70,59 @@ export function optStringArray(args, name) {
60
70
  * `filters` params across the CLI/local MCP (put/attach/set_metadata/
61
71
  * find_files) and the remote MCP worker (set_metadata/find_files).
62
72
  */
63
- export const METADATA_DESCRIPTION = "Queryable custom metadata (key→value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Suggested keys: app, url, page, device, resolution, commit, branch. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
73
+ export const METADATA_DESCRIPTION = "Queryable custom metadata (key→value), separate from provenance. Omit to leave any metadata already stored for this key untouched; pass an object (even {}) to fully replace it. Keys: lowercase, ^[a-z][a-z0-9._-]{0,63}$. Values: 1-512 printable ASCII characters. Caps: at most 24 keys, at most 8192 total key+value bytes. Canonical keys, which uploads.sh derives automatically where it can: url, path, env, theme, viewport, device, software, captured. Use `path` for the route (e.g. /settings) — that is the key `find_files` searches by, so spell it `path` and not route/page/screen. `gh.*` is reserved by convention for GitHub PR/issue attachment context (repo/kind/number/ref).";
64
74
  export const metadataProp = {
65
75
  type: "object",
66
76
  additionalProperties: { type: "string" },
67
77
  description: METADATA_DESCRIPTION,
68
78
  };
79
+ export const stateProp = {
80
+ type: "string",
81
+ enum: [...META_STATE_VALUES],
82
+ description: "The UI state this image shows. Set it whenever the image is one side of a comparison — before/after is the most useful pair in a PR, and is what makes `find_files` with state=after work later.",
83
+ };
84
+ export const appProp = {
85
+ type: "string",
86
+ description: "Which surface is shown: web, ios, android, cli. Worth setting only when the same route exists on more than one surface.",
87
+ };
88
+ /**
89
+ * Canonical `state`/`app` pairs from their dedicated tool params. The schema
90
+ * enum already constrains `state` for well-behaved clients; re-validate here
91
+ * because a schema is a hint, not an enforcement boundary.
92
+ */
93
+ export function canonicalMetaFromArgs(args) {
94
+ const meta = {};
95
+ const state = optString(args, "state");
96
+ if (state !== undefined) {
97
+ // Delegate rather than re-checking the enum here, so MCP callers get the
98
+ // same near-miss suggestions ("post" → "after") the CLI gives. Only the
99
+ // error channel differs.
100
+ try {
101
+ meta.state = validateStateValue(state);
102
+ }
103
+ catch (err) {
104
+ usage(err instanceof Error
105
+ ? err.message.replace(/^invalid --state: /, "invalid state: ")
106
+ : String(err));
107
+ }
108
+ }
109
+ const app = optString(args, "app");
110
+ if (app !== undefined) {
111
+ const normalized = app.trim().toLowerCase();
112
+ if (normalized.length > 0)
113
+ meta.app = normalized;
114
+ }
115
+ return meta;
116
+ }
117
+ /**
118
+ * The `metadata` tool arg merged with the canonical `state`/`app` params.
119
+ * `undefined` (leave stored metadata untouched) is preserved only when the
120
+ * caller supplied none of the three.
121
+ */
122
+ export function metadataArgWithCanonical(args) {
123
+ const canonical = canonicalMetaFromArgs(args);
124
+ const metadataArg = optStringRecord(args, "metadata");
125
+ return metadataArg === undefined && Object.keys(canonical).length === 0
126
+ ? undefined
127
+ : { ...metadataArg, ...canonical };
128
+ }
@@ -1,4 +1,4 @@
1
- export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
1
+ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, type ToolArgs, } from "./args.js";
2
2
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
3
3
  export { mapBounded } from "../async.js";
4
4
  export interface McpTool {
@@ -10,7 +10,7 @@
10
10
  import { UploadsError } from "../errors.js";
11
11
  import { errorCodeFromUnknown, recordEvent } from "../telemetry.js";
12
12
  import { ToolBatchError } from "./batch-error.js";
13
- export { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
13
+ export { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, stateProp, optBool, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
14
14
  export { ToolBatchError, batchFailureMessage } from "./batch-error.js";
15
15
  export { mapBounded } from "../async.js";
16
16
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
package/dist/mcp/tools.js CHANGED
@@ -4,20 +4,14 @@ import { resolveFrameId } from "../frame.js";
4
4
  import { resolveConfig, resolvePutDefaults, } from "../config.js";
5
5
  import { resolvePutPrefix } from "../destinations.js";
6
6
  import { ghKeyPrefix } from "../github.js";
7
+ import { safeCaptureFacts } from "../capture-facts.js";
7
8
  import { validateMetaMap } from "../metadata.js";
9
+ import { mergeDerivedMeta } from "../metadata-vocab.js";
8
10
  import { execRunner, ghMetadataFromTargetWithTitle, resolveCurrentPullRequest, resolveRepo, } from "../github-gh.js";
9
- import { METADATA_DESCRIPTION, metadataProp, optPosInt, optString, optStringArray, optStringRecord, usage, } from "./args.js";
11
+ import { appProp, canonicalMetaFromArgs, METADATA_DESCRIPTION, metadataArgWithCanonical, metadataProp, optBool, optPosInt, optString, optStringArray, optStringRecord, stateProp, usage, } from "./args.js";
10
12
  import { batchFailureMessage, ToolBatchError } from "./server.js";
11
13
  import { attachmentFromText, buildReportPayload, parseReportType, REPORT_TYPES, submitReport, validateReportMessage, } from "../report.js";
12
14
  import { resolveApiUrl } from "../config.js";
13
- function optBool(args, name) {
14
- const v = args[name];
15
- if (v === undefined || v === null)
16
- return false;
17
- if (typeof v !== "boolean")
18
- usage(`${name} must be a boolean`);
19
- return v;
20
- }
21
15
  function mcpOptimizeOptions(args, defaults) {
22
16
  const quality = optPosInt(args, "optimizeQuality");
23
17
  if (quality !== undefined && quality > 100)
@@ -338,9 +332,15 @@ export function createUploadsMcpTools(opts) {
338
332
  },
339
333
  dryRun: {
340
334
  type: "boolean",
341
- description: "Resolve key + public URL without uploading. Not with comment.",
335
+ description: "Resolve key + public URL without uploading (also previews a strict-key refusal via wouldRefuse). Not with comment.",
336
+ },
337
+ replace: {
338
+ type: "boolean",
339
+ description: "Allow overwriting an existing object on a strict (non-gh/) key: explicit key, or the default put path. Default false — an existing object there is refused (key_exists) unless this is true or UPLOADS_OVERWRITE=1 is set in the server's environment. No effect on pr/issue keys, which always overwrite.",
342
340
  },
343
341
  metadata: metadataProp,
342
+ state: stateProp,
343
+ app: appProp,
344
344
  workspace: workspaceProp,
345
345
  },
346
346
  additionalProperties: false,
@@ -372,6 +372,11 @@ export function createUploadsMcpTools(opts) {
372
372
  const destArg = optString(args, "destination");
373
373
  const prefixArg = optString(args, "prefix");
374
374
  const refArg = optString(args, "ref");
375
+ // Strict-overwrite gate (issue #174): defaults false; a strict-path
376
+ // put (explicit key or the default path) refuses an existing object
377
+ // unless this is true or UPLOADS_OVERWRITE=1 is set for this process.
378
+ // No effect on pr/issue keys — the server always overwrites those.
379
+ const replaceArg = optBool(args, "replace") ?? process.env.UPLOADS_OVERWRITE === "1";
375
380
  if (wantComment && !target)
376
381
  usage("comment requires pr or issue");
377
382
  if (dryRun && wantComment)
@@ -387,7 +392,7 @@ export function createUploadsMcpTools(opts) {
387
392
  // Validate up front (fail fast, before reading/optimizing the file).
388
393
  // undefined leaves existing metadata untouched; an object (even {})
389
394
  // fully replaces it — see metadataProp's description.
390
- const metadata = optStringRecord(args, "metadata");
395
+ const metadata = metadataArgWithCanonical(args);
391
396
  if (metadata)
392
397
  validateMetaMap(metadata);
393
398
  let resolvedPrefix;
@@ -419,9 +424,13 @@ export function createUploadsMcpTools(opts) {
419
424
  deriveRepoFromGit: !noGit,
420
425
  contentType,
421
426
  dryRun,
427
+ replace: replaceArg,
422
428
  optimize: optimizeOpts,
423
429
  frame: frameOpts,
424
430
  metadata,
431
+ // The shared metadata description promises uploads.sh derives these
432
+ // "automatically where it can" — MCP has no --no-auto, so always on.
433
+ deriveImageFacts: true,
425
434
  provenanceClient: "uploads-mcp",
426
435
  alt,
427
436
  width,
@@ -456,6 +465,7 @@ export function createUploadsMcpTools(opts) {
456
465
  contentType,
457
466
  deriveRepoFromGit: !noGit,
458
467
  dryRun,
468
+ replace: replaceArg,
459
469
  metadata,
460
470
  provenanceClient: "uploads-mcp",
461
471
  alt: () => alt ?? sourceName,
@@ -498,6 +508,7 @@ export function createUploadsMcpTools(opts) {
498
508
  size: u.size,
499
509
  contentType: u.contentType,
500
510
  replaced: u.replaced,
511
+ wouldRefuse: u.wouldRefuse,
501
512
  markdown: u.markdown,
502
513
  optimize: u.optimize,
503
514
  frame: u.frame,
@@ -613,6 +624,8 @@ export function createUploadsMcpTools(opts) {
613
624
  description: "Capture + resolve key/URL without uploading. Not with comment or galleryId.",
614
625
  },
615
626
  metadata: metadataProp,
627
+ state: stateProp,
628
+ app: appProp,
616
629
  workspace: workspaceProp,
617
630
  },
618
631
  required: ["target"],
@@ -652,7 +665,7 @@ export function createUploadsMcpTools(opts) {
652
665
  if (prefixArg)
653
666
  usage("prefix cannot be combined with pr/issue");
654
667
  }
655
- const metadata = optStringRecord(args, "metadata");
668
+ const metadata = metadataArgWithCanonical(args);
656
669
  if (metadata)
657
670
  validateMetaMap(metadata);
658
671
  let resolvedPrefix;
@@ -685,6 +698,14 @@ export function createUploadsMcpTools(opts) {
685
698
  catch (err) {
686
699
  usage(`screenshot capture is unavailable in this runtime; try via: "remote" instead (${err instanceof Error ? err.message : String(err)})`);
687
700
  }
701
+ const viewport = screenshotModule.parseViewport(optString(args, "viewport"));
702
+ // Same derivation the CLI does — explicit args win over capture facts.
703
+ // Keep undefined when nothing at all was supplied or derived, so the
704
+ // "omit to leave stored metadata untouched" contract still holds.
705
+ const captureDerived = safeCaptureFacts(targetArg, viewport, colorSchemeArg);
706
+ const metadataWithCaptureFacts = metadata === undefined && Object.keys(captureDerived).length === 0
707
+ ? undefined
708
+ : mergeDerivedMeta(metadata ?? {}, captureDerived);
688
709
  let captured;
689
710
  try {
690
711
  captured = await screenshotModule.captureScreenshot({
@@ -692,7 +713,7 @@ export function createUploadsMcpTools(opts) {
692
713
  via: viaArg,
693
714
  browserPath: optString(args, "browser"),
694
715
  cdp: optString(args, "cdp"),
695
- viewport: screenshotModule.parseViewport(optString(args, "viewport")),
716
+ viewport,
696
717
  selector: optString(args, "selector"),
697
718
  fullPage: optBool(args, "fullPage"),
698
719
  colorScheme: colorSchemeArg,
@@ -723,7 +744,8 @@ export function createUploadsMcpTools(opts) {
723
744
  ref: refArg ?? defaults.ref,
724
745
  deriveRepoFromGit: !noGit,
725
746
  dryRun,
726
- metadata,
747
+ metadata: metadataWithCaptureFacts,
748
+ deriveImageFacts: true,
727
749
  provenanceClient: "uploads-mcp-screenshot",
728
750
  alt: (p) => alt ?? p.filename,
729
751
  width,
@@ -809,6 +831,8 @@ export function createUploadsMcpTools(opts) {
809
831
  description: "Extra queryable metadata (key→value), merged with the automatic gh.repo/gh.kind/gh.number/gh.ref pairs — a gh.* pair here loses to the resolved target's own gh.* value. " +
810
832
  METADATA_DESCRIPTION,
811
833
  },
834
+ state: stateProp,
835
+ app: appProp,
812
836
  workspace: workspaceProp,
813
837
  },
814
838
  required: ["files"],
@@ -832,7 +856,10 @@ export function createUploadsMcpTools(opts) {
832
856
  // just the extras) so the 24-key/8KB caps are enforced client-side —
833
857
  // extras alone might pass while extras + the gh.* pairs exceed the
834
858
  // cap, which would otherwise only be caught server-side after upload.
835
- const metaExtras = optStringRecord(args, "metadata") ?? {};
859
+ const metaExtras = {
860
+ ...optStringRecord(args, "metadata"),
861
+ ...canonicalMetaFromArgs(args),
862
+ };
836
863
  const metadata = { ...metaExtras, ...ghMetadataFromTargetWithTitle(target, run) };
837
864
  if (Object.keys(metadata).length > 0)
838
865
  validateMetaMap(metadata);
@@ -0,0 +1,24 @@
1
+ export declare const CANONICAL_META_KEYS: readonly ["url", "path", "env", "theme", "viewport", "device", "software", "captured", "state", "app"];
2
+ export type CanonicalMetaKey = (typeof CANONICAL_META_KEYS)[number];
3
+ /** Closed enum for `state` — the highest-value hand-supplied search facet. */
4
+ export declare const META_STATE_VALUES: readonly ["before", "after", "empty", "error", "loading"];
5
+ export type MetaStateValue = (typeof META_STATE_VALUES)[number];
6
+ /**
7
+ * Validate a `--state` value. Fails fast with a suggestion when the value is a
8
+ * recognized near-miss, otherwise lists the valid set.
9
+ */
10
+ export declare function validateStateValue(raw: string): MetaStateValue;
11
+ /**
12
+ * Warning lines for supplied keys that look like misspellings of canonical
13
+ * ones. Callers warn and continue — we never silently rewrite a caller's key,
14
+ * because a wrong guess is worse than a nag.
15
+ */
16
+ export declare function nearMissMetaWarnings(keys: string[]): string[];
17
+ /** Canonical value format for the `viewport` key, e.g. `1280x800@2x`. */
18
+ export declare function formatViewport(width: number, height: number, scale: number): string;
19
+ /**
20
+ * Merge derived pairs under the explicit ones, adding derived keys only while
21
+ * the result still satisfies the metadata caps. Explicit keys always win and
22
+ * are never dropped; a full key budget must never fail an upload.
23
+ */
24
+ export declare function mergeDerivedMeta(explicit: Record<string, string>, derived: Record<string, string>): Record<string, string>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The canonical upload metadata vocabulary: a small closed set of keys that
3
+ * `uploads find` can rely on being spelled consistently. Most are derived (see
4
+ * capture-facts.ts / image-facts.ts); only `state` and `app` are typed by hand.
5
+ *
6
+ * Design: .context/2026-07-21-upload-metadata-vocabulary-design.md
7
+ */
8
+ import { UsageError } from "./cli-args.js";
9
+ import { validateMetaMap } from "./metadata.js";
10
+ export const CANONICAL_META_KEYS = [
11
+ "url",
12
+ "path",
13
+ "env",
14
+ "theme",
15
+ "viewport",
16
+ "device",
17
+ "software",
18
+ "captured",
19
+ "state",
20
+ "app",
21
+ ];
22
+ const CANONICAL_KEY_SET = new Set(CANONICAL_META_KEYS);
23
+ /** Closed enum for `state` — the highest-value hand-supplied search facet. */
24
+ export const META_STATE_VALUES = ["before", "after", "empty", "error", "loading"];
25
+ const STATE_VALUE_SET = new Set(META_STATE_VALUES);
26
+ /** Common spellings agents reach for, mapped to the canonical `state` value. */
27
+ const STATE_ALIASES = {
28
+ pre: "before",
29
+ prior: "before",
30
+ old: "before",
31
+ previous: "before",
32
+ post: "after",
33
+ new: "after",
34
+ updated: "after",
35
+ blank: "empty",
36
+ none: "empty",
37
+ failure: "error",
38
+ failed: "error",
39
+ err: "error",
40
+ spinner: "loading",
41
+ pending: "loading",
42
+ busy: "loading",
43
+ };
44
+ /**
45
+ * Validate a `--state` value. Fails fast with a suggestion when the value is a
46
+ * recognized near-miss, otherwise lists the valid set.
47
+ */
48
+ export function validateStateValue(raw) {
49
+ const value = raw.trim().toLowerCase();
50
+ if (STATE_VALUE_SET.has(value))
51
+ return value;
52
+ const suggestion = STATE_ALIASES[value];
53
+ if (suggestion) {
54
+ throw new UsageError(`invalid --state: "${raw}" — did you mean "${suggestion}"?`);
55
+ }
56
+ throw new UsageError(`invalid --state: "${raw}" (expected one of: ${META_STATE_VALUES.join(", ")})`);
57
+ }
58
+ /** Common misspellings of canonical keys, mapped to the canonical spelling. */
59
+ const META_KEY_ALIASES = {
60
+ route: "path",
61
+ page: "path",
62
+ screen: "path",
63
+ pathname: "path",
64
+ mode: "theme",
65
+ appearance: "theme",
66
+ colorscheme: "theme",
67
+ environment: "env",
68
+ stage: "env",
69
+ surface: "app",
70
+ platform: "app",
71
+ status: "state",
72
+ variant: "state",
73
+ resolution: "viewport",
74
+ size: "viewport",
75
+ dimensions: "viewport",
76
+ link: "url",
77
+ href: "url",
78
+ source: "url",
79
+ model: "device",
80
+ hardware: "device",
81
+ when: "captured",
82
+ date: "captured",
83
+ timestamp: "captured",
84
+ };
85
+ /**
86
+ * Warning lines for supplied keys that look like misspellings of canonical
87
+ * ones. Callers warn and continue — we never silently rewrite a caller's key,
88
+ * because a wrong guess is worse than a nag.
89
+ */
90
+ export function nearMissMetaWarnings(keys) {
91
+ const warnings = [];
92
+ for (const key of keys) {
93
+ if (CANONICAL_KEY_SET.has(key))
94
+ continue;
95
+ const canonical = META_KEY_ALIASES[key];
96
+ if (canonical) {
97
+ warnings.push(`metadata key "${key}" is not canonical — did you mean "${canonical}"?`);
98
+ }
99
+ }
100
+ return warnings;
101
+ }
102
+ /** Canonical value format for the `viewport` key, e.g. `1280x800@2x`. */
103
+ export function formatViewport(width, height, scale) {
104
+ const trimmed = Number(scale.toFixed(2));
105
+ return `${Math.round(width)}x${Math.round(height)}@${trimmed}x`;
106
+ }
107
+ /**
108
+ * Merge derived pairs under the explicit ones, adding derived keys only while
109
+ * the result still satisfies the metadata caps. Explicit keys always win and
110
+ * are never dropped; a full key budget must never fail an upload.
111
+ */
112
+ export function mergeDerivedMeta(explicit, derived) {
113
+ const out = { ...explicit };
114
+ for (const [key, value] of Object.entries(derived)) {
115
+ if (key in out)
116
+ continue;
117
+ const candidate = { ...out, [key]: value };
118
+ try {
119
+ validateMetaMap(candidate);
120
+ }
121
+ catch {
122
+ continue; // derived key does not fit — drop it, keep going
123
+ }
124
+ out[key] = value;
125
+ }
126
+ return out;
127
+ }
@@ -16,6 +16,13 @@ export declare function validateMetaEntry(key: string, value: string): void;
16
16
  * unicode (emoji, curly quotes) that the metadata value rule disallows.
17
17
  */
18
18
  export declare function isMetaValueSafe(value: string): boolean;
19
+ /**
20
+ * Drop, in place, any entry whose value breaks the metadata contract. For
21
+ * *derived* pairs only: a fact we inferred must never fail an upload, so an
22
+ * over-long URL or non-ASCII EXIF string is silently discarded rather than
23
+ * surfaced. Never use this on caller-supplied metadata, which should error.
24
+ */
25
+ export declare function dropUnsafeMetaValues(facts: Record<string, string>): Record<string, string>;
19
26
  /**
20
27
  * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
21
28
  * validate the pair. Throws `UsageError` on malformed input.
package/dist/metadata.js CHANGED
@@ -48,6 +48,19 @@ export function validateMetaEntry(key, value) {
48
48
  export function isMetaValueSafe(value) {
49
49
  return value.length >= 1 && value.length <= META_VALUE_MAX && VALUE_SAFE_RE.test(value);
50
50
  }
51
+ /**
52
+ * Drop, in place, any entry whose value breaks the metadata contract. For
53
+ * *derived* pairs only: a fact we inferred must never fail an upload, so an
54
+ * over-long URL or non-ASCII EXIF string is silently discarded rather than
55
+ * surfaced. Never use this on caller-supplied metadata, which should error.
56
+ */
57
+ export function dropUnsafeMetaValues(facts) {
58
+ for (const [key, value] of Object.entries(facts)) {
59
+ if (!isMetaValueSafe(value))
60
+ delete facts[key];
61
+ }
62
+ return facts;
63
+ }
51
64
  /**
52
65
  * Split `k=v` on the FIRST "=" (so values may themselves contain "="), then
53
66
  * validate the pair. Throws `UsageError` on malformed input.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildinternet/uploads",
3
- "version": "0.19.0",
3
+ "version": "0.22.0",
4
4
  "description": "CLI and client for uploads.sh — workspace-scoped image hosting for GitHub embeds",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -56,6 +56,7 @@
56
56
  "provenance": true
57
57
  },
58
58
  "dependencies": {
59
+ "exif-reader": "^2.0.3",
59
60
  "sharp": "^0.35.3"
60
61
  },
61
62
  "optionalDependencies": {