@aglyn/plugins-redirects 1.0.0-beta.221 → 1.0.0-beta.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -145,6 +145,81 @@ export declare function normalizeRedirectSource(input: string): string | null;
145
145
  * null when invalid.
146
146
  */
147
147
  export declare function normalizeRedirectDestination(input: string): string | null;
148
+ /**
149
+ * The key a rule's sampled hits are counted under in a day's analytics
150
+ * document (`hosts/{hostId}/analytics/{YYYY-MM-DD}` → `redirects.{key}`):
151
+ * the rule's id with the characters a field path cannot carry replaced.
152
+ */
153
+ export declare function redirectHitKey(ruleId: string): string;
154
+ /**
155
+ * One rule as the whole-set checks below read it: the redirects page's row
156
+ * shape, with the document id as `$id`. A rule not saved yet has none.
157
+ */
158
+ export interface RedirectRuleRow {
159
+ $id?: string | null;
160
+ source?: string;
161
+ destination?: string;
162
+ kind?: string;
163
+ }
164
+ /**
165
+ * The rule that already redirects `rule.source` in the same match mode, other
166
+ * than `rule` itself — the duplicate a save is refused for. Two rules may
167
+ * share a path when their modes differ (an exact `/blog` beside a prefix
168
+ * `/blog`), and a stored rule without a mode is an exact one.
169
+ *
170
+ * The redirects page and the importer both ask this, so a file can never
171
+ * create the collision the page refuses.
172
+ */
173
+ export declare function findDuplicateRedirect<T extends RedirectRuleRow>(rules: readonly T[], rule: {
174
+ $id?: string | null;
175
+ source: string;
176
+ kind?: string;
177
+ }): T | undefined;
178
+ /** How far a chain is followed before the walk gives up. */
179
+ export declare const REDIRECT_CHAIN_MAX_HOPS = 10;
180
+ /** Where following a rule's destination through the other rules leads. */
181
+ export interface RedirectChainWalk {
182
+ /**
183
+ * The walk came back to the rule's own source within
184
+ * {@link REDIRECT_CHAIN_MAX_HOPS} hops: the rule could never execute, and
185
+ * a save is refused.
186
+ */
187
+ loop: boolean;
188
+ /**
189
+ * The destinations passed through after the rule's own, in order: empty
190
+ * when the destination is no other rule's source.
191
+ */
192
+ hops: string[];
193
+ /**
194
+ * The walk entered a circle that does not pass through this rule: the
195
+ * rule leads visitors into a loop other rules already make.
196
+ */
197
+ cycle: boolean;
198
+ }
199
+ /**
200
+ * Follows a rule's internal destination through the other rules — each
201
+ * rule's source to its destination — the way a visitor would be sent on.
202
+ * Only internal destinations are followed, compared in lowercase like the
203
+ * normalized sources they meet; a rule whose own id is `rule.$id` is left
204
+ * out, so an edit is not compared with what it replaces.
205
+ *
206
+ * The redirects page refuses a save whose walk `loop`s, and the importer
207
+ * refuses the same row; the importer also reports `hops` (a chain) and
208
+ * `cycle`, which the page does not stop.
209
+ */
210
+ export declare function walkRedirectChain(rules: readonly RedirectRuleRow[], rule: {
211
+ $id?: string | null;
212
+ source: string;
213
+ destination: string;
214
+ }): RedirectChainWalk;
215
+ /**
216
+ * Whether `source` is the address of a published page of the site, read from
217
+ * the host's routing map (`screens`: screen id → path without its leading
218
+ * slash, `/` for the home page). A redirect there takes precedence over the
219
+ * page: the page and the importer warn, and neither refuses, because
220
+ * replacing a moved page is what a redirect is for.
221
+ */
222
+ export declare function redirectSourceIsLivePage(screens: Readonly<Record<string, unknown>> | null | undefined, source: string): boolean;
148
223
  /**
149
224
  * True when the rule would redirect a path onto itself — the loop case
150
225
  * both the console and the tenant enforcement must refuse (they validate
@@ -268,6 +268,79 @@ export const REDIRECT_DEFAULT_PRIORITY = 100;
268
268
  }
269
269
  return null;
270
270
  }
271
+ /**
272
+ * The key a rule's sampled hits are counted under in a day's analytics
273
+ * document (`hosts/{hostId}/analytics/{YYYY-MM-DD}` → `redirects.{key}`):
274
+ * the rule's id with the characters a field path cannot carry replaced.
275
+ */ export function redirectHitKey(ruleId) {
276
+ return ruleId.replace(/[.$#[\]/]/g, '_');
277
+ }
278
+ /**
279
+ * The rule that already redirects `rule.source` in the same match mode, other
280
+ * than `rule` itself — the duplicate a save is refused for. Two rules may
281
+ * share a path when their modes differ (an exact `/blog` beside a prefix
282
+ * `/blog`), and a stored rule without a mode is an exact one.
283
+ *
284
+ * The redirects page and the importer both ask this, so a file can never
285
+ * create the collision the page refuses.
286
+ */ export function findDuplicateRedirect(rules, rule) {
287
+ const kind = rule.kind || 'exact';
288
+ return rules.find((other)=>{
289
+ var _other_kind;
290
+ return other.source === rule.source && ((_other_kind = other.kind) != null ? _other_kind : 'exact') === kind && other.$id !== rule.$id;
291
+ });
292
+ }
293
+ /** How far a chain is followed before the walk gives up. */ export const REDIRECT_CHAIN_MAX_HOPS = 10;
294
+ /**
295
+ * Follows a rule's internal destination through the other rules — each
296
+ * rule's source to its destination — the way a visitor would be sent on.
297
+ * Only internal destinations are followed, compared in lowercase like the
298
+ * normalized sources they meet; a rule whose own id is `rule.$id` is left
299
+ * out, so an edit is not compared with what it replaces.
300
+ *
301
+ * The redirects page refuses a save whose walk `loop`s, and the importer
302
+ * refuses the same row; the importer also reports `hops` (a chain) and
303
+ * `cycle`, which the page does not stop.
304
+ */ export function walkRedirectChain(rules, rule) {
305
+ const walk = {
306
+ loop: false,
307
+ hops: [],
308
+ cycle: false
309
+ };
310
+ if (!rule.destination.startsWith('/')) return walk;
311
+ const bySource = new Map(rules.filter((other)=>other.$id !== rule.$id).map((other)=>[
312
+ other.source,
313
+ other.destination
314
+ ]));
315
+ const seen = new Set();
316
+ let cursor = rule.destination.toLowerCase();
317
+ for(let hop = 0; hop < REDIRECT_CHAIN_MAX_HOPS && cursor; hop += 1){
318
+ if (cursor === rule.source) {
319
+ walk.loop = true;
320
+ return walk;
321
+ }
322
+ // A path seen before repeats from here on without reaching the source,
323
+ // so the walk stops: it is a circle this rule only leads into.
324
+ if (seen.has(cursor)) {
325
+ walk.cycle = true;
326
+ return walk;
327
+ }
328
+ seen.add(cursor);
329
+ const next = bySource.get(cursor);
330
+ if (typeof next === 'string') walk.hops.push(next);
331
+ cursor = next && next.startsWith('/') ? next.toLowerCase() : undefined;
332
+ }
333
+ return walk;
334
+ }
335
+ /**
336
+ * Whether `source` is the address of a published page of the site, read from
337
+ * the host's routing map (`screens`: screen id → path without its leading
338
+ * slash, `/` for the home page). A redirect there takes precedence over the
339
+ * page: the page and the importer warn, and neither refuses, because
340
+ * replacing a moved page is what a redirect is for.
341
+ */ export function redirectSourceIsLivePage(screens, source) {
342
+ return Object.values(screens != null ? screens : {}).map((path)=>path === '/' ? '/' : `/${path}`).includes(source);
343
+ }
271
344
  /**
272
345
  * True when the rule would redirect a path onto itself — the loop case
273
346
  * both the console and the tenant enforcement must refuse (they validate
@@ -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 { 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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"}
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 * The key a rule's sampled hits are counted under in a day's analytics\n * document (`hosts/{hostId}/analytics/{YYYY-MM-DD}` → `redirects.{key}`):\n * the rule's id with the characters a field path cannot carry replaced.\n */\nexport function redirectHitKey(ruleId: string): string {\n return ruleId.replace(/[.$#[\\]/]/g, '_')\n}\n\n/**\n * One rule as the whole-set checks below read it: the redirects page's row\n * shape, with the document id as `$id`. A rule not saved yet has none.\n */\nexport interface RedirectRuleRow {\n $id?: string | null\n source?: string\n destination?: string\n kind?: string\n}\n\n/**\n * The rule that already redirects `rule.source` in the same match mode, other\n * than `rule` itself — the duplicate a save is refused for. Two rules may\n * share a path when their modes differ (an exact `/blog` beside a prefix\n * `/blog`), and a stored rule without a mode is an exact one.\n *\n * The redirects page and the importer both ask this, so a file can never\n * create the collision the page refuses.\n */\nexport function findDuplicateRedirect<T extends RedirectRuleRow>(\n rules: readonly T[],\n rule: { $id?: string | null; source: string; kind?: string },\n): T | undefined {\n const kind = rule.kind || 'exact'\n return rules.find(\n (other) =>\n other.source === rule.source &&\n (other.kind ?? 'exact') === kind &&\n other.$id !== rule.$id,\n )\n}\n\n/** How far a chain is followed before the walk gives up. */\nexport const REDIRECT_CHAIN_MAX_HOPS = 10\n\n/** Where following a rule's destination through the other rules leads. */\nexport interface RedirectChainWalk {\n /**\n * The walk came back to the rule's own source within\n * {@link REDIRECT_CHAIN_MAX_HOPS} hops: the rule could never execute, and\n * a save is refused.\n */\n loop: boolean\n /**\n * The destinations passed through after the rule's own, in order: empty\n * when the destination is no other rule's source.\n */\n hops: string[]\n /**\n * The walk entered a circle that does not pass through this rule: the\n * rule leads visitors into a loop other rules already make.\n */\n cycle: boolean\n}\n\n/**\n * Follows a rule's internal destination through the other rules — each\n * rule's source to its destination — the way a visitor would be sent on.\n * Only internal destinations are followed, compared in lowercase like the\n * normalized sources they meet; a rule whose own id is `rule.$id` is left\n * out, so an edit is not compared with what it replaces.\n *\n * The redirects page refuses a save whose walk `loop`s, and the importer\n * refuses the same row; the importer also reports `hops` (a chain) and\n * `cycle`, which the page does not stop.\n */\nexport function walkRedirectChain(\n rules: readonly RedirectRuleRow[],\n rule: { $id?: string | null; source: string; destination: string },\n): RedirectChainWalk {\n const walk: RedirectChainWalk = { loop: false, hops: [], cycle: false }\n if (!rule.destination.startsWith('/')) return walk\n const bySource = new Map<string | undefined, string | undefined>(\n rules\n .filter((other) => other.$id !== rule.$id)\n .map((other) => [other.source, other.destination]),\n )\n const seen = new Set<string>()\n let cursor: string | undefined = rule.destination.toLowerCase()\n for (let hop = 0; hop < REDIRECT_CHAIN_MAX_HOPS && cursor; hop += 1) {\n if (cursor === rule.source) {\n walk.loop = true\n return walk\n }\n // A path seen before repeats from here on without reaching the source,\n // so the walk stops: it is a circle this rule only leads into.\n if (seen.has(cursor)) {\n walk.cycle = true\n return walk\n }\n seen.add(cursor)\n const next: string | undefined = bySource.get(cursor)\n if (typeof next === 'string') walk.hops.push(next)\n cursor = next && next.startsWith('/') ? next.toLowerCase() : undefined\n }\n return walk\n}\n\n/**\n * Whether `source` is the address of a published page of the site, read from\n * the host's routing map (`screens`: screen id → path without its leading\n * slash, `/` for the home page). A redirect there takes precedence over the\n * page: the page and the importer warn, and neither refuses, because\n * replacing a moved page is what a redirect is for.\n */\nexport function redirectSourceIsLivePage(\n screens: Readonly<Record<string, unknown>> | null | undefined,\n source: string,\n): boolean {\n return Object.values((screens ?? {}) as Record<string, string>)\n .map((path) => (path === '/' ? '/' : `/${path}`))\n .includes(source)\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","redirectHitKey","ruleId","findDuplicateRedirect","find","other","$id","REDIRECT_CHAIN_MAX_HOPS","walkRedirectChain","walk","loop","hops","cycle","bySource","Map","seen","Set","cursor","hop","has","add","next","get","push","undefined","redirectSourceIsLivePage","screens","Object","values","redirect","normalized"],"mappings":"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"}
package/src/lib/plugin.js CHANGED
@@ -14,10 +14,12 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import * as Aglyn from "@aglyn/aglyn";
17
+ import { registerPluginTransferResourceUi } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
17
18
  import { mdiSignDirection } from "@aglyn/shared-data-mdi";
18
19
  import { lazy } from "react";
19
20
  import { BUNDLE_ID } from "./constants/bundle-common.js";
20
21
  import { REDIRECTS_SEARCH_SOURCES } from "./model/redirects-search-sources.js";
22
+ import { REDIRECTS_TRANSFER_KEY } from "./transfer/redirects-transfer-fields.js";
21
23
  /** Code-split: the Redirects console page only loads when opened. */ const RedirectsConsolePage = lazy(()=>import("./components/redirects-console-page.js"));
22
24
  /**
23
25
  * Redirects feature plugin (AGL-395). Console-only — redirects render on
@@ -52,6 +54,17 @@ import { REDIRECTS_SEARCH_SOURCES } from "./model/redirects-search-sources.js";
52
54
  }
53
55
  ]
54
56
  });
57
+ // The client half of the `redirects` transfer resource: how the import
58
+ // wizard and the export dialog name it. The server half is registered from
59
+ // `declarations.console-server.ts`.
60
+ registerPluginTransferResourceUi(REDIRECTS_TRANSFER_KEY, {
61
+ label: 'Redirects',
62
+ icon: {
63
+ path: mdiSignDirection.path
64
+ }
65
+ }, {
66
+ pluginId: BUNDLE_ID
67
+ });
55
68
  }
56
69
 
57
70
  //# sourceMappingURL=plugin.js.map
@@ -1 +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'\nimport { REDIRECTS_SEARCH_SOURCES } from './model/redirects-search-sources'\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 searchSources: REDIRECTS_SEARCH_SOURCES,\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","REDIRECTS_SEARCH_SOURCES","RedirectsConsolePage","registerRedirectsConsole","registerConsoleExtension","pluginId","displayName","featureFlag","searchSources","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;AACrD,SAASC,wBAAwB,QAAQ,sCAAkC;AAE3E,mEAAmE,GACnE,MAAMC,uBAAuBH,KAC3B,IAAM,MAAM,CAAC;AAGf;;;;;;CAMC,GACD,OAAO,SAASI;IACdN,MAAMO,wBAAwB,CAAC;QAC7BC,UAAUL;QACVM,aAAa;QACbC,aAAa;QACbC,eAAeP;QACfQ,UAAU;YACR;gBACEC,OAAO;gBACPC,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVC,MAAM;oBAAEC,MAAMhB,iBAAiBgB,IAAI;gBAAC;gBACpCC,QAAQ;oBACNC,OAAO;oBACPH,MAAM;wBAAEC,MAAMhB,iBAAiBgB,IAAI;oBAAC;oBACpCG,WAAW;gBACb;gBACAC,WAAWhB;YACb;SACD;IACH;AACF"}
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 { registerPluginTransferResourceUi } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { mdiSignDirection } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { REDIRECTS_SEARCH_SOURCES } from './model/redirects-search-sources'\nimport { REDIRECTS_TRANSFER_KEY } from './transfer/redirects-transfer-fields'\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 searchSources: REDIRECTS_SEARCH_SOURCES,\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 // The client half of the `redirects` transfer resource: how the import\n // wizard and the export dialog name it. The server half is registered from\n // `declarations.console-server.ts`.\n registerPluginTransferResourceUi(\n REDIRECTS_TRANSFER_KEY,\n { label: 'Redirects', icon: { path: mdiSignDirection.path } },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["Aglyn","registerPluginTransferResourceUi","mdiSignDirection","lazy","BUNDLE_ID","REDIRECTS_SEARCH_SOURCES","REDIRECTS_TRANSFER_KEY","RedirectsConsolePage","registerRedirectsConsole","registerConsoleExtension","pluginId","displayName","featureFlag","searchSources","navItems","label","href","navTabId","icon","path","header","title","docsTopic","Component"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,eAAc;AACrC,SAASC,gCAAgC,QAAQ,wDAAuD;AACxG,SAASC,gBAAgB,QAAQ,yBAAwB;AACzD,SAASC,IAAI,QAAQ,QAAO;AAC5B,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,wBAAwB,QAAQ,sCAAkC;AAC3E,SAASC,sBAAsB,QAAQ,0CAAsC;AAE7E,mEAAmE,GACnE,MAAMC,uBAAuBJ,KAC3B,IAAM,MAAM,CAAC;AAGf;;;;;;CAMC,GACD,OAAO,SAASK;IACdR,MAAMS,wBAAwB,CAAC;QAC7BC,UAAUN;QACVO,aAAa;QACbC,aAAa;QACbC,eAAeR;QACfS,UAAU;YACR;gBACEC,OAAO;gBACPC,MAAM;gBACN,qEAAqE;gBACrE,8DAA8D;gBAC9DC,UAAU;gBACVC,MAAM;oBAAEC,MAAMjB,iBAAiBiB,IAAI;gBAAC;gBACpCC,QAAQ;oBACNC,OAAO;oBACPH,MAAM;wBAAEC,MAAMjB,iBAAiBiB,IAAI;oBAAC;oBACpCG,WAAW;gBACb;gBACAC,WAAWhB;YACb;SACD;IACH;IACA,uEAAuE;IACvE,2EAA2E;IAC3E,oCAAoC;IACpCN,iCACEK,wBACA;QAAES,OAAO;QAAaG,MAAM;YAAEC,MAAMjB,iBAAiBiB,IAAI;QAAC;IAAE,GAC5D;QAAET,UAAUN;IAAU;AAE1B"}
@@ -15,10 +15,15 @@ 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 { isExternalRedirectDestination, matchRedirect, normalizeRedirectSource } from "../model/redirects.js";
18
+ import { isExternalRedirectDestination, matchRedirect, normalizeRedirectSource, redirectHitKey } from "../model/redirects.js";
19
19
  import { analyticsDayExpiresAt, firebaseAdmin, getOrgForHost, reviewSiteRedirect } from "@aglyn/tenant-data-admin";
20
20
  import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
21
+ import { boundedAwait } from "@aglyn/shared-util-http/bounded-await";
21
22
  import { FieldValue } from "firebase-admin/firestore";
23
+ /**
24
+ * How long a render waits on the phishing review of an off-site rule before
25
+ * treating the rule as held for this request (AGL-3565).
26
+ */ const REDIRECT_REVIEW_DEADLINE_MS = 5000;
22
27
  /**
23
28
  * One of three per-render reads AGL-1302 left uncached (AGL-1440).
24
29
  *
@@ -39,7 +44,6 @@ async function readEnabledRules(hostId) {
39
44
  $id: doc.id
40
45
  }));
41
46
  }
42
- /** Firestore map keys: strip characters that complicate field paths. */ const idKey = (value)=>value.replace(/[.$#[\]/]/g, '_');
43
47
  /**
44
48
  * Redirect enforcement (AGL-155), Option A per the issue: rules apply in
45
49
  * `getStaticProps` before route resolution, with a low revalidate on the
@@ -102,9 +106,15 @@ async function readEnabledRules(hostId) {
102
106
  // not fire and is not counted; the request resolves as if it had no rule.
103
107
  // Outside the cache, like the paid gate: a staff decision takes effect on
104
108
  // the next request, not at the end of an hour.
109
+ //
110
+ // The review asks Google Web Risk and, for a rule it holds, files the hold
111
+ // and tells staff — all inside this page render. It answers within a
112
+ // deadline or the rule is treated as held, as the review itself fails
113
+ // closed; the review carries on behind the page, so the next request
114
+ // reads its decision (AGL-3565).
105
115
  if (isExternalRedirectDestination(matched.destination)) {
106
116
  var _ref, _ref1;
107
- const review = await reviewSiteRedirect({
117
+ const review = await boundedAwait(reviewSiteRedirect({
108
118
  hostId: host.$id,
109
119
  ruleId: matchId,
110
120
  source: ruleList[matched.index].source,
@@ -113,8 +123,8 @@ async function readEnabledRules(hostId) {
113
123
  host: typeof host['subdomain'] === 'string' ? host : undefined,
114
124
  org: (_ref = owner == null ? void 0 : owner.org) != null ? _ref : null,
115
125
  orgId: (_ref1 = owner == null ? void 0 : owner.orgId) != null ? _ref1 : null
116
- });
117
- if (review.outcome !== 'serve') return null;
126
+ }), REDIRECT_REVIEW_DEADLINE_MS, null, 'redirects.site-redirect-review');
127
+ if ((review == null ? void 0 : review.outcome) !== 'serve') return null;
118
128
  }
119
129
  // Sampled hit recording (fire-and-forget): day-doc counter + recency
120
130
  // stamp for the manager (AGL-157). Also outside the cache — a cached
@@ -124,7 +134,7 @@ async function readEnabledRules(hostId) {
124
134
  const day = new Date().toISOString().slice(0, 10);
125
135
  void hostRef.collection('analytics').doc(day).set({
126
136
  redirects: {
127
- [idKey(matchId)]: FieldValue.increment(1)
137
+ [redirectHitKey(matchId)]: FieldValue.increment(1)
128
138
  },
129
139
  // Retention (AGL-1844): every writer of a day doc stamps the
130
140
  // day-anchored expiry the TTL policy sweeps on.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/resolve-redirect.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n type HostRedirect,\n isExternalRedirectDestination,\n matchRedirect,\n normalizeRedirectSource,\n} 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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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/server/resolve-redirect.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { checkEntitlement } from '@aglyn/aglyn/server'\nimport {\n type HostRedirect,\n isExternalRedirectDestination,\n matchRedirect,\n normalizeRedirectSource,\n redirectHitKey,\n} from '../model/redirects'\nimport {\n analyticsDayExpiresAt,\n firebaseAdmin,\n getOrgForHost,\n reviewSiteRedirect,\n} from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\nimport { boundedAwait } from '@aglyn/shared-util-http/bounded-await'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * How long a render waits on the phishing review of an off-site rule before\n * treating the rule as held for this request (AGL-3565).\n */\nconst REDIRECT_REVIEW_DEADLINE_MS = 5_000\n\n/**\n * One of three per-render reads AGL-1302 left uncached (AGL-1440).\n *\n * This query runs before route resolution on every render of every path, and\n * Firestore bills a minimum of one read for a query returning nothing — so a\n * site that has never written a rule still paid for asking, forever. The rules\n * are pure host-scoped published config: `revalidateTag(tenant-data:{hostId})`\n * busts them, and the shared hour-long TTL is only the backstop for a write\n * that never announces itself. Rule edits DO announce themselves: the\n * console's redirect manager posts `/api/screens/revalidate` with the rule's\n * source path after every save, toggle and delete, which busts the tag and\n * drops that path's cached HTML — that call is what keeps the manager's\n * \"live within ~30 seconds\" true, not this TTL.\n */\nconst REDIRECT_RULES_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/** One enabled rule, flattened to the plain data the matcher reads. */\ntype StoredRedirect = HostRedirect & { $id: string }\n\nasync function readEnabledRules(hostId: string): Promise<StoredRedirect[]> {\n const rules = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('redirects')\n .where('enabled', '==', true)\n .limit(100)\n .get()\n return rules.docs.map((doc) => ({\n ...(doc.data() as HostRedirect),\n $id: doc.id,\n }))\n}\n\nexport interface ResolvedRedirect {\n destination: string\n statusCode: 301 | 302 | 307 | 308\n}\n\n\n/**\n * Redirect enforcement (AGL-155), Option A per the issue: rules apply in\n * `getStaticProps` before route resolution, with a low revalidate on the\n * redirect response. Trade-offs, documented: rule edits take effect on\n * the next revalidation (≤30s), and hit counts are SAMPLED — cached\n * redirect responses never re-execute code, so each count represents \"at\n * least one hit in a revalidation window\", not per-request accuracy.\n * Middleware-accurate counting stays open as a later upgrade.\n *\n * Paid gating: when the host has enabled rules but the owning org's\n * plan lacks the `redirects` flag (downgrade with leftover rules), the\n * rules stop firing — the org read only happens when rules exist.\n * Loop guard: self-redirects never execute even if stored (console\n * validation and this floor can disagree; both refuse).\n * Phishing screen: a rule that leaves the site fires only once\n * `reviewSiteRedirect` answers `serve` — a destination that wears a brand is\n * held for every workspace, a lure path sent off the site for a workspace in\n * its first fortnight (AGL-3447).\n */\nexport async function resolveRedirect(\n host: { $id: string } & Record<string, unknown>,\n requestPath: string,\n): Promise<ResolvedRedirect | null> {\n try {\n const source = normalizeRedirectSource(\n requestPath === '/' || requestPath === '' ? '/' : `/${requestPath}`,\n )\n if (!source) return null\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(host.$id)\n // Cached per host, never per path (AGL-1440): the rule set is the same for\n // every URL on the site, so a path in the key would mint one entry per\n // route and cache nothing.\n const ruleList = await withRenderCache({\n key: ['redirect-rules', host.$id],\n revalidate: REDIRECT_RULES_TTL_SECONDS,\n tags: [tenantDataTag(host.$id)],\n read: () => readEnabledRules(host.$id),\n })\n if (!ruleList.length) return null\n\n // v2 matcher (AGL-375): exact/prefix/regex in priority order, capture\n // substitution, self-redirect floor. Run per request — it depends on the\n // path, which the cache key deliberately does not carry.\n const matched = matchRedirect(ruleList, source)\n if (!matched) return null\n const matchId = ruleList[matched.index]?.$id\n if (!matchId) return null\n\n // Paid gate — only paid orgs' rules fire (dark-launch orgs pass).\n //\n // Deliberately OUTSIDE the cache above. A cached entitlement would keep a\n // downgraded org's leftover rules redirecting for the life of the entry,\n // which on this code path means taking pages off the internet on the\n // strength of a plan the org no longer has.\n const owner = await getOrgForHost(host.$id)\n if (!checkEntitlement(owner?.org as any, 'redirects')) {\n return null\n }\n\n // The phishing screen (AGL-3447), for a rule that sends visitors OFF the\n // site: a page whose one call to action fires on arrival. A held rule does\n // not fire and is not counted; the request resolves as if it had no rule.\n // Outside the cache, like the paid gate: a staff decision takes effect on\n // the next request, not at the end of an hour.\n //\n // The review asks Google Web Risk and, for a rule it holds, files the hold\n // and tells staff — all inside this page render. It answers within a\n // deadline or the rule is treated as held, as the review itself fails\n // closed; the review carries on behind the page, so the next request\n // reads its decision (AGL-3565).\n if (isExternalRedirectDestination(matched.destination)) {\n const review = await boundedAwait(\n reviewSiteRedirect({\n hostId: host.$id,\n ruleId: matchId,\n source: ruleList[matched.index].source,\n destination: matched.destination,\n // The resolved host doc, when the hook handed one over; read otherwise.\n host: typeof host['subdomain'] === 'string' ? host : undefined,\n org: (owner?.org as Record<string, unknown> | undefined) ?? null,\n orgId: owner?.orgId ?? null,\n }),\n REDIRECT_REVIEW_DEADLINE_MS,\n null,\n 'redirects.site-redirect-review',\n )\n if (review?.outcome !== 'serve') return null\n }\n\n // Sampled hit recording (fire-and-forget): day-doc counter + recency\n // stamp for the manager (AGL-157). Also outside the cache — a cached\n // function would stop counting altogether rather than counting less often.\n // The rule's ref is rebuilt from its id, because a `DocumentReference`\n // cannot survive the cache's JSON round trip.\n const day = new Date().toISOString().slice(0, 10)\n void hostRef\n .collection('analytics')\n .doc(day)\n .set(\n {\n redirects: { [redirectHitKey(matchId)]: FieldValue.increment(1) },\n // Retention (AGL-1844): every writer of a day doc stamps the\n // day-anchored expiry the TTL policy sweeps on.\n expiresAt: analyticsDayExpiresAt(day),\n },\n { merge: true },\n )\n .catch(() => undefined)\n void hostRef\n .collection('redirects')\n .doc(matchId)\n .set({ lastHitAt: FieldValue.serverTimestamp() }, { merge: true })\n .catch(() => undefined)\n\n return { destination: matched.destination, statusCode: matched.statusCode }\n } catch (error) {\n console.error('resolveRedirect failed', host.$id, requestPath, error)\n return null\n }\n}\n\nexport default resolveRedirect\n"],"names":["checkEntitlement","isExternalRedirectDestination","matchRedirect","normalizeRedirectSource","redirectHitKey","analyticsDayExpiresAt","firebaseAdmin","getOrgForHost","reviewSiteRedirect","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","boundedAwait","FieldValue","REDIRECT_REVIEW_DEADLINE_MS","REDIRECT_RULES_TTL_SECONDS","readEnabledRules","hostId","rules","app","firestore","collection","doc","where","limit","get","docs","map","data","$id","id","resolveRedirect","host","requestPath","ruleList","source","hostRef","key","revalidate","tags","read","length","matched","matchId","index","owner","org","destination","review","ruleId","undefined","orgId","outcome","day","Date","toISOString","slice","set","redirects","increment","expiresAt","merge","catch","lastHitAt","serverTimestamp","statusCode","error","console"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginTransferResource } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources';
18
+ export declare function redirectsTransferResource(): PluginTransferResource;
@@ -0,0 +1,62 @@
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 { firebaseAdmin, getOrgForHost, logHostActivity } from "@aglyn/tenant-data-admin";
17
+ import { findUserByUidAcrossPools } from "@aglyn/tenant-data-admin/server/auth-pools";
18
+ import { createRedirectsTransferResource } from "./redirects-transfer.js";
19
+ /**
20
+ * The redirects resource over the console's Admin SDK, made once per process
21
+ * on the first import or export. The declarations file reaches it by a
22
+ * relative `import()`, so the console's boot loads neither this nor the
23
+ * Admin SDK.
24
+ */ let resource = null;
25
+ /** Who an activity line names, by uid; read once per member per process. */ const emails = new Map();
26
+ async function emailOf(uid) {
27
+ var _emails_get;
28
+ if (emails.has(uid)) return (_emails_get = emails.get(uid)) != null ? _emails_get : null;
29
+ // Across every sign-in pool: an SSO member is not in the project's pool.
30
+ const email = await findUserByUidAcrossPools(uid).then((found)=>{
31
+ var _ref;
32
+ return (_ref = found == null ? void 0 : found.record.email) != null ? _ref : null;
33
+ }).catch(()=>null);
34
+ emails.set(uid, email);
35
+ return email;
36
+ }
37
+ export function redirectsTransferResource() {
38
+ resource != null ? resource : resource = createRedirectsTransferResource({
39
+ firestore: firebaseAdmin.app().firestore(),
40
+ // The create route's owner read: a plan-less or missing workspace is the
41
+ // free plan, which has no redirects.
42
+ loadOrg: async (hostId)=>{
43
+ var _ref;
44
+ var _this;
45
+ return (_ref = (_this = await getOrgForHost(hostId)) == null ? void 0 : _this.org) != null ? _ref : null;
46
+ },
47
+ // The create route's activity line for each rule an import creates.
48
+ logCreated: async (hostId, actorUid, ruleId, declared)=>{
49
+ var _declared_activityType;
50
+ await logHostActivity(hostId, {
51
+ uid: actorUid,
52
+ email: await emailOf(actorUid)
53
+ }, `Created ${declared.activityNoun}`, {
54
+ type: (_declared_activityType = declared.activityType) != null ? _declared_activityType : 'content',
55
+ id: ruleId
56
+ });
57
+ }
58
+ });
59
+ return resource;
60
+ }
61
+
62
+ //# sourceMappingURL=redirects-transfer-console.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/transfer/redirects-transfer-console.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 type { PluginTransferResource } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport type { HostActivityTarget } from '@aglyn/tenant-data-admin'\nimport { firebaseAdmin, getOrgForHost, logHostActivity } from '@aglyn/tenant-data-admin'\nimport { findUserByUidAcrossPools } from '@aglyn/tenant-data-admin/server/auth-pools'\nimport { createRedirectsTransferResource } from './redirects-transfer'\n\n/**\n * The redirects resource over the console's Admin SDK, made once per process\n * on the first import or export. The declarations file reaches it by a\n * relative `import()`, so the console's boot loads neither this nor the\n * Admin SDK.\n */\nlet resource: PluginTransferResource | null = null\n\n/** Who an activity line names, by uid; read once per member per process. */\nconst emails = new Map<string, string | null>()\n\nasync function emailOf(uid: string): Promise<string | null> {\n if (emails.has(uid)) return emails.get(uid) ?? null\n // Across every sign-in pool: an SSO member is not in the project's pool.\n const email = await findUserByUidAcrossPools(uid)\n .then((found) => found?.record.email ?? null)\n .catch(() => null)\n emails.set(uid, email)\n return email\n}\n\nexport function redirectsTransferResource(): PluginTransferResource {\n resource ??= createRedirectsTransferResource({\n firestore: firebaseAdmin.app().firestore(),\n // The create route's owner read: a plan-less or missing workspace is the\n // free plan, which has no redirects.\n loadOrg: async (hostId) => ((await getOrgForHost(hostId))?.org ?? null) as never,\n // The create route's activity line for each rule an import creates.\n logCreated: async (hostId, actorUid, ruleId, declared) => {\n await logHostActivity(\n hostId,\n { uid: actorUid, email: await emailOf(actorUid) },\n `Created ${declared.activityNoun}`,\n { type: (declared.activityType ?? 'content') as HostActivityTarget['type'], id: ruleId },\n )\n },\n })\n return resource\n}\n"],"names":["firebaseAdmin","getOrgForHost","logHostActivity","findUserByUidAcrossPools","createRedirectsTransferResource","resource","emails","Map","emailOf","uid","has","get","email","then","found","record","catch","set","redirectsTransferResource","firestore","app","loadOrg","hostId","org","logCreated","actorUid","ruleId","declared","activityNoun","type","activityType","id"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAID,SAASA,aAAa,EAAEC,aAAa,EAAEC,eAAe,QAAQ,2BAA0B;AACxF,SAASC,wBAAwB,QAAQ,6CAA4C;AACrF,SAASC,+BAA+B,QAAQ,0BAAsB;AAEtE;;;;;CAKC,GACD,IAAIC,WAA0C;AAE9C,0EAA0E,GAC1E,MAAMC,SAAS,IAAIC;AAEnB,eAAeC,QAAQC,GAAW;QACJH;IAA5B,IAAIA,OAAOI,GAAG,CAACD,MAAM,QAAOH,cAAAA,OAAOK,GAAG,CAACF,gBAAXH,cAAmB;IAC/C,yEAAyE;IACzE,MAAMM,QAAQ,MAAMT,yBAAyBM,KAC1CI,IAAI,CAAC,CAACC;;uBAAUA,yBAAAA,MAAOC,MAAM,CAACH,KAAK,mBAAI;OACvCI,KAAK,CAAC,IAAM;IACfV,OAAOW,GAAG,CAACR,KAAKG;IAChB,OAAOA;AACT;AAEA,OAAO,SAASM;IACdb,mBAAAA,WAAAA,WAAaD,gCAAgC;QAC3Ce,WAAWnB,cAAcoB,GAAG,GAAGD,SAAS;QACxC,yEAAyE;QACzE,qCAAqC;QACrCE,SAAS,OAAOC;;gBAAa;4BAAA,QAAA,MAAMrB,cAAcqB,4BAArB,AAAC,MAA8BC,GAAG,mBAAI;;QAClE,oEAAoE;QACpEC,YAAY,OAAOF,QAAQG,UAAUC,QAAQC;gBAKhCA;YAJX,MAAMzB,gBACJoB,QACA;gBAAEb,KAAKgB;gBAAUb,OAAO,MAAMJ,QAAQiB;YAAU,GAChD,CAAC,QAAQ,EAAEE,SAASC,YAAY,EAAE,EAClC;gBAAEC,IAAI,GAAGF,yBAAAA,SAASG,YAAY,YAArBH,yBAAyB;gBAA0CI,IAAIL;YAAO;QAE3F;IACF;IACA,OAAOrB;AACT"}