@ibanzajoe/uploader 1.4.0 → 1.6.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.
package/dist/server.cjs CHANGED
@@ -61,12 +61,14 @@ function encodePolicy(spec) {
61
61
  }
62
62
  function getSignedDeliveryUrl(options) {
63
63
  const { handle, secret, baseUrl, expiresIn = 300, ops } = options;
64
+ const stableWindow = options.stableWindow ?? expiresIn;
64
65
  if (!handle) throw new Error("getSignedDeliveryUrl: `handle` is required");
65
66
  if (!secret) throw new Error("getSignedDeliveryUrl: `secret` is required");
66
67
  if (!baseUrl) throw new Error("getSignedDeliveryUrl: `baseUrl` is required");
67
68
  const base = baseUrl.replace(/\/$/, "");
68
69
  const path = ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`;
69
- const expiry = Math.floor(Date.now() / 1e3) + expiresIn;
70
+ const earliest = Math.floor(Date.now() / 1e3) + expiresIn;
71
+ const expiry = stableWindow > 0 ? Math.ceil(earliest / stableWindow) * stableWindow : earliest;
70
72
  const policy = encodePolicy({ expiry, call: ["read"], handle });
71
73
  const signature = (0, import_node_crypto.createHmac)("sha256", secret).update(policy).digest("hex");
72
74
  const sep = path.includes("?") ? "&" : "?";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server/index.ts","../src/core/transform.ts"],"sourcesContent":["/**\n * Server-only helpers for `@ibanzajoe/uploader`.\n *\n * These require the account's api-key **secret** and use `node:crypto`, so they\n * MUST run on your backend — never ship the secret to the browser. That is why\n * they live in a dedicated entry point (`@ibanzajoe/uploader/server`) that the\n * browser/React bundles never import.\n *\n * The signing format here mirrors `@uploader/shared` (base64url policy JSON +\n * hex HMAC-SHA256) exactly, so the URLs it produces verify against the same\n * delivery guard the platform runs (`call: ['read']`, handle-bound).\n */\nimport { createHmac } from 'node:crypto'\nimport type { TransformOp } from '../core/transform.js'\nimport { transformUrl } from '../core/transform.js'\n\nexport type GetSignedDeliveryUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The signature is an HMAC over this secret; the guard verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Delivery base URL — the host that serves this file. For a `signed` file this\n * is your edge Worker host, i.e. the origin of `FileResult.url`\n * (e.g. `https://edge.postila.app`).\n */\n baseUrl: string\n /**\n * Seconds until the URL expires. Keep it short and mint a fresh URL per view.\n * Default: 300 (5 minutes).\n */\n expiresIn?: number\n /**\n * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).\n * Omit for the original file.\n */\n ops?: TransformOp[]\n}\n\n/** Base64url-encode a policy spec as JSON (matches `@uploader/shared` encodePolicy). */\nfunction encodePolicy(spec: Record<string, unknown>): string {\n return Buffer.from(JSON.stringify(spec)).toString('base64url')\n}\n\n/**\n * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in\n * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a\n * `<baseUrl>/<chain>/<handle>?…` URL when `ops` is given), signed with\n * HMAC-SHA256 over the account secret in the exact format the edge delivery\n * guard verifies.\n *\n * SERVER-ONLY: needs the account secret. Call it from your backend, per view,\n * with a short `expiresIn`, and hand the returned URL to your frontend\n * (e.g. as an `<img src>`).\n *\n * @example\n * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route:\n * const url = getSignedDeliveryUrl({\n * handle: file.handle, // from the upload response\n * secret: process.env.UPLOADER_API_SECRET!,\n * baseUrl: 'https://edge.postila.app',\n * expiresIn: 300,\n * })\n * // → \"https://edge.postila.app/file/<handle>?policy=…&signature=…\"\n */\nexport function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string {\n const { handle, secret, baseUrl, expiresIn = 300, ops } = options\n if (!handle) throw new Error('getSignedDeliveryUrl: `handle` is required')\n if (!secret) throw new Error('getSignedDeliveryUrl: `secret` is required')\n if (!baseUrl) throw new Error('getSignedDeliveryUrl: `baseUrl` is required')\n\n const base = baseUrl.replace(/\\/$/, '')\n // Original → /file/<handle>; transform → /<chain>/<handle> (edge route grammar).\n const path =\n ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`\n\n const expiry = Math.floor(Date.now() / 1000) + expiresIn\n const policy = encodePolicy({ expiry, call: ['read'], handle })\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n\n const sep = path.includes('?') ? '&' : '?'\n return `${path}${sep}policy=${encodeURIComponent(policy)}&signature=${encodeURIComponent(signature)}`\n}\n","/**\n * Delivery / transform URL builder.\n *\n * The API serves transformed derivatives at `GET /<chain>/<handle>`, where\n * <chain> is a slash-joined list of ops (e.g. `resize=w:200,h:200,fit:crop`).\n * These op builders + `transformUrl()` construct that URL from an uploaded file\n * handle, so consumers render an image at any size/format/quality without\n * hand-assembling URL strings.\n *\n * The op/param types and serialization mirror @uploader/shared EXACTLY — the\n * API's `parseTransformChain` is the other half of this contract, and\n * `contract.conformance.ts` asserts at typecheck time that they stay in sync.\n */\n\n// ─── Op vocabulary (mirrors @uploader/shared) ────────────────────────────────\n\nexport type TransformOpName =\n | 'resize'\n | 'crop'\n | 'rotate'\n | 'flip'\n | 'flop'\n | 'quality'\n | 'output'\n\n/** Parameters for the resize operation. */\nexport type ResizeParams = {\n w?: number\n h?: number\n fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' | 'crop'\n}\n\n/** Parameters for the crop operation. */\nexport type CropParams = {\n /** \"x,y,w,h\" notation. */\n dim: string\n x?: number\n y?: number\n w?: number\n h?: number\n}\n\n/** Parameters for the rotate operation. */\nexport type RotateParams = { deg: number }\n\n/** Parameters for the quality operation. */\nexport type QualityParams = { n: number }\n\n/** Parameters for the output operation. */\nexport type OutputParams = { format: string }\n\n/** A single transform operation with its typed params. */\nexport type TransformOp =\n | { name: 'resize'; params: ResizeParams }\n | { name: 'crop'; params: CropParams }\n | { name: 'rotate'; params: RotateParams }\n | { name: 'flip'; params: Record<string, never> }\n | { name: 'flop'; params: Record<string, never> }\n | { name: 'quality'; params: QualityParams }\n | { name: 'output'; params: OutputParams }\n\n/** An ordered list of transform operations. */\nexport type TransformChain = { ops: TransformOp[] }\n\n// ─── Op builders ─────────────────────────────────────────────────────────────\n\n/** Resize to a width and/or height with an optional fit mode. */\nexport const resize = (params: ResizeParams): TransformOp => ({ name: 'resize', params })\n\n/** Crop a rectangular region: origin (x, y) and size w×h, in source pixels. */\nexport const crop = (rect: { x: number; y: number; w: number; h: number }): TransformOp => ({\n name: 'crop',\n params: { dim: `${rect.x},${rect.y},${rect.w},${rect.h}`, ...rect },\n})\n\n/** Rotate clockwise by `deg` degrees. */\nexport const rotate = (params: RotateParams): TransformOp => ({ name: 'rotate', params })\n\n/** Flip vertically (mirror top↔bottom). */\nexport const flip = (): TransformOp => ({ name: 'flip', params: {} })\n\n/** Flop horizontally (mirror left↔right). */\nexport const flop = (): TransformOp => ({ name: 'flop', params: {} })\n\n/** Set output quality 1–100 (ignored by lossless formats). */\nexport const quality = (params: QualityParams): TransformOp => ({ name: 'quality', params })\n\n/** Convert the output format, e.g. `{ format: 'webp' }`. */\nexport const output = (params: OutputParams): TransformOp => ({ name: 'output', params })\n\n// ─── Serialization (vendored from @uploader/shared, parser-compatible) ────────\n\nfunction serializeOp(op: TransformOp): string {\n switch (op.name) {\n case 'resize': {\n const parts: string[] = []\n if (op.params.w !== undefined) parts.push(`w:${op.params.w}`)\n if (op.params.h !== undefined) parts.push(`h:${op.params.h}`)\n if (op.params.fit !== undefined) parts.push(`fit:${op.params.fit}`)\n return `resize=${parts.join(',')}`\n }\n case 'crop':\n return `crop=dim:${op.params.dim}`\n case 'rotate':\n return `rotate=deg:${op.params.deg}`\n case 'flip':\n return 'flip'\n case 'flop':\n return 'flop'\n case 'quality':\n return `quality=n:${op.params.n}`\n case 'output':\n return `output=format:${op.params.format}`\n }\n}\n\nexport type TransformUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /** Ordered transform ops to apply. Must contain at least one op. */\n ops: TransformOp[]\n /**\n * Base API URL. Defaults to '' so the URL is relative — `/<chain>/<handle>` —\n * and resolves same-origin (e.g. proxied to the API). Pass an absolute URL to\n * point at a remote API directly.\n */\n apiUrl?: string\n}\n\n/**\n * Build a delivery URL that applies `ops` to the file identified by `handle`.\n *\n * @example\n * transformUrl({\n * handle: file.handle,\n * ops: [resize({ w: 200, h: 200, fit: 'crop' }), output({ format: 'webp' })],\n * })\n * // => \"/resize=w:200,h:200,fit:crop/output=format:webp/<handle>\"\n */\nexport function transformUrl({ handle, ops, apiUrl = '' }: TransformUrlOptions): string {\n const base = apiUrl.replace(/\\/$/, '')\n const chain = ops.map(serializeOp).join('/')\n // The API's transform route requires at least one op segment before the\n // handle; for the unmodified original, use FileResult.url instead.\n return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`\n}\n\n/**\n * Append a signed read policy to a delivery/transform URL for accounts on the\n * `signed` delivery-protection tier (Private plan, docs/12).\n *\n * The `policy` + `signature` pair is produced SERVER-SIDE by the customer's\n * backend (HMAC over the account's api-key secret, via @uploader/shared). This\n * helper only assembles the URL — the SDK never signs and never sees the secret.\n *\n * @example\n * const url = withSignedPolicy(\n * transformUrl({ handle, ops: [resize({ w: 400 })], apiUrl }),\n * { policy, signature }, // from your backend\n * )\n */\nexport function withSignedPolicy(\n url: string,\n security: { policy: string; signature: string },\n): string {\n const sep = url.includes('?') ? '&' : '?'\n return `${url}${sep}policy=${encodeURIComponent(security.policy)}&signature=${encodeURIComponent(security.signature)}`\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAYA,yBAA2B;;;ACgF3B,SAAS,YAAY,IAAyB;AAC5C,UAAQ,GAAG,MAAM;AAAA,IACf,KAAK,UAAU;AACb,YAAM,QAAkB,CAAC;AACzB,UAAI,GAAG,OAAO,MAAM,OAAW,OAAM,KAAK,KAAK,GAAG,OAAO,CAAC,EAAE;AAC5D,UAAI,GAAG,OAAO,MAAM,OAAW,OAAM,KAAK,KAAK,GAAG,OAAO,CAAC,EAAE;AAC5D,UAAI,GAAG,OAAO,QAAQ,OAAW,OAAM,KAAK,OAAO,GAAG,OAAO,GAAG,EAAE;AAClE,aAAO,UAAU,MAAM,KAAK,GAAG,CAAC;AAAA,IAClC;AAAA,IACA,KAAK;AACH,aAAO,YAAY,GAAG,OAAO,GAAG;AAAA,IAClC,KAAK;AACH,aAAO,cAAc,GAAG,OAAO,GAAG;AAAA,IACpC,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO,aAAa,GAAG,OAAO,CAAC;AAAA,IACjC,KAAK;AACH,aAAO,iBAAiB,GAAG,OAAO,MAAM;AAAA,EAC5C;AACF;AAyBO,SAAS,aAAa,EAAE,QAAQ,KAAK,SAAS,GAAG,GAAgC;AACtF,QAAM,OAAO,OAAO,QAAQ,OAAO,EAAE;AACrC,QAAM,QAAQ,IAAI,IAAI,WAAW,EAAE,KAAK,GAAG;AAG3C,SAAO,QAAQ,GAAG,IAAI,IAAI,KAAK,IAAI,MAAM,KAAK,GAAG,IAAI,IAAI,MAAM;AACjE;;;ADrGA,SAAS,aAAa,MAAuC;AAC3D,SAAO,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC,EAAE,SAAS,WAAW;AAC/D;AAyBO,SAAS,qBAAqB,SAA8C;AACjF,QAAM,EAAE,QAAQ,QAAQ,SAAS,YAAY,KAAK,IAAI,IAAI;AAC1D,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,6CAA6C;AAE3E,QAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAEtC,QAAM,OACJ,OAAO,IAAI,SAAS,IAAI,aAAa,EAAE,QAAQ,KAAK,QAAQ,KAAK,CAAC,IAAI,GAAG,IAAI,SAAS,MAAM;AAE9F,QAAM,SAAS,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AAC/C,QAAM,SAAS,aAAa,EAAE,QAAQ,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC;AAC9D,QAAM,gBAAY,+BAAW,UAAU,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,KAAK;AAE1E,QAAM,MAAM,KAAK,SAAS,GAAG,IAAI,MAAM;AACvC,SAAO,GAAG,IAAI,GAAG,GAAG,UAAU,mBAAmB,MAAM,CAAC,cAAc,mBAAmB,SAAS,CAAC;AACrG;","names":[]}
1
+ {"version":3,"sources":["../src/server/index.ts","../src/core/transform.ts"],"sourcesContent":["/**\n * Server-only helpers for `@ibanzajoe/uploader`.\n *\n * These require the account's api-key **secret** and use `node:crypto`, so they\n * MUST run on your backend — never ship the secret to the browser. That is why\n * they live in a dedicated entry point (`@ibanzajoe/uploader/server`) that the\n * browser/React bundles never import.\n *\n * The signing format here mirrors `@uploader/shared` (base64url policy JSON +\n * hex HMAC-SHA256) exactly, so the URLs it produces verify against the same\n * delivery guard the platform runs (`call: ['read']`, handle-bound).\n */\nimport { createHmac } from 'node:crypto'\nimport type { TransformOp } from '../core/transform.js'\nimport { transformUrl } from '../core/transform.js'\n\nexport type GetSignedDeliveryUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The signature is an HMAC over this secret; the guard verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Delivery base URL — the host that serves this file. For a `signed` file this\n * is your edge Worker host, i.e. the origin of `FileResult.url`\n * (e.g. `https://edge.postila.app`).\n */\n baseUrl: string\n /**\n * Minimum seconds until the URL expires. Default: 300 (5 minutes).\n *\n * The actual expiry is rounded up to the next `stableWindow` boundary, so\n * the effective lifetime lands between `expiresIn` and `expiresIn +\n * stableWindow` — never shorter than you asked for.\n */\n expiresIn?: number\n /**\n * Quantize the expiry timestamp to a fixed window (seconds) so that repeated\n * calls for the same file within one window produce a **byte-identical URL**.\n * Default: the value of `expiresIn`. Pass `0` to disable.\n *\n * This matters much more than it looks. A signed URL's `?policy=` embeds an\n * absolute expiry, so computing it from \"now\" on every render yields a\n * different URL every time — and a different URL is, to a browser, a\n * different image. The browser cache can then NEVER hit, and every view of\n * every protected image pays a full network round trip no matter how well\n * the edge caches it.\n *\n * Quantizing fixes that: within a window the URL string is stable, so the\n * browser serves it straight from its own cache. The edge is unaffected\n * either way (its cache key deliberately excludes policy/signature, so all\n * valid signatures already share one entry) — this is purely about letting\n * the client cache work at all.\n *\n * Longer windows cache better; shorter windows revoke sooner. Pick per how\n * sensitive the content is.\n */\n stableWindow?: number\n /**\n * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).\n * Omit for the original file.\n */\n ops?: TransformOp[]\n}\n\n/** Base64url-encode a policy spec as JSON (matches `@uploader/shared` encodePolicy). */\nfunction encodePolicy(spec: Record<string, unknown>): string {\n return Buffer.from(JSON.stringify(spec)).toString('base64url')\n}\n\n/**\n * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in\n * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a\n * `<baseUrl>/<chain>/<handle>?…` URL when `ops` is given), signed with\n * HMAC-SHA256 over the account secret in the exact format the edge delivery\n * guard verifies.\n *\n * SERVER-ONLY: needs the account secret. Call it from your backend and hand the\n * returned URL to your frontend (e.g. as an `<img src>`).\n *\n * Calling it per view is fine and expected — by default the expiry is\n * quantized (`stableWindow`) so repeated calls inside one window return the\n * exact same URL string, which is what lets the browser cache the image\n * instead of re-fetching it on every render.\n *\n * @example\n * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route:\n * const url = getSignedDeliveryUrl({\n * handle: file.handle, // from the upload response\n * secret: process.env.UPLOADER_API_SECRET!,\n * baseUrl: 'https://edge.postila.app',\n * expiresIn: 3600, // valid ~1h, and stable for ~1h\n * })\n * // → \"https://edge.postila.app/file/<handle>?policy=…&signature=…\"\n */\nexport function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string {\n const { handle, secret, baseUrl, expiresIn = 300, ops } = options\n const stableWindow = options.stableWindow ?? expiresIn\n if (!handle) throw new Error('getSignedDeliveryUrl: `handle` is required')\n if (!secret) throw new Error('getSignedDeliveryUrl: `secret` is required')\n if (!baseUrl) throw new Error('getSignedDeliveryUrl: `baseUrl` is required')\n\n const base = baseUrl.replace(/\\/$/, '')\n // Original → /file/<handle>; transform → /<chain>/<handle> (edge route grammar).\n const path =\n ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`\n\n // Round the expiry UP to the next stableWindow boundary so every call within\n // a window signs the identical policy — and therefore returns the identical\n // URL, which the browser can actually cache. Rounding up (never down) means\n // the URL is always valid for at least `expiresIn`.\n const earliest = Math.floor(Date.now() / 1000) + expiresIn\n const expiry =\n stableWindow > 0 ? Math.ceil(earliest / stableWindow) * stableWindow : earliest\n const policy = encodePolicy({ expiry, call: ['read'], handle })\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n\n const sep = path.includes('?') ? '&' : '?'\n return `${path}${sep}policy=${encodeURIComponent(policy)}&signature=${encodeURIComponent(signature)}`\n}\n","/**\n * Delivery / transform URL builder.\n *\n * The API serves transformed derivatives at `GET /<chain>/<handle>`, where\n * <chain> is a slash-joined list of ops (e.g. `resize=w:200,h:200,fit:crop`).\n * These op builders + `transformUrl()` construct that URL from an uploaded file\n * handle, so consumers render an image at any size/format/quality without\n * hand-assembling URL strings.\n *\n * The op/param types and serialization mirror @uploader/shared EXACTLY — the\n * API's `parseTransformChain` is the other half of this contract, and\n * `contract.conformance.ts` asserts at typecheck time that they stay in sync.\n */\n\n// ─── Op vocabulary (mirrors @uploader/shared) ────────────────────────────────\n\nexport type TransformOpName =\n | 'resize'\n | 'crop'\n | 'rotate'\n | 'flip'\n | 'flop'\n | 'quality'\n | 'output'\n\n/** Parameters for the resize operation. */\nexport type ResizeParams = {\n w?: number\n h?: number\n fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside' | 'crop'\n}\n\n/** Parameters for the crop operation. */\nexport type CropParams = {\n /** \"x,y,w,h\" notation. */\n dim: string\n x?: number\n y?: number\n w?: number\n h?: number\n}\n\n/** Parameters for the rotate operation. */\nexport type RotateParams = { deg: number }\n\n/** Parameters for the quality operation. */\nexport type QualityParams = { n: number }\n\n/** Parameters for the output operation. */\nexport type OutputParams = { format: string }\n\n/** A single transform operation with its typed params. */\nexport type TransformOp =\n | { name: 'resize'; params: ResizeParams }\n | { name: 'crop'; params: CropParams }\n | { name: 'rotate'; params: RotateParams }\n | { name: 'flip'; params: Record<string, never> }\n | { name: 'flop'; params: Record<string, never> }\n | { name: 'quality'; params: QualityParams }\n | { name: 'output'; params: OutputParams }\n\n/** An ordered list of transform operations. */\nexport type TransformChain = { ops: TransformOp[] }\n\n// ─── Op builders ─────────────────────────────────────────────────────────────\n\n/** Resize to a width and/or height with an optional fit mode. */\nexport const resize = (params: ResizeParams): TransformOp => ({ name: 'resize', params })\n\n/** Crop a rectangular region: origin (x, y) and size w×h, in source pixels. */\nexport const crop = (rect: { x: number; y: number; w: number; h: number }): TransformOp => ({\n name: 'crop',\n params: { dim: `${rect.x},${rect.y},${rect.w},${rect.h}`, ...rect },\n})\n\n/** Rotate clockwise by `deg` degrees. */\nexport const rotate = (params: RotateParams): TransformOp => ({ name: 'rotate', params })\n\n/** Flip vertically (mirror top↔bottom). */\nexport const flip = (): TransformOp => ({ name: 'flip', params: {} })\n\n/** Flop horizontally (mirror left↔right). */\nexport const flop = (): TransformOp => ({ name: 'flop', params: {} })\n\n/** Set output quality 1–100 (ignored by lossless formats). */\nexport const quality = (params: QualityParams): TransformOp => ({ name: 'quality', params })\n\n/** Convert the output format, e.g. `{ format: 'webp' }`. */\nexport const output = (params: OutputParams): TransformOp => ({ name: 'output', params })\n\n// ─── Serialization (vendored from @uploader/shared, parser-compatible) ────────\n\nfunction serializeOp(op: TransformOp): string {\n switch (op.name) {\n case 'resize': {\n const parts: string[] = []\n if (op.params.w !== undefined) parts.push(`w:${op.params.w}`)\n if (op.params.h !== undefined) parts.push(`h:${op.params.h}`)\n if (op.params.fit !== undefined) parts.push(`fit:${op.params.fit}`)\n return `resize=${parts.join(',')}`\n }\n case 'crop':\n return `crop=dim:${op.params.dim}`\n case 'rotate':\n return `rotate=deg:${op.params.deg}`\n case 'flip':\n return 'flip'\n case 'flop':\n return 'flop'\n case 'quality':\n return `quality=n:${op.params.n}`\n case 'output':\n return `output=format:${op.params.format}`\n }\n}\n\nexport type TransformUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /** Ordered transform ops to apply. Must contain at least one op. */\n ops: TransformOp[]\n /**\n * Base API URL. Defaults to '' so the URL is relative — `/<chain>/<handle>` —\n * and resolves same-origin (e.g. proxied to the API). Pass an absolute URL to\n * point at a remote API directly.\n */\n apiUrl?: string\n}\n\n/**\n * Build a delivery URL that applies `ops` to the file identified by `handle`.\n *\n * @example\n * transformUrl({\n * handle: file.handle,\n * ops: [resize({ w: 200, h: 200, fit: 'crop' }), output({ format: 'webp' })],\n * })\n * // => \"/resize=w:200,h:200,fit:crop/output=format:webp/<handle>\"\n */\nexport function transformUrl({ handle, ops, apiUrl = '' }: TransformUrlOptions): string {\n const base = apiUrl.replace(/\\/$/, '')\n const chain = ops.map(serializeOp).join('/')\n // The API's transform route requires at least one op segment before the\n // handle; for the unmodified original, use FileResult.url instead.\n return chain ? `${base}/${chain}/${handle}` : `${base}/${handle}`\n}\n\n/**\n * Append a signed read policy to a delivery/transform URL for accounts on the\n * `signed` delivery-protection tier (Private plan, docs/12).\n *\n * The `policy` + `signature` pair is produced SERVER-SIDE by the customer's\n * backend (HMAC over the account's api-key secret, via @uploader/shared). This\n * helper only assembles the URL — the SDK never signs and never sees the secret.\n *\n * @example\n * const url = withSignedPolicy(\n * transformUrl({ handle, ops: [resize({ w: 400 })], apiUrl }),\n * { policy, signature }, // from your backend\n * )\n */\nexport function withSignedPolicy(\n url: string,\n security: { policy: string; signature: string },\n): string {\n const sep = url.includes('?') ? '&' : '?'\n return `${url}${sep}policy=${encodeURIComponent(security.policy)}&signature=${encodeURIComponent(security.signature)}`\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAYA,yBAA2B;;;ACgF3B,SAAS,YAAY,IAAyB;AAC5C,UAAQ,GAAG,MAAM;AAAA,IACf,KAAK,UAAU;AACb,YAAM,QAAkB,CAAC;AACzB,UAAI,GAAG,OAAO,MAAM,OAAW,OAAM,KAAK,KAAK,GAAG,OAAO,CAAC,EAAE;AAC5D,UAAI,GAAG,OAAO,MAAM,OAAW,OAAM,KAAK,KAAK,GAAG,OAAO,CAAC,EAAE;AAC5D,UAAI,GAAG,OAAO,QAAQ,OAAW,OAAM,KAAK,OAAO,GAAG,OAAO,GAAG,EAAE;AAClE,aAAO,UAAU,MAAM,KAAK,GAAG,CAAC;AAAA,IAClC;AAAA,IACA,KAAK;AACH,aAAO,YAAY,GAAG,OAAO,GAAG;AAAA,IAClC,KAAK;AACH,aAAO,cAAc,GAAG,OAAO,GAAG;AAAA,IACpC,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO,aAAa,GAAG,OAAO,CAAC;AAAA,IACjC,KAAK;AACH,aAAO,iBAAiB,GAAG,OAAO,MAAM;AAAA,EAC5C;AACF;AAyBO,SAAS,aAAa,EAAE,QAAQ,KAAK,SAAS,GAAG,GAAgC;AACtF,QAAM,OAAO,OAAO,QAAQ,OAAO,EAAE;AACrC,QAAM,QAAQ,IAAI,IAAI,WAAW,EAAE,KAAK,GAAG;AAG3C,SAAO,QAAQ,GAAG,IAAI,IAAI,KAAK,IAAI,MAAM,KAAK,GAAG,IAAI,IAAI,MAAM;AACjE;;;AD5EA,SAAS,aAAa,MAAuC;AAC3D,SAAO,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC,EAAE,SAAS,WAAW;AAC/D;AA6BO,SAAS,qBAAqB,SAA8C;AACjF,QAAM,EAAE,QAAQ,QAAQ,SAAS,YAAY,KAAK,IAAI,IAAI;AAC1D,QAAM,eAAe,QAAQ,gBAAgB;AAC7C,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,6CAA6C;AAE3E,QAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAEtC,QAAM,OACJ,OAAO,IAAI,SAAS,IAAI,aAAa,EAAE,QAAQ,KAAK,QAAQ,KAAK,CAAC,IAAI,GAAG,IAAI,SAAS,MAAM;AAM9F,QAAM,WAAW,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AACjD,QAAM,SACJ,eAAe,IAAI,KAAK,KAAK,WAAW,YAAY,IAAI,eAAe;AACzE,QAAM,SAAS,aAAa,EAAE,QAAQ,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC;AAC9D,QAAM,gBAAY,+BAAW,UAAU,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,KAAK;AAE1E,QAAM,MAAM,KAAK,SAAS,GAAG,IAAI,MAAM;AACvC,SAAO,GAAG,IAAI,GAAG,GAAG,UAAU,mBAAmB,MAAM,CAAC,cAAc,mBAAmB,SAAS,CAAC;AACrG;","names":[]}
package/dist/server.d.cts CHANGED
@@ -16,10 +16,35 @@ type GetSignedDeliveryUrlOptions = {
16
16
  */
17
17
  baseUrl: string;
18
18
  /**
19
- * Seconds until the URL expires. Keep it short and mint a fresh URL per view.
20
- * Default: 300 (5 minutes).
19
+ * Minimum seconds until the URL expires. Default: 300 (5 minutes).
20
+ *
21
+ * The actual expiry is rounded up to the next `stableWindow` boundary, so
22
+ * the effective lifetime lands between `expiresIn` and `expiresIn +
23
+ * stableWindow` — never shorter than you asked for.
21
24
  */
22
25
  expiresIn?: number;
26
+ /**
27
+ * Quantize the expiry timestamp to a fixed window (seconds) so that repeated
28
+ * calls for the same file within one window produce a **byte-identical URL**.
29
+ * Default: the value of `expiresIn`. Pass `0` to disable.
30
+ *
31
+ * This matters much more than it looks. A signed URL's `?policy=` embeds an
32
+ * absolute expiry, so computing it from "now" on every render yields a
33
+ * different URL every time — and a different URL is, to a browser, a
34
+ * different image. The browser cache can then NEVER hit, and every view of
35
+ * every protected image pays a full network round trip no matter how well
36
+ * the edge caches it.
37
+ *
38
+ * Quantizing fixes that: within a window the URL string is stable, so the
39
+ * browser serves it straight from its own cache. The edge is unaffected
40
+ * either way (its cache key deliberately excludes policy/signature, so all
41
+ * valid signatures already share one entry) — this is purely about letting
42
+ * the client cache work at all.
43
+ *
44
+ * Longer windows cache better; shorter windows revoke sooner. Pick per how
45
+ * sensitive the content is.
46
+ */
47
+ stableWindow?: number;
23
48
  /**
24
49
  * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).
25
50
  * Omit for the original file.
@@ -33,9 +58,13 @@ type GetSignedDeliveryUrlOptions = {
33
58
  * HMAC-SHA256 over the account secret in the exact format the edge delivery
34
59
  * guard verifies.
35
60
  *
36
- * SERVER-ONLY: needs the account secret. Call it from your backend, per view,
37
- * with a short `expiresIn`, and hand the returned URL to your frontend
38
- * (e.g. as an `<img src>`).
61
+ * SERVER-ONLY: needs the account secret. Call it from your backend and hand the
62
+ * returned URL to your frontend (e.g. as an `<img src>`).
63
+ *
64
+ * Calling it per view is fine and expected — by default the expiry is
65
+ * quantized (`stableWindow`) so repeated calls inside one window return the
66
+ * exact same URL string, which is what lets the browser cache the image
67
+ * instead of re-fetching it on every render.
39
68
  *
40
69
  * @example
41
70
  * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'
@@ -45,7 +74,7 @@ type GetSignedDeliveryUrlOptions = {
45
74
  * handle: file.handle, // from the upload response
46
75
  * secret: process.env.UPLOADER_API_SECRET!,
47
76
  * baseUrl: 'https://edge.postila.app',
48
- * expiresIn: 300,
77
+ * expiresIn: 3600, // valid ~1h, and stable for ~1h
49
78
  * })
50
79
  * // → "https://edge.postila.app/file/<handle>?policy=…&signature=…"
51
80
  */
package/dist/server.d.ts CHANGED
@@ -16,10 +16,35 @@ type GetSignedDeliveryUrlOptions = {
16
16
  */
17
17
  baseUrl: string;
18
18
  /**
19
- * Seconds until the URL expires. Keep it short and mint a fresh URL per view.
20
- * Default: 300 (5 minutes).
19
+ * Minimum seconds until the URL expires. Default: 300 (5 minutes).
20
+ *
21
+ * The actual expiry is rounded up to the next `stableWindow` boundary, so
22
+ * the effective lifetime lands between `expiresIn` and `expiresIn +
23
+ * stableWindow` — never shorter than you asked for.
21
24
  */
22
25
  expiresIn?: number;
26
+ /**
27
+ * Quantize the expiry timestamp to a fixed window (seconds) so that repeated
28
+ * calls for the same file within one window produce a **byte-identical URL**.
29
+ * Default: the value of `expiresIn`. Pass `0` to disable.
30
+ *
31
+ * This matters much more than it looks. A signed URL's `?policy=` embeds an
32
+ * absolute expiry, so computing it from "now" on every render yields a
33
+ * different URL every time — and a different URL is, to a browser, a
34
+ * different image. The browser cache can then NEVER hit, and every view of
35
+ * every protected image pays a full network round trip no matter how well
36
+ * the edge caches it.
37
+ *
38
+ * Quantizing fixes that: within a window the URL string is stable, so the
39
+ * browser serves it straight from its own cache. The edge is unaffected
40
+ * either way (its cache key deliberately excludes policy/signature, so all
41
+ * valid signatures already share one entry) — this is purely about letting
42
+ * the client cache work at all.
43
+ *
44
+ * Longer windows cache better; shorter windows revoke sooner. Pick per how
45
+ * sensitive the content is.
46
+ */
47
+ stableWindow?: number;
23
48
  /**
24
49
  * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).
25
50
  * Omit for the original file.
@@ -33,9 +58,13 @@ type GetSignedDeliveryUrlOptions = {
33
58
  * HMAC-SHA256 over the account secret in the exact format the edge delivery
34
59
  * guard verifies.
35
60
  *
36
- * SERVER-ONLY: needs the account secret. Call it from your backend, per view,
37
- * with a short `expiresIn`, and hand the returned URL to your frontend
38
- * (e.g. as an `<img src>`).
61
+ * SERVER-ONLY: needs the account secret. Call it from your backend and hand the
62
+ * returned URL to your frontend (e.g. as an `<img src>`).
63
+ *
64
+ * Calling it per view is fine and expected — by default the expiry is
65
+ * quantized (`stableWindow`) so repeated calls inside one window return the
66
+ * exact same URL string, which is what lets the browser cache the image
67
+ * instead of re-fetching it on every render.
39
68
  *
40
69
  * @example
41
70
  * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'
@@ -45,7 +74,7 @@ type GetSignedDeliveryUrlOptions = {
45
74
  * handle: file.handle, // from the upload response
46
75
  * secret: process.env.UPLOADER_API_SECRET!,
47
76
  * baseUrl: 'https://edge.postila.app',
48
- * expiresIn: 300,
77
+ * expiresIn: 3600, // valid ~1h, and stable for ~1h
49
78
  * })
50
79
  * // → "https://edge.postila.app/file/<handle>?policy=…&signature=…"
51
80
  */
package/dist/server.js CHANGED
@@ -9,12 +9,14 @@ function encodePolicy(spec) {
9
9
  }
10
10
  function getSignedDeliveryUrl(options) {
11
11
  const { handle, secret, baseUrl, expiresIn = 300, ops } = options;
12
+ const stableWindow = options.stableWindow ?? expiresIn;
12
13
  if (!handle) throw new Error("getSignedDeliveryUrl: `handle` is required");
13
14
  if (!secret) throw new Error("getSignedDeliveryUrl: `secret` is required");
14
15
  if (!baseUrl) throw new Error("getSignedDeliveryUrl: `baseUrl` is required");
15
16
  const base = baseUrl.replace(/\/$/, "");
16
17
  const path = ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`;
17
- const expiry = Math.floor(Date.now() / 1e3) + expiresIn;
18
+ const earliest = Math.floor(Date.now() / 1e3) + expiresIn;
19
+ const expiry = stableWindow > 0 ? Math.ceil(earliest / stableWindow) * stableWindow : earliest;
18
20
  const policy = encodePolicy({ expiry, call: ["read"], handle });
19
21
  const signature = createHmac("sha256", secret).update(policy).digest("hex");
20
22
  const sep = path.includes("?") ? "&" : "?";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/server/index.ts"],"sourcesContent":["/**\n * Server-only helpers for `@ibanzajoe/uploader`.\n *\n * These require the account's api-key **secret** and use `node:crypto`, so they\n * MUST run on your backend — never ship the secret to the browser. That is why\n * they live in a dedicated entry point (`@ibanzajoe/uploader/server`) that the\n * browser/React bundles never import.\n *\n * The signing format here mirrors `@uploader/shared` (base64url policy JSON +\n * hex HMAC-SHA256) exactly, so the URLs it produces verify against the same\n * delivery guard the platform runs (`call: ['read']`, handle-bound).\n */\nimport { createHmac } from 'node:crypto'\nimport type { TransformOp } from '../core/transform.js'\nimport { transformUrl } from '../core/transform.js'\n\nexport type GetSignedDeliveryUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The signature is an HMAC over this secret; the guard verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Delivery base URL — the host that serves this file. For a `signed` file this\n * is your edge Worker host, i.e. the origin of `FileResult.url`\n * (e.g. `https://edge.postila.app`).\n */\n baseUrl: string\n /**\n * Seconds until the URL expires. Keep it short and mint a fresh URL per view.\n * Default: 300 (5 minutes).\n */\n expiresIn?: number\n /**\n * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).\n * Omit for the original file.\n */\n ops?: TransformOp[]\n}\n\n/** Base64url-encode a policy spec as JSON (matches `@uploader/shared` encodePolicy). */\nfunction encodePolicy(spec: Record<string, unknown>): string {\n return Buffer.from(JSON.stringify(spec)).toString('base64url')\n}\n\n/**\n * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in\n * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a\n * `<baseUrl>/<chain>/<handle>?…` URL when `ops` is given), signed with\n * HMAC-SHA256 over the account secret in the exact format the edge delivery\n * guard verifies.\n *\n * SERVER-ONLY: needs the account secret. Call it from your backend, per view,\n * with a short `expiresIn`, and hand the returned URL to your frontend\n * (e.g. as an `<img src>`).\n *\n * @example\n * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route:\n * const url = getSignedDeliveryUrl({\n * handle: file.handle, // from the upload response\n * secret: process.env.UPLOADER_API_SECRET!,\n * baseUrl: 'https://edge.postila.app',\n * expiresIn: 300,\n * })\n * // → \"https://edge.postila.app/file/<handle>?policy=…&signature=…\"\n */\nexport function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string {\n const { handle, secret, baseUrl, expiresIn = 300, ops } = options\n if (!handle) throw new Error('getSignedDeliveryUrl: `handle` is required')\n if (!secret) throw new Error('getSignedDeliveryUrl: `secret` is required')\n if (!baseUrl) throw new Error('getSignedDeliveryUrl: `baseUrl` is required')\n\n const base = baseUrl.replace(/\\/$/, '')\n // Original → /file/<handle>; transform → /<chain>/<handle> (edge route grammar).\n const path =\n ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`\n\n const expiry = Math.floor(Date.now() / 1000) + expiresIn\n const policy = encodePolicy({ expiry, call: ['read'], handle })\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n\n const sep = path.includes('?') ? '&' : '?'\n return `${path}${sep}policy=${encodeURIComponent(policy)}&signature=${encodeURIComponent(signature)}`\n}\n"],"mappings":";;;;;AAYA,SAAS,kBAAkB;AAgC3B,SAAS,aAAa,MAAuC;AAC3D,SAAO,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC,EAAE,SAAS,WAAW;AAC/D;AAyBO,SAAS,qBAAqB,SAA8C;AACjF,QAAM,EAAE,QAAQ,QAAQ,SAAS,YAAY,KAAK,IAAI,IAAI;AAC1D,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,6CAA6C;AAE3E,QAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAEtC,QAAM,OACJ,OAAO,IAAI,SAAS,IAAI,aAAa,EAAE,QAAQ,KAAK,QAAQ,KAAK,CAAC,IAAI,GAAG,IAAI,SAAS,MAAM;AAE9F,QAAM,SAAS,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AAC/C,QAAM,SAAS,aAAa,EAAE,QAAQ,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC;AAC9D,QAAM,YAAY,WAAW,UAAU,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,KAAK;AAE1E,QAAM,MAAM,KAAK,SAAS,GAAG,IAAI,MAAM;AACvC,SAAO,GAAG,IAAI,GAAG,GAAG,UAAU,mBAAmB,MAAM,CAAC,cAAc,mBAAmB,SAAS,CAAC;AACrG;","names":[]}
1
+ {"version":3,"sources":["../src/server/index.ts"],"sourcesContent":["/**\n * Server-only helpers for `@ibanzajoe/uploader`.\n *\n * These require the account's api-key **secret** and use `node:crypto`, so they\n * MUST run on your backend — never ship the secret to the browser. That is why\n * they live in a dedicated entry point (`@ibanzajoe/uploader/server`) that the\n * browser/React bundles never import.\n *\n * The signing format here mirrors `@uploader/shared` (base64url policy JSON +\n * hex HMAC-SHA256) exactly, so the URLs it produces verify against the same\n * delivery guard the platform runs (`call: ['read']`, handle-bound).\n */\nimport { createHmac } from 'node:crypto'\nimport type { TransformOp } from '../core/transform.js'\nimport { transformUrl } from '../core/transform.js'\n\nexport type GetSignedDeliveryUrlOptions = {\n /** File handle from an upload (`FileResult.handle`). */\n handle: string\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The signature is an HMAC over this secret; the guard verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Delivery base URL — the host that serves this file. For a `signed` file this\n * is your edge Worker host, i.e. the origin of `FileResult.url`\n * (e.g. `https://edge.postila.app`).\n */\n baseUrl: string\n /**\n * Minimum seconds until the URL expires. Default: 300 (5 minutes).\n *\n * The actual expiry is rounded up to the next `stableWindow` boundary, so\n * the effective lifetime lands between `expiresIn` and `expiresIn +\n * stableWindow` — never shorter than you asked for.\n */\n expiresIn?: number\n /**\n * Quantize the expiry timestamp to a fixed window (seconds) so that repeated\n * calls for the same file within one window produce a **byte-identical URL**.\n * Default: the value of `expiresIn`. Pass `0` to disable.\n *\n * This matters much more than it looks. A signed URL's `?policy=` embeds an\n * absolute expiry, so computing it from \"now\" on every render yields a\n * different URL every time — and a different URL is, to a browser, a\n * different image. The browser cache can then NEVER hit, and every view of\n * every protected image pays a full network round trip no matter how well\n * the edge caches it.\n *\n * Quantizing fixes that: within a window the URL string is stable, so the\n * browser serves it straight from its own cache. The edge is unaffected\n * either way (its cache key deliberately excludes policy/signature, so all\n * valid signatures already share one entry) — this is purely about letting\n * the client cache work at all.\n *\n * Longer windows cache better; shorter windows revoke sooner. Pick per how\n * sensitive the content is.\n */\n stableWindow?: number\n /**\n * Optional transform chain to apply (e.g. `[resize({ w: 400 }), output({ format: 'webp' })]`).\n * Omit for the original file.\n */\n ops?: TransformOp[]\n}\n\n/** Base64url-encode a policy spec as JSON (matches `@uploader/shared` encodePolicy). */\nfunction encodePolicy(spec: Record<string, unknown>): string {\n return Buffer.from(JSON.stringify(spec)).toString('base64url')\n}\n\n/**\n * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in\n * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a\n * `<baseUrl>/<chain>/<handle>?…` URL when `ops` is given), signed with\n * HMAC-SHA256 over the account secret in the exact format the edge delivery\n * guard verifies.\n *\n * SERVER-ONLY: needs the account secret. Call it from your backend and hand the\n * returned URL to your frontend (e.g. as an `<img src>`).\n *\n * Calling it per view is fine and expected — by default the expiry is\n * quantized (`stableWindow`) so repeated calls inside one window return the\n * exact same URL string, which is what lets the browser cache the image\n * instead of re-fetching it on every render.\n *\n * @example\n * import { getSignedDeliveryUrl } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route:\n * const url = getSignedDeliveryUrl({\n * handle: file.handle, // from the upload response\n * secret: process.env.UPLOADER_API_SECRET!,\n * baseUrl: 'https://edge.postila.app',\n * expiresIn: 3600, // valid ~1h, and stable for ~1h\n * })\n * // → \"https://edge.postila.app/file/<handle>?policy=…&signature=…\"\n */\nexport function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string {\n const { handle, secret, baseUrl, expiresIn = 300, ops } = options\n const stableWindow = options.stableWindow ?? expiresIn\n if (!handle) throw new Error('getSignedDeliveryUrl: `handle` is required')\n if (!secret) throw new Error('getSignedDeliveryUrl: `secret` is required')\n if (!baseUrl) throw new Error('getSignedDeliveryUrl: `baseUrl` is required')\n\n const base = baseUrl.replace(/\\/$/, '')\n // Original → /file/<handle>; transform → /<chain>/<handle> (edge route grammar).\n const path =\n ops && ops.length > 0 ? transformUrl({ handle, ops, apiUrl: base }) : `${base}/file/${handle}`\n\n // Round the expiry UP to the next stableWindow boundary so every call within\n // a window signs the identical policy — and therefore returns the identical\n // URL, which the browser can actually cache. Rounding up (never down) means\n // the URL is always valid for at least `expiresIn`.\n const earliest = Math.floor(Date.now() / 1000) + expiresIn\n const expiry =\n stableWindow > 0 ? Math.ceil(earliest / stableWindow) * stableWindow : earliest\n const policy = encodePolicy({ expiry, call: ['read'], handle })\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n\n const sep = path.includes('?') ? '&' : '?'\n return `${path}${sep}policy=${encodeURIComponent(policy)}&signature=${encodeURIComponent(signature)}`\n}\n"],"mappings":";;;;;AAYA,SAAS,kBAAkB;AAyD3B,SAAS,aAAa,MAAuC;AAC3D,SAAO,OAAO,KAAK,KAAK,UAAU,IAAI,CAAC,EAAE,SAAS,WAAW;AAC/D;AA6BO,SAAS,qBAAqB,SAA8C;AACjF,QAAM,EAAE,QAAQ,QAAQ,SAAS,YAAY,KAAK,IAAI,IAAI;AAC1D,QAAM,eAAe,QAAQ,gBAAgB;AAC7C,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,4CAA4C;AACzE,MAAI,CAAC,QAAS,OAAM,IAAI,MAAM,6CAA6C;AAE3E,QAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;AAEtC,QAAM,OACJ,OAAO,IAAI,SAAS,IAAI,aAAa,EAAE,QAAQ,KAAK,QAAQ,KAAK,CAAC,IAAI,GAAG,IAAI,SAAS,MAAM;AAM9F,QAAM,WAAW,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AACjD,QAAM,SACJ,eAAe,IAAI,KAAK,KAAK,WAAW,YAAY,IAAI,eAAe;AACzE,QAAM,SAAS,aAAa,EAAE,QAAQ,MAAM,CAAC,MAAM,GAAG,OAAO,CAAC;AAC9D,QAAM,YAAY,WAAW,UAAU,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,KAAK;AAE1E,QAAM,MAAM,KAAK,SAAS,GAAG,IAAI,MAAM;AACvC,SAAO,GAAG,IAAI,GAAG,GAAG,UAAU,mBAAmB,MAAM,CAAC,cAAc,mBAAmB,SAAS,CAAC;AACrG;","names":[]}
package/dist/styles.css CHANGED
@@ -929,6 +929,48 @@
929
929
  transform: scaleX(-1);
930
930
  }
931
931
 
932
+ /*
933
+ * Flip-camera control. Overlaid on the stage (top-right) instead of joining the
934
+ * actions row, whose Back/spacer symmetry is what keeps the shutter centered.
935
+ * Sits on the dark video, so it uses its own light-on-scrim colors rather than
936
+ * the --uploader-* surface tokens.
937
+ */
938
+ .uploader-camera-flip {
939
+ position: absolute;
940
+ top: 10px;
941
+ right: 10px;
942
+ width: 38px;
943
+ height: 38px;
944
+ border-radius: 999px;
945
+ border: none;
946
+ padding: 0;
947
+ display: inline-flex;
948
+ align-items: center;
949
+ justify-content: center;
950
+ color: #f0ede8;
951
+ background: rgb(0 0 0 / 45%);
952
+ cursor: pointer;
953
+ transition: background var(--uploader-transition), transform var(--uploader-transition);
954
+ }
955
+
956
+ .uploader-camera-flip:hover:not(:disabled) {
957
+ background: rgb(0 0 0 / 65%);
958
+ }
959
+
960
+ .uploader-camera-flip:active:not(:disabled) {
961
+ transform: scale(0.92);
962
+ }
963
+
964
+ .uploader-camera-flip:focus-visible {
965
+ outline: none;
966
+ box-shadow: 0 0 0 3px var(--uploader-accent-ring);
967
+ }
968
+
969
+ .uploader-camera-flip:disabled {
970
+ opacity: 0.45;
971
+ cursor: not-allowed;
972
+ }
973
+
932
974
  .uploader-camera-status {
933
975
  position: absolute;
934
976
  inset: 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibanzajoe/uploader",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Drop-in React file-upload picker (drag-and-drop + dialog), headless upload client, in-picker image editor, and transform-URL builder.",
5
5
  "license": "MIT",
6
6
  "author": "ibanzajoe",