@nitida/sdk 0.31.6 → 0.32.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.
@@ -501,6 +501,31 @@ The progressive MP4 is capped unconditionally at 1920 wide, so `getAssetUrl(sha,
501
501
  never exceeds 1080p whatever you uploaded. A rung that weighs *more* than its own source (measured:
502
502
  5090 vs 4866 kbps) is the signature of the fallback path, not of a broken ladder.
503
503
 
504
+ ### 3c. ⭐ Dedup is CROSS-TENANT, and three things follow from it
505
+
506
+ The sha hashes the **bytes**, so two customers who upload the same logo, stock
507
+ photo or placeholder share a candidate row. A `/t/…` URL names content and
508
+ carries **no tenant segment**. Measured behaviour as of 2026-08-25:
509
+
510
+ | | |
511
+ |---|---|
512
+ | **signed** `/t/` | the **signature** picks the tenant — it proves possession of one key, which is the identity the URL lacks. (Before this, the lowest tenant id won and a valid signature could answer `invalid_signature`.) |
513
+ | **`strict_transforms`** | fails **closed** across every tenant sharing that sha. If any of them requires signing, the unsigned request is refused. |
514
+ | **`visibility: "private"`** | retracts **your** row only. Another tenant's public copy of the same bytes keeps serving, and `/t/` resolves to it. |
515
+
516
+ ⚠️ **Do not read `private` as "these bytes are unreachable".** It means
517
+ "reachable through me only by signature". Nothing new leaks — those bytes were
518
+ already public via the other tenant — but the promise is narrower than it looks.
519
+
520
+ ⚠️ And the cost of failing closed, stated plainly: a tenant that does **not**
521
+ use `strict_transforms` but shares content with one that does will need signed
522
+ URLs for that sha. Failing open would let a stranger cancel someone's policy;
523
+ a 401 you fix by signing is the cheaper of the two mistakes.
524
+
525
+ ⭐ **Anything unique to you has a unique sha and never collides** — a customer
526
+ photo, a document, a render. Collisions are for generic assets, which is exactly
527
+ the content where sharing costs you nothing.
528
+
504
529
  ## 4. Resolving a stored reference — `extractAssetSha`
505
530
 
506
531
  When a stored value is already an `8ok.uk` URL, pull its sha:
package/src/index.ts CHANGED
@@ -1135,7 +1135,37 @@ export type UploadResult = {
1135
1135
  * can close (`.mpga`, `.docx`, `.m4a` all arrive as octet-stream).
1136
1136
  */
1137
1137
  oext?: string | null;
1138
- cdnUrl: string;
1138
+ /**
1139
+ * La URL pública de una variante razonable — o **`null` si el asset es
1140
+ * privado**, que no tiene ninguna.
1141
+ *
1142
+ * ⭐⭐ ERA `string`, Y ESO HACÍA QUE `upload()` PERDIERA EL HANDLE
1143
+ *
1144
+ * Los tres retornos de `upload()` arman este campo con `urlFor`, que llama a
1145
+ * `assertPublic` y **tira** sobre un asset privado. Medido 2026-08-25 por una
1146
+ * auditoría externa: subir bytes que deduplican contra una fila privada hacía
1147
+ * que `upload()` lanzara DESPUÉS del PUT — los bytes quedaban guardados y el
1148
+ * `assetId` se perdía, porque el error es un `Error` pelado sin `assetId`, sin
1149
+ * `sha` y sin `cause`.
1150
+ *
1151
+ * ⚠️ Y el mensaje nombraba `getAssetUrl` y un preset `"lg"` que el llamador
1152
+ * nunca pidió, así que iba a buscar en su código una función que no llamó.
1153
+ *
1154
+ * Un asset privado **no tiene** URL pública: eso es la feature. Lo que no
1155
+ * puede pasar es que no tenerla cueste el resultado de una subida que ya
1156
+ * ocurrió. Para servirlo, mirá `visibility` y usá `getPrivateAssetUrl` con la
1157
+ * signing key, en tu backend.
1158
+ */
1159
+ cdnUrl: string | null;
1160
+ /**
1161
+ * `"public"` o `"private"`, tal como quedó el asset.
1162
+ *
1163
+ * ⭐ Existe porque `getAssetUrl(up, …)` —el patrón que enseña toda la doc—
1164
+ * no podía detectar un privado: el guarda mira `asset.visibility`, y este
1165
+ * objeto no lo llevaba. El resultado era una URL pública para un asset
1166
+ * privado, servida sin una queja, que después da 404.
1167
+ */
1168
+ visibility: "public" | "private";
1139
1169
  };
1140
1170
 
1141
1171
  async function computeSha256(
@@ -1687,7 +1717,8 @@ export class NitidaClient {
1687
1717
  // Same shape as the presign branch below, for the same reason. This
1688
1718
  // DTO does carry `sha` today — but relying on that is how the other
1689
1719
  // branch broke, and the value we hashed ourselves is authoritative.
1690
- cdnUrl: this.urlFor(
1720
+ visibility: existing.visibility ?? "public",
1721
+ cdnUrl: this.publicUrlOrNull(
1691
1722
  { ...existing, sha: sha.slice(0, 16) },
1692
1723
  this.bestPresetForAsset(existing, mime),
1693
1724
  ),
@@ -1778,7 +1809,8 @@ export class NitidaClient {
1778
1809
  // ACTUALLY has, now that we waited for it — not from
1779
1810
  // `defaultPresetForMime`, which is a guess made before anything exists.
1780
1811
  // Guessing was safe only while this branch never ran.
1781
- cdnUrl: this.urlFor(
1812
+ visibility: settled.visibility ?? "public",
1813
+ cdnUrl: this.publicUrlOrNull(
1782
1814
  { ...settled, sha: short },
1783
1815
  this.bestPresetForAsset(settled, mime),
1784
1816
  ),
@@ -1834,10 +1866,27 @@ export class NitidaClient {
1834
1866
  sha: sha.slice(0, 16),
1835
1867
  mime: final.mime ?? mime,
1836
1868
  oext: final.oext ?? null,
1837
- cdnUrl: this.urlFor(final, this.bestPresetForAsset(final, mime)),
1869
+ visibility: final.visibility ?? "public",
1870
+ cdnUrl: this.publicUrlOrNull(final, this.bestPresetForAsset(final, mime)),
1838
1871
  };
1839
1872
  }
1840
1873
 
1874
+ /**
1875
+ * `urlFor` sin la excepción: `null` cuando el asset es privado.
1876
+ *
1877
+ * `upload()` no puede fallar por no poder construir una URL pública. Los
1878
+ * bytes ya están; el handle tiene que volver igual.
1879
+ */
1880
+ private publicUrlOrNull(
1881
+ asset: Parameters<typeof getAssetUrl>[0] & {
1882
+ visibility?: "public" | "private";
1883
+ },
1884
+ preset: VariantPreset,
1885
+ ): string | null {
1886
+ if (asset.visibility === "private") return null;
1887
+ return this.urlFor(asset, preset);
1888
+ }
1889
+
1841
1890
  private defaultPresetForMime(mime: string): VariantPreset {
1842
1891
  if (mime.startsWith("video/")) return "video";
1843
1892
  // The `mp3` variant only exists AFTER /process transcodes it; at presign