@ironfang/render 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,17 @@ URL or from HTML you send. Zero dependencies; Node 20 and later.
7
7
  Formerly `@ironfang/renderwolf`; 2.0.0 removed the old `Renderwolf` and
8
8
  `RenderwolfError` exports. Webhooks are signed with the `Ironfang-*` headers.
9
9
 
10
+ 3.0.0 follows Ironfang's move from credits to metered usage on one billing
11
+ account: a render reports `meter` and `quantity` instead of `credits`, a job
12
+ reports `usage` instead of `credits`, `usage()` returns the month per meter,
13
+ request history records `meter` and `quantity`, and billing refusals throw
14
+ `IronfangBillingError`. Renders accept `idempotencyKey`.
15
+
16
+ 3.0.1 fixes `verifyWebhook` for Render: it used the text of the signing secret as
17
+ the HMAC key, while a Render secret is the hex of the 32-byte key deliveries
18
+ are signed with, so it rejected every genuine delivery. Upgrade if you
19
+ verify Render webhooks with it.
20
+
10
21
  ```sh
11
22
  npm install @ironfang/render
12
23
  ```
@@ -18,12 +29,12 @@ const rw = new IronfangRender({ apiKey: process.env.IRONFANG_API_KEY! });
18
29
 
19
30
  const shot = await rw.screenshot({ url: 'https://example.com', full_page: true });
20
31
  await shot.saveTo('page.png');
21
- console.log(shot.credits, shot.renderMs, shot.requestId);
32
+ console.log(shot.meter, shot.quantity, shot.renderMs, shot.requestId);
22
33
  ```
23
34
 
24
35
  Every render returns the bytes plus what the API reports about them: content
25
- type, credits charged (zero on a cache hit), render time and the request id
26
- to quote to support.
36
+ type, the billing meter it counted against and the quantity it counted there
37
+ (zero on a cache hit), render time and the request id to quote to support.
27
38
 
28
39
  ## Change the page before capture
29
40
 
@@ -68,9 +79,32 @@ if (done.status === 'succeeded') {
68
79
  }
69
80
  ```
70
81
 
