# @nitida/asset-client — for coding agents

**Read `README.md` in this package, then `src/`.** This package publishes its TypeScript sources
(`files: ["dist/**", "src/**"]`), so the source is the reference — every public function carries
TSDoc, and the ones worth copying carry `@example` blocks. Read the source for the canonical,
version-pinned answer rather than recalling an API from training data.

This package **builds URLs**. It makes no network calls and takes no API key. If the task involves
uploading, that is `@nitida/sdk` — and its `skills/nitida-sdk/SKILL.md` is the deeper guide.

## Where these symbols also live

All **53** exports of this package are re-exported by `@nitida/sdk`, and by its
`/server` and `/web` subpaths alike — a consumer that already installed the SDK
never needs a second import, from any of the three.

That is asserted in CI, not maintained by hand. ⚠️ It was **not** true before
2026-08-21: `/server` was short 28 of the root's 72 exports and `/web` short 37,
the palette helpers, the slot helpers, the HLS-ladder helpers and the preset
constants among them. If you are on an older `@nitida/sdk`, import them from
the root or from this package.

## ⚠️ `RequestablePreset` is not `VariantPreset`

Two sets, two types. What a variant can **be** is not what you can **ask for**.

| | ask for it | a variant can be it |
|---|---|---|
| `thumb` `sm` `md` `lg` `xl` `original` `poster` `video` `aiproxy` | ✅ | ✅ |
| `hls` — the ladder, built when a video transcodes | ❌ | ✅ |
| `mp3` — emitted by itself alongside any audio original | ❌ | ✅ |
| `probe` — indexed stills, never on the compact `presets` string | ✅ | ❌ |

`regenerate(id, { presets: ["hls"] })` and `upload(f, { presets: ["mp3"] })`
used to compile and answer **HTTP 400**. Three agents hit that in one
afternoon. They are compile errors now. Checked in CI against the server's own
schemas, not against a hand-kept list.

## The three that are wrong most often

1. **Image widths must be on `TRANSFORM_WIDTHS`.** Any other width is **HTTP 400** at the edge.
   Import the `TransformWidth` type so an off-ladder number fails at compile time instead.
2. **Video URLs use a base36 tenant segment.** `setTenantId(12)` → `/c/v/…`; a decimal `/12/v/`
   **404s**. Never hand-roll the path — call `getAssetUrl(asset, "video")`.
3. **`getAssetUrl(asset, "original")` wants the whole DTO, not just the sha.** `GET /assets/:id`
   sends `variants` and `oext`, and either one gives the exact stored key. With only `mime` it
   *guesses* — the server keys the original off the **uploaded filename**, which the mime does not
   determine — and with neither it emits the `-o.bin` sentinel and 404s forever.

The ladder itself — **generated from `TRANSFORM_WIDTHS`, do not edit by hand.**
`bun run gen:docs` rewrites it; `--check` fails the build if it drifts:

<!-- BEGIN GENERATED: transform-widths · bun run gen:docs -->
`96, 128, 160, 180, 240, 256, 320, 400, 480, 600, 640, 800, 960, 1080, 1200, 1280, 1440, 1600, 1920, 2560, 3840` — 21 widths.
<!-- END GENERATED: transform-widths -->

## Existence checks

Use `hasPreset(asset, preset)` against `dto.presets` (the compact code string). It is on every
response shape, including the slim list/resolver one that carries no `variants` at all. **Do not
hand-roll `dto.presets.includes("o")`** — see below.

`variants` is real as of the 2026-08-17 deploy; against an older server deploy it comes back
empty on every response, so code that must survive both reads `presets` for existence and
`variants` only for URLs.

`presets` is a concatenation of **one-character** codes, so membership is a one-character substring
test — and any longer token in the string answers `true` for presets that are not there. Servers
older than 2026-08-17 emit exactly that (`transform-<hex>`, `probe`), and on a corpus written by
those servers a large minority of assets claim an `original` they do not have — which is the 404
`oext` exists to remove. The server
now emits only the 1-char vocabulary, and `hasPreset` strips multi-char tokens before the test, so
either half covers you — a hand-rolled `includes` covers neither. **`mp3` is a deliberate multi-char
token that stays in the vocabulary**, so a naive test also reads its `m` as `md`.


## Private assets — `visibility`

Default is `"public"`. Set `private` and **every public door answers 404** —
stored variants, the raw original, the HLS ladder, and `/t/` (including the
poster frame of a private video). The bytes come back only through a signed URL
that expires.

```ts
import { getPrivateAssetUrl, getPrivateTransformUrl } from "@nitida/sdk";
// ON YOUR BACKEND, once you decided this viewer may see it:
await getPrivateAssetUrl(asset, "lg", signingKey, { expiresInSeconds: 300 });
await getPrivateTransformUrl(asset, { width: 1280 }, signingKey, { expiresInSeconds: 300 });
```

- **A 404 on a private asset is NOT a missing file.** Check `visibility` on the
  DTO before you check storage. It is the number-one support question.
- **Seven URL builders throw** rather than hand you a doomed URL — `getAssetUrl`,
  `getAssetSrcSet`, `getTransformUrl`, `getTransformSrcSet`,
  `getVideoTransformUrl`, `getHlsStreamingUrl`, `getSignedTransformUrl` — but
  only when the value you pass carries `visibility`. `{ sha }` alone is never
  refused.
- **`exp` is mandatory**; revocation is *"within a minute"* (60 s TTL at the edge).
- **The signing key is a backend secret** — it mints URLs for every private
  asset the tenant owns.
- **⭐ Where the signing key comes from: the response that CREATED your
  project, once.** `POST /admin/projects` returns `signingKey` next to the
  three API keys, and the console shows it in the same panel. Nothing else
  hands it out — `GET /admin/projects/:code` does **not** include it. If it is
  lost, the only endpoint that returns a key is
  `POST /admin/projects/:code/rotate-signing-key`, which **invalidates every
  URL already signed** and needs the system-scope key the platform operator
  holds. A tenant with nothing signed yet can rotate for free; a live one
  cannot, which is why you save it at creation.
- **⭐ Already have a project and never saw a signing key?** Then you never got
  one — projects created before 2026-08-23 were not handed it, and no endpoint
  shows you the current one. **If you have not signed any URLs yet, ask us to
  rotate: with nothing in flight, rotation invalidates nothing and is free.**
  If you already have signed URLs circulating, ask us for the current key
  instead. This is the wall the T5 agent hit, and the sentence that was
  missing.

## The endpoint

The API this client's URLs belong to is `https://api.nitida.gofuture.space`. An older, longer
endpoint you may have been given is the *same service* under a generated name and keeps working
indefinitely — the branded host is additive, not a migration. The CDN host stays `8ok.uk`, which
was never renamed.

⚠️ `@aquienpz/asset-client` still exists on npm but is **frozen at 0.14.2 (2026-07-18)**. It
predates `oext`, the `variants` array, and a `hasPreset` that does not claim an `original` the
asset does not have. Install `@nitida/asset-client`. `@nitida/asset-uploader-web` and
`@nitida/asset-uploader-expo` ARE on npm since 2026-08-23 — reach for one only
when you need an upload to survive a reload or a backgrounded app.
`@aquienpz/tenant-config` is still internal.
