@nohead/sdk 0.7.0 → 0.9.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.
@@ -1,5 +1,5 @@
1
1
  export type ClientOptions = {
2
- baseUrl: 'https://api.nohead.io' | 'http://localhost:3000' | (string & {});
2
+ baseUrl: 'https://api.nohead.io' | (string & {});
3
3
  };
4
4
  export type Webhooks = WebhookEventWebhookRequest;
5
5
  export type RequestId = string;
@@ -1129,7 +1129,7 @@ export type Principal = {
1129
1129
  email: string;
1130
1130
  name: string | null;
1131
1131
  /**
1132
- * Nohead staff (internal tooling access).
1132
+ * Whether the user is Nohead staff.
1133
1133
  */
1134
1134
  staff: boolean;
1135
1135
  };
@@ -1539,6 +1539,25 @@ export type AssetProperties = {
1539
1539
  * The URL of a small rendition (fits in 640×640, WebP), for ready images and SVGs (rasterized; SVGs have no `image_url`), deleted ones included until they are purged; null otherwise.
1540
1540
  */
1541
1541
  preview_url: string | null;
1542
+ /**
1543
+ * The URL of the file as uploaded, for ready assets of any type,
1544
+ * deleted ones included until they are purged; null otherwise. It
1545
+ * downloads the file under its `filename` (a rename applies within a
1546
+ * minute or so), is open and never expires, and is cached on a CDN.
1547
+ *
1548
+ */
1549
+ file_url: string | null;
1550
+ /**
1551
+ * The URL that plays a ready video or audio file inline, for
1552
+ * `<video>` and `<audio>` (with range requests, so players can seek),
1553
+ * deleted ones included until they are purged: MP4, QuickTime, M4A,
1554
+ * 3GP, WebM, Matroska, MP3, AAC, FLAC, WAV and Ogg (whose content is
1555
+ * checked when the upload completes). Null for other types, which
1556
+ * `file_url` downloads.
1557
+ * Open, never expiring and cached on a CDN, like `file_url`.
1558
+ *
1559
+ */
1560
+ media_url: string | null;
1542
1561
  uploaded_at: NullableTimestamp;
1543
1562
  created_at: Timestamp;
1544
1563
  updated_at: Timestamp;
@@ -1569,17 +1588,11 @@ export type AssetUsage = {
1569
1588
  fields: Array<string>;
1570
1589
  }>;
1571
1590
  };
1572
- export type DownloadUrl = {
1573
- object: 'download_url';
1574
- asset_id: string;
1575
- url: string;
1576
- expires_at: Timestamp;
1577
- };
1578
1591
  export type AssetUploadCreate = {
1579
1592
  filename: string;
1580
1593
  content_type: string;
1581
1594
  /**
1582
- * Exact size of the file in bytes (at most 100 MB by default).
1595
+ * Exact size of the file in bytes, at most the plan's largest upload (10 MB on Free, 100 MB on paid plans).
1583
1596
  */
1584
1597
  byte_size: number;
1585
1598
  };
@@ -1609,7 +1622,7 @@ export type AssetUploadTarget = {
1609
1622
  expires_at: Timestamp;
1610
1623
  };
1611
1624
  /**
1612
- * What a project API key may do. `records:write` also publishes, unpublishes and schedules records (the `records:publish` permission). `webhooks:read` includes delivery payloads and `webhooks:write` can subscribe an endpoint to every event, so both give access to the record, schema and asset data in events, unpublished records included; published-only keys cannot have them.
1625
+ * What a project API key may do. `records:write` also publishes, unpublishes and schedules records. `webhooks:read` includes delivery payloads and `webhooks:write` can subscribe an endpoint to every event, so both give access to the record, schema and asset data in events, unpublished records included; published-only keys cannot have them.
1613
1626
  */
1614
1627
  export type ApiKeyScope = 'records:read' | 'records:write' | 'schema:read' | 'schema:write' | 'assets:read' | 'assets:write' | 'search:read' | 'webhooks:read' | 'webhooks:write' | 'audit:read';