82
+ The job's `usage` says what it holds: `{ meter, reserved, committed }`. The
83
+ most the job can count is reserved when it is accepted, and what it produced
84
+ is committed when it succeeds. A job that fails or is cancelled while queued
85
+ counts nothing.
86
+
87
+ ## Usage and billing
88
+
89
+ Ironfang Render has no plans or credits. Usage is billed to your
90
+ organisation's Ironfang billing account, shared by every Ironfang product and
91
+ managed in Billing in the [Ironfang portal](https://portal.ironfang.com). Each
92
+ kind of render counts against a meter, and each meter has a monthly free
93
+ allowance; beyond it, usage is pay-as-you-go once paid usage is enabled.
94
+
95
+ ```ts
96
+ const usage = await rw.usage();
97
+ if (!('unmetered' in usage)) {
98
+ for (const m of usage.meters) console.log(m.id, m.used, m.remaining_free);
99
+ }
100
+ ```
101
+
71
102
  ## Verifying a webhook
72
103
 
73
- Run the check over the raw request body, before parsing it.
104
+ Run the check over the raw request body, before parsing it. Pass the signing
105
+ secret exactly as it was shown when the destination was created: a Render (or
106
+ Finance) secret is 64 hex characters, and the check decodes it to the 32-byte
107
+ key; an Ironfang Audit secret (`awsec_...`) is used as it is.
74
108
 
75
109
  ```ts
76
110
  import { verifyWebhook } from '@ironfang/render';
@@ -86,9 +120,35 @@ const ok = await verifyWebhook({
86
120
  ## Errors
87
121
 
88
122
  Failures throw `IronfangRenderError` with `status`, the API's `code`
89
- (`quota_exhausted`, `render_failed`, ...) and `requestId`. Synchronous renders
90
- are never retried by the client: a render that timed out may already have
91
- been charged, and retrying it blind doubles the bill.
123
+ (`render_failed`, `rate_limited`, ...), `requestId` and, when the API sent
124
+ one, `retryAfter` in seconds. Branch on `code` rather than the status: rate
125
+ limits and billing refusals can share a 429.
126
+
127
+ When the billing account refuses a render, nothing is rendered or counted and
128
+ the error is an `IronfangBillingError` (a subclass) with `product`, and
129
+ `meter` and `resetAt` when the API states them. The codes are
130
+ `free_allowance_exhausted`, `account_budget_exhausted`,
131
+ `product_budget_exhausted`, `exemption_limit_reached`, `paid_usage_paused`,
132
+ `payment_required`, `payment_action_required`, `account_restricted`,
133
+ `product_not_eligible`, `account_closed`, `operation_too_large` and
134
+ `billing_temporarily_unavailable`. Only the last clears by itself, after
135
+ `retryAfter`; the rest are resolved in Billing or when the month rolls over.
136
+
137
+ ```ts
138
+ import { IronfangBillingError } from '@ironfang/render';
139
+
140
+ try {
141
+ await rw.screenshot({ url: 'https://example.com' });
142
+ } catch (err) {
143
+ if (err instanceof IronfangBillingError) console.error(err.code, err.resetAt);
144
+ else throw err;
145
+ }
146
+ ```
147
+
148
+ Synchronous renders are never retried by the client: a render that timed out
149
+ may already have counted, and retrying it blind counts it twice. Pass
150
+ `idempotencyKey` to make a render count at most once; the same key again is
151
+ refused with `idempotency_key_used`, since a synchronous result is not kept.
92
152
 
93
153
  ## Keys
94
154
 
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { ClipRequest, Destination, DestinationDraft, ImageRequest, Job, JobSubmission, PdfRequest, QrRequest, RequestFilter, RequestRecord, ScreenshotRequest, SignRequest, SignedUrl, SitePreviewRequest, Usage } from './types.js';
2
- export declare const VERSION = "2.0.0";
1
+ import type { ClipRequest, Destination, DestinationDraft, ImageRequest, Job, JobSubmission, PdfRequest, QrRequest, RequestFilter, RequestRecord, ScreenshotRequest, SignRequest, SignedUrl, SitePreviewRequest, UnmeteredUsage, Usage } from './types.js';
2
+ export declare const VERSION = "3.0.1";
3
3
  export interface ClientOptions {
4
4
  /** Your `rw_live_...` key. Never ship this to a browser. */
5
5
  apiKey: string;
@@ -11,18 +11,27 @@ export interface ClientOptions {
11
11
  fetch?: typeof fetch;
12
12
  }
13
13
  export interface RequestOptions {
14
- /** Cancel the request. A synchronous render that has started may still be charged. */
14
+ /** Cancel the request. A synchronous render that has started may still count. */
15
15
  signal?: AbortSignal;
16
16
  /** Your own correlation id, echoed as X-Request-ID and kept in request history. */
17
17
  requestId?: string;
18
18
  timeoutMs?: number;
19
+ /**
20
+ * Sent as Idempotency-Key, up to 255 characters. On a render it makes the
21
+ * render happen, and count, at most once: the same key again is refused
22
+ * with `idempotency_key_used`, because a synchronous result is not kept.
23
+ * On a job submission the same key returns the existing job.
24
+ */
25
+ idempotencyKey?: string;
19
26
  }
20
27
  /** A finished synchronous render with the facts the API reports about it. */
21
28
  export interface RenderResult {
22
29
  bytes: Uint8Array;
23
30
  contentType: string;
24
- /** Credits this render cost. Zero when served from cache. */
25
- credits: number;
31
+ /** The billing meter the render counted against, e.g. `render.screenshot`. */
32
+ meter: string | undefined;
33
+ /** What it counted there: 1 for a unit render, output seconds for video, 0 from cache. */
34
+ quantity: number;
26
35
  renderMs: number;
27
36
  cached: boolean;
28
37
  /** The id to quote to support. */
@@ -36,7 +45,10 @@ export interface RenderResult {
36
45
  export interface SitePreviewResult {
37
46
  poster: Uint8Array;
38
47
  video: Uint8Array;
39
- credits: number;
48
+ /** `render.site_preview_second`. */
49
+ meter: string | undefined;
50
+ /** Output seconds counted, rounded up; 0 from cache. */
51
+ quantity: number;
40
52
  renderMs: number;
41
53
  outputSeconds: number;
42
54
  cached: boolean;
@@ -66,11 +78,11 @@ export declare class IronfangRender {
66
78
  readonly jobs: {
67
79
  /**
68
80
  * Submit a durable job. Pass `idempotencyKey` to make a retry of the same
69
- * submission return the same job instead of a second one.
81
+ * submission return the same job instead of a second one. The most the
82
+ * job can count is reserved when it is accepted; its `usage` says what it
83
+ * holds and, once it succeeds, what it committed.
70
84
  */
71
- submit: (sub: JobSubmission, opts?: RequestOptions & {
72
- idempotencyKey?: string;
73
- }) => Promise<Job>;
85
+ submit: (sub: JobSubmission, opts?: RequestOptions) => Promise<Job>;
74
86
  get: (id: string, opts?: RequestOptions) => Promise<Job>;
75
87
  list: (params?: {
76
88
  status?: string;
@@ -104,7 +116,13 @@ export declare class IronfangRender {
104
116
  };
105
117
  /** Mint a signed render URL that an <img> or an email can fetch directly. */
106
118
  sign(req: SignRequest, opts?: RequestOptions): Promise<SignedUrl>;
107
- usage(opts?: RequestOptions): Promise<Usage>;
119
+ /**
120
+ * This month's Ironfang Render usage from your organisation's billing
121
+ * account: per meter, what was used, how much the free allowance covered
122
+ * and what is held by work in flight. A deployment with billing off, or an
123
+ * unmetered key, answers `{ unmetered: true }`.
124
+ */
125
+ usage(opts?: RequestOptions): Promise<Usage | UnmeteredUsage>;
108
126
  /** Request history for the last seven days. */
109
127
  requests(filter?: RequestFilter, opts?: RequestOptions): Promise<{
110
128
  requests: RequestRecord[];
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- import { IronfangRenderError } from './errors.js';
2
- export const VERSION = '2.0.0';
1
+ import { BILLING_CODES, IronfangBillingError, IronfangRenderError } from './errors.js';
2
+ export const VERSION = '3.0.1';
3
3
  export class IronfangRender {
4
4
  apiKey;
5
5
  baseUrl;
@@ -32,7 +32,7 @@ export class IronfangRender {
32
32
  return this.render('/v1/video', req, opts);
33
33
  }
34
34
  async sitePreview(req, opts = {}) {
35
- const resp = await this.send('POST', '/v1/site-preview', req, opts);
35
+ const resp = await this.send('POST', '/v1/site-preview', req, opts, idempotency(opts));
36
36
  const form = await resp.formData();
37
37
  const poster = form.get('poster');
38
38
  const video = form.get('video');
@@ -44,7 +44,7 @@ export class IronfangRender {
44
44
  return {
45
45
  poster: new Uint8Array(await poster.arrayBuffer()),
46
46
  video: new Uint8Array(await video.arrayBuffer()),
47
- credits: num(resp.headers.get('X-Ironfang-Credits')),
47
+ ...usageOf(resp),
48
48
  renderMs: num(resp.headers.get('X-Ironfang-Render-Ms')),
49
49
  outputSeconds: num(resp.headers.get('X-Ironfang-Output-Seconds')),
50
50
  cached: resp.headers.get('X-Ironfang-Cache') === 'hit',
@@ -53,12 +53,12 @@ export class IronfangRender {
53
53
  };
54
54
  }
55
55
  async render(path, body, opts) {
56
- const resp = await this.send('POST', path, body, opts);
56
+ const resp = await this.send('POST', path, body, opts, idempotency(opts));
57
57
  const bytes = new Uint8Array(await resp.arrayBuffer());
58
58
  return {
59
59
  bytes,
60
60
  contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
61
- credits: num(resp.headers.get('X-Ironfang-Credits')),
61
+ ...usageOf(resp),
62
62
  renderMs: num(resp.headers.get('X-Ironfang-Render-Ms')),
63
63
  cached: resp.headers.get('X-Ironfang-Cache') === 'hit',
64
64
  requestId: requestIdOf(resp),
@@ -70,13 +70,12 @@ export class IronfangRender {
70
70
  jobs = {
71
71
  /**
72
72
  * Submit a durable job. Pass `idempotencyKey` to make a retry of the same
73
- * submission return the same job instead of a second one.
73
+ * submission return the same job instead of a second one. The most the
74
+ * job can count is reserved when it is accepted; its `usage` says what it
75
+ * holds and, once it succeeds, what it committed.
74
76
  */
75
77
  submit: async (sub, opts = {}) => {
76
- const headers = {};
77
- if (opts.idempotencyKey)
78
- headers['Idempotency-Key'] = opts.idempotencyKey;
79
- const resp = await this.send('POST', '/v1/jobs', sub, opts, headers);
78
+ const resp = await this.send('POST', '/v1/jobs', sub, opts, idempotency(opts));
80
79
  return (await resp.json());
81
80
  },
82
81
  get: async (id, opts = {}) => {
@@ -98,7 +97,9 @@ export class IronfangRender {
98
97
  return {
99
98
  bytes,
100
99
  contentType: (resp.headers.get('Content-Type') ?? 'application/octet-stream').split(';')[0].trim(),
101
- credits: 0,
100
+ // Collecting a result counts nothing; the job's `usage` says what it did.
101
+ meter: undefined,
102
+ quantity: 0,
102
103
  renderMs: 0,
103
104
  cached: false,
104
105
  requestId: requestIdOf(resp),
@@ -151,6 +152,12 @@ export class IronfangRender {
151
152
  const resp = await this.send('POST', '/v1/sign', req, opts);
152
153
  return (await resp.json());
153
154
  }
155
+ /**
156
+ * This month's Ironfang Render usage from your organisation's billing
157
+ * account: per meter, what was used, how much the free allowance covered
158
+ * and what is held by work in flight. A deployment with billing off, or an
159
+ * unmetered key, answers `{ unmetered: true }`.
160
+ */
154
161
  async usage(opts = {}) {
155
162
  const resp = await this.send('GET', '/v1/usage', undefined, opts);
156
163
  return (await resp.json());
@@ -187,7 +194,7 @@ export class IronfangRender {
187
194
  }
188
195
  catch (err) {
189
196
  // No retry here on purpose: a synchronous render that timed out may
190
- // have been charged, and retrying it blind doubles the bill.
197
+ // already have counted, and retrying it blind counts it twice.
191
198
  if (controller.signal.aborted) {
192
199
  const reason = controller.signal.reason;
193
200
  const timedOut = reason instanceof DOMException && reason.name === 'TimeoutError';
@@ -208,17 +215,43 @@ export class IronfangRender {
208
215
  async function errorFrom(resp) {
209
216
  let code = `http_${resp.status}`;
210
217
  let message = `request failed with HTTP ${resp.status}`;
218
+ let detail = {};
211
219
  try {
212
220
  const body = (await resp.json());
213
221
  if (body?.error?.code)
214
222
  code = body.error.code;
215
223
  if (body?.error?.message)
216
224
  message = body.error.message;
225
+ if (body?.error)
226
+ detail = body.error;
217
227
  }
218
228
  catch {
219
229
  // Not JSON; the status is all there is.
220
230
  }
221
- return new IronfangRenderError(message, { status: resp.status, code, requestId: requestIdOf(resp) });
231
+ const opts = { status: resp.status, code, requestId: requestIdOf(resp), retryAfter: retryAfterOf(resp) };
232
+ if (BILLING_CODES.includes(code)) {
233
+ return new IronfangBillingError(message, {
234
+ ...opts, product: text(detail.product), meter: text(detail.meter), resetAt: text(detail.reset_at),
235
+ });
236
+ }
237
+ return new IronfangRenderError(message, opts);
238
+ }
239
+ /** The meter and quantity a render response reports. */
240
+ function usageOf(resp) {
241
+ return { meter: resp.headers.get('X-Ironfang-Meter') ?? undefined, quantity: num(resp.headers.get('X-Ironfang-Quantity')) };
242
+ }
243
+ function idempotency(opts) {
244
+ return opts.idempotencyKey ? { 'Idempotency-Key': opts.idempotencyKey } : {};
245
+ }
246
+ /** Retry-After in seconds; the API sends whole seconds. */
247
+ function retryAfterOf(resp) {
248
+ const v = resp.headers.get('Retry-After');
249
+ if (v === null || !/^\d+$/.test(v.trim()))
250
+ return undefined;
251
+ return Number(v.trim());
252
+ }
253
+ function text(v) {
254
+ return typeof v === 'string' && v !== '' ? v : undefined;
222
255
  }
223
256
  function requestIdOf(resp) {
224
257
  return resp.headers.get('X-Ironfang-Request-ID') ?? '';
package/dist/errors.d.ts CHANGED
@@ -1,20 +1,54 @@
1
1
  /**
2
2
  * An error the API returned, or a failure to reach it.
3
3
  *
4
- * `code` is the API's stable error code (`bad_request`, `quota_exhausted`,
5
- * `render_failed`, ...) or a client-side one (`network_error`, `timeout`,
6
- * `aborted`). `requestId` is the id to quote to support; it is on every
7
- * response the server produced, including errors.
4
+ * `code` is the API's stable error code (`bad_request`, `render_failed`,
5
+ * `free_allowance_exhausted`, ...) or a client-side one (`network_error`,
6
+ * `timeout`, `aborted`). `requestId` is the id to quote to support; it is on
7
+ * every response the server produced, including errors. `retryAfter` is the
8
+ * API's `Retry-After` in seconds, when it sent one.
8
9
  */
9
10
  export declare class IronfangRenderError extends Error {
10
11
  readonly status: number;
11
12
  readonly code: string;
12
13
  readonly requestId: string | undefined;
14
+ readonly retryAfter: number | undefined;
13
15
  constructor(message: string, opts: {
14
16
  status: number;
15
17
  code: string;
16
18
  requestId?: string | undefined;
19
+ retryAfter?: number | undefined;
17
20
  });
18
21
  /** The request was refused before any work was done. */
19
22
  get isClientError(): boolean;
20
23
  }
24
+ /**
25
+ * The codes a billing refusal carries. They are the same in every Ironfang
26
+ * product, because every product admits work through the one billing account.
27
+ */
28
+ export declare const BILLING_CODES: readonly string[];
29
+ /**
30
+ * The organisation's billing account refused the request before any work
31
+ * started: nothing was rendered and nothing counted. Most refusals are
32
+ * resolved in Billing in the Ironfang portal (enable paid usage, raise a
33
+ * limit, update the payment method) or by the month rolling over; only
34
+ * `billing_temporarily_unavailable` clears on its own, after `retryAfter`
35
+ * seconds.
36
+ *
37
+ * `product` is the product whose usage was refused (always `render` here).
38
+ * `meter` and `resetAt` (when the free allowance renews, RFC 3339) are set
39
+ * when the API states them.
40
+ */
41
+ export declare class IronfangBillingError extends IronfangRenderError {
42
+ readonly product: string;
43
+ readonly meter: string | undefined;
44
+ readonly resetAt: string | undefined;
45
+ constructor(message: string, opts: {
46
+ status: number;
47
+ code: string;
48
+ requestId?: string | undefined;
49
+ retryAfter?: number | undefined;
50
+ product?: string | undefined;
51
+ meter?: string | undefined;
52
+ resetAt?: string | undefined;
53
+ });
54
+ }
package/dist/errors.js CHANGED
@@ -1,24 +1,69 @@
1
1
  /**
2
2
  * An error the API returned, or a failure to reach it.
3
3
  *
4
- * `code` is the API's stable error code (`bad_request`, `quota_exhausted`,
5
- * `render_failed`, ...) or a client-side one (`network_error`, `timeout`,
6
- * `aborted`). `requestId` is the id to quote to support; it is on every
7
- * response the server produced, including errors.
4
+ * `code` is the API's stable error code (`bad_request`, `render_failed`,
5
+ * `free_allowance_exhausted`, ...) or a client-side one (`network_error`,
6
+ * `timeout`, `aborted`). `requestId` is the id to quote to support; it is on
7
+ * every response the server produced, including errors. `retryAfter` is the
8
+ * API's `Retry-After` in seconds, when it sent one.
8
9
  */
9
10
  export class IronfangRenderError extends Error {
10
11
  status;
11
12
  code;
12
13
  requestId;
14
+ retryAfter;
13
15
  constructor(message, opts) {
14
16
  super(message);
15
17
  this.name = 'IronfangRenderError';
16
18
  this.status = opts.status;
17
19
  this.code = opts.code;
18
20
  this.requestId = opts.requestId;
21
+ this.retryAfter = opts.retryAfter;
19
22
  }
20
23
  /** The request was refused before any work was done. */
21
24
  get isClientError() {
22
25
  return this.status >= 400 && this.status < 500;
23
26
  }
24
27
  }
28
+ /**
29
+ * The codes a billing refusal carries. They are the same in every Ironfang
30
+ * product, because every product admits work through the one billing account.
31
+ */
32
+ export const BILLING_CODES = [
33
+ 'free_allowance_exhausted',
34
+ 'account_budget_exhausted',
35
+ 'product_budget_exhausted',
36
+ 'exemption_limit_reached',
37
+ 'paid_usage_paused',
38
+ 'payment_required',
39
+ 'payment_action_required',
40
+ 'account_restricted',
41
+ 'product_not_eligible',
42
+ 'account_closed',
43
+ 'operation_too_large',
44
+ 'billing_temporarily_unavailable',
45
+ ];
46
+ /**
47
+ * The organisation's billing account refused the request before any work
48
+ * started: nothing was rendered and nothing counted. Most refusals are
49
+ * resolved in Billing in the Ironfang portal (enable paid usage, raise a
50
+ * limit, update the payment method) or by the month rolling over; only
51
+ * `billing_temporarily_unavailable` clears on its own, after `retryAfter`
52
+ * seconds.
53
+ *
54
+ * `product` is the product whose usage was refused (always `render` here).
55
+ * `meter` and `resetAt` (when the free allowance renews, RFC 3339) are set
56
+ * when the API states them.
57
+ */
58
+ export class IronfangBillingError extends IronfangRenderError {
59
+ product;
60
+ meter;
61
+ resetAt;
62
+ constructor(message, opts) {
63
+ super(message, opts);
64
+ this.name = 'IronfangBillingError';
65
+ this.product = opts.product ?? 'render';
66
+ this.meter = opts.meter;
67
+ this.resetAt = opts.resetAt;
68
+ }
69
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { IronfangRender, VERSION } from './client.js';
2
2
  export type { ClientOptions, RequestOptions, RenderResult, SitePreviewResult, WaitOptions } from './client.js';
3
- export { IronfangRenderError } from './errors.js';
3
+ export { IronfangBillingError, IronfangRenderError } from './errors.js';
4
4
  export { verifyWebhook, signWebhook } from './webhook.js';
5
5
  export type { WebhookInput } from './webhook.js';
6
6
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { IronfangRender, VERSION } from './client.js';
2
- export { IronfangRenderError } from './errors.js';
2
+ export { IronfangBillingError, IronfangRenderError } from './errors.js';
3
3
  export { verifyWebhook, signWebhook } from './webhook.js';
package/dist/types.d.ts CHANGED
@@ -118,6 +118,17 @@ export interface JobDelivery {
118
118
  storage_destination?: string;
119
119
  storage_key?: string;
120
120
  }
121
+ /**
122
+ * What a job counts against the billing account: its meter, the quantity
123
+ * reserved when it was accepted (0 when unmetered) and the quantity its
124
+ * success committed, never more than was reserved. A job that fails or is
125
+ * cancelled commits nothing.
126
+ */
127
+ export interface JobUsage {
128
+ meter: string;
129
+ reserved: number;
130
+ committed: number;
131
+ }
121
132
  export interface JobSubmission {
122
133
  kind: JobKind;
123
134
  request: Record<string, unknown>;
@@ -136,11 +147,7 @@ export interface Job {
136
147
  queued_at: string;
137
148
  started_at?: string | null;
138
149
  completed_at?: string | null;
139
- credits: {
140
- reserved: number;
141
- charged: number;
142
- refunded: number;
143
- };
150
+ usage: JobUsage;
144
151
  request_summary?: Record<string, unknown>;
145
152
  delivery?: JobDelivery;
146
153
  error?: {
@@ -171,23 +178,55 @@ export interface SignedUrl {
171
178
  url: string;
172
179
  path: string;
173
180
  }
181
+ /** One Ironfang Render meter's position in the month. */
182
+ export interface MeterUsage {
183
+ /** The meter, e.g. `render.screenshot` or `render.video_second_hd`. */
184
+ id: string;
185
+ unit: string;
186
+ unit_plural: string;
187
+ /** This month's free quantity. */
188
+ free_allowance: number;
189
+ /** Quantity committed this month. */
190
+ used: number;
191
+ /** How much of `used` the free allowance covered. */
192
+ free_used: number;
193
+ /** `used` beyond the free allowance. */
194
+ billable: number;
195
+ /** Quantity held by work in flight. */
196
+ reserved: number;
197
+ /** Free allowance left once work in flight is counted. */
198
+ remaining_free: number;
199
+ /** What `billable` costs before tax, as an exact decimal string. */
200
+ charge: string;
201
+ /** A meter that is never charged (QR codes). */
202
+ free_utility?: boolean;
203
+ /** The account is exempt from settling this product. */
204
+ exempt?: boolean;
205
+ }
206
+ /**
207
+ * This UTC calendar month's Ironfang Render usage from the organisation's
208
+ * billing account. Amounts are exact decimal strings in `currency`, before
209
+ * tax. The whole account, every product included, is managed in Billing in
210
+ * the Ironfang portal.
211
+ */
174
212
  export interface Usage {
175
- period: string;
176
- credits: number;
177
- renders: number;
178
- limit: number;
213
+ product: 'render';
214
+ /** The period as [period_start, period_end) in UTC dates. */
179
215
  period_start: string;
180
216
  period_end: string;
181
- by_kind?: Record<string, {
182
- credits: number;
183
- count: number;
184
- }>;
185
- unattributed?: number;
186
- daily?: {
187
- day: string;
188
- credits: number;
189
- by_kind?: Record<string, number>;
190
- }[];
217
+ catalogue_version: string;
218
+ /** `enabled` means paid usage beyond the free allowances is on. */
219
+ mode: 'free' | 'setup_pending' | 'enabled' | 'paused' | 'closed';
220
+ currency: string;
221
+ /** What Ironfang Render's settled usage costs this period. */
222
+ settled: string;
223
+ /** The most work in flight could add if it all succeeds; not charged on top of `settled`. */
224
+ reserved: string;
225
+ meters: MeterUsage[];
226
+ }
227
+ /** The usage answer for an unmetered key, or a deployment with billing off. */
228
+ export interface UnmeteredUsage {
229
+ unmetered: true;
191
230
  }
192
231
  /** One row of request history: what was asked for and what happened, never the values. */
193
232
  export interface RequestRecord {
@@ -202,8 +241,10 @@ export interface RequestRecord {
202
241
  outcome: 'ok' | 'error' | 'cancelled';
203
242
  error_code?: string;
204
243
  cache_hit: boolean;
205
- credits_charged: number;
206
- credits_refunded: number;
244
+ /** The billing meter the request counted against. */
245
+ meter?: string;
246
+ /** What it committed there: 0 for a cache hit, a failure or an unmetered key. */
247
+ quantity: number;
207
248
  response_bytes: number;
208
249
  content_type?: string;
209
250
  duration_ms: number;
package/dist/webhook.d.ts CHANGED
@@ -6,9 +6,15 @@
6
6
  * result as `Ironfang-Signature: v1=<hex>` with `Ironfang-Timestamp` beside
7
7
  * it. The check must run over the raw bytes as received: re-serialising parsed
8
8
  * JSON changes them and the signature will not match.
9
+ *
10
+ * Pass the signing secret exactly as Ironfang returned it. A Render or
11
+ * Finance secret is 64 hex characters, the hex form of a 32-byte key, and the
12
+ * key is those 32 bytes; an Audit secret (`awsec_...`) is used as it is. This
13
+ * module decodes accordingly, so the same call verifies a delivery from any of
14
+ * the three.
9
15
  */
10
16
  export interface WebhookInput {
11
- /** The endpoint's signing secret, shown once when the destination was created. */
17
+ /** The endpoint's signing secret, exactly as it was shown once when the destination was created. */
12
18
  secret: string;
13
19
  /** The `Ironfang-Timestamp` header. */
14
20
  timestamp: string;
package/dist/webhook.js CHANGED
@@ -13,7 +13,7 @@ export async function verifyWebhook(input) {
13
13
  }
14
14
  /** Compute the signature Ironfang Render would send. Exposed for tests and for tooling that replays deliveries. */
15
15
  export async function signWebhook(secret, timestamp, payload) {
16
- const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
16
+ const key = await crypto.subtle.importKey('raw', signingKey(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
17
17
  const body = typeof payload === 'string' ? encoder.encode(payload) : payload;
18
18
  const signed = new Uint8Array(encoder.encode(timestamp + '.').length + body.length);
19
19
  signed.set(encoder.encode(timestamp + '.'), 0);
@@ -21,6 +21,24 @@ export async function signWebhook(secret, timestamp, payload) {
21
21
  const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, signed));
22
22
  return 'v1=' + Array.from(mac, (b) => b.toString(16).padStart(2, '0')).join('');
23
23
  }
24
+ const HEX_SECRET = /^[0-9a-f]{64}$/i;
25
+ /**
26
+ * The HMAC key a secret stands for. Render and Finance mint 32 random bytes
27
+ * and show them as 64 hex characters, and sign with the bytes; Audit shows an
28
+ * `awsec_` string and signs with that string. Until 3.0.1 this used the text
29
+ * of every secret, so no Render delivery verified.
30
+ */
31
+ function signingKey(secret) {
32
+ const trimmed = secret.trim();
33
+ if (!HEX_SECRET.test(trimmed)) {
34
+ return encoder.encode(secret);
35
+ }
36
+ const key = new Uint8Array(32);
37
+ for (let i = 0; i < 32; i++) {
38
+ key[i] = parseInt(trimmed.slice(2 * i, 2 * i + 2), 16);
39
+ }
40
+ return key;
41
+ }
24
42
  function constantTimeEqual(a, b) {
25
43
  const x = encoder.encode(a);
26
44
  const y = encoder.encode(b);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ironfang/render",
3
- "version": "2.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Ironfang Render API client: screenshots, PDFs, QR codes, template images, clips and site previews from a URL or HTML.",
5
5
  "license": "MIT",
6
6
  "type": "module",