@aglyn/tenant-data-admin 1.0.0-beta.220 → 1.0.0-beta.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.d.ts +7 -0
  22. package/src/lib/server/hosted-page-review.js +144 -20
  23. package/src/lib/server/hosted-page-review.js.map +1 -1
  24. package/src/lib/server/leaving-notice.d.ts +98 -0
  25. package/src/lib/server/leaving-notice.js +250 -0
  26. package/src/lib/server/leaving-notice.js.map +1 -0
  27. package/src/lib/server/link-reputation-review.d.ts +58 -0
  28. package/src/lib/server/link-reputation-review.js +185 -0
  29. package/src/lib/server/link-reputation-review.js.map +1 -0
  30. package/src/lib/server/live-page-drops.d.ts +67 -0
  31. package/src/lib/server/live-page-drops.js +57 -0
  32. package/src/lib/server/live-page-drops.js.map +1 -0
  33. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  34. package/src/lib/server/media-bandwidth-cap.js +180 -0
  35. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  36. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  37. package/src/lib/server/media-cdn-icon.js +140 -0
  38. package/src/lib/server/media-cdn-icon.js.map +1 -0
  39. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  40. package/src/lib/server/media-delivery-regeneration.js +216 -0
  41. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  42. package/src/lib/server/media-delivery.d.ts +2 -0
  43. package/src/lib/server/media-delivery.js +2 -1
  44. package/src/lib/server/media-delivery.js.map +1 -1
  45. package/src/lib/server/media-serve-count.d.ts +50 -0
  46. package/src/lib/server/media-serve-count.js +62 -0
  47. package/src/lib/server/media-serve-count.js.map +1 -0
  48. package/src/lib/server/media-signing.d.ts +13 -2
  49. package/src/lib/server/media-signing.js +12 -8
  50. package/src/lib/server/media-signing.js.map +1 -1
  51. package/src/lib/server/media-tombstone.js +7 -5
  52. package/src/lib/server/media-tombstone.js.map +1 -1
  53. package/src/lib/server/media-variants.d.ts +151 -20
  54. package/src/lib/server/media-variants.js +204 -27
  55. package/src/lib/server/media-variants.js.map +1 -1
  56. package/src/lib/server/org-age.d.ts +15 -6
  57. package/src/lib/server/org-age.js +20 -8
  58. package/src/lib/server/org-age.js.map +1 -1
  59. package/src/lib/server/organizations.d.ts +102 -2
  60. package/src/lib/server/organizations.js +388 -58
  61. package/src/lib/server/organizations.js.map +1 -1
  62. package/src/lib/server/outbound-send-review.js +24 -6
  63. package/src/lib/server/outbound-send-review.js.map +1 -1
  64. package/src/lib/server/page-security-hold.d.ts +93 -0
  65. package/src/lib/server/page-security-hold.js +180 -0
  66. package/src/lib/server/page-security-hold.js.map +1 -0
  67. package/src/lib/server/paged-sweeps.d.ts +51 -0
  68. package/src/lib/server/paged-sweeps.js +79 -0
  69. package/src/lib/server/paged-sweeps.js.map +1 -0
  70. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  71. package/src/lib/server/platform-marketing-consent.js +27 -11
  72. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  73. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  74. package/src/lib/server/serve-media-cdn.js +424 -70
  75. package/src/lib/server/serve-media-cdn.js.map +1 -1
  76. package/src/lib/server/site-icon-render.d.ts +68 -0
  77. package/src/lib/server/site-icon-render.js +157 -0
  78. package/src/lib/server/site-icon-render.js.map +1 -0
  79. package/src/lib/server/staff-seat.d.ts +50 -0
  80. package/src/lib/server/staff-seat.js +118 -0
  81. package/src/lib/server/staff-seat.js.map +1 -0
  82. package/src/lib/server/transfer-export.d.ts +46 -0
  83. package/src/lib/server/transfer-export.js +221 -0
  84. package/src/lib/server/transfer-export.js.map +1 -0
  85. package/src/lib/server/transfer-jobs.d.ts +385 -0
  86. package/src/lib/server/transfer-jobs.js +2368 -0
  87. package/src/lib/server/transfer-jobs.js.map +1 -0
  88. package/src/lib/server/transfer-packages.d.ts +127 -0
  89. package/src/lib/server/transfer-packages.js +1036 -0
  90. package/src/lib/server/transfer-packages.js.map +1 -0
  91. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  92. package/src/lib/server/upgrade-proposal.js +312 -0
  93. package/src/lib/server/upgrade-proposal.js.map +1 -0
  94. package/src/lib/server/upsert-contact.d.ts +17 -8
  95. package/src/lib/server/upsert-contact.js +82 -16
  96. package/src/lib/server/upsert-contact.js.map +1 -1
  97. package/src/lib/server/web-risk.d.ts +139 -0
  98. package/src/lib/server/web-risk.js +521 -0
  99. package/src/lib/server/web-risk.js.map +1 -0
  100. package/src/tenancy.d.ts +3 -1
  101. package/src/tenancy.js +5 -1
  102. package/src/tenancy.js.map +1 -1
  103. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  104. package/src/lib/server/campaign-attribution-store.js +0 -95
  105. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  106. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  107. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  108. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  109. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  110. package/src/lib/server/dataset-live-pages.js +0 -421
  111. package/src/lib/server/dataset-live-pages.js.map +0 -1
  112. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  113. package/src/lib/server/email-revenue-attribution.js +0 -321
  114. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -0,0 +1,180 @@
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
+ */ import { analyticsBandwidthReading, bandwidthCapApplies, bandwidthCapMonthKey, bandwidthCapShouldEngage, bandwidthGbFromPageViews, ORIGIN_MEDIA_BANDWIDTH_WEIGHT, pageViewsFromBandwidthGb, resolveOrgEntitlements } from "@aglyn/aglyn/server";
17
+ import { firebaseAdmin } from "./firebase-admin.js";
18
+ import { notifyHostManagers } from "./notifications.js";
19
+ /**
20
+ * The Free cap, engaged by the media CDN (AGL-3474).
21
+ *
22
+ * The cap's marker (`orgs/{id}.bandwidthCap`) had two writers, and both are
23
+ * driven by PAGES: the analytics beacon, on a sample of page views, and the
24
+ * daily usage sweep. Video and files count against the same band now, but a
25
+ * film or a PDF linked from somewhere else is served with no page view at
26
+ * all — so a Free org whose traffic is its media would cross its band and
27
+ * keep serving until the next morning's sweep. A day of one popular clip is
28
+ * hundreds of gigabytes.
29
+ *
30
+ * So the CDN is a third writer, on the same terms as the other two: the same
31
+ * `bandwidthCapShouldEngage` predicate, the same marker fields, the same UTC
32
+ * month key, and a write only when the marker does not already name this
33
+ * month. A reader cannot tell which writer stamped a marker.
34
+ *
35
+ * ## The month it totals
36
+ *
37
+ * Org-wide, because the band is: every site's page views and counted media,
38
+ * plus the org library's media, read from the same analytics day documents
39
+ * the invoice reads, through the same {@link analyticsBandwidthReading}.
40
+ *
41
+ * ## What it costs
42
+ *
43
+ * Nothing for a paying org: the CDN asks only for an org whose plan the cap
44
+ * stops (`bandwidthCapApplies`, decided off the org document its delivery
45
+ * verdict already holds). For those, one evaluation per
46
+ * {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} an instance serves — the org
47
+ * document, its site ids and the month's day documents, a few dozen reads
48
+ * against about three cents of delivery.
49
+ *
50
+ * ## The slop, stated
51
+ *
52
+ * Each instance evaluates on its first counted request for an org and then
53
+ * once per {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES}, so the cap engages at
54
+ * most that much per instance past the band, and the CDN's own verdict cache
55
+ * takes up to fifteen seconds to see the marker. The beacon's slop is the
56
+ * same shape, measured in views.
57
+ */ /** How many counted bytes an instance serves an org between evaluations. */ export const MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES = 32 * 1024 * 1024;
58
+ /** Sites totalled per org. The cap is for Free, which has one. */ const MEDIA_BANDWIDTH_MAX_HOSTS = 100;
59
+ /** Bound on the per-instance maps, whose keys come from served requests. */ const MAX_TRACKED_ORGS = 5000;
60
+ /** Counted bytes since an org's last evaluation, on this instance. */ const bytesSinceEvaluation = new Map();
61
+ /** Orgs this instance has seen engaged, by the month they were engaged for. */ const engagedOrgs = new Map();
62
+ /** Drop the per-instance state. Tests need it between cases. */ export function invalidateMediaBandwidthCapState() {
63
+ bytesSinceEvaluation.clear();
64
+ engagedOrgs.clear();
65
+ }
66
+ /**
67
+ * Note `bytes` counted for `orgId`, and say whether this instance should now
68
+ * total the org's month.
69
+ *
70
+ * Due on the first counted request an instance sees for the org — a cold
71
+ * instance under a spike evaluates at once, which is when it matters — and
72
+ * then each time another {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} has
73
+ * gone out. Never due again in a month this instance has seen it engaged.
74
+ */ export function mediaBandwidthEvaluationDue(orgId, bytes, now = new Date()) {
75
+ if (!orgId || !(bytes > 0)) return false;
76
+ if (engagedOrgs.get(orgId) === bandwidthCapMonthKey(now)) return false;
77
+ if (bytesSinceEvaluation.size > MAX_TRACKED_ORGS) bytesSinceEvaluation.clear();
78
+ const since = bytesSinceEvaluation.get(orgId);
79
+ if (since === undefined || since + bytes >= MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES) {
80
+ bytesSinceEvaluation.set(orgId, 0);
81
+ return true;
82
+ }
83
+ bytesSinceEvaluation.set(orgId, since + bytes);
84
+ return false;
85
+ }
86
+ /** `YYYY-MM` as the day it rolls over, the way a notice prints it. */ function monthRolloverLabel(month) {
87
+ const [year, monthIndex] = month.split('-').map(Number);
88
+ return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString('en-US', {
89
+ month: 'long',
90
+ day: 'numeric',
91
+ timeZone: 'UTC'
92
+ });
93
+ }
94
+ /**
95
+ * Total `orgId`'s month and engage the cap if it is past the band. Answers
96
+ * whether THIS call engaged it.
97
+ *
98
+ * Best-effort, like the beacon's writer: it runs beside a delivery that has
99
+ * already been decided, and a failure here is logged and costs the request
100
+ * nothing.
101
+ */ export async function engageMediaBandwidthCap(orgId, now = new Date()) {
102
+ const month = bandwidthCapMonthKey(now);
103
+ if (!orgId || engagedOrgs.get(orgId) === month) return false;
104
+ try {
105
+ var _orgSnapshot_data;
106
+ var _org_bandwidthCap;
107
+ const firestore = firebaseAdmin.app().firestore();
108
+ const orgRef = firestore.collection('orgs').doc(orgId);
109
+ const orgSnapshot = await orgRef.get();
110
+ if (!orgSnapshot.exists) return false;
111
+ const org = (_orgSnapshot_data = orgSnapshot.data()) != null ? _orgSnapshot_data : {};
112
+ if (!bandwidthCapApplies(org)) return false;
113
+ if (((_org_bandwidthCap = org.bandwidthCap) == null ? void 0 : _org_bandwidthCap.month) === month) {
114
+ rememberEngaged(orgId, month);
115
+ return false;
116
+ }
117
+ const documentId = firebaseAdmin.firestore.FieldPath.documentId();
118
+ // The query `report-usage` and the beacon run, per scope: a cap that
119
+ // counted a different month from the invoice would refuse traffic nobody
120
+ // was charged for.
121
+ const monthOf = (ref)=>ref.collection('analytics').where(documentId, '>=', `${month}-01`).where(documentId, '<=', `${month}-31`).get();
122
+ const hosts = await firestore.collection('hosts').where('orgId', '==', orgId).select().limit(MEDIA_BANDWIDTH_MAX_HOSTS).get();
123
+ const months = await Promise.all([
124
+ monthOf(orgRef),
125
+ ...hosts.docs.map((host)=>monthOf(host.ref))
126
+ ]);
127
+ const reading = analyticsBandwidthReading(months.flatMap((days)=>days.docs));
128
+ const includedBandwidthGb = resolveOrgEntitlements(org).bandwidthGb;
129
+ if (!bandwidthCapShouldEngage({
130
+ org,
131
+ usedBandwidthGb: bandwidthGbFromPageViews(reading.meteredPageViews),
132
+ includedBandwidthGb
133
+ })) {
134
+ return false;
135
+ }
136
+ // Stamped inside a transaction that re-reads the marker, so two instances
137
+ // crossing the band together stamp it, and tell the owner, once.
138
+ const stamped = await firestore.runTransaction(async (transaction)=>{
139
+ var _current_get;
140
+ const current = await transaction.get(orgRef);
141
+ if (((_current_get = current.get('bandwidthCap')) == null ? void 0 : _current_get.month) === month) return false;
142
+ // `merge: true`: one field on a document that carries the plan, the
143
+ // subscription and the sites. A set without it would delete the org.
144
+ transaction.set(orgRef, {
145
+ bandwidthCap: {
146
+ month,
147
+ engagedAt: now.getTime(),
148
+ pageViews: Math.round(reading.meteredPageViews),
149
+ includedPageViews: Math.round(pageViewsFromBandwidthGb(includedBandwidthGb))
150
+ }
151
+ }, {
152
+ merge: true
153
+ });
154
+ return true;
155
+ });
156
+ rememberEngaged(orgId, month);
157
+ if (!stamped) return false;
158
+ const included = includedBandwidthGb.toLocaleString('en-US', {
159
+ maximumFractionDigits: 2
160
+ });
161
+ // Every site's managers, because the cap pauses every site in the org.
162
+ // `{site}` is filled by `notifyHostManagers` from the host it reads.
163
+ await Promise.all(hosts.docs.map((host)=>notifyHostManagers(host.id, {
164
+ type: 'system.bandwidthCapEngaged',
165
+ title: '{site} paused — monthly traffic limit reached',
166
+ body: `{site} has used the ${included} GB of traffic its plan includes ` + 'this month. Video, audio and files served from your media library ' + `count ${ORIGIN_MEDIA_BANDWIDTH_WEIGHT}× toward it, as well as page ` + 'views, so visitors see a temporary notice instead of its pages, ' + 'and its video and files do not load, ' + `until ${monthRolloverLabel(month)}. Nothing is charged for the ` + 'extra traffic. The site comes back on its own then, or as soon as ' + 'you upgrade in Billing.',
167
+ link: `/${host.id}`
168
+ })));
169
+ return true;
170
+ } catch (error) {
171
+ console.error('[media-cdn] bandwidth cap evaluation failed', orgId, error);
172
+ return false;
173
+ }
174
+ }
175
+ function rememberEngaged(orgId, month) {
176
+ if (engagedOrgs.size > MAX_TRACKED_ORGS) engagedOrgs.clear();
177
+ engagedOrgs.set(orgId, month);
178
+ }
179
+
180
+ //# sourceMappingURL=media-bandwidth-cap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-bandwidth-cap.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 {\n type AglynOrgBilling,\n analyticsBandwidthReading,\n bandwidthCapApplies,\n bandwidthCapMonthKey,\n bandwidthCapShouldEngage,\n bandwidthGbFromPageViews,\n ORIGIN_MEDIA_BANDWIDTH_WEIGHT,\n pageViewsFromBandwidthGb,\n resolveOrgEntitlements,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from './firebase-admin'\nimport { notifyHostManagers } from './notifications'\n\n/**\n * The Free cap, engaged by the media CDN (AGL-3474).\n *\n * The cap's marker (`orgs/{id}.bandwidthCap`) had two writers, and both are\n * driven by PAGES: the analytics beacon, on a sample of page views, and the\n * daily usage sweep. Video and files count against the same band now, but a\n * film or a PDF linked from somewhere else is served with no page view at\n * all — so a Free org whose traffic is its media would cross its band and\n * keep serving until the next morning's sweep. A day of one popular clip is\n * hundreds of gigabytes.\n *\n * So the CDN is a third writer, on the same terms as the other two: the same\n * `bandwidthCapShouldEngage` predicate, the same marker fields, the same UTC\n * month key, and a write only when the marker does not already name this\n * month. A reader cannot tell which writer stamped a marker.\n *\n * ## The month it totals\n *\n * Org-wide, because the band is: every site's page views and counted media,\n * plus the org library's media, read from the same analytics day documents\n * the invoice reads, through the same {@link analyticsBandwidthReading}.\n *\n * ## What it costs\n *\n * Nothing for a paying org: the CDN asks only for an org whose plan the cap\n * stops (`bandwidthCapApplies`, decided off the org document its delivery\n * verdict already holds). For those, one evaluation per\n * {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} an instance serves — the org\n * document, its site ids and the month's day documents, a few dozen reads\n * against about three cents of delivery.\n *\n * ## The slop, stated\n *\n * Each instance evaluates on its first counted request for an org and then\n * once per {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES}, so the cap engages at\n * most that much per instance past the band, and the CDN's own verdict cache\n * takes up to fifteen seconds to see the marker. The beacon's slop is the\n * same shape, measured in views.\n */\n\n/** How many counted bytes an instance serves an org between evaluations. */\nexport const MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES = 32 * 1024 * 1024\n\n/** Sites totalled per org. The cap is for Free, which has one. */\nconst MEDIA_BANDWIDTH_MAX_HOSTS = 100\n\n/** Bound on the per-instance maps, whose keys come from served requests. */\nconst MAX_TRACKED_ORGS = 5_000\n\n/** Counted bytes since an org's last evaluation, on this instance. */\nconst bytesSinceEvaluation = new Map<string, number>()\n\n/** Orgs this instance has seen engaged, by the month they were engaged for. */\nconst engagedOrgs = new Map<string, string>()\n\n/** Drop the per-instance state. Tests need it between cases. */\nexport function invalidateMediaBandwidthCapState(): void {\n bytesSinceEvaluation.clear()\n engagedOrgs.clear()\n}\n\n/**\n * Note `bytes` counted for `orgId`, and say whether this instance should now\n * total the org's month.\n *\n * Due on the first counted request an instance sees for the org — a cold\n * instance under a spike evaluates at once, which is when it matters — and\n * then each time another {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} has\n * gone out. Never due again in a month this instance has seen it engaged.\n */\nexport function mediaBandwidthEvaluationDue(\n orgId: string,\n bytes: number,\n now: Date = new Date(),\n): boolean {\n if (!orgId || !(bytes > 0)) return false\n if (engagedOrgs.get(orgId) === bandwidthCapMonthKey(now)) return false\n if (bytesSinceEvaluation.size > MAX_TRACKED_ORGS) bytesSinceEvaluation.clear()\n const since = bytesSinceEvaluation.get(orgId)\n if (since === undefined || since + bytes >= MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES) {\n bytesSinceEvaluation.set(orgId, 0)\n return true\n }\n bytesSinceEvaluation.set(orgId, since + bytes)\n return false\n}\n\n/** `YYYY-MM` as the day it rolls over, the way a notice prints it. */\nfunction monthRolloverLabel(month: string): string {\n const [year, monthIndex] = month.split('-').map(Number)\n return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString('en-US', {\n month: 'long',\n day: 'numeric',\n timeZone: 'UTC',\n })\n}\n\n/**\n * Total `orgId`'s month and engage the cap if it is past the band. Answers\n * whether THIS call engaged it.\n *\n * Best-effort, like the beacon's writer: it runs beside a delivery that has\n * already been decided, and a failure here is logged and costs the request\n * nothing.\n */\nexport async function engageMediaBandwidthCap(\n orgId: string,\n now: Date = new Date(),\n): Promise<boolean> {\n const month = bandwidthCapMonthKey(now)\n if (!orgId || engagedOrgs.get(orgId) === month) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const orgSnapshot = await orgRef.get()\n if (!orgSnapshot.exists) return false\n const org = (orgSnapshot.data() ?? {}) as Partial<AglynOrgBilling>\n if (!bandwidthCapApplies(org)) return false\n if (org.bandwidthCap?.month === month) {\n rememberEngaged(orgId, month)\n return false\n }\n const documentId = firebaseAdmin.firestore.FieldPath.documentId()\n // The query `report-usage` and the beacon run, per scope: a cap that\n // counted a different month from the invoice would refuse traffic nobody\n // was charged for.\n const monthOf = (ref: FirebaseFirestore.DocumentReference) =>\n ref\n .collection('analytics')\n .where(documentId, '>=', `${month}-01`)\n .where(documentId, '<=', `${month}-31`)\n .get()\n const hosts = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .select()\n .limit(MEDIA_BANDWIDTH_MAX_HOSTS)\n .get()\n const months = await Promise.all([\n monthOf(orgRef),\n ...hosts.docs.map((host) => monthOf(host.ref)),\n ])\n const reading = analyticsBandwidthReading(months.flatMap((days) => days.docs))\n const includedBandwidthGb = resolveOrgEntitlements(org).bandwidthGb\n if (\n !bandwidthCapShouldEngage({\n org,\n usedBandwidthGb: bandwidthGbFromPageViews(reading.meteredPageViews),\n includedBandwidthGb,\n })\n ) {\n return false\n }\n // Stamped inside a transaction that re-reads the marker, so two instances\n // crossing the band together stamp it, and tell the owner, once.\n const stamped = await firestore.runTransaction(async (transaction) => {\n const current = await transaction.get(orgRef)\n if (current.get('bandwidthCap')?.month === month) return false\n // `merge: true`: one field on a document that carries the plan, the\n // subscription and the sites. A set without it would delete the org.\n transaction.set(\n orgRef,\n {\n bandwidthCap: {\n month,\n engagedAt: now.getTime(),\n pageViews: Math.round(reading.meteredPageViews),\n includedPageViews: Math.round(\n pageViewsFromBandwidthGb(includedBandwidthGb),\n ),\n },\n },\n { merge: true },\n )\n return true\n })\n rememberEngaged(orgId, month)\n if (!stamped) return false\n const included = includedBandwidthGb.toLocaleString('en-US', {\n maximumFractionDigits: 2,\n })\n // Every site's managers, because the cap pauses every site in the org.\n // `{site}` is filled by `notifyHostManagers` from the host it reads.\n await Promise.all(\n hosts.docs.map((host) =>\n notifyHostManagers(host.id, {\n type: 'system.bandwidthCapEngaged',\n title: '{site} paused — monthly traffic limit reached',\n body:\n `{site} has used the ${included} GB of traffic its plan includes ` +\n 'this month. Video, audio and files served from your media library ' +\n `count ${ORIGIN_MEDIA_BANDWIDTH_WEIGHT}× toward it, as well as page ` +\n 'views, so visitors see a temporary notice instead of its pages, ' +\n 'and its video and files do not load, ' +\n `until ${monthRolloverLabel(month)}. Nothing is charged for the ` +\n 'extra traffic. The site comes back on its own then, or as soon as ' +\n 'you upgrade in Billing.',\n link: `/${host.id}`,\n }),\n ),\n )\n return true\n } catch (error) {\n console.error('[media-cdn] bandwidth cap evaluation failed', orgId, error)\n return false\n }\n}\n\nfunction rememberEngaged(orgId: string, month: string): void {\n if (engagedOrgs.size > MAX_TRACKED_ORGS) engagedOrgs.clear()\n engagedOrgs.set(orgId, month)\n}\n"],"names":["analyticsBandwidthReading","bandwidthCapApplies","bandwidthCapMonthKey","bandwidthCapShouldEngage","bandwidthGbFromPageViews","ORIGIN_MEDIA_BANDWIDTH_WEIGHT","pageViewsFromBandwidthGb","resolveOrgEntitlements","firebaseAdmin","notifyHostManagers","MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES","MEDIA_BANDWIDTH_MAX_HOSTS","MAX_TRACKED_ORGS","bytesSinceEvaluation","Map","engagedOrgs","invalidateMediaBandwidthCapState","clear","mediaBandwidthEvaluationDue","orgId","bytes","now","Date","get","size","since","undefined","set","monthRolloverLabel","month","year","monthIndex","split","map","Number","UTC","toLocaleDateString","day","timeZone","engageMediaBandwidthCap","orgSnapshot","org","firestore","app","orgRef","collection","doc","exists","data","bandwidthCap","rememberEngaged","documentId","FieldPath","monthOf","ref","where","hosts","select","limit","months","Promise","all","docs","host","reading","flatMap","days","includedBandwidthGb","bandwidthGb","usedBandwidthGb","meteredPageViews","stamped","runTransaction","transaction","current","engagedAt","getTime","pageViews","Math","round","includedPageViews","merge","included","toLocaleString","maximumFractionDigits","id","type","title","body","link","error","console"],"mappings":"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"}
@@ -0,0 +1,77 @@
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 { type SiteIconSpec } from '@aglyn/aglyn/app-utils/site-icon-set';
18
+ import type { NextApiRequest, NextApiResponse } from 'next';
19
+ import type { MediaCdnRateLimitRefusal } from './media-cdn-rate-limit';
20
+ /**
21
+ * The `?icon=` representation of a media CDN URL (AGL-3484): one icon of a
22
+ * site's derived set, drawn from the asset's current bytes.
23
+ *
24
+ * `serveMediaCdn` calls this only after every gate it applies to the bytes —
25
+ * scope, lockdown, quarantine, the private signature and the stale-pin
26
+ * redirect — so an icon is served exactly where its source would be.
27
+ *
28
+ * ## Derived once, cached by version
29
+ *
30
+ * The URL a page names carries `v`, the asset's `contentHash`. While `v` is
31
+ * current the icon is drawn once and held for a year by the edge and the
32
+ * browser (`immutable`): the same URL can never need different bytes, because
33
+ * a DAM Replace changes the hash and so the URL the next render names. A
34
+ * stale `v` 302s to the current one under the stable URL's revalidated policy,
35
+ * which is how a page rendered before a Replace reaches the new icon. A URL
36
+ * with no `v` (the source's hash is unknown) is served under the stable
37
+ * policy with an ETag keyed on the hash, exactly as the asset itself is.
38
+ *
39
+ * ## Anything the renderer cannot draw falls back to the original
40
+ *
41
+ * A non-image, an ICO (which `sharp` cannot decode), a source over the
42
+ * variant generator's fetch ceiling, or a file that fails to decode: each
43
+ * 302s to the asset's plain URL. A favicon link must never be the reason a
44
+ * tab shows nothing.
45
+ */
46
+ /** The fields of a stored asset this representation reads. */
47
+ export interface MediaCdnIconSource {
48
+ /** The media document's `contentType`. */
49
+ contentType: unknown;
50
+ /** The media document's `contentHash`; `''` for a legacy asset. */
51
+ contentHash: string;
52
+ /** `/api/media/cdn/{scope}/{mediaId}` — the asset's stable URL. */
53
+ stablePath: string;
54
+ /** The Storage object, as far as this reads it. */
55
+ file: {
56
+ getMetadata(): Promise<[{
57
+ size?: string | number;
58
+ } | null | undefined]>;
59
+ download(): Promise<[Buffer]>;
60
+ };
61
+ }
62
+ /** The `ETag` of one derived icon: the source's hash plus the representation. */
63
+ export declare function mediaCdnIconEtag(contentHash: string, spec: SiteIconSpec, background: string | undefined): string | null;
64
+ export declare function serveMediaCdnIcon(options: {
65
+ req: NextApiRequest;
66
+ res: NextApiResponse;
67
+ spec: SiteIconSpec;
68
+ source: MediaCdnIconSource;
69
+ /** Forces `private, no-store` on a signed asset, as the handler's does. */
70
+ setCacheControl: (value: string) => void;
71
+ cacheControl: {
72
+ stable: string;
73
+ immutable: string;
74
+ };
75
+ /** The caller's delivery count; `null` means serve. */
76
+ rateLimit: () => Promise<MediaCdnRateLimitRefusal | null>;
77
+ }): Promise<void>;
@@ -0,0 +1,140 @@
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
+ */ import { formatSiteIconSpec, normalizeSiteIconBackground, SITE_ICON_BACKGROUND_PARAM, SITE_ICON_PARAM, SITE_ICON_VERSION_PARAM, siteIconSourceTypeDerivable, siteIconUsesBackground, DEFAULT_SITE_ICON_BACKGROUND } from "@aglyn/aglyn/app-utils/site-icon-set";
17
+ import { MEDIA_VARIANT_SOURCE_MAX_BYTES } from "./media-variants.js";
18
+ import { renderSiteIcon } from "./site-icon-render.js";
19
+ const firstValue = (value)=>{
20
+ const raw = Array.isArray(value) ? value[0] : value;
21
+ return typeof raw === 'string' && raw !== '' ? raw : undefined;
22
+ };
23
+ /**
24
+ * The query this representation names, rebuilt from the parameters it reads —
25
+ * never forwarded whole, for `mediaCdnForwardedQuery`'s reasons. `exp`/`sig`
26
+ * ride along so a signed asset's redirect still serves.
27
+ */ function iconQuery(query, pairs) {
28
+ const params = new URLSearchParams();
29
+ for (const [key, value] of pairs)if (value) params.set(key, value);
30
+ for (const key of [
31
+ 'exp',
32
+ 'sig'
33
+ ]){
34
+ const value = firstValue(query[key]);
35
+ if (value) params.set(key, value);
36
+ }
37
+ const encoded = params.toString();
38
+ return encoded ? `?${encoded}` : '';
39
+ }
40
+ /** The `ETag` of one derived icon: the source's hash plus the representation. */ export function mediaCdnIconEtag(contentHash, spec, background) {
41
+ if (!contentHash) return null;
42
+ const plate = background ? `-${background}` : '';
43
+ return `"${contentHash}-icon-${formatSiteIconSpec(spec)}${plate}"`;
44
+ }
45
+ export async function serveMediaCdnIcon(options) {
46
+ var _normalizeSiteIconBackground, _firstValue, _metadata_size;
47
+ const { req, res, spec, source, setCacheControl, cacheControl } = options;
48
+ const background = siteIconUsesBackground(spec) ? (_normalizeSiteIconBackground = normalizeSiteIconBackground(`#${(_firstValue = firstValue(req.query[SITE_ICON_BACKGROUND_PARAM])) != null ? _firstValue : ''}`)) != null ? _normalizeSiteIconBackground : DEFAULT_SITE_ICON_BACKGROUND : undefined;
49
+ const version = firstValue(req.query[SITE_ICON_VERSION_PARAM]);
50
+ const { contentHash } = source;
51
+ const original = (cache)=>{
52
+ res.removeHeader('ETag');
53
+ setCacheControl(cache);
54
+ res.setHeader('Location', `${source.stablePath}${iconQuery(req.query, [])}`);
55
+ res.status(302).end();
56
+ };
57
+ if (!siteIconSourceTypeDerivable(source.contentType)) {
58
+ original(cacheControl.stable);
59
+ return;
60
+ }
61
+ if (version && contentHash && version !== contentHash) {
62
+ setCacheControl(cacheControl.stable);
63
+ res.setHeader('Location', `${source.stablePath}${iconQuery(req.query, [
64
+ [
65
+ SITE_ICON_PARAM,
66
+ formatSiteIconSpec(spec)
67
+ ],
68
+ [
69
+ SITE_ICON_BACKGROUND_PARAM,
70
+ background
71
+ ],
72
+ [
73
+ SITE_ICON_VERSION_PARAM,
74
+ contentHash
75
+ ]
76
+ ])}`);
77
+ res.status(302).end();
78
+ return;
79
+ }
80
+ const etag = mediaCdnIconEtag(contentHash, spec, background);
81
+ setCacheControl(version && version === contentHash ? cacheControl.immutable : cacheControl.stable);
82
+ if (etag) {
83
+ res.setHeader('ETag', etag);
84
+ if (req.headers['if-none-match'] === etag) {
85
+ res.status(304).end();
86
+ return;
87
+ }
88
+ }
89
+ const counted = req.method === 'GET' ? options.rateLimit() : null;
90
+ const [metadata] = await source.file.getMetadata().catch(()=>[
91
+ null
92
+ ]);
93
+ if (!metadata) {
94
+ res.removeHeader('ETag');
95
+ setCacheControl('public, max-age=60');
96
+ res.status(404).json({
97
+ error: 'Not found'
98
+ });
99
+ return;
100
+ }
101
+ if (Number((_metadata_size = metadata.size) != null ? _metadata_size : 0) > MEDIA_VARIANT_SOURCE_MAX_BYTES) {
102
+ original(cacheControl.stable);
103
+ return;
104
+ }
105
+ const refused = await counted;
106
+ if (refused) {
107
+ res.removeHeader('ETag');
108
+ res.setHeader('Cache-Control', 'no-store');
109
+ res.setHeader('Retry-After', String(refused.retryAfterSeconds));
110
+ res.status(429).json({
111
+ error: 'Too many requests'
112
+ });
113
+ return;
114
+ }
115
+ let icon;
116
+ try {
117
+ const [bytes] = await source.file.download();
118
+ icon = await renderSiteIcon({
119
+ source: bytes,
120
+ spec,
121
+ background
122
+ });
123
+ } catch (error) {
124
+ // A file that will not decode is a property of the upload, not of the
125
+ // request, but it is not cached either: a Replace must be able to fix it.
126
+ console.error('[media-cdn] site icon render failed', source.stablePath, error);
127
+ original('no-store');
128
+ return;
129
+ }
130
+ res.setHeader('Content-Type', icon.contentType);
131
+ res.setHeader('Content-Length', String(icon.body.length));
132
+ res.setHeader('Content-Disposition', `inline; filename="icon-${formatSiteIconSpec(spec)}.${spec.plate === 'ico' ? 'ico' : 'png'}"`);
133
+ if (req.method === 'HEAD') {
134
+ res.status(200).end();
135
+ return;
136
+ }
137
+ res.status(200).end(icon.body);
138
+ }
139
+
140
+ //# sourceMappingURL=media-cdn-icon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/media-cdn-icon.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 {\n formatSiteIconSpec,\n normalizeSiteIconBackground,\n SITE_ICON_BACKGROUND_PARAM,\n SITE_ICON_PARAM,\n SITE_ICON_VERSION_PARAM,\n siteIconSourceTypeDerivable,\n siteIconUsesBackground,\n DEFAULT_SITE_ICON_BACKGROUND,\n type SiteIconSpec,\n} from '@aglyn/aglyn/app-utils/site-icon-set'\nimport type { NextApiRequest, NextApiResponse } from 'next'\n\nimport type { MediaCdnRateLimitRefusal } from './media-cdn-rate-limit'\nimport { MEDIA_VARIANT_SOURCE_MAX_BYTES } from './media-variants'\nimport { renderSiteIcon } from './site-icon-render'\n\n/**\n * The `?icon=` representation of a media CDN URL (AGL-3484): one icon of a\n * site's derived set, drawn from the asset's current bytes.\n *\n * `serveMediaCdn` calls this only after every gate it applies to the bytes —\n * scope, lockdown, quarantine, the private signature and the stale-pin\n * redirect — so an icon is served exactly where its source would be.\n *\n * ## Derived once, cached by version\n *\n * The URL a page names carries `v`, the asset's `contentHash`. While `v` is\n * current the icon is drawn once and held for a year by the edge and the\n * browser (`immutable`): the same URL can never need different bytes, because\n * a DAM Replace changes the hash and so the URL the next render names. A\n * stale `v` 302s to the current one under the stable URL's revalidated policy,\n * which is how a page rendered before a Replace reaches the new icon. A URL\n * with no `v` (the source's hash is unknown) is served under the stable\n * policy with an ETag keyed on the hash, exactly as the asset itself is.\n *\n * ## Anything the renderer cannot draw falls back to the original\n *\n * A non-image, an ICO (which `sharp` cannot decode), a source over the\n * variant generator's fetch ceiling, or a file that fails to decode: each\n * 302s to the asset's plain URL. A favicon link must never be the reason a\n * tab shows nothing.\n */\n\n/** The fields of a stored asset this representation reads. */\nexport interface MediaCdnIconSource {\n /** The media document's `contentType`. */\n contentType: unknown\n /** The media document's `contentHash`; `''` for a legacy asset. */\n contentHash: string\n /** `/api/media/cdn/{scope}/{mediaId}` — the asset's stable URL. */\n stablePath: string\n /** The Storage object, as far as this reads it. */\n file: {\n getMetadata(): Promise<[{ size?: string | number } | null | undefined]>\n download(): Promise<[Buffer]>\n }\n}\n\nconst firstValue = (value: unknown): string | undefined => {\n const raw = Array.isArray(value) ? value[0] : value\n return typeof raw === 'string' && raw !== '' ? raw : undefined\n}\n\n/**\n * The query this representation names, rebuilt from the parameters it reads —\n * never forwarded whole, for `mediaCdnForwardedQuery`'s reasons. `exp`/`sig`\n * ride along so a signed asset's redirect still serves.\n */\nfunction iconQuery(\n query: NextApiRequest['query'],\n pairs: Array<[string, string | undefined]>,\n): string {\n const params = new URLSearchParams()\n for (const [key, value] of pairs) if (value) params.set(key, value)\n for (const key of ['exp', 'sig']) {\n const value = firstValue(query[key])\n if (value) params.set(key, value)\n }\n const encoded = params.toString()\n return encoded ? `?${encoded}` : ''\n}\n\n/** The `ETag` of one derived icon: the source's hash plus the representation. */\nexport function mediaCdnIconEtag(\n contentHash: string,\n spec: SiteIconSpec,\n background: string | undefined,\n): string | null {\n if (!contentHash) return null\n const plate = background ? `-${background}` : ''\n return `\"${contentHash}-icon-${formatSiteIconSpec(spec)}${plate}\"`\n}\n\nexport async function serveMediaCdnIcon(options: {\n req: NextApiRequest\n res: NextApiResponse\n spec: SiteIconSpec\n source: MediaCdnIconSource\n /** Forces `private, no-store` on a signed asset, as the handler's does. */\n setCacheControl: (value: string) => void\n cacheControl: { stable: string; immutable: string }\n /** The caller's delivery count; `null` means serve. */\n rateLimit: () => Promise<MediaCdnRateLimitRefusal | null>\n}): Promise<void> {\n const { req, res, spec, source, setCacheControl, cacheControl } = options\n const background = siteIconUsesBackground(spec)\n ? (normalizeSiteIconBackground(\n `#${firstValue(req.query[SITE_ICON_BACKGROUND_PARAM]) ?? ''}`,\n ) ?? DEFAULT_SITE_ICON_BACKGROUND)\n : undefined\n const version = firstValue(req.query[SITE_ICON_VERSION_PARAM])\n const { contentHash } = source\n\n const original = (cache: string) => {\n res.removeHeader('ETag')\n setCacheControl(cache)\n res.setHeader('Location', `${source.stablePath}${iconQuery(req.query, [])}`)\n res.status(302).end()\n }\n\n if (!siteIconSourceTypeDerivable(source.contentType)) {\n original(cacheControl.stable)\n return\n }\n if (version && contentHash && version !== contentHash) {\n setCacheControl(cacheControl.stable)\n res.setHeader(\n 'Location',\n `${source.stablePath}${iconQuery(req.query, [\n [SITE_ICON_PARAM, formatSiteIconSpec(spec)],\n [SITE_ICON_BACKGROUND_PARAM, background],\n [SITE_ICON_VERSION_PARAM, contentHash],\n ])}`,\n )\n res.status(302).end()\n return\n }\n\n const etag = mediaCdnIconEtag(contentHash, spec, background)\n setCacheControl(\n version && version === contentHash\n ? cacheControl.immutable\n : cacheControl.stable,\n )\n if (etag) {\n res.setHeader('ETag', etag)\n if (req.headers['if-none-match'] === etag) {\n res.status(304).end()\n return\n }\n }\n\n const counted = req.method === 'GET' ? options.rateLimit() : null\n const [metadata] = await source.file.getMetadata().catch(() => [null])\n if (!metadata) {\n res.removeHeader('ETag')\n setCacheControl('public, max-age=60')\n res.status(404).json({ error: 'Not found' })\n return\n }\n if (Number(metadata.size ?? 0) > MEDIA_VARIANT_SOURCE_MAX_BYTES) {\n original(cacheControl.stable)\n return\n }\n const refused = await counted\n if (refused) {\n res.removeHeader('ETag')\n res.setHeader('Cache-Control', 'no-store')\n res.setHeader('Retry-After', String(refused.retryAfterSeconds))\n res.status(429).json({ error: 'Too many requests' })\n return\n }\n\n let icon: { body: Buffer; contentType: string }\n try {\n const [bytes] = await source.file.download()\n icon = await renderSiteIcon({ source: bytes, spec, background })\n } catch (error) {\n // A file that will not decode is a property of the upload, not of the\n // request, but it is not cached either: a Replace must be able to fix it.\n console.error('[media-cdn] site icon render failed', source.stablePath, error)\n original('no-store')\n return\n }\n\n res.setHeader('Content-Type', icon.contentType)\n res.setHeader('Content-Length', String(icon.body.length))\n res.setHeader(\n 'Content-Disposition',\n `inline; filename=\"icon-${formatSiteIconSpec(spec)}.${\n spec.plate === 'ico' ? 'ico' : 'png'\n }\"`,\n )\n if (req.method === 'HEAD') {\n res.status(200).end()\n return\n }\n res.status(200).end(icon.body)\n}\n"],"names":["formatSiteIconSpec","normalizeSiteIconBackground","SITE_ICON_BACKGROUND_PARAM","SITE_ICON_PARAM","SITE_ICON_VERSION_PARAM","siteIconSourceTypeDerivable","siteIconUsesBackground","DEFAULT_SITE_ICON_BACKGROUND","MEDIA_VARIANT_SOURCE_MAX_BYTES","renderSiteIcon","firstValue","value","raw","Array","isArray","undefined","iconQuery","query","pairs","params","URLSearchParams","key","set","encoded","toString","mediaCdnIconEtag","contentHash","spec","background","plate","serveMediaCdnIcon","options","metadata","req","res","source","setCacheControl","cacheControl","version","original","cache","removeHeader","setHeader","stablePath","status","end","contentType","stable","etag","immutable","headers","counted","method","rateLimit","file","getMetadata","catch","json","error","Number","size","refused","String","retryAfterSeconds","icon","bytes","download","console","body","length"],"mappings":"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"}
@@ -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>;