@aglyn/plugins-redirects 1.0.0-beta.222 → 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/plugins-redirects",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.223",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-ui-jsx": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-ui-snackstack": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
34
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
35
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
36
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
37
|
-
"@aglyn/tenant-feature-instance": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.223",
|
|
29
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.223",
|
|
30
|
+
"@aglyn/shared-ui-jsx": "1.0.0-beta.223",
|
|
31
|
+
"@aglyn/shared-ui-snackstack": "1.0.0-beta.223",
|
|
32
|
+
"@aglyn/shared-util-email": "1.0.0-beta.223",
|
|
33
|
+
"@aglyn/shared-util-http": "1.0.0-beta.223",
|
|
34
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.223",
|
|
35
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.223",
|
|
36
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.223",
|
|
37
|
+
"@aglyn/tenant-feature-instance": "1.0.0-beta.223",
|
|
38
38
|
"@swc/helpers": "0.5.23"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
@@ -18,7 +18,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
18
|
import { isExternalRedirectDestination, matchRedirect, normalizeRedirectSource, redirectHitKey } from "../model/redirects.js";
|
|
19
19
|
import { analyticsDayExpiresAt, firebaseAdmin, getOrgForHost, reviewSiteRedirect } from "@aglyn/tenant-data-admin";
|
|
20
20
|
import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
|
|
21
|
+
import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
|
|
21
22
|
import { FieldValue } from "firebase-admin/firestore";
|
|
23
|
+
/**
|
|
24
|
+
* How long a render waits on the phishing review of an off-site rule before
|
|
25
|
+
* treating the rule as held for this request (AGL-3565).
|
|
26
|
+
*/ const REDIRECT_REVIEW_DEADLINE_MS = 5000;
|
|
22
27
|
/**
|
|
23
28
|
* One of three per-render reads AGL-1302 left uncached (AGL-1440).
|
|
24
29
|
*
|
|
@@ -101,9 +106,15 @@ async function readEnabledRules(hostId) {
|
|
|
101
106
|
// not fire and is not counted; the request resolves as if it had no rule.
|
|
102
107
|
// Outside the cache, like the paid gate: a staff decision takes effect on
|
|
103
108
|
// the next request, not at the end of an hour.
|
|
109
|
+
//
|
|
110
|
+
// The review asks Google Web Risk and, for a rule it holds, files the hold
|
|
111
|
+
// and tells staff — all inside this page render. It answers within a
|
|
112
|
+
// deadline or the rule is treated as held, as the review itself fails
|
|
113
|
+
// closed; the review carries on behind the page, so the next request
|
|
114
|
+
// reads its decision (AGL-3565).
|
|
104
115
|
if (isExternalRedirectDestination(matched.destination)) {
|
|
105
116
|
var _ref, _ref1;
|
|
106
|
-
const review = await reviewSiteRedirect({
|
|
117
|
+
const review = await boundedAwait(reviewSiteRedirect({
|
|
107
118
|
hostId: host.$id,
|
|
108
119
|
ruleId: matchId,
|
|
109
120
|
source: ruleList[matched.index].source,
|
|
@@ -112,8 +123,8 @@ async function readEnabledRules(hostId) {
|
|
|
112
123
|
host: typeof host['subdomain'] === 'string' ? host : undefined,
|
|
113
124
|
org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
|
|
114
125
|
orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null
|
|
115
|
-
});
|
|
116
|
-
if (review.outcome !== 'serve') return null;
|
|
126
|
+
}), REDIRECT_REVIEW_DEADLINE_MS, null, 'redirects.site-redirect-review');
|
|
127
|
+
if ((review == null ? void 0 : review.outcome) !== 'serve') return null;
|
|
117
128
|
}
|
|
118
129
|
// Sampled hit recording (fire-and-forget): day-doc counter + recency
|
|
119
130
|
// stamp for the manager (AGL-157). Also outside the cache — a cached
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/resolve-redirect.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 { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n type HostRedirect,\n isExternalRedirectDestination,\n matchRedirect,\n normalizeRedirectSource,\n redirectHitKey,\n} from '../model/redirects'\nimport {\n analyticsDayExpiresAt,\n firebaseAdmin,\n getOrgForHost,\n reviewSiteRedirect,\n} from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * One of three per-render reads AGL-1302 left uncached (AGL-1440).\n *\n * This query runs before route resolution on every render of every path, and\n * Firestore bills a minimum of one read for a query returning nothing — so a\n * site that has never written a rule still paid for asking, forever. The rules\n * are pure host-scoped published config: `revalidateTag(tenant-data:{hostId})`\n * busts them, and the shared hour-long TTL is only the backstop for a write\n * that never announces itself. Rule edits DO announce themselves: the\n * console's redirect manager posts `/api/screens/revalidate` with the rule's\n * source path after every save, toggle and delete, which busts the tag and\n * drops that path's cached HTML — that call is what keeps the manager's\n * \"live within ~30 seconds\" true, not this TTL.\n */\nconst REDIRECT_RULES_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/** One enabled rule, flattened to the plain data the matcher reads. */\ntype StoredRedirect = HostRedirect & { $id: string }\n\nasync function readEnabledRules(hostId: string): Promise<StoredRedirect[]> {\n const rules = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('redirects')\n .where('enabled', '==', true)\n .limit(100)\n .get()\n return rules.docs.map((doc) => ({\n ...(doc.data() as HostRedirect),\n $id: doc.id,\n }))\n}\n\nexport interface ResolvedRedirect {\n destination: string\n statusCode: 301 | 302 | 307 | 308\n}\n\n\n/**\n * Redirect enforcement (AGL-155), Option A per the issue: rules apply in\n * `getStaticProps` before route resolution, with a low revalidate on the\n * redirect response. Trade-offs, documented: rule edits take effect on\n * the next revalidation (≤30s), and hit counts are SAMPLED — cached\n * redirect responses never re-execute code, so each count represents \"at\n * least one hit in a revalidation window\", not per-request accuracy.\n * Middleware-accurate counting stays open as a later upgrade.\n *\n * Paid gating: when the host has enabled rules but the owning org's\n * plan lacks the `redirects` flag (downgrade with leftover rules), the\n * rules stop firing — the org read only happens when rules exist.\n * Loop guard: self-redirects never execute even if stored (console\n * validation and this floor can disagree; both refuse).\n * Phishing screen: a rule that leaves the site fires only once\n * `reviewSiteRedirect` answers `serve` — a destination that wears a brand is\n * held for every workspace, a lure path sent off the site for a workspace in\n * its first fortnight (AGL-3447).\n */\nexport async function resolveRedirect(\n host: { $id: string } & Record<string, unknown>,\n requestPath: string,\n): Promise<ResolvedRedirect | null> {\n try {\n const source = normalizeRedirectSource(\n requestPath === '/' || requestPath === '' ? '/' : `/${requestPath}`,\n )\n if (!source) return null\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(host.$id)\n // Cached per host, never per path (AGL-1440): the rule set is the same for\n // every URL on the site, so a path in the key would mint one entry per\n // route and cache nothing.\n const ruleList = await withRenderCache({\n key: ['redirect-rules', host.$id],\n revalidate: REDIRECT_RULES_TTL_SECONDS,\n tags: [tenantDataTag(host.$id)],\n read: () => readEnabledRules(host.$id),\n })\n if (!ruleList.length) return null\n\n // v2 matcher (AGL-375): exact/prefix/regex in priority order, capture\n // substitution, self-redirect floor. Run per request — it depends on the\n // path, which the cache key deliberately does not carry.\n const matched = matchRedirect(ruleList, source)\n if (!matched) return null\n const matchId = ruleList[matched.index]?.$id\n if (!matchId) return null\n\n // Paid gate — only paid orgs' rules fire (dark-launch orgs pass).\n //\n // Deliberately OUTSIDE the cache above. A cached entitlement would keep a\n // downgraded org's leftover rules redirecting for the life of the entry,\n // which on this code path means taking pages off the internet on the\n // strength of a plan the org no longer has.\n const owner = await getOrgForHost(host.$id)\n if (!checkEntitlement(owner?.org as any, 'redirects')) {\n return null\n }\n\n // The phishing screen (AGL-3447), for a rule that sends visitors OFF the\n // site: a page whose one call to action fires on arrival. A held rule does\n // not fire and is not counted; the request resolves as if it had no rule.\n // Outside the cache, like the paid gate: a staff decision takes effect on\n // the next request, not at the end of an hour.\n if (isExternalRedirectDestination(matched.destination)) {\n const review = await reviewSiteRedirect({\n hostId: host.$id,\n ruleId: matchId,\n source: ruleList[matched.index].source,\n destination: matched.destination,\n // The resolved host doc, when the hook handed one over; read otherwise.\n host: typeof host['subdomain'] === 'string' ? host : undefined,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n orgId: owner?.orgId ?? null,\n })\n if (review.outcome !== 'serve') return null\n }\n\n // Sampled hit recording (fire-and-forget): day-doc counter + recency\n // stamp for the manager (AGL-157). Also outside the cache — a cached\n // function would stop counting altogether rather than counting less often.\n // The rule's ref is rebuilt from its id, because a `DocumentReference`\n // cannot survive the cache's JSON round trip.\n const day = new Date().toISOString().slice(0, 10)\n void hostRef\n .collection('analytics')\n .doc(day)\n .set(\n {\n redirects: { [redirectHitKey(matchId)]: FieldValue.increment(1) },\n // Retention (AGL-1844): every writer of a day doc stamps the\n // day-anchored expiry the TTL policy sweeps on.\n expiresAt: analyticsDayExpiresAt(day),\n },\n { merge: true },\n )\n .catch(() => undefined)\n void hostRef\n .collection('redirects')\n .doc(matchId)\n .set({ lastHitAt: FieldValue.serverTimestamp() }, { merge: true })\n .catch(() => undefined)\n\n return { destination: matched.destination, statusCode: matched.statusCode }\n } catch (error) {\n console.error('resolveRedirect failed', host.$id, requestPath, error)\n return null\n }\n}\n\nexport default resolveRedirect\n"],"names":["checkEntitlement","isExternalRedirectDestination","matchRedirect","normalizeRedirectSource","redirectHitKey","analyticsDayExpiresAt","firebaseAdmin","getOrgForHost","reviewSiteRedirect","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","FieldValue","REDIRECT_RULES_TTL_SECONDS","readEnabledRules","hostId","rules","app","firestore","collection","doc","where","limit","get","docs","map","data","$id","id","resolveRedirect","host","requestPath","ruleList","source","hostRef","key","revalidate","tags","read","length","matched","matchId","index","owner","org","destination","review","ruleId","undefined","orgId","outcome","day","Date","toISOString","slice","set","redirects","increment","expiresAt","merge","catch","lastHitAt","serverTimestamp","statusCode","error","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/resolve-redirect.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 { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n type HostRedirect,\n isExternalRedirectDestination,\n matchRedirect,\n normalizeRedirectSource,\n redirectHitKey,\n} from '../model/redirects'\nimport {\n analyticsDayExpiresAt,\n firebaseAdmin,\n getOrgForHost,\n reviewSiteRedirect,\n} from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\nimport { boundedAwait } from '@aglyn/shared-util-http/bounded-await'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * How long a render waits on the phishing review of an off-site rule before\n * treating the rule as held for this request (AGL-3565).\n */\nconst REDIRECT_REVIEW_DEADLINE_MS = 5_000\n\n/**\n * One of three per-render reads AGL-1302 left uncached (AGL-1440).\n *\n * This query runs before route resolution on every render of every path, and\n * Firestore bills a minimum of one read for a query returning nothing — so a\n * site that has never written a rule still paid for asking, forever. The rules\n * are pure host-scoped published config: `revalidateTag(tenant-data:{hostId})`\n * busts them, and the shared hour-long TTL is only the backstop for a write\n * that never announces itself. Rule edits DO announce themselves: the\n * console's redirect manager posts `/api/screens/revalidate` with the rule's\n * source path after every save, toggle and delete, which busts the tag and\n * drops that path's cached HTML — that call is what keeps the manager's\n * \"live within ~30 seconds\" true, not this TTL.\n */\nconst REDIRECT_RULES_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/** One enabled rule, flattened to the plain data the matcher reads. */\ntype StoredRedirect = HostRedirect & { $id: string }\n\nasync function readEnabledRules(hostId: string): Promise<StoredRedirect[]> {\n const rules = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('redirects')\n .where('enabled', '==', true)\n .limit(100)\n .get()\n return rules.docs.map((doc) => ({\n ...(doc.data() as HostRedirect),\n $id: doc.id,\n }))\n}\n\nexport interface ResolvedRedirect {\n destination: string\n statusCode: 301 | 302 | 307 | 308\n}\n\n\n/**\n * Redirect enforcement (AGL-155), Option A per the issue: rules apply in\n * `getStaticProps` before route resolution, with a low revalidate on the\n * redirect response. Trade-offs, documented: rule edits take effect on\n * the next revalidation (≤30s), and hit counts are SAMPLED — cached\n * redirect responses never re-execute code, so each count represents \"at\n * least one hit in a revalidation window\", not per-request accuracy.\n * Middleware-accurate counting stays open as a later upgrade.\n *\n * Paid gating: when the host has enabled rules but the owning org's\n * plan lacks the `redirects` flag (downgrade with leftover rules), the\n * rules stop firing — the org read only happens when rules exist.\n * Loop guard: self-redirects never execute even if stored (console\n * validation and this floor can disagree; both refuse).\n * Phishing screen: a rule that leaves the site fires only once\n * `reviewSiteRedirect` answers `serve` — a destination that wears a brand is\n * held for every workspace, a lure path sent off the site for a workspace in\n * its first fortnight (AGL-3447).\n */\nexport async function resolveRedirect(\n host: { $id: string } & Record<string, unknown>,\n requestPath: string,\n): Promise<ResolvedRedirect | null> {\n try {\n const source = normalizeRedirectSource(\n requestPath === '/' || requestPath === '' ? '/' : `/${requestPath}`,\n )\n if (!source) return null\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(host.$id)\n // Cached per host, never per path (AGL-1440): the rule set is the same for\n // every URL on the site, so a path in the key would mint one entry per\n // route and cache nothing.\n const ruleList = await withRenderCache({\n key: ['redirect-rules', host.$id],\n revalidate: REDIRECT_RULES_TTL_SECONDS,\n tags: [tenantDataTag(host.$id)],\n read: () => readEnabledRules(host.$id),\n })\n if (!ruleList.length) return null\n\n // v2 matcher (AGL-375): exact/prefix/regex in priority order, capture\n // substitution, self-redirect floor. Run per request — it depends on the\n // path, which the cache key deliberately does not carry.\n const matched = matchRedirect(ruleList, source)\n if (!matched) return null\n const matchId = ruleList[matched.index]?.$id\n if (!matchId) return null\n\n // Paid gate — only paid orgs' rules fire (dark-launch orgs pass).\n //\n // Deliberately OUTSIDE the cache above. A cached entitlement would keep a\n // downgraded org's leftover rules redirecting for the life of the entry,\n // which on this code path means taking pages off the internet on the\n // strength of a plan the org no longer has.\n const owner = await getOrgForHost(host.$id)\n if (!checkEntitlement(owner?.org as any, 'redirects')) {\n return null\n }\n\n // The phishing screen (AGL-3447), for a rule that sends visitors OFF the\n // site: a page whose one call to action fires on arrival. A held rule does\n // not fire and is not counted; the request resolves as if it had no rule.\n // Outside the cache, like the paid gate: a staff decision takes effect on\n // the next request, not at the end of an hour.\n //\n // The review asks Google Web Risk and, for a rule it holds, files the hold\n // and tells staff — all inside this page render. It answers within a\n // deadline or the rule is treated as held, as the review itself fails\n // closed; the review carries on behind the page, so the next request\n // reads its decision (AGL-3565).\n if (isExternalRedirectDestination(matched.destination)) {\n const review = await boundedAwait(\n reviewSiteRedirect({\n hostId: host.$id,\n ruleId: matchId,\n source: ruleList[matched.index].source,\n destination: matched.destination,\n // The resolved host doc, when the hook handed one over; read otherwise.\n host: typeof host['subdomain'] === 'string' ? host : undefined,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n orgId: owner?.orgId ?? null,\n }),\n REDIRECT_REVIEW_DEADLINE_MS,\n null,\n 'redirects.site-redirect-review',\n )\n if (review?.outcome !== 'serve') return null\n }\n\n // Sampled hit recording (fire-and-forget): day-doc counter + recency\n // stamp for the manager (AGL-157). Also outside the cache — a cached\n // function would stop counting altogether rather than counting less often.\n // The rule's ref is rebuilt from its id, because a `DocumentReference`\n // cannot survive the cache's JSON round trip.\n const day = new Date().toISOString().slice(0, 10)\n void hostRef\n .collection('analytics')\n .doc(day)\n .set(\n {\n redirects: { [redirectHitKey(matchId)]: FieldValue.increment(1) },\n // Retention (AGL-1844): every writer of a day doc stamps the\n // day-anchored expiry the TTL policy sweeps on.\n expiresAt: analyticsDayExpiresAt(day),\n },\n { merge: true },\n )\n .catch(() => undefined)\n void hostRef\n .collection('redirects')\n .doc(matchId)\n .set({ lastHitAt: FieldValue.serverTimestamp() }, { merge: true })\n .catch(() => undefined)\n\n return { destination: matched.destination, statusCode: matched.statusCode }\n } catch (error) {\n console.error('resolveRedirect failed', host.$id, requestPath, error)\n return null\n }\n}\n\nexport default resolveRedirect\n"],"names":["checkEntitlement","isExternalRedirectDestination","matchRedirect","normalizeRedirectSource","redirectHitKey","analyticsDayExpiresAt","firebaseAdmin","getOrgForHost","reviewSiteRedirect","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","boundedAwait","FieldValue","REDIRECT_REVIEW_DEADLINE_MS","REDIRECT_RULES_TTL_SECONDS","readEnabledRules","hostId","rules","app","firestore","collection","doc","where","limit","get","docs","map","data","$id","id","resolveRedirect","host","requestPath","ruleList","source","hostRef","key","revalidate","tags","read","length","matched","matchId","index","owner","org","destination","review","ruleId","undefined","orgId","outcome","day","Date","toISOString","slice","set","redirects","increment","expiresAt","merge","catch","lastHitAt","serverTimestamp","statusCode","error","console"],"mappings":"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"}
|