@crabcrop/mcp 0.0.0-stage → 0.1.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,84 @@
1
+ /**
2
+ * Compute the signature segment for a remainder: the lowercase
3
+ * hex HMAC-SHA256 of `remainder` under `secret`. The remainder
4
+ * is everything after the signature segment in the URL,
5
+ * exactly as it will appear.
6
+ */
7
+ export declare function sign(secret: string, remainder: string): string;
8
+ /**
9
+ * The purge signature for a target path (the request
10
+ * remainder without its signature): HMAC-SHA256 of the
11
+ * path under a subkey derived from the secret (the raw
12
+ * HMAC of the secret over the label `crabcrop/purge-key`),
13
+ * so an image signature can never be replayed as a purge
14
+ * signature. This is the signature segment for
15
+ * `POST /purge/<signature>/<path>`.
16
+ */
17
+ export declare function purgeSignature(secret: string, target: string): string;
18
+ /**
19
+ * The purge-group signature for a cache group: a purge
20
+ * signature over the literal string `purge-group/<group>`,
21
+ * which appears nowhere in the request URL. The helper
22
+ * exists so nobody has to know the signed string. This is
23
+ * the signature segment for
24
+ * `POST /purge-group/<signature>/<group>`.
25
+ */
26
+ export declare function purgeGroupSignature(secret: string, group: string): string;
27
+ /**
28
+ * The upload signature for an object name: HMAC-SHA256 of
29
+ * the name under a subkey derived from the secret (the raw
30
+ * HMAC of the secret over the label `crabcrop/upload-key`),
31
+ * so neither an image signature nor a purge signature can
32
+ * ever be replayed as an upload signature. This is the
33
+ * signature segment for `PUT /upload/<signature>/<name>`.
34
+ */
35
+ export declare function uploadSignature(secret: string, name: string): string;
36
+ export interface BuildOptions {
37
+ /** Unix-seconds expiry, emitted as a leading `exp:` segment. */
38
+ expires?: number;
39
+ /**
40
+ * Key ID to stamp on the signature segment (`<key-id>:<tag>`),
41
+ * for servers running a secret rotation: they verify a keyed
42
+ * segment against the named key only. Requires a non-null
43
+ * secret (an `unsafe` URL has no key to name).
44
+ */
45
+ keyId?: string;
46
+ }
47
+ /** Whether `id` is a well-formed key ID (1-32 of `[A-Za-z0-9_-]`). */
48
+ export declare function isValidKeyId(id: string): boolean;
49
+ /**
50
+ * The signature segment for a keyed signature: `<key-id>:`
51
+ * followed by the hex tag `sign` computes.
52
+ */
53
+ export declare function signWithKeyId(secret: string, keyId: string, remainder: string): string;
54
+ /**
55
+ * Assemble a signed (or `unsafe`) crabcrop URL. `operations`
56
+ * is the operation string verbatim (several `/`-separated
57
+ * segments and `preset:` references included). A null secret
58
+ * produces the literal `unsafe` signature segment, for servers
59
+ * running with unsafe or presets-only configurations.
60
+ */
61
+ export declare function buildURL(base: string, secret: string | null, operations: string, source: string, options?: BuildOptions): string;
62
+ /**
63
+ * Build an `srcset` attribute value: one `{width}x0` URL per
64
+ * width, entries formatted `{url} {width}w` and joined by
65
+ * `, `. An expiry, when given, applies to every entry.
66
+ */
67
+ export declare function buildSrcSet(base: string, secret: string | null, source: string, widths: number[], options?: BuildOptions): string;
68
+ export { explainUrl } from "./explain.ts";
69
+ export { FILTER_DESCRIPTIONS } from "./explain.ts";
70
+ export type { ExplainedSegment, Explanation, SegmentKind, SignatureKind } from "./explain.ts";
71
+ export { filter, joinOps, ops } from "./operations.ts";
72
+ export type { Anchor, BlendMode, CropAlign, CurveChannel, Dimension, ImageFormat, MaskShape, } from "./operations.ts";
73
+ /**
74
+ * Verify a URL's signature locally. Returns true or false, or
75
+ * null when the URL is unsigned or malformed.
76
+ *
77
+ * With a single secret string, the key ID (when the URL
78
+ * carries one) is ignored: the question is whether this
79
+ * secret made the tag. With a keyring (a map of key ID to
80
+ * secret), the server's rotation semantics apply: a keyed
81
+ * URL consults exactly its named key (an unknown ID is
82
+ * false), and an unkeyed URL consults every key in the map.
83
+ */
84
+ export declare function verifyUrlSignature(secret: string | Record<string, string>, input: string): boolean | null;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Build and sign crabcrop URLs, per `spec/url-signing.md` in
3
+ * the crabcrop repository. Standard library only.
4
+ */
5
+ import { createHmac, timingSafeEqual } from "node:crypto";
6
+ /**
7
+ * Compute the signature segment for a remainder: the lowercase
8
+ * hex HMAC-SHA256 of `remainder` under `secret`. The remainder
9
+ * is everything after the signature segment in the URL,
10
+ * exactly as it will appear.
11
+ */
12
+ export function sign(secret, remainder) {
13
+ return createHmac("sha256", secret).update(remainder, "utf8").digest("hex");
14
+ }
15
+ /**
16
+ * The purge signature for a target path (the request
17
+ * remainder without its signature): HMAC-SHA256 of the
18
+ * path under a subkey derived from the secret (the raw
19
+ * HMAC of the secret over the label `crabcrop/purge-key`),
20
+ * so an image signature can never be replayed as a purge
21
+ * signature. This is the signature segment for
22
+ * `POST /purge/<signature>/<path>`.
23
+ */
24
+ export function purgeSignature(secret, target) {
25
+ const subkey = Buffer.from(sign(secret, "crabcrop/purge-key"), "hex");
26
+ return createHmac("sha256", subkey).update(target, "utf8").digest("hex");
27
+ }
28
+ /**
29
+ * The purge-group signature for a cache group: a purge
30
+ * signature over the literal string `purge-group/<group>`,
31
+ * which appears nowhere in the request URL. The helper
32
+ * exists so nobody has to know the signed string. This is
33
+ * the signature segment for
34
+ * `POST /purge-group/<signature>/<group>`.
35
+ */
36
+ export function purgeGroupSignature(secret, group) {
37
+ return purgeSignature(secret, `purge-group/${group}`);
38
+ }
39
+ /**
40
+ * The upload signature for an object name: HMAC-SHA256 of
41
+ * the name under a subkey derived from the secret (the raw
42
+ * HMAC of the secret over the label `crabcrop/upload-key`),
43
+ * so neither an image signature nor a purge signature can
44
+ * ever be replayed as an upload signature. This is the
45
+ * signature segment for `PUT /upload/<signature>/<name>`.
46
+ */
47
+ export function uploadSignature(secret, name) {
48
+ const subkey = Buffer.from(sign(secret, "crabcrop/upload-key"), "hex");
49
+ return createHmac("sha256", subkey).update(name, "utf8").digest("hex");
50
+ }
51
+ /** Whether `id` is a well-formed key ID (1-32 of `[A-Za-z0-9_-]`). */
52
+ export function isValidKeyId(id) {
53
+ return /^[A-Za-z0-9_-]{1,32}$/.test(id);
54
+ }
55
+ /**
56
+ * The signature segment for a keyed signature: `<key-id>:`
57
+ * followed by the hex tag `sign` computes.
58
+ */
59
+ export function signWithKeyId(secret, keyId, remainder) {
60
+ if (!isValidKeyId(keyId)) {
61
+ throw new Error(`invalid key ID ${JSON.stringify(keyId)}: 1 to 32 characters of [A-Za-z0-9_-]`);
62
+ }
63
+ return `${keyId}:${sign(secret, remainder)}`;
64
+ }
65
+ /**
66
+ * Fail loudly on inputs that can never produce a verifiable URL
67
+ * (audit P-4, P-5): the remainder travels as a raw URL path, so
68
+ * `?`/`#` would truncate it and whitespace, control, and
69
+ * non-ASCII characters cannot be sent as-is; an expiry must be
70
+ * a non-negative integer the server's u64 parser accepts.
71
+ */
72
+ function assertUrlText(text, what) {
73
+ for (const c of text) {
74
+ const code = c.codePointAt(0);
75
+ if (c === "?" || c === "#" || code <= 0x20 || code === 0x7f || code > 0x7e) {
76
+ throw new Error(`${what} contains a character that cannot appear in a crabcrop URL: ${JSON.stringify(c)}`);
77
+ }
78
+ }
79
+ }
80
+ function assertExpires(expires) {
81
+ if (!Number.isSafeInteger(expires) || expires < 0) {
82
+ throw new Error(`expires must be a non-negative integer number of unix seconds, got ${expires}`);
83
+ }
84
+ }
85
+ /**
86
+ * A srcset width becomes a `{width}x0` size segment, so it
87
+ * must satisfy the grammar's dimension rules: a positive
88
+ * integer within the server cap. Anything else mints a
89
+ * URL the server rejects (or worse, `-300` reads as a
90
+ * flip), so it throws here, at build time.
91
+ */
92
+ function assertWidth(width) {
93
+ if (!Number.isSafeInteger(width) || width < 1 || width > 16383) {
94
+ throw new Error(`srcset width must be a positive integer no larger than 16383, got ${width}`);
95
+ }
96
+ }
97
+ /**
98
+ * Assemble a signed (or `unsafe`) crabcrop URL. `operations`
99
+ * is the operation string verbatim (several `/`-separated
100
+ * segments and `preset:` references included). A null secret
101
+ * produces the literal `unsafe` signature segment, for servers
102
+ * running with unsafe or presets-only configurations.
103
+ */
104
+ export function buildURL(base, secret, operations, source, options = {}) {
105
+ if (options.expires !== undefined) {
106
+ assertExpires(options.expires);
107
+ }
108
+ assertUrlText(operations, "operations");
109
+ assertUrlText(source, "source");
110
+ const trimmedBase = base.replace(/\/+$/, "");
111
+ let remainder = "";
112
+ if (options.expires !== undefined) {
113
+ remainder += `exp:${options.expires}/`;
114
+ }
115
+ if (operations) {
116
+ remainder += `${operations}/`;
117
+ }
118
+ remainder += source;
119
+ let signature;
120
+ if (secret === null) {
121
+ if (options.keyId !== undefined) {
122
+ throw new Error("keyId requires a secret: an unsafe URL has no key to name");
123
+ }
124
+ signature = "unsafe";
125
+ }
126
+ else if (options.keyId !== undefined) {
127
+ signature = signWithKeyId(secret, options.keyId, remainder);
128
+ }
129
+ else {
130
+ signature = sign(secret, remainder);
131
+ }
132
+ return `${trimmedBase}/${signature}/${remainder}`;
133
+ }
134
+ /**
135
+ * Build an `srcset` attribute value: one `{width}x0` URL per
136
+ * width, entries formatted `{url} {width}w` and joined by
137
+ * `, `. An expiry, when given, applies to every entry.
138
+ */
139
+ export function buildSrcSet(base, secret, source, widths, options = {}) {
140
+ for (const w of widths) {
141
+ assertWidth(w);
142
+ }
143
+ return widths
144
+ .map((w) => `${buildURL(base, secret, `${w}x0`, source, options)} ${w}w`)
145
+ .join(", ");
146
+ }
147
+ export { explainUrl } from "./explain.js";
148
+ export { FILTER_DESCRIPTIONS } from "./explain.js";
149
+ export { filter, joinOps, ops } from "./operations.js";
150
+ import { explainUrl } from "./explain.js";
151
+ /**
152
+ * Verify a URL's signature locally. Returns true or false, or
153
+ * null when the URL is unsigned or malformed.
154
+ *
155
+ * With a single secret string, the key ID (when the URL
156
+ * carries one) is ignored: the question is whether this
157
+ * secret made the tag. With a keyring (a map of key ID to
158
+ * secret), the server's rotation semantics apply: a keyed
159
+ * URL consults exactly its named key (an unknown ID is
160
+ * false), and an unkeyed URL consults every key in the map.
161
+ */
162
+ export function verifyUrlSignature(secret, input) {
163
+ const { signature, remainder } = explainUrl(input);
164
+ if (!signature || signature.kind !== "hmac") {
165
+ return null;
166
+ }
167
+ // Constant-time, like the Rust and Python verifiers: a
168
+ // plain === leaks how much of the tag was right.
169
+ const given = Buffer.from(signature.value, "utf8");
170
+ const matches = (candidate) => {
171
+ const expected = Buffer.from(sign(candidate, remainder), "utf8");
172
+ return expected.length === given.length && timingSafeEqual(expected, given);
173
+ };
174
+ if (typeof secret === "string") {
175
+ return matches(secret);
176
+ }
177
+ if (signature.keyId !== undefined) {
178
+ const candidate = secret[signature.keyId];
179
+ return candidate === undefined ? false : matches(candidate);
180
+ }
181
+ return Object.values(secret).some(matches);
182
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Typed builders for crabcrop operation strings (AP-24).
3
+ *
4
+ * `buildURL` takes operations verbatim, by design: the string
5
+ * is the spec. This module is the optional typed surface over
6
+ * it: each call validates its arguments against the server's
7
+ * grammar (the same bounds the explainer enforces) and returns
8
+ * the operation segment in its canonical spelling, so a typed
9
+ * pipeline and the equivalent hand-written string produce
10
+ * byte-identical URLs. Validation throws at build time, like
11
+ * `buildSrcSet` does for widths: a value the server would 400
12
+ * never gets signed.
13
+ */
14
+ /** Output formats the `format(...)` filter accepts. */
15
+ export type ImageFormat = "jpeg" | "jpg" | "png" | "webp" | "jxl" | "avif" | "gif" | "mp4" | "webm";
16
+ /** Crop alignments; `center` is the bare `crop:WxH` spelling. */
17
+ export type CropAlign = "center" | "smart" | "face" | "top" | "bottom" | "left" | "right" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
18
+ /** Text-overlay anchor points. */
19
+ export type Anchor = "top-left" | "top-center" | "top-right" | "center-left" | "center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right";
20
+ /** Overlay blend modes. */
21
+ export type BlendMode = "normal" | "multiply" | "screen" | "overlay";
22
+ /** Mask shapes for the `mask(...)` filter. */
23
+ export type MaskShape = "circle" | "ellipse";
24
+ /** Channels the `curve(...)` filter remaps. */
25
+ export type CurveChannel = "rgb" | "r" | "g" | "b";
26
+ /**
27
+ * One side of a size: a pixel count (1..16383), 0 for
28
+ * proportional, a negative count to flip that axis, or
29
+ * `"orig"` for the source's own dimension.
30
+ */
31
+ export type Dimension = number | "orig";
32
+ /** Operation segments, one typed call per segment kind. */
33
+ export declare const ops: {
34
+ /** `300x200`; each side a Dimension (0 proportional, negative flips). */
35
+ size(width: Dimension, height: Dimension): string;
36
+ /**
37
+ * `fit-in:300x200`; no flips (the server rejects them here).
38
+ * With `adaptive`, `fit-in:300x200:adaptive`: the box axes
39
+ * swap when the source orientation disagrees with the box's.
40
+ */
41
+ fitIn(width: Dimension, height: Dimension, adaptive?: boolean): string;
42
+ /** `crop:300x200` or `crop:300x200:smart`; an exact pixel box. */
43
+ crop(width: number, height: number, align?: CropAlign): string;
44
+ /** `crop-rect:10x20:110x220` (left x top : right x bottom). */
45
+ cropRect(left: number, top: number, right: number, bottom: number): string;
46
+ /** `focal:10x20:110x220` (left x top : right x bottom); steers the next crop. */
47
+ focal(left: number, top: number, right: number, bottom: number): string;
48
+ /** `pad:300x200`, optionally `:blur` or a RRGGBB fill color. */
49
+ pad(width: number, height: number, fill?: "blur" | string): string;
50
+ /** `preset:thumb`; expands server-side. */
51
+ preset(name: string): string;
52
+ /**
53
+ * `maxage:60`; this response's Cache-Control lifetime in
54
+ * seconds (0 to 31536000), overriding the default year for
55
+ * this request only. Header-only: it never enters the
56
+ * cache key, so spellings that differ only here share one
57
+ * stored image.
58
+ */
59
+ maxage(seconds: number): string;
60
+ /**
61
+ * `debug:focal`; the smart-crop debug view. Place it
62
+ * before the crop it annotates: the response is a
63
+ * diagnostic image of the focal decision (focal point,
64
+ * focal rectangle, crop window drawn on the crop's
65
+ * canvas), never the transform result, and it is never
66
+ * cached (served with Cache-Control: no-store).
67
+ */
68
+ debugFocal(): string;
69
+ /** Join filter calls into one `filters:` segment. */
70
+ filters(...calls: string[]): string;
71
+ };
72
+ /** Join operation segments into the operations string `buildURL` takes. */
73
+ export declare function joinOps(...segments: string[]): string;
74
+ /** Filter calls for `ops.filters(...)`; each returns its canonical spelling. */
75
+ export declare const filter: {
76
+ grayscale(): string;
77
+ invert(): string;
78
+ noUpscale(): string;
79
+ equalize(): string;
80
+ removeBg(): string;
81
+ keepProfile(): string;
82
+ sepia(): string;
83
+ blurFaces(): string;
84
+ pixelateFaces(): string;
85
+ /** Gaussian blur by sigma (positive). */
86
+ blur(sigma: number): string;
87
+ /** Unsharp-mask strength (positive). */
88
+ sharpen(amount: number): string;
89
+ /** Add to every channel: -255..255. */
90
+ brighten(amount: number): string;
91
+ /** Percent contrast: -100..100 (0 unchanged, -100 flat). */
92
+ contrast(percent: number): string;
93
+ /** Rotate the hue by integer degrees. */
94
+ hueRotate(degrees: number): string;
95
+ /** Saturation factor: 0..255 (0 grayscale, 1 identity). */
96
+ saturation(factor: number): string;
97
+ /** Per-channel offsets, each -255..255. */
98
+ rgb(r: number, g: number, b: number): string;
99
+ /**
100
+ * Tone curve: remap the channel through 2 to 16 [x, y]
101
+ * control points, each coordinate 0..255 with x strictly
102
+ * increasing. Between points the server interpolates
103
+ * linearly; inputs outside the point range clamp to the
104
+ * nearest end value.
105
+ */
106
+ curve(channel: CurveChannel, points: [number, number][]): string;
107
+ /**
108
+ * Convolution: apply a caller-supplied kernel, 9 values
109
+ * (3x3) or 25 (5x5) in row-major order, each a plain
110
+ * decimal from -1024 to 1024. With normalize set the
111
+ * server scales the kernel by the sum of its entries
112
+ * first (a zero sum applies the kernel as written). The
113
+ * edges replicate. `normalize` is a boolean or the 0/1
114
+ * flag itself.
115
+ */
116
+ convolution(values: number[], normalize?: boolean | 0 | 1): string;
117
+ /**
118
+ * Film grain: one noise value per pixel, added equally
119
+ * to all three channels (alpha untouched), the value
120
+ * uniform in plus or minus the given percent of full
121
+ * scale. The field is deterministic, seeded from the
122
+ * image content and the amount, so the same request
123
+ * always renders the same bytes; 0 is a no-op.
124
+ */
125
+ noise(amount: number): string;
126
+ /**
127
+ * Rotate by an angle in degrees (decimals allowed, no
128
+ * exponent spelling); multiples of 90 are exact quarter
129
+ * turns on the server.
130
+ */
131
+ rotate(angle: number): string;
132
+ /** Trim uniform borders; tolerance 0..255, default 10. */
133
+ trim(tolerance?: number): string;
134
+ /** Encoding quality 1..100, or `"auto"` for perceptual targeting. */
135
+ quality(value: number | "auto"): string;
136
+ format(format: ImageFormat): string;
137
+ /** The background color other operations pad with (RRGGBB). */
138
+ fill(color: string): string;
139
+ /** How much a face crop expands around the face: 0..3. */
140
+ facePad(amount: number): string;
141
+ /** AI upscale by the given factor. */
142
+ upscale(factor: 2 | 4): string;
143
+ /** Device pixel ratio the sizes are multiplied by. */
144
+ dpr(factor: 1 | 1.5 | 2 | 3): string;
145
+ /** Cap the output bytes, lowering quality to fit: at least 1. */
146
+ maxBytes(bytes: number): string;
147
+ /** Round the corners: one radius for all, or [tl, tr, br, bl]. */
148
+ radius(value: number | [number, number, number, number]): string;
149
+ /** Paint an opaque border inside the frame (RRGGBB). */
150
+ border(width: number, color: string): string;
151
+ mask(shape: MaskShape): string;
152
+ duotone(shadow: string, highlight: string): string;
153
+ monochrome(color: string): string;
154
+ /** Mosaic block size in pixels: 1..16383. */
155
+ pixelate(blockSize: number): string;
156
+ flip(axis: "h" | "v"): string;
157
+ /** Overlay a watermark image at (x, y), opacity 0..100. */
158
+ watermark(url: string, x: number, y: number, alpha: number): string;
159
+ /**
160
+ * Composite another image over the result at (x, y) with
161
+ * opacity 0..100 and a blend mode; `widthPct` optionally
162
+ * scales it to that percent of the base width (1..400).
163
+ */
164
+ overlay(url: string, x: number, y: number, alpha: number, blend: BlendMode, widthPct?: number): string;
165
+ /**
166
+ * Draw a text overlay. Color is RRGGBB or RRGGBBAA; the
167
+ * canonical spelling is always eight lowercase hex digits.
168
+ */
169
+ text(content: string, size: number, color: string, anchor: Anchor): string;
170
+ /** Blur every detected object of the named class. */
171
+ blurClass(name: string): string;
172
+ /** Pixelate every detected object of the named class. */
173
+ pixelateClass(name: string): string;
174
+ /** Blur the rectangle (x, y, w, h) in pixels. */
175
+ blurRegion(x: number, y: number, w: number, h: number): string;
176
+ /** Pixelate the rectangle (x, y, w, h) in pixels. */
177
+ pixelateRegion(x: number, y: number, w: number, h: number): string;
178
+ };