@aglyn/tenant-data-admin 1.0.0-beta.220 → 1.0.0-beta.222
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/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +7 -0
- package/src/lib/server/hosted-page-review.js +144 -20
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/leaving-notice.d.ts +98 -0
- package/src/lib/server/leaving-notice.js +250 -0
- package/src/lib/server/leaving-notice.js.map +1 -0
- package/src/lib/server/link-reputation-review.d.ts +58 -0
- package/src/lib/server/link-reputation-review.js +185 -0
- package/src/lib/server/link-reputation-review.js.map +1 -0
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/org-age.d.ts +15 -6
- package/src/lib/server/org-age.js +20 -8
- package/src/lib/server/org-age.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +24 -6
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/page-security-hold.d.ts +93 -0
- package/src/lib/server/page-security-hold.js +180 -0
- package/src/lib/server/page-security-hold.js.map +1 -0
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +139 -0
- package/src/lib/server/web-risk.js +521 -0
- package/src/lib/server/web-risk.js.map +1 -0
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -90,10 +90,21 @@ export declare const MEDIA_SIGNATURE_MAX_TTL_MS: number;
|
|
|
90
90
|
* `hosts/{hostId}` — different documents, same id space — so a caller
|
|
91
91
|
* authorized for one library could read the other's asset of the same id.
|
|
92
92
|
*/
|
|
93
|
-
export declare function signMediaAccess(scope: string, mediaId: string, expiresAtMs: number): string;
|
|
93
|
+
export declare function signMediaAccess(scope: string, mediaId: string, expiresAtMs: number, audience?: MediaSignatureAudience): string;
|
|
94
|
+
/**
|
|
95
|
+
* Who a signature was minted for, when it is not a visitor (AGL-3474).
|
|
96
|
+
*
|
|
97
|
+
* `team` marks the console's own preview of a private asset: the workspace
|
|
98
|
+
* looking at its library, which is not a serve the bandwidth band bills. It is
|
|
99
|
+
* inside the HMAC payload, so a buyer's link cannot be turned into a team one
|
|
100
|
+
* by adding `aud=team`, nor a team link into a buyer's by dropping it — either
|
|
101
|
+
* edit breaks the signature.
|
|
102
|
+
*/
|
|
103
|
+
export type MediaSignatureAudience = 'team';
|
|
94
104
|
export interface MediaSignature {
|
|
95
105
|
exp: number;
|
|
96
106
|
sig: string;
|
|
107
|
+
aud?: MediaSignatureAudience;
|
|
97
108
|
}
|
|
98
109
|
/**
|
|
99
110
|
* Whether a presented signature authorizes this asset right now.
|
|
@@ -123,4 +134,4 @@ export declare function assertMediaSignatureTtl(ttlMs: number): void;
|
|
|
123
134
|
* unless the caller names a longer window, bounded by
|
|
124
135
|
* {@link assertMediaSignatureTtl}.
|
|
125
136
|
*/
|
|
126
|
-
export declare function mintMediaSignature(scope: string, mediaId: string, nowMs?: number, ttlMs?: number): MediaSignature;
|
|
137
|
+
export declare function mintMediaSignature(scope: string, mediaId: string, nowMs?: number, ttlMs?: number, audience?: MediaSignatureAudience): MediaSignature;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -96,8 +97,8 @@ import { safeEqual } from "./safe-equal.js";
|
|
|
96
97
|
* signature minted for `org:{orgId}` would verify against
|
|
97
98
|
* `hosts/{hostId}` — different documents, same id space — so a caller
|
|
98
99
|
* authorized for one library could read the other's asset of the same id.
|
|
99
|
-
*/ export function signMediaAccess(scope, mediaId, expiresAtMs) {
|
|
100
|
-
return createHmac('sha256', tokenSigningSecret()).update(`media:${scope}:${mediaId}:${expiresAtMs}`).digest('hex').slice(0, 32);
|
|
100
|
+
*/ export function signMediaAccess(scope, mediaId, expiresAtMs, audience) {
|
|
101
|
+
return createHmac('sha256', tokenSigningSecret()).update(`media:${scope}:${mediaId}:${expiresAtMs}${audience ? `:${audience}` : ''}`).digest('hex').slice(0, 32);
|
|
101
102
|
}
|
|
102
103
|
/**
|
|
103
104
|
* Whether a presented signature authorizes this asset right now.
|
|
@@ -117,9 +118,10 @@ import { safeEqual } from "./safe-equal.js";
|
|
|
117
118
|
if (exp - nowMs > MEDIA_SIGNATURE_MAX_TTL_MS + MEDIA_SIGNATURE_CLOCK_SKEW_MS) {
|
|
118
119
|
return false;
|
|
119
120
|
}
|
|
121
|
+
const aud = (presented == null ? void 0 : presented.aud) === 'team' ? 'team' : undefined;
|
|
120
122
|
let expected;
|
|
121
123
|
try {
|
|
122
|
-
expected = signMediaAccess(scope, mediaId, exp);
|
|
124
|
+
expected = signMediaAccess(scope, mediaId, exp, aud);
|
|
123
125
|
} catch (unused) {
|
|
124
126
|
// Secret missing — fail closed rather than serving the bytes.
|
|
125
127
|
return false;
|
|
@@ -127,7 +129,7 @@ import { safeEqual } from "./safe-equal.js";
|
|
|
127
129
|
return safeEqual(sig, expected);
|
|
128
130
|
}
|
|
129
131
|
/** The query string that carries a signature on a CDN URL. */ export function mediaSignatureQuery(signature) {
|
|
130
|
-
return `exp=${signature.exp}&sig=${encodeURIComponent(signature.sig)}
|
|
132
|
+
return `exp=${signature.exp}&sig=${encodeURIComponent(signature.sig)}` + (signature.aud ? `&aud=${signature.aud}` : '');
|
|
131
133
|
}
|
|
132
134
|
/**
|
|
133
135
|
* Throws unless `ttlMs` is a lifetime the platform issues: positive, and no
|
|
@@ -147,13 +149,15 @@ import { safeEqual } from "./safe-equal.js";
|
|
|
147
149
|
* Mints a signature valid for `ttlMs` from now — {@link MEDIA_SIGNATURE_TTL_MS}
|
|
148
150
|
* unless the caller names a longer window, bounded by
|
|
149
151
|
* {@link assertMediaSignatureTtl}.
|
|
150
|
-
*/ export function mintMediaSignature(scope, mediaId, nowMs = Date.now(), ttlMs = MEDIA_SIGNATURE_TTL_MS) {
|
|
152
|
+
*/ export function mintMediaSignature(scope, mediaId, nowMs = Date.now(), ttlMs = MEDIA_SIGNATURE_TTL_MS, audience) {
|
|
151
153
|
assertMediaSignatureTtl(ttlMs);
|
|
152
154
|
const exp = nowMs + ttlMs;
|
|
153
|
-
return {
|
|
155
|
+
return _extends({
|
|
154
156
|
exp,
|
|
155
|
-
sig: signMediaAccess(scope, mediaId, exp)
|
|
156
|
-
}
|
|
157
|
+
sig: signMediaAccess(scope, mediaId, exp, audience)
|
|
158
|
+
}, audience ? {
|
|
159
|
+
aud: audience
|
|
160
|
+
} : {});
|
|
157
161
|
}
|
|
158
162
|
|
|
159
163
|
//# sourceMappingURL=media-signing.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-signing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHmac } from 'crypto'\nimport { safeEqual } from './safe-equal'\n\n/**\n * Signed access to a PRIVATE media asset (AGL-1051).\n *\n * The rest of this project controls *discovery and use* — `visibleTo` says\n * which sites may reference an asset. It cannot control secrecy, because\n * the CDN route is unauthenticated by necessity (it serves images to\n * anonymous visitors) and a URL, once shared, is a bearer capability with\n * no revocation. An asset marked `private` opts out of that model: it gets\n * no `cdnPath`, and the only way to fetch it is a signature that expires.\n *\n * Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`\n * (docs/COMMERCE_TOKEN_SIGNING.md) rather than a new secret — it is already\n * provisioned as one team-level variable on both the console and tenant\n * projects, and a second secret would be a second thing to keep in step\n * across two apps, which is the failure mode that doc exists to describe.\n * The `media:` prefix namespaces this payload so a token minted here can\n * never be replayed as a download or stream token.\n */\nexport function tokenSigningSecret(): string {\n const secret = process.env['TOKEN_SIGNING_SECRET']\n // No fallback, deliberately. AGL-509 was `STRIPE_SECRET_KEY ?? 'aglyn'`:\n // every payload is built from public identifiers, so a default key makes\n // signatures forgeable on any deploy missing the real one.\n if (!secret) throw new Error('TOKEN_SIGNING_SECRET is not configured')\n return secret\n}\n\n/**\n * Fifteen minutes, matching the gated-video stream token.\n *\n * The TTL is the only revocation this scheme has, so it is deliberately\n * short: long enough to open a link and download the file, too short for a\n * URL pasted into a chat to stay useful. Console previews re-mint per view.\n */\nexport const MEDIA_SIGNATURE_TTL_MS = 15 * 60 * 1000\n\n/**\n * How long one viewing session of a purchased, gated video stays playable\n * (AGL-2814): four hours.\n *\n * The console's fifteen minutes is the wrong number for playback, because a\n * video is not fetched once. Every seek, every resume after a pause and every\n * buffer refill is another byte-range request under the same URL, so the\n * signature has to outlive the sitting rather than the first request. Four\n * hours covers a feature-length recording plus the breaks people take inside\n * one sitting.\n *\n * It stays hours, not days, because the URL is a bearer capability. Anyone who\n * copies it out of a buyer's network panel holds the whole film until it\n * expires, and past four hours a pause is long enough that asking the stream\n * endpoint again is the right answer anyway: that endpoint re-checks the\n * entitlement before it signs another session, and the gated player asks it\n * again on its own when a request under an expired URL fails.\n */\nexport const GATED_VIDEO_SESSION_TTL_MS = 4 * 60 * 60 * 1000\n\n/**\n * How long a paid download's link works (AGL-2847): one hour.\n *\n * A download is one transfer rather than a sitting, so it needs far less than\n * a viewing session. It gets more than the console's fifteen minutes because a\n * large file on a slow connection can take longer than that to arrive, and a\n * download manager that resumes a broken transfer asks again under the same\n * URL. A refused resume sends the buyer back to the receipt link, which counts\n * another attempt against the download limit, so the window is sized to make\n * that rare while a forwarded link still dies the same evening.\n */\nexport const PAID_DOWNLOAD_LINK_TTL_MS = 60 * 60 * 1000\n\n/**\n * The longest lifetime any signature may claim, enforced by the VERIFIER as\n * well as the minter.\n *\n * `exp` is inside the signed payload, so nobody without the secret can stretch\n * it. What this bounds is the platform's own mistakes: a minter that one day\n * passes a day where it meant an hour would otherwise hand out links nothing\n * downstream ever questions.\n */\nexport const MEDIA_SIGNATURE_MAX_TTL_MS = GATED_VIDEO_SESSION_TTL_MS\n\n/**\n * Allowance for the minting and verifying instances disagreeing about the\n * time. The console signs and the tenant app verifies, on different machines.\n */\nconst MEDIA_SIGNATURE_CLOCK_SKEW_MS = 60 * 1000\n\n/**\n * The signature over one asset at one expiry.\n *\n * `scope` is inside the payload, not just alongside it. Without it a\n * signature minted for `org:{orgId}` would verify against\n * `hosts/{hostId}` — different documents, same id space — so a caller\n * authorized for one library could read the other's asset of the same id.\n */\nexport function signMediaAccess(\n scope: string,\n mediaId: string,\n expiresAtMs: number,\n): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`media:${scope}:${mediaId}:${expiresAtMs}`)\n .digest('hex')\n .slice(0, 32)\n}\n\nexport interface MediaSignature {\n exp: number\n sig: string\n}\n\n/**\n * Whether a presented signature authorizes this asset right now.\n *\n * Compared with `safeEqual`, which is constant-time on equal-length inputs and\n * length-checks first because `timingSafeEqual` THROWS on a length mismatch —\n * and an attacker controls that length, so an exception here would be a 500\n * where a 403 belongs. That dance was hand-written in six files before\n * AGL-1902 lifted it into one.\n */\nexport function verifyMediaAccess(\n scope: string,\n mediaId: string,\n presented: Partial<MediaSignature> | undefined,\n nowMs: number = Date.now(),\n): boolean {\n const exp = Number(presented?.exp ?? 0)\n const sig = String(presented?.sig ?? '')\n if (!Number.isFinite(exp) || exp <= nowMs) return false\n // Refused whatever the signature says: no minter issues a lifetime this\n // long, so a link that claims one was never meant to exist.\n if (exp - nowMs > MEDIA_SIGNATURE_MAX_TTL_MS + MEDIA_SIGNATURE_CLOCK_SKEW_MS) {\n return false\n }\n let expected: string\n try {\n expected = signMediaAccess(scope, mediaId, exp)\n } catch {\n // Secret missing — fail closed rather than serving the bytes.\n return false\n }\n return safeEqual(sig, expected)\n}\n\n/** The query string that carries a signature on a CDN URL. */\nexport function mediaSignatureQuery(signature: MediaSignature): string {\n return `exp=${signature.exp}&sig=${encodeURIComponent(signature.sig)}`\n}\n\n/**\n * Throws unless `ttlMs` is a lifetime the platform issues: positive, and no\n * longer than {@link MEDIA_SIGNATURE_MAX_TTL_MS}.\n *\n * It THROWS rather than clamping. The verifier refuses a longer link, so\n * minting one would hand out a URL that is dead on arrival, and a quiet clamp\n * would hide the caller asking for the wrong thing. Exported for the minters\n * that sign something other than a CDN URL, such as a Storage read, so every\n * expiring link the platform hands out obeys the same bound.\n */\nexport function assertMediaSignatureTtl(ttlMs: number): void {\n if (!(ttlMs > 0) || ttlMs > MEDIA_SIGNATURE_MAX_TTL_MS) {\n throw new RangeError(\n `A media link lifetime must be between 1 ms and ${MEDIA_SIGNATURE_MAX_TTL_MS} ms`,\n )\n }\n}\n\n/**\n * Mints a signature valid for `ttlMs` from now — {@link MEDIA_SIGNATURE_TTL_MS}\n * unless the caller names a longer window, bounded by\n * {@link assertMediaSignatureTtl}.\n */\nexport function mintMediaSignature(\n scope: string,\n mediaId: string,\n nowMs: number = Date.now(),\n ttlMs: number = MEDIA_SIGNATURE_TTL_MS,\n): MediaSignature {\n assertMediaSignatureTtl(ttlMs)\n const exp = nowMs + ttlMs\n return { exp, sig: signMediaAccess(scope, mediaId, exp) }\n}\n"],"names":["createHmac","safeEqual","tokenSigningSecret","secret","process","env","Error","MEDIA_SIGNATURE_TTL_MS","GATED_VIDEO_SESSION_TTL_MS","PAID_DOWNLOAD_LINK_TTL_MS","MEDIA_SIGNATURE_MAX_TTL_MS","MEDIA_SIGNATURE_CLOCK_SKEW_MS","signMediaAccess","scope","mediaId","expiresAtMs","update","digest","slice","verifyMediaAccess","presented","nowMs","Date","now","exp","Number","sig","String","isFinite","expected","mediaSignatureQuery","signature","encodeURIComponent","assertMediaSignatureTtl","ttlMs","RangeError","mintMediaSignature"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC;IACd,MAAMC,SAASC,QAAQC,GAAG,CAAC,uBAAuB;IAClD,yEAAyE;IACzE,yEAAyE;IACzE,2DAA2D;IAC3D,IAAI,CAACF,QAAQ,MAAM,IAAIG,MAAM;IAC7B,OAAOH;AACT;AAEA;;;;;;CAMC,GACD,OAAO,MAAMI,yBAAyB,KAAK,KAAK,KAAI;AAEpD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,6BAA6B,IAAI,KAAK,KAAK,KAAI;AAE5D;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,4BAA4B,KAAK,KAAK,KAAI;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,6BAA6BF,2BAA0B;AAEpE;;;CAGC,GACD,MAAMG,gCAAgC,KAAK;AAE3C;;;;;;;CAOC,GACD,OAAO,SAASC,gBACdC,KAAa,EACbC,OAAe,EACfC,WAAmB;IAEnB,OAAOf,WAAW,UAAUE,sBACzBc,MAAM,CAAC,CAAC,MAAM,EAAEH,MAAM,CAAC,EAAEC,QAAQ,CAAC,EAAEC,aAAa,EACjDE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;AACd;AAOA;;;;;;;;CAQC,GACD,OAAO,SAASC,kBACdN,KAAa,EACbC,OAAe,EACfM,SAA8C,EAC9CC,QAAgBC,KAAKC,GAAG,EAAE;;IAE1B,MAAMC,MAAMC,eAAOL,6BAAAA,UAAWI,GAAG,mBAAI;IACrC,MAAME,MAAMC,gBAAOP,6BAAAA,UAAWM,GAAG,oBAAI;IACrC,IAAI,CAACD,OAAOG,QAAQ,CAACJ,QAAQA,OAAOH,OAAO,OAAO;IAClD,wEAAwE;IACxE,4DAA4D;IAC5D,IAAIG,MAAMH,QAAQX,6BAA6BC,+BAA+B;QAC5E,OAAO;IACT;IACA,IAAIkB;IACJ,IAAI;QACFA,WAAWjB,gBAAgBC,OAAOC,SAASU;IAC7C,EAAE,eAAM;QACN,8DAA8D;QAC9D,OAAO;IACT;IACA,OAAOvB,UAAUyB,KAAKG;AACxB;AAEA,4DAA4D,GAC5D,OAAO,SAASC,oBAAoBC,SAAyB;IAC3D,OAAO,CAAC,IAAI,EAAEA,UAAUP,GAAG,CAAC,KAAK,EAAEQ,mBAAmBD,UAAUL,GAAG,GAAG;AACxE;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASO,wBAAwBC,KAAa;IACnD,IAAI,CAAEA,CAAAA,QAAQ,CAAA,KAAMA,QAAQxB,4BAA4B;QACtD,MAAM,IAAIyB,WACR,CAAC,+CAA+C,EAAEzB,2BAA2B,GAAG,CAAC;IAErF;AACF;AAEA;;;;CAIC,GACD,OAAO,SAAS0B,mBACdvB,KAAa,EACbC,OAAe,EACfO,QAAgBC,KAAKC,GAAG,EAAE,EAC1BW,QAAgB3B,sBAAsB;IAEtC0B,wBAAwBC;IACxB,MAAMV,MAAMH,QAAQa;IACpB,OAAO;QAAEV;QAAKE,KAAKd,gBAAgBC,OAAOC,SAASU;IAAK;AAC1D"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-signing.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { createHmac } from 'crypto'\nimport { safeEqual } from './safe-equal'\n\n/**\n * Signed access to a PRIVATE media asset (AGL-1051).\n *\n * The rest of this project controls *discovery and use* — `visibleTo` says\n * which sites may reference an asset. It cannot control secrecy, because\n * the CDN route is unauthenticated by necessity (it serves images to\n * anonymous visitors) and a URL, once shared, is a bearer capability with\n * no revocation. An asset marked `private` opts out of that model: it gets\n * no `cdnPath`, and the only way to fetch it is a signature that expires.\n *\n * Signed with the shared, fail-closed `TOKEN_SIGNING_SECRET`\n * (docs/COMMERCE_TOKEN_SIGNING.md) rather than a new secret — it is already\n * provisioned as one team-level variable on both the console and tenant\n * projects, and a second secret would be a second thing to keep in step\n * across two apps, which is the failure mode that doc exists to describe.\n * The `media:` prefix namespaces this payload so a token minted here can\n * never be replayed as a download or stream token.\n */\nexport function tokenSigningSecret(): string {\n const secret = process.env['TOKEN_SIGNING_SECRET']\n // No fallback, deliberately. AGL-509 was `STRIPE_SECRET_KEY ?? 'aglyn'`:\n // every payload is built from public identifiers, so a default key makes\n // signatures forgeable on any deploy missing the real one.\n if (!secret) throw new Error('TOKEN_SIGNING_SECRET is not configured')\n return secret\n}\n\n/**\n * Fifteen minutes, matching the gated-video stream token.\n *\n * The TTL is the only revocation this scheme has, so it is deliberately\n * short: long enough to open a link and download the file, too short for a\n * URL pasted into a chat to stay useful. Console previews re-mint per view.\n */\nexport const MEDIA_SIGNATURE_TTL_MS = 15 * 60 * 1000\n\n/**\n * How long one viewing session of a purchased, gated video stays playable\n * (AGL-2814): four hours.\n *\n * The console's fifteen minutes is the wrong number for playback, because a\n * video is not fetched once. Every seek, every resume after a pause and every\n * buffer refill is another byte-range request under the same URL, so the\n * signature has to outlive the sitting rather than the first request. Four\n * hours covers a feature-length recording plus the breaks people take inside\n * one sitting.\n *\n * It stays hours, not days, because the URL is a bearer capability. Anyone who\n * copies it out of a buyer's network panel holds the whole film until it\n * expires, and past four hours a pause is long enough that asking the stream\n * endpoint again is the right answer anyway: that endpoint re-checks the\n * entitlement before it signs another session, and the gated player asks it\n * again on its own when a request under an expired URL fails.\n */\nexport const GATED_VIDEO_SESSION_TTL_MS = 4 * 60 * 60 * 1000\n\n/**\n * How long a paid download's link works (AGL-2847): one hour.\n *\n * A download is one transfer rather than a sitting, so it needs far less than\n * a viewing session. It gets more than the console's fifteen minutes because a\n * large file on a slow connection can take longer than that to arrive, and a\n * download manager that resumes a broken transfer asks again under the same\n * URL. A refused resume sends the buyer back to the receipt link, which counts\n * another attempt against the download limit, so the window is sized to make\n * that rare while a forwarded link still dies the same evening.\n */\nexport const PAID_DOWNLOAD_LINK_TTL_MS = 60 * 60 * 1000\n\n/**\n * The longest lifetime any signature may claim, enforced by the VERIFIER as\n * well as the minter.\n *\n * `exp` is inside the signed payload, so nobody without the secret can stretch\n * it. What this bounds is the platform's own mistakes: a minter that one day\n * passes a day where it meant an hour would otherwise hand out links nothing\n * downstream ever questions.\n */\nexport const MEDIA_SIGNATURE_MAX_TTL_MS = GATED_VIDEO_SESSION_TTL_MS\n\n/**\n * Allowance for the minting and verifying instances disagreeing about the\n * time. The console signs and the tenant app verifies, on different machines.\n */\nconst MEDIA_SIGNATURE_CLOCK_SKEW_MS = 60 * 1000\n\n/**\n * The signature over one asset at one expiry.\n *\n * `scope` is inside the payload, not just alongside it. Without it a\n * signature minted for `org:{orgId}` would verify against\n * `hosts/{hostId}` — different documents, same id space — so a caller\n * authorized for one library could read the other's asset of the same id.\n */\nexport function signMediaAccess(\n scope: string,\n mediaId: string,\n expiresAtMs: number,\n audience?: MediaSignatureAudience,\n): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`media:${scope}:${mediaId}:${expiresAtMs}${audience ? `:${audience}` : ''}`)\n .digest('hex')\n .slice(0, 32)\n}\n\n/**\n * Who a signature was minted for, when it is not a visitor (AGL-3474).\n *\n * `team` marks the console's own preview of a private asset: the workspace\n * looking at its library, which is not a serve the bandwidth band bills. It is\n * inside the HMAC payload, so a buyer's link cannot be turned into a team one\n * by adding `aud=team`, nor a team link into a buyer's by dropping it — either\n * edit breaks the signature.\n */\nexport type MediaSignatureAudience = 'team'\n\nexport interface MediaSignature {\n exp: number\n sig: string\n aud?: MediaSignatureAudience\n}\n\n/**\n * Whether a presented signature authorizes this asset right now.\n *\n * Compared with `safeEqual`, which is constant-time on equal-length inputs and\n * length-checks first because `timingSafeEqual` THROWS on a length mismatch —\n * and an attacker controls that length, so an exception here would be a 500\n * where a 403 belongs. That dance was hand-written in six files before\n * AGL-1902 lifted it into one.\n */\nexport function verifyMediaAccess(\n scope: string,\n mediaId: string,\n presented: Partial<MediaSignature> | undefined,\n nowMs: number = Date.now(),\n): boolean {\n const exp = Number(presented?.exp ?? 0)\n const sig = String(presented?.sig ?? '')\n if (!Number.isFinite(exp) || exp <= nowMs) return false\n // Refused whatever the signature says: no minter issues a lifetime this\n // long, so a link that claims one was never meant to exist.\n if (exp - nowMs > MEDIA_SIGNATURE_MAX_TTL_MS + MEDIA_SIGNATURE_CLOCK_SKEW_MS) {\n return false\n }\n const aud = presented?.aud === 'team' ? 'team' : undefined\n let expected: string\n try {\n expected = signMediaAccess(scope, mediaId, exp, aud)\n } catch {\n // Secret missing — fail closed rather than serving the bytes.\n return false\n }\n return safeEqual(sig, expected)\n}\n\n/** The query string that carries a signature on a CDN URL. */\nexport function mediaSignatureQuery(signature: MediaSignature): string {\n return (\n `exp=${signature.exp}&sig=${encodeURIComponent(signature.sig)}` +\n (signature.aud ? `&aud=${signature.aud}` : '')\n )\n}\n\n/**\n * Throws unless `ttlMs` is a lifetime the platform issues: positive, and no\n * longer than {@link MEDIA_SIGNATURE_MAX_TTL_MS}.\n *\n * It THROWS rather than clamping. The verifier refuses a longer link, so\n * minting one would hand out a URL that is dead on arrival, and a quiet clamp\n * would hide the caller asking for the wrong thing. Exported for the minters\n * that sign something other than a CDN URL, such as a Storage read, so every\n * expiring link the platform hands out obeys the same bound.\n */\nexport function assertMediaSignatureTtl(ttlMs: number): void {\n if (!(ttlMs > 0) || ttlMs > MEDIA_SIGNATURE_MAX_TTL_MS) {\n throw new RangeError(\n `A media link lifetime must be between 1 ms and ${MEDIA_SIGNATURE_MAX_TTL_MS} ms`,\n )\n }\n}\n\n/**\n * Mints a signature valid for `ttlMs` from now — {@link MEDIA_SIGNATURE_TTL_MS}\n * unless the caller names a longer window, bounded by\n * {@link assertMediaSignatureTtl}.\n */\nexport function mintMediaSignature(\n scope: string,\n mediaId: string,\n nowMs: number = Date.now(),\n ttlMs: number = MEDIA_SIGNATURE_TTL_MS,\n audience?: MediaSignatureAudience,\n): MediaSignature {\n assertMediaSignatureTtl(ttlMs)\n const exp = nowMs + ttlMs\n return {\n exp,\n sig: signMediaAccess(scope, mediaId, exp, audience),\n ...(audience ? { aud: audience } : {}),\n }\n}\n"],"names":["createHmac","safeEqual","tokenSigningSecret","secret","process","env","Error","MEDIA_SIGNATURE_TTL_MS","GATED_VIDEO_SESSION_TTL_MS","PAID_DOWNLOAD_LINK_TTL_MS","MEDIA_SIGNATURE_MAX_TTL_MS","MEDIA_SIGNATURE_CLOCK_SKEW_MS","signMediaAccess","scope","mediaId","expiresAtMs","audience","update","digest","slice","verifyMediaAccess","presented","nowMs","Date","now","exp","Number","sig","String","isFinite","aud","undefined","expected","mediaSignatureQuery","signature","encodeURIComponent","assertMediaSignatureTtl","ttlMs","RangeError","mintMediaSignature"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC;IACd,MAAMC,SAASC,QAAQC,GAAG,CAAC,uBAAuB;IAClD,yEAAyE;IACzE,yEAAyE;IACzE,2DAA2D;IAC3D,IAAI,CAACF,QAAQ,MAAM,IAAIG,MAAM;IAC7B,OAAOH;AACT;AAEA;;;;;;CAMC,GACD,OAAO,MAAMI,yBAAyB,KAAK,KAAK,KAAI;AAEpD;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMC,6BAA6B,IAAI,KAAK,KAAK,KAAI;AAE5D;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,4BAA4B,KAAK,KAAK,KAAI;AAEvD;;;;;;;;CAQC,GACD,OAAO,MAAMC,6BAA6BF,2BAA0B;AAEpE;;;CAGC,GACD,MAAMG,gCAAgC,KAAK;AAE3C;;;;;;;CAOC,GACD,OAAO,SAASC,gBACdC,KAAa,EACbC,OAAe,EACfC,WAAmB,EACnBC,QAAiC;IAEjC,OAAOhB,WAAW,UAAUE,sBACzBe,MAAM,CAAC,CAAC,MAAM,EAAEJ,MAAM,CAAC,EAAEC,QAAQ,CAAC,EAAEC,cAAcC,WAAW,CAAC,CAAC,EAAEA,UAAU,GAAG,IAAI,EAClFE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;AACd;AAmBA;;;;;;;;CAQC,GACD,OAAO,SAASC,kBACdP,KAAa,EACbC,OAAe,EACfO,SAA8C,EAC9CC,QAAgBC,KAAKC,GAAG,EAAE;;IAE1B,MAAMC,MAAMC,eAAOL,6BAAAA,UAAWI,GAAG,mBAAI;IACrC,MAAME,MAAMC,gBAAOP,6BAAAA,UAAWM,GAAG,oBAAI;IACrC,IAAI,CAACD,OAAOG,QAAQ,CAACJ,QAAQA,OAAOH,OAAO,OAAO;IAClD,wEAAwE;IACxE,4DAA4D;IAC5D,IAAIG,MAAMH,QAAQZ,6BAA6BC,+BAA+B;QAC5E,OAAO;IACT;IACA,MAAMmB,MAAMT,CAAAA,6BAAAA,UAAWS,GAAG,MAAK,SAAS,SAASC;IACjD,IAAIC;IACJ,IAAI;QACFA,WAAWpB,gBAAgBC,OAAOC,SAASW,KAAKK;IAClD,EAAE,eAAM;QACN,8DAA8D;QAC9D,OAAO;IACT;IACA,OAAO7B,UAAU0B,KAAKK;AACxB;AAEA,4DAA4D,GAC5D,OAAO,SAASC,oBAAoBC,SAAyB;IAC3D,OACE,CAAC,IAAI,EAAEA,UAAUT,GAAG,CAAC,KAAK,EAAEU,mBAAmBD,UAAUP,GAAG,GAAG,GAC9DO,CAAAA,UAAUJ,GAAG,GAAG,CAAC,KAAK,EAAEI,UAAUJ,GAAG,EAAE,GAAG,EAAC;AAEhD;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASM,wBAAwBC,KAAa;IACnD,IAAI,CAAEA,CAAAA,QAAQ,CAAA,KAAMA,QAAQ3B,4BAA4B;QACtD,MAAM,IAAI4B,WACR,CAAC,+CAA+C,EAAE5B,2BAA2B,GAAG,CAAC;IAErF;AACF;AAEA;;;;CAIC,GACD,OAAO,SAAS6B,mBACd1B,KAAa,EACbC,OAAe,EACfQ,QAAgBC,KAAKC,GAAG,EAAE,EAC1Ba,QAAgB9B,sBAAsB,EACtCS,QAAiC;IAEjCoB,wBAAwBC;IACxB,MAAMZ,MAAMH,QAAQe;IACpB,OAAO;QACLZ;QACAE,KAAKf,gBAAgBC,OAAOC,SAASW,KAAKT;OACtCA,WAAW;QAAEc,KAAKd;IAAS,IAAI,CAAC;AAExC"}
|
|
@@ -18,6 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
import { checkQuota } from "@aglyn/aglyn/server";
|
|
19
19
|
import { mediaFilterKeys } from "@aglyn/aglyn/app-utils/media-metadata";
|
|
20
20
|
import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-delivery.js";
|
|
21
|
+
import { mediaDerivedObjectPaths } from "./media-variants.js";
|
|
21
22
|
/**
|
|
22
23
|
* What a DAM delete has to leave behind for an undo to be possible at all
|
|
23
24
|
* (AGL-1467).
|
|
@@ -176,7 +177,7 @@ import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-d
|
|
|
176
177
|
* The second is recoverable by the code in this file. The first is not
|
|
177
178
|
* recoverable by anything.
|
|
178
179
|
*/ export async function deleteMediaWithTombstone(options) {
|
|
179
|
-
var _options_nowMs, _snapshot_get, _snapshot_get1
|
|
180
|
+
var _options_nowMs, _snapshot_get, _snapshot_get1;
|
|
180
181
|
const { scopeRef, bucket, mediaId, objectPath, uid } = options;
|
|
181
182
|
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
182
183
|
const mediaRef = scopeRef.collection('media').doc(mediaId);
|
|
@@ -191,15 +192,16 @@ import { removeMediaDeliveryCopies, withoutMediaDeliveryCopies } from "./media-d
|
|
|
191
192
|
deleted: false
|
|
192
193
|
};
|
|
193
194
|
}
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
// Every derived object, not only the variants (AGL-3486): a display copy,
|
|
196
|
+
// a film's poster and its renditions are kept and restored with the rest.
|
|
197
|
+
const variantPaths = mediaDerivedObjectPaths(objectPath, snapshot);
|
|
196
198
|
// BEFORE the delete — the whole point. See `captureObjectGenerations`.
|
|
197
199
|
const objects = await captureObjectGenerations(bucket, [
|
|
198
200
|
objectPath,
|
|
199
201
|
...variantPaths
|
|
200
202
|
]);
|
|
201
|
-
const sizeBytes = Number((
|
|
202
|
-
const fileName = String((
|
|
203
|
+
const sizeBytes = Number((_snapshot_get = snapshot.get('sizeBytes')) != null ? _snapshot_get : 0);
|
|
204
|
+
const fileName = String((_snapshot_get1 = snapshot.get('fileName')) != null ? _snapshot_get1 : mediaId);
|
|
203
205
|
const expiresAt = mediaTombstoneExpiry(nowMs);
|
|
204
206
|
const tombstoneRef = scopeRef.collection(MEDIA_TOMBSTONE_COLLECTION).doc(mediaId);
|
|
205
207
|
const counterRef = scopeRef.collection('counters').doc('media');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-tombstone.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { checkQuota } from '@aglyn/aglyn/server'\nimport { mediaFilterKeys } from '@aglyn/aglyn/app-utils/media-metadata'\nimport {\n removeMediaDeliveryCopies,\n withoutMediaDeliveryCopies,\n} from './media-delivery'\n\n/**\n * What a DAM delete has to leave behind for an undo to be possible at all\n * (AGL-1467).\n *\n * ## The gap this closes\n *\n * The bucket has soft-delete on with a **seven-day** retention, so the bytes\n * of a deleted asset really are still there. That was only ever half of an\n * undo, and AGL-1461 shipped honest copy rather than a button because of the\n * other half: `/api/media/upload`'s DELETE branch hard-deleted the Firestore\n * document. After it ran, nothing in the product knew the asset's id, file\n * name, folder, tags, alt text, description, custom metadata, `visibleTo`\n * scope tokens, `cdnPath` or `variants` — and `File.restore()` needs a\n * **generation number** that nobody had captured. Surviving bytes with no\n * address are not a recovery story. On 2026-08-13 two files deleted in error\n * came back only because an unrelated analysis document happened to have\n * recorded their ids; a customer has no such document.\n *\n * A tombstone is the three facts that make the bytes addressable again:\n *\n * 1. the media document verbatim, as it was at the moment of the delete;\n * 2. the **generation** of the storage object and of every CDN variant,\n * read BEFORE the delete — afterwards there is no live object to ask;\n * 3. `sizeBytes`, so the counter decrement can be reversed by exactly the\n * amount it moved rather than by a recomputation that can drift.\n *\n * ## Where it lives, and why that is the whole erasure answer\n *\n * `{hosts|orgs}/{scopeId}/mediaTombstones/{mediaId}` — a subcollection of the\n * scope that owned the asset, never a top-level collection keyed by media id.\n *\n * This is the load-bearing choice. `eraseHost` ends in\n * `recursiveDelete(hostRef)` and `eraseOrg` ends in `recursiveDelete(orgRef)`,\n * and a path-scoped cascade takes every subcollection under the path with it.\n * Put the tombstones there and an erasure removes them with no new code — put\n * them in a top-level `mediaTombstones/{id}` with an `orgId` field and they\n * become exactly the shape AGL-1444 and AGL-1448 had to go back and wire by\n * hand: a collection the cascade is structurally blind to, holding a verbatim\n * copy of customer content, surviving the request that promised it was gone.\n * A tombstone is customer data. It has to die with the customer.\n *\n * (The property is asserted rather than assumed —\n * `erase-media-tombstones.emulator.spec.ts` erases a host and an org that each\n * hold one and looks for the survivor.)\n *\n * ## Retention: the same seven days as the bytes, and not a day more\n *\n * `expiresAt` is stamped at `MEDIA_TOMBSTONE_RETENTION_MS` past the delete,\n * which is the bucket's soft-delete window exactly. Both other numbers are\n * wrong for the same reason from opposite directions: a shorter tombstone\n * strands recoverable bytes with no address, which is today's defect made\n * smaller, and a longer one can only ever produce a failed restore while\n * holding a copy of a customer's asset for no reachable purpose. AGL-1443 is\n * open on precisely that — a durable copy of customer data whose retention\n * nobody chose — and this must not become a second instance of it.\n *\n * The field is enforced two ways, because neither alone is enough. A Firestore\n * TTL policy on `mediaTombstones.expiresAt` reaps them (see\n * `docs/FIRESTORE_MANUAL_CONFIG.md`), and TTL deletion is explicitly not\n * prompt — so `restoreMediaFromTombstone` treats an expired tombstone as\n * absent and deletes it on sight rather than trusting the sweeper.\n */\n\n/** The subcollection, under the scope that owned the asset. */\nexport const MEDIA_TOMBSTONE_COLLECTION = 'mediaTombstones'\n\n/**\n * How long a tombstone lives: the bucket's soft-delete retention, exactly.\n *\n * Changing this without changing the bucket policy breaks the invariant the\n * whole module rests on — see the header.\n */\nexport const MEDIA_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000\n\n/** Days, for the copy that has to name the window to a person. */\nexport const MEDIA_TOMBSTONE_RETENTION_DAYS = Math.round(\n MEDIA_TOMBSTONE_RETENTION_MS / (24 * 60 * 60 * 1000),\n)\n\n/**\n * One deleted object and the generation that addresses it.\n *\n * `generation` is nullable on purpose: reading it is a Storage round-trip that\n * can fail, and an asset whose object was ALREADY missing (replaced, swept,\n * never written) must still produce a tombstone — the document half of the\n * restore is worth having on its own. A null generation means \"nothing to\n * restore here\", not \"restore failed\".\n */\nexport interface MediaObjectGeneration {\n path: string\n generation: string | null\n}\n\n/** The stored tombstone document. */\nexport interface MediaTombstoneDoc {\n /** The media document verbatim, exactly as the delete found it. */\n media: Record<string, unknown>\n /** Primary object first, then every CDN variant. */\n objects: MediaObjectGeneration[]\n /** The counter decrement to reverse, taken from the document. */\n sizeBytes: number\n fileName: string\n deletedBy: string\n deletedAt: Timestamp | number\n /**\n * Hard bound — see the header. Written as a **Timestamp**, which the TTL\n * policy requires (a number field cannot carry one, which is why\n * `bookings.expiresAtMs` is documented as not a TTL target). Read through\n * `isMediaTombstoneExpired`, which takes either form so a hand-written\n * fixture and the real write are both legible.\n */\n expiresAt: Timestamp | number\n}\n\n/**\n * The slice of `File` this module needs; the real one satisfies it.\n *\n * `restore` takes a NUMBER because the SDK's `RestoreOptions` does, while the\n * tombstone stores the generation as a string — a GCS generation is a\n * microsecond timestamp (~1.7e15), comfortably inside a double, but a string\n * is what survives JSON, a Firestore round-trip and a log line without anyone\n * having to reason about that. The conversion happens once, at the call.\n */\nexport interface TombstoneStorageFile {\n getMetadata(): Promise<any>\n delete(): Promise<any>\n exists(): Promise<any>\n restore(options: { generation: number }): Promise<any>\n}\n\n/** The slice of `Bucket` this module needs. */\nexport interface TombstoneStorageBucket {\n file(path: string): TombstoneStorageFile\n}\n\n/** When a tombstone written now stops being useful. */\nexport function mediaTombstoneExpiry(deletedAtMs: number): number {\n return deletedAtMs + MEDIA_TOMBSTONE_RETENTION_MS\n}\n\n/** Milliseconds out of a Firestore Timestamp or a plain number. */\nfunction toMillis(value: unknown): number {\n const timestamp = value as { toMillis?: () => number }\n if (typeof timestamp?.toMillis === 'function') return timestamp.toMillis()\n return Number(value ?? 0)\n}\n\n/**\n * Whether the bytes this tombstone addresses are past the bucket's window.\n *\n * Missing or unreadable reads as EXPIRED, which is the safe direction: the\n * failure mode of a false positive is a refusal with a clear message, and the\n * failure mode of a false negative is a restore that half-succeeds against\n * bytes the bucket has already reaped.\n */\nexport function isMediaTombstoneExpired(\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt'>,\n nowMs: number,\n): boolean {\n return !(toMillis(tombstone.expiresAt) > nowMs)\n}\n\n/**\n * Read the generation of each object, best-effort (AGL-1467).\n *\n * Must run BEFORE the delete. Once the object is soft-deleted there is no live\n * generation to read, and the number is the only handle `restore()` takes —\n * this is the single fact whose absence made undo impossible rather than\n * merely unbuilt.\n *\n * A failure here degrades the tombstone to its document half rather than\n * failing the delete: an author who asked for a file to go must not be told\n * \"no\" because a metadata read timed out.\n */\nexport async function captureObjectGenerations(\n bucket: TombstoneStorageBucket,\n paths: readonly string[],\n): Promise<MediaObjectGeneration[]> {\n return Promise.all(\n paths.map(async (path) => {\n try {\n const response = await bucket.file(path).getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n const generation = metadata?.generation\n return {\n path,\n generation:\n generation === undefined || generation === null\n ? null\n : String(generation),\n }\n } catch {\n return { path, generation: null }\n }\n }),\n )\n}\n\n/**\n * Removes the delivery provider's copies of a deleted asset (AGL-2824).\n *\n * Unlike the bucket's objects they are not kept for a restore — a restore\n * copies the video again — and no tombstone records them. The library is\n * read off the scope reference (`hosts/{id}` or `orgs/{id}`). Nothing runs\n * when no provider is configured to store, and a failure is logged rather\n * than answered, like the object deletes beside it.\n */\nasync function removeDeliveryCopies(\n scopeRef: FirebaseFirestore.DocumentReference,\n mediaId: string,\n): Promise<void> {\n const collection = scopeRef.parent?.id\n if (collection !== 'hosts' && collection !== 'orgs') return\n await removeMediaDeliveryCopies({\n asset: { collection, scopeId: scopeRef.id, mediaId },\n })\n}\n\nexport interface DeleteMediaResult {\n /** False when there was no document to delete (already gone). */\n deleted: boolean\n /** Present when a tombstone was written — the id is the media id. */\n tombstone?: { mediaId: string; expiresAt: number; fileName: string }\n}\n\n/**\n * Delete a media document and leave a tombstone behind, in one commit.\n *\n * **The invariant: the media document is never destroyed except in the same\n * transaction that writes its tombstone and moves the counter.** All three\n * belong to one commit, so there is no interleaving in which the document is\n * gone and the record of it is not — which is the state the product was\n * permanently in before this issue.\n *\n * The object delete happens AFTER that commit, deliberately, and the ordering\n * is a choice between two partial failures:\n *\n * - object first: a failed commit leaves a live document pointing at bytes\n * that are already soft-deleted — a broken asset in the library that\n * nothing detects;\n * - commit first: a failed object delete leaves a tombstone whose object is\n * still live, which `restoreMediaFromTombstone` handles by skipping the\n * restore for an object that is already there.\n *\n * The second is recoverable by the code in this file. The first is not\n * recoverable by anything.\n */\nexport async function deleteMediaWithTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n /** Path of the primary object, resolved by the caller (legacy fallback). */\n objectPath: string\n /** Who asked — an erasure record with no actor is half a record. */\n uid: string\n nowMs?: number\n}): Promise<DeleteMediaResult> {\n const { scopeRef, bucket, mediaId, objectPath, uid } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const snapshot = await mediaRef.get()\n if (!snapshot.exists) {\n // No document, so nothing to record and nothing to restore to. The object\n // sweep below still runs: an orphan object is exactly what this branch is\n // for, and it predates the tombstone.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await removeDeliveryCopies(scopeRef, mediaId)\n return { deleted: false }\n }\n\n const variantWidths: number[] = snapshot.get('variants') ?? []\n const variantPaths = variantWidths.map(\n (width) => `${objectPath}__w${width}.webp`,\n )\n // BEFORE the delete — the whole point. See `captureObjectGenerations`.\n const objects = await captureObjectGenerations(bucket, [\n objectPath,\n ...variantPaths,\n ])\n\n const sizeBytes = Number(snapshot.get('sizeBytes') ?? 0)\n const fileName = String(snapshot.get('fileName') ?? mediaId)\n const expiresAt = mediaTombstoneExpiry(nowMs)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const committed = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(mediaRef)\n // Someone else deleted it between the read above and here. Their commit\n // wrote the tombstone; ours must not write a second one over it.\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const tombstone: MediaTombstoneDoc = {\n media: data,\n objects,\n sizeBytes: Number(data['sizeBytes'] ?? sizeBytes) || 0,\n fileName: String(data['fileName'] ?? fileName),\n deletedBy: uid,\n deletedAt: Timestamp.fromMillis(nowMs),\n // Timestamp, not a number: the TTL policy on\n // `mediaTombstones.expiresAt` cannot key on anything else.\n expiresAt: Timestamp.fromMillis(expiresAt),\n }\n transaction.set(tombstoneRef, tombstone)\n transaction.delete(mediaRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(-tombstone.sizeBytes),\n count: FieldValue.increment(-1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n // Object and variants last, best-effort, exactly as before — the bucket\n // keeps them for the retention window either way.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await Promise.all(\n variantPaths.map((path) =>\n bucket.file(path).delete().catch(() => undefined),\n ),\n )\n await removeDeliveryCopies(scopeRef, mediaId)\n\n if (!committed) return { deleted: false }\n return { deleted: true, tombstone: { mediaId, expiresAt, fileName } }\n}\n\nexport interface RestoreMediaResult {\n ok: boolean\n /** HTTP status the route should answer with. */\n status: number\n /** A real sentence for a person — never a bare code. */\n message: string\n fileName?: string\n}\n\n/**\n * Why a restore cannot proceed, decided without touching Storage (AGL-1467).\n *\n * Pure, because these are the answers a person actually sees and the two\n * interesting ones are cheap to get wrong: an expired tombstone must fail\n * CLEANLY rather than half-succeed against bytes the bucket has already\n * reaped, and a restore that would breach the plan must say so rather than\n * push the counter past the limit in silence.\n *\n * ## The quota call\n *\n * A restore re-adds real bytes, so it is gated exactly like an upload —\n * `storagePerHostMb`, same off-by-one — and REFUSED when it would breach the\n * plan. Waiving it would let delete-then-restore launder an over-quota\n * library, which is the AGL-1471/AGL-1279 shape (a limit evaluated at a moment\n * the caller controls). Refusing is safe precisely because the tombstone\n * outlives the refusal: the asset is still restorable for the rest of the\n * window once space is freed, which is what makes \"no, and here is why\"\n * an honest answer rather than a loss.\n *\n * Note what is NOT metered: the soft-deleted bytes themselves. They sit in the\n * bucket for seven days at Aglyn's cost, and counting them would mean deleting\n * a file did not free space until a week later — which breaks the one action\n * an author takes when they hit the wall. The window is the price of offering\n * undo, not an allocation to bill back.\n */\nexport function mediaRestoreRefusal(options: {\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt' | 'sizeBytes' | 'fileName'>\n nowMs: number\n /** Counter bytes as they stand now, with the asset already removed. */\n usedBytes: number\n billing: Record<string, unknown> | undefined\n}): { status: number; message: string } | null {\n const { tombstone, nowMs, usedBytes, billing } = options\n if (isMediaTombstoneExpired(tombstone, nowMs)) {\n return {\n status: 410,\n message:\n `\"${tombstone.fileName}\" was deleted more than ` +\n `${MEDIA_TOMBSTONE_RETENTION_DAYS} days ago, so its file is no ` +\n 'longer recoverable.',\n }\n }\n const usedMb = (usedBytes + Number(tombstone.sizeBytes ?? 0)) / (1024 * 1024)\n const quota = checkQuota(\n billing as never,\n 'storagePerHostMb',\n Math.ceil(usedMb) - 1,\n )\n if (!quota.allowed) {\n return {\n status: 403,\n message:\n `Restoring \"${tombstone.fileName}\" would put this library over its ` +\n `storage limit (${quota.limit} MB). Free up space and try again — ` +\n 'the file stays restorable until the window closes.',\n }\n }\n return null\n}\n\n/**\n * Put a deleted asset back: object, variants, document and counters.\n *\n * Order is the same argument as the delete, run backwards. Bytes first, then\n * the commit — so the only partial state this can leave is an object restored\n * with no document, which the tombstone (still present, because the commit is\n * what consumes it) makes a retry away from finished. The reverse order would\n * publish a document whose bytes may never arrive.\n *\n * The commit itself carries the document write, the tombstone delete and both\n * counter increments together, and it re-reads the tombstone inside the\n * transaction. That re-read is what makes a double-click harmless: the second\n * transaction finds no tombstone, commits nothing, and the caller is told the\n * file is already back rather than being charged for it twice.\n */\nexport async function restoreMediaFromTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n billing: Record<string, unknown> | undefined\n nowMs?: number\n}): Promise<RestoreMediaResult> {\n const { scopeRef, bucket, mediaId, billing } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const tombstoneSnapshot = await tombstoneRef.get()\n if (!tombstoneSnapshot.exists) {\n // Either it was never written, or the window closed and the TTL swept it.\n // Both are the same fact to the caller, and neither is an error state the\n // product can act on.\n const media = await mediaRef.get()\n if (media.exists) {\n return {\n ok: true,\n status: 200,\n message: 'That file is already back in the library.',\n fileName: String(media.get('fileName') ?? mediaId),\n }\n }\n return {\n ok: false,\n status: 404,\n message: 'That file can no longer be restored.',\n }\n }\n\n const tombstone = tombstoneSnapshot.data() as MediaTombstoneDoc\n const counter = await counterRef.get()\n const usedBytes = Number(counter.get('bytes') ?? 0)\n const refusal = mediaRestoreRefusal({\n tombstone,\n nowMs,\n usedBytes,\n billing,\n })\n if (refusal) {\n // An expired tombstone is dead weight — a copy of customer data whose only\n // possible future is this refusal. Reap it here rather than waiting on a\n // TTL sweep that is documented as unprompt.\n if (refusal.status === 410) {\n await tombstoneRef.delete().catch(() => undefined)\n }\n return { ok: false, ...refusal, fileName: tombstone.fileName }\n }\n\n const objects = Array.isArray(tombstone.objects) ? tombstone.objects : []\n const [primary, ...variants] = objects\n const restoreObject = async (\n object: MediaObjectGeneration,\n ): Promise<boolean> => {\n if (!object?.generation) return false\n const file = bucket.file(object.path)\n // Already live — the delete's object sweep is best-effort, so this is a\n // real branch, not a defensive one. `restore()` on a live generation is\n // not a no-op, so the check has to come first.\n const existing = await file.exists().catch(() => [false])\n if (Array.isArray(existing) ? existing[0] : existing) return true\n await file.restore({ generation: Number(object.generation) })\n return true\n }\n\n if (primary) {\n try {\n await restoreObject(primary)\n } catch (error) {\n console.error(\n `restoreMediaFromTombstone: object restore failed for ${mediaId}`,\n error,\n )\n return {\n ok: false,\n status: 502,\n message:\n `\"${tombstone.fileName}\" could not be restored — its file could ` +\n 'not be recovered from storage. Nothing was changed.',\n fileName: tombstone.fileName,\n }\n }\n }\n // Variants are derived artifacts the platform can regenerate, and the upload\n // path already refuses to fail an asset for them. Same call here.\n for (const variant of variants) {\n await restoreObject(variant).catch((error) => {\n console.error(\n `restoreMediaFromTombstone: variant ${variant?.path} failed`,\n error,\n )\n return false\n })\n }\n\n const restored = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(tombstoneRef)\n if (!fresh.exists) return false\n const record = fresh.data() as MediaTombstoneDoc\n // Without its delivery copy record: the delete removed those copies,\n // and a record of them would name objects that are gone (AGL-2824).\n // With the library's filter keys re-derived, so a file deleted before\n // they existed comes back findable by them (AGL-3327).\n const media = withoutMediaDeliveryCopies(record.media)\n transaction.set(mediaRef, {\n ...media,\n ...mediaFilterKeys(media as Parameters<typeof mediaFilterKeys>[0]),\n folderId: (media as { folderId?: unknown }).folderId ?? null,\n })\n transaction.delete(tombstoneRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(Number(record.sizeBytes ?? 0)),\n count: FieldValue.increment(1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n return {\n ok: true,\n status: 200,\n message: restored\n ? `Restored \"${tombstone.fileName}\".`\n : 'That file is already back in the library.',\n fileName: tombstone.fileName,\n }\n}\n"],"names":["FieldValue","Timestamp","checkQuota","mediaFilterKeys","removeMediaDeliveryCopies","withoutMediaDeliveryCopies","MEDIA_TOMBSTONE_COLLECTION","MEDIA_TOMBSTONE_RETENTION_MS","MEDIA_TOMBSTONE_RETENTION_DAYS","Math","round","mediaTombstoneExpiry","deletedAtMs","toMillis","value","timestamp","Number","isMediaTombstoneExpired","tombstone","nowMs","expiresAt","captureObjectGenerations","bucket","paths","Promise","all","map","path","response","file","getMetadata","metadata","Array","isArray","generation","undefined","String","removeDeliveryCopies","scopeRef","mediaId","collection","parent","id","asset","scopeId","deleteMediaWithTombstone","options","snapshot","objectPath","uid","Date","now","mediaRef","doc","get","exists","delete","catch","deleted","variantWidths","variantPaths","width","objects","sizeBytes","fileName","tombstoneRef","counterRef","committed","firestore","runTransaction","transaction","fresh","data","media","deletedBy","deletedAt","fromMillis","set","bytes","increment","count","merge","mediaRestoreRefusal","usedBytes","billing","status","message","usedMb","quota","ceil","allowed","limit","restoreMediaFromTombstone","counter","tombstoneSnapshot","ok","refusal","primary","variants","restoreObject","object","existing","restore","error","console","variant","restored","record","folderId"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-tombstone.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { checkQuota } from '@aglyn/aglyn/server'\nimport { mediaFilterKeys } from '@aglyn/aglyn/app-utils/media-metadata'\nimport {\n removeMediaDeliveryCopies,\n withoutMediaDeliveryCopies,\n} from './media-delivery'\nimport { mediaDerivedObjectPaths } from './media-variants'\n\n/**\n * What a DAM delete has to leave behind for an undo to be possible at all\n * (AGL-1467).\n *\n * ## The gap this closes\n *\n * The bucket has soft-delete on with a **seven-day** retention, so the bytes\n * of a deleted asset really are still there. That was only ever half of an\n * undo, and AGL-1461 shipped honest copy rather than a button because of the\n * other half: `/api/media/upload`'s DELETE branch hard-deleted the Firestore\n * document. After it ran, nothing in the product knew the asset's id, file\n * name, folder, tags, alt text, description, custom metadata, `visibleTo`\n * scope tokens, `cdnPath` or `variants` — and `File.restore()` needs a\n * **generation number** that nobody had captured. Surviving bytes with no\n * address are not a recovery story. On 2026-08-13 two files deleted in error\n * came back only because an unrelated analysis document happened to have\n * recorded their ids; a customer has no such document.\n *\n * A tombstone is the three facts that make the bytes addressable again:\n *\n * 1. the media document verbatim, as it was at the moment of the delete;\n * 2. the **generation** of the storage object and of every CDN variant,\n * read BEFORE the delete — afterwards there is no live object to ask;\n * 3. `sizeBytes`, so the counter decrement can be reversed by exactly the\n * amount it moved rather than by a recomputation that can drift.\n *\n * ## Where it lives, and why that is the whole erasure answer\n *\n * `{hosts|orgs}/{scopeId}/mediaTombstones/{mediaId}` — a subcollection of the\n * scope that owned the asset, never a top-level collection keyed by media id.\n *\n * This is the load-bearing choice. `eraseHost` ends in\n * `recursiveDelete(hostRef)` and `eraseOrg` ends in `recursiveDelete(orgRef)`,\n * and a path-scoped cascade takes every subcollection under the path with it.\n * Put the tombstones there and an erasure removes them with no new code — put\n * them in a top-level `mediaTombstones/{id}` with an `orgId` field and they\n * become exactly the shape AGL-1444 and AGL-1448 had to go back and wire by\n * hand: a collection the cascade is structurally blind to, holding a verbatim\n * copy of customer content, surviving the request that promised it was gone.\n * A tombstone is customer data. It has to die with the customer.\n *\n * (The property is asserted rather than assumed —\n * `erase-media-tombstones.emulator.spec.ts` erases a host and an org that each\n * hold one and looks for the survivor.)\n *\n * ## Retention: the same seven days as the bytes, and not a day more\n *\n * `expiresAt` is stamped at `MEDIA_TOMBSTONE_RETENTION_MS` past the delete,\n * which is the bucket's soft-delete window exactly. Both other numbers are\n * wrong for the same reason from opposite directions: a shorter tombstone\n * strands recoverable bytes with no address, which is today's defect made\n * smaller, and a longer one can only ever produce a failed restore while\n * holding a copy of a customer's asset for no reachable purpose. AGL-1443 is\n * open on precisely that — a durable copy of customer data whose retention\n * nobody chose — and this must not become a second instance of it.\n *\n * The field is enforced two ways, because neither alone is enough. A Firestore\n * TTL policy on `mediaTombstones.expiresAt` reaps them (see\n * `docs/FIRESTORE_MANUAL_CONFIG.md`), and TTL deletion is explicitly not\n * prompt — so `restoreMediaFromTombstone` treats an expired tombstone as\n * absent and deletes it on sight rather than trusting the sweeper.\n */\n\n/** The subcollection, under the scope that owned the asset. */\nexport const MEDIA_TOMBSTONE_COLLECTION = 'mediaTombstones'\n\n/**\n * How long a tombstone lives: the bucket's soft-delete retention, exactly.\n *\n * Changing this without changing the bucket policy breaks the invariant the\n * whole module rests on — see the header.\n */\nexport const MEDIA_TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000\n\n/** Days, for the copy that has to name the window to a person. */\nexport const MEDIA_TOMBSTONE_RETENTION_DAYS = Math.round(\n MEDIA_TOMBSTONE_RETENTION_MS / (24 * 60 * 60 * 1000),\n)\n\n/**\n * One deleted object and the generation that addresses it.\n *\n * `generation` is nullable on purpose: reading it is a Storage round-trip that\n * can fail, and an asset whose object was ALREADY missing (replaced, swept,\n * never written) must still produce a tombstone — the document half of the\n * restore is worth having on its own. A null generation means \"nothing to\n * restore here\", not \"restore failed\".\n */\nexport interface MediaObjectGeneration {\n path: string\n generation: string | null\n}\n\n/** The stored tombstone document. */\nexport interface MediaTombstoneDoc {\n /** The media document verbatim, exactly as the delete found it. */\n media: Record<string, unknown>\n /** Primary object first, then every CDN variant. */\n objects: MediaObjectGeneration[]\n /** The counter decrement to reverse, taken from the document. */\n sizeBytes: number\n fileName: string\n deletedBy: string\n deletedAt: Timestamp | number\n /**\n * Hard bound — see the header. Written as a **Timestamp**, which the TTL\n * policy requires (a number field cannot carry one, which is why\n * `bookings.expiresAtMs` is documented as not a TTL target). Read through\n * `isMediaTombstoneExpired`, which takes either form so a hand-written\n * fixture and the real write are both legible.\n */\n expiresAt: Timestamp | number\n}\n\n/**\n * The slice of `File` this module needs; the real one satisfies it.\n *\n * `restore` takes a NUMBER because the SDK's `RestoreOptions` does, while the\n * tombstone stores the generation as a string — a GCS generation is a\n * microsecond timestamp (~1.7e15), comfortably inside a double, but a string\n * is what survives JSON, a Firestore round-trip and a log line without anyone\n * having to reason about that. The conversion happens once, at the call.\n */\nexport interface TombstoneStorageFile {\n getMetadata(): Promise<any>\n delete(): Promise<any>\n exists(): Promise<any>\n restore(options: { generation: number }): Promise<any>\n}\n\n/** The slice of `Bucket` this module needs. */\nexport interface TombstoneStorageBucket {\n file(path: string): TombstoneStorageFile\n}\n\n/** When a tombstone written now stops being useful. */\nexport function mediaTombstoneExpiry(deletedAtMs: number): number {\n return deletedAtMs + MEDIA_TOMBSTONE_RETENTION_MS\n}\n\n/** Milliseconds out of a Firestore Timestamp or a plain number. */\nfunction toMillis(value: unknown): number {\n const timestamp = value as { toMillis?: () => number }\n if (typeof timestamp?.toMillis === 'function') return timestamp.toMillis()\n return Number(value ?? 0)\n}\n\n/**\n * Whether the bytes this tombstone addresses are past the bucket's window.\n *\n * Missing or unreadable reads as EXPIRED, which is the safe direction: the\n * failure mode of a false positive is a refusal with a clear message, and the\n * failure mode of a false negative is a restore that half-succeeds against\n * bytes the bucket has already reaped.\n */\nexport function isMediaTombstoneExpired(\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt'>,\n nowMs: number,\n): boolean {\n return !(toMillis(tombstone.expiresAt) > nowMs)\n}\n\n/**\n * Read the generation of each object, best-effort (AGL-1467).\n *\n * Must run BEFORE the delete. Once the object is soft-deleted there is no live\n * generation to read, and the number is the only handle `restore()` takes —\n * this is the single fact whose absence made undo impossible rather than\n * merely unbuilt.\n *\n * A failure here degrades the tombstone to its document half rather than\n * failing the delete: an author who asked for a file to go must not be told\n * \"no\" because a metadata read timed out.\n */\nexport async function captureObjectGenerations(\n bucket: TombstoneStorageBucket,\n paths: readonly string[],\n): Promise<MediaObjectGeneration[]> {\n return Promise.all(\n paths.map(async (path) => {\n try {\n const response = await bucket.file(path).getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n const generation = metadata?.generation\n return {\n path,\n generation:\n generation === undefined || generation === null\n ? null\n : String(generation),\n }\n } catch {\n return { path, generation: null }\n }\n }),\n )\n}\n\n/**\n * Removes the delivery provider's copies of a deleted asset (AGL-2824).\n *\n * Unlike the bucket's objects they are not kept for a restore — a restore\n * copies the video again — and no tombstone records them. The library is\n * read off the scope reference (`hosts/{id}` or `orgs/{id}`). Nothing runs\n * when no provider is configured to store, and a failure is logged rather\n * than answered, like the object deletes beside it.\n */\nasync function removeDeliveryCopies(\n scopeRef: FirebaseFirestore.DocumentReference,\n mediaId: string,\n): Promise<void> {\n const collection = scopeRef.parent?.id\n if (collection !== 'hosts' && collection !== 'orgs') return\n await removeMediaDeliveryCopies({\n asset: { collection, scopeId: scopeRef.id, mediaId },\n })\n}\n\nexport interface DeleteMediaResult {\n /** False when there was no document to delete (already gone). */\n deleted: boolean\n /** Present when a tombstone was written — the id is the media id. */\n tombstone?: { mediaId: string; expiresAt: number; fileName: string }\n}\n\n/**\n * Delete a media document and leave a tombstone behind, in one commit.\n *\n * **The invariant: the media document is never destroyed except in the same\n * transaction that writes its tombstone and moves the counter.** All three\n * belong to one commit, so there is no interleaving in which the document is\n * gone and the record of it is not — which is the state the product was\n * permanently in before this issue.\n *\n * The object delete happens AFTER that commit, deliberately, and the ordering\n * is a choice between two partial failures:\n *\n * - object first: a failed commit leaves a live document pointing at bytes\n * that are already soft-deleted — a broken asset in the library that\n * nothing detects;\n * - commit first: a failed object delete leaves a tombstone whose object is\n * still live, which `restoreMediaFromTombstone` handles by skipping the\n * restore for an object that is already there.\n *\n * The second is recoverable by the code in this file. The first is not\n * recoverable by anything.\n */\nexport async function deleteMediaWithTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n /** Path of the primary object, resolved by the caller (legacy fallback). */\n objectPath: string\n /** Who asked — an erasure record with no actor is half a record. */\n uid: string\n nowMs?: number\n}): Promise<DeleteMediaResult> {\n const { scopeRef, bucket, mediaId, objectPath, uid } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const snapshot = await mediaRef.get()\n if (!snapshot.exists) {\n // No document, so nothing to record and nothing to restore to. The object\n // sweep below still runs: an orphan object is exactly what this branch is\n // for, and it predates the tombstone.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await removeDeliveryCopies(scopeRef, mediaId)\n return { deleted: false }\n }\n\n // Every derived object, not only the variants (AGL-3486): a display copy,\n // a film's poster and its renditions are kept and restored with the rest.\n const variantPaths = mediaDerivedObjectPaths(objectPath, snapshot)\n // BEFORE the delete — the whole point. See `captureObjectGenerations`.\n const objects = await captureObjectGenerations(bucket, [\n objectPath,\n ...variantPaths,\n ])\n\n const sizeBytes = Number(snapshot.get('sizeBytes') ?? 0)\n const fileName = String(snapshot.get('fileName') ?? mediaId)\n const expiresAt = mediaTombstoneExpiry(nowMs)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const committed = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(mediaRef)\n // Someone else deleted it between the read above and here. Their commit\n // wrote the tombstone; ours must not write a second one over it.\n if (!fresh.exists) return false\n const data = (fresh.data() ?? {}) as Record<string, unknown>\n const tombstone: MediaTombstoneDoc = {\n media: data,\n objects,\n sizeBytes: Number(data['sizeBytes'] ?? sizeBytes) || 0,\n fileName: String(data['fileName'] ?? fileName),\n deletedBy: uid,\n deletedAt: Timestamp.fromMillis(nowMs),\n // Timestamp, not a number: the TTL policy on\n // `mediaTombstones.expiresAt` cannot key on anything else.\n expiresAt: Timestamp.fromMillis(expiresAt),\n }\n transaction.set(tombstoneRef, tombstone)\n transaction.delete(mediaRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(-tombstone.sizeBytes),\n count: FieldValue.increment(-1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n // Object and variants last, best-effort, exactly as before — the bucket\n // keeps them for the retention window either way.\n await bucket.file(objectPath).delete().catch(() => undefined)\n await Promise.all(\n variantPaths.map((path) =>\n bucket.file(path).delete().catch(() => undefined),\n ),\n )\n await removeDeliveryCopies(scopeRef, mediaId)\n\n if (!committed) return { deleted: false }\n return { deleted: true, tombstone: { mediaId, expiresAt, fileName } }\n}\n\nexport interface RestoreMediaResult {\n ok: boolean\n /** HTTP status the route should answer with. */\n status: number\n /** A real sentence for a person — never a bare code. */\n message: string\n fileName?: string\n}\n\n/**\n * Why a restore cannot proceed, decided without touching Storage (AGL-1467).\n *\n * Pure, because these are the answers a person actually sees and the two\n * interesting ones are cheap to get wrong: an expired tombstone must fail\n * CLEANLY rather than half-succeed against bytes the bucket has already\n * reaped, and a restore that would breach the plan must say so rather than\n * push the counter past the limit in silence.\n *\n * ## The quota call\n *\n * A restore re-adds real bytes, so it is gated exactly like an upload —\n * `storagePerHostMb`, same off-by-one — and REFUSED when it would breach the\n * plan. Waiving it would let delete-then-restore launder an over-quota\n * library, which is the AGL-1471/AGL-1279 shape (a limit evaluated at a moment\n * the caller controls). Refusing is safe precisely because the tombstone\n * outlives the refusal: the asset is still restorable for the rest of the\n * window once space is freed, which is what makes \"no, and here is why\"\n * an honest answer rather than a loss.\n *\n * Note what is NOT metered: the soft-deleted bytes themselves. They sit in the\n * bucket for seven days at Aglyn's cost, and counting them would mean deleting\n * a file did not free space until a week later — which breaks the one action\n * an author takes when they hit the wall. The window is the price of offering\n * undo, not an allocation to bill back.\n */\nexport function mediaRestoreRefusal(options: {\n tombstone: Pick<MediaTombstoneDoc, 'expiresAt' | 'sizeBytes' | 'fileName'>\n nowMs: number\n /** Counter bytes as they stand now, with the asset already removed. */\n usedBytes: number\n billing: Record<string, unknown> | undefined\n}): { status: number; message: string } | null {\n const { tombstone, nowMs, usedBytes, billing } = options\n if (isMediaTombstoneExpired(tombstone, nowMs)) {\n return {\n status: 410,\n message:\n `\"${tombstone.fileName}\" was deleted more than ` +\n `${MEDIA_TOMBSTONE_RETENTION_DAYS} days ago, so its file is no ` +\n 'longer recoverable.',\n }\n }\n const usedMb = (usedBytes + Number(tombstone.sizeBytes ?? 0)) / (1024 * 1024)\n const quota = checkQuota(\n billing as never,\n 'storagePerHostMb',\n Math.ceil(usedMb) - 1,\n )\n if (!quota.allowed) {\n return {\n status: 403,\n message:\n `Restoring \"${tombstone.fileName}\" would put this library over its ` +\n `storage limit (${quota.limit} MB). Free up space and try again — ` +\n 'the file stays restorable until the window closes.',\n }\n }\n return null\n}\n\n/**\n * Put a deleted asset back: object, variants, document and counters.\n *\n * Order is the same argument as the delete, run backwards. Bytes first, then\n * the commit — so the only partial state this can leave is an object restored\n * with no document, which the tombstone (still present, because the commit is\n * what consumes it) makes a retry away from finished. The reverse order would\n * publish a document whose bytes may never arrive.\n *\n * The commit itself carries the document write, the tombstone delete and both\n * counter increments together, and it re-reads the tombstone inside the\n * transaction. That re-read is what makes a double-click harmless: the second\n * transaction finds no tombstone, commits nothing, and the caller is told the\n * file is already back rather than being charged for it twice.\n */\nexport async function restoreMediaFromTombstone(options: {\n scopeRef: FirebaseFirestore.DocumentReference\n bucket: TombstoneStorageBucket\n mediaId: string\n billing: Record<string, unknown> | undefined\n nowMs?: number\n}): Promise<RestoreMediaResult> {\n const { scopeRef, bucket, mediaId, billing } = options\n const nowMs = options.nowMs ?? Date.now()\n const mediaRef = scopeRef.collection('media').doc(mediaId)\n const tombstoneRef = scopeRef\n .collection(MEDIA_TOMBSTONE_COLLECTION)\n .doc(mediaId)\n const counterRef = scopeRef.collection('counters').doc('media')\n\n const tombstoneSnapshot = await tombstoneRef.get()\n if (!tombstoneSnapshot.exists) {\n // Either it was never written, or the window closed and the TTL swept it.\n // Both are the same fact to the caller, and neither is an error state the\n // product can act on.\n const media = await mediaRef.get()\n if (media.exists) {\n return {\n ok: true,\n status: 200,\n message: 'That file is already back in the library.',\n fileName: String(media.get('fileName') ?? mediaId),\n }\n }\n return {\n ok: false,\n status: 404,\n message: 'That file can no longer be restored.',\n }\n }\n\n const tombstone = tombstoneSnapshot.data() as MediaTombstoneDoc\n const counter = await counterRef.get()\n const usedBytes = Number(counter.get('bytes') ?? 0)\n const refusal = mediaRestoreRefusal({\n tombstone,\n nowMs,\n usedBytes,\n billing,\n })\n if (refusal) {\n // An expired tombstone is dead weight — a copy of customer data whose only\n // possible future is this refusal. Reap it here rather than waiting on a\n // TTL sweep that is documented as unprompt.\n if (refusal.status === 410) {\n await tombstoneRef.delete().catch(() => undefined)\n }\n return { ok: false, ...refusal, fileName: tombstone.fileName }\n }\n\n const objects = Array.isArray(tombstone.objects) ? tombstone.objects : []\n const [primary, ...variants] = objects\n const restoreObject = async (\n object: MediaObjectGeneration,\n ): Promise<boolean> => {\n if (!object?.generation) return false\n const file = bucket.file(object.path)\n // Already live — the delete's object sweep is best-effort, so this is a\n // real branch, not a defensive one. `restore()` on a live generation is\n // not a no-op, so the check has to come first.\n const existing = await file.exists().catch(() => [false])\n if (Array.isArray(existing) ? existing[0] : existing) return true\n await file.restore({ generation: Number(object.generation) })\n return true\n }\n\n if (primary) {\n try {\n await restoreObject(primary)\n } catch (error) {\n console.error(\n `restoreMediaFromTombstone: object restore failed for ${mediaId}`,\n error,\n )\n return {\n ok: false,\n status: 502,\n message:\n `\"${tombstone.fileName}\" could not be restored — its file could ` +\n 'not be recovered from storage. Nothing was changed.',\n fileName: tombstone.fileName,\n }\n }\n }\n // Variants are derived artifacts the platform can regenerate, and the upload\n // path already refuses to fail an asset for them. Same call here.\n for (const variant of variants) {\n await restoreObject(variant).catch((error) => {\n console.error(\n `restoreMediaFromTombstone: variant ${variant?.path} failed`,\n error,\n )\n return false\n })\n }\n\n const restored = await scopeRef.firestore.runTransaction(\n async (transaction) => {\n const fresh = await transaction.get(tombstoneRef)\n if (!fresh.exists) return false\n const record = fresh.data() as MediaTombstoneDoc\n // Without its delivery copy record: the delete removed those copies,\n // and a record of them would name objects that are gone (AGL-2824).\n // With the library's filter keys re-derived, so a file deleted before\n // they existed comes back findable by them (AGL-3327).\n const media = withoutMediaDeliveryCopies(record.media)\n transaction.set(mediaRef, {\n ...media,\n ...mediaFilterKeys(media as Parameters<typeof mediaFilterKeys>[0]),\n folderId: (media as { folderId?: unknown }).folderId ?? null,\n })\n transaction.delete(tombstoneRef)\n transaction.set(\n counterRef,\n {\n bytes: FieldValue.increment(Number(record.sizeBytes ?? 0)),\n count: FieldValue.increment(1),\n },\n { merge: true },\n )\n return true\n },\n )\n\n return {\n ok: true,\n status: 200,\n message: restored\n ? `Restored \"${tombstone.fileName}\".`\n : 'That file is already back in the library.',\n fileName: tombstone.fileName,\n }\n}\n"],"names":["FieldValue","Timestamp","checkQuota","mediaFilterKeys","removeMediaDeliveryCopies","withoutMediaDeliveryCopies","mediaDerivedObjectPaths","MEDIA_TOMBSTONE_COLLECTION","MEDIA_TOMBSTONE_RETENTION_MS","MEDIA_TOMBSTONE_RETENTION_DAYS","Math","round","mediaTombstoneExpiry","deletedAtMs","toMillis","value","timestamp","Number","isMediaTombstoneExpired","tombstone","nowMs","expiresAt","captureObjectGenerations","bucket","paths","Promise","all","map","path","response","file","getMetadata","metadata","Array","isArray","generation","undefined","String","removeDeliveryCopies","scopeRef","mediaId","collection","parent","id","asset","scopeId","deleteMediaWithTombstone","options","snapshot","objectPath","uid","Date","now","mediaRef","doc","get","exists","delete","catch","deleted","variantPaths","objects","sizeBytes","fileName","tombstoneRef","counterRef","committed","firestore","runTransaction","transaction","fresh","data","media","deletedBy","deletedAt","fromMillis","set","bytes","increment","count","merge","mediaRestoreRefusal","usedBytes","billing","status","message","usedMb","quota","ceil","allowed","limit","restoreMediaFromTombstone","counter","tombstoneSnapshot","ok","refusal","primary","variants","restoreObject","object","existing","restore","error","console","variant","restored","record","folderId"],"mappings":"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"}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
import type { Sharp, SharpOptions } from 'sharp';
|
|
17
18
|
/**
|
|
18
19
|
* WebP variant generation for a media asset (AGL-175), and the record of
|
|
19
20
|
* whether it worked (AGL-1468).
|
|
@@ -50,28 +51,104 @@
|
|
|
50
51
|
* A partial run reports both: the widths that landed AND the error, because
|
|
51
52
|
* the widths that landed are real files that the CDN route can serve.
|
|
52
53
|
*/
|
|
53
|
-
/** The shape of `sharp`'s default export
|
|
54
|
-
type SharpFactory = (input
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
54
|
+
/** The shape of `sharp`'s default export. */
|
|
55
|
+
type SharpFactory = (input?: Buffer, options?: SharpOptions) => Sharp;
|
|
56
|
+
/**
|
|
57
|
+
* What a media document records about its delivery copies' display half
|
|
58
|
+
* (AGL-3486). Present only when a display object was written; its absence on
|
|
59
|
+
* a document at the current encoder generation means the original is clean
|
|
60
|
+
* enough to serve as it is.
|
|
61
|
+
*/
|
|
62
|
+
export interface MediaDisplayCopy {
|
|
63
|
+
/** Always the original's own type — the display copy never changes format. */
|
|
64
|
+
contentType: string;
|
|
65
|
+
width: number;
|
|
66
|
+
height: number;
|
|
67
|
+
sizeBytes: number;
|
|
68
|
+
}
|
|
66
69
|
export interface MediaVariantOutcome {
|
|
67
70
|
/** Widths actually written. Safe to store — every entry is a real object. */
|
|
68
71
|
variants: number[];
|
|
72
|
+
/**
|
|
73
|
+
* The display copy written for this asset, when one was needed and made.
|
|
74
|
+
* Only ever produced when the caller asked for one (`display: true`).
|
|
75
|
+
*/
|
|
76
|
+
display?: MediaDisplayCopy;
|
|
69
77
|
/**
|
|
70
78
|
* Present ONLY when generation was attempted and did not complete.
|
|
71
79
|
* `undefined` means nothing went wrong, which includes "nothing to do".
|
|
72
80
|
*/
|
|
73
81
|
error?: string;
|
|
74
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Writes one derived object. `contentType` is the type the bytes ARE: WebP
|
|
85
|
+
* for a variant, the original's own type for a display copy.
|
|
86
|
+
*/
|
|
87
|
+
export type SaveMediaDerivedObject = (path: string, bytes: Buffer, contentType: string) => Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* The WebP encoder settings for every `?w=` variant (AGL-3486).
|
|
90
|
+
*
|
|
91
|
+
* Quality 72 is where a photo stops showing a difference to the eye at the
|
|
92
|
+
* size it is painted; 80, the setting since AGL-175, spent 15-25% more bytes
|
|
93
|
+
* on detail nobody sees. Effort 5 of 6: measured on the four images the issue
|
|
94
|
+
* names, effort 6 saved another 1-2% for twice the encode time, and the
|
|
95
|
+
* encode runs inside an upload request or after a CDN response.
|
|
96
|
+
*
|
|
97
|
+
* Changing either value changes the bytes every asset should hold, so it
|
|
98
|
+
* bumps `MEDIA_VARIANT_ENCODER_VERSION` — that is what reaches the existing
|
|
99
|
+
* corpus.
|
|
100
|
+
*/
|
|
101
|
+
export declare const MEDIA_VARIANT_WEBP_OPTIONS: Readonly<{
|
|
102
|
+
quality: 72;
|
|
103
|
+
effort: 5;
|
|
104
|
+
}>;
|
|
105
|
+
/**
|
|
106
|
+
* The quality the display copy is re-encoded at (AGL-3486). Higher than a
|
|
107
|
+
* variant's because the display copy is what a link preview, an email client
|
|
108
|
+
* and a CSS background receive at full size — it stands in for the original.
|
|
109
|
+
*/
|
|
110
|
+
export declare const MEDIA_DISPLAY_QUALITY = 82;
|
|
111
|
+
/**
|
|
112
|
+
* Formats that get a display copy. JPEG, PNG and WebP are the formats a
|
|
113
|
+
* camera, a screenshot tool and a design export produce, and the ones `sharp`
|
|
114
|
+
* re-encodes losslessly enough to stand in for the original. Not GIF, whose
|
|
115
|
+
* animation the encoder would flatten, and not SVG or ICO, which have no
|
|
116
|
+
* variants at all.
|
|
117
|
+
*/
|
|
118
|
+
export declare const MEDIA_DISPLAY_TYPES: ReadonlySet<string>;
|
|
119
|
+
/**
|
|
120
|
+
* Encode one `?w=` variant: auto-oriented, downscaled, metadata stripped.
|
|
121
|
+
*
|
|
122
|
+
* `rotate()` with no angle applies the EXIF orientation, which a WebP cannot
|
|
123
|
+
* carry — before AGL-3486 a portrait phone photo's variants came out lying on
|
|
124
|
+
* their side. `sharp` writes no EXIF, XMP or IPTC unless asked to keep it, so
|
|
125
|
+
* a photo's GPS position never reaches a variant.
|
|
126
|
+
*/
|
|
127
|
+
export declare function encodeMediaVariant(sharp: SharpFactory, source: Buffer, width: number): Promise<Buffer>;
|
|
128
|
+
/**
|
|
129
|
+
* The display copy of an image, or null when the original can be served as
|
|
130
|
+
* it is (AGL-3486).
|
|
131
|
+
*
|
|
132
|
+
* The bare URL is what a link preview, an email client, a CSS background and
|
|
133
|
+
* every pasted Copy URL fetch, and before this it served the upload byte for
|
|
134
|
+
* byte: a 6 MB, 6000-pixel phone photo with the GPS position it was taken at.
|
|
135
|
+
* A display copy is made when the original has any of three properties:
|
|
136
|
+
*
|
|
137
|
+
* - **larger than {@link MEDIA_DELIVERY_MAX_EDGE} on its long edge** —
|
|
138
|
+
* downscaled to fit, since nothing on a page paints more;
|
|
139
|
+
* - **EXIF, XMP or IPTC metadata** — stripped. This is the privacy half, and
|
|
140
|
+
* it is kept even when the re-encode is not smaller: a location is not
|
|
141
|
+
* something to trade for bytes;
|
|
142
|
+
* - **an EXIF orientation** — applied, so the pixels are upright without it.
|
|
143
|
+
*
|
|
144
|
+
* Same format as the original, so nothing that could read the original loses
|
|
145
|
+
* the ability to read the copy. Null for an animated image, whose frames the
|
|
146
|
+
* encoder would drop, and for a format outside {@link MEDIA_DISPLAY_TYPES}.
|
|
147
|
+
*/
|
|
148
|
+
export declare function encodeMediaDisplay(sharp: SharpFactory, source: Buffer, contentType: string): Promise<{
|
|
149
|
+
bytes: Buffer;
|
|
150
|
+
copy: MediaDisplayCopy;
|
|
151
|
+
} | null>;
|
|
75
152
|
/**
|
|
76
153
|
* `image/*` types that produce no variants, for two different reasons.
|
|
77
154
|
*
|
|
@@ -85,9 +162,10 @@ export interface MediaVariantOutcome {
|
|
|
85
162
|
* outcome, and it has to read as one or the fault counter measures the favicon
|
|
86
163
|
* instead of a fault (AGL-3121).
|
|
87
164
|
*
|
|
88
|
-
* Exported because
|
|
89
|
-
*
|
|
90
|
-
* and not the
|
|
165
|
+
* Exported because lazy regeneration (`media-delivery-regeneration.ts`) has to
|
|
166
|
+
* skip exactly this set. The backfill script it replaced kept a second copy of
|
|
167
|
+
* the SVG half, which is how ICO came to be missing from one and not the
|
|
168
|
+
* other.
|
|
91
169
|
*/
|
|
92
170
|
export declare const MEDIA_TYPES_WITHOUT_VARIANTS: ReadonlySet<string>;
|
|
93
171
|
/**
|
|
@@ -143,12 +221,25 @@ export declare function loadSharp(): Promise<SharpFactory>;
|
|
|
143
221
|
*/
|
|
144
222
|
export declare function classifyLoadFailure(error: unknown): string;
|
|
145
223
|
/**
|
|
146
|
-
* Generate the
|
|
224
|
+
* Generate the delivery copies for one asset and report the outcome: its
|
|
225
|
+
* WebP variants, and with `display: true` its display copy (AGL-3486).
|
|
147
226
|
*
|
|
148
227
|
* `saveVariant` is a callback rather than a `Bucket` so this stays free of a
|
|
149
228
|
* storage dependency and so a spec can assert on the BYTES it produces —
|
|
150
229
|
* which is the only assertion that distinguishes a working variant from the
|
|
151
230
|
* original served back under a `?w=` that selects nothing.
|
|
231
|
+
*
|
|
232
|
+
* ## A variant is never larger than what it replaces
|
|
233
|
+
*
|
|
234
|
+
* A width the asset has no variant for is answered with the display copy, or
|
|
235
|
+
* the original when there is none — so a variant is only worth storing when
|
|
236
|
+
* it is SMALLER than that answer. Measured on a customer's 1280px JPEGs before
|
|
237
|
+
* AGL-3486: the source-width WebP at `?w=1280` was 384 KB against a 305 KB
|
|
238
|
+
* original, so the variant made every retina visit 26% heavier. A width whose
|
|
239
|
+
* encode comes out no smaller is now skipped and the smaller bytes serve it.
|
|
240
|
+
*
|
|
241
|
+
* The display copy is made FIRST for that reason: it is what the comparison
|
|
242
|
+
* is against.
|
|
152
243
|
*/
|
|
153
244
|
export declare function generateMediaVariants(options: {
|
|
154
245
|
buffer: Buffer;
|
|
@@ -156,8 +247,46 @@ export declare function generateMediaVariants(options: {
|
|
|
156
247
|
sourceWidth?: number | null;
|
|
157
248
|
/** Object path of the ORIGINAL; variants are `${objectPath}__w{n}.webp`. */
|
|
158
249
|
objectPath: string;
|
|
159
|
-
saveVariant:
|
|
250
|
+
saveVariant: SaveMediaDerivedObject;
|
|
251
|
+
/** Also make the display copy. Asked for by an asset, never by a poster. */
|
|
252
|
+
display?: boolean;
|
|
160
253
|
}): Promise<MediaVariantOutcome>;
|
|
254
|
+
/**
|
|
255
|
+
* The media document fields a generation outcome writes (AGL-3486), shared by
|
|
256
|
+
* every route that generates so none of them can forget the encoder
|
|
257
|
+
* generation — the field lazy regeneration keys on.
|
|
258
|
+
*
|
|
259
|
+
* `display` is written as `null` rather than left out when there is none, so
|
|
260
|
+
* a replace whose new bytes need no display copy clears the previous one's
|
|
261
|
+
* record instead of pointing at an object it just deleted. The caller decides
|
|
262
|
+
* `variantsError`, because the routes record it with a counter beside it.
|
|
263
|
+
*/
|
|
264
|
+
export declare function mediaVariantDocFields(outcome: Pick<MediaVariantOutcome, 'variants' | 'display'>): {
|
|
265
|
+
variants: number[];
|
|
266
|
+
variantEncoderVersion: number;
|
|
267
|
+
display: MediaDisplayCopy | null;
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* Every derived object a media document names, given its original's path
|
|
271
|
+
* (AGL-3486): the `?w=` variants, the display copy, a video's poster and the
|
|
272
|
+
* poster's widths, and its renditions.
|
|
273
|
+
*
|
|
274
|
+
* The one list every path that moves, replaces or deletes an asset reads, so
|
|
275
|
+
* a new kind of derived object is added in one place. Before it, each of the
|
|
276
|
+
* three kept its own: a replace dropped all four kinds, while a folder move
|
|
277
|
+
* and a delete knew only the variants — so a moved film lost its poster and
|
|
278
|
+
* renditions to the old prefix, and a deleted one left them in the bucket.
|
|
279
|
+
*/
|
|
280
|
+
export declare function mediaDerivedObjectPaths(objectPath: string,
|
|
281
|
+
/** The media document's fields, or its snapshot. */
|
|
282
|
+
document: {
|
|
283
|
+
variants?: unknown;
|
|
284
|
+
display?: unknown;
|
|
285
|
+
poster?: unknown;
|
|
286
|
+
videoRenditions?: unknown;
|
|
287
|
+
} | {
|
|
288
|
+
get(field: string): unknown;
|
|
289
|
+
}): string[];
|
|
161
290
|
/**
|
|
162
291
|
* The largest object this will pull back OUT of storage in order to generate.
|
|
163
292
|
*
|
|
@@ -210,8 +339,10 @@ export declare function generateStoredMediaVariants(options: {
|
|
|
210
339
|
objectPath: string;
|
|
211
340
|
/** Fetches the whole object. Only ever called when there is work to do. */
|
|
212
341
|
readSource: () => Promise<Buffer>;
|
|
213
|
-
saveVariant:
|
|
342
|
+
saveVariant: SaveMediaDerivedObject;
|
|
214
343
|
maxSourceBytes?: number;
|
|
344
|
+
/** Also make the display copy (see {@link generateMediaVariants}). */
|
|
345
|
+
display?: boolean;
|
|
215
346
|
}): Promise<MediaVariantOutcome>;
|
|
216
347
|
/**
|
|
217
348
|
* Can this deployment produce a variant at all?
|
|
@@ -303,7 +434,7 @@ export declare function generateMediaPoster(options: {
|
|
|
303
434
|
buffer: Buffer;
|
|
304
435
|
/** Object path of the ORIGINAL video, without the poster suffix. */
|
|
305
436
|
objectPath: string;
|
|
306
|
-
saveVariant:
|
|
437
|
+
saveVariant: SaveMediaDerivedObject;
|
|
307
438
|
maxWidth?: number;
|
|
308
439
|
}): Promise<MediaPosterOutcome>;
|
|
309
440
|
export {};
|