@aglyn/plugins-redirects 1.0.0-beta.219 → 1.0.0-beta.220

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.219",
3
+ "version": "1.0.0-beta.220",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,15 +25,16 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.219",
29
- "@aglyn/shared-data-mdi": "1.0.0-beta.219",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.219",
31
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.219",
32
- "@aglyn/shared-util-http": "1.0.0-beta.219",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.219",
34
- "@aglyn/shared-util-tools": "1.0.0-beta.219",
35
- "@aglyn/tenant-data-admin": "1.0.0-beta.219",
36
- "@aglyn/tenant-feature-instance": "1.0.0-beta.219",
28
+ "@aglyn/aglyn": "1.0.0-beta.220",
29
+ "@aglyn/shared-data-mdi": "1.0.0-beta.220",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.220",
31
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.220",
32
+ "@aglyn/shared-util-email": "1.0.0-beta.220",
33
+ "@aglyn/shared-util-http": "1.0.0-beta.220",
34
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.220",
35
+ "@aglyn/shared-util-tools": "1.0.0-beta.220",
36
+ "@aglyn/tenant-data-admin": "1.0.0-beta.220",
37
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.220",
37
38
  "@swc/helpers": "0.5.23"
38
39
  },
39
40
  "peerDependencies": {
@@ -17,6 +17,7 @@
17
17
  import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
19
19
  import { checkQuota, pluginDocsHelp } from "@aglyn/aglyn";
20
+ import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
20
21
  import { isExternalRedirectDestination, isSelfRedirect, matchRedirect, normalizeRedirectDestination, normalizeRedirectSource, REDIRECT_DEFAULT_PRIORITY, validateRedirectRule, REDIRECT_STATUS_CODES } from "../model/index.js";
21
22
  import { CardDisplay, useConfirmationContext } from "@aglyn/shared-ui-jsx";
22
23
  import { authorizedFetch } from "@aglyn/shared-util-http/authorized-token";
@@ -296,11 +297,17 @@ import { ceilingedWindow, collectionCeiling, useFirestore, useFirestoreCollectio
296
297
  if (!draft) return;
297
298
  const kind = draft.kind || 'exact';
298
299
  // Shared v2 validation (AGL-375): regex patterns must compile; path
299
- // kinds keep the v1 normalization.
300
+ // kinds keep the v1 normalization. A destination that wears another
301
+ // brand is refused (AGL-3447); the site's own hosts never do.
300
302
  const problem = validateRedirectRule({
301
303
  kind,
302
304
  source: draft.source,
303
305
  destination: draft.destination
306
+ }, {
307
+ ownDomains: [
308
+ typeof (host == null ? void 0 : host.cname) === 'string' ? host.cname : null,
309
+ typeof (host == null ? void 0 : host.subdomain) === 'string' && host.subdomain ? `${host.subdomain}.${TENANT_APEX}` : null
310
+ ]
304
311
  });
305
312
  if (problem) {
306
313
  return void enqueueSnackbar(problem, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/components/redirects-console-page.tsx"],"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'use client'\n\nimport { checkQuota, pluginDocsHelp } from '@aglyn/aglyn'\nimport { type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { isExternalRedirectDestination, isSelfRedirect, matchRedirect, normalizeRedirectDestination, normalizeRedirectSource, REDIRECT_DEFAULT_PRIORITY, validateRedirectRule, REDIRECT_STATUS_CODES } from '../model'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport QuotaReadoutComponent from '@aglyn/shared-ui-jsx/components/quota-readout.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n deleteField,\n doc,\n getCountFromServer,\n getDoc,\n setDoc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useHostResourceApi,\n useUser,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * How many redirect documents this page reads.\n *\n * A CEILING, not a page size. Everything the page decides is computed over\n * the WHOLE rule set: the duplicate-source check, the chain-loop walk that\n * follows internal destinations through the rules, and the inline tester that\n * runs the enforcement matcher. A server page would give each of them a\n * tenth of the rules to reason about, and the duplicate check would then\n * create exactly the collision it exists to prevent.\n */\nconst REDIRECT_CEILING = 200\n\ninterface RedirectDraft {\n id: string | null\n source: string\n destination: string\n statusCode: number\n /** Match mode (AGL-375): exact | prefix | regex. */\n kind: string\n /** Lower fires first. */\n priority: number\n /**\n * The rule's on/off state, carried through the editor rather than reset by\n * it (AGL-1372). The save used to hardcode `true`, so changing a **disabled**\n * rule's target turned it back on and rerouted the site without the author\n * asking. A new rule seeds this `true`; an edit seeds it from the stored\n * rule, which is also where the row's switch reads from.\n */\n enabled: boolean\n}\n\n/**\n * Redirect manager (AGL-156): CRUD over `hosts/{hostId}/redirects` with\n * the shared AGL-154 validation (normalized sources, https-or-internal\n * destinations, self-redirect refusal, duplicate-source check). Paid\n * (`redirects` flag + `redirectsPerHost` quota); rules take effect on the\n * site within ~30 seconds (AGL-155 ISR window).\n *\n * The plan gate is the SHELL's, and it runs before this component exists\n * (AGL-2484): the console resolves `redirects` from the org billing doc and\n * renders its own refusal notice in place of the surface, so this card is\n * only ever mounted for an entitled org and states no plan terms of its own.\n * Redirects registers no `upgradeNotice` override because it is a genuine\n * upgrade — free denies it, Starter and above grant it — which makes the\n * shell's plan-derived sentence the accurate one already.\n *\n * `entitled` is still read below, where it is not redundant: it holds back\n * the thirty analytics day-doc reads behind the hit counts, and refuses the\n * add path ahead of the server that enforces it.\n */\nexport function RedirectsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, entitled, org } = props\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n const { data: currentUser } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n const {\n data: redirectDocs,\n status: redirectsStatus,\n /**\n * The rows this editor is seeded from are unconfirmed by the server\n * (AGL-1358). Editing copies every field the form owns out of the stored\n * rule and writes all of them back, so `merge: true` protects only the\n * fields written elsewhere (`lastHitAt`, `deletedAt`). This is the site\n * where a rollback is hardest to undo: a reverted `destination` on a\n * **301** is cached by every browser that has already followed it, so the\n * stale target outlives the fix in the console. `kind` reverting from\n * exact to prefix silently widens a rule over a whole subtree, and\n * `priority` reorders which rule fires when several match.\n */\n fromCache: redirectsFromCache,\n } = useFirestoreCollection<any>(\n /*\n * ORDERED AND CEILINGED, with a probe (AGL-2501).\n *\n * `limit(200)` alone is answered in DOCUMENT-ID order and the ids are\n * generated, so the window was a pseudo-random two hundred of the site's\n * rules — sorted by `source` in the browser afterwards, which is what\n * made it invisible: the rules on screen run alphabetically and are\n * simply the wrong rules, and the ones missing leave no gap to notice.\n *\n * `orderBy('source')` is the tempting fix and the dangerous one. It\n * matches only documents that HAVE the field, and nothing validates\n * `source` for presence — `/api/hosts/resources` stores an allow-list of\n * fields and checks none of them — so a rule saved without one would\n * vanish from this page while still redirecting live traffic. The\n * document NAME cannot be absent, so the walk is total: every rule is\n * reachable, and the alphabetical order stays where it belongs, in the\n * sort below over a window the page holds whole.\n */\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'redirects'),\n REDIRECT_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readRedirects, truncated: redirectsTruncated } =\n ceilingedWindow<any>(redirectDocs, REDIRECT_CEILING)\n const redirects = [...readRedirects]\n .filter((redirect: any) => !redirect.deletedAt)\n .sort((a: any, b: any) =>\n String(a.source ?? '').localeCompare(String(b.source ?? '')),\n )\n\n /*\n * The page is a SLICE of the ceiling, which is what the three whole-set\n * consumers above require. The count is the rules this page HOLDS, after\n * soft-deleted ones are dropped — not the quota, which is a server count of\n * every document including the soft-deleted, and is read separately below.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRedirects = useMemo(\n () => redirects.slice(page * pageSize, page * pageSize + pageSize),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [redirectDocs, page, pageSize],\n )\n\n /**\n * What the ENFORCING route counts, which is not what this page was counting.\n *\n * `hosts/{id}/redirects` is read here as an unordered `limit(200)` window\n * and then filtered to drop soft-deleted rows, and that filtered length was\n * feeding both `checkQuota('redirectsPerHost')` and the readout beside it.\n * The route in `app/api/hosts/resources` counts the collection plainly —\n * `collectionRef.count()`, every document, soft-deleted ones included — so\n * the two disagreed on both counts at once: a rule deleted in this console\n * still occupies a slot on the server, and a site past 200 rules has rows\n * the window never saw.\n *\n * The visible effect is the worst kind: the page shows room, the author\n * fills in the form, and the save is refused by a server counting something\n * else. Same shape as AGL-1716.\n *\n * An aggregation query, so it costs one read unit per thousand documents\n * rather than reading them. Re-run when the document set changes, which\n * covers an add; a soft delete leaves the document in place and so does not\n * move this number — correctly, because it does not move the server's\n * either.\n *\n * Only the COUNT is corrected here. Whether a soft-deleted rule should go on\n * occupying a slot is the quota RULE, and that lives with the enforcing\n * route.\n */\n const [enforcedCount, setEnforcedCount] = useState<number | null>(null)\n const redirectDocCount = redirectDocs?.length ?? 0\n\n /**\n * `null` when the count cannot be taken, never a number.\n *\n * `try`, not `.catch()`: `getCountFromServer` validates its reference and\n * THROWS synchronously on a bad one, so a rejection handler alone leaves the\n * error to escape the effect and take the page down with it. Unreadable is\n * also not zero — answering zero would tell an author at their cap that the\n * whole allowance is free.\n */\n const countRedirects = useCallback(async (): Promise<number | null> => {\n try {\n const snapshot = await getCountFromServer(\n collection(firestore, 'hosts', hostId, 'redirects'),\n )\n return snapshot.data().count\n } catch {\n return null\n }\n }, [firestore, hostId])\n\n useEffect(() => {\n let active = true\n void countRedirects().then((count) => {\n if (active && count != null) setEnforcedCount(count)\n })\n return () => {\n active = false\n }\n }, [countRedirects, redirectDocCount])\n\n const [draft, setDraft] = useState<RedirectDraft | null>(null)\n const [testPath, setTestPath] = useState('')\n\n // Host routing map for the screen-collision warning (AGL-156 spec).\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n // Sampled hit counts (AGL-157): summed from the last 30 analytics day\n // docs' `redirects` maps written by enforcement. Counts are sampled —\n // one per ISR revalidation window with traffic, not per request.\n const [hits, setHits] = useState<Record<string, number> | null>(null)\n useEffect(() => {\n if (!entitled) return\n let active = true\n const ids = Array.from({ length: 30 }, (_, index) => {\n const date = new Date()\n date.setDate(date.getDate() - index)\n return date.toISOString().slice(0, 10)\n })\n void Promise.all(\n ids.map((id) =>\n getDoc(doc(firestore, 'hosts', hostId, 'analytics', id))\n .then((snapshot) => (snapshot.get('redirects') ?? {}) as Record<string, number>)\n .catch(() => ({}) as Record<string, number>),\n ),\n ).then((days) => {\n if (!active) return\n const totals: Record<string, number> = {}\n for (const dayMap of days) {\n for (const [redirectId, count] of Object.entries(dayMap)) {\n totals[redirectId] = (totals[redirectId] ?? 0) + Number(count)\n }\n }\n setHits(totals)\n })\n return () => {\n active = false\n }\n }, [entitled, firestore, hostId])\n const idKey = (value: string) => value.replace(/[.$#[\\]/]/g, '_')\n const totalHits = Object.values(hits ?? {}).reduce(\n (sum, count) => sum + count,\n 0,\n )\n\n const handleAdd = useCallback(() => {\n if (!entitled) {\n return void enqueueSnackbar(\n 'URL redirects require a Starter plan — see Billing to upgrade',\n { variant: 'warning', persist: false },\n )\n }\n /*\n * The SERVER's count, not the rows on screen — the whole point of this\n * fix. Read from the polled value rather than re-counted on the click:\n * this gate is a courtesy that spares the author a form they are about to\n * be refused, the route is the actual enforcer, and making the button\n * await an aggregation would delay the dialog on every press to sharpen a\n * number the server re-checks anyway.\n *\n * `null` means the count could not be read, and that lets the add through:\n * refusing on a number we do not have would block an author who may well\n * be inside their cap.\n */\n const quota = checkQuota(org, 'redirectsPerHost', enforcedCount ?? 0)\n if (!quota.allowed) {\n return void enqueueSnackbar(\n `Redirect limit reached (${quota.limit}) — upgrade in Billing`,\n { variant: 'warning', persist: false },\n )\n }\n setDraft({\n id: null,\n source: '',\n destination: '',\n statusCode: 302,\n kind: 'exact',\n priority: REDIRECT_DEFAULT_PRIORITY,\n // A NEW rule is on: the author is adding it to make it fire, and\n // enforcement queries `where('enabled', '==', true)`, so a rule created\n // without the field would never resolve (AGL-1372).\n enabled: true,\n })\n }, [entitled, org, enforcedCount, enqueueSnackbar])\n\n /**\n * Tell the tenant a rule changed (best effort, never awaited into the save\n * outcome). Rule writes are client Firestore writes with no publish step,\n * and the tenant's rules list sits behind the hour-long\n * `tenant-data:{hostId}` backstop — without this call a new rule waits out\n * that TTL while the snackbar above promises ~30 seconds. The route busts\n * the host tag and drops the source path's cached HTML; a regex pattern has\n * no single literal path, so it announces `/` for the tag bust alone and\n * the matching pages catch up on their own ISR window.\n */\n const announceRuleChange = useCallback(\n (source: string | undefined) => {\n const redirectPath =\n typeof source === 'string' && source.startsWith('/') ? source : '/'\n void authorizedFetch(currentUser, '/api/screens/revalidate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, redirectPath }),\n }).catch(() => undefined)\n },\n [currentUser, hostId],\n )\n\n const handleSave = useCallback(async () => {\n if (!draft) return\n const kind = draft.kind || 'exact'\n // Shared v2 validation (AGL-375): regex patterns must compile; path\n // kinds keep the v1 normalization.\n const problem = validateRedirectRule({\n kind,\n source: draft.source,\n destination: draft.destination,\n })\n if (problem) {\n return void enqueueSnackbar(problem, {\n variant: 'warning',\n persist: false,\n })\n }\n const source =\n kind === 'regex'\n ? draft.source.trim()\n : (normalizeRedirectSource(draft.source) as string)\n const destination = normalizeRedirectDestination(\n draft.destination,\n ) as string\n if (kind !== 'regex' && isSelfRedirect({ source, destination })) {\n return void enqueueSnackbar('That would redirect the path to itself', {\n variant: 'warning',\n persist: false,\n })\n }\n const duplicate = redirects.find(\n (redirect: any) =>\n redirect.source === source &&\n (redirect.kind ?? 'exact') === kind &&\n redirect.$id !== draft.id,\n )\n if (duplicate) {\n return void enqueueSnackbar(`A rule for ${source} already exists`, {\n variant: 'warning',\n persist: false,\n })\n }\n // Chain-loop detection (AGL-156): follow internal destinations through\n // the rule set; a walk that returns to this source can never execute.\n if (destination.startsWith('/')) {\n const bySource = new Map<string, string>(\n redirects\n .filter((redirect: any) => redirect.$id !== draft.id)\n .map((redirect: any) => [redirect.source, redirect.destination]),\n )\n let cursor: string | undefined = destination.toLowerCase()\n for (let hop = 0; hop < 10 && cursor; hop += 1) {\n if (cursor === source) {\n return void enqueueSnackbar(\n 'That destination chains back to this rule — a redirect loop',\n { variant: 'warning', persist: false },\n )\n }\n const next: string | undefined = bySource.get(cursor)\n cursor = next && next.startsWith('/') ? next.toLowerCase() : undefined\n }\n }\n // Screen-route collision: shadowing a live page may be intentional\n // (moved pages) — warn, don't block (decision per the issue).\n const routedPaths = Object.values(\n (host?.screens ?? {}) as Record<string, string>,\n ).map((path) => (path === '/' ? '/' : `/${path}`))\n if (routedPaths.includes(source)) {\n enqueueSnackbar(\n `${source} is a published page — the redirect takes precedence`,\n { variant: 'info', persist: false },\n )\n }\n const fields = {\n source,\n destination,\n statusCode: (REDIRECT_STATUS_CODES as readonly number[]).includes(\n draft.statusCode,\n )\n ? draft.statusCode\n : 302,\n kind,\n priority: Number.isFinite(Number(draft.priority))\n ? Number(draft.priority)\n : REDIRECT_DEFAULT_PRIORITY,\n /**\n * Whatever the rule already was (AGL-1372). This was `true`, so editing\n * a **disabled** rule's target silently re-enabled it — a deliberate\n * \"off\" (a retired campaign, a rule that broke something) undone by a\n * routine save, changing site routing without the author asking.\n *\n * `!== false` rather than the raw value, because the switch below reads\n * the stored field the same way: absent means on. Enforcement's query is\n * `where('enabled', '==', true)`, so a rule missing the field is not\n * served, and normalising it here makes the console's claim true.\n *\n * The create default lives in `handleAdd` — a new rule seeds the draft\n * `true`, so both defaults hold without either one reading the other.\n */\n enabled: draft.enabled !== false,\n }\n /**\n * The publisher's stamp on an external destination (AGL-1881).\n *\n * `matchRedirect` will not send traffic off the platform without it, and\n * `cloud/firebase-firestore.rules` admits no write to this collection from\n * anyone but `canPublishHostContent` — so reaching this line at all is the\n * approval, and saving is how a rule written before that fix starts firing\n * again.\n *\n * Written on EDIT only. A create rides /api/hosts/resources, which stamps\n * the same field from the id token's verified uid; sending it from here\n * would be the client supplying its own provenance, and that route drops\n * it for exactly that reason.\n *\n * Cleared with `deleteField()` when the destination is internal, rather\n * than left standing. `merge: true` never removes a key, so an edit from\n * `https://elsewhere.example` back to `/pricing` would otherwise leave a\n * stamp behind that a LATER edit back to an external target would inherit\n * — approval carried across a destination nobody approved.\n */\n const approval = isExternalRedirectDestination(destination)\n ? { externalDestinationApprovedBy: currentUser?.uid ?? null }\n : { externalDestinationApprovedBy: deleteField() }\n try {\n if (draft.id) {\n /**\n * Edit stays client-direct (no quota consumed) — and is refused when\n * its seed was never confirmed by the server (AGL-1358); the eight\n * validation refusals above have all already returned by here.\n *\n * `merge: true` is load-bearing, contrary to what this said before\n * (AGL-1372): `fields` is every key the FORM owns, not every key the\n * document holds. `lastHitAt` is written by enforcement\n * (`resolve-redirect`), and `deletedAt` by the delete below — a\n * replacing write would erase both.\n *\n * The guard WRAPS the write. An early return is a shape you can keep\n * while losing the protection; here the write is only reachable\n * through the verdict.\n */\n const verdict = await writeGuardedBySeed(\n {\n subject: 'redirect',\n unreadable: redirectsStatus === 'error',\n fromCache: redirectsFromCache,\n },\n async () => {\n await setDoc(\n doc(firestore, 'hosts', hostId, 'redirects', draft.id),\n { ...fields, ...approval, updatedAt: Timestamp.now() },\n { merge: true },\n )\n },\n )\n // Before `setDraft(null)`, so a refusal keeps the dialog open with\n // what was typed, in the same warning vocabulary as the validation\n // refusals above it.\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n } else {\n // New redirect rides the quota-enforcing resources API (AGL-473) —\n // it also re-checks the `redirects` entitlement server-side.\n await createHostResource({ hostId, resource: 'redirect', data: fields })\n }\n // The changed rule, and — when an edit moved the source — the path\n // that just STOPPED redirecting, so its page refreshes too.\n announceRuleChange(source)\n if (draft.id) {\n const previous = (redirects ?? []).find(\n (rule: any) => rule.$id === draft.id,\n )?.source\n if (previous && previous !== source) announceRuleChange(previous)\n }\n setDraft(null)\n enqueueSnackbar('Redirect saved — live within ~30 seconds', {\n variant: 'success',\n persist: false,\n })\n } catch (error: any) {\n console.error(error)\n enqueueSnackbar(error?.message ?? 'An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n draft,\n redirects,\n host,\n firestore,\n hostId,\n createHostResource,\n currentUser,\n enqueueSnackbar,\n redirectsStatus,\n redirectsFromCache,\n announceRuleChange,\n ])\n\n /**\n * A refusal has to be visible (AGL-1881).\n *\n * These two writes were bare `await`s. The rules now gate this collection on\n * a publishing role, so an `author` reaching either gets a\n * permission-denied — and an unhandled rejection renders as a control that\n * flips back with no error, which is the worst way to say no. `handleSave`\n * has always ended in a snackbar; these now match it.\n *\n * The card is not role-gated on purpose: it is entitlement-gated, and the\n * rules leave READ to the catch-all so an author can still see the rules\n * that affect the pages they write.\n */\n const reportWriteFailure = useCallback(\n (error: any) => {\n enqueueSnackbar(\n error?.code === 'permission-denied'\n ? 'Changing a redirect needs a publishing role — ask an editor or admin'\n : (error?.message ?? 'An error has occurred'),\n { variant: 'warning', persist: false },\n )\n },\n [enqueueSnackbar],\n )\n\n const handleToggle = useCallback(\n (redirect: any) => async (event: { target: { checked: boolean } }) => {\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'redirects', redirect.$id),\n { enabled: event.target.checked, updatedAt: Timestamp.now() },\n )\n announceRuleChange(redirect.source)\n } catch (error: any) {\n reportWriteFailure(error)\n }\n },\n [firestore, hostId, reportWriteFailure, announceRuleChange],\n )\n\n const handleDelete = useCallback(\n (redirect: any) => async () => {\n const confirmed = await confirm({\n title: 'Delete this redirect?',\n description: `${redirect.source} stops redirecting.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'redirects', redirect.$id),\n { deletedAt: Timestamp.now(), enabled: false },\n )\n announceRuleChange(redirect.source)\n } catch (error: any) {\n reportWriteFailure(error)\n }\n },\n [confirm, firestore, hostId, reportWriteFailure, announceRuleChange],\n )\n\n return (\n <CardDisplay\n header={'URL redirects'}\n help={pluginDocsHelp('redirects', { anchor: '#manage-redirects' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Exact-path rules; 302 while testing, promote to 301 when ' +\n 'sure (browsers cache 301 aggressively). Changes go live ' +\n 'within ~30 seconds.'}\n </Typography>\n {hits && totalHits > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${totalHits} redirect hit${totalHits === 1 ? '' : 's'} in ` +\n 'the last 30 days (sampled — one per cache window with ' +\n 'traffic).'}\n </Typography>\n ) : null}\n {visibleRedirects.map((redirect: any) => (\n <Stack\n key={redirect.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Switch\n size=\"small\"\n checked={redirect.enabled !== false}\n onChange={handleToggle(redirect)}\n />\n <Chip size=\"small\" label={redirect.statusCode ?? 302} />\n {(redirect.kind ?? 'exact') !== 'exact' ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={redirect.kind}\n />\n ) : null}\n {/* An external rule with no publisher stamp is not being served\n (AGL-1881) — `matchRedirect` skips it. Say so on the row\n rather than letting it read as live: a rule that silently\n stops firing is the failure mode a fail-closed gate owes an\n explanation for, and pressing Edit → Save is the whole fix.\n Shown for external destinations only, since that is the only\n case the gate applies to. */}\n {isExternalRedirectDestination(redirect.destination) &&\n !redirect.externalDestinationApprovedBy ? (\n <Chip\n size=\"small\"\n color=\"warning\"\n variant=\"outlined\"\n label=\"not serving\"\n title={\n 'This rule sends visitors to another site. Open it and ' +\n 'save to confirm the destination — until then it is ' +\n 'skipped.'\n }\n />\n ) : null}\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {`${redirect.source} → ${redirect.destination}`}\n </Typography>\n {hits || redirect.lastHitAt ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {[\n hits\n ? `${hits[idKey(redirect.$id)] ?? 0} hits (30d, sampled)`\n : null,\n redirect.lastHitAt\n ? `last ${redirect.lastHitAt\n .toDate()\n .toLocaleString()}`\n : null,\n ]\n .filter(Boolean)\n .join(' · ')}\n </Typography>\n ) : null}\n </Stack>\n <Button\n size=\"small\"\n onClick={() =>\n setDraft({\n id: redirect.$id,\n source: redirect.source ?? '',\n destination: redirect.destination ?? '',\n statusCode: redirect.statusCode ?? 302,\n kind: redirect.kind ?? 'exact',\n priority: redirect.priority ?? REDIRECT_DEFAULT_PRIORITY,\n // Read exactly as the switch beside it does (AGL-1372):\n // an edit must not be a way to turn a rule back on.\n enabled: redirect.enabled !== false,\n })\n }\n >\n {'Edit'}\n </Button>\n <Button\n size=\"small\"\n color=\"error\"\n onClick={handleDelete(redirect)}\n >\n {'Delete'}\n </Button>\n </Stack>\n ))}\n {redirects.length === 0 ? null : (\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRedirects.length}\n // The rules this page HOLDS, after the soft-deleted ones are\n // dropped. Not the quota figure below it: that is a server\n // count over every document, soft-deleted rows included,\n // because a deleted rule still occupies a slot.\n count={redirects.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n )}\n {redirectsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${REDIRECT_CEILING} rules, ordered by id. This site ` +\n 'has more — the duplicate check and the tester below only ' +\n 'cover the ones listed here.'}\n </Alert>\n ) : null}\n <Button\n size=\"small\"\n color=\"primary\"\n sx={{ alignSelf: 'flex-start' }}\n onClick={handleAdd}\n >\n {'Add redirect'}\n </Button>\n {/* The cap, standing rather than only on refusal (AGL-2113).\n `enforcedCount` is what the enforcing route counts and what\n `handleAdd` checks against, so the readout, the gate and the\n server cannot disagree. It is deliberately NOT the number of rows\n on screen: a soft-deleted rule still occupies a slot, so showing\n the visible count here would promise room the server refuses. */}\n <QuotaReadoutComponent\n ready={org != null && enforcedCount != null}\n used={enforcedCount ?? 0}\n limit={\n checkQuota(org, 'redirectsPerHost', enforcedCount ?? 0).limit\n }\n noun=\"redirect\"\n />\n {/* Inline tester (AGL-375): same matcher as enforcement. */}\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <TextField\n size=\"small\"\n label=\"Test a path\"\n placeholder=\"/old-page\"\n value={testPath}\n onChange={(event) => setTestPath(event.target.value)}\n sx={{ maxWidth: 260 }}\n />\n {testPath.trim() ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(() => {\n const normalized =\n normalizeRedirectSource(testPath) ?? testPath.trim()\n const result = matchRedirect(redirects as any, normalized)\n return result\n ? `→ ${result.destination} (${result.statusCode})`\n : 'No rule matches'\n })()}\n </Typography>\n ) : null}\n </Stack>\n </Stack>\n\n <Dialog\n open={Boolean(draft)}\n onClose={() => setDraft(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{draft?.id ? 'Edit redirect' : 'Add redirect'}</DialogTitle>\n <DialogContent\n sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}\n >\n <TextField\n select\n label=\"Match mode\"\n value={draft?.kind ?? 'exact'}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, kind: event.target.value } : prev,\n )\n }\n size=\"small\"\n sx={{ mt: 1 }}\n >\n <MenuItem value=\"exact\">{'Exact path'}</MenuItem>\n <MenuItem value=\"prefix\">{'Path prefix'}</MenuItem>\n <MenuItem value=\"regex\">{'Regular expression'}</MenuItem>\n </TextField>\n <TextField\n label={draft?.kind === 'regex' ? 'Pattern' : 'From path'}\n placeholder={\n draft?.kind === 'regex' ? '/product/(\\\\d+)' : '/old-page'\n }\n helperText={\n draft?.kind === 'regex'\n ? 'Anchored to the whole path; use $1, $2 in the destination'\n : draft?.kind === 'prefix'\n ? 'Matches the path and everything under it'\n : undefined\n }\n value={draft?.source ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, source: event.target.value } : prev,\n )\n }\n size=\"small\"\n autoFocus\n />\n <TextField\n label=\"To\"\n placeholder=\"/new-page or https://example.com\"\n value={draft?.destination ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, destination: event.target.value } : prev,\n )\n }\n size=\"small\"\n />\n <TextField\n select\n label=\"Status code\"\n value={draft?.statusCode ?? 302}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, statusCode: Number(event.target.value) }\n : prev,\n )\n }\n size=\"small\"\n >\n <MenuItem value={302}>{'302 — temporary (default)'}</MenuItem>\n <MenuItem value={301}>{'301 — permanent'}</MenuItem>\n <MenuItem value={307}>{'307 — temporary, keep method'}</MenuItem>\n <MenuItem value={308}>{'308 — permanent, keep method'}</MenuItem>\n </TextField>\n <TextField\n label=\"Priority\"\n type=\"number\"\n helperText=\"Lower fires first when several rules match\"\n value={draft?.priority ?? REDIRECT_DEFAULT_PRIORITY}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, priority: Number(event.target.value) }\n : prev,\n )\n }\n size=\"small\"\n />\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setDraft(null)}>{'Cancel'}</Button>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!draft?.source.trim() || !draft?.destination.trim()}\n onClick={handleSave}\n >\n {'Save redirect'}\n </Button>\n </DialogActions>\n </Dialog>\n </CardDisplay>\n )\n}\nRedirectsConsolePage.displayName = 'RedirectsConsolePage'\n\nexport default RedirectsConsolePage\n"],"names":["checkQuota","pluginDocsHelp","isExternalRedirectDestination","isSelfRedirect","matchRedirect","normalizeRedirectDestination","normalizeRedirectSource","REDIRECT_DEFAULT_PRIORITY","validateRedirectRule","REDIRECT_STATUS_CODES","CardDisplay","useConfirmationContext","authorizedFetch","QuotaReadoutComponent","useSnackbar","Timestamp","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","MenuItem","Stack","Switch","TextField","Typography","collection","deleteField","doc","getCountFromServer","getDoc","setDoc","updateDoc","useCallback","useEffect","useMemo","useState","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","useFirestoreDoc","useHostResourceApi","useUser","writeGuardedBySeed","REDIRECT_CEILING","RedirectsConsolePage","props","hostId","entitled","org","firestore","createHostResource","data","currentUser","enqueueSnackbar","confirm","redirectDocs","status","redirectsStatus","fromCache","redirectsFromCache","idField","rows","readRedirects","truncated","redirectsTruncated","redirects","filter","redirect","deletedAt","sort","a","b","String","source","localeCompare","page","setPage","pageSize","setPageSize","visibleRedirects","slice","enforcedCount","setEnforcedCount","redirectDocCount","length","countRedirects","snapshot","count","active","then","draft","setDraft","testPath","setTestPath","host","hits","setHits","ids","Array","from","_","index","date","Date","setDate","getDate","toISOString","Promise","all","map","id","get","catch","days","totals","dayMap","redirectId","Object","entries","Number","idKey","value","replace","totalHits","values","reduce","sum","handleAdd","variant","persist","quota","allowed","limit","destination","statusCode","kind","priority","enabled","announceRuleChange","redirectPath","startsWith","method","headers","body","JSON","stringify","undefined","handleSave","problem","trim","duplicate","find","$id","bySource","Map","cursor","toLowerCase","hop","next","routedPaths","screens","path","includes","fields","isFinite","approval","externalDestinationApprovedBy","uid","verdict","subject","unreadable","updatedAt","now","merge","ok","message","resource","previous","rule","error","console","allowDuplicate","reportWriteFailure","code","handleToggle","event","target","checked","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","header","help","anchor","contentGutterX","contentGutterY","spacing","direction","sx","alignItems","size","onChange","label","flex","minWidth","noWrap","lastHitAt","toDate","toLocaleString","Boolean","join","onClick","rowCount","onPageChange","onPageSizeChange","severity","alignSelf","ready","used","noun","placeholder","maxWidth","normalized","result","open","onClose","fullWidth","display","flexDirection","gap","pt","select","prev","mt","helperText","autoFocus","type","disabled","displayName"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/components/redirects-console-page.tsx"],"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'use client'\n\nimport { checkQuota, pluginDocsHelp } from '@aglyn/aglyn'\nimport { type ConsolePluginPageProps } from '@aglyn/aglyn'\nimport { TENANT_APEX } from '@aglyn/aglyn/app-utils/tenant-apex'\nimport { isExternalRedirectDestination, isSelfRedirect, matchRedirect, normalizeRedirectDestination, normalizeRedirectSource, REDIRECT_DEFAULT_PRIORITY, validateRedirectRule, REDIRECT_STATUS_CODES } from '../model'\nimport { CardDisplay, useConfirmationContext } from '@aglyn/shared-ui-jsx'\nimport { authorizedFetch } from '@aglyn/shared-util-http/authorized-token'\nimport QuotaReadoutComponent from '@aglyn/shared-ui-jsx/components/quota-readout.component'\nimport { useSnackbar } from '@aglyn/shared-ui-snackstack'\nimport { Timestamp } from '@aglyn/shared-util-timestamp'\nimport {\n Alert,\n Button,\n Chip,\n Dialog,\n DialogActions,\n DialogContent,\n DialogTitle,\n MenuItem,\n Stack,\n Switch,\n TextField,\n Typography,\n} from '@mui/material'\nimport {\n collection,\n deleteField,\n doc,\n getCountFromServer,\n getDoc,\n setDoc,\n updateDoc,\n} from 'firebase/firestore'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport { ListPagination } from '@aglyn/shared-ui-jsx/components/list-pagination.component'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n ceilingedWindow,\n collectionCeiling,\n useFirestore,\n useFirestoreCollection,\n useFirestoreDoc,\n useHostResourceApi,\n useUser,\n writeGuardedBySeed,\n} from '@aglyn/tenant-feature-instance'\n\n/**\n * How many redirect documents this page reads.\n *\n * A CEILING, not a page size. Everything the page decides is computed over\n * the WHOLE rule set: the duplicate-source check, the chain-loop walk that\n * follows internal destinations through the rules, and the inline tester that\n * runs the enforcement matcher. A server page would give each of them a\n * tenth of the rules to reason about, and the duplicate check would then\n * create exactly the collision it exists to prevent.\n */\nconst REDIRECT_CEILING = 200\n\ninterface RedirectDraft {\n id: string | null\n source: string\n destination: string\n statusCode: number\n /** Match mode (AGL-375): exact | prefix | regex. */\n kind: string\n /** Lower fires first. */\n priority: number\n /**\n * The rule's on/off state, carried through the editor rather than reset by\n * it (AGL-1372). The save used to hardcode `true`, so changing a **disabled**\n * rule's target turned it back on and rerouted the site without the author\n * asking. A new rule seeds this `true`; an edit seeds it from the stored\n * rule, which is also where the row's switch reads from.\n */\n enabled: boolean\n}\n\n/**\n * Redirect manager (AGL-156): CRUD over `hosts/{hostId}/redirects` with\n * the shared AGL-154 validation (normalized sources, https-or-internal\n * destinations, self-redirect refusal, duplicate-source check). Paid\n * (`redirects` flag + `redirectsPerHost` quota); rules take effect on the\n * site within ~30 seconds (AGL-155 ISR window).\n *\n * The plan gate is the SHELL's, and it runs before this component exists\n * (AGL-2484): the console resolves `redirects` from the org billing doc and\n * renders its own refusal notice in place of the surface, so this card is\n * only ever mounted for an entitled org and states no plan terms of its own.\n * Redirects registers no `upgradeNotice` override because it is a genuine\n * upgrade — free denies it, Starter and above grant it — which makes the\n * shell's plan-derived sentence the accurate one already.\n *\n * `entitled` is still read below, where it is not redundant: it holds back\n * the thirty analytics day-doc reads behind the hit counts, and refuses the\n * add path ahead of the server that enforces it.\n */\nexport function RedirectsConsolePage(props: ConsolePluginPageProps) {\n const { hostId, entitled, org } = props\n const firestore = useFirestore()\n const createHostResource = useHostResourceApi()\n const { data: currentUser } = useUser()\n const { enqueueSnackbar } = useSnackbar()\n const { confirm } = useConfirmationContext()\n\n const {\n data: redirectDocs,\n status: redirectsStatus,\n /**\n * The rows this editor is seeded from are unconfirmed by the server\n * (AGL-1358). Editing copies every field the form owns out of the stored\n * rule and writes all of them back, so `merge: true` protects only the\n * fields written elsewhere (`lastHitAt`, `deletedAt`). This is the site\n * where a rollback is hardest to undo: a reverted `destination` on a\n * **301** is cached by every browser that has already followed it, so the\n * stale target outlives the fix in the console. `kind` reverting from\n * exact to prefix silently widens a rule over a whole subtree, and\n * `priority` reorders which rule fires when several match.\n */\n fromCache: redirectsFromCache,\n } = useFirestoreCollection<any>(\n /*\n * ORDERED AND CEILINGED, with a probe (AGL-2501).\n *\n * `limit(200)` alone is answered in DOCUMENT-ID order and the ids are\n * generated, so the window was a pseudo-random two hundred of the site's\n * rules — sorted by `source` in the browser afterwards, which is what\n * made it invisible: the rules on screen run alphabetically and are\n * simply the wrong rules, and the ones missing leave no gap to notice.\n *\n * `orderBy('source')` is the tempting fix and the dangerous one. It\n * matches only documents that HAVE the field, and nothing validates\n * `source` for presence — `/api/hosts/resources` stores an allow-list of\n * fields and checks none of them — so a rule saved without one would\n * vanish from this page while still redirecting live traffic. The\n * document NAME cannot be absent, so the walk is total: every rule is\n * reachable, and the alphabetical order stays where it belongs, in the\n * sort below over a window the page holds whole.\n */\n () =>\n collectionCeiling(\n collection(firestore, 'hosts', hostId, 'redirects'),\n REDIRECT_CEILING,\n ),\n [firestore, hostId],\n { idField: '$id' },\n )\n const { rows: readRedirects, truncated: redirectsTruncated } =\n ceilingedWindow<any>(redirectDocs, REDIRECT_CEILING)\n const redirects = [...readRedirects]\n .filter((redirect: any) => !redirect.deletedAt)\n .sort((a: any, b: any) =>\n String(a.source ?? '').localeCompare(String(b.source ?? '')),\n )\n\n /*\n * The page is a SLICE of the ceiling, which is what the three whole-set\n * consumers above require. The count is the rules this page HOLDS, after\n * soft-deleted ones are dropped — not the quota, which is a server count of\n * every document including the soft-deleted, and is read separately below.\n */\n const [page, setPage] = useState(0)\n const [pageSize, setPageSize] = useState(TABLE_PAGE_SIZE_DEFAULT)\n const visibleRedirects = useMemo(\n () => redirects.slice(page * pageSize, page * pageSize + pageSize),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [redirectDocs, page, pageSize],\n )\n\n /**\n * What the ENFORCING route counts, which is not what this page was counting.\n *\n * `hosts/{id}/redirects` is read here as an unordered `limit(200)` window\n * and then filtered to drop soft-deleted rows, and that filtered length was\n * feeding both `checkQuota('redirectsPerHost')` and the readout beside it.\n * The route in `app/api/hosts/resources` counts the collection plainly —\n * `collectionRef.count()`, every document, soft-deleted ones included — so\n * the two disagreed on both counts at once: a rule deleted in this console\n * still occupies a slot on the server, and a site past 200 rules has rows\n * the window never saw.\n *\n * The visible effect is the worst kind: the page shows room, the author\n * fills in the form, and the save is refused by a server counting something\n * else. Same shape as AGL-1716.\n *\n * An aggregation query, so it costs one read unit per thousand documents\n * rather than reading them. Re-run when the document set changes, which\n * covers an add; a soft delete leaves the document in place and so does not\n * move this number — correctly, because it does not move the server's\n * either.\n *\n * Only the COUNT is corrected here. Whether a soft-deleted rule should go on\n * occupying a slot is the quota RULE, and that lives with the enforcing\n * route.\n */\n const [enforcedCount, setEnforcedCount] = useState<number | null>(null)\n const redirectDocCount = redirectDocs?.length ?? 0\n\n /**\n * `null` when the count cannot be taken, never a number.\n *\n * `try`, not `.catch()`: `getCountFromServer` validates its reference and\n * THROWS synchronously on a bad one, so a rejection handler alone leaves the\n * error to escape the effect and take the page down with it. Unreadable is\n * also not zero — answering zero would tell an author at their cap that the\n * whole allowance is free.\n */\n const countRedirects = useCallback(async (): Promise<number | null> => {\n try {\n const snapshot = await getCountFromServer(\n collection(firestore, 'hosts', hostId, 'redirects'),\n )\n return snapshot.data().count\n } catch {\n return null\n }\n }, [firestore, hostId])\n\n useEffect(() => {\n let active = true\n void countRedirects().then((count) => {\n if (active && count != null) setEnforcedCount(count)\n })\n return () => {\n active = false\n }\n }, [countRedirects, redirectDocCount])\n\n const [draft, setDraft] = useState<RedirectDraft | null>(null)\n const [testPath, setTestPath] = useState('')\n\n // Host routing map for the screen-collision warning (AGL-156 spec).\n const { data: host } = useFirestoreDoc<any>(\n () => doc(firestore, 'hosts', hostId),\n [firestore, hostId],\n { idField: '$id' },\n )\n\n // Sampled hit counts (AGL-157): summed from the last 30 analytics day\n // docs' `redirects` maps written by enforcement. Counts are sampled —\n // one per ISR revalidation window with traffic, not per request.\n const [hits, setHits] = useState<Record<string, number> | null>(null)\n useEffect(() => {\n if (!entitled) return\n let active = true\n const ids = Array.from({ length: 30 }, (_, index) => {\n const date = new Date()\n date.setDate(date.getDate() - index)\n return date.toISOString().slice(0, 10)\n })\n void Promise.all(\n ids.map((id) =>\n getDoc(doc(firestore, 'hosts', hostId, 'analytics', id))\n .then((snapshot) => (snapshot.get('redirects') ?? {}) as Record<string, number>)\n .catch(() => ({}) as Record<string, number>),\n ),\n ).then((days) => {\n if (!active) return\n const totals: Record<string, number> = {}\n for (const dayMap of days) {\n for (const [redirectId, count] of Object.entries(dayMap)) {\n totals[redirectId] = (totals[redirectId] ?? 0) + Number(count)\n }\n }\n setHits(totals)\n })\n return () => {\n active = false\n }\n }, [entitled, firestore, hostId])\n const idKey = (value: string) => value.replace(/[.$#[\\]/]/g, '_')\n const totalHits = Object.values(hits ?? {}).reduce(\n (sum, count) => sum + count,\n 0,\n )\n\n const handleAdd = useCallback(() => {\n if (!entitled) {\n return void enqueueSnackbar(\n 'URL redirects require a Starter plan — see Billing to upgrade',\n { variant: 'warning', persist: false },\n )\n }\n /*\n * The SERVER's count, not the rows on screen — the whole point of this\n * fix. Read from the polled value rather than re-counted on the click:\n * this gate is a courtesy that spares the author a form they are about to\n * be refused, the route is the actual enforcer, and making the button\n * await an aggregation would delay the dialog on every press to sharpen a\n * number the server re-checks anyway.\n *\n * `null` means the count could not be read, and that lets the add through:\n * refusing on a number we do not have would block an author who may well\n * be inside their cap.\n */\n const quota = checkQuota(org, 'redirectsPerHost', enforcedCount ?? 0)\n if (!quota.allowed) {\n return void enqueueSnackbar(\n `Redirect limit reached (${quota.limit}) — upgrade in Billing`,\n { variant: 'warning', persist: false },\n )\n }\n setDraft({\n id: null,\n source: '',\n destination: '',\n statusCode: 302,\n kind: 'exact',\n priority: REDIRECT_DEFAULT_PRIORITY,\n // A NEW rule is on: the author is adding it to make it fire, and\n // enforcement queries `where('enabled', '==', true)`, so a rule created\n // without the field would never resolve (AGL-1372).\n enabled: true,\n })\n }, [entitled, org, enforcedCount, enqueueSnackbar])\n\n /**\n * Tell the tenant a rule changed (best effort, never awaited into the save\n * outcome). Rule writes are client Firestore writes with no publish step,\n * and the tenant's rules list sits behind the hour-long\n * `tenant-data:{hostId}` backstop — without this call a new rule waits out\n * that TTL while the snackbar above promises ~30 seconds. The route busts\n * the host tag and drops the source path's cached HTML; a regex pattern has\n * no single literal path, so it announces `/` for the tag bust alone and\n * the matching pages catch up on their own ISR window.\n */\n const announceRuleChange = useCallback(\n (source: string | undefined) => {\n const redirectPath =\n typeof source === 'string' && source.startsWith('/') ? source : '/'\n void authorizedFetch(currentUser, '/api/screens/revalidate', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ hostId, redirectPath }),\n }).catch(() => undefined)\n },\n [currentUser, hostId],\n )\n\n const handleSave = useCallback(async () => {\n if (!draft) return\n const kind = draft.kind || 'exact'\n // Shared v2 validation (AGL-375): regex patterns must compile; path\n // kinds keep the v1 normalization. A destination that wears another\n // brand is refused (AGL-3447); the site's own hosts never do.\n const problem = validateRedirectRule(\n {\n kind,\n source: draft.source,\n destination: draft.destination,\n },\n {\n ownDomains: [\n typeof host?.cname === 'string' ? host.cname : null,\n typeof host?.subdomain === 'string' && host.subdomain\n ? `${host.subdomain}.${TENANT_APEX}`\n : null,\n ],\n },\n )\n if (problem) {\n return void enqueueSnackbar(problem, {\n variant: 'warning',\n persist: false,\n })\n }\n const source =\n kind === 'regex'\n ? draft.source.trim()\n : (normalizeRedirectSource(draft.source) as string)\n const destination = normalizeRedirectDestination(\n draft.destination,\n ) as string\n if (kind !== 'regex' && isSelfRedirect({ source, destination })) {\n return void enqueueSnackbar('That would redirect the path to itself', {\n variant: 'warning',\n persist: false,\n })\n }\n const duplicate = redirects.find(\n (redirect: any) =>\n redirect.source === source &&\n (redirect.kind ?? 'exact') === kind &&\n redirect.$id !== draft.id,\n )\n if (duplicate) {\n return void enqueueSnackbar(`A rule for ${source} already exists`, {\n variant: 'warning',\n persist: false,\n })\n }\n // Chain-loop detection (AGL-156): follow internal destinations through\n // the rule set; a walk that returns to this source can never execute.\n if (destination.startsWith('/')) {\n const bySource = new Map<string, string>(\n redirects\n .filter((redirect: any) => redirect.$id !== draft.id)\n .map((redirect: any) => [redirect.source, redirect.destination]),\n )\n let cursor: string | undefined = destination.toLowerCase()\n for (let hop = 0; hop < 10 && cursor; hop += 1) {\n if (cursor === source) {\n return void enqueueSnackbar(\n 'That destination chains back to this rule — a redirect loop',\n { variant: 'warning', persist: false },\n )\n }\n const next: string | undefined = bySource.get(cursor)\n cursor = next && next.startsWith('/') ? next.toLowerCase() : undefined\n }\n }\n // Screen-route collision: shadowing a live page may be intentional\n // (moved pages) — warn, don't block (decision per the issue).\n const routedPaths = Object.values(\n (host?.screens ?? {}) as Record<string, string>,\n ).map((path) => (path === '/' ? '/' : `/${path}`))\n if (routedPaths.includes(source)) {\n enqueueSnackbar(\n `${source} is a published page — the redirect takes precedence`,\n { variant: 'info', persist: false },\n )\n }\n const fields = {\n source,\n destination,\n statusCode: (REDIRECT_STATUS_CODES as readonly number[]).includes(\n draft.statusCode,\n )\n ? draft.statusCode\n : 302,\n kind,\n priority: Number.isFinite(Number(draft.priority))\n ? Number(draft.priority)\n : REDIRECT_DEFAULT_PRIORITY,\n /**\n * Whatever the rule already was (AGL-1372). This was `true`, so editing\n * a **disabled** rule's target silently re-enabled it — a deliberate\n * \"off\" (a retired campaign, a rule that broke something) undone by a\n * routine save, changing site routing without the author asking.\n *\n * `!== false` rather than the raw value, because the switch below reads\n * the stored field the same way: absent means on. Enforcement's query is\n * `where('enabled', '==', true)`, so a rule missing the field is not\n * served, and normalising it here makes the console's claim true.\n *\n * The create default lives in `handleAdd` — a new rule seeds the draft\n * `true`, so both defaults hold without either one reading the other.\n */\n enabled: draft.enabled !== false,\n }\n /**\n * The publisher's stamp on an external destination (AGL-1881).\n *\n * `matchRedirect` will not send traffic off the platform without it, and\n * `cloud/firebase-firestore.rules` admits no write to this collection from\n * anyone but `canPublishHostContent` — so reaching this line at all is the\n * approval, and saving is how a rule written before that fix starts firing\n * again.\n *\n * Written on EDIT only. A create rides /api/hosts/resources, which stamps\n * the same field from the id token's verified uid; sending it from here\n * would be the client supplying its own provenance, and that route drops\n * it for exactly that reason.\n *\n * Cleared with `deleteField()` when the destination is internal, rather\n * than left standing. `merge: true` never removes a key, so an edit from\n * `https://elsewhere.example` back to `/pricing` would otherwise leave a\n * stamp behind that a LATER edit back to an external target would inherit\n * — approval carried across a destination nobody approved.\n */\n const approval = isExternalRedirectDestination(destination)\n ? { externalDestinationApprovedBy: currentUser?.uid ?? null }\n : { externalDestinationApprovedBy: deleteField() }\n try {\n if (draft.id) {\n /**\n * Edit stays client-direct (no quota consumed) — and is refused when\n * its seed was never confirmed by the server (AGL-1358); the eight\n * validation refusals above have all already returned by here.\n *\n * `merge: true` is load-bearing, contrary to what this said before\n * (AGL-1372): `fields` is every key the FORM owns, not every key the\n * document holds. `lastHitAt` is written by enforcement\n * (`resolve-redirect`), and `deletedAt` by the delete below — a\n * replacing write would erase both.\n *\n * The guard WRAPS the write. An early return is a shape you can keep\n * while losing the protection; here the write is only reachable\n * through the verdict.\n */\n const verdict = await writeGuardedBySeed(\n {\n subject: 'redirect',\n unreadable: redirectsStatus === 'error',\n fromCache: redirectsFromCache,\n },\n async () => {\n await setDoc(\n doc(firestore, 'hosts', hostId, 'redirects', draft.id),\n { ...fields, ...approval, updatedAt: Timestamp.now() },\n { merge: true },\n )\n },\n )\n // Before `setDraft(null)`, so a refusal keeps the dialog open with\n // what was typed, in the same warning vocabulary as the validation\n // refusals above it.\n if (!verdict.ok) {\n return void enqueueSnackbar(verdict.message, {\n variant: 'warning',\n persist: false,\n })\n }\n } else {\n // New redirect rides the quota-enforcing resources API (AGL-473) —\n // it also re-checks the `redirects` entitlement server-side.\n await createHostResource({ hostId, resource: 'redirect', data: fields })\n }\n // The changed rule, and — when an edit moved the source — the path\n // that just STOPPED redirecting, so its page refreshes too.\n announceRuleChange(source)\n if (draft.id) {\n const previous = (redirects ?? []).find(\n (rule: any) => rule.$id === draft.id,\n )?.source\n if (previous && previous !== source) announceRuleChange(previous)\n }\n setDraft(null)\n enqueueSnackbar('Redirect saved — live within ~30 seconds', {\n variant: 'success',\n persist: false,\n })\n } catch (error: any) {\n console.error(error)\n enqueueSnackbar(error?.message ?? 'An error has occurred', {\n variant: 'error',\n allowDuplicate: true,\n })\n }\n }, [\n draft,\n redirects,\n host,\n firestore,\n hostId,\n createHostResource,\n currentUser,\n enqueueSnackbar,\n redirectsStatus,\n redirectsFromCache,\n announceRuleChange,\n ])\n\n /**\n * A refusal has to be visible (AGL-1881).\n *\n * These two writes were bare `await`s. The rules now gate this collection on\n * a publishing role, so an `author` reaching either gets a\n * permission-denied — and an unhandled rejection renders as a control that\n * flips back with no error, which is the worst way to say no. `handleSave`\n * has always ended in a snackbar; these now match it.\n *\n * The card is not role-gated on purpose: it is entitlement-gated, and the\n * rules leave READ to the catch-all so an author can still see the rules\n * that affect the pages they write.\n */\n const reportWriteFailure = useCallback(\n (error: any) => {\n enqueueSnackbar(\n error?.code === 'permission-denied'\n ? 'Changing a redirect needs a publishing role — ask an editor or admin'\n : (error?.message ?? 'An error has occurred'),\n { variant: 'warning', persist: false },\n )\n },\n [enqueueSnackbar],\n )\n\n const handleToggle = useCallback(\n (redirect: any) => async (event: { target: { checked: boolean } }) => {\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'redirects', redirect.$id),\n { enabled: event.target.checked, updatedAt: Timestamp.now() },\n )\n announceRuleChange(redirect.source)\n } catch (error: any) {\n reportWriteFailure(error)\n }\n },\n [firestore, hostId, reportWriteFailure, announceRuleChange],\n )\n\n const handleDelete = useCallback(\n (redirect: any) => async () => {\n const confirmed = await confirm({\n title: 'Delete this redirect?',\n description: `${redirect.source} stops redirecting.`,\n confirmationText: 'Delete',\n confirmationButtonProps: { color: 'error' },\n })\n .then(() => true)\n .catch(() => false)\n if (!confirmed) return\n try {\n await updateDoc(\n doc(firestore, 'hosts', hostId, 'redirects', redirect.$id),\n { deletedAt: Timestamp.now(), enabled: false },\n )\n announceRuleChange(redirect.source)\n } catch (error: any) {\n reportWriteFailure(error)\n }\n },\n [confirm, firestore, hostId, reportWriteFailure, announceRuleChange],\n )\n\n return (\n <CardDisplay\n header={'URL redirects'}\n help={pluginDocsHelp('redirects', { anchor: '#manage-redirects' })}\n contentGutterX\n contentGutterY\n >\n <Stack spacing={1}>\n <Typography variant=\"body2\" color=\"text.secondary\">\n {'Exact-path rules; 302 while testing, promote to 301 when ' +\n 'sure (browsers cache 301 aggressively). Changes go live ' +\n 'within ~30 seconds.'}\n </Typography>\n {hits && totalHits > 0 ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {`${totalHits} redirect hit${totalHits === 1 ? '' : 's'} in ` +\n 'the last 30 days (sampled — one per cache window with ' +\n 'traffic).'}\n </Typography>\n ) : null}\n {visibleRedirects.map((redirect: any) => (\n <Stack\n key={redirect.$id}\n direction=\"row\"\n spacing={1}\n sx={{ alignItems: 'center' }}\n >\n <Switch\n size=\"small\"\n checked={redirect.enabled !== false}\n onChange={handleToggle(redirect)}\n />\n <Chip size=\"small\" label={redirect.statusCode ?? 302} />\n {(redirect.kind ?? 'exact') !== 'exact' ? (\n <Chip\n size=\"small\"\n variant=\"outlined\"\n label={redirect.kind}\n />\n ) : null}\n {/* An external rule with no publisher stamp is not being served\n (AGL-1881) — `matchRedirect` skips it. Say so on the row\n rather than letting it read as live: a rule that silently\n stops firing is the failure mode a fail-closed gate owes an\n explanation for, and pressing Edit → Save is the whole fix.\n Shown for external destinations only, since that is the only\n case the gate applies to. */}\n {isExternalRedirectDestination(redirect.destination) &&\n !redirect.externalDestinationApprovedBy ? (\n <Chip\n size=\"small\"\n color=\"warning\"\n variant=\"outlined\"\n label=\"not serving\"\n title={\n 'This rule sends visitors to another site. Open it and ' +\n 'save to confirm the destination — until then it is ' +\n 'skipped.'\n }\n />\n ) : null}\n <Stack sx={{ flex: 1, minWidth: 0 }}>\n <Typography variant=\"body2\" noWrap>\n {`${redirect.source} → ${redirect.destination}`}\n </Typography>\n {hits || redirect.lastHitAt ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {[\n hits\n ? `${hits[idKey(redirect.$id)] ?? 0} hits (30d, sampled)`\n : null,\n redirect.lastHitAt\n ? `last ${redirect.lastHitAt\n .toDate()\n .toLocaleString()}`\n : null,\n ]\n .filter(Boolean)\n .join(' · ')}\n </Typography>\n ) : null}\n </Stack>\n <Button\n size=\"small\"\n onClick={() =>\n setDraft({\n id: redirect.$id,\n source: redirect.source ?? '',\n destination: redirect.destination ?? '',\n statusCode: redirect.statusCode ?? 302,\n kind: redirect.kind ?? 'exact',\n priority: redirect.priority ?? REDIRECT_DEFAULT_PRIORITY,\n // Read exactly as the switch beside it does (AGL-1372):\n // an edit must not be a way to turn a rule back on.\n enabled: redirect.enabled !== false,\n })\n }\n >\n {'Edit'}\n </Button>\n <Button\n size=\"small\"\n color=\"error\"\n onClick={handleDelete(redirect)}\n >\n {'Delete'}\n </Button>\n </Stack>\n ))}\n {redirects.length === 0 ? null : (\n <ListPagination\n page={page}\n pageSize={pageSize}\n rowCount={visibleRedirects.length}\n // The rules this page HOLDS, after the soft-deleted ones are\n // dropped. Not the quota figure below it: that is a server\n // count over every document, soft-deleted rows included,\n // because a deleted rule still occupies a slot.\n count={redirects.length}\n onPageChange={setPage}\n onPageSizeChange={setPageSize}\n />\n )}\n {redirectsTruncated ? (\n <Alert severity=\"info\">\n {`Showing ${REDIRECT_CEILING} rules, ordered by id. This site ` +\n 'has more — the duplicate check and the tester below only ' +\n 'cover the ones listed here.'}\n </Alert>\n ) : null}\n <Button\n size=\"small\"\n color=\"primary\"\n sx={{ alignSelf: 'flex-start' }}\n onClick={handleAdd}\n >\n {'Add redirect'}\n </Button>\n {/* The cap, standing rather than only on refusal (AGL-2113).\n `enforcedCount` is what the enforcing route counts and what\n `handleAdd` checks against, so the readout, the gate and the\n server cannot disagree. It is deliberately NOT the number of rows\n on screen: a soft-deleted rule still occupies a slot, so showing\n the visible count here would promise room the server refuses. */}\n <QuotaReadoutComponent\n ready={org != null && enforcedCount != null}\n used={enforcedCount ?? 0}\n limit={\n checkQuota(org, 'redirectsPerHost', enforcedCount ?? 0).limit\n }\n noun=\"redirect\"\n />\n {/* Inline tester (AGL-375): same matcher as enforcement. */}\n <Stack direction=\"row\" spacing={1} sx={{ alignItems: 'center' }}>\n <TextField\n size=\"small\"\n label=\"Test a path\"\n placeholder=\"/old-page\"\n value={testPath}\n onChange={(event) => setTestPath(event.target.value)}\n sx={{ maxWidth: 260 }}\n />\n {testPath.trim() ? (\n <Typography variant=\"caption\" color=\"text.secondary\">\n {(() => {\n const normalized =\n normalizeRedirectSource(testPath) ?? testPath.trim()\n const result = matchRedirect(redirects as any, normalized)\n return result\n ? `→ ${result.destination} (${result.statusCode})`\n : 'No rule matches'\n })()}\n </Typography>\n ) : null}\n </Stack>\n </Stack>\n\n <Dialog\n open={Boolean(draft)}\n onClose={() => setDraft(null)}\n maxWidth=\"xs\"\n fullWidth\n >\n <DialogTitle>{draft?.id ? 'Edit redirect' : 'Add redirect'}</DialogTitle>\n <DialogContent\n sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, pt: 1 }}\n >\n <TextField\n select\n label=\"Match mode\"\n value={draft?.kind ?? 'exact'}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, kind: event.target.value } : prev,\n )\n }\n size=\"small\"\n sx={{ mt: 1 }}\n >\n <MenuItem value=\"exact\">{'Exact path'}</MenuItem>\n <MenuItem value=\"prefix\">{'Path prefix'}</MenuItem>\n <MenuItem value=\"regex\">{'Regular expression'}</MenuItem>\n </TextField>\n <TextField\n label={draft?.kind === 'regex' ? 'Pattern' : 'From path'}\n placeholder={\n draft?.kind === 'regex' ? '/product/(\\\\d+)' : '/old-page'\n }\n helperText={\n draft?.kind === 'regex'\n ? 'Anchored to the whole path; use $1, $2 in the destination'\n : draft?.kind === 'prefix'\n ? 'Matches the path and everything under it'\n : undefined\n }\n value={draft?.source ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, source: event.target.value } : prev,\n )\n }\n size=\"small\"\n autoFocus\n />\n <TextField\n label=\"To\"\n placeholder=\"/new-page or https://example.com\"\n value={draft?.destination ?? ''}\n onChange={(event) =>\n setDraft((prev) =>\n prev ? { ...prev, destination: event.target.value } : prev,\n )\n }\n size=\"small\"\n />\n <TextField\n select\n label=\"Status code\"\n value={draft?.statusCode ?? 302}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, statusCode: Number(event.target.value) }\n : prev,\n )\n }\n size=\"small\"\n >\n <MenuItem value={302}>{'302 — temporary (default)'}</MenuItem>\n <MenuItem value={301}>{'301 — permanent'}</MenuItem>\n <MenuItem value={307}>{'307 — temporary, keep method'}</MenuItem>\n <MenuItem value={308}>{'308 — permanent, keep method'}</MenuItem>\n </TextField>\n <TextField\n label=\"Priority\"\n type=\"number\"\n helperText=\"Lower fires first when several rules match\"\n value={draft?.priority ?? REDIRECT_DEFAULT_PRIORITY}\n onChange={(event) =>\n setDraft((prev) =>\n prev\n ? { ...prev, priority: Number(event.target.value) }\n : prev,\n )\n }\n size=\"small\"\n />\n </DialogContent>\n <DialogActions>\n <Button onClick={() => setDraft(null)}>{'Cancel'}</Button>\n <Button\n variant=\"contained\"\n color=\"primary\"\n disabled={!draft?.source.trim() || !draft?.destination.trim()}\n onClick={handleSave}\n >\n {'Save redirect'}\n </Button>\n </DialogActions>\n </Dialog>\n </CardDisplay>\n )\n}\nRedirectsConsolePage.displayName = 'RedirectsConsolePage'\n\nexport default RedirectsConsolePage\n"],"names":["checkQuota","pluginDocsHelp","TENANT_APEX","isExternalRedirectDestination","isSelfRedirect","matchRedirect","normalizeRedirectDestination","normalizeRedirectSource","REDIRECT_DEFAULT_PRIORITY","validateRedirectRule","REDIRECT_STATUS_CODES","CardDisplay","useConfirmationContext","authorizedFetch","QuotaReadoutComponent","useSnackbar","Timestamp","Alert","Button","Chip","Dialog","DialogActions","DialogContent","DialogTitle","MenuItem","Stack","Switch","TextField","Typography","collection","deleteField","doc","getCountFromServer","getDoc","setDoc","updateDoc","useCallback","useEffect","useMemo","useState","ListPagination","TABLE_PAGE_SIZE_DEFAULT","ceilingedWindow","collectionCeiling","useFirestore","useFirestoreCollection","useFirestoreDoc","useHostResourceApi","useUser","writeGuardedBySeed","REDIRECT_CEILING","RedirectsConsolePage","props","hostId","entitled","org","firestore","createHostResource","data","currentUser","enqueueSnackbar","confirm","redirectDocs","status","redirectsStatus","fromCache","redirectsFromCache","idField","rows","readRedirects","truncated","redirectsTruncated","redirects","filter","redirect","deletedAt","sort","a","b","String","source","localeCompare","page","setPage","pageSize","setPageSize","visibleRedirects","slice","enforcedCount","setEnforcedCount","redirectDocCount","length","countRedirects","snapshot","count","active","then","draft","setDraft","testPath","setTestPath","host","hits","setHits","ids","Array","from","_","index","date","Date","setDate","getDate","toISOString","Promise","all","map","id","get","catch","days","totals","dayMap","redirectId","Object","entries","Number","idKey","value","replace","totalHits","values","reduce","sum","handleAdd","variant","persist","quota","allowed","limit","destination","statusCode","kind","priority","enabled","announceRuleChange","redirectPath","startsWith","method","headers","body","JSON","stringify","undefined","handleSave","problem","ownDomains","cname","subdomain","trim","duplicate","find","$id","bySource","Map","cursor","toLowerCase","hop","next","routedPaths","screens","path","includes","fields","isFinite","approval","externalDestinationApprovedBy","uid","verdict","subject","unreadable","updatedAt","now","merge","ok","message","resource","previous","rule","error","console","allowDuplicate","reportWriteFailure","code","handleToggle","event","target","checked","handleDelete","confirmed","title","description","confirmationText","confirmationButtonProps","color","header","help","anchor","contentGutterX","contentGutterY","spacing","direction","sx","alignItems","size","onChange","label","flex","minWidth","noWrap","lastHitAt","toDate","toLocaleString","Boolean","join","onClick","rowCount","onPageChange","onPageSizeChange","severity","alignSelf","ready","used","noun","placeholder","maxWidth","normalized","result","open","onClose","fullWidth","display","flexDirection","gap","pt","select","prev","mt","helperText","autoFocus","type","disabled","displayName"],"mappings":"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"}
@@ -14,13 +14,6 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- /**
18
- * URL redirects (AGL-154): paid per-host rules at
19
- * `hosts/{hostId}/redirects/{id}`. Exact source match in v1 (the `kind`
20
- * field leaves room for patterns later); destinations are internal paths
21
- * or absolute https URLs. Console validation and tenant enforcement share
22
- * these helpers so they can't disagree.
23
- */
24
17
  import { type LinearPattern } from '@aglyn/shared-util-tools/linear-regex';
25
18
  export interface HostRedirect {
26
19
  /**
@@ -82,12 +75,32 @@ export declare const REDIRECT_DEFAULT_PRIORITY = 100;
82
75
  * never take a site down.
83
76
  */
84
77
  export declare function compileRedirectRegex(source: string): LinearPattern | null;
85
- /** Validation for a v2 rule; returns a problem string or null when ok. */
78
+ /**
79
+ * Validation for a v2 rule; returns a problem string or null when ok.
80
+ *
81
+ * `workspace` names the site's own hosts, so a destination on one of them
82
+ * is never read as somebody else's.
83
+ */
86
84
  export declare function validateRedirectRule(rule: {
87
85
  kind?: string;
88
86
  source: string;
89
87
  destination: string;
88
+ }, workspace?: {
89
+ ownNames?: readonly (string | null | undefined)[];
90
+ ownDomains?: readonly (string | null | undefined)[];
90
91
  }): string | null;
92
+ /**
93
+ * Does this destination wear a brand it is not (AGL-3447)? The phishing
94
+ * screen's STRONG tier, the one that holds for every workspace whatever its
95
+ * age: `https://sharepoint-files.example.top/`, `https://paypa1.com/`. Read
96
+ * the same at save, where the console refuses it, and on the serve path,
97
+ * where `reviewSiteRedirect` holds it. The softer rules need the workspace's
98
+ * age, which only the serve path reads.
99
+ */
100
+ export declare function isLookalikeRedirectDestination(destination: string, workspace?: {
101
+ ownNames?: readonly (string | null | undefined)[];
102
+ ownDomains?: readonly (string | null | undefined)[];
103
+ }): boolean;
91
104
  /**
92
105
  * Does this destination leave the platform? (AGL-1881.)
93
106
  *
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -19,7 +20,9 @@
19
20
  * field leaves room for patterns later); destinations are internal paths
20
21
  * or absolute https URLs. Console validation and tenant enforcement share
21
22
  * these helpers so they can't disagree.
22
- */ import { compileLinearPattern, explainLinearPattern } from "@aglyn/shared-util-tools/linear-regex";
23
+ */ import { screenSiteRedirect } from "@aglyn/shared-util-email/hosted-page-screen";
24
+ import { signalsThatHold } from "@aglyn/shared-util-email/outbound-phishing-screen";
25
+ import { compileLinearPattern, explainLinearPattern } from "@aglyn/shared-util-tools/linear-regex";
23
26
  export const REDIRECT_STATUS_CODES = [
24
27
  301,
25
28
  302,
@@ -63,7 +66,12 @@ export const REDIRECT_DEFAULT_PRIORITY = 100;
63
66
  if (!pattern || pattern.length > REGEX_SOURCE_MAX) return null;
64
67
  return compileLinearPattern(anchorSource(pattern));
65
68
  }
66
- /** Validation for a v2 rule; returns a problem string or null when ok. */ export function validateRedirectRule(rule) {
69
+ /**
70
+ * Validation for a v2 rule; returns a problem string or null when ok.
71
+ *
72
+ * `workspace` names the site's own hosts, so a destination on one of them
73
+ * is never read as somebody else's.
74
+ */ export function validateRedirectRule(rule, workspace) {
67
75
  var _rule_kind;
68
76
  const kind = (_rule_kind = rule.kind) != null ? _rule_kind : 'exact';
69
77
  if (!REDIRECT_KINDS.includes(kind)) {
@@ -86,11 +94,32 @@ export const REDIRECT_DEFAULT_PRIORITY = 100;
86
94
  } else if (!normalizeRedirectSource(rule.source)) {
87
95
  return 'Enter a site path like /old-page';
88
96
  }
89
- if (!normalizeRedirectDestination(rule.destination)) {
97
+ const destination = normalizeRedirectDestination(rule.destination);
98
+ if (!destination) {
90
99
  return 'Destinations are internal paths or https:// URLs';
91
100
  }
101
+ if (isLookalikeRedirectDestination(destination, workspace)) {
102
+ return "That destination's address looks like another company's website, so it can't be used";
103
+ }
92
104
  return null;
93
105
  }
106
+ /**
107
+ * Does this destination wear a brand it is not (AGL-3447)? The phishing
108
+ * screen's STRONG tier, the one that holds for every workspace whatever its
109
+ * age: `https://sharepoint-files.example.top/`, `https://paypa1.com/`. Read
110
+ * the same at save, where the console refuses it, and on the serve path,
111
+ * where `reviewSiteRedirect` holds it. The softer rules need the workspace's
112
+ * age, which only the serve path reads.
113
+ */ export function isLookalikeRedirectDestination(destination, workspace) {
114
+ if (!isExternalRedirectDestination(destination)) return false;
115
+ const { signals } = screenSiteRedirect(_extends({
116
+ source: '',
117
+ destination
118
+ }, workspace));
119
+ return signalsThatHold(signals, {
120
+ ageDays: null
121
+ }).length > 0;
122
+ }
94
123
  /**
95
124
  * Does this destination leave the platform? (AGL-1881.)
96
125
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/model/redirects.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 * URL redirects (AGL-154): paid per-host rules at\n * `hosts/{hostId}/redirects/{id}`. Exact source match in v1 (the `kind`\n * field leaves room for patterns later); destinations are internal paths\n * or absolute https URLs. Console validation and tenant enforcement share\n * these helpers so they can't disagree.\n */\n\nimport {\n compileLinearPattern,\n explainLinearPattern,\n type LinearPattern,\n} from '@aglyn/shared-util-tools/linear-regex'\n\nexport interface HostRedirect {\n /**\n * Match input: a normalized path for exact/prefix rules, a regular\n * expression for regex rules (v2, AGL-375).\n */\n source: string\n /**\n * Internal path (`/pricing`) or absolute https URL. Regex rules may\n * reference capture groups as `$1`, `$2`, ….\n */\n destination: string\n /** 302 by default while testing; owners promote to 301 when sure. */\n statusCode: 301 | 302 | 307 | 308\n enabled?: boolean\n /** Match mode (AGL-375); missing = exact (v1 rules). */\n kind?: 'exact' | 'prefix' | 'regex'\n /** Evaluation order — lower fires first; missing = 100 (v1 rules). */\n priority?: number\n /**\n * The uid of the publishing-role member who last saved this rule with an\n * EXTERNAL destination (AGL-1881). Absent on every internal rule, and on\n * every rule written before that issue.\n *\n * This is provenance, not preference. `cloud/firebase-firestore.rules` lets\n * only `canPublishHostContent` — admin or editor, never `author` — write\n * anything in `hosts/{hostId}/redirects`, so the presence of this field is\n * the serve path's evidence that a publisher chose to send traffic off the\n * platform. `matchRedirect` will not serve an absolute destination without\n * it, which is what makes a rule written BEFORE the rules were fixed refuse\n * to fire rather than having to be trusted.\n *\n * Deliberately a uid rather than a boolean: `true` says nothing a log can be\n * read against, and the recovery for a rule that stops firing is for a\n * publisher to open it and save, which stamps their own uid.\n */\n externalDestinationApprovedBy?: string\n}\n\nexport const REDIRECT_STATUS_CODES = [301, 302, 307, 308] as const\nexport const REDIRECT_KINDS = ['exact', 'prefix', 'regex'] as const\nexport const REDIRECT_DEFAULT_PRIORITY = 100\n\n/** Max regex source length — a rule source has no business being longer. */\nconst REGEX_SOURCE_MAX = 200\n\n/**\n * Anchors a rule source to the whole path unless the author anchored it\n * themselves. Kept in one place so validation and matching cannot disagree\n * about what is actually compiled.\n */\nfunction anchorSource(pattern: string): string {\n return (\n (pattern.startsWith('^') ? '' : '^') +\n pattern +\n (pattern.endsWith('$') ? '' : '$')\n )\n}\n\n/**\n * Compiles a regex rule's source for LINEAR-TIME matching (SEC-M8).\n *\n * Redirect sources are attacker-controlled: any host member with the\n * `author` role can write `source` and `kind` straight to Firestore with the\n * client SDK, so the console's validation is a convenience, not a control —\n * this function, on the request path, is the only thing standing between a\n * customer-authored string and the tenant event loop.\n *\n * It therefore does not use `RegExp`. `compileLinearPattern` runs the\n * pattern on a Thompson NFA simulation whose cost is bounded by\n * `path length × program length` for every possible input, replacing the\n * AGL-505 star-height heuristic that `(a|a|aa)+` walked straight through\n * (9 characters, 59 s measured on a 27-character path).\n *\n * Returns null for malformed patterns and for syntax outside the supported\n * subset — callers reject at save and skip at match, so a bad pattern can\n * never take a site down.\n */\nexport function compileRedirectRegex(source: string): LinearPattern | null {\n const pattern = String(source ?? '').trim()\n if (!pattern || pattern.length > REGEX_SOURCE_MAX) return null\n return compileLinearPattern(anchorSource(pattern))\n}\n\n/** Validation for a v2 rule; returns a problem string or null when ok. */\nexport function validateRedirectRule(rule: {\n kind?: string\n source: string\n destination: string\n}): string | null {\n const kind = rule.kind ?? 'exact'\n if (!(REDIRECT_KINDS as readonly string[]).includes(kind)) {\n return 'Unknown match mode'\n }\n if (kind === 'regex') {\n if (compileRedirectRegex(rule.source) === null) {\n // Say *why* — the supported subset excludes lookaround and\n // backreferences, and \"not a valid regular expression\" would send an\n // author hunting for a typo that isn't there.\n const pattern = String(rule.source ?? '').trim()\n if (!pattern) return 'Enter a pattern'\n if (pattern.length > REGEX_SOURCE_MAX) {\n return `Patterns are limited to ${REGEX_SOURCE_MAX} characters`\n }\n const reason = explainLinearPattern(anchorSource(pattern))\n return reason\n ? `That pattern can't be used: ${reason}`\n : 'The pattern is not a valid regular expression'\n }\n } else if (!normalizeRedirectSource(rule.source)) {\n return 'Enter a site path like /old-page'\n }\n if (!normalizeRedirectDestination(rule.destination)) {\n return 'Destinations are internal paths or https:// URLs'\n }\n return null\n}\n\n/**\n * Does this destination leave the platform? (AGL-1881.)\n *\n * Written as \"not internal\" rather than as \"starts with https://\", so that\n * anything the normalizer has not already vouched for — an empty string, a\n * protocol-relative `//host`, a scheme we never expected — answers TRUE and\n * meets the approval gate rather than slipping past it. `strictNullChecks` is\n * off repo-wide: an absent destination arrives here as `undefined`, folds to\n * `''`, and must not read as \"internal, therefore safe\".\n */\nexport function isExternalRedirectDestination(destination: string): boolean {\n const value = String(destination ?? '').trim()\n return !(value.startsWith('/') && !value.startsWith('//'))\n}\n\n/**\n * Has a publisher vouched for this rule's external destination? (AGL-1881.)\n *\n * The `typeof` test is the point. With `strictNullChecks` off, a missing field\n * is `undefined` and a stored `null` is `null`; both must answer FALSE, and so\n * must a stamp that is present but blank. Only a non-empty string counts.\n */\nfunction externalDestinationApproved(rule: {\n externalDestinationApprovedBy?: unknown\n}): boolean {\n const approver = rule.externalDestinationApprovedBy\n return typeof approver === 'string' && approver.trim().length > 0\n}\n\nexport interface RedirectMatch {\n /** Destination with any capture groups substituted. */\n destination: string\n statusCode: 301 | 302 | 307 | 308\n /** The rule that fired (for hit recording). */\n index: number\n}\n\n/**\n * Evaluates rules against a request path (AGL-375): priority order\n * (lower first, stable by list order), exact → equality, prefix →\n * segment-boundary prefix, regex → anchored pattern with `$n` capture\n * substitution in the destination. Disabled, self-targeting, and\n * invalid rules never fire.\n *\n * The DESTINATION is validated here too (AGL-1881), after substitution: it\n * must still normalize, and an external one must carry a publisher's stamp.\n * See the two notes inside the loop.\n */\nexport function matchRedirect(\n rules: Array<HostRedirect & { deletedAt?: unknown }>,\n path: string,\n): RedirectMatch | null {\n const ordered = rules\n .map((rule, index) => ({ rule, index }))\n .filter(({ rule }) => rule.enabled !== false && !rule.deletedAt)\n .sort(\n (a, b) =>\n (a.rule.priority ?? REDIRECT_DEFAULT_PRIORITY) -\n (b.rule.priority ?? REDIRECT_DEFAULT_PRIORITY) ||\n a.index - b.index,\n )\n for (const { rule, index } of ordered) {\n const kind = rule.kind ?? 'exact'\n let destination: string | null = null\n if (kind === 'exact') {\n if (rule.source === path) destination = rule.destination\n } else if (kind === 'prefix') {\n if (\n path === rule.source ||\n (path.startsWith(rule.source) &&\n (rule.source === '/' || path[rule.source.length] === '/'))\n ) {\n destination = rule.destination\n }\n } else if (kind === 'regex') {\n const pattern = compileRedirectRegex(rule.source)\n const matched = pattern?.exec(path)\n if (matched) {\n destination = rule.destination.replace(\n /\\$(\\d)/g,\n (token, groupIndex: string) => matched[Number(groupIndex)] ?? '',\n )\n }\n }\n if (!destination) continue\n /**\n * Validate the destination HERE, on the serve path, after any `$n`\n * substitution (AGL-1881).\n *\n * This function used to hand `rule.destination` straight back. Console\n * validation was the only thing standing between a stored string and a\n * `Location:` header — the same \"the console is a convenience, not a\n * control\" note `compileRedirectRegex` already carries about `source`,\n * one field over and never applied.\n *\n * Two distinct holes close on this line:\n *\n * 1. A destination that never met `normalizeRedirectDestination` at all,\n * because it was written straight to Firestore rather than through the\n * console — `http://` (a downgrade), `//host` (protocol-relative), a\n * `javascript:`/`data:` scheme, a value carrying whitespace or CRLF.\n * 2. A destination that DID meet it and stops meeting it once captures\n * are substituted. `/$1` is an internal path at save time and becomes\n * `//attacker.example` when the pattern's group swallows a hostname —\n * an open redirect out of a rule that reads as entirely internal.\n * Post-substitution is the only place either can be seen.\n *\n * A rule that fails is SKIPPED, not fatal: evaluation continues to the\n * next rule exactly as it does for an uncompilable pattern, so a bad\n * destination can never take a site down.\n */\n const validated = normalizeRedirectDestination(destination)\n if (!validated) continue\n destination = validated\n /**\n * An external destination fires only with a publisher's stamp on it.\n *\n * This is the defence-in-depth half of AGL-1881, and it is what makes the\n * rules fix retroactive. Rules stop an `author` from WRITING one from now\n * on; a rule already written — before this file, from a role that has\n * since been refused, or by any path we have not thought of — still sits\n * in Firestore, and without this it would still be served on every request\n * to the site. Absent stamp, absent field, blank stamp: all refuse.\n *\n * Internal destinations are untouched, which is deliberate. The cost of\n * failing closed here is paid ONLY by rules that send traffic off the\n * platform, and recovering one is a publisher opening it and pressing\n * Save.\n */\n if (\n isExternalRedirectDestination(destination) &&\n !externalDestinationApproved(rule)\n ) {\n continue\n }\n if (isSelfRedirect({ source: path, destination })) continue\n const statusCode = (REDIRECT_STATUS_CODES as readonly number[]).includes(\n rule.statusCode,\n )\n ? rule.statusCode\n : 302\n return { destination, statusCode, index }\n }\n return null\n}\n\n/**\n * Source normalization: leading slash, lowercase, query/hash stripped,\n * trailing slash stripped (root stays `/`). Returns null for unusable\n * input so callers reject rather than store junk.\n */\nexport function normalizeRedirectSource(input: string): string | null {\n let path = String(input ?? '').trim()\n if (!path) return null\n // Absolute URLs and protocol-ish sources are not paths.\n if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith('//')) return null\n if (!path.startsWith('/')) path = `/${path}`\n path = path.split(/[?#]/)[0].toLowerCase()\n if (path.length > 1) path = path.replace(/\\/+$/, '')\n if (path.length > 500 || /\\s/.test(path)) return null\n return path || '/'\n}\n\n/**\n * Destination validation: an internal path (normalized like sources but\n * case-preserved) or an absolute https URL. Returns the cleaned value or\n * null when invalid.\n */\nexport function normalizeRedirectDestination(input: string): string | null {\n const value = String(input ?? '').trim()\n if (!value || value.length > 1000 || /\\s/.test(value)) return null\n if (/^https:\\/\\/[^/]+/i.test(value)) return value\n if (value.startsWith('/') && !value.startsWith('//')) {\n return value.length > 1 ? value.replace(/\\/+$/, '') : '/'\n }\n return null\n}\n\n/**\n * True when the rule would redirect a path onto itself — the loop case\n * both the console and the tenant enforcement must refuse (they validate\n * independently and may disagree; this is the shared floor).\n */\nexport function isSelfRedirect(redirect: {\n source: string\n destination: string\n}): boolean {\n const destination = redirect.destination.toLowerCase()\n if (destination.startsWith('/')) {\n const normalized =\n destination.length > 1 ? destination.replace(/\\/+$/, '') : '/'\n return normalized === redirect.source\n }\n return false\n}\n"],"names":["compileLinearPattern","explainLinearPattern","REDIRECT_STATUS_CODES","REDIRECT_KINDS","REDIRECT_DEFAULT_PRIORITY","REGEX_SOURCE_MAX","anchorSource","pattern","startsWith","endsWith","compileRedirectRegex","source","String","trim","length","validateRedirectRule","rule","kind","includes","reason","normalizeRedirectSource","normalizeRedirectDestination","destination","isExternalRedirectDestination","value","externalDestinationApproved","approver","externalDestinationApprovedBy","matchRedirect","rules","path","ordered","map","index","filter","enabled","deletedAt","sort","a","b","priority","matched","exec","replace","token","groupIndex","Number","validated","isSelfRedirect","statusCode","input","test","split","toLowerCase","redirect","normalized"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/model/redirects.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 * URL redirects (AGL-154): paid per-host rules at\n * `hosts/{hostId}/redirects/{id}`. Exact source match in v1 (the `kind`\n * field leaves room for patterns later); destinations are internal paths\n * or absolute https URLs. Console validation and tenant enforcement share\n * these helpers so they can't disagree.\n */\n\nimport { screenSiteRedirect } from '@aglyn/shared-util-email/hosted-page-screen'\nimport { signalsThatHold } from '@aglyn/shared-util-email/outbound-phishing-screen'\nimport {\n compileLinearPattern,\n explainLinearPattern,\n type LinearPattern,\n} from '@aglyn/shared-util-tools/linear-regex'\n\nexport interface HostRedirect {\n /**\n * Match input: a normalized path for exact/prefix rules, a regular\n * expression for regex rules (v2, AGL-375).\n */\n source: string\n /**\n * Internal path (`/pricing`) or absolute https URL. Regex rules may\n * reference capture groups as `$1`, `$2`, ….\n */\n destination: string\n /** 302 by default while testing; owners promote to 301 when sure. */\n statusCode: 301 | 302 | 307 | 308\n enabled?: boolean\n /** Match mode (AGL-375); missing = exact (v1 rules). */\n kind?: 'exact' | 'prefix' | 'regex'\n /** Evaluation order — lower fires first; missing = 100 (v1 rules). */\n priority?: number\n /**\n * The uid of the publishing-role member who last saved this rule with an\n * EXTERNAL destination (AGL-1881). Absent on every internal rule, and on\n * every rule written before that issue.\n *\n * This is provenance, not preference. `cloud/firebase-firestore.rules` lets\n * only `canPublishHostContent` — admin or editor, never `author` — write\n * anything in `hosts/{hostId}/redirects`, so the presence of this field is\n * the serve path's evidence that a publisher chose to send traffic off the\n * platform. `matchRedirect` will not serve an absolute destination without\n * it, which is what makes a rule written BEFORE the rules were fixed refuse\n * to fire rather than having to be trusted.\n *\n * Deliberately a uid rather than a boolean: `true` says nothing a log can be\n * read against, and the recovery for a rule that stops firing is for a\n * publisher to open it and save, which stamps their own uid.\n */\n externalDestinationApprovedBy?: string\n}\n\nexport const REDIRECT_STATUS_CODES = [301, 302, 307, 308] as const\nexport const REDIRECT_KINDS = ['exact', 'prefix', 'regex'] as const\nexport const REDIRECT_DEFAULT_PRIORITY = 100\n\n/** Max regex source length — a rule source has no business being longer. */\nconst REGEX_SOURCE_MAX = 200\n\n/**\n * Anchors a rule source to the whole path unless the author anchored it\n * themselves. Kept in one place so validation and matching cannot disagree\n * about what is actually compiled.\n */\nfunction anchorSource(pattern: string): string {\n return (\n (pattern.startsWith('^') ? '' : '^') +\n pattern +\n (pattern.endsWith('$') ? '' : '$')\n )\n}\n\n/**\n * Compiles a regex rule's source for LINEAR-TIME matching (SEC-M8).\n *\n * Redirect sources are attacker-controlled: any host member with the\n * `author` role can write `source` and `kind` straight to Firestore with the\n * client SDK, so the console's validation is a convenience, not a control —\n * this function, on the request path, is the only thing standing between a\n * customer-authored string and the tenant event loop.\n *\n * It therefore does not use `RegExp`. `compileLinearPattern` runs the\n * pattern on a Thompson NFA simulation whose cost is bounded by\n * `path length × program length` for every possible input, replacing the\n * AGL-505 star-height heuristic that `(a|a|aa)+` walked straight through\n * (9 characters, 59 s measured on a 27-character path).\n *\n * Returns null for malformed patterns and for syntax outside the supported\n * subset — callers reject at save and skip at match, so a bad pattern can\n * never take a site down.\n */\nexport function compileRedirectRegex(source: string): LinearPattern | null {\n const pattern = String(source ?? '').trim()\n if (!pattern || pattern.length > REGEX_SOURCE_MAX) return null\n return compileLinearPattern(anchorSource(pattern))\n}\n\n/**\n * Validation for a v2 rule; returns a problem string or null when ok.\n *\n * `workspace` names the site's own hosts, so a destination on one of them\n * is never read as somebody else's.\n */\nexport function validateRedirectRule(\n rule: {\n kind?: string\n source: string\n destination: string\n },\n workspace?: {\n ownNames?: readonly (string | null | undefined)[]\n ownDomains?: readonly (string | null | undefined)[]\n },\n): string | null {\n const kind = rule.kind ?? 'exact'\n if (!(REDIRECT_KINDS as readonly string[]).includes(kind)) {\n return 'Unknown match mode'\n }\n if (kind === 'regex') {\n if (compileRedirectRegex(rule.source) === null) {\n // Say *why* — the supported subset excludes lookaround and\n // backreferences, and \"not a valid regular expression\" would send an\n // author hunting for a typo that isn't there.\n const pattern = String(rule.source ?? '').trim()\n if (!pattern) return 'Enter a pattern'\n if (pattern.length > REGEX_SOURCE_MAX) {\n return `Patterns are limited to ${REGEX_SOURCE_MAX} characters`\n }\n const reason = explainLinearPattern(anchorSource(pattern))\n return reason\n ? `That pattern can't be used: ${reason}`\n : 'The pattern is not a valid regular expression'\n }\n } else if (!normalizeRedirectSource(rule.source)) {\n return 'Enter a site path like /old-page'\n }\n const destination = normalizeRedirectDestination(rule.destination)\n if (!destination) {\n return 'Destinations are internal paths or https:// URLs'\n }\n if (isLookalikeRedirectDestination(destination, workspace)) {\n return \"That destination's address looks like another company's website, so it can't be used\"\n }\n return null\n}\n\n/**\n * Does this destination wear a brand it is not (AGL-3447)? The phishing\n * screen's STRONG tier, the one that holds for every workspace whatever its\n * age: `https://sharepoint-files.example.top/`, `https://paypa1.com/`. Read\n * the same at save, where the console refuses it, and on the serve path,\n * where `reviewSiteRedirect` holds it. The softer rules need the workspace's\n * age, which only the serve path reads.\n */\nexport function isLookalikeRedirectDestination(\n destination: string,\n workspace?: {\n ownNames?: readonly (string | null | undefined)[]\n ownDomains?: readonly (string | null | undefined)[]\n },\n): boolean {\n if (!isExternalRedirectDestination(destination)) return false\n const { signals } = screenSiteRedirect({ source: '', destination, ...workspace })\n return signalsThatHold(signals, { ageDays: null }).length > 0\n}\n\n/**\n * Does this destination leave the platform? (AGL-1881.)\n *\n * Written as \"not internal\" rather than as \"starts with https://\", so that\n * anything the normalizer has not already vouched for — an empty string, a\n * protocol-relative `//host`, a scheme we never expected — answers TRUE and\n * meets the approval gate rather than slipping past it. `strictNullChecks` is\n * off repo-wide: an absent destination arrives here as `undefined`, folds to\n * `''`, and must not read as \"internal, therefore safe\".\n */\nexport function isExternalRedirectDestination(destination: string): boolean {\n const value = String(destination ?? '').trim()\n return !(value.startsWith('/') && !value.startsWith('//'))\n}\n\n/**\n * Has a publisher vouched for this rule's external destination? (AGL-1881.)\n *\n * The `typeof` test is the point. With `strictNullChecks` off, a missing field\n * is `undefined` and a stored `null` is `null`; both must answer FALSE, and so\n * must a stamp that is present but blank. Only a non-empty string counts.\n */\nfunction externalDestinationApproved(rule: {\n externalDestinationApprovedBy?: unknown\n}): boolean {\n const approver = rule.externalDestinationApprovedBy\n return typeof approver === 'string' && approver.trim().length > 0\n}\n\nexport interface RedirectMatch {\n /** Destination with any capture groups substituted. */\n destination: string\n statusCode: 301 | 302 | 307 | 308\n /** The rule that fired (for hit recording). */\n index: number\n}\n\n/**\n * Evaluates rules against a request path (AGL-375): priority order\n * (lower first, stable by list order), exact → equality, prefix →\n * segment-boundary prefix, regex → anchored pattern with `$n` capture\n * substitution in the destination. Disabled, self-targeting, and\n * invalid rules never fire.\n *\n * The DESTINATION is validated here too (AGL-1881), after substitution: it\n * must still normalize, and an external one must carry a publisher's stamp.\n * See the two notes inside the loop.\n */\nexport function matchRedirect(\n rules: Array<HostRedirect & { deletedAt?: unknown }>,\n path: string,\n): RedirectMatch | null {\n const ordered = rules\n .map((rule, index) => ({ rule, index }))\n .filter(({ rule }) => rule.enabled !== false && !rule.deletedAt)\n .sort(\n (a, b) =>\n (a.rule.priority ?? REDIRECT_DEFAULT_PRIORITY) -\n (b.rule.priority ?? REDIRECT_DEFAULT_PRIORITY) ||\n a.index - b.index,\n )\n for (const { rule, index } of ordered) {\n const kind = rule.kind ?? 'exact'\n let destination: string | null = null\n if (kind === 'exact') {\n if (rule.source === path) destination = rule.destination\n } else if (kind === 'prefix') {\n if (\n path === rule.source ||\n (path.startsWith(rule.source) &&\n (rule.source === '/' || path[rule.source.length] === '/'))\n ) {\n destination = rule.destination\n }\n } else if (kind === 'regex') {\n const pattern = compileRedirectRegex(rule.source)\n const matched = pattern?.exec(path)\n if (matched) {\n destination = rule.destination.replace(\n /\\$(\\d)/g,\n (token, groupIndex: string) => matched[Number(groupIndex)] ?? '',\n )\n }\n }\n if (!destination) continue\n /**\n * Validate the destination HERE, on the serve path, after any `$n`\n * substitution (AGL-1881).\n *\n * This function used to hand `rule.destination` straight back. Console\n * validation was the only thing standing between a stored string and a\n * `Location:` header — the same \"the console is a convenience, not a\n * control\" note `compileRedirectRegex` already carries about `source`,\n * one field over and never applied.\n *\n * Two distinct holes close on this line:\n *\n * 1. A destination that never met `normalizeRedirectDestination` at all,\n * because it was written straight to Firestore rather than through the\n * console — `http://` (a downgrade), `//host` (protocol-relative), a\n * `javascript:`/`data:` scheme, a value carrying whitespace or CRLF.\n * 2. A destination that DID meet it and stops meeting it once captures\n * are substituted. `/$1` is an internal path at save time and becomes\n * `//attacker.example` when the pattern's group swallows a hostname —\n * an open redirect out of a rule that reads as entirely internal.\n * Post-substitution is the only place either can be seen.\n *\n * A rule that fails is SKIPPED, not fatal: evaluation continues to the\n * next rule exactly as it does for an uncompilable pattern, so a bad\n * destination can never take a site down.\n */\n const validated = normalizeRedirectDestination(destination)\n if (!validated) continue\n destination = validated\n /**\n * An external destination fires only with a publisher's stamp on it.\n *\n * This is the defence-in-depth half of AGL-1881, and it is what makes the\n * rules fix retroactive. Rules stop an `author` from WRITING one from now\n * on; a rule already written — before this file, from a role that has\n * since been refused, or by any path we have not thought of — still sits\n * in Firestore, and without this it would still be served on every request\n * to the site. Absent stamp, absent field, blank stamp: all refuse.\n *\n * Internal destinations are untouched, which is deliberate. The cost of\n * failing closed here is paid ONLY by rules that send traffic off the\n * platform, and recovering one is a publisher opening it and pressing\n * Save.\n */\n if (\n isExternalRedirectDestination(destination) &&\n !externalDestinationApproved(rule)\n ) {\n continue\n }\n if (isSelfRedirect({ source: path, destination })) continue\n const statusCode = (REDIRECT_STATUS_CODES as readonly number[]).includes(\n rule.statusCode,\n )\n ? rule.statusCode\n : 302\n return { destination, statusCode, index }\n }\n return null\n}\n\n/**\n * Source normalization: leading slash, lowercase, query/hash stripped,\n * trailing slash stripped (root stays `/`). Returns null for unusable\n * input so callers reject rather than store junk.\n */\nexport function normalizeRedirectSource(input: string): string | null {\n let path = String(input ?? '').trim()\n if (!path) return null\n // Absolute URLs and protocol-ish sources are not paths.\n if (/^[a-z][a-z0-9+.-]*:/i.test(path) || path.startsWith('//')) return null\n if (!path.startsWith('/')) path = `/${path}`\n path = path.split(/[?#]/)[0].toLowerCase()\n if (path.length > 1) path = path.replace(/\\/+$/, '')\n if (path.length > 500 || /\\s/.test(path)) return null\n return path || '/'\n}\n\n/**\n * Destination validation: an internal path (normalized like sources but\n * case-preserved) or an absolute https URL. Returns the cleaned value or\n * null when invalid.\n */\nexport function normalizeRedirectDestination(input: string): string | null {\n const value = String(input ?? '').trim()\n if (!value || value.length > 1000 || /\\s/.test(value)) return null\n if (/^https:\\/\\/[^/]+/i.test(value)) return value\n if (value.startsWith('/') && !value.startsWith('//')) {\n return value.length > 1 ? value.replace(/\\/+$/, '') : '/'\n }\n return null\n}\n\n/**\n * True when the rule would redirect a path onto itself — the loop case\n * both the console and the tenant enforcement must refuse (they validate\n * independently and may disagree; this is the shared floor).\n */\nexport function isSelfRedirect(redirect: {\n source: string\n destination: string\n}): boolean {\n const destination = redirect.destination.toLowerCase()\n if (destination.startsWith('/')) {\n const normalized =\n destination.length > 1 ? destination.replace(/\\/+$/, '') : '/'\n return normalized === redirect.source\n }\n return false\n}\n"],"names":["screenSiteRedirect","signalsThatHold","compileLinearPattern","explainLinearPattern","REDIRECT_STATUS_CODES","REDIRECT_KINDS","REDIRECT_DEFAULT_PRIORITY","REGEX_SOURCE_MAX","anchorSource","pattern","startsWith","endsWith","compileRedirectRegex","source","String","trim","length","validateRedirectRule","rule","workspace","kind","includes","reason","normalizeRedirectSource","destination","normalizeRedirectDestination","isLookalikeRedirectDestination","isExternalRedirectDestination","signals","ageDays","value","externalDestinationApproved","approver","externalDestinationApprovedBy","matchRedirect","rules","path","ordered","map","index","filter","enabled","deletedAt","sort","a","b","priority","matched","exec","replace","token","groupIndex","Number","validated","isSelfRedirect","statusCode","input","test","split","toLowerCase","redirect","normalized"],"mappings":"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"}
@@ -32,8 +32,12 @@ export interface ResolvedRedirect {
32
32
  * rules stop firing — the org read only happens when rules exist.
33
33
  * Loop guard: self-redirects never execute even if stored (console
34
34
  * validation and this floor can disagree; both refuse).
35
+ * Phishing screen: a rule that leaves the site fires only once
36
+ * `reviewSiteRedirect` answers `serve` — a destination that wears a brand is
37
+ * held for every workspace, a lure path sent off the site for a workspace in
38
+ * its first fortnight (AGL-3447).
35
39
  */
36
40
  export declare function resolveRedirect(host: {
37
41
  $id: string;
38
- }, requestPath: string): Promise<ResolvedRedirect | null>;
42
+ } & Record<string, unknown>, requestPath: string): Promise<ResolvedRedirect | null>;
39
43
  export default resolveRedirect;
@@ -15,8 +15,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { checkEntitlement } from "@aglyn/aglyn/server";
18
- import { matchRedirect, normalizeRedirectSource } from "../model/redirects.js";
19
- import { analyticsDayExpiresAt, firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
18
+ import { isExternalRedirectDestination, matchRedirect, normalizeRedirectSource } from "../model/redirects.js";
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
21
  import { FieldValue } from "firebase-admin/firestore";
22
22
  /**
@@ -54,6 +54,10 @@ async function readEnabledRules(hostId) {
54
54
  * rules stop firing — the org read only happens when rules exist.
55
55
  * Loop guard: self-redirects never execute even if stored (console
56
56
  * validation and this floor can disagree; both refuse).
57
+ * Phishing screen: a rule that leaves the site fires only once
58
+ * `reviewSiteRedirect` answers `serve` — a destination that wears a brand is
59
+ * held for every workspace, a lure path sent off the site for a workspace in
60
+ * its first fortnight (AGL-3447).
57
61
  */ export async function resolveRedirect(host, requestPath) {
58
62
  try {
59
63
  var _ruleList_matched_index;
@@ -89,12 +93,28 @@ async function readEnabledRules(hostId) {
89
93
  // downgraded org's leftover rules redirecting for the life of the entry,
90
94
  // which on this code path means taking pages off the internet on the
91
95
  // strength of a plan the org no longer has.
92
- {
93
- var _this;
94
- const org = (_this = await getOrgForHost(host.$id)) == null ? void 0 : _this.org;
95
- if (!checkEntitlement(org, 'redirects')) {
96
- return null;
97
- }
96
+ const owner = await getOrgForHost(host.$id);
97
+ if (!checkEntitlement(owner == null ? void 0 : owner.org, 'redirects')) {
98
+ return null;
99
+ }
100
+ // The phishing screen (AGL-3447), for a rule that sends visitors OFF the
101
+ // site: a page whose one call to action fires on arrival. A held rule does
102
+ // not fire and is not counted; the request resolves as if it had no rule.
103
+ // Outside the cache, like the paid gate: a staff decision takes effect on
104
+ // the next request, not at the end of an hour.
105
+ if (isExternalRedirectDestination(matched.destination)) {
106
+ var _ref, _ref1;
107
+ const review = await reviewSiteRedirect({
108
+ hostId: host.$id,
109
+ ruleId: matchId,
110
+ source: ruleList[matched.index].source,
111
+ destination: matched.destination,
112
+ // The resolved host doc, when the hook handed one over; read otherwise.
113
+ host: typeof host['subdomain'] === 'string' ? host : undefined,
114
+ org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
115
+ orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null
116
+ });
117
+ if (review.outcome !== 'serve') return null;
98
118
  }
99
119
  // Sampled hit recording (fire-and-forget): day-doc counter + recency
100
120
  // 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 { type HostRedirect, matchRedirect, normalizeRedirectSource } from '../model/redirects'\nimport {\n analyticsDayExpiresAt,\n firebaseAdmin,\n getOrgForHost,\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/** Firestore map keys: strip characters that complicate field paths. */\nconst idKey = (value: string) => value.replace(/[.$#[\\]/]/g, '_')\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 */\nexport async function resolveRedirect(\n host: { $id: string },\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 {\n const org = (await getOrgForHost(host.$id))?.org\n if (!checkEntitlement(org as any, 'redirects')) {\n return null\n }\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: { [idKey(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","matchRedirect","normalizeRedirectSource","analyticsDayExpiresAt","firebaseAdmin","getOrgForHost","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","idKey","value","replace","resolveRedirect","host","requestPath","ruleList","source","hostRef","key","revalidate","tags","read","length","matched","matchId","index","org","day","Date","toISOString","slice","set","redirects","increment","expiresAt","merge","catch","undefined","lastHitAt","serverTimestamp","destination","statusCode","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,gBAAgB,QAAQ,sBAAqB;AACtD,SAA4BC,aAAa,EAAEC,uBAAuB,QAAQ,wBAAoB;AAC9F,SACEC,qBAAqB,EACrBC,aAAa,EACbC,aAAa,QACR,2BAA0B;AACjC,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAC9C,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;;;;;;;;;;CAaC,GACD,MAAMC,6BAA6BJ;AAKnC,eAAeK,iBAAiBC,MAAc;IAC5C,MAAMC,QAAQ,MAAMT,cACjBU,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACL,QACJI,UAAU,CAAC,aACXE,KAAK,CAAC,WAAW,MAAM,MACvBC,KAAK,CAAC,KACNC,GAAG;IACN,OAAOP,MAAMQ,IAAI,CAACC,GAAG,CAAC,CAACL,MAAS,aAC1BA,IAAIM,IAAI;YACZC,KAAKP,IAAIQ,EAAE;;AAEf;AAOA,sEAAsE,GACtE,MAAMC,QAAQ,CAACC,QAAkBA,MAAMC,OAAO,CAAC,cAAc;AAE7D;;;;;;;;;;;;;;CAcC,GACD,OAAO,eAAeC,gBACpBC,IAAqB,EACrBC,WAAmB;IAEnB,IAAI;YAuBcC;QAtBhB,MAAMC,SAAS/B,wBACb6B,gBAAgB,OAAOA,gBAAgB,KAAK,MAAM,CAAC,CAAC,EAAEA,aAAa;QAErE,IAAI,CAACE,QAAQ,OAAO;QACpB,MAAMlB,YAAYX,cAAcU,GAAG,GAAGC,SAAS;QAC/C,MAAMmB,UAAUnB,UAAUC,UAAU,CAAC,SAASC,GAAG,CAACa,KAAKN,GAAG;QAC1D,2EAA2E;QAC3E,uEAAuE;QACvE,2BAA2B;QAC3B,MAAMQ,WAAW,MAAMxB,gBAAgB;YACrC2B,KAAK;gBAAC;gBAAkBL,KAAKN,GAAG;aAAC;YACjCY,YAAY1B;YACZ2B,MAAM;gBAAC9B,cAAcuB,KAAKN,GAAG;aAAE;YAC/Bc,MAAM,IAAM3B,iBAAiBmB,KAAKN,GAAG;QACvC;QACA,IAAI,CAACQ,SAASO,MAAM,EAAE,OAAO;QAE7B,sEAAsE;QACtE,yEAAyE;QACzE,yDAAyD;QACzD,MAAMC,UAAUvC,cAAc+B,UAAUC;QACxC,IAAI,CAACO,SAAS,OAAO;QACrB,MAAMC,WAAUT,0BAAAA,QAAQ,CAACQ,QAAQE,KAAK,CAAC,qBAAvBV,wBAAyBR,GAAG;QAC5C,IAAI,CAACiB,SAAS,OAAO;QAErB,kEAAkE;QAClE,EAAE;QACF,0EAA0E;QAC1E,yEAAyE;QACzE,qEAAqE;QACrE,4CAA4C;QAC5C;gBACe;YAAb,MAAME,OAAO,QAAA,MAAMtC,cAAcyB,KAAKN,GAAG,sBAA7B,AAAC,MAAgCmB,GAAG;YAChD,IAAI,CAAC3C,iBAAiB2C,KAAY,cAAc;gBAC9C,OAAO;YACT;QACF;QAEA,qEAAqE;QACrE,qEAAqE;QACrE,2EAA2E;QAC3E,uEAAuE;QACvE,8CAA8C;QAC9C,MAAMC,MAAM,IAAIC,OAAOC,WAAW,GAAGC,KAAK,CAAC,GAAG;QAC9C,KAAKb,QACFlB,UAAU,CAAC,aACXC,GAAG,CAAC2B,KACJI,GAAG,CACF;YACEC,WAAW;gBAAE,CAACvB,MAAMe,SAAS,EAAEhC,WAAWyC,SAAS,CAAC;YAAG;YACvD,6DAA6D;YAC7D,gDAAgD;YAChDC,WAAWhD,sBAAsByC;QACnC,GACA;YAAEQ,OAAO;QAAK,GAEfC,KAAK,CAAC,IAAMC;QACf,KAAKpB,QACFlB,UAAU,CAAC,aACXC,GAAG,CAACwB,SACJO,GAAG,CAAC;YAAEO,WAAW9C,WAAW+C,eAAe;QAAG,GAAG;YAAEJ,OAAO;QAAK,GAC/DC,KAAK,CAAC,IAAMC;QAEf,OAAO;YAAEG,aAAajB,QAAQiB,WAAW;YAAEC,YAAYlB,QAAQkB,UAAU;QAAC;IAC5E,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,0BAA0B7B,KAAKN,GAAG,EAAEO,aAAa4B;QAC/D,OAAO;IACT;AACF;AAEA,eAAe9B,gBAAe"}
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} 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/** Firestore map keys: strip characters that complicate field paths. */\nconst idKey = (value: string) => value.replace(/[.$#[\\]/]/g, '_')\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: { [idKey(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","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","idKey","value","replace","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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"}