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

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.
@@ -0,0 +1,98 @@
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 LeavingNoticeConfig } from '@aglyn/aglyn/app-utils/leaving-notice';
18
+ import { type SiteReturnHost } from '@aglyn/aglyn/app-utils/site-return-url';
19
+ /** How long a new free workspace's sites carry the notice. */
20
+ export declare const LEAVING_NOTICE_DAYS = 14;
21
+ /**
22
+ * The most outside addresses one page is signed for. A page with more is a
23
+ * link farm, and the ones past the cap still go through the notice — which
24
+ * refuses them — rather than around it.
25
+ */
26
+ export declare const LEAVING_NOTICE_MAX_DESTINATIONS = 500;
27
+ /**
28
+ * When a workspace's notice window closes (epoch ms), or `null` when its
29
+ * sites carry no notice: a paid plan, a workspace past its first
30
+ * {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.
31
+ *
32
+ * Takes the org as loaded anywhere — a live document, or the render cache's
33
+ * JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).
34
+ */
35
+ export declare function leavingNoticeEndsAt(org: unknown, nowMs?: number): number | null;
36
+ /**
37
+ * The hosts a link may reach without the notice: the site's own (its
38
+ * subdomain, its custom domain and `www.` of it) and the platform's own — the
39
+ * marketing site, console, support and docs, each with every name under its
40
+ * registrable domain, plus the bare tenant apex.
41
+ *
42
+ * Other sites on the tenant apex are NOT exempt. They are other customers'
43
+ * sites, and a chain of two new free sites is the same hop as one.
44
+ */
45
+ export declare function leavingNoticeHosts(site: SiteReturnHost | null | undefined): string[];
46
+ /**
47
+ * Every outside address a rendered page could link to: each string in the
48
+ * payload that is one, and each one embedded in a string (Markdown, rich
49
+ * text HTML, a plain sentence). Over-collecting is harmless — a signature
50
+ * nobody follows is never read — and under-collecting only sends a link to a
51
+ * refusal, never around the notice.
52
+ *
53
+ * Returned normalized, the spelling the client looks signatures up by.
54
+ */
55
+ export declare function collectLeavingDestinations(value: unknown, hosts: readonly string[], limit?: number): string[];
56
+ /**
57
+ * The signature for one destination on one site. Throws when the deployment
58
+ * has no signing secret, like every signer of it.
59
+ */
60
+ export declare function signLeavingDestination(hostId: string, destination: string): string;
61
+ /**
62
+ * Whether `signature` was issued for `destination` on this site. False for
63
+ * anything else — another site's signature, an edited address, a missing
64
+ * secret (fail closed).
65
+ */
66
+ export declare function verifyLeavingDestination(hostId: string, destination: unknown, signature: unknown): boolean;
67
+ /** Signs a page's destinations for one site. */
68
+ export declare function signLeavingDestinations(hostId: string, destinations: readonly string[]): Record<string, string>;
69
+ /**
70
+ * What a published page of this site carries, or `null` when the site is not
71
+ * in its window.
72
+ *
73
+ * `content` is everything the page renders from — its props, its nodes —
74
+ * walked for outside addresses to sign.
75
+ *
76
+ * A deployment with no signing secret gets no notice, loudly. The route
77
+ * could verify nothing there and would refuse every link on every new free
78
+ * site; an outage of every outbound link is not a safer failure than the
79
+ * links working as they did before this existed.
80
+ */
81
+ export declare function leavingNoticeConfig(options: {
82
+ hostId: string | null | undefined;
83
+ site: SiteReturnHost | null | undefined;
84
+ org: unknown;
85
+ content?: unknown;
86
+ nowMs?: number;
87
+ }): LeavingNoticeConfig | null;
88
+ /**
89
+ * Only the signatures, for a payload that arrives after its page — a gated
90
+ * screen's nodes (`enrichGatedScreenPage`). `null` outside the window.
91
+ */
92
+ export declare function leavingNoticeSignatures(options: {
93
+ hostId: string | null | undefined;
94
+ site: SiteReturnHost | null | undefined;
95
+ org: unknown;
96
+ content: unknown;
97
+ nowMs?: number;
98
+ }): Record<string, string> | null;
@@ -0,0 +1,250 @@
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
+ * THE "YOU'RE LEAVING THIS SITE" NOTICE, SERVER HALF (AGL-3452).
18
+ *
19
+ * Who gets it, which hosts are exempt, and the signature that keeps the
20
+ * notice route from becoming an open redirect. The client half — rewriting
21
+ * an href, the click interceptor — is `@aglyn/aglyn/app-utils/leaving-notice`.
22
+ *
23
+ * ## Who
24
+ *
25
+ * A site whose workspace is on the FREE plan (the effective plan, so a dead
26
+ * subscription counts as free, as it does for every entitlement) and in its
27
+ * first {@link LEAVING_NOTICE_DAYS} days — the same window, read through the
28
+ * same `isYoungWorkspaceAge`, as the soft phishing rules. Paid and older
29
+ * workspaces are untouched, and a workspace whose creation date cannot be
30
+ * read is an existing customer, never a new one.
31
+ *
32
+ * ## Why signed
33
+ *
34
+ * The notice lives on the site's own host and its Continue button goes
35
+ * wherever `?to=` says. Unsigned, any site's notice would be a page that
36
+ * vouches for any address anyone puts in a link — the hop this feature exists
37
+ * to remove. So the server signs each outside address it finds on a page it
38
+ * renders, bound to that site, and the route shows a Continue button only for
39
+ * an address whose signature checks out. Everything else is refused.
40
+ *
41
+ * The signature carries no expiry on purpose. The route never redirects on
42
+ * its own, so a signature that outlives its page buys nothing but one more
43
+ * view of the same warning — while an expiring one would break the links an
44
+ * ISR-cached page is still serving the moment the window closes.
45
+ *=========================================*/ import { createHmac } from "crypto";
46
+ import { DOCS_BASE_URL } from "@aglyn/aglyn/app-utils/docs-help";
47
+ import { leavingDestination, normalizeLeavingDestination } from "@aglyn/aglyn/app-utils/leaving-notice";
48
+ import { resolveEffectivePlan } from "@aglyn/aglyn/app-utils/plan-entitlements";
49
+ import { PLATFORM_HOME_URL, PLATFORM_SUPPORT_URL, platformConsoleOrigin } from "@aglyn/aglyn/app-utils/platform-brand";
50
+ import { siteOwnHosts } from "@aglyn/aglyn/app-utils/site-return-url";
51
+ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
52
+ import { isYoungWorkspaceAge, OUTBOUND_REVIEW_YOUNG_DAYS, registrableDomain } from "@aglyn/shared-util-email/outbound-phishing-screen";
53
+ import { tokenSigningSecret } from "./media-signing.js";
54
+ import { orgAgeDays, orgCreatedMs } from "./org-age.js";
55
+ import { safeEqual } from "./safe-equal.js";
56
+ /** How long a new free workspace's sites carry the notice. */ export const LEAVING_NOTICE_DAYS = OUTBOUND_REVIEW_YOUNG_DAYS;
57
+ const DAY_MS = 86400000;
58
+ /**
59
+ * The most outside addresses one page is signed for. A page with more is a
60
+ * link farm, and the ones past the cap still go through the notice — which
61
+ * refuses them — rather than around it.
62
+ */ export const LEAVING_NOTICE_MAX_DESTINATIONS = 500;
63
+ /** Deepest a page payload is walked; composed node maps sit far above it. */ const MAX_WALK_DEPTH = 64;
64
+ /** Longest string scanned for embedded addresses (a long entry body). */ const MAX_SCANNED_STRING = 200000;
65
+ /**
66
+ * When a workspace's notice window closes (epoch ms), or `null` when its
67
+ * sites carry no notice: a paid plan, a workspace past its first
68
+ * {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.
69
+ *
70
+ * Takes the org as loaded anywhere — a live document, or the render cache's
71
+ * JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).
72
+ */ export function leavingNoticeEndsAt(org, nowMs = Date.now()) {
73
+ if (!org || typeof org !== 'object') return null;
74
+ if (resolveEffectivePlan(org) !== 'free') return null;
75
+ const createdAt = org.createdAt;
76
+ if (!isYoungWorkspaceAge(orgAgeDays(createdAt, nowMs))) return null;
77
+ const createdMs = orgCreatedMs(createdAt);
78
+ return createdMs === null ? null : createdMs + LEAVING_NOTICE_DAYS * DAY_MS;
79
+ }
80
+ function hostnameOf(raw) {
81
+ if (!raw) return null;
82
+ try {
83
+ const url = new URL(raw);
84
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
85
+ return url.hostname.toLowerCase() || null;
86
+ } catch (unused) {
87
+ return null;
88
+ }
89
+ }
90
+ /**
91
+ * The hosts a link may reach without the notice: the site's own (its
92
+ * subdomain, its custom domain and `www.` of it) and the platform's own — the
93
+ * marketing site, console, support and docs, each with every name under its
94
+ * registrable domain, plus the bare tenant apex.
95
+ *
96
+ * Other sites on the tenant apex are NOT exempt. They are other customers'
97
+ * sites, and a chain of two new free sites is the same hop as one.
98
+ */ export function leavingNoticeHosts(site) {
99
+ const hosts = new Set(siteOwnHosts(site));
100
+ const apex = TENANT_APEX.toLowerCase();
101
+ hosts.add(apex);
102
+ const apexRegistrable = registrableDomain(apex);
103
+ for (const raw of [
104
+ PLATFORM_HOME_URL,
105
+ platformConsoleOrigin(),
106
+ PLATFORM_SUPPORT_URL,
107
+ DOCS_BASE_URL
108
+ ]){
109
+ const hostname = hostnameOf(raw);
110
+ if (!hostname) continue;
111
+ hosts.add(hostname);
112
+ const registrable = registrableDomain(hostname);
113
+ // A platform name under the tenant apex itself must not exempt the apex:
114
+ // that would exempt every customer's site with it.
115
+ if (registrable.includes('.') && registrable !== apexRegistrable) {
116
+ hosts.add(`.${registrable}`);
117
+ }
118
+ }
119
+ return [
120
+ ...hosts
121
+ ];
122
+ }
123
+ /** An address as it appears in text: scheme, then anything a URL may hold. */ const EMBEDDED_URL = /https?:\/\/[^\s"'<>()[\]{}\\^`|]+/gi;
124
+ /** Characters prose puts after an address that the address does not own. */ const TRAILING_PROSE = /[.,;:!?'"*_~]+$/;
125
+ /** A whole value shaped like an absolute or protocol-relative address. */ const ADDRESS_SHAPE = /^\s*(?:https?:|[\\/]{2})/i;
126
+ /**
127
+ * Every outside address a rendered page could link to: each string in the
128
+ * payload that is one, and each one embedded in a string (Markdown, rich
129
+ * text HTML, a plain sentence). Over-collecting is harmless — a signature
130
+ * nobody follows is never read — and under-collecting only sends a link to a
131
+ * refusal, never around the notice.
132
+ *
133
+ * Returned normalized, the spelling the client looks signatures up by.
134
+ */ export function collectLeavingDestinations(value, hosts, limit = LEAVING_NOTICE_MAX_DESTINATIONS) {
135
+ const found = new Set();
136
+ const consider = (candidate)=>{
137
+ if (found.size >= limit) return;
138
+ const destination = leavingDestination(candidate, hosts);
139
+ if (destination) found.add(destination);
140
+ };
141
+ const scan = (text)=>{
142
+ if (ADDRESS_SHAPE.test(text)) consider(text);
143
+ if (!text.includes('http')) return;
144
+ const scanned = text.length > MAX_SCANNED_STRING ? text.slice(0, MAX_SCANNED_STRING) : text;
145
+ for (const match of scanned.matchAll(EMBEDDED_URL)){
146
+ const raw = match[0];
147
+ consider(raw);
148
+ const trimmed = raw.replace(TRAILING_PROSE, '');
149
+ if (trimmed !== raw) consider(trimmed);
150
+ // An href inside stored HTML is entity-encoded; the DOM decodes it.
151
+ if (trimmed.includes('&amp;')) consider(trimmed.replace(/&amp;/g, '&'));
152
+ }
153
+ };
154
+ const walk = (node, depth)=>{
155
+ if (found.size >= limit || depth > MAX_WALK_DEPTH) return;
156
+ if (typeof node === 'string') {
157
+ scan(node);
158
+ } else if (Array.isArray(node)) {
159
+ for (const item of node)walk(item, depth + 1);
160
+ } else if (node && typeof node === 'object') {
161
+ for (const item of Object.values(node))walk(item, depth + 1);
162
+ }
163
+ };
164
+ walk(value, 0);
165
+ return [
166
+ ...found
167
+ ];
168
+ }
169
+ function leavingSignature(hostId, destination) {
170
+ return createHmac('sha256', tokenSigningSecret()).update(`leaving-notice:v1:${hostId}:${destination}`).digest('base64url');
171
+ }
172
+ /**
173
+ * The signature for one destination on one site. Throws when the deployment
174
+ * has no signing secret, like every signer of it.
175
+ */ export function signLeavingDestination(hostId, destination) {
176
+ const normalized = normalizeLeavingDestination(destination);
177
+ if (!hostId || !normalized) {
178
+ throw new Error('A leaving-notice signature needs a site and an address');
179
+ }
180
+ return leavingSignature(hostId, normalized);
181
+ }
182
+ /**
183
+ * Whether `signature` was issued for `destination` on this site. False for
184
+ * anything else — another site's signature, an edited address, a missing
185
+ * secret (fail closed).
186
+ */ export function verifyLeavingDestination(hostId, destination, signature) {
187
+ if (!hostId || typeof signature !== 'string' || signature.length > 128) {
188
+ return false;
189
+ }
190
+ const normalized = normalizeLeavingDestination(destination);
191
+ if (!normalized) return false;
192
+ let expected;
193
+ try {
194
+ expected = leavingSignature(hostId, normalized);
195
+ } catch (unused) {
196
+ return false;
197
+ }
198
+ return safeEqual(signature, expected);
199
+ }
200
+ /** Signs a page's destinations for one site. */ export function signLeavingDestinations(hostId, destinations) {
201
+ const sigs = {};
202
+ for (const destination of destinations){
203
+ sigs[destination] = leavingSignature(hostId, destination);
204
+ }
205
+ return sigs;
206
+ }
207
+ /** Whether this deployment can sign at all. */ function canSign() {
208
+ try {
209
+ tokenSigningSecret();
210
+ return true;
211
+ } catch (unused) {
212
+ return false;
213
+ }
214
+ }
215
+ /**
216
+ * What a published page of this site carries, or `null` when the site is not
217
+ * in its window.
218
+ *
219
+ * `content` is everything the page renders from — its props, its nodes —
220
+ * walked for outside addresses to sign.
221
+ *
222
+ * A deployment with no signing secret gets no notice, loudly. The route
223
+ * could verify nothing there and would refuse every link on every new free
224
+ * site; an outage of every outbound link is not a safer failure than the
225
+ * links working as they did before this existed.
226
+ */ export function leavingNoticeConfig(options) {
227
+ const { hostId, site, org, content } = options;
228
+ if (!hostId || !site) return null;
229
+ const until = leavingNoticeEndsAt(org, options.nowMs);
230
+ if (until === null) return null;
231
+ if (!canSign()) {
232
+ console.error('[leaving-notice] TOKEN_SIGNING_SECRET is not configured; the notice is off');
233
+ return null;
234
+ }
235
+ const hosts = leavingNoticeHosts(site);
236
+ return {
237
+ until,
238
+ hosts,
239
+ sigs: signLeavingDestinations(hostId, collectLeavingDestinations(content, hosts))
240
+ };
241
+ }
242
+ /**
243
+ * Only the signatures, for a payload that arrives after its page — a gated
244
+ * screen's nodes (`enrichGatedScreenPage`). `null` outside the window.
245
+ */ export function leavingNoticeSignatures(options) {
246
+ const config = leavingNoticeConfig(options);
247
+ return config && Object.keys(config.sigs).length ? config.sigs : null;
248
+ }
249
+
250
+ //# sourceMappingURL=leaving-notice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/leaving-notice.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 * THE \"YOU'RE LEAVING THIS SITE\" NOTICE, SERVER HALF (AGL-3452).\n *\n * Who gets it, which hosts are exempt, and the signature that keeps the\n * notice route from becoming an open redirect. The client half — rewriting\n * an href, the click interceptor — is `@aglyn/aglyn/app-utils/leaving-notice`.\n *\n * ## Who\n *\n * A site whose workspace is on the FREE plan (the effective plan, so a dead\n * subscription counts as free, as it does for every entitlement) and in its\n * first {@link LEAVING_NOTICE_DAYS} days — the same window, read through the\n * same `isYoungWorkspaceAge`, as the soft phishing rules. Paid and older\n * workspaces are untouched, and a workspace whose creation date cannot be\n * read is an existing customer, never a new one.\n *\n * ## Why signed\n *\n * The notice lives on the site's own host and its Continue button goes\n * wherever `?to=` says. Unsigned, any site's notice would be a page that\n * vouches for any address anyone puts in a link — the hop this feature exists\n * to remove. So the server signs each outside address it finds on a page it\n * renders, bound to that site, and the route shows a Continue button only for\n * an address whose signature checks out. Everything else is refused.\n *\n * The signature carries no expiry on purpose. The route never redirects on\n * its own, so a signature that outlives its page buys nothing but one more\n * view of the same warning — while an expiring one would break the links an\n * ISR-cached page is still serving the moment the window closes.\n *=========================================*/\n\nimport { createHmac } from 'crypto'\nimport { DOCS_BASE_URL } from '@aglyn/aglyn/app-utils/docs-help'\nimport {\n type LeavingNoticeConfig,\n leavingDestination,\n normalizeLeavingDestination,\n} from '@aglyn/aglyn/app-utils/leaving-notice'\nimport { resolveEffectivePlan } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n PLATFORM_HOME_URL,\n PLATFORM_SUPPORT_URL,\n platformConsoleOrigin,\n} from '@aglyn/aglyn/app-utils/platform-brand'\nimport {\n type SiteReturnHost,\n siteOwnHosts,\n} from '@aglyn/aglyn/app-utils/site-return-url'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport {\n isYoungWorkspaceAge,\n OUTBOUND_REVIEW_YOUNG_DAYS,\n registrableDomain,\n} from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport { tokenSigningSecret } from './media-signing'\nimport { orgAgeDays, orgCreatedMs } from './org-age'\nimport { safeEqual } from './safe-equal'\n\n/** How long a new free workspace's sites carry the notice. */\nexport const LEAVING_NOTICE_DAYS = OUTBOUND_REVIEW_YOUNG_DAYS\n\nconst DAY_MS = 86_400_000\n\n/**\n * The most outside addresses one page is signed for. A page with more is a\n * link farm, and the ones past the cap still go through the notice — which\n * refuses them — rather than around it.\n */\nexport const LEAVING_NOTICE_MAX_DESTINATIONS = 500\n\n/** Deepest a page payload is walked; composed node maps sit far above it. */\nconst MAX_WALK_DEPTH = 64\n\n/** Longest string scanned for embedded addresses (a long entry body). */\nconst MAX_SCANNED_STRING = 200_000\n\n/**\n * When a workspace's notice window closes (epoch ms), or `null` when its\n * sites carry no notice: a paid plan, a workspace past its first\n * {@link LEAVING_NOTICE_DAYS} days, or one whose creation date is unreadable.\n *\n * Takes the org as loaded anywhere — a live document, or the render cache's\n * JSON copy, whose `createdAt` has lost its methods (`orgCreatedMs`).\n */\nexport function leavingNoticeEndsAt(\n org: unknown,\n nowMs: number = Date.now(),\n): number | null {\n if (!org || typeof org !== 'object') return null\n if (resolveEffectivePlan(org as never) !== 'free') return null\n const createdAt = (org as { createdAt?: unknown }).createdAt\n if (!isYoungWorkspaceAge(orgAgeDays(createdAt, nowMs))) return null\n const createdMs = orgCreatedMs(createdAt)\n return createdMs === null ? null : createdMs + LEAVING_NOTICE_DAYS * DAY_MS\n}\n\nfunction hostnameOf(raw: string | null | undefined): string | null {\n if (!raw) return null\n try {\n const url = new URL(raw)\n if (url.protocol !== 'http:' && url.protocol !== 'https:') return null\n return url.hostname.toLowerCase() || null\n } catch {\n return null\n }\n}\n\n/**\n * The hosts a link may reach without the notice: the site's own (its\n * subdomain, its custom domain and `www.` of it) and the platform's own — the\n * marketing site, console, support and docs, each with every name under its\n * registrable domain, plus the bare tenant apex.\n *\n * Other sites on the tenant apex are NOT exempt. They are other customers'\n * sites, and a chain of two new free sites is the same hop as one.\n */\nexport function leavingNoticeHosts(\n site: SiteReturnHost | null | undefined,\n): string[] {\n const hosts = new Set<string>(siteOwnHosts(site))\n const apex = TENANT_APEX.toLowerCase()\n hosts.add(apex)\n const apexRegistrable = registrableDomain(apex)\n for (const raw of [\n PLATFORM_HOME_URL,\n platformConsoleOrigin(),\n PLATFORM_SUPPORT_URL,\n DOCS_BASE_URL,\n ]) {\n const hostname = hostnameOf(raw)\n if (!hostname) continue\n hosts.add(hostname)\n const registrable = registrableDomain(hostname)\n // A platform name under the tenant apex itself must not exempt the apex:\n // that would exempt every customer's site with it.\n if (registrable.includes('.') && registrable !== apexRegistrable) {\n hosts.add(`.${registrable}`)\n }\n }\n return [...hosts]\n}\n\n/** An address as it appears in text: scheme, then anything a URL may hold. */\nconst EMBEDDED_URL = /https?:\\/\\/[^\\s\"'<>()[\\]{}\\\\^`|]+/gi\n\n/** Characters prose puts after an address that the address does not own. */\nconst TRAILING_PROSE = /[.,;:!?'\"*_~]+$/\n\n/** A whole value shaped like an absolute or protocol-relative address. */\nconst ADDRESS_SHAPE = /^\\s*(?:https?:|[\\\\/]{2})/i\n\n/**\n * Every outside address a rendered page could link to: each string in the\n * payload that is one, and each one embedded in a string (Markdown, rich\n * text HTML, a plain sentence). Over-collecting is harmless — a signature\n * nobody follows is never read — and under-collecting only sends a link to a\n * refusal, never around the notice.\n *\n * Returned normalized, the spelling the client looks signatures up by.\n */\nexport function collectLeavingDestinations(\n value: unknown,\n hosts: readonly string[],\n limit: number = LEAVING_NOTICE_MAX_DESTINATIONS,\n): string[] {\n const found = new Set<string>()\n const consider = (candidate: string) => {\n if (found.size >= limit) return\n const destination = leavingDestination(candidate, hosts)\n if (destination) found.add(destination)\n }\n const scan = (text: string) => {\n if (ADDRESS_SHAPE.test(text)) consider(text)\n if (!text.includes('http')) return\n const scanned =\n text.length > MAX_SCANNED_STRING ? text.slice(0, MAX_SCANNED_STRING) : text\n for (const match of scanned.matchAll(EMBEDDED_URL)) {\n const raw = match[0]\n consider(raw)\n const trimmed = raw.replace(TRAILING_PROSE, '')\n if (trimmed !== raw) consider(trimmed)\n // An href inside stored HTML is entity-encoded; the DOM decodes it.\n if (trimmed.includes('&amp;')) consider(trimmed.replace(/&amp;/g, '&'))\n }\n }\n const walk = (node: unknown, depth: number) => {\n if (found.size >= limit || depth > MAX_WALK_DEPTH) return\n if (typeof node === 'string') {\n scan(node)\n } else if (Array.isArray(node)) {\n for (const item of node) walk(item, depth + 1)\n } else if (node && typeof node === 'object') {\n for (const item of Object.values(node)) walk(item, depth + 1)\n }\n }\n walk(value, 0)\n return [...found]\n}\n\nfunction leavingSignature(hostId: string, destination: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`leaving-notice:v1:${hostId}:${destination}`)\n .digest('base64url')\n}\n\n/**\n * The signature for one destination on one site. Throws when the deployment\n * has no signing secret, like every signer of it.\n */\nexport function signLeavingDestination(\n hostId: string,\n destination: string,\n): string {\n const normalized = normalizeLeavingDestination(destination)\n if (!hostId || !normalized) {\n throw new Error('A leaving-notice signature needs a site and an address')\n }\n return leavingSignature(hostId, normalized)\n}\n\n/**\n * Whether `signature` was issued for `destination` on this site. False for\n * anything else — another site's signature, an edited address, a missing\n * secret (fail closed).\n */\nexport function verifyLeavingDestination(\n hostId: string,\n destination: unknown,\n signature: unknown,\n): boolean {\n if (!hostId || typeof signature !== 'string' || signature.length > 128) {\n return false\n }\n const normalized = normalizeLeavingDestination(destination)\n if (!normalized) return false\n let expected: string\n try {\n expected = leavingSignature(hostId, normalized)\n } catch {\n return false\n }\n return safeEqual(signature, expected)\n}\n\n/** Signs a page's destinations for one site. */\nexport function signLeavingDestinations(\n hostId: string,\n destinations: readonly string[],\n): Record<string, string> {\n const sigs: Record<string, string> = {}\n for (const destination of destinations) {\n sigs[destination] = leavingSignature(hostId, destination)\n }\n return sigs\n}\n\n/** Whether this deployment can sign at all. */\nfunction canSign(): boolean {\n try {\n tokenSigningSecret()\n return true\n } catch {\n return false\n }\n}\n\n/**\n * What a published page of this site carries, or `null` when the site is not\n * in its window.\n *\n * `content` is everything the page renders from — its props, its nodes —\n * walked for outside addresses to sign.\n *\n * A deployment with no signing secret gets no notice, loudly. The route\n * could verify nothing there and would refuse every link on every new free\n * site; an outage of every outbound link is not a safer failure than the\n * links working as they did before this existed.\n */\nexport function leavingNoticeConfig(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content?: unknown\n nowMs?: number\n}): LeavingNoticeConfig | null {\n const { hostId, site, org, content } = options\n if (!hostId || !site) return null\n const until = leavingNoticeEndsAt(org, options.nowMs)\n if (until === null) return null\n if (!canSign()) {\n console.error(\n '[leaving-notice] TOKEN_SIGNING_SECRET is not configured; the notice is off',\n )\n return null\n }\n const hosts = leavingNoticeHosts(site)\n return {\n until,\n hosts,\n sigs: signLeavingDestinations(\n hostId,\n collectLeavingDestinations(content, hosts),\n ),\n }\n}\n\n/**\n * Only the signatures, for a payload that arrives after its page — a gated\n * screen's nodes (`enrichGatedScreenPage`). `null` outside the window.\n */\nexport function leavingNoticeSignatures(options: {\n hostId: string | null | undefined\n site: SiteReturnHost | null | undefined\n org: unknown\n content: unknown\n nowMs?: number\n}): Record<string, string> | null {\n const config = leavingNoticeConfig(options)\n return config && Object.keys(config.sigs).length ? config.sigs : null\n}\n"],"names":["createHmac","DOCS_BASE_URL","leavingDestination","normalizeLeavingDestination","resolveEffectivePlan","PLATFORM_HOME_URL","PLATFORM_SUPPORT_URL","platformConsoleOrigin","siteOwnHosts","TENANT_APEX","isYoungWorkspaceAge","OUTBOUND_REVIEW_YOUNG_DAYS","registrableDomain","tokenSigningSecret","orgAgeDays","orgCreatedMs","safeEqual","LEAVING_NOTICE_DAYS","DAY_MS","LEAVING_NOTICE_MAX_DESTINATIONS","MAX_WALK_DEPTH","MAX_SCANNED_STRING","leavingNoticeEndsAt","org","nowMs","Date","now","createdAt","createdMs","hostnameOf","raw","url","URL","protocol","hostname","toLowerCase","leavingNoticeHosts","site","hosts","Set","apex","add","apexRegistrable","registrable","includes","EMBEDDED_URL","TRAILING_PROSE","ADDRESS_SHAPE","collectLeavingDestinations","value","limit","found","consider","candidate","size","destination","scan","text","test","scanned","length","slice","match","matchAll","trimmed","replace","walk","node","depth","Array","isArray","item","Object","values","leavingSignature","hostId","update","digest","signLeavingDestination","normalized","Error","verifyLeavingDestination","signature","expected","signLeavingDestinations","destinations","sigs","canSign","leavingNoticeConfig","options","content","until","console","error","leavingNoticeSignatures","config","keys"],"mappings":"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"}
@@ -0,0 +1,54 @@
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
+ /** What one chunk of the re-check did. */
18
+ export interface LivePageLinkRecheck {
19
+ /** Sites this chunk walked. */
20
+ sites: number;
21
+ /** Live pages with foreign links among them. */
22
+ pages: number;
23
+ /** Distinct foreign hosts looked up. */
24
+ hosts: number;
25
+ /** Of those, how many Web Risk lists. */
26
+ listed: number;
27
+ /** Hosts nobody could answer for: not evidence, looked up again next time. */
28
+ unknown: number;
29
+ /** Pages sent back through the review with a listed host. */
30
+ reviewed: number;
31
+ /** Of those, how many the review held (or found already rejected). */
32
+ held: number;
33
+ /** The sites a hold was placed on, whose cached pages need dropping. */
34
+ heldHostIds: string[];
35
+ nextCursor: string | null;
36
+ done: boolean;
37
+ }
38
+ /** Sites walked per chunk. */
39
+ export declare const LINK_RECHECK_SITES_PER_CHUNK = 200;
40
+ /**
41
+ * Re-check the foreign hosts of every live page, a chunk of sites at a time
42
+ * (`cursor` resumes after the last site of the previous chunk). A site that
43
+ * is locked is skipped: nothing of it serves. A page with a newly listed host
44
+ * goes back through {@link reviewHostedPage} with that listing as an extra
45
+ * signal, so what a hold does is decided in one place.
46
+ */
47
+ export declare function recheckLivePageLinks(options?: {
48
+ cursor?: string | null;
49
+ sitesPerChunk?: number;
50
+ nowMs?: number;
51
+ firestore?: FirebaseFirestore.Firestore;
52
+ /** Walk and count only: no lookup, no review. */
53
+ dryRun?: boolean;
54
+ }): Promise<LivePageLinkRecheck>;
@@ -0,0 +1,144 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /*==========================================
18
+ * THE DAILY LINK RE-CHECK (AGL-3451).
19
+ *
20
+ * The page review (`hosted-page-review.ts`), the redirect review and the send
21
+ * seam look up every foreign host a page, a redirect or a message points at
22
+ * as it goes out. What is already live is not re-reviewed until somebody
23
+ * publishes it again — and a harvester is often listed only after the page
24
+ * linking to it went up.
25
+ *
26
+ * So {@link recheckLivePageLinks} walks every live page's foreign hosts, as
27
+ * the review noted them beside the version it served
28
+ * (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends
29
+ * each page with a newly listed host back through `reviewHostedPage` with the
30
+ * listing as an extra signal — the same path, so it is held or flagged, its
31
+ * row filed, and a young workspace's security hold requested (AGL-3450),
32
+ * exactly as at render. The console's `/api/admin/web-risk-recheck` runs it
33
+ * a chunk of sites at a time.
34
+ *=========================================*/ import { MAX_REPUTATION_HOSTS_PER_LOOKUP, webRiskSignals } from "@aglyn/shared-util-email/link-reputation";
35
+ import { FieldPath } from "firebase-admin/firestore";
36
+ import firebaseAdmin from "./firebase-admin.js";
37
+ import { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from "./hosted-page-review.js";
38
+ import { lookupHostReputation } from "./web-risk.js";
39
+ /** Sites walked per chunk. */ export const LINK_RECHECK_SITES_PER_CHUNK = 200;
40
+ /** Sites whose page notes are read at once. */ const SITE_READ_CONCURRENCY = 20;
41
+ /** The budget of each group of hosts looked up within a chunk. */ const RECHECK_DEADLINE_MS = 15000;
42
+ /**
43
+ * Re-check the foreign hosts of every live page, a chunk of sites at a time
44
+ * (`cursor` resumes after the last site of the previous chunk). A site that
45
+ * is locked is skipped: nothing of it serves. A page with a newly listed host
46
+ * goes back through {@link reviewHostedPage} with that listing as an extra
47
+ * signal, so what a hold does is decided in one place.
48
+ */ export async function recheckLivePageLinks(options = {}) {
49
+ var _options_firestore, _options_nowMs, _options_sitesPerChunk, _ref;
50
+ const firestore = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
51
+ const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
52
+ const limit = (_options_sitesPerChunk = options.sitesPerChunk) != null ? _options_sitesPerChunk : LINK_RECHECK_SITES_PER_CHUNK;
53
+ let query = firestore.collection('hosts').orderBy(FieldPath.documentId()).select('suspendedAt').limit(limit);
54
+ if (options.cursor) query = query.startAfter(options.cursor);
55
+ const sites = await query.get();
56
+ const pages = [];
57
+ const live = sites.docs.filter((site)=>site.get('suspendedAt') == null);
58
+ for(let at = 0; at < live.length; at += SITE_READ_CONCURRENCY){
59
+ const batch = live.slice(at, at + SITE_READ_CONCURRENCY);
60
+ const notes = await Promise.all(batch.map((site)=>site.ref.collection(PAGE_REVIEW_SUBCOLLECTION).where('foreignHostCount', '>', 0).limit(500).get().then((reviews)=>({
61
+ site,
62
+ reviews
63
+ }))));
64
+ for (const { site, reviews } of notes){
65
+ for (const review of reviews.docs){
66
+ const versionId = review.get('servedVersionId');
67
+ const hosts = review.get('foreignHosts');
68
+ if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue;
69
+ pages.push({
70
+ hostId: site.id,
71
+ screenId: review.id,
72
+ versionId,
73
+ hosts: hosts.filter((host)=>typeof host === 'string')
74
+ });
75
+ }
76
+ }
77
+ }
78
+ const distinct = [
79
+ ...new Set(pages.flatMap((page)=>page.hosts))
80
+ ];
81
+ const last = sites.docs[sites.docs.length - 1];
82
+ const chunk = {
83
+ sites: sites.size,
84
+ pages: pages.length,
85
+ hosts: distinct.length,
86
+ nextCursor: sites.size < limit ? null : (_ref = last == null ? void 0 : last.id) != null ? _ref : null,
87
+ done: sites.size < limit
88
+ };
89
+ if (options.dryRun) {
90
+ return _extends({}, chunk, {
91
+ listed: 0,
92
+ unknown: 0,
93
+ reviewed: 0,
94
+ held: 0,
95
+ heldHostIds: []
96
+ });
97
+ }
98
+ const listed = new Map();
99
+ let unknown = 0;
100
+ for(let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP){
101
+ const answer = await lookupHostReputation(distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP), {
102
+ deadlineMs: RECHECK_DEADLINE_MS,
103
+ nowMs
104
+ });
105
+ for (const hit of answer.hits)listed.set(hit.host, hit);
106
+ unknown += answer.unknown.length;
107
+ }
108
+ let reviewed = 0;
109
+ let held = 0;
110
+ const heldHostIds = new Set();
111
+ for (const page of pages){
112
+ const hits = page.hosts.map((host)=>listed.get(host)).filter(Boolean);
113
+ if (!hits.length) continue;
114
+ reviewed += 1;
115
+ try {
116
+ const outcome = await reviewHostedPage({
117
+ hostId: page.hostId,
118
+ screenId: page.screenId,
119
+ versionId: page.versionId,
120
+ // The page itself is not composed here: the listing is the finding.
121
+ nodes: {},
122
+ extraSignals: webRiskSignals(hits),
123
+ nowMs
124
+ });
125
+ if (outcome.outcome !== 'serve') {
126
+ held += 1;
127
+ heldHostIds.add(page.hostId);
128
+ }
129
+ } catch (error) {
130
+ console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error);
131
+ }
132
+ }
133
+ return _extends({}, chunk, {
134
+ listed: listed.size,
135
+ unknown,
136
+ reviewed,
137
+ held,
138
+ heldHostIds: [
139
+ ...heldHostIds
140
+ ]
141
+ });
142
+ }
143
+
144
+ //# sourceMappingURL=link-reputation-review.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/link-reputation-review.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 * THE DAILY LINK RE-CHECK (AGL-3451).\n *\n * The page review (`hosted-page-review.ts`), the redirect review and the send\n * seam look up every foreign host a page, a redirect or a message points at\n * as it goes out. What is already live is not re-reviewed until somebody\n * publishes it again — and a harvester is often listed only after the page\n * linking to it went up.\n *\n * So {@link recheckLivePageLinks} walks every live page's foreign hosts, as\n * the review noted them beside the version it served\n * (`pageReviews/{screenId}.foreignHosts`), looks them up again, and sends\n * each page with a newly listed host back through `reviewHostedPage` with the\n * listing as an extra signal — the same path, so it is held or flagged, its\n * row filed, and a young workspace's security hold requested (AGL-3450),\n * exactly as at render. The console's `/api/admin/web-risk-recheck` runs it\n * a chunk of sites at a time.\n *=========================================*/\n\nimport {\n type HostReputationHit,\n MAX_REPUTATION_HOSTS_PER_LOOKUP,\n webRiskSignals,\n} from '@aglyn/shared-util-email/link-reputation'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\nimport { PAGE_REVIEW_SUBCOLLECTION, reviewHostedPage } from './hosted-page-review'\nimport { lookupHostReputation } from './web-risk'\n\n/** One live page whose foreign hosts the review noted. */\ninterface LivePageLinks {\n hostId: string\n screenId: string\n versionId: string\n hosts: string[]\n}\n\n/** What one chunk of the re-check did. */\nexport interface LivePageLinkRecheck {\n /** Sites this chunk walked. */\n sites: number\n /** Live pages with foreign links among them. */\n pages: number\n /** Distinct foreign hosts looked up. */\n hosts: number\n /** Of those, how many Web Risk lists. */\n listed: number\n /** Hosts nobody could answer for: not evidence, looked up again next time. */\n unknown: number\n /** Pages sent back through the review with a listed host. */\n reviewed: number\n /** Of those, how many the review held (or found already rejected). */\n held: number\n /** The sites a hold was placed on, whose cached pages need dropping. */\n heldHostIds: string[]\n nextCursor: string | null\n done: boolean\n}\n\n/** Sites walked per chunk. */\nexport const LINK_RECHECK_SITES_PER_CHUNK = 200\n/** Sites whose page notes are read at once. */\nconst SITE_READ_CONCURRENCY = 20\n/** The budget of each group of hosts looked up within a chunk. */\nconst RECHECK_DEADLINE_MS = 15_000\n\n/**\n * Re-check the foreign hosts of every live page, a chunk of sites at a time\n * (`cursor` resumes after the last site of the previous chunk). A site that\n * is locked is skipped: nothing of it serves. A page with a newly listed host\n * goes back through {@link reviewHostedPage} with that listing as an extra\n * signal, so what a hold does is decided in one place.\n */\nexport async function recheckLivePageLinks(\n options: {\n cursor?: string | null\n sitesPerChunk?: number\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n /** Walk and count only: no lookup, no review. */\n dryRun?: boolean\n } = {},\n): Promise<LivePageLinkRecheck> {\n const firestore = options.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = options.nowMs ?? Date.now()\n const limit = options.sitesPerChunk ?? LINK_RECHECK_SITES_PER_CHUNK\n let query = firestore\n .collection('hosts')\n .orderBy(FieldPath.documentId())\n .select('suspendedAt')\n .limit(limit)\n if (options.cursor) query = query.startAfter(options.cursor)\n const sites = await query.get()\n\n const pages: LivePageLinks[] = []\n const live = sites.docs.filter((site) => site.get('suspendedAt') == null)\n for (let at = 0; at < live.length; at += SITE_READ_CONCURRENCY) {\n const batch = live.slice(at, at + SITE_READ_CONCURRENCY)\n const notes = await Promise.all(\n batch.map((site) =>\n site.ref\n .collection(PAGE_REVIEW_SUBCOLLECTION)\n .where('foreignHostCount', '>', 0)\n .limit(500)\n .get()\n .then((reviews) => ({ site, reviews })),\n ),\n )\n for (const { site, reviews } of notes) {\n for (const review of reviews.docs) {\n const versionId = review.get('servedVersionId')\n const hosts = review.get('foreignHosts')\n if (typeof versionId !== 'string' || !versionId || !Array.isArray(hosts)) continue\n pages.push({\n hostId: site.id,\n screenId: review.id,\n versionId,\n hosts: hosts.filter((host): host is string => typeof host === 'string'),\n })\n }\n }\n }\n\n const distinct = [...new Set(pages.flatMap((page) => page.hosts))]\n const last = sites.docs[sites.docs.length - 1]\n const chunk = {\n sites: sites.size,\n pages: pages.length,\n hosts: distinct.length,\n nextCursor: sites.size < limit ? null : (last?.id ?? null),\n done: sites.size < limit,\n }\n if (options.dryRun) {\n return { ...chunk, listed: 0, unknown: 0, reviewed: 0, held: 0, heldHostIds: [] }\n }\n const listed = new Map<string, HostReputationHit>()\n let unknown = 0\n for (let at = 0; at < distinct.length; at += MAX_REPUTATION_HOSTS_PER_LOOKUP) {\n const answer = await lookupHostReputation(\n distinct.slice(at, at + MAX_REPUTATION_HOSTS_PER_LOOKUP),\n { deadlineMs: RECHECK_DEADLINE_MS, nowMs },\n )\n for (const hit of answer.hits) listed.set(hit.host, hit)\n unknown += answer.unknown.length\n }\n\n let reviewed = 0\n let held = 0\n const heldHostIds = new Set<string>()\n for (const page of pages) {\n const hits = page.hosts.map((host) => listed.get(host)).filter(Boolean) as HostReputationHit[]\n if (!hits.length) continue\n reviewed += 1\n try {\n const outcome = await reviewHostedPage({\n hostId: page.hostId,\n screenId: page.screenId,\n versionId: page.versionId,\n // The page itself is not composed here: the listing is the finding.\n nodes: {},\n extraSignals: webRiskSignals(hits),\n nowMs,\n })\n if (outcome.outcome !== 'serve') {\n held += 1\n heldHostIds.add(page.hostId)\n }\n } catch (error) {\n console.error('[link-reputation] a live page could not be re-reviewed', page.hostId, page.screenId, error)\n }\n }\n\n return {\n ...chunk,\n listed: listed.size,\n unknown,\n reviewed,\n held,\n heldHostIds: [...heldHostIds],\n }\n}\n"],"names":["MAX_REPUTATION_HOSTS_PER_LOOKUP","webRiskSignals","FieldPath","firebaseAdmin","PAGE_REVIEW_SUBCOLLECTION","reviewHostedPage","lookupHostReputation","LINK_RECHECK_SITES_PER_CHUNK","SITE_READ_CONCURRENCY","RECHECK_DEADLINE_MS","recheckLivePageLinks","options","firestore","app","nowMs","Date","now","limit","sitesPerChunk","query","collection","orderBy","documentId","select","cursor","startAfter","sites","get","pages","live","docs","filter","site","at","length","batch","slice","notes","Promise","all","map","ref","where","then","reviews","review","versionId","hosts","Array","isArray","push","hostId","id","screenId","host","distinct","Set","flatMap","page","last","chunk","size","nextCursor","done","dryRun","listed","unknown","reviewed","held","heldHostIds","Map","answer","deadlineMs","hit","hits","set","Boolean","outcome","nodes","extraSignals","add","error","console"],"mappings":"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"}
@@ -15,13 +15,22 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  /**
18
- * How old a workspace is, in days, from whatever its `createdAt` turns out to
19
- * be.
18
+ * When a workspace was created, in epoch ms, from whatever its `createdAt`
19
+ * turns out to be — or `null` when it cannot be read.
20
20
  *
21
21
  * Firestore hands back a `Timestamp`, a restore or a fixture can leave a
22
- * number, and an org written before the field existed has nothing. All three
23
- * are handled and the last one answers `null`, which
24
- * `claimOrgEmailSendDay` reads as graduated — the only safe direction,
25
- * for the reason stated there.
22
+ * number, and an org written before the field existed has nothing. A
23
+ * `Timestamp` that has been through JSON — the render cache stores plain
24
+ * values, so the tenant's copy of an org arrives this way — is a bare
25
+ * `{ _seconds, _nanoseconds }` with no methods left, and is read like the
26
+ * live one rather than as missing (AGL-3452).
27
+ */
28
+ export declare function orgCreatedMs(createdAt: unknown): number | null;
29
+ /**
30
+ * How old a workspace is, in days, from whatever its `createdAt` turns out to
31
+ * be (see {@link orgCreatedMs}).
32
+ *
33
+ * An unreadable date answers `null`, which `claimOrgEmailSendDay` reads as
34
+ * graduated — the only safe direction, for the reason stated there.
26
35
  */
27
36
  export declare function orgAgeDays(createdAt: unknown, now?: number): number | null;