@ideare/kit 0.0.0-stage → 0.1.0-next.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -2
  3. package/THIRD_PARTY.md +52 -0
  4. package/dist/brand/schema.d.ts +265 -0
  5. package/dist/brand/schema.js +271 -0
  6. package/dist/check/copy.d.ts +27 -0
  7. package/dist/check/copy.js +139 -0
  8. package/dist/check/index.d.ts +54 -0
  9. package/dist/check/index.js +492 -0
  10. package/dist/color/contrast.d.ts +41 -0
  11. package/dist/color/contrast.js +84 -0
  12. package/dist/color/palette.d.ts +27 -0
  13. package/dist/color/palette.js +114 -0
  14. package/dist/color/wcag.d.ts +22 -0
  15. package/dist/color/wcag.js +50 -0
  16. package/dist/draft/draft.d.ts +61 -0
  17. package/dist/draft/draft.js +383 -0
  18. package/dist/editor/history.d.ts +78 -0
  19. package/dist/editor/history.js +221 -0
  20. package/dist/editor/operations.d.ts +112 -0
  21. package/dist/editor/operations.js +621 -0
  22. package/dist/files/docs.d.ts +49 -0
  23. package/dist/files/docs.js +392 -0
  24. package/dist/files/generate.d.ts +47 -0
  25. package/dist/files/generate.js +254 -0
  26. package/dist/files/png.d.ts +40 -0
  27. package/dist/files/png.js +168 -0
  28. package/dist/files/raster.d.ts +14 -0
  29. package/dist/files/raster.js +0 -0
  30. package/dist/files/templates.d.ts +162 -0
  31. package/dist/files/templates.js +390 -0
  32. package/dist/files/tokens.d.ts +22 -0
  33. package/dist/files/tokens.js +204 -0
  34. package/dist/fonts/catalog.d.ts +3 -0
  35. package/dist/fonts/catalog.js +345 -0
  36. package/dist/fonts/ofl.d.ts +5 -0
  37. package/dist/fonts/ofl.js +5 -0
  38. package/dist/fonts/pick.d.ts +33 -0
  39. package/dist/fonts/pick.js +73 -0
  40. package/dist/imports/folder.d.ts +80 -0
  41. package/dist/imports/folder.js +124 -0
  42. package/dist/imports/unzip.d.ts +32 -0
  43. package/dist/imports/unzip.js +128 -0
  44. package/dist/index.d.ts +59 -0
  45. package/dist/index.js +34 -0
  46. package/dist/marks/draw.d.ts +48 -0
  47. package/dist/marks/draw.js +149 -0
  48. package/dist/marks/icons.data.d.ts +5 -0
  49. package/dist/marks/icons.data.js +600 -0
  50. package/dist/marks/svg.d.ts +46 -0
  51. package/dist/marks/svg.js +341 -0
  52. package/dist/node/index.d.ts +13 -0
  53. package/dist/node/index.js +41 -0
  54. package/dist/posts/pix.d.ts +19 -0
  55. package/dist/posts/pix.js +49 -0
  56. package/dist/posts/templates.d.ts +45 -0
  57. package/dist/posts/templates.js +868 -0
  58. package/dist/resvg/index.d.ts +2 -0
  59. package/dist/resvg/index.js +22 -0
  60. package/dist/scene/brand-art.d.ts +49 -0
  61. package/dist/scene/brand-art.js +154 -0
  62. package/dist/scene/frame.d.ts +89 -0
  63. package/dist/scene/frame.js +213 -0
  64. package/dist/scene/grain.d.ts +7 -0
  65. package/dist/scene/grain.js +7 -0
  66. package/dist/scene/qr.d.ts +10 -0
  67. package/dist/scene/qr.js +359 -0
  68. package/dist/scene/render.d.ts +76 -0
  69. package/dist/scene/render.js +557 -0
  70. package/dist/scene/schema.d.ts +271 -0
  71. package/dist/scene/schema.js +257 -0
  72. package/dist/sign.d.ts +47 -0
  73. package/dist/sign.js +88 -0
  74. package/dist/text/engine.d.ts +128 -0
  75. package/dist/text/engine.js +245 -0
  76. package/dist/text/fonts.d.ts +56 -0
  77. package/dist/text/fonts.js +65 -0
  78. package/dist/version.d.ts +2 -0
  79. package/dist/version.js +2 -0
  80. package/dist/wasm/index.d.ts +15 -0
  81. package/dist/wasm/index.js +46 -0
  82. package/package.json +78 -5
  83. package/schema/brand.v1.json +442 -0
