# Access and privacy — what a URL means

> A public URL is the authorisation, and that is a deliberate trade. Private assets close every public door and hand access back through a signed, expiring URL your backend mints.

Every asset has a `visibility`, and the two values mean genuinely different
things. Read the one that applies to you.

> **`public`** — whoever has the URL has the bytes.
> **`private`** — every public URL answers **404**, and the bytes come back
> only through a signed URL that expires.

`public` is the default, and it is what almost everything should be.

## Public: the URL *is* the authorisation

There is no per-request identity in the delivery path of a public asset. The
CDN does not know who you are, does not ask, and cannot be told. A URL is not a
*pointer* to an asset you then get authorised for — the URL **is** the
authorisation.

That is deliberate, and it is what makes delivery cost what it costs: a
response that depends on *who is asking* cannot be cached by URL alone, and
public delivery is cached by URL alone. Same trade every URL-addressed media
CDN makes. In practice:

- **No credential is checked on delivery.** Your runtime key authorises
  *uploads and reads of metadata*. It has nothing to do with fetching bytes.
- **Links do not expire.**
- **There is no per-asset access log.** We can tell you aggregate usage; we
  cannot tell you who fetched a particular file.

### The part that surprises people: the URL is derived from the content

The `sha` in every URL is the **first 16 hex characters of the SHA-256 of the
uploaded bytes**. Not a secret — but the consequence is worth stating outright:

```bash
# no key, no cookie, no header of any kind
$ curl -sO https://8ok.uk/d/v/818105b8e773f53c-o.jpg
$ shasum -a 256 818105b8e773f53c-o.jpg
818105b8e773f53cd0e0063c311dc100aeedbc7be1989cdbb6e96c2d13945c71
  └──────┬───────┘
     the sha in the URL
```

So a public URL is not merely *hard to guess*: **it is computable by anyone who
already holds a copy of the same file.** Given a candidate document, a third
party can ask whether you host it and get a yes or no, with no credential.

For listing photographs, a product catalogue or a restaurant menu this is
irrelevant — those are meant to be seen. For a signed contract, an ID scan or a
medical record, *the existence of the file is itself the secret*. That is what
`private` is for.

## Private: the sha stops being enough

Set `visibility: "private"` and every public door closes — the stored variants,
the raw original, the HLS ladder, and the on-the-fly `/t/` transform route.
All of them answer **404**, to anyone, with the sha and nothing else.

```bash
curl -X PATCH https://api.nitida.gofuture.space/assets/<id>/visibility \
  -H "Authorization: Bearer $NITIDA_API_KEY" \
  -H "X-Tenant-Code: your-tenant" \
  -H "Content-Type: application/json" \
  -d '{"visibility":"private"}'
```

Nothing moves. One row changes, the edge cache for that asset is purged, and a
1 GB video flips as fast as a thumbnail — it is the same single copy behind
both doors.

:::caution[404 on a private asset is the feature, not a missing file]
Everywhere else on this platform a 404 means the object was never written. On a
private asset it means the door is closed. If you are debugging a 404, check
`visibility` on the DTO **before** you check R2.
:::

### Getting the bytes back: a signed URL your backend mints

```ts
import { getPrivateAssetUrl } from "@nitida/sdk";

// On YOUR server, once you have decided this viewer may see it:
const url = await getPrivateAssetUrl(asset, "lg", tenantSigningKey, {
  expiresInSeconds: 300,
});
// → https://8ok.uk/a/5/v/<sha>-l.webp?exp=…&sig=…
```

The division of labour is the whole design: **your backend knows who the viewer
is and decides; we verify the signature and serve.** We never learn who your
end user is, and we do not want to.

- The signed tree is `/a/{tenant}/…`, mirroring the public paths.
- It covers stored variants, the raw original, the whole HLS ladder — the master
  playlist's children are re-signed on the way out, so a player just works — and
  **on-the-fly transforms**, via `getPrivateTransformUrl`:

  ```ts
  const url = await getPrivateTransformUrl(
    asset,
    { width: 1280, format: "webp" },
    tenantSigningKey,
    { expiresInSeconds: 300 },
  );
  ```

  A private asset servable only at the sizes somebody already generated would
  barely be a product, so the private tree resizes too. The signature covers the
  transform itself, so one signed URL authorises one rendition — not every
  width.
- **`exp` is mandatory.** `getPrivateAssetUrl` refuses to build a URL without
  one. A signed URL that never expires is a public URL the moment somebody
  forwards it.
- Signed responses are `Cache-Control: private`. They are not shared-cached, so
  they cost more per request than public ones. That is the trade.

:::danger[The signing key is a backend secret]
It mints URLs for **every** private asset the tenant owns. Shipping it to a
browser hands a visitor the whole library. It belongs beside your database
password, not in a bundle.
:::

### The SDK refuses rather than hand you a doomed URL

```ts
getAssetUrl(privateAsset, "lg");
// Error: getAssetUrl: this asset is private, so a public CDN URL for it will
// answer 404 — that is the feature, not a missing file. Mint a signed URL on
// your BACKEND instead: await getPrivateAssetUrl(...)
```

Every public URL builder does this — `getAssetUrl`, `getAssetSrcSet`,
`getTransformUrl`, `getTransformSrcSet`, `getVideoTransformUrl`,
`getHlsStreamingUrl` — whenever the value you pass carries `visibility`. Pass
only `{ sha }` and there is nothing to check, so nothing is refused.

### Revocation, and its one honest caveat

Flipping back to `private` **is** revocation: URLs handed out while the asset
was public stop working. The edge cache is purged on the flip, so this is real
and not merely a database opinion.

The caveat, stated rather than buried: propagation is not instantaneous. The
CDN refreshes the list of private assets on a **60-second** TTL, so a flip can
take up to a minute to reach every location. Plan a revocation as "within a
minute", not "this instant".

### One consequence of content-addressed storage

Storage is deduplicated by content hash. If you mark your copy private but
another tenant holds the **same bytes** and publishes them, the bytes stay
reachable — through *their* public URL, because they published them, not
through any door of yours. This is inherent to deduplicating by hash and every
platform that does it shares it. If the existence of a file is the secret and
the file is one somebody else might also hold, that is worth knowing before you
upload.

## What the transform `?sig=` is not

Some transform URLs carry a `?sig=` that has nothing to do with access:

| | |
|---|---|
| **`?sig=` on `/t/…`** | authorises an image width **outside** the standard ladder. No expiry, no access meaning. Treat such a URL as publicly as an unsigned one |
| **`?exp=…&sig=…` on `/a/…`** | authorises **access**, until `exp` |

They come from the same tenant signing key but not the same key material — the
access signature is computed under a separately derived key, so a signature
minted to resize can never be replayed as one to enter.

## Keep the runtime key server-side

This is the access rule that costs money if you get it wrong. An `amk_rt_*`
runtime key is a **write** credential for a paid platform; shipping it in a
browser bundle hands a stranger your upload quota.

Uploads from a browser or a phone go one of two ways, and neither ships the
key: the browser posts to **your** route and your route calls us, or your
server asks us for a **presigned URL scoped to that one upload** and hands the
browser only that. `@nitida/sdk/web` exports a client whose *type* rejects
`apiKey`, so the wrong shape does not compile. See
[Credentials](/start/credentials/) and
[Browser-direct upload](/examples/browser-direct/).

## What we will not do

We will not become an identity provider. We will never know who your end user
is. Your backend already does, and that is where the decision belongs — you
decide yes or no and mint a signed URL; we check the signature and hand over
the bytes.