@ibanzajoe/uploader 1.6.0 → 1.10.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/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DeliveryProtection, P as PickerResponse, F as FileResult, d as UploaderClientOptions } from './client-DW6DcS6o.cjs';
2
- export { a as PolicySpec, U as UploadAllOptions, b as UploadOptions, c as UploaderClient, e as UsageWarning } from './client-DW6DcS6o.cjs';
1
+ import { D as DeliveryProtection, P as PickerResponse, a as FileResult, h as UploaderClientOptions } from './client-Brg4Ebyc.cjs';
2
+ export { F as FileManageOptions, L as ListFilesQuery, b as ListFilesResult, c as PolicySpec, S as SecurityPair, d as StoredFileRecord, e as StoredFileStatus, U as UploadAllOptions, f as UploadOptions, g as UploaderClient, i as UsageWarning } from './client-Brg4Ebyc.cjs';
3
3
  export { C as CropParams, O as OutputParams, Q as QualityParams, R as ResizeParams, a as RotateParams, T as TransformChain, b as TransformOp, c as TransformOpName, d as TransformUrlOptions, e as crop, f as flip, g as flop, o as output, q as quality, r as resize, h as rotate, t as transformUrl, w as withSignedPolicy } from './transform-BTZ0kodO.cjs';
4
4
  import * as React from 'react';
5
5
  import React__default, { CSSProperties } from 'react';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DeliveryProtection, P as PickerResponse, F as FileResult, d as UploaderClientOptions } from './client-DW6DcS6o.js';
2
- export { a as PolicySpec, U as UploadAllOptions, b as UploadOptions, c as UploaderClient, e as UsageWarning } from './client-DW6DcS6o.js';
1
+ import { D as DeliveryProtection, P as PickerResponse, a as FileResult, h as UploaderClientOptions } from './client-Brg4Ebyc.js';
2
+ export { F as FileManageOptions, L as ListFilesQuery, b as ListFilesResult, c as PolicySpec, S as SecurityPair, d as StoredFileRecord, e as StoredFileStatus, U as UploadAllOptions, f as UploadOptions, g as UploaderClient, i as UsageWarning } from './client-Brg4Ebyc.js';
3
3
  export { C as CropParams, O as OutputParams, Q as QualityParams, R as ResizeParams, a as RotateParams, T as TransformChain, b as TransformOp, c as TransformOpName, d as TransformUrlOptions, e as crop, f as flip, g as flop, o as output, q as quality, r as resize, h as rotate, t as transformUrl, w as withSignedPolicy } from './transform-BTZ0kodO.js';
4
4
  import * as React from 'react';
5
5
  import React__default, { CSSProperties } from 'react';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  UploaderClient