1615
1628
  /**
@@ -5741,55 +5754,6 @@ export type AssetsUpdateResponses = {
5741
5754
  200: Asset;
5742
5755
  };
5743
5756
  export type AssetsUpdateResponse = AssetsUpdateResponses[keyof AssetsUpdateResponses];
5744
- export type AssetsDownloadUrlData = {
5745
- body?: never;
5746
- path: {
5747
- asset_id: string;
5748
- };
5749
- query?: never;
5750
- url: '/v1/assets/{asset_id}/download-url';
5751
- };
5752
- export type AssetsDownloadUrlErrors = {
5753
- /**
5754
- * The request was malformed: unparseable JSON, an invalid query parameter, or a body key the operation doesn't take (detail code `unknown_parameter`, naming the key).
5755
- */
5756
- 400: Error;
5757
- /**
5758
- * Authentication is missing or invalid.
5759
- */
5760
- 401: Error;
5761
- /**
5762
- * The caller is not allowed to perform this action.
5763
- */
5764
- 403: Error;
5765
- /**
5766
- * The resource does not exist or is not visible to the caller.
5767
- */
5768
- 404: Error;
5769
- /**
5770
- * The request conflicts with current state.
5771
- */
5772
- 409: Error;
5773
- /**
5774
- * Too many requests for this API key or user (or, with failing
5775
- * credentials, this IP address). A published-only key is counted per
5776
- * client IP. Wait `Retry-After` seconds; retries with the same `Idempotency-Key`
5777
- * are safe. On the Free plan, an organization's API keys are also
5778
- * refused once its API requests reach the month's limit, with a
5779
- * `plan_limit` detail (`field` `api_requests`, `usage` and `limit`),
5780
- * until the next month (UTC) or an upgrade.
5781
- *
5782
- */
5783
- 429: Error;
5784
- };
5785
- export type AssetsDownloadUrlError = AssetsDownloadUrlErrors[keyof AssetsDownloadUrlErrors];
5786
- export type AssetsDownloadUrlResponses = {
5787
- /**
5788
- * The download URL.
5789
- */
5790
- 200: DownloadUrl;
5791
- };
5792
- export type AssetsDownloadUrlResponse = AssetsDownloadUrlResponses[keyof AssetsDownloadUrlResponses];
5793
5757
  export type AssetsUsageData = {
5794
5758
  body?: never;
5795
5759
  path: {
package/dist/images.js CHANGED
@@ -16,8 +16,13 @@ export function imageUrl(image, options = {}) {
16
16
  const at = base.indexOf("/plain/");
17
17
  if (at < 0)
18
18
  throw new TypeError(`Not a Nohead image URL: ${base}`);
19
- const { width, height, fit = "contain", format, quality, dpr, gravity, } = options;
20
- for (const option of [gravity, ...(options.options ?? [])])
19
+ // Empty options count as unset.
20
+ const { width, height, format, quality, dpr, gravity } = options;
21
+ const fit = options.fit || "contain";
22
+ if (!Object.hasOwn(FITS, fit))
23
+ throw new TypeError(`fit must be one of ${Object.keys(FITS).join(", ")}`);
24
+ const others = (options.options ?? []).filter(Boolean);
25
+ for (const option of [gravity, ...others])
21
26
  if (option && /[/@?#]/.test(option))
22
27
  throw new TypeError(`Not an imgproxy option: ${option}`);
23
28
  const segments = [];
@@ -29,7 +34,7 @@ export function imageUrl(image, options = {}) {
29
34
  segments.push(`dpr:${dpr}`);
30
35
  if (quality)
31
36
  segments.push(`q:${quality}`);
32
- segments.push(...(options.options ?? []));
37
+ segments.push(...others);
33
38
  // The source comes last; a format replaces any the URL already names.
34
39
  let source = base.slice(at);
35
40
  if (format)
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Nohead } from "./client.ts";
2
2
  export { APIPromise, Page, PagePromise, type APIResponse, } from "./api-promise.ts";
3
- export type { ClientOptions, ConditionalRequestOptions, ConditionalWriteOptions, RequestOptions, RetryEvent, WriteOptions, } from "./core.ts";
3
+ export { DEFAULT_BASE_URL, type ClientOptions, type ConditionalRequestOptions, type ConditionalWriteOptions, type CopyOptions, type RequestOptions, type RetryEvent, type WriteOptions, } from "./core.ts";
4
4
  export { APIError, AbortError, AuthenticationError, AuthorizationError, ConflictError, ConnectionError, InternalServerError, InvalidRequestError, NoheadError, NotFoundError, PlanLimitExceededError, PreconditionFailedError, RateLimitError, ServiceUnavailableError, TimeoutError, UploadError, ValidationError, WebhookVerificationError, } from "./errors.ts";
5
5
  export type { Uploadable, UploadParams } from "./resources/assets.ts";
6
6
  export type * from "./types.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Nohead } from "./client.js";
2
2
  export { APIPromise, Page, PagePromise, } from "./api-promise.js";
3
+ export { DEFAULT_BASE_URL, } from "./core.js";
3
4
  export { APIError, AbortError, AuthenticationError, AuthorizationError, ConflictError, ConnectionError, InternalServerError, InvalidRequestError, NoheadError, NotFoundError, PlanLimitExceededError, PreconditionFailedError, RateLimitError, ServiceUnavailableError, TimeoutError, UploadError, ValidationError, WebhookVerificationError, } from "./errors.js";
4
5
  export { unwrapWebhook, } from "./webhooks.js";
5
6
  export { VERSION } from "./version.js";
@@ -1,6 +1,6 @@
1
1
  import type { APIPromise, PagePromise } from "../api-promise.ts";
2
2
  import type { RequestOptions } from "../core.ts";
3
- import type { Asset, AssetUpdate, AssetUploadCreate, AssetWithUpload, AssetUsage, DownloadUrl } from "../generated/types.gen.ts";
3
+ import type { Asset, AssetUpdate, AssetUploadCreate, AssetWithUpload, AssetUsage } from "../generated/types.gen.ts";
4
4
  import type { AssetListParams } from "../types.ts";
5
5
  import { Resource } from "./resource.ts";
6
6
  /** What `assets.upload` accepts. A stream needs `byte_size`. */
@@ -8,11 +8,15 @@ export type Uploadable = Blob | ArrayBuffer | Uint8Array | ReadableStream;
8
8
  export interface UploadParams {
9
9
  /** Defaults to a `File`'s name, else `upload`. */
10
10
  filename?: string;
11
- /** Defaults to the `Blob`'s type, else `application/octet-stream`. */
11
+ /**
12
+ * Defaults to the `Blob`'s type, else the filename's extension's for
13
+ * common files, else `application/octet-stream`.
14
+ */
12
15
  content_type?: string;
13
16
  /** Required for a `ReadableStream`; known for everything else. */
14
17
  byte_size?: number;
15
18
  }
19
+ /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
16
20
  export declare class Assets extends Resource {
17
21
  /**
18
22
  * Uploads a file and returns the `ready` asset: creates the upload, sends
@@ -69,6 +73,4 @@ export declare class Assets extends Resource {
69
73
  * updated with the fields that use it (`uses`). Needs `records:read` too.
70
74
  */
71
75
  usage(asset: string, options?: RequestOptions): APIPromise<AssetUsage>;
72
- /** A link to download the original file (`url`), valid for 2 minutes. */
73
- downloadUrl(asset: string, options?: RequestOptions): APIPromise<DownloadUrl>;
74
76
  }
@@ -1,6 +1,5 @@
1
1
  import { AbortError, UploadError } from "../errors.js";
2
2
  import { listQuery, Resource } from "./resource.js";
3
- /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
4
3
  /** Common files' types by extension, as the other SDKs guess them. */
5
4
  const CONTENT_TYPES = {
6
5
  avif: "image/avif",
@@ -26,6 +25,7 @@ function contentTypeOf(filename) {
26
25
  ? CONTENT_TYPES[filename.slice(dot + 1).toLowerCase()]
27
26
  : undefined;
28
27
  }
28
+ /** Files of the key's project. `asset` is an asset ID (`ast_…`). */
29
29
  export class Assets extends Resource {
30
30
  /**
31
31
  * Uploads a file and returns the `ready` asset: creates the upload, sends
@@ -45,23 +45,28 @@ export class Assets extends Resource {
45
45
  * extension's for common files.
46
46
  */
47
47
  async upload(file, params = {}, options) {
48
+ // Never a path: the Python and Ruby SDKs take one, so it would be easy
49
+ // to send its text instead.
50
+ if (typeof file === "string") {
51
+ throw new TypeError("upload takes a Blob, File, ArrayBuffer, Uint8Array or ReadableStream; to upload a file from disk, pass await fs.openAsBlob(path)");
52
+ }
48
53
  const byteSize = params.byte_size ?? sizeOf(file);
49
54
  if (byteSize === undefined) {
50
55
  throw new UploadError("Uploading a stream needs `byte_size`");
51
56
  }
52
- const filename = params.filename ??
53
- (typeof File !== "undefined" && file instanceof File
54
- ? file.name
55
- : "upload");
57
+ // Empty ones count as unset.
58
+ const filename = params.filename ||
59
+ (typeof File !== "undefined" && file instanceof File && file.name) ||
60
+ "upload";
56
61
  const { upload, ...asset } = await this.createUpload({
57
62
  filename,
58
63
  // The Blob's own type, or one the filename's extension implies (a
59
64
  // Blob from Node's openAsBlob has none), as the Python and Ruby
60
65
  // SDKs do.
61
- content_type: params.content_type ??
62
- ((file instanceof Blob && file.type) ||
63
- contentTypeOf(filename) ||
64
- "application/octet-stream"),
66
+ content_type: params.content_type ||
67
+ (file instanceof Blob && file.type) ||
68
+ contentTypeOf(filename) ||
69
+ "application/octet-stream",
65
70
  byte_size: byteSize,
66
71
  }, options);
67
72
  // A replayed start returns the first run's asset, which may be done.
@@ -74,24 +79,28 @@ export class Assets extends Resource {
74
79
  return current;
75
80
  // The key replays the first run's upload URL, which expires sooner.
76
81
  if (Date.parse(upload.expires_at) <= Date.now()) {
77
- throw new UploadError(`The upload of ${asset.filename} expired: start it again with a new idempotencyKey`);
82
+ throw new UploadError(`The upload of ${filename} expired: start it again with a new idempotencyKey`);
78
83
  }
79
84
  }
80
85
  const stream = file instanceof ReadableStream;
81
86
  const response = await this.core
82
87
  .fetchWithRetries(upload.url, {
83
88
  method: upload.method,
84
- headers: upload.headers,
89
+ // The URL is signed for this size; fetch would send a stream
90
+ // in chunks, without one.
91
+ headers: stream
92
+ ? { ...upload.headers, "Content-Length": String(byteSize) }
93
+ : upload.headers,
85
94
  body: file,
86
95
  ...(stream ? { duplex: "half" } : {}),
87
- }, { ...options, retry: !stream })
96
+ }, { ...options, retry: !stream, byteSize })
88
97
  .catch((error) => {
89
98
  if (error instanceof AbortError)
90
99
  throw error;
91
- throw new UploadError(`Could not upload ${asset.filename}: ${error.message}`, undefined, { cause: error });
100
+ throw new UploadError(`Could not upload ${filename}: ${error.message}`, undefined, { cause: error });
92
101
  });
93
102
  if (!response.ok) {
94
- throw new UploadError(`Storage refused the upload of ${asset.filename} (${response.status})`, response.status);
103
+ throw new UploadError(`Storage refused the upload of ${filename} (${response.status})`, response.status);
95
104
  }
96
105
  // The key belongs to the start: the API refuses one key on two requests.
97
106
  return this.complete(asset.id, { ...options, idempotencyKey: undefined });
@@ -159,10 +168,6 @@ export class Assets extends Resource {
159
168
  usage(asset, options) {
160
169
  return this.core.request("assets_usage", { path: { asset_id: asset } }, options);
161
170
  }
162
- /** A link to download the original file (`url`), valid for 2 minutes. */
163
- downloadUrl(asset, options) {
164
- return this.core.request("assets_download_url", { path: { asset_id: asset } }, options);
165
- }
166
171
  }
167
172
  function sizeOf(file) {
168
173
  if (file instanceof Blob)
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.7.0";
1
+ export declare const VERSION = "0.9.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.0";
1
+ export const VERSION = "0.9.0";
@@ -49,8 +49,9 @@ export interface UnwrapOptions {
49
49
  /**
50
50
  * The webhook's signing secret (`whsec_…`). While a rotation overlaps,
51
51
  * requests carry a signature for each secret, so either one verifies.
52
+ * An unset one (as from a missing environment variable) throws.
52
53
  */
53
- secret: string;
54
+ secret: string | undefined;
54
55
  /** Seconds a timestamp may be off from now. Default 300. */
55
56
  tolerance?: number;
56
57
  }
package/dist/webhooks.js CHANGED
@@ -24,7 +24,11 @@ export async function unwrapWebhook(body, headers, { secret, tolerance = 300 })
24
24
  throw new WebhookVerificationError("The webhook timestamp is too far from the current time");
25
25
  }
26
26
  const text = typeof body === "string" ? body : new TextDecoder().decode(body);
27
- const key = await crypto.subtle.importKey("raw", decodeBase64(secret.replace(/^whsec_/, ""), "secret"), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
27
+ const keyBytes = decodeBase64((secret ?? "").replace(/^whsec_/, ""), "secret");
28
+ if (keyBytes.length === 0) {
29
+ throw new WebhookVerificationError("The webhook secret is empty");
30
+ }
31
+ const key = await crypto.subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
28
32
  const signed = new TextEncoder().encode(`${id}.${timestamp}.${text}`);
29
33
  for (const entry of signatures.split(" ")) {
30
34
  const [version, signature] = entry.split(",", 2);
@@ -63,8 +67,5 @@ function decodeBase64(value, what) {
63
67
  catch {
64
68
  throw new WebhookVerificationError(`The webhook ${what} is not base64`);
65
69
  }
66
- const bytes = new Uint8Array(binary.length);
67
- for (let i = 0; i < binary.length; i++)
68
- bytes[i] = binary.charCodeAt(i);
69
- return bytes;
70
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
70
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nohead/sdk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Official TypeScript SDK for the Nohead API",
5
5
  "license": "MIT",
6
6
  "homepage": "https://nohead.io",
@@ -61,6 +61,7 @@
61
61
  "eslint": "^10.12.0",
62
62
  "globals": "^17.13.0",
63
63
  "prettier": "^3.9.9",
64
+ "standardwebhooks": "^1.1.1",
64
65
  "typescript": "^6.0.3",
65
66
  "typescript-eslint": "^8.71.0",
66
67
  "vitest": "^5.0.3"