@aglyn/plugins-redirects 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +7 -0
- package/package.json +49 -0
- package/src/index.d.ts +19 -0
- package/src/index.js +20 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/redirects-console-page.d.ts +25 -0
- package/src/lib/components/redirects-console-page.js +833 -0
- package/src/lib/components/redirects-console-page.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +8 -0
- package/src/lib/constants/bundle-common.js +9 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/model/index.d.ts +22 -0
- package/src/lib/model/index.js +22 -0
- package/src/lib/model/index.js.map +1 -0
- package/src/lib/model/redirects.d.ts +143 -0
- package/src/lib/model/redirects.js +255 -0
- package/src/lib/model/redirects.js.map +1 -0
- package/src/lib/plugin.d.ts +24 -0
- package/src/lib/plugin.js +55 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/server/resolve-redirect.d.ts +39 -0
- package/src/lib/server/resolve-redirect.js +131 -0
- package/src/lib/server/resolve-redirect.js.map +1 -0
- package/src/lib/server.d.ts +23 -0
- package/src/lib/server.js +33 -0
- package/src/lib/server.js.map +1 -0
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Redirects feature plugin (AGL-395). Console-only — redirects render on
|
|
19
|
+
* published sites through server enforcement (ISR), not a canvas component,
|
|
20
|
+
* so there is no UI bundle to register in the tenant/besigner. The console
|
|
21
|
+
* half declares the Redirects nav + page through the ConsoleExtension
|
|
22
|
+
* registry, gated by the `redirects` entitlement.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerRedirectsConsole(): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import * as Aglyn from "@aglyn/aglyn";
|
|
17
|
+
import { mdiSignDirection } from "@aglyn/shared-data-mdi";
|
|
18
|
+
import { lazy } from "react";
|
|
19
|
+
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
20
|
+
/** Code-split: the Redirects console page only loads when opened. */ const RedirectsConsolePage = lazy(()=>import("./components/redirects-console-page.js"));
|
|
21
|
+
/**
|
|
22
|
+
* Redirects feature plugin (AGL-395). Console-only — redirects render on
|
|
23
|
+
* published sites through server enforcement (ISR), not a canvas component,
|
|
24
|
+
* so there is no UI bundle to register in the tenant/besigner. The console
|
|
25
|
+
* half declares the Redirects nav + page through the ConsoleExtension
|
|
26
|
+
* registry, gated by the `redirects` entitlement.
|
|
27
|
+
*/ export function registerRedirectsConsole() {
|
|
28
|
+
Aglyn.registerConsoleExtension({
|
|
29
|
+
pluginId: BUNDLE_ID,
|
|
30
|
+
displayName: 'Redirects',
|
|
31
|
+
featureFlag: 'redirects',
|
|
32
|
+
navItems: [
|
|
33
|
+
{
|
|
34
|
+
label: 'Redirects',
|
|
35
|
+
href: '/redirects',
|
|
36
|
+
// Reuse the existing release-flag nav-tab so staff-preview gating is
|
|
37
|
+
// unchanged now that the tab comes from the plugin (AGL-395).
|
|
38
|
+
navTabId: 'nav-tab-redirects',
|
|
39
|
+
icon: {
|
|
40
|
+
path: mdiSignDirection.path
|
|
41
|
+
},
|
|
42
|
+
header: {
|
|
43
|
+
title: 'Redirects',
|
|
44
|
+
icon: {
|
|
45
|
+
path: mdiSignDirection.path
|
|
46
|
+
},
|
|
47
|
+
docsTopic: 'redirects'
|
|
48
|
+
},
|
|
49
|
+
Component: RedirectsConsolePage
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=plugin.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/redirects/src/lib/plugin.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 * as Aglyn from '@aglyn/aglyn'\nimport { mdiSignDirection } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/** Code-split: the Redirects console page only loads when opened. */\nconst RedirectsConsolePage = lazy(\n () => import('./components/redirects-console-page'),\n)\n\n/**\n * Redirects feature plugin (AGL-395). Console-only — redirects render on\n * published sites through server enforcement (ISR), not a canvas component,\n * so there is no UI bundle to register in the tenant/besigner. The console\n * half declares the Redirects nav + page through the ConsoleExtension\n * registry, gated by the `redirects` entitlement.\n */\nexport function registerRedirectsConsole(): void {\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Redirects',\n featureFlag: 'redirects',\n navItems: [\n {\n label: 'Redirects',\n href: '/redirects',\n // Reuse the existing release-flag nav-tab so staff-preview gating is\n // unchanged now that the tab comes from the plugin (AGL-395).\n navTabId: 'nav-tab-redirects',\n icon: { path: mdiSignDirection.path },\n header: {\n title: 'Redirects',\n icon: { path: mdiSignDirection.path },\n docsTopic: 'redirects',\n },\n Component: RedirectsConsolePage,\n },\n ],\n })\n}\n"],"names":["Aglyn","mdiSignDirection","lazy","BUNDLE_ID","RedirectsConsolePage","registerRedirectsConsole","registerConsoleExtension","pluginId","displayName","featureFlag","navItems","label","href","navTabId","icon","path","header","title","docsTopic","Component"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SAASC,SAAS,QAAQ,+BAA2B;AAErD,mEAAmE,GACnE,MAAMC,uBAAuBF,KAC3B,IAAM,MAAM,CAAC;AAGf;;;;;;CAMC,GACD,OAAO,SAASG;IACdL,MAAMM,wBAAwB,CAAC;QAC7BC,UAAUJ;QACVK,aAAa;QACbC,aAAa;QACbC,UAAU;YACR;gBACEC,OAAO;gBACPC,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVC,MAAM;oBAAEC,MAAMd,iBAAiBc,IAAI;gBAAC;gBACpCC,QAAQ;oBACNC,OAAO;oBACPH,MAAM;wBAAEC,MAAMd,iBAAiBc,IAAI;oBAAC;oBACpCG,WAAW;gBACb;gBACAC,WAAWf;YACb;SACD;IACH;AACF"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
export interface ResolvedRedirect {
|
|
18
|
+
destination: string;
|
|
19
|
+
statusCode: 301 | 302 | 307 | 308;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Redirect enforcement (AGL-155), Option A per the issue: rules apply in
|
|
23
|
+
* `getStaticProps` before route resolution, with a low revalidate on the
|
|
24
|
+
* redirect response. Trade-offs, documented: rule edits take effect on
|
|
25
|
+
* the next revalidation (≤30s), and hit counts are SAMPLED — cached
|
|
26
|
+
* redirect responses never re-execute code, so each count represents "at
|
|
27
|
+
* least one hit in a revalidation window", not per-request accuracy.
|
|
28
|
+
* Middleware-accurate counting stays open as a later upgrade.
|
|
29
|
+
*
|
|
30
|
+
* Paid gating: when the host has enabled rules but the owning org's
|
|
31
|
+
* plan lacks the `redirects` flag (downgrade with leftover rules), the
|
|
32
|
+
* rules stop firing — the org read only happens when rules exist.
|
|
33
|
+
* Loop guard: self-redirects never execute even if stored (console
|
|
34
|
+
* validation and this floor can disagree; both refuse).
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveRedirect(host: {
|
|
37
|
+
$id: string;
|
|
38
|
+
}, requestPath: string): Promise<ResolvedRedirect | null>;
|
|
39
|
+
export default resolveRedirect;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { checkEntitlement } from "@aglyn/aglyn/server";
|
|
18
|
+
import { matchRedirect, normalizeRedirectSource } from "../model/redirects.js";
|
|
19
|
+
import { analyticsDayExpiresAt, firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
|
|
20
|
+
import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
|
|
21
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
22
|
+
/**
|
|
23
|
+
* One of three per-render reads AGL-1302 left uncached (AGL-1440).
|
|
24
|
+
*
|
|
25
|
+
* This query runs before route resolution on every render of every path, and
|
|
26
|
+
* Firestore bills a minimum of one read for a query returning nothing — so a
|
|
27
|
+
* site that has never written a rule still paid for asking, forever. The rules
|
|
28
|
+
* are pure host-scoped published config: `revalidateTag(tenant-data:{hostId})`
|
|
29
|
+
* busts them, and the shared hour-long TTL is only the backstop for a write
|
|
30
|
+
* that never announces itself. Rule edits DO announce themselves: the
|
|
31
|
+
* console's redirect manager posts `/api/screens/revalidate` with the rule's
|
|
32
|
+
* source path after every save, toggle and delete, which busts the tag and
|
|
33
|
+
* drops that path's cached HTML — that call is what keeps the manager's
|
|
34
|
+
* "live within ~30 seconds" true, not this TTL.
|
|
35
|
+
*/ const REDIRECT_RULES_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS;
|
|
36
|
+
async function readEnabledRules(hostId) {
|
|
37
|
+
const rules = await firebaseAdmin.app().firestore().collection('hosts').doc(hostId).collection('redirects').where('enabled', '==', true).limit(100).get();
|
|
38
|
+
return rules.docs.map((doc)=>_extends({}, doc.data(), {
|
|
39
|
+
$id: doc.id
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/** Firestore map keys: strip characters that complicate field paths. */ const idKey = (value)=>value.replace(/[.$#[\]/]/g, '_');
|
|
43
|
+
/**
|
|
44
|
+
* Redirect enforcement (AGL-155), Option A per the issue: rules apply in
|
|
45
|
+
* `getStaticProps` before route resolution, with a low revalidate on the
|
|
46
|
+
* redirect response. Trade-offs, documented: rule edits take effect on
|
|
47
|
+
* the next revalidation (≤30s), and hit counts are SAMPLED — cached
|
|
48
|
+
* redirect responses never re-execute code, so each count represents "at
|
|
49
|
+
* least one hit in a revalidation window", not per-request accuracy.
|
|
50
|
+
* Middleware-accurate counting stays open as a later upgrade.
|
|
51
|
+
*
|
|
52
|
+
* Paid gating: when the host has enabled rules but the owning org's
|
|
53
|
+
* plan lacks the `redirects` flag (downgrade with leftover rules), the
|
|
54
|
+
* rules stop firing — the org read only happens when rules exist.
|
|
55
|
+
* Loop guard: self-redirects never execute even if stored (console
|
|
56
|
+
* validation and this floor can disagree; both refuse).
|
|
57
|
+
*/ export async function resolveRedirect(host, requestPath) {
|
|
58
|
+
try {
|
|
59
|
+
var _ruleList_matched_index;
|
|
60
|
+
const source = normalizeRedirectSource(requestPath === '/' || requestPath === '' ? '/' : `/${requestPath}`);
|
|
61
|
+
if (!source) return null;
|
|
62
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
63
|
+
const hostRef = firestore.collection('hosts').doc(host.$id);
|
|
64
|
+
// Cached per host, never per path (AGL-1440): the rule set is the same for
|
|
65
|
+
// every URL on the site, so a path in the key would mint one entry per
|
|
66
|
+
// route and cache nothing.
|
|
67
|
+
const ruleList = await withRenderCache({
|
|
68
|
+
key: [
|
|
69
|
+
'redirect-rules',
|
|
70
|
+
host.$id
|
|
71
|
+
],
|
|
72
|
+
revalidate: REDIRECT_RULES_TTL_SECONDS,
|
|
73
|
+
tags: [
|
|
74
|
+
tenantDataTag(host.$id)
|
|
75
|
+
],
|
|
76
|
+
read: ()=>readEnabledRules(host.$id)
|
|
77
|
+
});
|
|
78
|
+
if (!ruleList.length) return null;
|
|
79
|
+
// v2 matcher (AGL-375): exact/prefix/regex in priority order, capture
|
|
80
|
+
// substitution, self-redirect floor. Run per request — it depends on the
|
|
81
|
+
// path, which the cache key deliberately does not carry.
|
|
82
|
+
const matched = matchRedirect(ruleList, source);
|
|
83
|
+
if (!matched) return null;
|
|
84
|
+
const matchId = (_ruleList_matched_index = ruleList[matched.index]) == null ? void 0 : _ruleList_matched_index.$id;
|
|
85
|
+
if (!matchId) return null;
|
|
86
|
+
// Paid gate — only paid orgs' rules fire (dark-launch orgs pass).
|
|
87
|
+
//
|
|
88
|
+
// Deliberately OUTSIDE the cache above. A cached entitlement would keep a
|
|
89
|
+
// downgraded org's leftover rules redirecting for the life of the entry,
|
|
90
|
+
// which on this code path means taking pages off the internet on the
|
|
91
|
+
// 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
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Sampled hit recording (fire-and-forget): day-doc counter + recency
|
|
100
|
+
// stamp for the manager (AGL-157). Also outside the cache — a cached
|
|
101
|
+
// function would stop counting altogether rather than counting less often.
|
|
102
|
+
// The rule's ref is rebuilt from its id, because a `DocumentReference`
|
|
103
|
+
// cannot survive the cache's JSON round trip.
|
|
104
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
105
|
+
void hostRef.collection('analytics').doc(day).set({
|
|
106
|
+
redirects: {
|
|
107
|
+
[idKey(matchId)]: FieldValue.increment(1)
|
|
108
|
+
},
|
|
109
|
+
// Retention (AGL-1844): every writer of a day doc stamps the
|
|
110
|
+
// day-anchored expiry the TTL policy sweeps on.
|
|
111
|
+
expiresAt: analyticsDayExpiresAt(day)
|
|
112
|
+
}, {
|
|
113
|
+
merge: true
|
|
114
|
+
}).catch(()=>undefined);
|
|
115
|
+
void hostRef.collection('redirects').doc(matchId).set({
|
|
116
|
+
lastHitAt: FieldValue.serverTimestamp()
|
|
117
|
+
}, {
|
|
118
|
+
merge: true
|
|
119
|
+
}).catch(()=>undefined);
|
|
120
|
+
return {
|
|
121
|
+
destination: matched.destination,
|
|
122
|
+
statusCode: matched.statusCode
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('resolveRedirect failed', host.$id, requestPath, error);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export default resolveRedirect;
|
|
130
|
+
|
|
131
|
+
//# sourceMappingURL=resolve-redirect.js.map
|
|
@@ -0,0 +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":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Registers the redirects plugin's tenant-side hooks (AGL-418): the rule
|
|
19
|
+
* engine (AGL-155) answers site requests with a redirect before any route
|
|
20
|
+
* resolution — relocated from the tenant app's resolve-redirect util into
|
|
21
|
+
* the plugin behind the core site-page hook.
|
|
22
|
+
*/
|
|
23
|
+
export declare function registerRedirectsApi(): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { registerSiteRedirectResolver } from "@aglyn/aglyn/server";
|
|
17
|
+
import { resolveRedirect } from "./server/resolve-redirect.js";
|
|
18
|
+
/**
|
|
19
|
+
* Registers the redirects plugin's tenant-side hooks (AGL-418): the rule
|
|
20
|
+
* engine (AGL-155) answers site requests with a redirect before any route
|
|
21
|
+
* resolution — relocated from the tenant app's resolve-redirect util into
|
|
22
|
+
* the plugin behind the core site-page hook.
|
|
23
|
+
*/ export function registerRedirectsApi() {
|
|
24
|
+
registerSiteRedirectResolver(async ({ host, path })=>{
|
|
25
|
+
const rule = await resolveRedirect(host, path);
|
|
26
|
+
return rule ? {
|
|
27
|
+
destination: rule.destination,
|
|
28
|
+
statusCode: rule.statusCode
|
|
29
|
+
} : undefined;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/redirects/src/lib/server.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 { registerSiteRedirectResolver } from '@aglyn/aglyn/server'\nimport { resolveRedirect } from './server/resolve-redirect'\n\n/**\n * Registers the redirects plugin's tenant-side hooks (AGL-418): the rule\n * engine (AGL-155) answers site requests with a redirect before any route\n * resolution — relocated from the tenant app's resolve-redirect util into\n * the plugin behind the core site-page hook.\n */\nexport function registerRedirectsApi(): void {\n registerSiteRedirectResolver(async ({ host, path }) => {\n const rule = await resolveRedirect(host, path)\n return rule\n ? { destination: rule.destination, statusCode: rule.statusCode }\n : undefined\n })\n}\n"],"names":["registerSiteRedirectResolver","resolveRedirect","registerRedirectsApi","host","path","rule","destination","statusCode","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,4BAA4B,QAAQ,sBAAqB;AAClE,SAASC,eAAe,QAAQ,+BAA2B;AAE3D;;;;;CAKC,GACD,OAAO,SAASC;IACdF,6BAA6B,OAAO,EAAEG,IAAI,EAAEC,IAAI,EAAE;QAChD,MAAMC,OAAO,MAAMJ,gBAAgBE,MAAMC;QACzC,OAAOC,OACH;YAAEC,aAAaD,KAAKC,WAAW;YAAEC,YAAYF,KAAKE,UAAU;QAAC,IAC7DC;IACN;AACF"}
|