@@ -0,0 +1,271 @@
1
+ import { z } from "zod";
2
+ import { type ColorRole } from "../brand/schema.ts";
3
+ /**
4
+ * The scene, v1: what every derived file, every editor design and every live image is drawn
5
+ * from. The shape stays close to Polotno's (pages of absolutely placed layers), so importing and
6
+ * exporting stays cheap. Coordinates are px in the page's own space; print sizes are converted to
7
+ * px at their dpi by the template that makes them. A group's layers sit in the group's own space:
8
+ * (0, 0) is the group frame's top-left corner, and they move and turn with it. A frame's `rotate`
9
+ * is in degrees, clockwise, about the frame's center.
10
+ *
11
+ * Page ids are unique in a scene and layer ids are unique on their page, groups included: the
12
+ * editor, its history and the render's boxes all find a layer by its id.
13
+ */
14
+ export declare const SCENE_VERSION = 1;
15
+ export type SolidPaint = {
16
+ token: ColorRole;
17
+ alpha?: number;
18
+ } | {
19
+ hex: string;
20
+ alpha?: number;
21
+ };
22
+ export type GradientStop = {
23
+ at: number;
24
+ paint: SolidPaint;
25
+ };
26
+ export type Paint = SolidPaint | {
27
+ linear: {
28
+ angle: number;
29
+ stops: GradientStop[];
30
+ };
31
+ }
32
+ /** cx, cy and r are fractions of the layer's box (r of its larger side). */
33
+ | {
34
+ radial: {
35
+ cx: number;
36
+ cy: number;
37
+ r: number;
38
+ stops: GradientStop[];
39
+ };
40
+ };
41
+ export type FontRef = {
42
+ role: "display" | "text";
43
+ weight?: number;
44
+ } | {
45
+ id: string;
46
+ weight: number;
47
+ };
48
+ export type Frame = {
49
+ x: number;
50
+ y: number;
51
+ w: number;
52
+ h: number;
53
+ rotate?: number;
54
+ };
55
+ export type Lock = {
56
+ move?: boolean;
57
+ resize?: boolean;
58
+ edit?: boolean;
59
+ style?: boolean;
60
+ delete?: boolean;
61
+ };
62
+ export type AssetRef = {
63
+ sha256: string;
64
+ width: number;
65
+ height: number;
66
+ mime: string;
67
+ };
68
+ export type Blend = "normal" | "multiply" | "screen" | "overlay";
69
+ type Base = {
70
+ id: string;
71
+ name?: string;
72
+ frame: Frame;
73
+ opacity?: number;
74
+ blend?: Blend;
75
+ hidden?: boolean;
76
+ lock?: Lock;
77
+ /** "Use any color" and any font: hex paints and font ids outside the brand are allowed. */
78
+ free?: boolean;
79
+ };
80
+ export type TextLayer = Base & {
81
+ type: "text";
82
+ text: string;
83
+ /** A slot the template or a live image URL can fill (`t.<slot>`). */
84
+ slot?: string;
85
+ style: {
86
+ font: FontRef;
87
+ size: number;
88
+ lineHeight: number;
89
+ tracking?: number;
90
+ align?: "start" | "center" | "end";
91
+ valign?: "top" | "middle" | "bottom";
92
+ uppercase?: boolean;
93
+ fill: SolidPaint;
94
+ };
95
+ fit?: {
96
+ mode: "none" | "shrink";
97
+ min: number;
98
+ maxLines?: number;
99
+ };
100
+ };
101
+ export type ImageLayer = Base & {
102
+ type: "image";
103
+ slot?: string;
104
+ asset: AssetRef;
105
+ fit: "cover" | "contain";
106
+ /** Where the crop centers, 0..1 on each axis. */
107
+ focus?: {
108
+ x: number;
109
+ y: number;
110
+ };
111
+ radius?: number;
112
+ mask?: "rect" | "circle";
113
+ };
114
+ export type LogoLayer = Base & {
115
+ type: "logo";
116
+ variant: "mark" | "small" | "wordmark" | "horizontal" | "stacked";
117
+ /** color: as drawn, adapted to the ground. ink or white: one color. auto: the same as color. */
118
+ tone: "color" | "ink" | "white" | "auto";
119
+ /** Where the logo sits across its frame when the frame is wider. Default center. */
120
+ align?: "start" | "center" | "end";
121
+ };
122
+ export type ShapeLayer = Base & {
123
+ type: "shape";
124
+ shape: "rect" | "ellipse" | "line" | "path";
125
+ /** Path data in the frame's own space (0,0 is the frame's corner). */
126
+ d?: string;
127
+ radius?: number;
128
+ fill?: Paint;
129
+ stroke?: {
130
+ paint: SolidPaint;
131
+ width: number;
132
+ dash?: number[];
133
+ };
134
+ shadow?: {
135
+ x: number;
136
+ y: number;
137
+ blur: number;
138
+ paint: SolidPaint;
139
+ };
140
+ };
141
+ export type IconLayer = Base & {
142
+ type: "icon";
143
+ /** A name from the kit's icon set, like "scissors" or "lucide:scissors". */
144
+ icon: string;
145
+ stroke: SolidPaint;
146
+ strokeWidth?: number;
147
+ };
148
+ export type ArtLayer = Base & {
149
+ type: "art";
150
+ /** A photo slot the editor and the template name (`photo`), filled by an upload or AI art. */
151
+ slot?: string;
152
+ asset?: AssetRef;
153
+ /** The brief for an AI picture in the brand's style; drawn by a job, never in the scene. */
154
+ prompt?: string;
155
+ focus?: {
156
+ x: number;
157
+ y: number;
158
+ };
159
+ radius?: number;
160
+ };
161
+ export type QrLayer = Base & {
162
+ type: "qr";
163
+ value: string;
164
+ fill: SolidPaint;
165
+ background?: SolidPaint;
166
+ ecc?: "L" | "M" | "Q" | "H";
167
+ };
168
+ export type GroupLayer = Base & {
169
+ type: "group";
170
+ layers: Layer[];
171
+ };
172
+ export type Layer = TextLayer | ImageLayer | LogoLayer | ShapeLayer | IconLayer | ArtLayer | QrLayer | GroupLayer;
173
+ export type Background = Paint | {
174
+ image: AssetRef;
175
+ fit: "cover" | "contain";
176
+ focus?: {
177
+ x: number;
178
+ y: number;
179
+ };
180
+ };
181
+ export type Page = {
182
+ id: string;
183
+ name?: string;
184
+ background: Background;
185
+ /** A tiled grain over the page, 0..1 opacity (a PNG tile, never feTurbulence). */
186
+ grain?: number;
187
+ layers: Layer[];
188
+ };
189
+ export type Slot = {
190
+ kind: "text" | "image";
191
+ label: string;
192
+ maxChars?: number;
193
+ required?: boolean;
194
+ live?: boolean;
195
+ };
196
+ export type Scene = {
197
+ v: 1;
198
+ id?: string;
199
+ /** A preset from FORMATS, like "ig-post". */
200
+ format?: string;
201
+ size: {
202
+ w: number;
203
+ h: number;
204
+ };
205
+ theme: "light" | "dark";
206
+ locale?: string;
207
+ pages: Page[];
208
+ slots?: Record<string, Slot>;
209
+ meta?: {
210
+ name?: string;
211
+ template?: string;
212
+ source?: "ai" | "user" | "template";
213
+ };
214
+ };
215
+ export declare const layerSchema: z.ZodType<Layer>;
216
+ export declare const sceneSchema: z.ZodType<Scene>;
217
+ export declare function parseScene(input: unknown): {
218
+ ok: true;
219
+ scene: Scene;
220
+ } | {
221
+ ok: false;
222
+ issues: {
223
+ path: string;
224
+ message: string;
225
+ }[];
226
+ };
227
+ /** The sizes the studio offers and the derived files use, in px. */
228
+ export declare const FORMATS: {
229
+ readonly "ig-post": {
230
+ readonly w: 1080;
231
+ readonly h: 1350;
232
+ readonly label: "Instagram post";
233
+ };
234
+ readonly "ig-square": {
235
+ readonly w: 1080;
236
+ readonly h: 1080;
237
+ readonly label: "Square post";
238
+ };
239
+ readonly "ig-story": {
240
+ readonly w: 1080;
241
+ readonly h: 1920;
242
+ readonly label: "Story";
243
+ };
244
+ readonly "wa-status": {
245
+ readonly w: 1080;
246
+ readonly h: 1920;
247
+ readonly label: "WhatsApp status";
248
+ };
249
+ readonly "share-card": {
250
+ readonly w: 1200;
251
+ readonly h: 630;
252
+ readonly label: "Share card";
253
+ };
254
+ readonly "x-header": {
255
+ readonly w: 1500;
256
+ readonly h: 500;
257
+ readonly label: "X header";
258
+ };
259
+ readonly "linkedin-banner": {
260
+ readonly w: 1584;
261
+ readonly h: 396;
262
+ readonly label: "LinkedIn banner";
263
+ };
264
+ readonly avatar: {
265
+ readonly w: 1080;
266
+ readonly h: 1080;
267
+ readonly label: "Profile picture";
268
+ };
269
+ };
270
+ export type FormatId = keyof typeof FORMATS;
271
+ export {};
@@ -0,0 +1,257 @@
1
+ import { z } from "zod";
2
+ import { COLOR_ROLES } from "../brand/schema.js";
3
+ /**
4
+ * The scene, v1: what every derived file, every editor design and every live image is drawn
5
+ * from. The shape stays close to Polotno's (pages of absolutely placed layers), so importing and
6
+ * exporting stays cheap. Coordinates are px in the page's own space; print sizes are converted to
7
+ * px at their dpi by the template that makes them. A group's layers sit in the group's own space:
8
+ * (0, 0) is the group frame's top-left corner, and they move and turn with it. A frame's `rotate`
9
+ * is in degrees, clockwise, about the frame's center.
10
+ *
11
+ * Page ids are unique in a scene and layer ids are unique on their page, groups included: the
12
+ * editor, its history and the render's boxes all find a layer by its id.
13
+ */
14
+ export const SCENE_VERSION = 1;
15
+ const unit = z.number().min(0).max(1);
16
+ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/);
17
+ const solid = z.union([
18
+ z.object({ token: z.enum(COLOR_ROLES), alpha: unit.optional() }).strict(),
19
+ z.object({ hex, alpha: unit.optional() }).strict(),
20
+ ]);
21
+ const stops = z
22
+ .array(z.object({ at: unit, paint: solid }))
23
+ .min(2)
24
+ .max(8);
25
+ const paint = z.union([
26
+ solid,
27
+ z.object({ linear: z.object({ angle: z.number(), stops }) }).strict(),
28
+ z
29
+ .object({
30
+ radial: z.object({ cx: z.number(), cy: z.number(), r: z.number().positive(), stops }),
31
+ })
32
+ .strict(),
33
+ ]);
34
+ const focus = z.object({ x: unit, y: unit });
35
+ /** An upload's own size, as the scene claims it. The renderer draws by it, so it is capped. */
36
+ const assetSide = z.number().int().positive().max(65_535);
37
+ const asset = z.object({
38
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
39
+ width: assetSide,
40
+ height: assetSide,
41
+ mime: z.enum(["image/png", "image/jpeg"]),
42
+ });
43
+ const frame = z.object({
44
+ x: z.number(),
45
+ y: z.number(),
46
+ w: z.number().nonnegative(),
47
+ h: z.number().nonnegative(),
48
+ rotate: z.number().optional(),
49
+ });
50
+ const base = {
51
+ id: z.string().min(1).max(64),
52
+ name: z.string().max(80).optional(),
53
+ frame,
54
+ opacity: unit.optional(),
55
+ blend: z.enum(["normal", "multiply", "screen", "overlay"]).optional(),
56
+ hidden: z.boolean().optional(),
57
+ lock: z
58
+ .object({
59
+ move: z.boolean().optional(),
60
+ resize: z.boolean().optional(),
61
+ edit: z.boolean().optional(),
62
+ style: z.boolean().optional(),
63
+ delete: z.boolean().optional(),
64
+ })
65
+ .optional(),
66
+ free: z.boolean().optional(),
67
+ };
68
+ const fontRef = z.union([
69
+ z.object({
70
+ role: z.enum(["display", "text"]),
71
+ weight: z.number().int().min(100).max(900).optional(),
72
+ }),
73
+ z.object({ id: z.string().regex(/^[a-z0-9-]+$/), weight: z.number().int().min(100).max(900) }),
74
+ ]);
75
+ export const layerSchema = z.lazy(() => z.discriminatedUnion("type", [
76
+ z.object({
77
+ ...base,
78
+ type: z.literal("text"),
79
+ text: z.string().max(2000),
80
+ slot: z.string().max(40).optional(),
81
+ style: z.object({
82
+ font: fontRef,
83
+ size: z.number().positive().max(2000),
84
+ lineHeight: z.number().positive().max(4),
85
+ tracking: z.number().min(-0.5).max(1).optional(),
86
+ align: z.enum(["start", "center", "end"]).optional(),
87
+ valign: z.enum(["top", "middle", "bottom"]).optional(),
88
+ uppercase: z.boolean().optional(),
89
+ fill: solid,
90
+ }),
91
+ fit: z
92
+ .object({
93
+ mode: z.enum(["none", "shrink"]),
94
+ min: z.number().positive(),
95
+ maxLines: z.number().int().positive().optional(),
96
+ })
97
+ .optional(),
98
+ }),
99
+ z.object({
100
+ ...base,
101
+ type: z.literal("image"),
102
+ slot: z.string().max(40).optional(),
103
+ asset,
104
+ fit: z.enum(["cover", "contain"]),
105
+ focus: focus.optional(),
106
+ radius: z.number().nonnegative().optional(),
107
+ mask: z.enum(["rect", "circle"]).optional(),
108
+ }),
109
+ z.object({
110
+ ...base,
111
+ type: z.literal("logo"),
112
+ variant: z.enum(["mark", "small", "wordmark", "horizontal", "stacked"]),
113
+ tone: z.enum(["color", "ink", "white", "auto"]),
114
+ align: z.enum(["start", "center", "end"]).optional(),
115
+ }),
116
+ z.object({
117
+ ...base,
118
+ type: z.literal("shape"),
119
+ shape: z.enum(["rect", "ellipse", "line", "path"]),
120
+ d: z
121
+ .string()
122
+ .regex(/^[MmLlHhVvCcSsQqTtAaZz\d\s,.eE+-]*$/)
123
+ .optional(),
124
+ radius: z.number().nonnegative().optional(),
125
+ fill: paint.optional(),
126
+ stroke: z
127
+ .object({
128
+ paint: solid,
129
+ width: z.number().positive(),
130
+ dash: z.array(z.number().nonnegative()).max(8).optional(),
131
+ })
132
+ .optional(),
133
+ shadow: z
134
+ .object({
135
+ x: z.number(),
136
+ y: z.number(),
137
+ blur: z.number().nonnegative().max(200),
138
+ paint: solid,
139
+ })
140
+ .optional(),
141
+ }),
142
+ z.object({
143
+ ...base,
144
+ type: z.literal("icon"),
145
+ icon: z.string().max(60),
146
+ stroke: solid,
147
+ strokeWidth: z.number().positive().max(6).optional(),
148
+ }),
149
+ z.object({
150
+ ...base,
151
+ type: z.literal("art"),
152
+ slot: z.string().max(40).optional(),
153
+ asset: asset.optional(),
154
+ prompt: z.string().max(2000).optional(),
155
+ focus: focus.optional(),
156
+ radius: z.number().nonnegative().optional(),
157
+ }),
158
+ z.object({
159
+ ...base,
160
+ type: z.literal("qr"),
161
+ value: z.string().min(1).max(2000),
162
+ fill: solid,
163
+ background: solid.optional(),
164
+ ecc: z.enum(["L", "M", "Q", "H"]).optional(),
165
+ }),
166
+ z.object({ ...base, type: z.literal("group"), layers: z.array(layerSchema).max(200) }),
167
+ ]));
168
+ const background = z.union([
169
+ paint,
170
+ z.object({ image: asset, fit: z.enum(["cover", "contain"]), focus: focus.optional() }).strict(),
171
+ ]);
172
+ export const sceneSchema = z
173
+ .object({
174
+ v: z.literal(SCENE_VERSION),
175
+ id: z.string().max(64).optional(),
176
+ format: z.string().max(40).optional(),
177
+ size: z.object({
178
+ w: z.number().int().positive().max(8192),
179
+ h: z.number().int().positive().max(8192),
180
+ }),
181
+ theme: z.enum(["light", "dark"]),
182
+ locale: z.string().max(10).optional(),
183
+ pages: z
184
+ .array(z.object({
185
+ id: z.string().min(1).max(64),
186
+ name: z.string().max(80).optional(),
187
+ background,
188
+ grain: unit.optional(),
189
+ layers: z.array(layerSchema).max(200),
190
+ }))
191
+ .min(1)
192
+ .max(20),
193
+ slots: z
194
+ .record(z.string(), z.object({
195
+ kind: z.enum(["text", "image"]),
196
+ label: z.string().max(60),
197
+ maxChars: z.number().int().positive().optional(),
198
+ required: z.boolean().optional(),
199
+ live: z.boolean().optional(),
200
+ }))
201
+ .optional(),
202
+ meta: z
203
+ .object({
204
+ name: z.string().max(80).optional(),
205
+ template: z.string().max(80).optional(),
206
+ source: z.enum(["ai", "user", "template"]).optional(),
207
+ })
208
+ .optional(),
209
+ })
210
+ .superRefine((scene, ctx) => {
211
+ const pageIds = new Set();
212
+ scene.pages.forEach((page, pageIndex) => {
213
+ if (pageIds.has(page.id))
214
+ ctx.addIssue({
215
+ code: "custom",
216
+ path: ["pages", pageIndex, "id"],
217
+ message: "Give each page its own id.",
218
+ });
219
+ pageIds.add(page.id);
220
+ const layerIds = new Set();
221
+ const walk = (layers, path) => layers.forEach((layer, index) => {
222
+ if (layerIds.has(layer.id))
223
+ ctx.addIssue({
224
+ code: "custom",
225
+ path: [...path, index, "id"],
226
+ message: "Give each layer on a page its own id.",
227
+ });
228
+ layerIds.add(layer.id);
229
+ if (layer.type === "group")
230
+ walk(layer.layers, [...path, index, "layers"]);
231
+ });
232
+ walk(page.layers, ["pages", pageIndex, "layers"]);
233
+ });
234
+ });
235
+ export function parseScene(input) {
236
+ const result = sceneSchema.safeParse(input);
237
+ if (result.success)
238
+ return { ok: true, scene: result.data };
239
+ return {
240
+ ok: false,
241
+ issues: result.error.issues.map((issue) => ({
242
+ path: issue.path.map(String).join(".") || "(root)",
243
+ message: issue.message,
244
+ })),
245
+ };
246
+ }
247
+ /** The sizes the studio offers and the derived files use, in px. */
248
+ export const FORMATS = {
249
+ "ig-post": { w: 1080, h: 1350, label: "Instagram post" },
250
+ "ig-square": { w: 1080, h: 1080, label: "Square post" },
251
+ "ig-story": { w: 1080, h: 1920, label: "Story" },
252
+ "wa-status": { w: 1080, h: 1920, label: "WhatsApp status" },
253
+ "share-card": { w: 1200, h: 630, label: "Share card" },
254
+ "x-header": { w: 1500, h: 500, label: "X header" },
255
+ "linkedin-banner": { w: 1584, h: 396, label: "LinkedIn banner" },
256
+ avatar: { w: 1080, h: 1080, label: "Profile picture" },
257
+ };
package/dist/sign.d.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Purpose-keyed HMAC signing on Web Crypto, so the API, the worker, the SDK and a browser share
3
+ * one implementation (Node 20, Bun, Workers and browsers all have crypto.subtle). Copied from
4
+ * @gusnips/server's token.ts (MIT, same author) so the kit does not depend on a server package.
5
+ *
6
+ * **The purpose is in the key.** The signing key is HMAC(secret, purpose), so the secret never
7
+ * signs anything itself, and a signature made for one purpose never verifies for another.
8
+ */
9
+ /** Equal in a time that does not depend on where they first differ. False when either is empty. */
10
+ export declare function safeEqual(a: string, b: string): boolean;
11
+ /** The base64url HMAC of `message`, keyed by HMAC(secret, purpose). */
12
+ export declare function sign(input: {
13
+ secret: string;
14
+ purpose: string;
15
+ message: string;
16
+ }): Promise<string>;
17
+ export declare function verify(input: {
18
+ secret: string;
19
+ purpose: string;
20
+ message: string;
21
+ signature: string;
22
+ }): Promise<boolean>;
23
+ /** The purpose live image URLs are signed for. Bump the suffix to retire every old link. */
24
+ export declare const LIVE_IMAGE_PURPOSE = "live-image:v1";
25
+ /**
26
+ * The exact string a live image signature covers: the path, then every query parameter except
27
+ * `sig`, sorted by name (then value) and percent-encoded the same way on both sides. Reordering
28
+ * the query or encoding a space as `+` or `%20` gives the same message.
29
+ */
30
+ export declare function canonicalImageMessage(path: string, params: Iterable<[string, string]>): string;
31
+ /**
32
+ * A signed live image URL, like `https://img.ideare.app/acme/sale.png?v=3&t.title=...&sig=...`.
33
+ * The brand's live-image secret signs it; text without a valid signature gets a 403.
34
+ */
35
+ export declare function signImageUrl(input: {
36
+ secret: string;
37
+ /** The origin, like https://img.ideare.app. */
38
+ origin: string;
39
+ /** The path, like /acme/sale.png. */
40
+ path: string;
41
+ params: Record<string, string>;
42
+ }): Promise<string>;
43
+ /** Whether a request URL carries a valid live image signature. */
44
+ export declare function verifyImageUrl(input: {
45
+ secret: string;
46
+ url: string | URL;
47
+ }): Promise<boolean>;
package/dist/sign.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Purpose-keyed HMAC signing on Web Crypto, so the API, the worker, the SDK and a browser share
3
+ * one implementation (Node 20, Bun, Workers and browsers all have crypto.subtle). Copied from
4
+ * @gusnips/server's token.ts (MIT, same author) so the kit does not depend on a server package.
5
+ *
6
+ * **The purpose is in the key.** The signing key is HMAC(secret, purpose), so the secret never
7
+ * signs anything itself, and a signature made for one purpose never verifies for another.
8
+ */
9
+ const encoder = new TextEncoder();
10
+ /** HMAC-SHA256 as raw bytes. Throws on an empty key: Web Crypto refuses one anyway. */
11
+ async function hmacBytes(key, message) {
12
+ // A copy, because Web Crypto takes only a view over a plain ArrayBuffer.
13
+ const raw = new Uint8Array(typeof key === "string" ? encoder.encode(key) : key);
14
+ if (raw.length === 0)
15
+ throw new TypeError("An HMAC key must not be empty");
16
+ const imported = await crypto.subtle.importKey("raw", raw, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
17
+ return new Uint8Array(await crypto.subtle.sign("HMAC", imported, encoder.encode(message)));
18
+ }
19
+ /** Equal in a time that does not depend on where they first differ. False when either is empty. */
20
+ export function safeEqual(a, b) {
21
+ const left = encoder.encode(a);
22
+ const right = encoder.encode(b);
23
+ if (left.length === 0 || left.length !== right.length)
24
+ return false;
25
+ let difference = 0;
26
+ for (let i = 0; i < left.length; i++)
27
+ difference |= (left[i] ?? 0) ^ (right[i] ?? 0);
28
+ return difference === 0;
29
+ }
30
+ function base64Url(bytes) {
31
+ let binary = "";
32
+ for (const byte of bytes)
33
+ binary += String.fromCharCode(byte);
34
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
35
+ }
36
+ /** The base64url HMAC of `message`, keyed by HMAC(secret, purpose). */
37
+ export async function sign(input) {
38
+ if (!input.purpose)
39
+ throw new TypeError("A signature's purpose must not be empty");
40
+ const key = await hmacBytes(input.secret, input.purpose);
41
+ return base64Url(await hmacBytes(key, input.message));
42
+ }
43
+ export async function verify(input) {
44
+ if (!/^[A-Za-z0-9_-]{43}$/.test(input.signature))
45
+ return false;
46
+ return safeEqual(input.signature, await sign(input));
47
+ }
48
+ /** The purpose live image URLs are signed for. Bump the suffix to retire every old link. */
49
+ export const LIVE_IMAGE_PURPOSE = "live-image:v1";
50
+ /**
51
+ * The exact string a live image signature covers: the path, then every query parameter except
52
+ * `sig`, sorted by name (then value) and percent-encoded the same way on both sides. Reordering
53
+ * the query or encoding a space as `+` or `%20` gives the same message.
54
+ */
55
+ export function canonicalImageMessage(path, params) {
56
+ const pairs = [...params]
57
+ .filter(([name]) => name !== "sig")
58
+ .sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0))
59
+ .map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
60
+ return `${path}?${pairs.join("&")}`;
61
+ }
62
+ /**
63
+ * A signed live image URL, like `https://img.ideare.app/acme/sale.png?v=3&t.title=...&sig=...`.
64
+ * The brand's live-image secret signs it; text without a valid signature gets a 403.
65
+ */
66
+ export async function signImageUrl(input) {
67
+ const entries = Object.entries(input.params);
68
+ const sig = await sign({
69
+ secret: input.secret,
70
+ purpose: LIVE_IMAGE_PURPOSE,
71
+ message: canonicalImageMessage(input.path, entries),
72
+ });
73
+ const query = new URLSearchParams([...entries, ["sig", sig]]);
74
+ return `${input.origin.replace(/\/$/, "")}${input.path}?${query.toString()}`;
75
+ }
76
+ /** Whether a request URL carries a valid live image signature. */
77
+ export async function verifyImageUrl(input) {
78
+ const url = typeof input.url === "string" ? new URL(input.url) : input.url;
79
+ const signature = url.searchParams.get("sig");
80
+ if (!signature)
81
+ return false;
82
+ return verify({
83
+ secret: input.secret,
84
+ purpose: LIVE_IMAGE_PURPOSE,
85
+ message: canonicalImageMessage(url.pathname, url.searchParams.entries()),
86
+ signature,
87
+ });
88
+ }