@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.223

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.
Files changed (121) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/ga4-measurement-protocol.js +41 -28
  19. package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
  20. package/src/lib/server/host-visitor-records.d.ts +25 -4
  21. package/src/lib/server/host-visitor-records.js +39 -11
  22. package/src/lib/server/host-visitor-records.js.map +1 -1
  23. package/src/lib/server/hosted-page-review.js +52 -36
  24. package/src/lib/server/hosted-page-review.js.map +1 -1
  25. package/src/lib/server/link-reputation-review.d.ts +6 -2
  26. package/src/lib/server/link-reputation-review.js +61 -20
  27. package/src/lib/server/link-reputation-review.js.map +1 -1
  28. package/src/lib/server/live-page-drops.d.ts +67 -0
  29. package/src/lib/server/live-page-drops.js +57 -0
  30. package/src/lib/server/live-page-drops.js.map +1 -0
  31. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  32. package/src/lib/server/media-bandwidth-cap.js +180 -0
  33. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  34. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  35. package/src/lib/server/media-cdn-icon.js +140 -0
  36. package/src/lib/server/media-cdn-icon.js.map +1 -0
  37. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  38. package/src/lib/server/media-delivery-regeneration.js +216 -0
  39. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  40. package/src/lib/server/media-delivery.d.ts +2 -0
  41. package/src/lib/server/media-delivery.js +2 -1
  42. package/src/lib/server/media-delivery.js.map +1 -1
  43. package/src/lib/server/media-serve-count.d.ts +50 -0
  44. package/src/lib/server/media-serve-count.js +62 -0
  45. package/src/lib/server/media-serve-count.js.map +1 -0
  46. package/src/lib/server/media-signing.d.ts +13 -2
  47. package/src/lib/server/media-signing.js +12 -8
  48. package/src/lib/server/media-signing.js.map +1 -1
  49. package/src/lib/server/media-tombstone.js +7 -5
  50. package/src/lib/server/media-tombstone.js.map +1 -1
  51. package/src/lib/server/media-variants.d.ts +151 -20
  52. package/src/lib/server/media-variants.js +204 -27
  53. package/src/lib/server/media-variants.js.map +1 -1
  54. package/src/lib/server/organizations.d.ts +102 -2
  55. package/src/lib/server/organizations.js +388 -58
  56. package/src/lib/server/organizations.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +11 -7
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/paged-sweeps.d.ts +51 -0
  60. package/src/lib/server/paged-sweeps.js +79 -0
  61. package/src/lib/server/paged-sweeps.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.d.ts +49 -9
  63. package/src/lib/server/payment-fraud-signal.js +192 -48
  64. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  65. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  66. package/src/lib/server/platform-marketing-consent.js +27 -11
  67. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  68. package/src/lib/server/render-monitor.d.ts +166 -0
  69. package/src/lib/server/render-monitor.js +412 -0
  70. package/src/lib/server/render-monitor.js.map +1 -0
  71. package/src/lib/server/risk-notice.d.ts +7 -0
  72. package/src/lib/server/risk-notice.js +6 -0
  73. package/src/lib/server/risk-notice.js.map +1 -1
  74. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  75. package/src/lib/server/serve-media-cdn.js +424 -70
  76. package/src/lib/server/serve-media-cdn.js.map +1 -1
  77. package/src/lib/server/site-icon-render.d.ts +68 -0
  78. package/src/lib/server/site-icon-render.js +157 -0
  79. package/src/lib/server/site-icon-render.js.map +1 -0
  80. package/src/lib/server/staff-seat.d.ts +50 -0
  81. package/src/lib/server/staff-seat.js +118 -0
  82. package/src/lib/server/staff-seat.js.map +1 -0
  83. package/src/lib/server/transfer-export.d.ts +46 -0
  84. package/src/lib/server/transfer-export.js +221 -0
  85. package/src/lib/server/transfer-export.js.map +1 -0
  86. package/src/lib/server/transfer-jobs.d.ts +385 -0
  87. package/src/lib/server/transfer-jobs.js +2368 -0
  88. package/src/lib/server/transfer-jobs.js.map +1 -0
  89. package/src/lib/server/transfer-packages.d.ts +127 -0
  90. package/src/lib/server/transfer-packages.js +1036 -0
  91. package/src/lib/server/transfer-packages.js.map +1 -0
  92. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  93. package/src/lib/server/upgrade-proposal.js +312 -0
  94. package/src/lib/server/upgrade-proposal.js.map +1 -0
  95. package/src/lib/server/upsert-contact.d.ts +17 -8
  96. package/src/lib/server/upsert-contact.js +82 -16
  97. package/src/lib/server/upsert-contact.js.map +1 -1
  98. package/src/lib/server/web-risk.d.ts +50 -9
  99. package/src/lib/server/web-risk.js +290 -155
  100. package/src/lib/server/web-risk.js.map +1 -1
  101. package/src/tenancy.d.ts +3 -3
  102. package/src/tenancy.js +5 -3
  103. package/src/tenancy.js.map +1 -1
  104. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  105. package/src/lib/server/campaign-attribution-store.js +0 -95
  106. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  107. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  108. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  109. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  110. package/src/lib/server/crm-email-activity.d.ts +0 -118
  111. package/src/lib/server/crm-email-activity.js +0 -148
  112. package/src/lib/server/crm-email-activity.js.map +0 -1
  113. package/src/lib/server/crm-next-activity.d.ts +0 -74
  114. package/src/lib/server/crm-next-activity.js +0 -194
  115. package/src/lib/server/crm-next-activity.js.map +0 -1
  116. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  117. package/src/lib/server/dataset-live-pages.js +0 -421
  118. package/src/lib/server/dataset-live-pages.js.map +0 -1
  119. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  120. package/src/lib/server/email-revenue-attribution.js +0 -321
  121. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { MEDIA_VARIANT_ENCODER_VERSION_FIELD } from '@aglyn/aglyn/app-utils/media-ref';