3
- } from "./chunk-7LTEX76J.js";
3
+ } from "./chunk-Y2U6IGZ2.js";
4
4
  import {
5
5
  crop,
6
6
  flip,
package/dist/server.cjs CHANGED
@@ -20,7 +20,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/server/index.ts
21
21
  var server_exports = {};
22
22
  __export(server_exports, {
23
- getSignedDeliveryUrl: () => getSignedDeliveryUrl
23
+ getSignedDeliveryUrl: () => getSignedDeliveryUrl,
24
+ getUploadToken: () => getUploadToken
24
25
  });
25
26
  module.exports = __toCommonJS(server_exports);
26
27
  var import_node_crypto = require("crypto");
@@ -59,6 +60,22 @@ function transformUrl({ handle, ops, apiUrl = "" }) {
59
60
  function encodePolicy(spec) {
60
61
  return Buffer.from(JSON.stringify(spec)).toString("base64url");
61
62
  }
63
+ function getUploadToken(options) {
64
+ const { secret, expiresIn = 3600, call = ["store"], maxSize, minSize, handle } = options;
65
+ if (!secret) throw new Error("getUploadToken: `secret` is required");
66
+ if (!call.length) throw new Error("getUploadToken: `call` must grant at least one operation");
67
+ if (call.includes("read") && !handle) {
68
+ throw new Error("getUploadToken: a 'read' token requires `handle` (an unbound read grant would expose every file on the account)");
69
+ }
70
+ const expirySeconds = Math.floor(Date.now() / 1e3) + expiresIn;
71
+ const spec = { expiry: expirySeconds, call };
72
+ if (handle) spec.handle = handle;
73
+ if (maxSize !== void 0) spec.maxSize = maxSize;
74
+ if (minSize !== void 0) spec.minSize = minSize;
75
+ const policy = encodePolicy(spec);
76
+ const signature = (0, import_node_crypto.createHmac)("sha256", secret).update(policy).digest("hex");
77
+ return { policy, signature, expiresAt: new Date(expirySeconds * 1e3).toISOString() };
78
+ }
62
79
  function getSignedDeliveryUrl(options) {
63
80
  const { handle, secret, baseUrl, expiresIn = 300, ops } = options;
64
81
  const stableWindow = options.stableWindow ?? expiresIn;
@@ -76,6 +93,7 @@ function getSignedDeliveryUrl(options) {
76
93
  }
77
94
  // Annotate the CommonJS export names for ESM import in node:
78
95
  0 && (module.exports = {
79
- getSignedDeliveryUrl
96
+ getSignedDeliveryUrl,
97
+ getUploadToken
80
98
  });
81
99
  //# sourceMappingURL=server.cjs.map
@@ -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 * 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":[]}
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\nexport type GetUploadTokenOptions = {\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The token is an HMAC over this secret; the upload plane verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Seconds until the token expires. Default: 3600 (1 hour).\n *\n * Keep this SHORT. The token is handed to the browser, so a long lifetime is\n * effectively a second, un-revocable API key — mint a fresh one per upload\n * session instead. (Rotating the api-key secret is the only way to kill an\n * outstanding token before it expires, and that invalidates them all.)\n */\n expiresIn?: number\n /**\n * Which operations the token grants. Default: `['store']` — an upload token.\n * Only the verbs the API enforces are meaningful: `store`, `read`, `list`,\n * `remove`.\n */\n call?: string[]\n /** Cap the uploaded file size, in bytes (enforced at store time as a 403). */\n maxSize?: number\n /** Require a minimum uploaded file size, in bytes (enforced as a 403). */\n minSize?: number\n /**\n * Lock the token to a single file handle. Required by the API for a `read`\n * token (an unbound read policy would expose every file on the account); has\n * no effect on a plain `store` token.\n */\n handle?: string\n}\n\n/** A short-lived signed policy pair, ready to hand to the browser. */\nexport type UploadToken = {\n /** base64url policy — pass to the SDK as `security.policy`. */\n policy: string\n /** hex HMAC-SHA256 signature — pass to the SDK as `security.signature`. */\n signature: string\n /** ISO-8601 timestamp when the token stops working (for your frontend to refresh). */\n expiresAt: string\n}\n\n/**\n * Mint a short-lived **signed upload token** for an account that has\n * *Require signed policies* turned on. Returns a `{ policy, signature,\n * expiresAt }` pair to hand to your frontend, which passes `{ policy,\n * signature }` to the SDK as its `security` option.\n *\n * SERVER-ONLY: needs the account secret. Call it from an authenticated backend\n * route, per upload session — the secret never reaches the browser, and a\n * fresh short-lived token per session keeps a leaked token nearly worthless.\n *\n * @example\n * import { getUploadToken } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route (e.g. GET /api/upload-token):\n * const token = getUploadToken({\n * secret: process.env.UPLOADER_API_SECRET!,\n * expiresIn: 3600, // 1 hour\n * maxSize: 25 * 1024 * 1024 // optional: cap at 25 MB\n * })\n * res.json(token) // → { policy, signature, expiresAt }\n *\n * // frontend then: new UploaderClient({ apikey, security: { policy, signature } })\n */\nexport function getUploadToken(options: GetUploadTokenOptions): UploadToken {\n const { secret, expiresIn = 3600, call = ['store'], maxSize, minSize, handle } = options\n if (!secret) throw new Error('getUploadToken: `secret` is required')\n if (!call.length) throw new Error('getUploadToken: `call` must grant at least one operation')\n if (call.includes('read') && !handle) {\n throw new Error(\"getUploadToken: a 'read' token requires `handle` (an unbound read grant would expose every file on the account)\")\n }\n\n const expirySeconds = Math.floor(Date.now() / 1000) + expiresIn\n const spec: Record<string, unknown> = { expiry: expirySeconds, call }\n if (handle) spec.handle = handle\n if (maxSize !== undefined) spec.maxSize = maxSize\n if (minSize !== undefined) spec.minSize = minSize\n\n const policy = encodePolicy(spec)\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n return { policy, signature, expiresAt: new Date(expirySeconds * 1000).toISOString() }\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":"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","names":[]}
package/dist/server.d.cts CHANGED
@@ -51,6 +51,72 @@ type GetSignedDeliveryUrlOptions = {
51
51
  */
52
52
  ops?: TransformOp[];
53
53
  };
54
+ type GetUploadTokenOptions = {
55
+ /**
56
+ * The account's api-key **secret** (server-only — never expose to the browser).
57
+ * The token is an HMAC over this secret; the upload plane verifies against the
58
+ * file owner's secret.
59
+ */
60
+ secret: string;
61
+ /**
62
+ * Seconds until the token expires. Default: 3600 (1 hour).
63
+ *
64
+ * Keep this SHORT. The token is handed to the browser, so a long lifetime is
65
+ * effectively a second, un-revocable API key — mint a fresh one per upload
66
+ * session instead. (Rotating the api-key secret is the only way to kill an
67
+ * outstanding token before it expires, and that invalidates them all.)
68
+ */
69
+ expiresIn?: number;
70
+ /**
71
+ * Which operations the token grants. Default: `['store']` — an upload token.
72
+ * Only the verbs the API enforces are meaningful: `store`, `read`, `list`,
73
+ * `remove`.
74
+ */
75
+ call?: string[];
76
+ /** Cap the uploaded file size, in bytes (enforced at store time as a 403). */
77
+ maxSize?: number;
78
+ /** Require a minimum uploaded file size, in bytes (enforced as a 403). */
79
+ minSize?: number;
80
+ /**
81
+ * Lock the token to a single file handle. Required by the API for a `read`
82
+ * token (an unbound read policy would expose every file on the account); has
83
+ * no effect on a plain `store` token.
84
+ */
85
+ handle?: string;
86
+ };
87
+ /** A short-lived signed policy pair, ready to hand to the browser. */
88
+ type UploadToken = {
89
+ /** base64url policy — pass to the SDK as `security.policy`. */
90
+ policy: string;
91
+ /** hex HMAC-SHA256 signature — pass to the SDK as `security.signature`. */
92
+ signature: string;
93
+ /** ISO-8601 timestamp when the token stops working (for your frontend to refresh). */
94
+ expiresAt: string;
95
+ };
96
+ /**
97
+ * Mint a short-lived **signed upload token** for an account that has
98
+ * *Require signed policies* turned on. Returns a `{ policy, signature,
99
+ * expiresAt }` pair to hand to your frontend, which passes `{ policy,
100
+ * signature }` to the SDK as its `security` option.
101
+ *
102
+ * SERVER-ONLY: needs the account secret. Call it from an authenticated backend
103
+ * route, per upload session — the secret never reaches the browser, and a
104
+ * fresh short-lived token per session keeps a leaked token nearly worthless.
105
+ *
106
+ * @example
107
+ * import { getUploadToken } from '@ibanzajoe/uploader/server'
108
+ *
109
+ * // in an authenticated backend route (e.g. GET /api/upload-token):
110
+ * const token = getUploadToken({
111
+ * secret: process.env.UPLOADER_API_SECRET!,
112
+ * expiresIn: 3600, // 1 hour
113
+ * maxSize: 25 * 1024 * 1024 // optional: cap at 25 MB
114
+ * })
115
+ * res.json(token) // → { policy, signature, expiresAt }
116
+ *
117
+ * // frontend then: new UploaderClient({ apikey, security: { policy, signature } })
118
+ */
119
+ declare function getUploadToken(options: GetUploadTokenOptions): UploadToken;
54
120
  /**
55
121
  * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in
56
122
  * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a
@@ -80,4 +146,4 @@ type GetSignedDeliveryUrlOptions = {
80
146
  */
81
147
  declare function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string;
82
148
 
83
- export { type GetSignedDeliveryUrlOptions, getSignedDeliveryUrl };
149
+ export { type GetSignedDeliveryUrlOptions, type GetUploadTokenOptions, type UploadToken, getSignedDeliveryUrl, getUploadToken };
package/dist/server.d.ts CHANGED
@@ -51,6 +51,72 @@ type GetSignedDeliveryUrlOptions = {
51
51
  */
52
52
  ops?: TransformOp[];
53
53
  };
54
+ type GetUploadTokenOptions = {
55
+ /**
56
+ * The account's api-key **secret** (server-only — never expose to the browser).
57
+ * The token is an HMAC over this secret; the upload plane verifies against the
58
+ * file owner's secret.
59
+ */
60
+ secret: string;
61
+ /**
62
+ * Seconds until the token expires. Default: 3600 (1 hour).
63
+ *
64
+ * Keep this SHORT. The token is handed to the browser, so a long lifetime is
65
+ * effectively a second, un-revocable API key — mint a fresh one per upload
66
+ * session instead. (Rotating the api-key secret is the only way to kill an
67
+ * outstanding token before it expires, and that invalidates them all.)
68
+ */
69
+ expiresIn?: number;
70
+ /**
71
+ * Which operations the token grants. Default: `['store']` — an upload token.
72
+ * Only the verbs the API enforces are meaningful: `store`, `read`, `list`,
73
+ * `remove`.
74
+ */
75
+ call?: string[];
76
+ /** Cap the uploaded file size, in bytes (enforced at store time as a 403). */
77
+ maxSize?: number;
78
+ /** Require a minimum uploaded file size, in bytes (enforced as a 403). */
79
+ minSize?: number;
80
+ /**
81
+ * Lock the token to a single file handle. Required by the API for a `read`
82
+ * token (an unbound read policy would expose every file on the account); has
83
+ * no effect on a plain `store` token.
84
+ */
85
+ handle?: string;
86
+ };
87
+ /** A short-lived signed policy pair, ready to hand to the browser. */
88
+ type UploadToken = {
89
+ /** base64url policy — pass to the SDK as `security.policy`. */
90
+ policy: string;
91
+ /** hex HMAC-SHA256 signature — pass to the SDK as `security.signature`. */
92
+ signature: string;
93
+ /** ISO-8601 timestamp when the token stops working (for your frontend to refresh). */
94
+ expiresAt: string;
95
+ };
96
+ /**
97
+ * Mint a short-lived **signed upload token** for an account that has
98
+ * *Require signed policies* turned on. Returns a `{ policy, signature,
99
+ * expiresAt }` pair to hand to your frontend, which passes `{ policy,
100
+ * signature }` to the SDK as its `security` option.
101
+ *
102
+ * SERVER-ONLY: needs the account secret. Call it from an authenticated backend
103
+ * route, per upload session — the secret never reaches the browser, and a
104
+ * fresh short-lived token per session keeps a leaked token nearly worthless.
105
+ *
106
+ * @example
107
+ * import { getUploadToken } from '@ibanzajoe/uploader/server'
108
+ *
109
+ * // in an authenticated backend route (e.g. GET /api/upload-token):
110
+ * const token = getUploadToken({
111
+ * secret: process.env.UPLOADER_API_SECRET!,
112
+ * expiresIn: 3600, // 1 hour
113
+ * maxSize: 25 * 1024 * 1024 // optional: cap at 25 MB
114
+ * })
115
+ * res.json(token) // → { policy, signature, expiresAt }
116
+ *
117
+ * // frontend then: new UploaderClient({ apikey, security: { policy, signature } })
118
+ */
119
+ declare function getUploadToken(options: GetUploadTokenOptions): UploadToken;
54
120
  /**
55
121
  * Build a ready-to-use **signed delivery URL** for a `signed`-protected file, in
56
122
  * one call. Returns `<baseUrl>/file/<handle>?policy=…&signature=…` (or a
@@ -80,4 +146,4 @@ type GetSignedDeliveryUrlOptions = {
80
146
  */
81
147
  declare function getSignedDeliveryUrl(options: GetSignedDeliveryUrlOptions): string;
82
148
 
83
- export { type GetSignedDeliveryUrlOptions, getSignedDeliveryUrl };
149
+ export { type GetSignedDeliveryUrlOptions, type GetUploadTokenOptions, type UploadToken, getSignedDeliveryUrl, getUploadToken };
package/dist/server.js CHANGED
@@ -7,6 +7,22 @@ import { createHmac } from "crypto";
7
7
  function encodePolicy(spec) {
8
8
  return Buffer.from(JSON.stringify(spec)).toString("base64url");
9
9
  }
10
+ function getUploadToken(options) {
11
+ const { secret, expiresIn = 3600, call = ["store"], maxSize, minSize, handle } = options;
12
+ if (!secret) throw new Error("getUploadToken: `secret` is required");
13
+ if (!call.length) throw new Error("getUploadToken: `call` must grant at least one operation");
14
+ if (call.includes("read") && !handle) {
15
+ throw new Error("getUploadToken: a 'read' token requires `handle` (an unbound read grant would expose every file on the account)");
16
+ }
17
+ const expirySeconds = Math.floor(Date.now() / 1e3) + expiresIn;
18
+ const spec = { expiry: expirySeconds, call };
19
+ if (handle) spec.handle = handle;
20
+ if (maxSize !== void 0) spec.maxSize = maxSize;
21
+ if (minSize !== void 0) spec.minSize = minSize;
22
+ const policy = encodePolicy(spec);
23
+ const signature = createHmac("sha256", secret).update(policy).digest("hex");
24
+ return { policy, signature, expiresAt: new Date(expirySeconds * 1e3).toISOString() };
25
+ }
10
26
  function getSignedDeliveryUrl(options) {
11
27
  const { handle, secret, baseUrl, expiresIn = 300, ops } = options;
12
28
  const stableWindow = options.stableWindow ?? expiresIn;
@@ -23,6 +39,7 @@ function getSignedDeliveryUrl(options) {
23
39
  return `${path}${sep}policy=${encodeURIComponent(policy)}&signature=${encodeURIComponent(signature)}`;
24
40
  }
25
41
  export {
26
- getSignedDeliveryUrl
42
+ getSignedDeliveryUrl,
43
+ getUploadToken
27
44
  };
28
45
  //# sourceMappingURL=server.js.map
@@ -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 * 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":[]}
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\nexport type GetUploadTokenOptions = {\n /**\n * The account's api-key **secret** (server-only — never expose to the browser).\n * The token is an HMAC over this secret; the upload plane verifies against the\n * file owner's secret.\n */\n secret: string\n /**\n * Seconds until the token expires. Default: 3600 (1 hour).\n *\n * Keep this SHORT. The token is handed to the browser, so a long lifetime is\n * effectively a second, un-revocable API key — mint a fresh one per upload\n * session instead. (Rotating the api-key secret is the only way to kill an\n * outstanding token before it expires, and that invalidates them all.)\n */\n expiresIn?: number\n /**\n * Which operations the token grants. Default: `['store']` — an upload token.\n * Only the verbs the API enforces are meaningful: `store`, `read`, `list`,\n * `remove`.\n */\n call?: string[]\n /** Cap the uploaded file size, in bytes (enforced at store time as a 403). */\n maxSize?: number\n /** Require a minimum uploaded file size, in bytes (enforced as a 403). */\n minSize?: number\n /**\n * Lock the token to a single file handle. Required by the API for a `read`\n * token (an unbound read policy would expose every file on the account); has\n * no effect on a plain `store` token.\n */\n handle?: string\n}\n\n/** A short-lived signed policy pair, ready to hand to the browser. */\nexport type UploadToken = {\n /** base64url policy — pass to the SDK as `security.policy`. */\n policy: string\n /** hex HMAC-SHA256 signature — pass to the SDK as `security.signature`. */\n signature: string\n /** ISO-8601 timestamp when the token stops working (for your frontend to refresh). */\n expiresAt: string\n}\n\n/**\n * Mint a short-lived **signed upload token** for an account that has\n * *Require signed policies* turned on. Returns a `{ policy, signature,\n * expiresAt }` pair to hand to your frontend, which passes `{ policy,\n * signature }` to the SDK as its `security` option.\n *\n * SERVER-ONLY: needs the account secret. Call it from an authenticated backend\n * route, per upload session — the secret never reaches the browser, and a\n * fresh short-lived token per session keeps a leaked token nearly worthless.\n *\n * @example\n * import { getUploadToken } from '@ibanzajoe/uploader/server'\n *\n * // in an authenticated backend route (e.g. GET /api/upload-token):\n * const token = getUploadToken({\n * secret: process.env.UPLOADER_API_SECRET!,\n * expiresIn: 3600, // 1 hour\n * maxSize: 25 * 1024 * 1024 // optional: cap at 25 MB\n * })\n * res.json(token) // → { policy, signature, expiresAt }\n *\n * // frontend then: new UploaderClient({ apikey, security: { policy, signature } })\n */\nexport function getUploadToken(options: GetUploadTokenOptions): UploadToken {\n const { secret, expiresIn = 3600, call = ['store'], maxSize, minSize, handle } = options\n if (!secret) throw new Error('getUploadToken: `secret` is required')\n if (!call.length) throw new Error('getUploadToken: `call` must grant at least one operation')\n if (call.includes('read') && !handle) {\n throw new Error(\"getUploadToken: a 'read' token requires `handle` (an unbound read grant would expose every file on the account)\")\n }\n\n const expirySeconds = Math.floor(Date.now() / 1000) + expiresIn\n const spec: Record<string, unknown> = { expiry: expirySeconds, call }\n if (handle) spec.handle = handle\n if (maxSize !== undefined) spec.maxSize = maxSize\n if (minSize !== undefined) spec.minSize = minSize\n\n const policy = encodePolicy(spec)\n const signature = createHmac('sha256', secret).update(policy).digest('hex')\n return { policy, signature, expiresAt: new Date(expirySeconds * 1000).toISOString() }\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;AAqEO,SAAS,eAAe,SAA6C;AAC1E,QAAM,EAAE,QAAQ,YAAY,MAAM,OAAO,CAAC,OAAO,GAAG,SAAS,SAAS,OAAO,IAAI;AACjF,MAAI,CAAC,OAAQ,OAAM,IAAI,MAAM,sCAAsC;AACnE,MAAI,CAAC,KAAK,OAAQ,OAAM,IAAI,MAAM,0DAA0D;AAC5F,MAAI,KAAK,SAAS,MAAM,KAAK,CAAC,QAAQ;AACpC,UAAM,IAAI,MAAM,iHAAiH;AAAA,EACnI;AAEA,QAAM,gBAAgB,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI,IAAI;AACtD,QAAM,OAAgC,EAAE,QAAQ,eAAe,KAAK;AACpE,MAAI,OAAQ,MAAK,SAAS;AAC1B,MAAI,YAAY,OAAW,MAAK,UAAU;AAC1C,MAAI,YAAY,OAAW,MAAK,UAAU;AAE1C,QAAM,SAAS,aAAa,IAAI;AAChC,QAAM,YAAY,WAAW,UAAU,MAAM,EAAE,OAAO,MAAM,EAAE,OAAO,KAAK;AAC1E,SAAO,EAAE,QAAQ,WAAW,WAAW,IAAI,KAAK,gBAAgB,GAAI,EAAE,YAAY,EAAE;AACtF;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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibanzajoe/uploader",
3
- "version": "1.6.0",
3
+ "version": "1.10.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",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/errors.ts","../src/core/chunk.ts","../src/core/client.ts"],"sourcesContent":["/**\n * Typed errors for UploaderClient.\n *\n * UploaderError is thrown by upload / uploadAll on any non-retried failure.\n * The caller can narrow on `err.code` for structured handling.\n */\n\nexport type UploaderErrorCode =\n | 'ABORTED' // AbortSignal fired\n | 'NETWORK_ERROR' // fetch() threw (no response)\n | 'SERVER_ERROR' // 5xx after all retries exhausted\n | 'CLIENT_ERROR' // 4xx (not retried)\n | 'INVALID_RESPONSE' // response body did not match expected shape\n\nexport class UploaderError extends Error {\n readonly code: UploaderErrorCode\n /** HTTP status code when available (undefined for ABORTED / NETWORK_ERROR). */\n readonly statusCode?: number\n\n constructor(code: UploaderErrorCode, message: string, statusCode?: number) {\n super(message)\n this.name = 'UploaderError'\n this.code = code\n this.statusCode = statusCode\n }\n}\n","/**\n * Chunk planner for multipart uploads.\n *\n * Decides single-shot vs multipart by comparing the file size against\n * MULTIPART_THRESHOLD. For multipart files, slices the Blob into parts of\n * `chunkSize` bytes.\n */\n\n/** Files ≤ this size use single-shot POST /api/store. */\nexport const MULTIPART_THRESHOLD = 5 * 1024 * 1024 // 5 MB\n\n/** Default part size for multipart uploads. */\nexport const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 // 5 MB\n\nexport type ChunkPlan =\n | { mode: 'single' }\n | { mode: 'multipart'; parts: Blob[]; partSize: number }\n\n/**\n * Build a chunk plan for a file.\n *\n * @param file The File or Blob to upload.\n * @param chunkSize Desired part size in bytes (default DEFAULT_CHUNK_SIZE).\n * @returns A plan describing whether to use single-shot or multipart.\n */\nexport function planChunks(file: File | Blob, chunkSize = DEFAULT_CHUNK_SIZE): ChunkPlan {\n if (file.size <= MULTIPART_THRESHOLD) {\n return { mode: 'single' }\n }\n\n const parts: Blob[] = []\n let offset = 0\n while (offset < file.size) {\n parts.push(file.slice(offset, offset + chunkSize))\n offset += chunkSize\n }\n return { mode: 'multipart', parts, partSize: chunkSize }\n}\n","/**\n * UploaderClient — headless upload client.\n *\n * Chooses single-shot (POST /api/store, multipart/form-data) vs multipart\n * (start/part/complete) based on file size relative to MULTIPART_THRESHOLD\n * (5 MB). Parts are retried individually with exponential backoff. Progress\n * is emitted as a 0–100 integer. AbortSignal cancels in-flight work.\n */\n\nimport type {\n FileResult,\n UploadStartResponse,\n UploadPartResponse,\n UploaderClientOptions,\n UploadAllOptions,\n UploadOptions,\n UsageWarning,\n} from './types.js'\nimport { UploaderError } from './errors.js'\nimport { planChunks } from './chunk.js'\n\n// ─── Constants ────────────────────────────────────────────────────────────────\n\nconst MAX_RETRIES = 3\nconst RETRY_BASE_MS = 200\n\n/**\n * Single presigned PUT ceiling (mirrors the server's Phase-A limit). Files above\n * this fall back to the proxied multipart flow.\n */\nconst MAX_DIRECT_PUT_BYTES = 5 * 1024 * 1024 * 1024 // 5 GB\n\n/**\n * Skip the advisory client checksum above this size — SubtleCrypto has no\n * streaming API, so hashing would load the whole file into memory a second time.\n * The checksum is advisory only (the server can't verify it anyway).\n */\nconst SHA256_MAX_BYTES = 64 * 1024 * 1024 // 64 MB\n\n// ─── Internal helpers ─────────────────────────────────────────────────────────\n\n/** Sleep for `ms` milliseconds, resolving early if signal fires. */\nfunction sleep(ms: number, signal?: AbortSignal): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (signal?.aborted) {\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n return\n }\n const timer = setTimeout(resolve, ms)\n signal?.addEventListener('abort', () => {\n clearTimeout(timer)\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n }, { once: true })\n })\n}\n\n/** Throw UploaderError(ABORTED) if signal has already fired. */\nfunction checkAbort(signal?: AbortSignal): void {\n if (signal?.aborted) {\n throw new UploaderError('ABORTED', 'Upload aborted')\n }\n}\n\n/**\n * Fetch with retry on network errors and 5xx responses.\n * 4xx responses are not retried — they surface immediately as CLIENT_ERROR.\n */\nasync function fetchWithRetry(\n url: string,\n init: RequestInit,\n signal?: AbortSignal,\n maxRetries = MAX_RETRIES,\n): Promise<Response> {\n let lastErr: unknown\n for (let attempt = 0; attempt < maxRetries; attempt++) {\n checkAbort(signal)\n try {\n const res = await fetch(url, { ...init, signal })\n if (res.status >= 400 && res.status < 500) {\n // Client error — do not retry\n const body = await res.text().catch(() => '')\n throw new UploaderError(\n 'CLIENT_ERROR',\n `HTTP ${res.status}: ${body}`,\n res.status,\n )\n }\n if (res.status >= 500) {\n // Server error — retry with backoff\n lastErr = new UploaderError(\n 'SERVER_ERROR',\n `HTTP ${res.status}`,\n res.status,\n )\n if (attempt < maxRetries - 1) {\n await sleep(RETRY_BASE_MS * 2 ** attempt, signal)\n }\n continue\n }\n return res\n } catch (err) {\n if (err instanceof UploaderError) {\n if (err.code === 'CLIENT_ERROR' || err.code === 'ABORTED') throw err\n lastErr = err\n } else {\n // fetch() threw (network failure, CORS, etc.)\n lastErr = new UploaderError(\n 'NETWORK_ERROR',\n err instanceof Error ? err.message : String(err),\n )\n }\n if (attempt < maxRetries - 1) {\n await sleep(RETRY_BASE_MS * 2 ** attempt, signal)\n }\n }\n }\n throw lastErr\n}\n\n/**\n * Best-effort SHA-256 (hex) of a Blob for the advisory checksum sent at confirm.\n * Returns undefined when SubtleCrypto is unavailable (insecure context / older\n * runtime) or the file is large — never throws. The server treats this as a hint\n * only, so skipping it is safe.\n */\nasync function sha256Hex(blob: Blob): Promise<string | undefined> {\n try {\n const c = (globalThis as { crypto?: Crypto }).crypto\n if (!c?.subtle || blob.size > SHA256_MAX_BYTES) return undefined\n const digest = await c.subtle.digest('SHA-256', await blob.arrayBuffer())\n return Array.from(new Uint8Array(digest))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('')\n } catch {\n return undefined\n }\n}\n\n/**\n * PUT a body straight to a presigned bucket URL via XMLHttpRequest.\n *\n * XHR (not fetch) so real upload progress is reported (fetch cannot). The\n * Content-Type header MUST equal the value the server signed, or S3/R2 reject\n * the signature. Bytes go browser → bucket; our server never sees them.\n */\nfunction xhrPut(\n url: string,\n body: Blob,\n opts: { contentType: string; onProgress?: (percent: number) => void; signal?: AbortSignal },\n): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n if (opts.signal?.aborted) {\n reject(new UploaderError('ABORTED', 'Upload aborted'))\n return\n }\n const xhr = new XMLHttpRequest()\n xhr.open('PUT', url)\n xhr.setRequestHeader('Content-Type', opts.contentType)\n\n if (opts.onProgress) {\n xhr.upload.onprogress = (e: ProgressEvent) => {\n if (e.lengthComputable) {\n // Reserve the last 5% for the confirm round-trip.\n opts.onProgress!(Math.round((e.loaded / e.total) * 95))\n }\n }\n }\n xhr.onload = () => {\n if (xhr.status >= 200 && xhr.status < 300) {\n resolve()\n } else {\n const code = xhr.status >= 400 && xhr.status < 500 ? 'CLIENT_ERROR' : 'SERVER_ERROR'\n reject(new UploaderError(code, `Bucket PUT failed: HTTP ${xhr.status}`, xhr.status))\n }\n }\n xhr.onerror = () => reject(new UploaderError('NETWORK_ERROR', 'Bucket PUT network error'))\n xhr.onabort = () => reject(new UploaderError('ABORTED', 'Upload aborted'))\n\n if (opts.signal) {\n opts.signal.addEventListener('abort', () => xhr.abort(), { once: true })\n }\n xhr.send(body)\n })\n}\n\n/** Shape returned by POST /api/uploads/presign. */\ntype PresignResponse = { handle: string; storageKey: string; putUrl: string; contentType: string }\n\n// ─── UploaderClient ────────────────────────────────────────────────────────────\n\nexport class UploaderClient {\n readonly apikey: string\n readonly apiUrl: string\n readonly security: UploaderClientOptions['security']\n readonly #directUploadOption?: boolean\n /** Client-level default per-file delivery protection (docs/13). */\n readonly #deliveryProtection?: UploaderClientOptions['deliveryProtection']\n /** Client-level default per-file allowed origins (docs/13). */\n readonly #allowedOrigins?: string[]\n /** Optional consumer hook for non-disruptive usage warnings (soft bandwidth cap). */\n readonly #onUsageWarning?: UploaderClientOptions['onUsageWarning']\n\n /** Memoized capability probe — one request per client, shared across uploads. */\n #capsPromise: Promise<{ directUpload: boolean }> | null = null\n\n constructor(options: UploaderClientOptions) {\n this.apikey = options.apikey\n this.apiUrl = (options.apiUrl ?? 'https://api.uploaderhq.io').replace(/\\/$/, '')\n this.security = options.security\n this.#directUploadOption = options.directUpload\n this.#deliveryProtection = options.deliveryProtection\n this.#allowedOrigins = options.allowedOrigins\n this.#onUsageWarning = options.onUsageWarning\n }\n\n /**\n * Surface a non-disruptive usage warning carried on an upload response. Routes\n * to the consumer's `onUsageWarning` hook if provided, else `console.warn`.\n * Tolerates arbitrary JSON: only a well-formed `{ usageWarning }` triggers it.\n */\n #emitUsageWarning(body: unknown): void {\n if (typeof body !== 'object' || body === null) return\n const w = (body as { usageWarning?: unknown }).usageWarning\n if (typeof w !== 'object' || w === null) return\n const warning = w as UsageWarning\n if (warning.type !== 'bandwidth') return\n if (this.#onUsageWarning) this.#onUsageWarning(warning)\n else console.warn(`[uploader] ${warning.message}`)\n }\n\n /**\n * Resolve the effective per-file protection for one upload: a per-upload value\n * overrides the client-level default (docs/13). Returns an object carrying ONLY\n * the keys that are set, so callers spread it into the request body and unset\n * fields are omitted entirely — an old server ignores them and the file inherits\n * the account mode (`null`).\n */\n #resolveProtection(opts: UploadOptions): {\n deliveryProtection?: UploaderClientOptions['deliveryProtection']\n allowedOrigins?: string[]\n } {\n const out: { deliveryProtection?: UploaderClientOptions['deliveryProtection']; allowedOrigins?: string[] } = {}\n const mode = opts.deliveryProtection ?? this.#deliveryProtection\n if (mode !== undefined) out.deliveryProtection = mode\n const origins = opts.allowedOrigins ?? this.#allowedOrigins\n if (origins !== undefined) out.allowedOrigins = origins\n return out\n }\n\n // ─── Capability negotiation ─────────────────────────────────────────────────\n\n /**\n * Probe GET /api/capabilities once per client and cache the result. Fails OPEN\n * to the proxied flow ({ directUpload: false }) on any error/timeout, so a\n * flaky probe never blocks uploads and old servers (404) are handled.\n */\n #getCapabilities(): Promise<{ directUpload: boolean }> {\n // Explicit opt-out — never probe, never use direct upload.\n if (this.#directUploadOption === false) {\n return Promise.resolve({ directUpload: false })\n }\n if (!this.#capsPromise) {\n this.#capsPromise = fetch(`${this.apiUrl}/api/capabilities`, {\n headers: this.#authHeaders(),\n })\n .then(async (res) => {\n if (!res.ok) return { directUpload: false }\n const body = (await res.json().catch(() => ({}))) as Record<string, unknown>\n return { directUpload: body['directUpload'] === true }\n })\n .catch(() => ({ directUpload: false }))\n }\n return this.#capsPromise\n }\n\n // ─── Direct-to-bucket upload ────────────────────────────────────────────────\n\n /**\n * Presign → PUT-to-bucket → confirm. Bytes go browser → bucket directly; our\n * server only signs and records. Used when the account has the directUpload\n * capability and the file fits a single PUT.\n */\n async #uploadDirect(file: File | Blob, opts: UploadOptions): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n const name = filename ?? (file instanceof File ? file.name : 'upload')\n // Must match the Content-Type we send on the PUT (the server signs it).\n const contentType = file instanceof File && file.type ? file.type : 'application/octet-stream'\n\n onProgress?.(0)\n checkAbort(signal)\n\n const protection = this.#resolveProtection(opts)\n const presign = async (): Promise<PresignResponse> => {\n const res = await fetchWithRetry(\n `${this.apiUrl}/api/uploads/presign`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename: name, contentType, size: file.size, ...protection }),\n },\n signal,\n )\n return (await res.json()) as PresignResponse\n }\n\n let signed = await presign()\n\n // Advisory checksum (best-effort; unverifiable server-side).\n const checksum = await sha256Hex(file)\n\n // PUT straight to the bucket. A 403 means the short-lived signature lapsed\n // (or a stale key) — re-presign once and retry before giving up.\n try {\n await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal })\n } catch (err) {\n if (err instanceof UploaderError && err.statusCode === 403) {\n signed = await presign()\n await xhrPut(signed.putUrl, file, { contentType: signed.contentType, onProgress, signal })\n } else {\n throw err\n }\n }\n\n // Confirm — server verifies the object exists, records usage, enqueues.\n checkAbort(signal)\n const confirmRes = await fetchWithRetry(\n `${this.apiUrl}/api/uploads/confirm`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ handle: signed.handle, checksum }),\n },\n signal,\n )\n const body = (await confirmRes.json()) as unknown\n onProgress?.(100)\n // Non-disruptive soft-cap heads-up (bandwidth). Emitted before returning so a\n // consumer sees it even if they ignore the return value; never throws.\n this.#emitUsageWarning(body)\n return this.#parseFileResult(body)\n }\n\n // ─── Auth headers ──────────────────────────────────────────────────────────\n\n /**\n * Build the auth headers shared by all requests.\n * Attaches the API key and, when present, the signed policy pair.\n */\n #authHeaders(): Record<string, string> {\n const headers: Record<string, string> = {\n 'X-Uploader-Key': this.apikey,\n }\n if (this.security) {\n headers['X-Uploader-Policy'] = this.security.policy\n headers['X-Uploader-Signature'] = this.security.signature\n }\n return headers\n }\n\n // ─── Single-shot upload ────────────────────────────────────────────────────\n\n /**\n * POST /api/store — multipart/form-data for files ≤ MULTIPART_THRESHOLD.\n */\n async #uploadSingleShot(\n file: File | Blob,\n opts: UploadOptions,\n ): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n\n onProgress?.(0)\n checkAbort(signal)\n\n const form = new FormData()\n form.append('file', file, filename ?? (file instanceof File ? file.name : 'upload'))\n if (filename) form.append('filename', filename)\n // Per-file delivery protection (docs/13) — sent as form fields; allowedOrigins\n // is JSON-encoded to match the server's parse. Omitted when unset (inherit).\n const protection = this.#resolveProtection(opts)\n if (protection.deliveryProtection) form.append('deliveryProtection', protection.deliveryProtection)\n if (protection.allowedOrigins) form.append('allowedOrigins', JSON.stringify(protection.allowedOrigins))\n\n const res = await fetchWithRetry(\n `${this.apiUrl}/api/store`,\n {\n method: 'POST',\n headers: this.#authHeaders(),\n body: form,\n },\n signal,\n )\n\n const body = await res.json() as unknown\n onProgress?.(100)\n return this.#parseFileResult(body)\n }\n\n // ─── Multipart upload ──────────────────────────────────────────────────────\n\n /**\n * start → parts (parallel with progress) → complete for files > MULTIPART_THRESHOLD.\n */\n async #uploadMultipart(\n file: File | Blob,\n parts: Blob[],\n opts: UploadOptions,\n ): Promise<FileResult> {\n const { onProgress, filename, signal } = opts\n const name = filename ?? (file instanceof File ? file.name : 'upload')\n const mime = file instanceof File ? file.type : 'application/octet-stream'\n\n onProgress?.(0)\n checkAbort(signal)\n\n // 1. Start\n const protection = this.#resolveProtection(opts)\n const startRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/start`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ filename: name, mimetype: mime, size: file.size, ...protection }),\n },\n signal,\n )\n const { uploadId } = await startRes.json() as UploadStartResponse\n\n // 2. Upload parts sequentially (retried individually)\n const etags: { partNumber: number; etag: string }[] = []\n let uploadedBytes = 0\n\n for (let i = 0; i < parts.length; i++) {\n checkAbort(signal)\n const partBlob = parts[i]!\n const partNumber = i + 1\n\n const partForm = new FormData()\n partForm.append('uploadId', uploadId)\n partForm.append('partNumber', String(partNumber))\n partForm.append('part', partBlob)\n\n const partRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/part`,\n {\n method: 'POST',\n headers: this.#authHeaders(),\n body: partForm,\n },\n signal,\n )\n const { etag } = await partRes.json() as UploadPartResponse\n\n etags.push({ partNumber, etag })\n uploadedBytes += partBlob.size\n onProgress?.(Math.round((uploadedBytes / file.size) * 95)) // reserve 5% for complete\n }\n\n // 3. Complete\n checkAbort(signal)\n const completeRes = await fetchWithRetry(\n `${this.apiUrl}/api/upload/complete`,\n {\n method: 'POST',\n headers: { ...this.#authHeaders(), 'Content-Type': 'application/json' },\n body: JSON.stringify({ uploadId, parts: etags }),\n },\n signal,\n )\n const body = await completeRes.json() as unknown\n onProgress?.(100)\n return this.#parseFileResult(body)\n }\n\n // ─── Response parser ───────────────────────────────────────────────────────\n\n #parseFileResult(body: unknown): FileResult {\n if (\n typeof body !== 'object' ||\n body === null ||\n typeof (body as Record<string, unknown>)['handle'] !== 'string' ||\n typeof (body as Record<string, unknown>)['url'] !== 'string'\n ) {\n throw new UploaderError('INVALID_RESPONSE', 'Unexpected response shape from upload API')\n }\n return body as FileResult\n }\n\n // ─── Public API ───────────────────────────────────────────────────────────\n\n /**\n * Upload a single file.\n *\n * Automatically selects single-shot vs multipart upload based on file size.\n * Emits progress via `opts.onProgress` (0–100). Respects `opts.signal` for\n * cancellation. Retries network errors and 5xx responses up to 3 times with\n * exponential backoff; 4xx errors are surfaced immediately.\n *\n * @throws {UploaderError} with code ABORTED | NETWORK_ERROR | SERVER_ERROR |\n * CLIENT_ERROR | INVALID_RESPONSE\n */\n async upload(\n file: File | Blob,\n opts: UploadOptions = {},\n ): Promise<FileResult> {\n // Prefer direct-to-bucket when the account supports it and the file fits a\n // single PUT. The probe fails open, so an old server or a disabled account\n // transparently uses the proxied flow below.\n const caps = await this.#getCapabilities()\n if (caps.directUpload && file.size <= MAX_DIRECT_PUT_BYTES) {\n return this.#uploadDirect(file, opts)\n }\n\n const plan = planChunks(file, opts.chunkSize)\n if (plan.mode === 'single') {\n return this.#uploadSingleShot(file, opts)\n }\n return this.#uploadMultipart(file, plan.parts, opts)\n }\n\n /**\n * Upload multiple files with concurrency limiting.\n *\n * Resolves once all uploads settle (fulfilled or rejected). The returned\n * array preserves input order. `opts.concurrency` caps simultaneous\n * in-flight uploads (default 3). Each file shares the same opts\n * (including onProgress — the callback fires per-file, not aggregate).\n *\n * @returns Array of PromiseSettledResult in input order.\n */\n async uploadAll(\n files: Array<File | Blob>,\n opts: UploadAllOptions = {},\n ): Promise<PromiseSettledResult<FileResult>[]> {\n const concurrency = opts.concurrency ?? 3\n const results: PromiseSettledResult<FileResult>[] = new Array(files.length)\n\n let index = 0\n\n async function worker(client: UploaderClient): Promise<void> {\n while (index < files.length) {\n const i = index++\n const file = files[i]!\n try {\n results[i] = { status: 'fulfilled', value: await client.upload(file, opts) }\n } catch (err) {\n results[i] = { status: 'rejected', reason: err }\n }\n }\n }\n\n const workers = Array.from({ length: Math.min(concurrency, files.length) }, () =>\n worker(this),\n )\n await Promise.all(workers)\n return results\n }\n}\n"],"mappings":"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","names":[]}