@crabcrop/url 0.0.0-stage → 0.1.1

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,96 @@
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: HMAC-SHA256
20
+ * of the group name under a subkey derived from the secret
21
+ * (the raw HMAC of the secret over the label
22
+ * `crabcrop/purge-group-key`), its own domain beside the
23
+ * purge subkey, so a per-target purge signature can never
24
+ * be transplanted to the purge-group endpoint. The helper
25
+ * exists so nobody has to know the signed string. This is
26
+ * the signature segment for
27
+ * `POST /purge-group/<signature>/<group>`.
28
+ */
29
+ export declare function purgeGroupSignature(secret: string, group: string): string;
30
+ /**
31
+ * The upload signature for an object name: HMAC-SHA256 of
32
+ * the name under a subkey derived from the secret (the raw
33
+ * HMAC of the secret over the label `crabcrop/upload-key`),
34
+ * so neither an image signature nor a purge signature can
35
+ * ever be replayed as an upload signature. This is the
36
+ * signature segment for `PUT /upload/<signature>/<name>`.
37
+ *
38
+ * With `expires` (unix seconds), the signature covers
39
+ * `exp:<expires>/<name>` instead, and the request path is
40
+ * `PUT /upload/<signature>/exp:<expires>/<name>`: the
41
+ * expiry sits inside the signed string, so it can neither
42
+ * be stripped nor extended, and the server refuses the
43
+ * upload once that second is reached. Delegated upload
44
+ * tokens should always use the expiring form; without it
45
+ * a token replays until the secret rotates.
46
+ */
47
+ export declare function uploadSignature(secret: string, name: string, expires?: number): string;
48
+ export interface BuildOptions {
49
+ /** Unix-seconds expiry, emitted as a leading `exp:` segment. */
50
+ expires?: number;
51
+ /**
52
+ * Key ID to stamp on the signature segment (`<key-id>:<tag>`),
53
+ * for servers running a secret rotation: they verify a keyed
54
+ * segment against the named key only. Requires a non-null
55
+ * secret (an `unsafe` URL has no key to name).
56
+ */
57
+ keyId?: string;
58
+ }
59
+ /** Whether `id` is a well-formed key ID (1-32 of `[A-Za-z0-9_-]`). */
60
+ export declare function isValidKeyId(id: string): boolean;
61
+ /**
62
+ * The signature segment for a keyed signature: `<key-id>:`
63
+ * followed by the hex tag `sign` computes.
64
+ */
65
+ export declare function signWithKeyId(secret: string, keyId: string, remainder: string): string;
66
+ /**
67
+ * Assemble a signed (or `unsafe`) crabcrop URL. `operations`
68
+ * is the operation string verbatim (several `/`-separated
69
+ * segments and `preset:` references included). A null secret
70
+ * produces the literal `unsafe` signature segment, for servers
71
+ * running with unsafe or presets-only configurations.
72
+ */
73
+ export declare function buildURL(base: string, secret: string | null, operations: string, source: string, options?: BuildOptions): string;
74
+ /**
75
+ * Build an `srcset` attribute value: one `{width}x0` URL per
76
+ * width, entries formatted `{url} {width}w` and joined by
77
+ * `, `. An expiry, when given, applies to every entry.
78
+ */
79
+ export declare function buildSrcSet(base: string, secret: string | null, source: string, widths: number[], options?: BuildOptions): string;
80
+ export { explainUrl } from "./explain.ts";
81
+ export { FILTER_DESCRIPTIONS } from "./explain.ts";
82
+ export type { ExplainedSegment, Explanation, SegmentKind, SignatureKind } from "./explain.ts";
83
+ export { filter, joinOps, ops } from "./operations.ts";
84
+ export type { Anchor, BlendMode, CropAlign, CurveChannel, Dimension, ImageFormat, MaskShape, } from "./operations.ts";
85
+ /**
86
+ * Verify a URL's signature locally. Returns true or false, or
87
+ * null when the URL is unsigned or malformed.
88
+ *
89
+ * With a single secret string, the key ID (when the URL
90
+ * carries one) is ignored: the question is whether this
91
+ * secret made the tag. With a keyring (a map of key ID to
92
+ * secret), the server's rotation semantics apply: a keyed
93
+ * URL consults exactly its named key (an unknown ID is
94
+ * false), and an unkeyed URL consults every key in the map.
95
+ */
96
+ export declare function verifyUrlSignature(secret: string | Record<string, string>, input: string): boolean | null;
package/dist/index.js ADDED
@@ -0,0 +1,202 @@
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: HMAC-SHA256
30
+ * of the group name under a subkey derived from the secret
31
+ * (the raw HMAC of the secret over the label
32
+ * `crabcrop/purge-group-key`), its own domain beside the
33
+ * purge subkey, so a per-target purge signature can never
34
+ * be transplanted to the purge-group endpoint. The helper
35
+ * exists so nobody has to know the signed string. This is
36
+ * the signature segment for
37
+ * `POST /purge-group/<signature>/<group>`.
38
+ */
39
+ export function purgeGroupSignature(secret, group) {
40
+ const subkey = Buffer.from(sign(secret, "crabcrop/purge-group-key"), "hex");
41
+ return createHmac("sha256", subkey).update(group, "utf8").digest("hex");
42
+ }
43
+ /**
44
+ * The upload signature for an object name: HMAC-SHA256 of
45
+ * the name under a subkey derived from the secret (the raw
46
+ * HMAC of the secret over the label `crabcrop/upload-key`),
47
+ * so neither an image signature nor a purge signature can
48
+ * ever be replayed as an upload signature. This is the
49
+ * signature segment for `PUT /upload/<signature>/<name>`.
50
+ *
51
+ * With `expires` (unix seconds), the signature covers
52
+ * `exp:<expires>/<name>` instead, and the request path is
53
+ * `PUT /upload/<signature>/exp:<expires>/<name>`: the
54
+ * expiry sits inside the signed string, so it can neither
55
+ * be stripped nor extended, and the server refuses the
56
+ * upload once that second is reached. Delegated upload
57
+ * tokens should always use the expiring form; without it
58
+ * a token replays until the secret rotates.
59
+ */
60
+ export function uploadSignature(secret, name, expires) {
61
+ const subkey = Buffer.from(sign(secret, "crabcrop/upload-key"), "hex");
62
+ const signed = expires === undefined ? name : `exp:${expires}/${name}`;
63
+ return createHmac("sha256", subkey).update(signed, "utf8").digest("hex");
64
+ }
65
+ /** Whether `id` is a well-formed key ID (1-32 of `[A-Za-z0-9_-]`). */
66
+ export function isValidKeyId(id) {
67
+ return /^[A-Za-z0-9_-]{1,32}$/.test(id);
68
+ }
69
+ /**
70
+ * The signature segment for a keyed signature: `<key-id>:`
71
+ * followed by the hex tag `sign` computes.
72
+ */
73
+ export function signWithKeyId(secret, keyId, remainder) {
74
+ if (!isValidKeyId(keyId)) {
75
+ throw new Error(`invalid key ID ${JSON.stringify(keyId)}: 1 to 32 characters of [A-Za-z0-9_-]`);
76
+ }
77
+ return `${keyId}:${sign(secret, remainder)}`;
78
+ }
79
+ /**
80
+ * Fail loudly on inputs that can never produce a verifiable URL
81
+ * (audit P-4, P-5): the remainder travels as a raw URL path, so
82
+ * `?`/`#` would truncate it and whitespace, control, and
83
+ * non-ASCII characters cannot be sent as-is; an expiry must be
84
+ * a non-negative integer the server's u64 parser accepts.
85
+ */
86
+ function assertUrlText(text, what) {
87
+ for (const c of text) {
88
+ const code = c.codePointAt(0);
89
+ if (c === "?" || c === "#" || code <= 0x20 || code === 0x7f || code > 0x7e) {
90
+ throw new Error(`${what} contains a character that cannot appear in a crabcrop URL: ${JSON.stringify(c)}`);
91
+ }
92
+ }
93
+ }
94
+ function assertExpires(expires) {
95
+ if (!Number.isSafeInteger(expires) || expires < 0) {
96
+ throw new Error(`expires must be a non-negative integer number of unix seconds, got ${expires}`);
97
+ }
98
+ }
99
+ /**
100
+ * A srcset width becomes a `{width}x0` size segment, so it
101
+ * must satisfy the grammar's dimension rules: a positive
102
+ * integer within the server cap. Anything else mints a
103
+ * URL the server rejects (or worse, `-300` reads as a
104
+ * flip), so it throws here, at build time.
105
+ */
106
+ function assertWidth(width) {
107
+ if (!Number.isSafeInteger(width) || width < 1 || width > 16383) {
108
+ throw new Error(`srcset width must be a positive integer no larger than 16383, got ${width}`);
109
+ }
110
+ }
111
+ /**
112
+ * Assemble a signed (or `unsafe`) crabcrop URL. `operations`
113
+ * is the operation string verbatim (several `/`-separated
114
+ * segments and `preset:` references included). A null secret
115
+ * produces the literal `unsafe` signature segment, for servers
116
+ * running with unsafe or presets-only configurations.
117
+ */
118
+ export function buildURL(base, secret, operations, source, options = {}) {
119
+ if (options.expires !== undefined) {
120
+ assertExpires(options.expires);
121
+ }
122
+ assertUrlText(operations, "operations");
123
+ assertUrlText(source, "source");
124
+ const trimmedBase = base.replace(/\/+$/, "");
125
+ let remainder = "";
126
+ if (options.expires !== undefined) {
127
+ remainder += `exp:${options.expires}/`;
128
+ }
129
+ if (operations) {
130
+ remainder += `${operations}/`;
131
+ }
132
+ remainder += source;
133
+ let signature;
134
+ if (secret === null) {
135
+ if (options.keyId !== undefined) {
136
+ throw new Error("keyId requires a secret: an unsafe URL has no key to name");
137
+ }
138
+ signature = "unsafe";
139
+ }
140
+ else if (options.keyId !== undefined) {
141
+ signature = signWithKeyId(secret, options.keyId, remainder);
142
+ }
143
+ else {
144
+ signature = sign(secret, remainder);
145
+ }
146
+ return `${trimmedBase}/${signature}/${remainder}`;
147
+ }
148
+ /**
149
+ * Build an `srcset` attribute value: one `{width}x0` URL per
150
+ * width, entries formatted `{url} {width}w` and joined by
151
+ * `, `. An expiry, when given, applies to every entry.
152
+ */
153
+ export function buildSrcSet(base, secret, source, widths, options = {}) {
154
+ for (const w of widths) {
155
+ assertWidth(w);
156
+ }
157
+ return widths
158
+ .map((w) => `${buildURL(base, secret, `${w}x0`, source, options)} ${w}w`)
159
+ .join(", ");
160
+ }
161
+ export { explainUrl } from "./explain.js";
162
+ export { FILTER_DESCRIPTIONS } from "./explain.js";
163
+ export { filter, joinOps, ops } from "./operations.js";
164
+ import { explainUrl } from "./explain.js";
165
+ /**
166
+ * Verify a URL's signature locally. Returns true or false, or
167
+ * null when the URL is unsigned or malformed.
168
+ *
169
+ * With a single secret string, the key ID (when the URL
170
+ * carries one) is ignored: the question is whether this
171
+ * secret made the tag. With a keyring (a map of key ID to
172
+ * secret), the server's rotation semantics apply: a keyed
173
+ * URL consults exactly its named key (an unknown ID is
174
+ * false), and an unkeyed URL consults every key in the map.
175
+ */
176
+ export function verifyUrlSignature(secret, input) {
177
+ const { signature, remainder } = explainUrl(input);
178
+ if (!signature || signature.kind !== "hmac") {
179
+ return null;
180
+ }
181
+ // Constant-time, like the Rust and Python verifiers: a
182
+ // plain === leaks how much of the tag was right.
183
+ const given = Buffer.from(signature.value, "utf8");
184
+ const matches = (candidate) => {
185
+ const expected = Buffer.from(sign(candidate, remainder), "utf8");
186
+ return expected.length === given.length && timingSafeEqual(expected, given);
187
+ };
188
+ if (typeof secret === "string") {
189
+ return matches(secret);
190
+ }
191
+ if (signature.keyId !== undefined) {
192
+ // Own properties only: key IDs are attacker-shaped text
193
+ // (`constructor`, `toString`, `__proto__` are all legal
194
+ // IDs), and an inherited lookup would hand a non-string
195
+ // to the HMAC and throw instead of answering false.
196
+ const candidate = Object.hasOwn(secret, signature.keyId)
197
+ ? secret[signature.keyId]
198
+ : undefined;
199
+ return typeof candidate === "string" ? matches(candidate) : false;
200
+ }
201
+ return Object.values(secret).some(matches);
202
+ }
@@ -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
+ };