@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.
- package/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
* HOW THIS PROCESS DROPS ONE SITE'S CACHED PAGES, for a write that worked out
|
|
18
|
+
* which of them it made stale (AGL-3113, AGL-3080).
|
|
19
|
+
*
|
|
20
|
+
* A record write — a dataset row, a collection entry — refreshes the pages
|
|
21
|
+
* that show it, and nothing else. Working out WHICH pages is the writer's: the
|
|
22
|
+
* data plugin walks the pages that repeat over a dataset, the collection scan
|
|
23
|
+
* the pages that list an entry. HOW a page is dropped is the runtime's, and
|
|
24
|
+
* the two runtimes do it differently: the tenant drops its own caches in
|
|
25
|
+
* process, and the console announces over the shared secret and passes its own
|
|
26
|
+
* dropper at the call site. So the deployment that owns the caches registers
|
|
27
|
+
* the dropper here, and a writer reads it; neither names the other.
|
|
28
|
+
*
|
|
29
|
+
* ## One per process, on `globalThis`
|
|
30
|
+
*
|
|
31
|
+
* The tenant registers from `instrumentation.ts`, by name, at boot. Next
|
|
32
|
+
* compiles that file apart from the routes, so a dropper held in a module
|
|
33
|
+
* `let` was set in the boot's copy of this module and read as missing by
|
|
34
|
+
* every route's copy — a form submission or an automation step that wrote a
|
|
35
|
+
* row logged `no-live-page-dropper` and left the pages stale for the rest of
|
|
36
|
+
* their window (AGL-3412 was the same split for the plugin services). Keyed on
|
|
37
|
+
* `globalThis` under `Symbol.for`, every copy in a realm reads the one slot.
|
|
38
|
+
*/ /** One site's share of a change: the pages on it that must be dropped. */ const DROPPER_KEY = Symbol.for('@aglyn/tenant-data-admin:live-page-dropper');
|
|
39
|
+
const globalScope = globalThis;
|
|
40
|
+
/**
|
|
41
|
+
* Registers the dropper for this process, replacing any previous one. Returns
|
|
42
|
+
* the unregister, which removes it only if it is still the one registered.
|
|
43
|
+
*
|
|
44
|
+
* Called from a function the platform calls BY NAME at boot, never from a
|
|
45
|
+
* module's top level: a bundler deletes a module imported only for its side
|
|
46
|
+
* effect when its package says it has none (AGL-3025).
|
|
47
|
+
*/ export function registerLivePageDropper(drop) {
|
|
48
|
+
globalScope[DROPPER_KEY] = drop;
|
|
49
|
+
return ()=>{
|
|
50
|
+
if (globalScope[DROPPER_KEY] === drop) delete globalScope[DROPPER_KEY];
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** The dropper this process registered, or `undefined` when none did. */ export function registeredLivePageDropper() {
|
|
54
|
+
return globalScope[DROPPER_KEY];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=live-page-drops.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/live-page-drops.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\n/**\n * HOW THIS PROCESS DROPS ONE SITE'S CACHED PAGES, for a write that worked out\n * which of them it made stale (AGL-3113, AGL-3080).\n *\n * A record write — a dataset row, a collection entry — refreshes the pages\n * that show it, and nothing else. Working out WHICH pages is the writer's: the\n * data plugin walks the pages that repeat over a dataset, the collection scan\n * the pages that list an entry. HOW a page is dropped is the runtime's, and\n * the two runtimes do it differently: the tenant drops its own caches in\n * process, and the console announces over the shared secret and passes its own\n * dropper at the call site. So the deployment that owns the caches registers\n * the dropper here, and a writer reads it; neither names the other.\n *\n * ## One per process, on `globalThis`\n *\n * The tenant registers from `instrumentation.ts`, by name, at boot. Next\n * compiles that file apart from the routes, so a dropper held in a module\n * `let` was set in the boot's copy of this module and read as missing by\n * every route's copy — a form submission or an automation step that wrote a\n * row logged `no-live-page-dropper` and left the pages stale for the rest of\n * their window (AGL-3412 was the same split for the plugin services). Keyed on\n * `globalThis` under `Symbol.for`, every copy in a realm reads the one slot.\n */\n\n/** One site's share of a change: the pages on it that must be dropped. */\nexport interface LivePageTarget {\n hostId: string\n /** The tenant keys its cache on this, never on `hostId`. */\n subdomain: string\n /** The attached custom domain, whose pages live under a second cache key. */\n cname?: string\n /** Site-absolute addresses (`/`, `/team`) the change makes stale. */\n paths: string[]\n /** The site held more documents than the scan read, so `paths` is partial. */\n truncated: boolean\n}\n\n/**\n * Drops one site's cached pages. Returns whether the drop landed. It must\n * never throw: everything above it has already been written.\n */\nexport type LivePageDropper = (target: LivePageTarget) => Promise<boolean>\n\nconst DROPPER_KEY = Symbol.for('@aglyn/tenant-data-admin:live-page-dropper')\n\nconst globalScope = globalThis as typeof globalThis & {\n [DROPPER_KEY]?: LivePageDropper\n}\n\n/**\n * Registers the dropper for this process, replacing any previous one. Returns\n * the unregister, which removes it only if it is still the one registered.\n *\n * Called from a function the platform calls BY NAME at boot, never from a\n * module's top level: a bundler deletes a module imported only for its side\n * effect when its package says it has none (AGL-3025).\n */\nexport function registerLivePageDropper(drop: LivePageDropper): () => void {\n globalScope[DROPPER_KEY] = drop\n return () => {\n if (globalScope[DROPPER_KEY] === drop) delete globalScope[DROPPER_KEY]\n }\n}\n\n/** The dropper this process registered, or `undefined` when none did. */\nexport function registeredLivePageDropper(): LivePageDropper | undefined {\n return globalScope[DROPPER_KEY]\n}\n"],"names":["DROPPER_KEY","Symbol","for","globalScope","globalThis","registerLivePageDropper","drop","registeredLivePageDropper"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GAED,wEAAwE,GAmBxE,MAAMA,cAAcC,OAAOC,GAAG,CAAC;AAE/B,MAAMC,cAAcC;AAIpB;;;;;;;CAOC,GACD,OAAO,SAASC,wBAAwBC,IAAqB;IAC3DH,WAAW,CAACH,YAAY,GAAGM;IAC3B,OAAO;QACL,IAAIH,WAAW,CAACH,YAAY,KAAKM,MAAM,OAAOH,WAAW,CAACH,YAAY;IACxE;AACF;AAEA,uEAAuE,GACvE,OAAO,SAASO;IACd,OAAOJ,WAAW,CAACH,YAAY;AACjC"}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* The Free cap, engaged by the media CDN (AGL-3474).
|
|
19
|
+
*
|
|
20
|
+
* The cap's marker (`orgs/{id}.bandwidthCap`) had two writers, and both are
|
|
21
|
+
* driven by PAGES: the analytics beacon, on a sample of page views, and the
|
|
22
|
+
* daily usage sweep. Video and files count against the same band now, but a
|
|
23
|
+
* film or a PDF linked from somewhere else is served with no page view at
|
|
24
|
+
* all — so a Free org whose traffic is its media would cross its band and
|
|
25
|
+
* keep serving until the next morning's sweep. A day of one popular clip is
|
|
26
|
+
* hundreds of gigabytes.
|
|
27
|
+
*
|
|
28
|
+
* So the CDN is a third writer, on the same terms as the other two: the same
|
|
29
|
+
* `bandwidthCapShouldEngage` predicate, the same marker fields, the same UTC
|
|
30
|
+
* month key, and a write only when the marker does not already name this
|
|
31
|
+
* month. A reader cannot tell which writer stamped a marker.
|
|
32
|
+
*
|
|
33
|
+
* ## The month it totals
|
|
34
|
+
*
|
|
35
|
+
* Org-wide, because the band is: every site's page views and counted media,
|
|
36
|
+
* plus the org library's media, read from the same analytics day documents
|
|
37
|
+
* the invoice reads, through the same {@link analyticsBandwidthReading}.
|
|
38
|
+
*
|
|
39
|
+
* ## What it costs
|
|
40
|
+
*
|
|
41
|
+
* Nothing for a paying org: the CDN asks only for an org whose plan the cap
|
|
42
|
+
* stops (`bandwidthCapApplies`, decided off the org document its delivery
|
|
43
|
+
* verdict already holds). For those, one evaluation per
|
|
44
|
+
* {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} an instance serves — the org
|
|
45
|
+
* document, its site ids and the month's day documents, a few dozen reads
|
|
46
|
+
* against about three cents of delivery.
|
|
47
|
+
*
|
|
48
|
+
* ## The slop, stated
|
|
49
|
+
*
|
|
50
|
+
* Each instance evaluates on its first counted request for an org and then
|
|
51
|
+
* once per {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES}, so the cap engages at
|
|
52
|
+
* most that much per instance past the band, and the CDN's own verdict cache
|
|
53
|
+
* takes up to fifteen seconds to see the marker. The beacon's slop is the
|
|
54
|
+
* same shape, measured in views.
|
|
55
|
+
*/
|
|
56
|
+
/** How many counted bytes an instance serves an org between evaluations. */
|
|
57
|
+
export declare const MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES: number;
|
|
58
|
+
/** Drop the per-instance state. Tests need it between cases. */
|
|
59
|
+
export declare function invalidateMediaBandwidthCapState(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Note `bytes` counted for `orgId`, and say whether this instance should now
|
|
62
|
+
* total the org's month.
|
|
63
|
+
*
|
|
64
|
+
* Due on the first counted request an instance sees for the org — a cold
|
|
65
|
+
* instance under a spike evaluates at once, which is when it matters — and
|
|
66
|
+
* then each time another {@link MEDIA_BANDWIDTH_EVALUATE_EVERY_BYTES} has
|
|
67
|
+
* gone out. Never due again in a month this instance has seen it engaged.
|
|
68
|
+
*/
|
|
69
|
+
export declare function mediaBandwidthEvaluationDue(orgId: string, bytes: number, now?: Date): boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Total `orgId`'s month and engage the cap if it is past the band. Answers
|
|
72
|
+
* whether THIS call engaged it.
|
|
73
|
+
*
|
|
74
|
+
* Best-effort, like the beacon's writer: it runs beside a delivery that has
|
|
75
|
+
* already been decided, and a failure here is logged and costs the request
|
|
76
|
+
* nothing.
|
|
77
|
+
*/
|
|
78
|
+
export declare function engageMediaBandwidthCap(orgId: string, now?: Date): Promise<boolean>;
|
|
@@ -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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"}
|