18
+ /**
19
+ * Lazy regeneration of an asset's delivery copies (AGL-3486).
20
+ *
21
+ * A change to the encoder — its quality, the width ladder, the display copy —
22
+ * has to reach every asset already in every library, and the obvious tool is
23
+ * a backfill script: a pass over the corpus that somebody has to remember to
24
+ * run, against production, after each deploy that changes the encoder. The
25
+ * last one (`backfill-media-variants.mjs`) was written, documented as
26
+ * outstanding and never converged.
27
+ *
28
+ * So the CDN converges the corpus itself. A media document records the
29
+ * encoder generation that made its copies
30
+ * (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`); a request that finds it older than
31
+ * `MEDIA_VARIANT_ENCODER_VERSION` is SERVED what exists — an old variant, or
32
+ * the original — and the asset is regenerated after the response. Every asset
33
+ * a visitor actually sees reaches the new encoder on its first view; one
34
+ * nobody requests costs nothing. An asset that never got variants at all,
35
+ * which is what the old backfill was for, is simply an asset at generation 1.
36
+ *
37
+ * ## Why after the response, and not inside it
38
+ *
39
+ * Regenerating means fetching the original (up to 15 MB) and making up to ten
40
+ * WebP encodes and a display copy: about two seconds for a large photo. A
41
+ * visitor should not wait for that, and nothing about the old bytes is wrong —
42
+ * only heavier — so the request is answered from what exists and the work
43
+ * runs in `after()`.
44
+ *
45
+ * ## One regeneration per asset, not one per visitor
46
+ *
47
+ * A popular image is requested many times before its first regeneration
48
+ * finishes. The first request takes a lease on the document in a transaction
49
+ * (`variantRegeneration.startedAtMs`); every other request in the lease window
50
+ * sees it on the snapshot it already read and schedules nothing, so the lease
51
+ * costs one transaction per asset, not one per request. A failure records the
52
+ * generation it failed at, so a source `sharp` cannot decode is tried once per
53
+ * encoder generation rather than once per minute forever.
54
+ */
55
+ /** How long a regeneration's lease keeps others from starting one. */
56
+ export declare const MEDIA_REGENERATION_LEASE_MS: number;
57
+ /** The document field holding the lease and any failure. */
58
+ export declare const MEDIA_REGENERATION_FIELD = "variantRegeneration";
59
+ export interface MediaRegenerationState {
60
+ /** When the current (or last) regeneration took its lease. */
61
+ startedAtMs?: number;
62
+ /** The encoder generation a regeneration last FAILED at. */
63
+ failedVersion?: number;
64
+ /** Why, kept short — the same contract as `variantsError`. */
65
+ error?: string;
66
+ }
67
+ /** The fields of a media document this decision reads, among any others. */
68
+ export interface MediaRegenerationFacts {
69
+ [field: string]: unknown;
70
+ contentType?: unknown;
71
+ cdnPath?: unknown;
72
+ sizeBytes?: unknown;
73
+ width?: unknown;
74
+ [MEDIA_VARIANT_ENCODER_VERSION_FIELD]?: unknown;
75
+ [MEDIA_REGENERATION_FIELD]?: unknown;
76
+ }
77
+ /**
78
+ * Whether this asset's delivery copies should be regenerated now.
79
+ *
80
+ * Pure, and decided from the snapshot the CDN already holds, so a request
81
+ * that does not need to regenerate reads nothing more.
82
+ *
83
+ * - Only assets the generator would have worked on at upload: an image with a
84
+ * CDN path (the `mediaCdn` entitlement and a public asset, the same gate the
85
+ * upload routes apply), of a type that has variants or a display copy, no
86
+ * larger than the generator will fetch back.
87
+ * - Only when the recorded generation is older than the current one.
88
+ * - Not while another regeneration holds the lease, and not again at a
89
+ * generation that already failed.
90
+ */
91
+ export declare function mediaDeliveryCopiesStale(facts: MediaRegenerationFacts, nowMs?: number): boolean;
92
+ /** The slice of a Storage bucket this needs. */
93
+ export interface MediaRegenerationBucket {
94
+ file(path: string): {
95
+ download(): Promise<[Buffer]>;
96
+ save(bytes: Buffer, options: {
97
+ contentType: string;
98
+ metadata: {
99
+ cacheControl: string;
100
+ };
101
+ }): Promise<unknown>;
102
+ delete(): Promise<unknown>;
103
+ };
104
+ }
105
+ export type MediaRegenerationResult = 'regenerated' | 'not-stale' | 'changed' | 'failed';
106
+ /**
107
+ * Regenerate one asset's delivery copies with the current encoder.
108
+ *
109
+ * `basePath` is the original's object path as the CDN resolved it — already
110
+ * proved to be inside the asset's own scope (AGL-1881) — and every object this
111
+ * writes or deletes is derived from it.
112
+ *
113
+ * Ordering, and why:
114
+ *
115
+ * 1. **Lease**, in a transaction that re-reads the document: two requests that
116
+ * both saw it stale cannot both start.
117
+ * 2. **Generate and save** the new copies. Their paths are the old ones, so a
118
+ * request in this window may receive new bytes under an old validator —
119
+ * harmless, both are the same picture.
120
+ * 3. **Record**, in a transaction that refuses when the content hash moved: a
121
+ * replace during the work owns the document, and copies of the bytes it
122
+ * replaced must not be recorded over it.
123
+ * 4. **Delete** what the new set no longer names — a width the new encoder
124
+ * skipped, a display copy the new rules do not need — only once the
125
+ * document stops naming them, so the CDN never points at a deleted object.
126
+ */
127
+ export declare function regenerateMediaDeliveryCopies(options: {
128
+ docRef: FirebaseFirestore.DocumentReference;
129
+ bucket: MediaRegenerationBucket;
130
+ basePath: string;
131
+ nowMs?: number;
132
+ }): Promise<MediaRegenerationResult>;
@@ -0,0 +1,216 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { FieldValue } from "firebase-admin/firestore";
18
+ import { MEDIA_VARIANT_ENCODER_VERSION, MEDIA_VARIANT_ENCODER_VERSION_FIELD, mediaDisplayObjectPath, mediaVariantEncoderVersionOf } from "@aglyn/aglyn/app-utils/media-ref";
19
+ import { generateMediaVariants, MEDIA_DISPLAY_TYPES, MEDIA_TYPES_WITHOUT_VARIANTS, MEDIA_VARIANT_SOURCE_MAX_BYTES, mediaVariantDocFields, mediaVariantWidthsFor } from "./media-variants.js";
20
+ /**
21
+ * Lazy regeneration of an asset's delivery copies (AGL-3486).
22
+ *
23
+ * A change to the encoder — its quality, the width ladder, the display copy —
24
+ * has to reach every asset already in every library, and the obvious tool is
25
+ * a backfill script: a pass over the corpus that somebody has to remember to
26
+ * run, against production, after each deploy that changes the encoder. The
27
+ * last one (`backfill-media-variants.mjs`) was written, documented as
28
+ * outstanding and never converged.
29
+ *
30
+ * So the CDN converges the corpus itself. A media document records the
31
+ * encoder generation that made its copies
32
+ * (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`); a request that finds it older than
33
+ * `MEDIA_VARIANT_ENCODER_VERSION` is SERVED what exists — an old variant, or
34
+ * the original — and the asset is regenerated after the response. Every asset
35
+ * a visitor actually sees reaches the new encoder on its first view; one
36
+ * nobody requests costs nothing. An asset that never got variants at all,
37
+ * which is what the old backfill was for, is simply an asset at generation 1.
38
+ *
39
+ * ## Why after the response, and not inside it
40
+ *
41
+ * Regenerating means fetching the original (up to 15 MB) and making up to ten
42
+ * WebP encodes and a display copy: about two seconds for a large photo. A
43
+ * visitor should not wait for that, and nothing about the old bytes is wrong —
44
+ * only heavier — so the request is answered from what exists and the work
45
+ * runs in `after()`.
46
+ *
47
+ * ## One regeneration per asset, not one per visitor
48
+ *
49
+ * A popular image is requested many times before its first regeneration
50
+ * finishes. The first request takes a lease on the document in a transaction
51
+ * (`variantRegeneration.startedAtMs`); every other request in the lease window
52
+ * sees it on the snapshot it already read and schedules nothing, so the lease
53
+ * costs one transaction per asset, not one per request. A failure records the
54
+ * generation it failed at, so a source `sharp` cannot decode is tried once per
55
+ * encoder generation rather than once per minute forever.
56
+ */ /** How long a regeneration's lease keeps others from starting one. */ export const MEDIA_REGENERATION_LEASE_MS = 2 * 60 * 1000;
57
+ /** The document field holding the lease and any failure. */ export const MEDIA_REGENERATION_FIELD = 'variantRegeneration';
58
+ /**
59
+ * Whether this asset's delivery copies should be regenerated now.
60
+ *
61
+ * Pure, and decided from the snapshot the CDN already holds, so a request
62
+ * that does not need to regenerate reads nothing more.
63
+ *
64
+ * - Only assets the generator would have worked on at upload: an image with a
65
+ * CDN path (the `mediaCdn` entitlement and a public asset, the same gate the
66
+ * upload routes apply), of a type that has variants or a display copy, no
67
+ * larger than the generator will fetch back.
68
+ * - Only when the recorded generation is older than the current one.
69
+ * - Not while another regeneration holds the lease, and not again at a
70
+ * generation that already failed.
71
+ */ export function mediaDeliveryCopiesStale(facts, nowMs = Date.now()) {
72
+ var _facts_sizeBytes, _facts_MEDIA_REGENERATION_FIELD;
73
+ const contentType = typeof facts.contentType === 'string' ? facts.contentType : '';
74
+ if (!contentType.startsWith('image/')) return false;
75
+ if (MEDIA_TYPES_WITHOUT_VARIANTS.has(contentType)) return false;
76
+ if (typeof facts.cdnPath !== 'string' || !facts.cdnPath) return false;
77
+ if (mediaVariantEncoderVersionOf(facts[MEDIA_VARIANT_ENCODER_VERSION_FIELD]) >= MEDIA_VARIANT_ENCODER_VERSION) {
78
+ return false;
79
+ }
80
+ const sizeBytes = Number((_facts_sizeBytes = facts.sizeBytes) != null ? _facts_sizeBytes : 0);
81
+ if (!Number.isFinite(sizeBytes) || sizeBytes > MEDIA_VARIANT_SOURCE_MAX_BYTES) {
82
+ return false;
83
+ }
84
+ const sourceWidth = typeof facts.width === 'number' && facts.width > 0 ? facts.width : null;
85
+ const hasWork = mediaVariantWidthsFor({
86
+ contentType,
87
+ sourceWidth
88
+ }).length > 0 || MEDIA_DISPLAY_TYPES.has(contentType);
89
+ if (!hasWork) return false;
90
+ const state = (_facts_MEDIA_REGENERATION_FIELD = facts[MEDIA_REGENERATION_FIELD]) != null ? _facts_MEDIA_REGENERATION_FIELD : {};
91
+ if (state.failedVersion === MEDIA_VARIANT_ENCODER_VERSION) return false;
92
+ if (typeof state.startedAtMs === 'number' && nowMs - state.startedAtMs < MEDIA_REGENERATION_LEASE_MS) {
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ /**
98
+ * Regenerate one asset's delivery copies with the current encoder.
99
+ *
100
+ * `basePath` is the original's object path as the CDN resolved it — already
101
+ * proved to be inside the asset's own scope (AGL-1881) — and every object this
102
+ * writes or deletes is derived from it.
103
+ *
104
+ * Ordering, and why:
105
+ *
106
+ * 1. **Lease**, in a transaction that re-reads the document: two requests that
107
+ * both saw it stale cannot both start.
108
+ * 2. **Generate and save** the new copies. Their paths are the old ones, so a
109
+ * request in this window may receive new bytes under an old validator —
110
+ * harmless, both are the same picture.
111
+ * 3. **Record**, in a transaction that refuses when the content hash moved: a
112
+ * replace during the work owns the document, and copies of the bytes it
113
+ * replaced must not be recorded over it.
114
+ * 4. **Delete** what the new set no longer names — a width the new encoder
115
+ * skipped, a display copy the new rules do not need — only once the
116
+ * document stops naming them, so the CDN never points at a deleted object.
117
+ */ export async function regenerateMediaDeliveryCopies(options) {
118
+ var _options_nowMs;
119
+ const { docRef, bucket, basePath } = options;
120
+ const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
121
+ const { firestore } = docRef;
122
+ const leased = await firestore.runTransaction(async (transaction)=>{
123
+ var _snapshot_data, _data_MEDIA_REGENERATION_FIELD;
124
+ const snapshot = await transaction.get(docRef);
125
+ const data = snapshot.exists ? (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {} : null;
126
+ if (!data || data['deletedAt'] || !mediaDeliveryCopiesStale(data, nowMs)) {
127
+ return null;
128
+ }
129
+ const previous = (_data_MEDIA_REGENERATION_FIELD = data[MEDIA_REGENERATION_FIELD]) != null ? _data_MEDIA_REGENERATION_FIELD : {};
130
+ transaction.update(docRef, {
131
+ [MEDIA_REGENERATION_FIELD]: _extends({}, previous, {
132
+ startedAtMs: nowMs
133
+ })
134
+ });
135
+ return data;
136
+ });
137
+ if (!leased) return 'not-stale';
138
+ const contentType = String(leased['contentType']);
139
+ const contentHash = leased['contentHash'];
140
+ const previousVariants = Array.isArray(leased['variants']) ? leased['variants'] : [];
141
+ const previousDisplay = Boolean(leased['display']);
142
+ const saveVariant = (path, bytes, type)=>bucket.file(path).save(bytes, {
143
+ contentType: type,
144
+ metadata: {
145
+ cacheControl: 'public, max-age=31536000, immutable'
146
+ }
147
+ }).then(()=>undefined);
148
+ let outcome;
149
+ try {
150
+ const [source] = await bucket.file(basePath).download();
151
+ outcome = await generateMediaVariants({
152
+ buffer: source,
153
+ contentType,
154
+ sourceWidth: typeof leased['width'] === 'number' ? leased['width'] : null,
155
+ objectPath: basePath,
156
+ saveVariant,
157
+ display: true
158
+ });
159
+ } catch (error) {
160
+ outcome = {
161
+ variants: [],
162
+ error: `source download failed — ${error instanceof Error ? error.message : String(error)}`.slice(0, 300)
163
+ };
164
+ }
165
+ if (outcome.error) {
166
+ // The widths that DID land are real objects at the current generation and
167
+ // the ones that did not are still the old encode, so the document names
168
+ // both; the generation stays where it was, and the failure is recorded so
169
+ // it is not retried until the encoder changes again.
170
+ const written = new Set(outcome.variants);
171
+ await firestore.runTransaction(async (transaction)=>{
172
+ var _fresh_data;
173
+ const fresh = await transaction.get(docRef);
174
+ if (!fresh.exists || ((_fresh_data = fresh.data()) == null ? void 0 : _fresh_data['contentHash']) !== contentHash) return;
175
+ transaction.update(docRef, _extends({
176
+ variants: [
177
+ ...new Set([
178
+ ...previousVariants,
179
+ ...written
180
+ ])
181
+ ].sort((a, b)=>a - b)
182
+ }, outcome.display ? {
183
+ display: outcome.display
184
+ } : {}, {
185
+ variantsError: outcome.error,
186
+ [MEDIA_REGENERATION_FIELD]: {
187
+ startedAtMs: nowMs,
188
+ failedVersion: MEDIA_VARIANT_ENCODER_VERSION,
189
+ error: outcome.error
190
+ }
191
+ }));
192
+ });
193
+ return 'failed';
194
+ }
195
+ const recorded = await firestore.runTransaction(async (transaction)=>{
196
+ var _fresh_data;
197
+ const fresh = await transaction.get(docRef);
198
+ if (!fresh.exists || ((_fresh_data = fresh.data()) == null ? void 0 : _fresh_data['contentHash']) !== contentHash) return false;
199
+ transaction.update(docRef, _extends({}, mediaVariantDocFields(outcome), {
200
+ variantsError: FieldValue.delete(),
201
+ [MEDIA_REGENERATION_FIELD]: FieldValue.delete()
202
+ }));
203
+ return true;
204
+ });
205
+ if (!recorded) return 'changed';
206
+ const kept = new Set(outcome.variants);
207
+ await Promise.all([
208
+ ...previousVariants.filter((width)=>!kept.has(width)).map((width)=>bucket.file(`${basePath}__w${width}.webp`).delete().catch(()=>undefined)),
209
+ ...previousDisplay && !outcome.display ? [
210
+ bucket.file(mediaDisplayObjectPath(basePath)).delete().catch(()=>undefined)
211
+ ] : []
212
+ ]);
213
+ return 'regenerated';
214
+ }
215
+
216
+ //# sourceMappingURL=media-delivery-regeneration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-delivery-regeneration.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 } from 'firebase-admin/firestore'\nimport {\n MEDIA_VARIANT_ENCODER_VERSION,\n MEDIA_VARIANT_ENCODER_VERSION_FIELD,\n mediaDisplayObjectPath,\n mediaVariantEncoderVersionOf,\n} from '@aglyn/aglyn/app-utils/media-ref'\n\nimport {\n generateMediaVariants,\n MEDIA_DISPLAY_TYPES,\n MEDIA_TYPES_WITHOUT_VARIANTS,\n MEDIA_VARIANT_SOURCE_MAX_BYTES,\n mediaVariantDocFields,\n mediaVariantWidthsFor,\n type SaveMediaDerivedObject,\n} from './media-variants'\n\n/**\n * Lazy regeneration of an asset's delivery copies (AGL-3486).\n *\n * A change to the encoder — its quality, the width ladder, the display copy —\n * has to reach every asset already in every library, and the obvious tool is\n * a backfill script: a pass over the corpus that somebody has to remember to\n * run, against production, after each deploy that changes the encoder. The\n * last one (`backfill-media-variants.mjs`) was written, documented as\n * outstanding and never converged.\n *\n * So the CDN converges the corpus itself. A media document records the\n * encoder generation that made its copies\n * (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`); a request that finds it older than\n * `MEDIA_VARIANT_ENCODER_VERSION` is SERVED what exists — an old variant, or\n * the original — and the asset is regenerated after the response. Every asset\n * a visitor actually sees reaches the new encoder on its first view; one\n * nobody requests costs nothing. An asset that never got variants at all,\n * which is what the old backfill was for, is simply an asset at generation 1.\n *\n * ## Why after the response, and not inside it\n *\n * Regenerating means fetching the original (up to 15 MB) and making up to ten\n * WebP encodes and a display copy: about two seconds for a large photo. A\n * visitor should not wait for that, and nothing about the old bytes is wrong —\n * only heavier — so the request is answered from what exists and the work\n * runs in `after()`.\n *\n * ## One regeneration per asset, not one per visitor\n *\n * A popular image is requested many times before its first regeneration\n * finishes. The first request takes a lease on the document in a transaction\n * (`variantRegeneration.startedAtMs`); every other request in the lease window\n * sees it on the snapshot it already read and schedules nothing, so the lease\n * costs one transaction per asset, not one per request. A failure records the\n * generation it failed at, so a source `sharp` cannot decode is tried once per\n * encoder generation rather than once per minute forever.\n */\n\n/** How long a regeneration's lease keeps others from starting one. */\nexport const MEDIA_REGENERATION_LEASE_MS = 2 * 60 * 1000\n\n/** The document field holding the lease and any failure. */\nexport const MEDIA_REGENERATION_FIELD = 'variantRegeneration'\n\nexport interface MediaRegenerationState {\n /** When the current (or last) regeneration took its lease. */\n startedAtMs?: number\n /** The encoder generation a regeneration last FAILED at. */\n failedVersion?: number\n /** Why, kept short — the same contract as `variantsError`. */\n error?: string\n}\n\n/** The fields of a media document this decision reads, among any others. */\nexport interface MediaRegenerationFacts {\n [field: string]: unknown\n contentType?: unknown\n cdnPath?: unknown\n sizeBytes?: unknown\n width?: unknown\n [MEDIA_VARIANT_ENCODER_VERSION_FIELD]?: unknown\n [MEDIA_REGENERATION_FIELD]?: unknown\n}\n\n/**\n * Whether this asset's delivery copies should be regenerated now.\n *\n * Pure, and decided from the snapshot the CDN already holds, so a request\n * that does not need to regenerate reads nothing more.\n *\n * - Only assets the generator would have worked on at upload: an image with a\n * CDN path (the `mediaCdn` entitlement and a public asset, the same gate the\n * upload routes apply), of a type that has variants or a display copy, no\n * larger than the generator will fetch back.\n * - Only when the recorded generation is older than the current one.\n * - Not while another regeneration holds the lease, and not again at a\n * generation that already failed.\n */\nexport function mediaDeliveryCopiesStale(\n facts: MediaRegenerationFacts,\n nowMs: number = Date.now(),\n): boolean {\n const contentType = typeof facts.contentType === 'string' ? facts.contentType : ''\n if (!contentType.startsWith('image/')) return false\n if (MEDIA_TYPES_WITHOUT_VARIANTS.has(contentType)) return false\n if (typeof facts.cdnPath !== 'string' || !facts.cdnPath) return false\n if (\n mediaVariantEncoderVersionOf(facts[MEDIA_VARIANT_ENCODER_VERSION_FIELD]) >=\n MEDIA_VARIANT_ENCODER_VERSION\n ) {\n return false\n }\n const sizeBytes = Number(facts.sizeBytes ?? 0)\n if (!Number.isFinite(sizeBytes) || sizeBytes > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n return false\n }\n const sourceWidth =\n typeof facts.width === 'number' && facts.width > 0 ? facts.width : null\n const hasWork =\n mediaVariantWidthsFor({ contentType, sourceWidth }).length > 0 ||\n MEDIA_DISPLAY_TYPES.has(contentType)\n if (!hasWork) return false\n const state = (facts[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n if (state.failedVersion === MEDIA_VARIANT_ENCODER_VERSION) return false\n if (\n typeof state.startedAtMs === 'number' &&\n nowMs - state.startedAtMs < MEDIA_REGENERATION_LEASE_MS\n ) {\n return false\n }\n return true\n}\n\n/** The slice of a Storage bucket this needs. */\nexport interface MediaRegenerationBucket {\n file(path: string): {\n download(): Promise<[Buffer]>\n save(\n bytes: Buffer,\n options: { contentType: string; metadata: { cacheControl: string } },\n ): Promise<unknown>\n delete(): Promise<unknown>\n }\n}\n\nexport type MediaRegenerationResult =\n | 'regenerated'\n | 'not-stale'\n | 'changed'\n | 'failed'\n\n/**\n * Regenerate one asset's delivery copies with the current encoder.\n *\n * `basePath` is the original's object path as the CDN resolved it — already\n * proved to be inside the asset's own scope (AGL-1881) — and every object this\n * writes or deletes is derived from it.\n *\n * Ordering, and why:\n *\n * 1. **Lease**, in a transaction that re-reads the document: two requests that\n * both saw it stale cannot both start.\n * 2. **Generate and save** the new copies. Their paths are the old ones, so a\n * request in this window may receive new bytes under an old validator —\n * harmless, both are the same picture.\n * 3. **Record**, in a transaction that refuses when the content hash moved: a\n * replace during the work owns the document, and copies of the bytes it\n * replaced must not be recorded over it.\n * 4. **Delete** what the new set no longer names — a width the new encoder\n * skipped, a display copy the new rules do not need — only once the\n * document stops naming them, so the CDN never points at a deleted object.\n */\nexport async function regenerateMediaDeliveryCopies(options: {\n docRef: FirebaseFirestore.DocumentReference\n bucket: MediaRegenerationBucket\n basePath: string\n nowMs?: number\n}): Promise<MediaRegenerationResult> {\n const { docRef, bucket, basePath } = options\n const nowMs = options.nowMs ?? Date.now()\n const { firestore } = docRef\n\n const leased = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(docRef)\n const data = snapshot.exists ? (snapshot.data() ?? {}) : null\n if (!data || data['deletedAt'] || !mediaDeliveryCopiesStale(data, nowMs)) {\n return null\n }\n const previous = (data[MEDIA_REGENERATION_FIELD] ?? {}) as MediaRegenerationState\n transaction.update(docRef, {\n [MEDIA_REGENERATION_FIELD]: { ...previous, startedAtMs: nowMs },\n })\n return data\n })\n if (!leased) return 'not-stale'\n\n const contentType = String(leased['contentType'])\n const contentHash = leased['contentHash']\n const previousVariants: number[] = Array.isArray(leased['variants'])\n ? (leased['variants'] as number[])\n : []\n const previousDisplay = Boolean(leased['display'])\n\n const saveVariant: SaveMediaDerivedObject = (path, bytes, type) =>\n bucket\n .file(path)\n .save(bytes, {\n contentType: type,\n metadata: { cacheControl: 'public, max-age=31536000, immutable' },\n })\n .then(() => undefined)\n\n let outcome: Awaited<ReturnType<typeof generateMediaVariants>>\n try {\n const [source] = await bucket.file(basePath).download()\n outcome = await generateMediaVariants({\n buffer: source,\n contentType,\n sourceWidth:\n typeof leased['width'] === 'number' ? (leased['width'] as number) : null,\n objectPath: basePath,\n saveVariant,\n display: true,\n })\n } catch (error) {\n outcome = {\n variants: [],\n error: `source download failed — ${error instanceof Error ? error.message : String(error)}`.slice(0, 300),\n }\n }\n\n if (outcome.error) {\n // The widths that DID land are real objects at the current generation and\n // the ones that did not are still the old encode, so the document names\n // both; the generation stays where it was, and the failure is recorded so\n // it is not retried until the encoder changes again.\n const written = new Set(outcome.variants)\n await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return\n transaction.update(docRef, {\n variants: [...new Set([...previousVariants, ...written])].sort((a, b) => a - b),\n // A display copy that landed before the failure is a real object too.\n ...(outcome.display ? { display: outcome.display } : {}),\n variantsError: outcome.error,\n [MEDIA_REGENERATION_FIELD]: {\n startedAtMs: nowMs,\n failedVersion: MEDIA_VARIANT_ENCODER_VERSION,\n error: outcome.error,\n } satisfies MediaRegenerationState,\n })\n })\n return 'failed'\n }\n\n const recorded = await firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.data()?.['contentHash'] !== contentHash) return false\n transaction.update(docRef, {\n ...mediaVariantDocFields(outcome),\n variantsError: FieldValue.delete(),\n [MEDIA_REGENERATION_FIELD]: FieldValue.delete(),\n })\n return true\n })\n if (!recorded) return 'changed'\n\n const kept = new Set(outcome.variants)\n await Promise.all([\n ...previousVariants\n .filter((width) => !kept.has(width))\n .map((width) =>\n bucket.file(`${basePath}__w${width}.webp`).delete().catch(() => undefined),\n ),\n ...(previousDisplay && !outcome.display\n ? [bucket.file(mediaDisplayObjectPath(basePath)).delete().catch(() => undefined)]\n : []),\n ])\n return 'regenerated'\n}\n"],"names":["FieldValue","MEDIA_VARIANT_ENCODER_VERSION","MEDIA_VARIANT_ENCODER_VERSION_FIELD","mediaDisplayObjectPath","mediaVariantEncoderVersionOf","generateMediaVariants","MEDIA_DISPLAY_TYPES","MEDIA_TYPES_WITHOUT_VARIANTS","MEDIA_VARIANT_SOURCE_MAX_BYTES","mediaVariantDocFields","mediaVariantWidthsFor","MEDIA_REGENERATION_LEASE_MS","MEDIA_REGENERATION_FIELD","mediaDeliveryCopiesStale","facts","nowMs","Date","now","contentType","startsWith","has","cdnPath","sizeBytes","Number","isFinite","sourceWidth","width","hasWork","length","state","failedVersion","startedAtMs","regenerateMediaDeliveryCopies","options","docRef","bucket","basePath","firestore","leased","runTransaction","transaction","snapshot","data","get","exists","previous","update","String","contentHash","previousVariants","Array","isArray","previousDisplay","Boolean","saveVariant","path","bytes","type","file","save","metadata","cacheControl","then","undefined","outcome","source","download","buffer","objectPath","display","error","variants","Error","message","slice","written","Set","fresh","sort","a","b","variantsError","recorded","delete","kept","Promise","all","filter","map","catch"],"mappings":"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"}
@@ -147,6 +147,8 @@ export interface MediaDeliveryRedirect {
147
147
  location: string;
148
148
  expiresAtMs: number;
149
149
  key: string;
150
+ /** The copy's size, which the bandwidth meter counts a sitting at. */
151
+ sizeBytes: number;
150
152
  }
151
153
  /**
152
154
  * The delivery URL for one representation of one asset, or null when the
@@ -241,7 +241,8 @@ const hostOrgCache = new Map();
241
241
  return {
242
242
  location,
243
243
  expiresAtMs,
244
- key: copy.key
244
+ key: copy.key,
245
+ sizeBytes: copy.sizeBytes
245
246
  };
246
247
  } catch (error) {
247
248
  console.error('[media-delivery] could not mint a delivery URL', JSON.stringify({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-delivery.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 { Readable } from 'node:stream'\nimport {\n type MediaVideoRendition,\n mediaRenditionObjectPath,\n parseMediaRenditions,\n} from '@aglyn/aglyn/app-utils/media-ref'\nimport type { ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags'\nimport {\n type MediaDeliveryClaims,\n type MediaDeliveryProvider,\n mediaDeliveryProvider,\n} from '@aglyn/aglyn/plugin-manager/media-delivery-provider'\nimport { firebaseAdmin } from './firebase-admin'\nimport { MEDIA_SIGNATURE_MAX_TTL_MS } from './media-signing'\nimport { mediaStoragePathInScope } from './media-storage-path'\nimport { isServerReleaseFlagOnForOrg } from './release-flags'\n\n/**\n * Video delivered from the configured delivery provider (AGL-2824): the\n * platform's half.\n *\n * `serveMediaCdn` keeps every access decision it has always made. When a\n * video request passes all of them, and this module finds a current copy of\n * the requested representation at the delivery provider, the CDN answers\n * with a `302` to a short-lived signed URL on the provider's host instead of\n * streaming the bytes itself. The commerce stream mints the same URL for a\n * buyer. Everything else — images, posters, downloads, and every video with\n * no current copy — serves exactly as it did.\n *\n * ## Three switches, all of which must be on\n *\n * 1. **A provider.** A plugin registers one against core's contract\n * (`media-delivery-provider`), and it must be configured for the\n * capability in question: `store` to copy or remove, `deliver` to mint.\n * With none, every function here returns before any I/O.\n * 2. **The release flag**, per org: {@link MEDIA_DELIVERY_RELEASE_FLAG}. It\n * gates the COPY as well as the redirect, because a copy is customer data\n * at the provider. Removal is never gated: a copy made while the flag was\n * on must still go when its asset does.\n * 3. **A current copy**, recorded on the media document under\n * {@link MEDIA_DELIVERY_COPIES_FIELD}. A copy is current when it was made\n * from the asset's present `contentHash` and its key is the one this\n * module derives for the asset. A replace changes the hash, so every copy\n * of the previous bytes stops being served in the same write, before\n * anything at the provider is removed.\n *\n * ## The platform stays the system of record\n *\n * The bucket keeps every byte it kept before, and the storage counter moves\n * exactly as it did: a copy's size is recorded on the copy and never added to\n * `counters/media`, so storage is billed once. Making the provider's copy the\n * only one is a later step, and the storage cost model depends on it (see\n * AGL-2825); nothing here deletes a platform object.\n *\n * ## Keys carry the content hash\n *\n * `{hosts|orgs}/{scopeId}/{mediaId}/{sourceHash}/{representation}/{objectHash}`.\n * The source hash is the asset's `contentHash`; the object hash is the\n * copied object's own digest (for the master the two are the same). A\n * replaced file or a re-encoded rendition therefore gets a new key, so a URL\n * minted for old bytes can never be answered with new ones, and the whole\n * asset is one prefix for removal. The record is written by the server only,\n * but the key it holds is checked against the derivation before it is used:\n * a record copied onto another document names a key that is not that\n * document's own, and is ignored.\n */\n\n/** The per-org release flag both halves — copy and redirect — ask. */\nexport const MEDIA_DELIVERY_RELEASE_FLAG: ReleaseFlagKey = 'release_video_delivery'\n\n/** The media document field that records the copies at the provider. */\nexport const MEDIA_DELIVERY_COPIES_FIELD = 'deliveryCopies'\n\n/** The representation name of the uploaded file itself. */\nexport const MEDIA_DELIVERY_MASTER = 'master'\n\n/**\n * The shortest delivery URL lifetime. A delivery URL is a bearer capability,\n * so it is short, but a player sends its range requests to the URL it was\n * redirected to, so it must outlive a sitting: seeking, pausing and resuming\n * all reuse it.\n */\nexport const MEDIA_DELIVERY_MIN_TTL_MS = 15 * 60 * 1000\n\n/** The lifetime when the film's duration is unknown. */\nexport const MEDIA_DELIVERY_DEFAULT_TTL_MS = 60 * 60 * 1000\n\n/** The longest lifetime, shared with every other signed media link. */\nexport const MEDIA_DELIVERY_MAX_TTL_MS = MEDIA_SIGNATURE_MAX_TTL_MS\n\n/**\n * How long a delivery URL for a film of `durationMs` lives: twice the film,\n * so a viewer who pauses halfway still finishes it, between\n * {@link MEDIA_DELIVERY_MIN_TTL_MS} and {@link MEDIA_DELIVERY_MAX_TTL_MS}.\n */\nexport function mediaDeliveryTtlMs(durationMs: unknown): number {\n const duration = Number(durationMs)\n if (!Number.isFinite(duration) || duration <= 0) {\n return MEDIA_DELIVERY_DEFAULT_TTL_MS\n }\n return Math.min(\n MEDIA_DELIVERY_MAX_TTL_MS,\n Math.max(MEDIA_DELIVERY_MIN_TTL_MS, Math.round(duration * 2)),\n )\n}\n\n/** The asset a copy belongs to. */\nexport interface MediaDeliveryAsset {\n collection: 'hosts' | 'orgs'\n scopeId: string\n mediaId: string\n}\n\n/** An id segment, as the CDN route accepts one. */\nconst ID_SEGMENT = /^[A-Za-z0-9_-]{1,64}$/\n/** A content digest: `contentHash` is 16 hex, a full digest up to 64. */\nconst DIGEST = /^[a-f0-9]{16,64}$/\n/** `master`, or `r-` and a rendition key. */\nconst REPRESENTATION = /^(?:master|r-[a-z0-9][a-z0-9-]{0,23})$/\n\n/** The representation a rendition (or the master, for none) is copied as. */\nexport function mediaDeliveryRepresentation(\n rendition: Pick<MediaVideoRendition, 'key'> | null | undefined,\n): string {\n return rendition ? `r-${rendition.key}` : MEDIA_DELIVERY_MASTER\n}\n\nfunction validAsset(asset: MediaDeliveryAsset): boolean {\n return (\n (asset.collection === 'hosts' || asset.collection === 'orgs') &&\n ID_SEGMENT.test(asset.scopeId) &&\n ID_SEGMENT.test(asset.mediaId)\n )\n}\n\n/**\n * The prefix every copy of one asset lives under, or null for an asset whose\n * identity does not fit the grammar. Ends in `/`, so one asset's prefix is\n * never another's.\n */\nexport function mediaDeliveryAssetPrefix(asset: MediaDeliveryAsset): string | null {\n if (!validAsset(asset)) return null\n return `${asset.collection}/${asset.scopeId}/${asset.mediaId}/`\n}\n\n/** The prefix every copy of one library lives under, for an erasure. */\nexport function mediaDeliveryScopePrefix(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n): string | null {\n if (collection !== 'hosts' && collection !== 'orgs') return null\n if (!ID_SEGMENT.test(scopeId)) return null\n return `${collection}/${scopeId}/`\n}\n\n/** The object key of one copy, or null when a part does not fit the grammar. */\nexport function mediaDeliveryObjectKey(\n asset: MediaDeliveryAsset,\n copy: { sourceHash: string; representation: string; objectHash: string },\n): string | null {\n const prefix = mediaDeliveryAssetPrefix(asset)\n if (!prefix) return null\n if (!DIGEST.test(copy.sourceHash) || !DIGEST.test(copy.objectHash)) return null\n if (!REPRESENTATION.test(copy.representation)) return null\n return `${prefix}${copy.sourceHash}/${copy.representation}/${copy.objectHash}`\n}\n\n/** One copy at the provider, as the media document records it. */\nexport interface MediaDeliveryCopy {\n key: string\n /** The asset's `contentHash` when the copy was made. */\n sourceHash: string\n /** The copied object's own digest. */\n objectHash: string\n contentType: string\n sizeBytes: number\n copiedAtMs: number\n}\n\n/** The recorded copies of one asset, by representation. */\nexport type MediaDeliveryCopies = Record<string, MediaDeliveryCopy>\n\n/**\n * The copies a media document records that belong to THIS asset: each entry\n * well formed, and its key exactly the one {@link mediaDeliveryObjectKey}\n * derives from its own fields. Anything else is dropped rather than trusted.\n */\nexport function parseMediaDeliveryCopies(\n raw: unknown,\n asset: MediaDeliveryAsset,\n): MediaDeliveryCopies {\n const copies: MediaDeliveryCopies = {}\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return copies\n for (const [representation, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!value || typeof value !== 'object') continue\n const entry = value as Record<string, unknown>\n const sourceHash = String(entry['sourceHash'] ?? '')\n const objectHash = String(entry['objectHash'] ?? '')\n const key = mediaDeliveryObjectKey(asset, { sourceHash, representation, objectHash })\n if (!key || entry['key'] !== key) continue\n const contentType = String(entry['contentType'] ?? '')\n if (!/^video\\/[\\w.+-]{1,64}$/.test(contentType)) continue\n const sizeBytes = Number(entry['sizeBytes'])\n copies[representation] = {\n key,\n sourceHash,\n objectHash,\n contentType,\n sizeBytes: Number.isFinite(sizeBytes) && sizeBytes > 0 ? Math.round(sizeBytes) : 0,\n copiedAtMs: Number(entry['copiedAtMs']) || 0,\n }\n }\n return copies\n}\n\n/** Whether a served type is one this module delivers: video, nothing else. */\nexport function isMediaDeliveryType(contentType: unknown): boolean {\n return String(contentType ?? '')\n .split(';')[0]\n .trim()\n .toLowerCase()\n .startsWith('video/')\n}\n\nfunction currentContentHash(document: { get(field: string): unknown }): string | null {\n const hash = document.get('contentHash')\n return typeof hash === 'string' && DIGEST.test(hash) ? hash : null\n}\n\n/*===========================================================================\n * The owning org, for the flag.\n *==========================================================================*/\n\nconst ORG_CACHE_TTL_MS = 60_000\nconst hostOrgCache = new Map<string, { at: number; orgId: string | null }>()\n\n/** Drops the host → org cache. Tests need it between cases. */\nexport function invalidateMediaDeliveryOrgCache(): void {\n hostOrgCache.clear()\n}\n\n/**\n * The org that owns a library: the id itself for an org library, and the\n * host's `hostIndex` entry for a site's. Cached for a minute, like the\n * release-flag reads it feeds; a failed read answers null, which the flag\n * treats as \"no org\" and so refuses anything short of a full release.\n */\nexport async function mediaDeliveryOrgIdFor(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n): Promise<string | null> {\n if (collection === 'orgs') return scopeId || null\n const hit = hostOrgCache.get(scopeId)\n if (hit && Date.now() - hit.at < ORG_CACHE_TTL_MS) return hit.orgId\n const orgId = await (async (): Promise<string | null> => {\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [index] = await firestore.getAll(\n firestore.collection('hostIndex').doc(scopeId),\n { fieldMask: ['orgId'] },\n )\n const value = index?.get('orgId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n })()\n hostOrgCache.set(scopeId, { at: Date.now(), orgId })\n return orgId\n}\n\n/** The flag verdict for one org. */\nexport function mediaDeliveryEnabledForOrg(orgId: string | null): Promise<boolean> {\n return isServerReleaseFlagOnForOrg(MEDIA_DELIVERY_RELEASE_FLAG, orgId)\n}\n\n/*===========================================================================\n * The redirect.\n *==========================================================================*/\n\nexport interface MediaDeliveryRedirect {\n location: string\n expiresAtMs: number\n key: string\n}\n\n/**\n * The delivery URL for one representation of one asset, or null when the\n * request must be served the way it always was.\n *\n * Called only after the caller has made every access decision. Null for: a\n * type that is not video; an asset with no current copy of this\n * representation; the flag off for the owning org; a lifetime that has\n * already run out; and a provider that fails to mint. The cheap answers come\n * first, so an asset with no copy costs no read at all.\n */\nexport async function mediaDeliveryRedirect(input: {\n provider: MediaDeliveryProvider\n asset: MediaDeliveryAsset\n document: { get(field: string): unknown }\n /** The rendition being served, or none for the master. */\n rendition?: Pick<MediaVideoRendition, 'key'> | null\n servedType: unknown\n claims: Omit<MediaDeliveryClaims, 'orgId'>\n /** The owning org, asked only once a current copy is found. */\n orgId: () => Promise<string | null>\n /** Defaults to the release flag. */\n deliveryEnabled?: (orgId: string | null) => Promise<boolean>\n /** The URL may not outlive this, e.g. the signature the request carried. */\n notAfterMs?: number\n /** A fixed lifetime; otherwise {@link mediaDeliveryTtlMs} of the film. */\n ttlMs?: number\n nowMs?: number\n}): Promise<MediaDeliveryRedirect | null> {\n if (!isMediaDeliveryType(input.servedType)) return null\n const sourceHash = currentContentHash(input.document)\n if (!sourceHash) return null\n const copies = parseMediaDeliveryCopies(\n input.document.get(MEDIA_DELIVERY_COPIES_FIELD),\n input.asset,\n )\n const copy = copies[mediaDeliveryRepresentation(input.rendition)]\n if (!copy || copy.sourceHash !== sourceHash) return null\n\n const orgId = await input.orgId()\n const enabled = await (input.deliveryEnabled ?? mediaDeliveryEnabledForOrg)(orgId)\n if (!enabled) return null\n\n const nowMs = input.nowMs ?? Date.now()\n const video = input.document.get('video') as { durationMs?: unknown } | undefined\n const lifetime = Math.min(\n MEDIA_DELIVERY_MAX_TTL_MS,\n input.ttlMs ?? mediaDeliveryTtlMs(video?.durationMs),\n )\n let expiresAtMs = nowMs + lifetime\n if (input.notAfterMs !== undefined) {\n // A bound that cannot be read is no bound, and a URL with none could\n // outlive what the caller was entitled to; serve from the platform.\n if (!Number.isFinite(input.notAfterMs)) return null\n expiresAtMs = Math.min(expiresAtMs, input.notAfterMs)\n }\n if (!(expiresAtMs > nowMs)) return null\n try {\n const location = await input.provider.deliveryUrl({\n key: copy.key,\n expiresAtMs,\n claims: { ...input.claims, orgId },\n })\n return { location, expiresAtMs, key: copy.key }\n } catch (error) {\n console.error(\n '[media-delivery] could not mint a delivery URL',\n JSON.stringify({ key: copy.key }),\n error,\n )\n return null\n }\n}\n\n/*===========================================================================\n * Copies.\n *==========================================================================*/\n\n/** The slice of a Storage file the copy reads. */\nexport interface MediaDeliverySourceFile {\n getMetadata(): Promise<unknown>\n createReadStream(): NodeJS.ReadableStream\n}\n\n/** The slice of a Storage bucket the copy reads. */\nexport interface MediaDeliverySourceBucket {\n file(path: string): MediaDeliverySourceFile\n}\n\nexport type MediaDeliverySyncStatus =\n | 'no-provider'\n | 'no-asset'\n | 'not-video'\n | 'no-hash'\n | 'disabled'\n | 'replaced'\n | 'synced'\n\nexport interface MediaDeliverySyncResult {\n status: MediaDeliverySyncStatus\n /** Representations copied by this call. */\n copied: string[]\n /** Representations whose current copy was already there. */\n kept: string[]\n /** Keys removed from the provider because nothing current names them. */\n removed: string[]\n /** Representations whose copy failed; they keep serving from the platform. */\n failed: string[]\n}\n\n/** The md5 Storage records for an object, as hex. */\nfunction md5Hex(metadata: Record<string, unknown>): string | null {\n const raw = metadata['md5Hash']\n if (typeof raw !== 'string' || !raw) return null\n const hex = Buffer.from(raw, 'base64').toString('hex')\n return DIGEST.test(hex) ? hex : null\n}\n\n/** One `getMetadata()` answer, unwrapped from the SDK's tuple. */\nasync function readMetadata(file: MediaDeliverySourceFile): Promise<Record<string, unknown>> {\n const response = await file.getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n return (metadata ?? {}) as Record<string, unknown>\n}\n\n/**\n * Brings the provider's copies of one video in line with its document: every\n * representation the document serves — the master and each recorded\n * rendition — gets a copy made from its current bytes, and a copy nothing\n * current names is removed.\n *\n * Every copy path runs through here: an upload's finalize, a replace, a\n * restore and the rendition producer. It is safe to run again at any time.\n * The master's copy is current when it was made from the asset's\n * `contentHash`; a rendition's when it was made from that hash AND from the\n * rendition object Storage holds now, since the producer can encode a key\n * again without the master changing.\n *\n * The record is written in a transaction that re-reads the document, and\n * only while the asset still has the content hash the copies were made from.\n * An asset replaced or deleted while its copies were uploading keeps that\n * state, and the copies just made are removed rather than recorded against\n * bytes they are not.\n */\nexport async function syncMediaDeliveryCopies(options: {\n docRef: FirebaseFirestore.DocumentReference\n asset: MediaDeliveryAsset\n bucket: MediaDeliverySourceBucket\n /** The owning org, when the caller has it; otherwise resolved. */\n orgId?: string | null\n /** Defaults to the registered provider configured to store. */\n provider?: MediaDeliveryProvider | null\n /** Defaults to the release flag. */\n deliveryEnabled?: (orgId: string | null) => Promise<boolean>\n nowMs?: () => number\n}): Promise<MediaDeliverySyncResult> {\n const result: MediaDeliverySyncResult = {\n status: 'synced',\n copied: [],\n kept: [],\n removed: [],\n failed: [],\n }\n const done = (status: MediaDeliverySyncStatus): MediaDeliverySyncResult => ({\n ...result,\n status,\n })\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return done('no-provider')\n const { asset, bucket, docRef } = options\n if (!mediaDeliveryAssetPrefix(asset)) return done('no-asset')\n\n const snapshot = await docRef.get()\n if (!snapshot.exists || snapshot.get('deletedAt')) return done('no-asset')\n if (!isMediaDeliveryType(snapshot.get('contentType'))) return done('not-video')\n const sourceHash = currentContentHash(snapshot)\n if (!sourceHash) return done('no-hash')\n\n const orgId =\n options.orgId === undefined\n ? await mediaDeliveryOrgIdFor(asset.collection, asset.scopeId)\n : options.orgId\n const enabled = await (options.deliveryEnabled ?? mediaDeliveryEnabledForOrg)(orgId)\n if (!enabled) return done('disabled')\n\n const now = options.nowMs ?? Date.now\n const basePath = mediaStoragePathInScope({\n storagePath: snapshot.get('storagePath'),\n base: `${asset.collection}/${asset.scopeId}`,\n mediaId: asset.mediaId,\n })\n const wanted: Array<{ objectPath: string; rendition: MediaVideoRendition | null }> = [\n { objectPath: basePath, rendition: null },\n ...parseMediaRenditions(snapshot.get('videoRenditions')).map((rendition) => ({\n objectPath: mediaRenditionObjectPath(basePath, rendition),\n rendition,\n })),\n ]\n\n const recorded = parseMediaDeliveryCopies(snapshot.get(MEDIA_DELIVERY_COPIES_FIELD), asset)\n const next: MediaDeliveryCopies = {}\n for (const { objectPath, rendition } of wanted) {\n const representation = mediaDeliveryRepresentation(rendition)\n const existing = recorded[representation]\n try {\n const file = bucket.file(objectPath)\n // The master's digest is on the document already, so a current copy of\n // it costs no Storage read. A rendition's is read every time.\n if (!rendition && existing?.sourceHash === sourceHash) {\n next[representation] = existing\n result.kept.push(representation)\n continue\n }\n const metadata = await readMetadata(file)\n const objectHash = rendition ? md5Hex(metadata) : sourceHash\n if (!objectHash) throw new Error('the object reports no digest')\n if (\n existing &&\n existing.sourceHash === sourceHash &&\n existing.objectHash === objectHash\n ) {\n next[representation] = existing\n result.kept.push(representation)\n continue\n }\n const sizeBytes = Number(metadata['size'])\n if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {\n throw new Error('the object reports no size')\n }\n const contentType = rendition\n ? rendition.contentType\n : String(metadata['contentType'] ?? snapshot.get('contentType') ?? '')\n if (!isMediaDeliveryType(contentType)) throw new Error('the object is not a video')\n const key = mediaDeliveryObjectKey(asset, { sourceHash, representation, objectHash })\n if (!key) throw new Error('no key fits the asset')\n await provider.putObject({\n key,\n body: Readable.toWeb(file.createReadStream() as Readable) as ReadableStream<Uint8Array>,\n contentLength: sizeBytes,\n contentType,\n })\n next[representation] = {\n key,\n sourceHash,\n objectHash,\n contentType,\n sizeBytes,\n copiedAtMs: now(),\n }\n result.copied.push(representation)\n } catch (error) {\n result.failed.push(representation)\n console.error(\n '[media-delivery] copy failed; it keeps serving from the platform',\n JSON.stringify({ ...asset, representation }),\n error,\n )\n }\n }\n\n const nextKeys = new Set(Object.values(next).map((copy) => copy.key))\n const retired = Object.values(recorded)\n .map((copy) => copy.key)\n .filter((key) => !nextKeys.has(key))\n const madeNow = result.copied\n .map((representation) => next[representation]?.key)\n .filter((key): key is string => Boolean(key))\n\n if (result.copied.length || retired.length) {\n const written = await docRef.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.get('deletedAt')) return false\n if (currentContentHash(fresh) !== sourceHash) return false\n transaction.update(docRef, { [MEDIA_DELIVERY_COPIES_FIELD]: next })\n return true\n })\n if (!written) {\n // Replaced or deleted while the copies were uploading. Nothing records\n // what was just made, so it goes rather than lingering unreferenced.\n await removeKeys(provider, madeNow, result)\n return done('replaced')\n }\n }\n await removeKeys(provider, retired, result)\n return done('synced')\n}\n\nasync function removeKeys(\n provider: MediaDeliveryProvider,\n keys: string[],\n result: Pick<MediaDeliverySyncResult, 'removed'>,\n): Promise<void> {\n for (const key of keys) {\n try {\n await provider.deleteObject(key)\n result.removed.push(key)\n } catch (error) {\n console.error('[media-delivery] could not remove a retired copy', JSON.stringify({ key }), error)\n }\n }\n}\n\n/**\n * Removes every copy of one asset from the provider: a delete, a takedown and\n * a replace all end the life of the bytes the copies were made from.\n *\n * By prefix rather than by the document's record, so a copy the record lost\n * track of goes too. Never gated on the flag, since copies made while it was\n * on must still go, and never throws: the caller's own action has already\n * happened and must not be reported as failed because a copy lingered. Null\n * when no provider is configured to store.\n */\nexport async function removeMediaDeliveryCopies(options: {\n asset: MediaDeliveryAsset\n provider?: MediaDeliveryProvider | null\n}): Promise<{ removed: number; failed: boolean } | null> {\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return null\n const prefix = mediaDeliveryAssetPrefix(options.asset)\n if (!prefix) return { removed: 0, failed: false }\n try {\n return { removed: await provider.deleteObjectsWithPrefix(prefix), failed: false }\n } catch (error) {\n console.error(\n '[media-delivery] could not remove an asset\\'s copies',\n JSON.stringify(options.asset),\n error,\n )\n return { removed: 0, failed: true }\n }\n}\n\n/**\n * Removes every copy a library holds at the provider — the erasure of a site\n * or an org. Same posture as {@link removeMediaDeliveryCopies}.\n */\nexport async function eraseMediaDeliveryScope(options: {\n collection: 'hosts' | 'orgs'\n scopeId: string\n provider?: MediaDeliveryProvider | null\n}): Promise<{ removed: number; failed: boolean } | null> {\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return null\n const prefix = mediaDeliveryScopePrefix(options.collection, options.scopeId)\n if (!prefix) return { removed: 0, failed: false }\n try {\n return { removed: await provider.deleteObjectsWithPrefix(prefix), failed: false }\n } catch (error) {\n console.error(\n '[media-delivery] could not erase a library\\'s copies',\n JSON.stringify({ collection: options.collection, scopeId: options.scopeId }),\n error,\n )\n return { removed: 0, failed: true }\n }\n}\n\n/**\n * A media document without its copy record, for a restore: the delete that\n * made the tombstone removed the copies, so the record describes objects\n * that are gone.\n */\nexport function withoutMediaDeliveryCopies(\n media: Record<string, unknown>,\n): Record<string, unknown> {\n if (!(MEDIA_DELIVERY_COPIES_FIELD in media)) return media\n const rest = { ...media }\n delete rest[MEDIA_DELIVERY_COPIES_FIELD]\n return rest\n}\n"],"names":["Readable","mediaRenditionObjectPath","parseMediaRenditions","mediaDeliveryProvider","firebaseAdmin","MEDIA_SIGNATURE_MAX_TTL_MS","mediaStoragePathInScope","isServerReleaseFlagOnForOrg","MEDIA_DELIVERY_RELEASE_FLAG","MEDIA_DELIVERY_COPIES_FIELD","MEDIA_DELIVERY_MASTER","MEDIA_DELIVERY_MIN_TTL_MS","MEDIA_DELIVERY_DEFAULT_TTL_MS","MEDIA_DELIVERY_MAX_TTL_MS","mediaDeliveryTtlMs","durationMs","duration","Number","isFinite","Math","min","max","round","ID_SEGMENT","DIGEST","REPRESENTATION","mediaDeliveryRepresentation","rendition","key","validAsset","asset","collection","test","scopeId","mediaId","mediaDeliveryAssetPrefix","mediaDeliveryScopePrefix","mediaDeliveryObjectKey","copy","prefix","sourceHash","objectHash","representation","parseMediaDeliveryCopies","raw","copies","Array","isArray","value","Object","entries","entry","String","contentType","sizeBytes","copiedAtMs","isMediaDeliveryType","split","trim","toLowerCase","startsWith","currentContentHash","document","hash","get","ORG_CACHE_TTL_MS","hostOrgCache","Map","invalidateMediaDeliveryOrgCache","clear","mediaDeliveryOrgIdFor","hit","Date","now","at","orgId","firestore","app","index","getAll","doc","fieldMask","set","mediaDeliveryEnabledForOrg","mediaDeliveryRedirect","input","servedType","enabled","deliveryEnabled","nowMs","video","lifetime","ttlMs","expiresAtMs","notAfterMs","undefined","location","provider","deliveryUrl","claims","error","console","JSON","stringify","md5Hex","metadata","hex","Buffer","from","toString","readMetadata","file","response","getMetadata","syncMediaDeliveryCopies","options","result","status","copied","kept","removed","failed","done","bucket","docRef","snapshot","exists","basePath","storagePath","base","wanted","objectPath","map","recorded","next","existing","push","Error","isSafeInteger","putObject","body","toWeb","createReadStream","contentLength","nextKeys","Set","values","retired","filter","has","madeNow","Boolean","length","written","runTransaction","transaction","fresh","update","removeKeys","keys","deleteObject","removeMediaDeliveryCopies","deleteObjectsWithPrefix","eraseMediaDeliveryScope","withoutMediaDeliveryCopies","media","rest"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-delivery.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 { Readable } from 'node:stream'\nimport {\n type MediaVideoRendition,\n mediaRenditionObjectPath,\n parseMediaRenditions,\n} from '@aglyn/aglyn/app-utils/media-ref'\nimport type { ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags'\nimport {\n type MediaDeliveryClaims,\n type MediaDeliveryProvider,\n mediaDeliveryProvider,\n} from '@aglyn/aglyn/plugin-manager/media-delivery-provider'\nimport { firebaseAdmin } from './firebase-admin'\nimport { MEDIA_SIGNATURE_MAX_TTL_MS } from './media-signing'\nimport { mediaStoragePathInScope } from './media-storage-path'\nimport { isServerReleaseFlagOnForOrg } from './release-flags'\n\n/**\n * Video delivered from the configured delivery provider (AGL-2824): the\n * platform's half.\n *\n * `serveMediaCdn` keeps every access decision it has always made. When a\n * video request passes all of them, and this module finds a current copy of\n * the requested representation at the delivery provider, the CDN answers\n * with a `302` to a short-lived signed URL on the provider's host instead of\n * streaming the bytes itself. The commerce stream mints the same URL for a\n * buyer. Everything else — images, posters, downloads, and every video with\n * no current copy — serves exactly as it did.\n *\n * ## Three switches, all of which must be on\n *\n * 1. **A provider.** A plugin registers one against core's contract\n * (`media-delivery-provider`), and it must be configured for the\n * capability in question: `store` to copy or remove, `deliver` to mint.\n * With none, every function here returns before any I/O.\n * 2. **The release flag**, per org: {@link MEDIA_DELIVERY_RELEASE_FLAG}. It\n * gates the COPY as well as the redirect, because a copy is customer data\n * at the provider. Removal is never gated: a copy made while the flag was\n * on must still go when its asset does.\n * 3. **A current copy**, recorded on the media document under\n * {@link MEDIA_DELIVERY_COPIES_FIELD}. A copy is current when it was made\n * from the asset's present `contentHash` and its key is the one this\n * module derives for the asset. A replace changes the hash, so every copy\n * of the previous bytes stops being served in the same write, before\n * anything at the provider is removed.\n *\n * ## The platform stays the system of record\n *\n * The bucket keeps every byte it kept before, and the storage counter moves\n * exactly as it did: a copy's size is recorded on the copy and never added to\n * `counters/media`, so storage is billed once. Making the provider's copy the\n * only one is a later step, and the storage cost model depends on it (see\n * AGL-2825); nothing here deletes a platform object.\n *\n * ## Keys carry the content hash\n *\n * `{hosts|orgs}/{scopeId}/{mediaId}/{sourceHash}/{representation}/{objectHash}`.\n * The source hash is the asset's `contentHash`; the object hash is the\n * copied object's own digest (for the master the two are the same). A\n * replaced file or a re-encoded rendition therefore gets a new key, so a URL\n * minted for old bytes can never be answered with new ones, and the whole\n * asset is one prefix for removal. The record is written by the server only,\n * but the key it holds is checked against the derivation before it is used:\n * a record copied onto another document names a key that is not that\n * document's own, and is ignored.\n */\n\n/** The per-org release flag both halves — copy and redirect — ask. */\nexport const MEDIA_DELIVERY_RELEASE_FLAG: ReleaseFlagKey = 'release_video_delivery'\n\n/** The media document field that records the copies at the provider. */\nexport const MEDIA_DELIVERY_COPIES_FIELD = 'deliveryCopies'\n\n/** The representation name of the uploaded file itself. */\nexport const MEDIA_DELIVERY_MASTER = 'master'\n\n/**\n * The shortest delivery URL lifetime. A delivery URL is a bearer capability,\n * so it is short, but a player sends its range requests to the URL it was\n * redirected to, so it must outlive a sitting: seeking, pausing and resuming\n * all reuse it.\n */\nexport const MEDIA_DELIVERY_MIN_TTL_MS = 15 * 60 * 1000\n\n/** The lifetime when the film's duration is unknown. */\nexport const MEDIA_DELIVERY_DEFAULT_TTL_MS = 60 * 60 * 1000\n\n/** The longest lifetime, shared with every other signed media link. */\nexport const MEDIA_DELIVERY_MAX_TTL_MS = MEDIA_SIGNATURE_MAX_TTL_MS\n\n/**\n * How long a delivery URL for a film of `durationMs` lives: twice the film,\n * so a viewer who pauses halfway still finishes it, between\n * {@link MEDIA_DELIVERY_MIN_TTL_MS} and {@link MEDIA_DELIVERY_MAX_TTL_MS}.\n */\nexport function mediaDeliveryTtlMs(durationMs: unknown): number {\n const duration = Number(durationMs)\n if (!Number.isFinite(duration) || duration <= 0) {\n return MEDIA_DELIVERY_DEFAULT_TTL_MS\n }\n return Math.min(\n MEDIA_DELIVERY_MAX_TTL_MS,\n Math.max(MEDIA_DELIVERY_MIN_TTL_MS, Math.round(duration * 2)),\n )\n}\n\n/** The asset a copy belongs to. */\nexport interface MediaDeliveryAsset {\n collection: 'hosts' | 'orgs'\n scopeId: string\n mediaId: string\n}\n\n/** An id segment, as the CDN route accepts one. */\nconst ID_SEGMENT = /^[A-Za-z0-9_-]{1,64}$/\n/** A content digest: `contentHash` is 16 hex, a full digest up to 64. */\nconst DIGEST = /^[a-f0-9]{16,64}$/\n/** `master`, or `r-` and a rendition key. */\nconst REPRESENTATION = /^(?:master|r-[a-z0-9][a-z0-9-]{0,23})$/\n\n/** The representation a rendition (or the master, for none) is copied as. */\nexport function mediaDeliveryRepresentation(\n rendition: Pick<MediaVideoRendition, 'key'> | null | undefined,\n): string {\n return rendition ? `r-${rendition.key}` : MEDIA_DELIVERY_MASTER\n}\n\nfunction validAsset(asset: MediaDeliveryAsset): boolean {\n return (\n (asset.collection === 'hosts' || asset.collection === 'orgs') &&\n ID_SEGMENT.test(asset.scopeId) &&\n ID_SEGMENT.test(asset.mediaId)\n )\n}\n\n/**\n * The prefix every copy of one asset lives under, or null for an asset whose\n * identity does not fit the grammar. Ends in `/`, so one asset's prefix is\n * never another's.\n */\nexport function mediaDeliveryAssetPrefix(asset: MediaDeliveryAsset): string | null {\n if (!validAsset(asset)) return null\n return `${asset.collection}/${asset.scopeId}/${asset.mediaId}/`\n}\n\n/** The prefix every copy of one library lives under, for an erasure. */\nexport function mediaDeliveryScopePrefix(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n): string | null {\n if (collection !== 'hosts' && collection !== 'orgs') return null\n if (!ID_SEGMENT.test(scopeId)) return null\n return `${collection}/${scopeId}/`\n}\n\n/** The object key of one copy, or null when a part does not fit the grammar. */\nexport function mediaDeliveryObjectKey(\n asset: MediaDeliveryAsset,\n copy: { sourceHash: string; representation: string; objectHash: string },\n): string | null {\n const prefix = mediaDeliveryAssetPrefix(asset)\n if (!prefix) return null\n if (!DIGEST.test(copy.sourceHash) || !DIGEST.test(copy.objectHash)) return null\n if (!REPRESENTATION.test(copy.representation)) return null\n return `${prefix}${copy.sourceHash}/${copy.representation}/${copy.objectHash}`\n}\n\n/** One copy at the provider, as the media document records it. */\nexport interface MediaDeliveryCopy {\n key: string\n /** The asset's `contentHash` when the copy was made. */\n sourceHash: string\n /** The copied object's own digest. */\n objectHash: string\n contentType: string\n sizeBytes: number\n copiedAtMs: number\n}\n\n/** The recorded copies of one asset, by representation. */\nexport type MediaDeliveryCopies = Record<string, MediaDeliveryCopy>\n\n/**\n * The copies a media document records that belong to THIS asset: each entry\n * well formed, and its key exactly the one {@link mediaDeliveryObjectKey}\n * derives from its own fields. Anything else is dropped rather than trusted.\n */\nexport function parseMediaDeliveryCopies(\n raw: unknown,\n asset: MediaDeliveryAsset,\n): MediaDeliveryCopies {\n const copies: MediaDeliveryCopies = {}\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return copies\n for (const [representation, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!value || typeof value !== 'object') continue\n const entry = value as Record<string, unknown>\n const sourceHash = String(entry['sourceHash'] ?? '')\n const objectHash = String(entry['objectHash'] ?? '')\n const key = mediaDeliveryObjectKey(asset, { sourceHash, representation, objectHash })\n if (!key || entry['key'] !== key) continue\n const contentType = String(entry['contentType'] ?? '')\n if (!/^video\\/[\\w.+-]{1,64}$/.test(contentType)) continue\n const sizeBytes = Number(entry['sizeBytes'])\n copies[representation] = {\n key,\n sourceHash,\n objectHash,\n contentType,\n sizeBytes: Number.isFinite(sizeBytes) && sizeBytes > 0 ? Math.round(sizeBytes) : 0,\n copiedAtMs: Number(entry['copiedAtMs']) || 0,\n }\n }\n return copies\n}\n\n/** Whether a served type is one this module delivers: video, nothing else. */\nexport function isMediaDeliveryType(contentType: unknown): boolean {\n return String(contentType ?? '')\n .split(';')[0]\n .trim()\n .toLowerCase()\n .startsWith('video/')\n}\n\nfunction currentContentHash(document: { get(field: string): unknown }): string | null {\n const hash = document.get('contentHash')\n return typeof hash === 'string' && DIGEST.test(hash) ? hash : null\n}\n\n/*===========================================================================\n * The owning org, for the flag.\n *==========================================================================*/\n\nconst ORG_CACHE_TTL_MS = 60_000\nconst hostOrgCache = new Map<string, { at: number; orgId: string | null }>()\n\n/** Drops the host → org cache. Tests need it between cases. */\nexport function invalidateMediaDeliveryOrgCache(): void {\n hostOrgCache.clear()\n}\n\n/**\n * The org that owns a library: the id itself for an org library, and the\n * host's `hostIndex` entry for a site's. Cached for a minute, like the\n * release-flag reads it feeds; a failed read answers null, which the flag\n * treats as \"no org\" and so refuses anything short of a full release.\n */\nexport async function mediaDeliveryOrgIdFor(\n collection: 'hosts' | 'orgs',\n scopeId: string,\n): Promise<string | null> {\n if (collection === 'orgs') return scopeId || null\n const hit = hostOrgCache.get(scopeId)\n if (hit && Date.now() - hit.at < ORG_CACHE_TTL_MS) return hit.orgId\n const orgId = await (async (): Promise<string | null> => {\n try {\n const firestore = firebaseAdmin.app().firestore()\n const [index] = await firestore.getAll(\n firestore.collection('hostIndex').doc(scopeId),\n { fieldMask: ['orgId'] },\n )\n const value = index?.get('orgId')\n return typeof value === 'string' && value ? value : null\n } catch {\n return null\n }\n })()\n hostOrgCache.set(scopeId, { at: Date.now(), orgId })\n return orgId\n}\n\n/** The flag verdict for one org. */\nexport function mediaDeliveryEnabledForOrg(orgId: string | null): Promise<boolean> {\n return isServerReleaseFlagOnForOrg(MEDIA_DELIVERY_RELEASE_FLAG, orgId)\n}\n\n/*===========================================================================\n * The redirect.\n *==========================================================================*/\n\nexport interface MediaDeliveryRedirect {\n location: string\n expiresAtMs: number\n key: string\n /** The copy's size, which the bandwidth meter counts a sitting at. */\n sizeBytes: number\n}\n\n/**\n * The delivery URL for one representation of one asset, or null when the\n * request must be served the way it always was.\n *\n * Called only after the caller has made every access decision. Null for: a\n * type that is not video; an asset with no current copy of this\n * representation; the flag off for the owning org; a lifetime that has\n * already run out; and a provider that fails to mint. The cheap answers come\n * first, so an asset with no copy costs no read at all.\n */\nexport async function mediaDeliveryRedirect(input: {\n provider: MediaDeliveryProvider\n asset: MediaDeliveryAsset\n document: { get(field: string): unknown }\n /** The rendition being served, or none for the master. */\n rendition?: Pick<MediaVideoRendition, 'key'> | null\n servedType: unknown\n claims: Omit<MediaDeliveryClaims, 'orgId'>\n /** The owning org, asked only once a current copy is found. */\n orgId: () => Promise<string | null>\n /** Defaults to the release flag. */\n deliveryEnabled?: (orgId: string | null) => Promise<boolean>\n /** The URL may not outlive this, e.g. the signature the request carried. */\n notAfterMs?: number\n /** A fixed lifetime; otherwise {@link mediaDeliveryTtlMs} of the film. */\n ttlMs?: number\n nowMs?: number\n}): Promise<MediaDeliveryRedirect | null> {\n if (!isMediaDeliveryType(input.servedType)) return null\n const sourceHash = currentContentHash(input.document)\n if (!sourceHash) return null\n const copies = parseMediaDeliveryCopies(\n input.document.get(MEDIA_DELIVERY_COPIES_FIELD),\n input.asset,\n )\n const copy = copies[mediaDeliveryRepresentation(input.rendition)]\n if (!copy || copy.sourceHash !== sourceHash) return null\n\n const orgId = await input.orgId()\n const enabled = await (input.deliveryEnabled ?? mediaDeliveryEnabledForOrg)(orgId)\n if (!enabled) return null\n\n const nowMs = input.nowMs ?? Date.now()\n const video = input.document.get('video') as { durationMs?: unknown } | undefined\n const lifetime = Math.min(\n MEDIA_DELIVERY_MAX_TTL_MS,\n input.ttlMs ?? mediaDeliveryTtlMs(video?.durationMs),\n )\n let expiresAtMs = nowMs + lifetime\n if (input.notAfterMs !== undefined) {\n // A bound that cannot be read is no bound, and a URL with none could\n // outlive what the caller was entitled to; serve from the platform.\n if (!Number.isFinite(input.notAfterMs)) return null\n expiresAtMs = Math.min(expiresAtMs, input.notAfterMs)\n }\n if (!(expiresAtMs > nowMs)) return null\n try {\n const location = await input.provider.deliveryUrl({\n key: copy.key,\n expiresAtMs,\n claims: { ...input.claims, orgId },\n })\n return { location, expiresAtMs, key: copy.key, sizeBytes: copy.sizeBytes }\n } catch (error) {\n console.error(\n '[media-delivery] could not mint a delivery URL',\n JSON.stringify({ key: copy.key }),\n error,\n )\n return null\n }\n}\n\n/*===========================================================================\n * Copies.\n *==========================================================================*/\n\n/** The slice of a Storage file the copy reads. */\nexport interface MediaDeliverySourceFile {\n getMetadata(): Promise<unknown>\n createReadStream(): NodeJS.ReadableStream\n}\n\n/** The slice of a Storage bucket the copy reads. */\nexport interface MediaDeliverySourceBucket {\n file(path: string): MediaDeliverySourceFile\n}\n\nexport type MediaDeliverySyncStatus =\n | 'no-provider'\n | 'no-asset'\n | 'not-video'\n | 'no-hash'\n | 'disabled'\n | 'replaced'\n | 'synced'\n\nexport interface MediaDeliverySyncResult {\n status: MediaDeliverySyncStatus\n /** Representations copied by this call. */\n copied: string[]\n /** Representations whose current copy was already there. */\n kept: string[]\n /** Keys removed from the provider because nothing current names them. */\n removed: string[]\n /** Representations whose copy failed; they keep serving from the platform. */\n failed: string[]\n}\n\n/** The md5 Storage records for an object, as hex. */\nfunction md5Hex(metadata: Record<string, unknown>): string | null {\n const raw = metadata['md5Hash']\n if (typeof raw !== 'string' || !raw) return null\n const hex = Buffer.from(raw, 'base64').toString('hex')\n return DIGEST.test(hex) ? hex : null\n}\n\n/** One `getMetadata()` answer, unwrapped from the SDK's tuple. */\nasync function readMetadata(file: MediaDeliverySourceFile): Promise<Record<string, unknown>> {\n const response = await file.getMetadata()\n const metadata = Array.isArray(response) ? response[0] : response\n return (metadata ?? {}) as Record<string, unknown>\n}\n\n/**\n * Brings the provider's copies of one video in line with its document: every\n * representation the document serves — the master and each recorded\n * rendition — gets a copy made from its current bytes, and a copy nothing\n * current names is removed.\n *\n * Every copy path runs through here: an upload's finalize, a replace, a\n * restore and the rendition producer. It is safe to run again at any time.\n * The master's copy is current when it was made from the asset's\n * `contentHash`; a rendition's when it was made from that hash AND from the\n * rendition object Storage holds now, since the producer can encode a key\n * again without the master changing.\n *\n * The record is written in a transaction that re-reads the document, and\n * only while the asset still has the content hash the copies were made from.\n * An asset replaced or deleted while its copies were uploading keeps that\n * state, and the copies just made are removed rather than recorded against\n * bytes they are not.\n */\nexport async function syncMediaDeliveryCopies(options: {\n docRef: FirebaseFirestore.DocumentReference\n asset: MediaDeliveryAsset\n bucket: MediaDeliverySourceBucket\n /** The owning org, when the caller has it; otherwise resolved. */\n orgId?: string | null\n /** Defaults to the registered provider configured to store. */\n provider?: MediaDeliveryProvider | null\n /** Defaults to the release flag. */\n deliveryEnabled?: (orgId: string | null) => Promise<boolean>\n nowMs?: () => number\n}): Promise<MediaDeliverySyncResult> {\n const result: MediaDeliverySyncResult = {\n status: 'synced',\n copied: [],\n kept: [],\n removed: [],\n failed: [],\n }\n const done = (status: MediaDeliverySyncStatus): MediaDeliverySyncResult => ({\n ...result,\n status,\n })\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return done('no-provider')\n const { asset, bucket, docRef } = options\n if (!mediaDeliveryAssetPrefix(asset)) return done('no-asset')\n\n const snapshot = await docRef.get()\n if (!snapshot.exists || snapshot.get('deletedAt')) return done('no-asset')\n if (!isMediaDeliveryType(snapshot.get('contentType'))) return done('not-video')\n const sourceHash = currentContentHash(snapshot)\n if (!sourceHash) return done('no-hash')\n\n const orgId =\n options.orgId === undefined\n ? await mediaDeliveryOrgIdFor(asset.collection, asset.scopeId)\n : options.orgId\n const enabled = await (options.deliveryEnabled ?? mediaDeliveryEnabledForOrg)(orgId)\n if (!enabled) return done('disabled')\n\n const now = options.nowMs ?? Date.now\n const basePath = mediaStoragePathInScope({\n storagePath: snapshot.get('storagePath'),\n base: `${asset.collection}/${asset.scopeId}`,\n mediaId: asset.mediaId,\n })\n const wanted: Array<{ objectPath: string; rendition: MediaVideoRendition | null }> = [\n { objectPath: basePath, rendition: null },\n ...parseMediaRenditions(snapshot.get('videoRenditions')).map((rendition) => ({\n objectPath: mediaRenditionObjectPath(basePath, rendition),\n rendition,\n })),\n ]\n\n const recorded = parseMediaDeliveryCopies(snapshot.get(MEDIA_DELIVERY_COPIES_FIELD), asset)\n const next: MediaDeliveryCopies = {}\n for (const { objectPath, rendition } of wanted) {\n const representation = mediaDeliveryRepresentation(rendition)\n const existing = recorded[representation]\n try {\n const file = bucket.file(objectPath)\n // The master's digest is on the document already, so a current copy of\n // it costs no Storage read. A rendition's is read every time.\n if (!rendition && existing?.sourceHash === sourceHash) {\n next[representation] = existing\n result.kept.push(representation)\n continue\n }\n const metadata = await readMetadata(file)\n const objectHash = rendition ? md5Hex(metadata) : sourceHash\n if (!objectHash) throw new Error('the object reports no digest')\n if (\n existing &&\n existing.sourceHash === sourceHash &&\n existing.objectHash === objectHash\n ) {\n next[representation] = existing\n result.kept.push(representation)\n continue\n }\n const sizeBytes = Number(metadata['size'])\n if (!Number.isSafeInteger(sizeBytes) || sizeBytes <= 0) {\n throw new Error('the object reports no size')\n }\n const contentType = rendition\n ? rendition.contentType\n : String(metadata['contentType'] ?? snapshot.get('contentType') ?? '')\n if (!isMediaDeliveryType(contentType)) throw new Error('the object is not a video')\n const key = mediaDeliveryObjectKey(asset, { sourceHash, representation, objectHash })\n if (!key) throw new Error('no key fits the asset')\n await provider.putObject({\n key,\n body: Readable.toWeb(file.createReadStream() as Readable) as ReadableStream<Uint8Array>,\n contentLength: sizeBytes,\n contentType,\n })\n next[representation] = {\n key,\n sourceHash,\n objectHash,\n contentType,\n sizeBytes,\n copiedAtMs: now(),\n }\n result.copied.push(representation)\n } catch (error) {\n result.failed.push(representation)\n console.error(\n '[media-delivery] copy failed; it keeps serving from the platform',\n JSON.stringify({ ...asset, representation }),\n error,\n )\n }\n }\n\n const nextKeys = new Set(Object.values(next).map((copy) => copy.key))\n const retired = Object.values(recorded)\n .map((copy) => copy.key)\n .filter((key) => !nextKeys.has(key))\n const madeNow = result.copied\n .map((representation) => next[representation]?.key)\n .filter((key): key is string => Boolean(key))\n\n if (result.copied.length || retired.length) {\n const written = await docRef.firestore.runTransaction(async (transaction) => {\n const fresh = await transaction.get(docRef)\n if (!fresh.exists || fresh.get('deletedAt')) return false\n if (currentContentHash(fresh) !== sourceHash) return false\n transaction.update(docRef, { [MEDIA_DELIVERY_COPIES_FIELD]: next })\n return true\n })\n if (!written) {\n // Replaced or deleted while the copies were uploading. Nothing records\n // what was just made, so it goes rather than lingering unreferenced.\n await removeKeys(provider, madeNow, result)\n return done('replaced')\n }\n }\n await removeKeys(provider, retired, result)\n return done('synced')\n}\n\nasync function removeKeys(\n provider: MediaDeliveryProvider,\n keys: string[],\n result: Pick<MediaDeliverySyncResult, 'removed'>,\n): Promise<void> {\n for (const key of keys) {\n try {\n await provider.deleteObject(key)\n result.removed.push(key)\n } catch (error) {\n console.error('[media-delivery] could not remove a retired copy', JSON.stringify({ key }), error)\n }\n }\n}\n\n/**\n * Removes every copy of one asset from the provider: a delete, a takedown and\n * a replace all end the life of the bytes the copies were made from.\n *\n * By prefix rather than by the document's record, so a copy the record lost\n * track of goes too. Never gated on the flag, since copies made while it was\n * on must still go, and never throws: the caller's own action has already\n * happened and must not be reported as failed because a copy lingered. Null\n * when no provider is configured to store.\n */\nexport async function removeMediaDeliveryCopies(options: {\n asset: MediaDeliveryAsset\n provider?: MediaDeliveryProvider | null\n}): Promise<{ removed: number; failed: boolean } | null> {\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return null\n const prefix = mediaDeliveryAssetPrefix(options.asset)\n if (!prefix) return { removed: 0, failed: false }\n try {\n return { removed: await provider.deleteObjectsWithPrefix(prefix), failed: false }\n } catch (error) {\n console.error(\n '[media-delivery] could not remove an asset\\'s copies',\n JSON.stringify(options.asset),\n error,\n )\n return { removed: 0, failed: true }\n }\n}\n\n/**\n * Removes every copy a library holds at the provider — the erasure of a site\n * or an org. Same posture as {@link removeMediaDeliveryCopies}.\n */\nexport async function eraseMediaDeliveryScope(options: {\n collection: 'hosts' | 'orgs'\n scopeId: string\n provider?: MediaDeliveryProvider | null\n}): Promise<{ removed: number; failed: boolean } | null> {\n const provider =\n options.provider === undefined ? mediaDeliveryProvider('store') : options.provider\n if (!provider) return null\n const prefix = mediaDeliveryScopePrefix(options.collection, options.scopeId)\n if (!prefix) return { removed: 0, failed: false }\n try {\n return { removed: await provider.deleteObjectsWithPrefix(prefix), failed: false }\n } catch (error) {\n console.error(\n '[media-delivery] could not erase a library\\'s copies',\n JSON.stringify({ collection: options.collection, scopeId: options.scopeId }),\n error,\n )\n return { removed: 0, failed: true }\n }\n}\n\n/**\n * A media document without its copy record, for a restore: the delete that\n * made the tombstone removed the copies, so the record describes objects\n * that are gone.\n */\nexport function withoutMediaDeliveryCopies(\n media: Record<string, unknown>,\n): Record<string, unknown> {\n if (!(MEDIA_DELIVERY_COPIES_FIELD in media)) return media\n const rest = { ...media }\n delete rest[MEDIA_DELIVERY_COPIES_FIELD]\n return rest\n}\n"],"names":["Readable","mediaRenditionObjectPath","parseMediaRenditions","mediaDeliveryProvider","firebaseAdmin","MEDIA_SIGNATURE_MAX_TTL_MS","mediaStoragePathInScope","isServerReleaseFlagOnForOrg","MEDIA_DELIVERY_RELEASE_FLAG","MEDIA_DELIVERY_COPIES_FIELD","MEDIA_DELIVERY_MASTER","MEDIA_DELIVERY_MIN_TTL_MS","MEDIA_DELIVERY_DEFAULT_TTL_MS","MEDIA_DELIVERY_MAX_TTL_MS","mediaDeliveryTtlMs","durationMs","duration","Number","isFinite","Math","min","max","round","ID_SEGMENT","DIGEST","REPRESENTATION","mediaDeliveryRepresentation","rendition","key","validAsset","asset","collection","test","scopeId","mediaId","mediaDeliveryAssetPrefix","mediaDeliveryScopePrefix","mediaDeliveryObjectKey","copy","prefix","sourceHash","objectHash","representation","parseMediaDeliveryCopies","raw","copies","Array","isArray","value","Object","entries","entry","String","contentType","sizeBytes","copiedAtMs","isMediaDeliveryType","split","trim","toLowerCase","startsWith","currentContentHash","document","hash","get","ORG_CACHE_TTL_MS","hostOrgCache","Map","invalidateMediaDeliveryOrgCache","clear","mediaDeliveryOrgIdFor","hit","Date","now","at","orgId","firestore","app","index","getAll","doc","fieldMask","set","mediaDeliveryEnabledForOrg","mediaDeliveryRedirect","input","servedType","enabled","deliveryEnabled","nowMs","video","lifetime","ttlMs","expiresAtMs","notAfterMs","undefined","location","provider","deliveryUrl","claims","error","console","JSON","stringify","md5Hex","metadata","hex","Buffer","from","toString","readMetadata","file","response","getMetadata","syncMediaDeliveryCopies","options","result","status","copied","kept","removed","failed","done","bucket","docRef","snapshot","exists","basePath","storagePath","base","wanted","objectPath","map","recorded","next","existing","push","Error","isSafeInteger","putObject","body","toWeb","createReadStream","contentLength","nextKeys","Set","values","retired","filter","has","madeNow","Boolean","length","written","runTransaction","transaction","fresh","update","removeKeys","keys","deleteObject","removeMediaDeliveryCopies","deleteObjectsWithPrefix","eraseMediaDeliveryScope","withoutMediaDeliveryCopies","media","rest"],"mappings":"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"}