@aglyn/aglyn 1.0.0-beta.232 → 1.0.0-beta.234

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.
Files changed (56) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/admin-audit-index.d.ts +3 -1
  3. package/src/lib/app-utils/admin-audit-index.js +9 -1
  4. package/src/lib/app-utils/admin-audit-index.js.map +1 -1
  5. package/src/lib/app-utils/analytics-summary.d.ts +95 -0
  6. package/src/lib/app-utils/analytics-summary.js +113 -0
  7. package/src/lib/app-utils/analytics-summary.js.map +1 -0
  8. package/src/lib/app-utils/artifact-list-keys.js +12 -0
  9. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  10. package/src/lib/app-utils/artifact-list-queries.d.ts +16 -0
  11. package/src/lib/app-utils/artifact-list-queries.js +122 -4
  12. package/src/lib/app-utils/artifact-list-queries.js.map +1 -1
  13. package/src/lib/app-utils/crm.d.ts +30 -2
  14. package/src/lib/app-utils/crm.js +77 -15
  15. package/src/lib/app-utils/crm.js.map +1 -1
  16. package/src/lib/app-utils/docs-help.generated.d.ts +17 -5
  17. package/src/lib/app-utils/docs-help.generated.js +35 -1
  18. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  19. package/src/lib/app-utils/docs-index.generated.js +139 -6
  20. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  21. package/src/lib/app-utils/lockdown.js +1 -1
  22. package/src/lib/app-utils/lockdown.js.map +1 -1
  23. package/src/lib/app-utils/message-search.js +5 -2
  24. package/src/lib/app-utils/message-search.js.map +1 -1
  25. package/src/lib/app-utils/name-search.js +15 -3
  26. package/src/lib/app-utils/name-search.js.map +1 -1
  27. package/src/lib/app-utils/screen-analytics-aggregate.d.ts +70 -0
  28. package/src/lib/app-utils/screen-analytics-aggregate.js +82 -0
  29. package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -0
  30. package/src/lib/app-utils/screen-kind.d.ts +25 -0
  31. package/src/lib/app-utils/screen-kind.js +25 -0
  32. package/src/lib/app-utils/screen-kind.js.map +1 -0
  33. package/src/lib/app-utils/screen-route.d.ts +1 -5
  34. package/src/lib/app-utils/screen-route.js +2 -4
  35. package/src/lib/app-utils/screen-route.js.map +1 -1
  36. package/src/lib/app-utils/site-journey-steps.d.ts +38 -0
  37. package/src/lib/app-utils/site-journey-steps.js +54 -0
  38. package/src/lib/app-utils/site-journey-steps.js.map +1 -0
  39. package/src/lib/app-utils/site-journey.d.ts +2 -22
  40. package/src/lib/app-utils/site-journey.js +2 -32
  41. package/src/lib/app-utils/site-journey.js.map +1 -1
  42. package/src/lib/foundation/definitions/platform.types.d.ts +33 -0
  43. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  44. package/src/lib/plugin-manager/first-party-plugins.generated.js +20 -2
  45. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  46. package/src/lib/plugin-manager/plugin-checkout-credits.d.ts +288 -0
  47. package/src/lib/plugin-manager/plugin-checkout-credits.js +196 -0
  48. package/src/lib/plugin-manager/plugin-checkout-credits.js.map +1 -0
  49. package/src/lib/plugin-manager/plugin-media-ingest.d.ts +87 -0
  50. package/src/lib/plugin-manager/plugin-media-ingest.js +34 -0
  51. package/src/lib/plugin-manager/plugin-media-ingest.js.map +1 -0
  52. package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +10 -0
  53. package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -1
  54. package/src/lib/plugin-manager/stock-photo-provider.d.ts +141 -0
  55. package/src/lib/plugin-manager/stock-photo-provider.js +59 -0
  56. package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -0
@@ -979,7 +979,7 @@ export const LOCKDOWN_REFUSAL_FALLBACK_MESSAGE = 'This is temporarily paused whi
979
979
  * generic pause rather than a guess; being vague at a stranger is cheap,
980
980
  * being wrong about their money is not.
981
981
  */ export function lockdownPausedSurfaceForPluginApiPath(path) {
982
- if (path === 'commerce/checkout' || path === 'commerce/cart-checkout' || path === 'commerce/pos-order' || path === 'commerce/pos-payment' || path === 'commerce/draft-order') {
982
+ if (path === 'commerce/checkout' || path === 'commerce/cart-checkout' || path === 'commerce/pos-order' || path === 'commerce/pos-payment' || path === 'commerce/pos-offline-sync' || path === 'commerce/draft-order') {
983
983
  return 'checkout';
984
984
  }
985
985
  if (path === 'commerce/cart' || path.startsWith('commerce/cart/')) return 'cart';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/lockdown.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 * Lockdown (AGL-1501): the panic button. ONE state shape and ONE resolver\n * across four scopes — platform, org, host, user — with precedence\n * platform > org > host > user.\n *\n * This module is deliberately pure: it normalizes the storage carriers into\n * `LockdownState` and answers \"is access locked, and what does the visitor\n * see\". Where the state LIVES differs per scope, because two of the four\n * scopes already shipped and re-implementing them beside themselves is the\n * bug class this repo keeps re-learning:\n *\n * - **org** — `orgs/{orgId}.suspendedAt` (AGL-202/210), extended here with\n * `suspendedReasonCode` / `suspendedMessage` / `suspendedUntilMs`. Every\n * existing reader of `suspendedAt` keeps working unchanged.\n * - **host** — NEW staff-only `suspendedAt` (+ same extensions) on the host\n * doc. Deliberately NOT `host.maintenance`: that field is CLIENT-writable\n * (the customer's own maintenance switch, AGL-131) and must never double\n * as a staff security control the customer could simply switch off.\n * - **platform** — NEW `lockdowns/platform` doc (Admin-SDK read/write; the\n * collection is staff-only in rules).\n * - **user** — NEW `lockdowns/user--{uid}` doc carrying reason/notice,\n * alongside the existing Firebase Auth `disabled` flag + refresh-token\n * revocation which do the actual keying-out.\n *\n * The un-panic invariant lives in the SERVER verdict helper\n * (libs/tenant/data/admin lockdown.ts): a verified `staff` claim bypasses\n * every scope, always — a platform lockdown must never lock out the staff\n * who can lift it. Nothing in this module may be given a way to override\n * that.\n */\n\n// Leaf imports only. `host-naming` imports nothing at all, so the DOMAIN\n// scope's apex check cannot introduce a cycle here.\nimport { TENANT_APEX } from './host-naming'\nimport { operatorContactLine } from './operator-identity'\n// Also a leaf — `platform-brand` imports nothing — so the same no-cycle\n// argument as `host-naming` above covers it. Staff-surface copy must read\n// the CONFIGURED brand: a self-host operator cannot edit source to rename\n// the product (AGL-2153), and \"Aglyn cannot reach the database\" is a\n// sentence their operators would be shown about their own install.\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n// The plugin-declared levers (AGL-2940). `plugin-entitlements` reaches only\n// the plugin catalog and the permission registry, neither of which imports\n// anything, so the leaf argument above still holds.\nimport {\n listPluginLockdownFeatures,\n type PluginLockdownFeatureDeclaration,\n} from '../plugin-manager/plugin-entitlements'\n\nexport type LockdownScope =\n | 'platform'\n | 'org'\n | 'host'\n | 'domain'\n | 'user'\n | 'feature'\n\n/**\n * FEATURE scope (AGL-1510): kill one capability platform-wide while\n * everything else keeps serving. The launch set maps one-to-one onto the\n * incident shapes the issue names — bot wave → `signups`, malware report →\n * `uploads`, billing bug → `checkout`, malicious listing →\n * `marketplace-installs`. A plugin that can be switched off in an incident\n * declares its own lever through `registerPluginEntitlements` (AGL-2940):\n * the label, the staff-bypass rule, the visitor notice and the API paths it\n * gates all arrive with the declaration, and the staff checklist, the\n * writer and the dispatcher read the two sets as one.\n *\n * Precedence COMPOSES rather than ranks: a platform lock implies every\n * feature (the feature verdict helpers check platform first), while a\n * feature lock implies nothing about the platform/org/host/user scopes —\n * feature states never enter `resolveLockdown`.\n */\nexport type CoreLockdownFeatureKey =\n | 'signups'\n | 'uploads'\n | 'checkout'\n | 'marketplace-installs'\n\n/**\n * A lever's wire identity: one of the core four, or a key a plugin\n * declared. A string rather than a union because the set is only complete\n * once every declaring plugin has registered, which no type can know.\n */\nexport type LockdownFeatureKey = CoreLockdownFeatureKey | (string & {})\n\n/**\n * The un-panic invariant, per feature (AGL-1510). At the PLATFORM scope a\n * verified staff claim bypasses unconditionally — that is unchanged and not\n * negotiable. A FEATURE lock is narrower, so the bypass is granted only\n * where it aids incident response, and withheld where a staff action would\n * be the very thing the lock exists to stop:\n *\n * - `uploads: true` — the uploads lock answers a malware report, and the\n * staff member responding needs to upload a test asset to verify the fix\n * before lifting the lock for everyone.\n * - `marketplace-installs: true` — same shape: a malicious listing slipped\n * review, and reproducing the install is part of investigating it.\n * - `checkout: false` — a checkout lock answers a billing/Stripe bug, and a\n * staff-created checkout session is still a real charge against a real\n * card. There is no incident-response step that needs money to move;\n * verification belongs in Stripe test mode.\n * - `signups: false` — an account being created has no staff claim yet, so\n * a bypass here could never fire honestly; declaring `false` states that\n * rather than leaving a bypass that only a misattributed claim could use.\n *\n * A plugin's lever carries its own answer on its declaration.\n *\n * Each notice says what is paused AND what still works — a feature lock's\n * whole point is that everything else keeps serving, and the copy must not\n * let a narrow pause read as a wider outage. The checkout notice in\n * particular must NEVER read as a payment failure: \"your card was declined\"\n * and \"we turned checkout off\" are different sentences, and only one of them\n * sends a customer to their bank.\n */\nconst CORE_LOCKDOWN_FEATURES: readonly PluginLockdownFeatureDeclaration[] = [\n {\n key: 'signups',\n label: 'New signups',\n customerName: 'new signups',\n staffBypass: false,\n notice: {\n title: 'New signups are paused',\n body: 'New signups are temporarily paused. Existing accounts can sign in and work as usual.',\n },\n },\n {\n key: 'uploads',\n label: 'Media uploads',\n customerName: 'media uploads',\n staffBypass: true,\n notice: {\n title: 'Uploads are paused',\n body: 'Media uploads are temporarily disabled while we address an issue. Your existing media and published sites are unaffected.',\n },\n },\n {\n key: 'checkout',\n label: 'Checkout (new subscriptions)',\n // Both doors it closes start a purchase: a plan or add-on, and a paid\n // marketplace install.\n customerName: 'new purchases',\n staffBypass: false,\n notice: {\n title: 'Checkout is temporarily unavailable',\n body: 'Checkout is temporarily unavailable — this is not a payment failure, and your account, subscription, and sites are unaffected. Please try again shortly.',\n },\n // `marketplace/checkout` creates NEW Stripe checkout sessions exactly\n // like the billing route (AGL-1545); it is also the front door of a paid\n // install, so it carries BOTH keys and each keeps its own bypass rule.\n apiPaths: { exact: ['marketplace/checkout'] },\n },\n {\n key: 'marketplace-installs',\n label: 'Marketplace installs',\n customerName: 'marketplace installs',\n staffBypass: true,\n notice: {\n title: 'Marketplace installs are paused',\n body: 'Installing from the marketplace is temporarily disabled. Everything already installed keeps working.',\n },\n // Installs-as-a-class, every artifact kind. `marketplace/update-artifact`\n // re-copies a publisher's version into the org, which is an install by\n // another name and the same vector a malicious listing would ride.\n // Publish/review/report paths map to nothing: a marketplace incident\n // must not stop publishers reporting or staff reviewing.\n apiPaths: {\n exact: ['marketplace/checkout', 'marketplace/install', 'marketplace/update-artifact'],\n prefixes: ['marketplace/install-'],\n },\n },\n]\n\n/**\n * Every lever, core first and then the plugin declarations in catalog\n * order — the list the staff checklist renders and the writer validates\n * against. Read live rather than snapshotted: plugin declarations arrive\n * at module scope, which can run after any constant here was evaluated.\n */\nexport function listLockdownFeatures(): PluginLockdownFeatureDeclaration[] {\n return [...CORE_LOCKDOWN_FEATURES, ...listPluginLockdownFeatures()]\n}\n\n/** The keys of {@link listLockdownFeatures}, in the same order. */\nexport function listLockdownFeatureKeys(): LockdownFeatureKey[] {\n return listLockdownFeatures().map((feature) => feature.key)\n}\n\n/** One lever's declaration — core or plugin — or `undefined`. */\nexport function lockdownFeatureDeclaration(\n key: unknown,\n): PluginLockdownFeatureDeclaration | undefined {\n if (typeof key !== 'string') return undefined\n return listLockdownFeatures().find((feature) => feature.key === key)\n}\n\nexport function isLockdownFeatureKey(\n value: unknown,\n): value is LockdownFeatureKey {\n return lockdownFeatureDeclaration(value) !== undefined\n}\n\n/** Staff-surface label; the key stays the wire/API identity. */\nexport function lockdownFeatureLabel(key: LockdownFeatureKey): string {\n return lockdownFeatureDeclaration(key)?.label ?? key\n}\n\n/**\n * What a customer's mail calls a lever (`media uploads`), never the staff\n * label. A key nothing declares, or a declaration without a name, reads as\n * \"a feature\" rather than leak a key or a staff label.\n */\nexport function lockdownFeatureCustomerName(key: LockdownFeatureKey): string {\n return lockdownFeatureDeclaration(key)?.customerName?.trim() || 'a feature'\n}\n\n/**\n * Several levers named together in a customer's mail: `AI assist and AI\n * generation`. One pause of several levers is one notice.\n */\nexport function lockdownFeaturesCustomerText(keys: readonly LockdownFeatureKey[]): string {\n const names = [...new Set(keys.map(lockdownFeatureCustomerName))]\n return new Intl.ListFormat('en-US', { style: 'long', type: 'conjunction' }).format(names)\n}\n\n/**\n * Whether a verified staff claim passes this feature's lock. A key nothing\n * declared answers `false`: an undeclared lever has no bypass argument on\n * record, and the safe reading of \"no argument\" is \"no bypass\".\n */\nexport function lockdownFeatureStaffBypass(key: LockdownFeatureKey): boolean {\n return lockdownFeatureDeclaration(key)?.staffBypass === true\n}\n\n/**\n * HOW HARD the lock bites (AGL-1511).\n *\n * - `full` — the shipped AGL-1501 behaviour: nothing serves. Sites 503,\n * sessions refuse, every API call gets the 423.\n * - `read-only` — reads keep serving, WRITES refuse. The right shape for the\n * most common maintenance need (a schema migration, a data repair, a\n * suspected-corruption investigation): the customer's site stays up and\n * earning, visitors browse normally, and nothing races the repair.\n *\n * ABSENT MEANS `full`. Every lockdown written before this field existed is a\n * full lock, and a lock whose strictness cannot be read must be treated as\n * the stricter one — so `full` is both the default and the fail-safe. The\n * writer stores `mode` only for read-only locks, which keeps every existing\n * carrier document byte-identical and needs no migration.\n */\nexport type LockdownMode = 'full' | 'read-only'\n\nconst LOCKDOWN_MODE_KEYS: Record<LockdownMode, true> = {\n full: true,\n 'read-only': true,\n}\nexport const LOCKDOWN_MODES = Object.keys(LOCKDOWN_MODE_KEYS) as LockdownMode[]\n\nexport function isLockdownMode(value: unknown): value is LockdownMode {\n return typeof value === 'string' && value in LOCKDOWN_MODE_KEYS\n}\n\n/** The mode of a state, with the absent-means-`full` default applied. */\nexport function lockdownMode(\n state: { mode?: LockdownMode } | null | undefined,\n): LockdownMode {\n return state?.mode === 'read-only' ? 'read-only' : 'full'\n}\n\nexport function isReadOnlyLockdown(\n state: { mode?: LockdownMode } | null | undefined,\n): boolean {\n return lockdownMode(state) === 'read-only'\n}\n\n/**\n * WHAT HAPPENS WHEN WE CANNOT READ THE LOCK (AGL-1621).\n *\n * Lockdown fails OPEN: if the carrier read throws, the verdict is \"not\n * locked\". That is the right posture for availability — a Firestore blip\n * must not weld every customer site shut — and the wrong one for a legal or\n * abuse takedown, where the whole point is that the content stops being\n * served and STAYS stopped. A DMCA notice is not satisfied by \"we served it\n * because our database was down\".\n *\n * So the two needs are split on their own axis rather than either one being\n * bent to fit the other:\n *\n * - `standard` — today's behaviour, unchanged. An unreadable lock is not\n * enforced. Maintenance windows, billing suspensions, precautionary\n * holds, incident response: everything whose cost of over-refusing is\n * higher than its cost of under-refusing.\n * - `takedown` — holds through an infrastructure failure. Legal orders,\n * abuse/CSAM removals, domain hijack disputes: the cost of serving is\n * higher than the cost of an outage on that one subject.\n *\n * ## Why this is its own field and not read off `scope` or `reason`\n *\n * NOT `scope`: every one of the six scopes carries both classes. A `host`\n * lock is a maintenance window on Tuesday and a court-ordered removal on\n * Wednesday; the scope says WHAT is locked, never why it must hold.\n *\n * NOT `reason`: `reason` is already spoken for as the VISITOR-FACING\n * classifier — it selects the notice copy in `lockdownNotice`. Deriving\n * fail-closed from `reason === 'security'` would both couple what the\n * public is told to how the system behaves under failure, and be exactly\n * the inference this field exists to avoid: most `security` locks are\n * precautionary holds during an investigation, and those must keep failing\n * open. A lock that becomes fail-closed because of a heuristic is a lock\n * that takes a site down for an unrelated reason.\n *\n * ABSENT MEANS `standard`, and that direction is the safety property. Every\n * lock written before this field existed, every lock an operator did not\n * classify, and every doc whose value this build cannot interpret is\n * fail-OPEN — the behaviour shipped today. Getting this backwards during a\n * Firestore incident is the platform-wide outage the split exists to\n * prevent, so only the exact string `takedown` opts in. Same posture and\n * same storage discipline as `mode` above: the writer stores the field only\n * for takedowns, so every existing carrier document stays byte-identical\n * and no migration is needed.\n */\nexport type LockdownEnforcement = 'standard' | 'takedown'\n\nconst LOCKDOWN_ENFORCEMENT_KEYS: Record<LockdownEnforcement, true> = {\n standard: true,\n takedown: true,\n}\nexport const LOCKDOWN_ENFORCEMENTS = Object.keys(\n LOCKDOWN_ENFORCEMENT_KEYS,\n) as LockdownEnforcement[]\n\nexport function isLockdownEnforcement(\n value: unknown,\n): value is LockdownEnforcement {\n return typeof value === 'string' && value in LOCKDOWN_ENFORCEMENT_KEYS\n}\n\n/** Staff-surface labels; the key stays the wire/API identity. */\nexport const LOCKDOWN_ENFORCEMENT_LABELS: Record<LockdownEnforcement, string> = {\n standard: `Standard — releases if ${PLATFORM_BRAND_NAME} cannot reach the database`,\n takedown: `Takedown — keeps holding if ${PLATFORM_BRAND_NAME} cannot reach the database`,\n}\n\n/**\n * The enforcement class of a state, with the absent-means-`standard`\n * default applied. Read through this, never bare — an unrecognised value\n * must land on the fail-open default rather than on a truthiness test that\n * would make any junk string fail closed.\n */\nexport function lockdownEnforcement(\n state: { enforcement?: LockdownEnforcement } | null | undefined,\n): LockdownEnforcement {\n return state?.enforcement === 'takedown' ? 'takedown' : 'standard'\n}\n\n/**\n * Is this the class that must survive an unreadable carrier? The ONE\n * predicate the fail-closed path reduces to, so \"what is a takedown\" has a\n * single definition rather than one per reader.\n */\nexport function isTakedownLockdown(\n state: { enforcement?: LockdownEnforcement } | null | undefined,\n): boolean {\n return lockdownEnforcement(state) === 'takedown'\n}\n\n/**\n * What a request is trying to DO, which is the only thing a read-only lock\n * discriminates on.\n *\n * `write` is the default everywhere it is not stated, and that direction is\n * deliberate: an enforcement point that forgot to declare its intent refuses\n * during a migration rather than letting an unaudited write through. The\n * cost of the safe default is an over-refused read; the cost of the unsafe\n * one is the corruption the whole mode exists to prevent.\n */\nexport type LockdownIntent = 'read' | 'write'\n\n/**\n * HTTP method → intent. The safe-method set from RFC 9110 minus TRACE (which\n * nothing here serves): a method not on this list mutates until proven\n * otherwise.\n *\n * A route whose POST is really a query (`where-used`, `plugin-impact`,\n * validators) states `intent: 'read'` explicitly rather than being guessed at\n * from a path.\n */\nexport function lockdownIntentForMethod(\n method: string | null | undefined,\n): LockdownIntent {\n const normalized = typeof method === 'string' ? method.toUpperCase() : ''\n return normalized === 'GET' || normalized === 'HEAD' || normalized === 'OPTIONS'\n ? 'read'\n : 'write'\n}\n\n/**\n * Does this state refuse a request of this intent? The ONE predicate every\n * chokepoint's discrimination reduces to — a full lock refuses everything, a\n * read-only lock refuses writes only.\n *\n * Note what is NOT here: the staff bypass. That lives above every read in the\n * server verdict helper and must not acquire a second, mode-shaped copy\n * — \"staff bypass unless…\" is how an un-panic invariant stops being one.\n */\nexport function lockdownBlocks(\n state: { mode?: LockdownMode } | null | undefined,\n intent: LockdownIntent,\n): boolean {\n if (!state) return false\n return lockdownMode(state) === 'full' || intent === 'write'\n}\n\nexport type LockdownReasonCode =\n | 'security'\n /**\n * Phishing, fraud or malicious content (AGL-3420). On an ACCOUNT it is a\n * permanent ban: the account is kept only so its address can never sign up\n * again, and after the lock notice it is sent no mail at all — see\n * {@link isAccountBanLockdownReason}. Everywhere a lock's strictness is\n * read from its reason it is at least as strict as `security`.\n */\n | 'abuse'\n | 'billing'\n | 'maintenance'\n | 'manual'\n\nconst LOCKDOWN_REASON_CODE_KEYS: Record<LockdownReasonCode, true> = {\n security: true,\n abuse: true,\n billing: true,\n maintenance: true,\n manual: true,\n}\nexport const LOCKDOWN_REASON_CODES = Object.keys(\n LOCKDOWN_REASON_CODE_KEYS,\n) as LockdownReasonCode[]\n\nexport function isLockdownReasonCode(\n value: unknown,\n): value is LockdownReasonCode {\n return (\n typeof value === 'string' && value in LOCKDOWN_REASON_CODE_KEYS\n )\n}\n\n/** Staff-surface labels; the key stays the wire/API identity. */\nexport const LOCKDOWN_REASON_LABELS: Record<LockdownReasonCode, string> = {\n security: 'Security — investigating a concern',\n abuse: 'Abuse — phishing, fraud or malicious content (permanent ban)',\n billing: 'Billing — unresolved payment',\n maintenance: 'Maintenance',\n manual: 'Manual',\n}\n\n/**\n * The reasons that mean \"a threat, act now\": `security`, and `abuse`, which\n * is a confirmed one. Every rule that is stricter for a security lock — who\n * is signed out, which bytes stop serving, which tokens rotate — reads it\n * through here, so a ban is never the milder of the two.\n */\nexport function isSecurityClassLockdownReason(reason: unknown): boolean {\n return reason === 'security' || reason === 'abuse'\n}\n\n/**\n * A lock with this reason, on an account, is a permanent ban (AGL-3420):\n * after its notice the account is sent nothing, by any sender.\n */\nexport function isAccountBanLockdownReason(reason: unknown): boolean {\n return reason === 'abuse'\n}\n\n/** The one shape every enforcement point consumes. */\nexport interface LockdownState {\n scope: LockdownScope\n /** Set only when `scope === 'feature'` — which capability is locked. */\n feature?: LockdownFeatureKey\n /**\n * Set only on a feature lock placed for ONE workspace (AGL-2927): the\n * org it pauses the capability for. Absent on the platform-wide feature\n * lock, which implies every workspace.\n */\n orgId?: string\n /** Absent = `full` (AGL-1511). Read through `lockdownMode`, never bare. */\n mode?: LockdownMode\n /**\n * Absent = `standard`, i.e. fail-open (AGL-1621). Read through\n * `lockdownEnforcement`/`isTakedownLockdown`, never bare.\n */\n enforcement?: LockdownEnforcement\n reason: LockdownReasonCode\n /**\n * Visitor/user-facing notice text (bounded at write time). Anything staff\n * types here is SHOWN to locked-out users — internal rationale belongs in\n * the audit row, not this field.\n */\n message?: string\n atMs?: number\n /**\n * Optional expiry (maintenance windows end). Once `untilMs` passes the\n * lockdown is simply inactive — access restores with NO staff action and\n * no write.\n */\n untilMs?: number\n actorUid?: string\n}\n\n/**\n * `lockdowns/{id}` — the carrier for the two scopes that had none.\n * Doc ids are scope-encoded so a lookup is a single `get`:\n * `platform` and `user--{uid}`. Plain-number timestamps on purpose: the\n * collection is Admin-SDK-only and converter-free, so a partial write can\n * never run a converter that \"defaults\" a sibling field away (the\n * withConverter-on-partial-writes bug class).\n */\nexport const LOCKDOWNS_COLLECTION = 'lockdowns'\nexport const PLATFORM_LOCKDOWN_DOC_ID = 'platform'\nexport const userLockdownDocId = (uid: string): string => `user--${uid}`\n/** `feature--{key}` — same collection, same rules, same audited writer. */\nexport const featureLockdownDocId = (feature: LockdownFeatureKey): string =>\n `feature--${feature}`\n/**\n * `feature--{key}--org--{orgId}` — the same capability switched off for ONE\n * workspace (AGL-2927). The carrier the staff org page's AI pause writes:\n * a spend stop that touches neither the org's entitlements nor its plan, so\n * lifting it restores exactly what the customer bought. Read only by the\n * doors that carry that feature key and name the org, and never implied by\n * the platform-wide document's absence.\n */\nexport const orgFeatureLockdownDocId = (\n feature: LockdownFeatureKey,\n orgId: string,\n): string => `feature--${feature}--org--${orgId}`\n\n/**\n * `domain--{hostname}` — the DOMAIN scope's carrier (AGL-1513).\n *\n * ## Keyed on the hostname, deliberately not on the host id\n *\n * Every other narrow scope keys on the thing it locks: a user by uid, a host\n * by host id. A domain lock cannot, because the incidents it exists for are\n * exactly the ones where the domain moves. A hijacked or disputed domain gets\n * detached and re-attached — to another site, or to another org — and a lock\n * keyed on `hosts/{id}` would be left guarding whichever site the name has\n * since left. Keying on the NAME means the lock follows the name, survives\n * detach/re-attach, and can be placed on a domain that is not currently\n * attached to anything at all, which is the state a dispute is usually\n * resolved in.\n *\n * The hostname is lowercased and used verbatim; the caller is responsible for\n * having validated it as a hostname, and the writer does. Firestore document\n * ids may contain dots, so `example.com` needs no escaping — and the `domain--`\n * prefix keeps the key space disjoint from `user--`/`feature--` in the shared\n * collection.\n */\nexport const domainLockdownDocId = (hostname: string): string =>\n `domain--${hostname.trim().toLowerCase()}`\n\n/** A bare hostname: labels of a-z0-9/dash, at least two of them. */\nconst LOCKABLE_DOMAIN_PATTERN =\n /^(?!-)[a-z0-9-]{1,63}(\\.(?!-)[a-z0-9-]{1,63})+$/\n/** The DNS limit; also what keeps the document id bounded. */\nexport const LOCKABLE_DOMAIN_MAX = 253\n\n/**\n * Whether `value` is a name the DOMAIN scope will accept (AGL-1513).\n *\n * Shared by the writer and anything that builds the doc id, so \"what is a\n * lockable name\" has one definition. Two refusals matter:\n *\n * - **Not hostname-shaped.** The id is derived from this string, and it\n * arrives from a staff form; `isDocumentId`-style discipline starts by\n * not minting a document for junk in the first place.\n * - **Inside the platform apex.** `{sub}.aglyn.app` is OUR name, and the\n * tenant resolves that space by `subdomain` and never consults the domain\n * scope for it — so a lock placed there would write a document that no\n * reader ever looks at. Refusing is the difference between \"you cannot do\n * that\" and a control that silently does nothing, which is the worse of\n * the two by a distance. Taking a platform subdomain down is the HOST\n * scope's job.\n */\nexport function isLockableDomain(value: unknown): value is string {\n if (typeof value !== 'string') return false\n const domain = value.trim().toLowerCase()\n if (!domain || domain.length > LOCKABLE_DOMAIN_MAX) return false\n if (!LOCKABLE_DOMAIN_PATTERN.test(domain)) return false\n return domain !== TENANT_APEX && !domain.endsWith(`.${TENANT_APEX}`)\n}\n\nexport interface LockdownDoc {\n scope: LockdownScope\n /** Present on `feature--{key}` docs only. */\n feature?: LockdownFeatureKey\n /** Present on `feature--{key}--org--{orgId}` docs only (AGL-2927). */\n orgId?: string\n /** Written only for read-only locks; absent = `full`. */\n mode?: LockdownMode\n /** Written only for takedowns; absent = `standard` (fail open). */\n enforcement?: LockdownEnforcement\n reason: LockdownReasonCode\n message?: string\n atMs?: number\n untilMs?: number\n actorUid?: string\n}\n\n/** Staff-typed notice text is user-facing; keep it bounded and plain. */\nexport const LOCKDOWN_MESSAGE_MAX = 500\n\n/**\n * Tolerant epoch-ms reader: Firestore `Timestamp`, `{ seconds }` JSON, a\n * number of ms, or an ISO string — the org carrier's `suspendedAt` arrives\n * in all of these shapes depending on which cache serialized it.\n */\nexport function toEpochMs(value: unknown): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return Number.isFinite(value) ? value : undefined\n if (typeof value === 'string') {\n const parsed = Date.parse(value)\n return Number.isNaN(parsed) ? undefined : parsed\n }\n if (typeof value === 'object') {\n const record = value as {\n toMillis?: () => number\n seconds?: number\n _seconds?: number\n }\n if (typeof record.toMillis === 'function') {\n try {\n return record.toMillis()\n } catch {\n return undefined\n }\n }\n const seconds = record.seconds ?? record._seconds\n if (typeof seconds === 'number' && Number.isFinite(seconds)) {\n return seconds * 1000\n }\n }\n return undefined\n}\n\n/**\n * Active now? Expiry passing deactivates without any write.\n *\n * Typed on the only field it reads rather than on `LockdownState`, so the\n * sibling levers built on the same field family — asset quarantine\n * (AGL-1512) — share this definition of \"expired\" instead of restating it.\n * A second copy is how two panic levers end up disagreeing about whether a\n * window that closed one millisecond ago is still in force.\n */\nexport function isLockdownActive(\n state: { untilMs?: number } | null | undefined,\n nowMs: number,\n): boolean {\n if (!state) return false\n if (typeof state.untilMs === 'number' && state.untilMs <= nowMs) return false\n return true\n}\n\n/**\n * ACCOUNT CREATION ITSELF (AGL-1531) — the decision a Firebase Auth\n * `beforeUserCreated` blocking function makes.\n *\n * The signups feature lock already refuses the SESSION (the mint in\n * /api/auth/session), the acceptance recorder and the signup-page doors, so\n * a wave's accounts are unusable. They are still CREATED: account creation\n * is client -> Firebase Auth and no Aglyn server sits in front of it. The\n * only thing that does is a blocking function, which is why this decision\n * exists as its own function rather than as another `isLockdownActive` call\n * at a route.\n *\n * ==== EVERYTHING BELOW, TO THE #endregion MARKER, IS COPIED VERBATIM INTO\n * ==== cloud/functions/src/signups-lock.ts.\n *\n * `cloud/functions` is a plain npm package outside the nx workspace — it can\n * import firebase-admin and firebase-functions and nothing else, so it\n * cannot import this library. The copy is therefore byte-for-byte and\n * apps/console/specs/signups-creation-lock-wiring.spec.ts fails if the two\n * regions ever differ by a character. That makes the tests below tests of\n * the code that actually deploys, rather than of a lookalike.\n *\n * The region imports nothing, for that reason.\n */\n// #region signups-creation-lock\n/**\n * FAIL OPEN WHEN THE LOCK CANNOT BE READ; KEEP HOLDING ONE ALREADY SEEN.\n *\n * This repo's postures are deliberately not uniform — rate limiting fails\n * soft, CSRF fails closed, and `getFeatureLockdown` fails OPEN because an\n * unreachable Firestore is an outage rather than a feature lockdown. This\n * gate sits with the last of those, and the split below is what makes that\n * safe rather than merely convenient.\n *\n * ## Not knowing is answered by admitting\n *\n * An unreadable lock means the platform does not KNOW whether staff pulled\n * the lever. Refusing on \"do not know\" is not a cautious answer here, it is\n * a total one: this is the only thing standing in front of Firebase Auth\n * account creation, so one refusal turns away every stranger at once, with a\n * generic error they cannot act on and no reason to come back. And the\n * condition that produces it is ordinary rather than exceptional — where\n * signup traffic is light the instance is cold for nearly every attempt, so\n * \"the first Firestore read costs more than the budget\" is the common case,\n * not the rare one.\n *\n * The error in the other direction is bounded. An account created while\n * Firestore is unreadable cannot finish signing up anyway — the profile, the\n * legal-acceptance record and the workspace all live in Firestore — so\n * admitting costs some orphan Auth records for the length of the read\n * outage, and those are enumerable and removable afterwards. One side's\n * mistake is a handful of empty records; the other side's is the whole\n * funnel, silently, for as long as reads are slow.\n *\n * ## A lever actually seen is a fact, and it survives\n *\n * Every read that COMPLETES is recorded below, and a read that then fails is\n * answered from that record instead of from nothing. So the objection\n * fail-open usually earns — a bot wave makes reads fail and thereby releases\n * the brake aimed at the wave — needs the wave to land on an instance that\n * has never once read the lock; every instance that has keeps refusing for\n * the whole outage. Lifting the lever is itself a Firestore write, so\n * nothing can lift it during that outage either, and a lock's own `untilMs`\n * is still honored against the clock, so a dead-man expiry cannot become\n * un-liftable by being remembered.\n *\n * This is STRICTER than the tenant takedown ledger (AGL-1621), which\n * remembers only locks classified `takedown` and lets a `standard` one\n * release during an outage. That trade is right where it sits, because\n * holding a remembered lock there keeps every visitor off a customer's whole\n * site. Holding one here refuses new signups, which is the cheap direction,\n * so no `enforcement` field is consulted at all and any active lock is\n * remembered.\n *\n * ## What it does not do, stated rather than implied\n *\n * An instance that has never completed a read has nothing to remember, so a\n * lever pulled DURING a total Firestore outage does not reach a cold one.\n * Closing that gap needs a carrier more available than Firestore, which is a\n * different and much larger change; pretending otherwise here would be worse\n * than naming it. The escape hatch that needs no deploy is unchanged:\n * unregister the `beforeCreate` trigger in Identity Platform, which the\n * staff lockdown page reports the state of.\n */\nexport type SignupsCreationVerdict =\n /**\n * `unreadable` marks an admission made BLIND: the read did not complete\n * and nothing this instance remembers said the lever was pulled. Carried\n * so the caller can log it — the two admissions are the same outcome for\n * the person signing up and a very different one for an operator.\n */\n | { refused: false; unreadable?: true }\n /**\n * `locked` = a read completed and found the lever pulled. `held` = a read\n * failed and an earlier one had found it pulled.\n */\n | { refused: true; cause: 'locked' | 'held' }\n\n/**\n * How long the blocking function waits on the lock read before deciding\n * without it.\n *\n * Blocking functions sit on the account-creation critical path and Identity\n * Platform gives them a bounded window, so an unbounded `get()` would hand\n * the decision to the platform's own timeout — which refuses, after burning\n * the whole budget and with no log saying why. Bounding it keeps the verdict\n * here, where it can be reasoned about and recorded.\n */\nexport const SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS = 2_500\n\n/**\n * What the last COMPLETED read saw, for the life of this instance.\n * `undefined` means no read has completed here yet — the state a cold\n * instance starts in, and the only one that cannot hold a lock.\n *\n * Not a cache: it is never consulted in place of a read, only in place of\n * one that failed, so it can never make this gate slower to notice a lift\n * than the lift's own write.\n */\nlet lastReadSignupsLock: { untilMs?: number } | null | undefined\n\n/**\n * Forget it. Not part of any panic path — this exists for process\n * boundaries and for tests, where one case's lock would otherwise become the\n * next case's refusal.\n */\nexport function resetSignupsLockMemory(): void {\n lastReadSignupsLock = undefined\n}\n\n/**\n * Is this state an engaged lock at `nowMs`?\n *\n * Deliberately stricter than `normalizeLockdownDoc`, which refuses to\n * interpret a malformed doc and so reports NOT LOCKED. Here the document's\n * existence is the lever: the only writer is the audited staff route, so a\n * doc that exists at all means someone pulled it, and a field this build\n * cannot parse must never un-pull it. An expiry that has passed deactivates\n * with no write, matching `isLockdownActive` exactly (the sibling test pins\n * that equivalence).\n */\nfunction signupsLockEngaged(\n state: { untilMs?: number } | null | undefined,\n nowMs: number,\n): boolean {\n if (state === null || state === undefined) return false\n return !(typeof state.untilMs === 'number' && state.untilMs <= nowMs)\n}\n\n/**\n * Refuse this account creation?\n *\n * `readLock` returns the `lockdowns/feature--signups` document, or null when\n * it does not exist. It is injected rather than imported so this decision —\n * including its fail-open and timeout behavior — is testable without a\n * Firestore, and so the same characters run in the function and in the test.\n *\n * NOT parameterized by provider, by email, or by tenant. Email/password,\n * Google and SSO all reach Firebase Auth account creation, and a lock that\n * discriminated between them would be a lock on one gate of three. The\n * caller passes no identity at all, so no future edit can quietly add a\n * carve-out here.\n */\nexport async function signupsCreationVerdict(\n readLock: () => Promise<{ untilMs?: number } | null | undefined>,\n nowMs: number,\n timeoutMs: number = SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS,\n): Promise<SignupsCreationVerdict> {\n let timer: ReturnType<typeof setTimeout> | undefined\n let state: { untilMs?: number } | null | undefined\n try {\n state = await Promise.race([\n readLock(),\n new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () => reject(new Error('signups lock read timed out')),\n timeoutMs,\n )\n }),\n ])\n } catch {\n // Nothing was learned, so the only thing that can refuse now is what an\n // earlier read established. A remembered lock whose window has closed is\n // dropped here rather than re-examined on every later failure.\n if (signupsLockEngaged(lastReadSignupsLock, nowMs)) {\n return { refused: true, cause: 'held' }\n }\n lastReadSignupsLock = undefined\n return { refused: false, unreadable: true }\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n // A completed read is the whole truth, \"no document\" included: it decides\n // this account AND replaces whatever was remembered, which is how a lift\n // takes effect.\n lastReadSignupsLock = state ?? null\n return signupsLockEngaged(state, nowMs)\n ? { refused: true, cause: 'locked' }\n : { refused: false }\n}\n\n// #endregion signups-creation-lock\n\n/**\n * Precedence: platform > org > host > domain > user. The widest active scope wins so\n * the notice a visitor sees names the real cause (a platform maintenance\n * window should not read as \"this account is suspended\").\n *\n * STRICTNESS OUTRANKS WIDTH (AGL-1511). Once locks have a mode, \"widest\n * wins\" alone is a security hole: a platform-wide read-only maintenance\n * window would outrank — and therefore SOFTEN — a full security takedown on\n * one org, quietly readmitting every visitor to the site staff just took\n * down. So an active `full` lock is chosen first (in scope order among the\n * full ones), and the widest read-only lock only wins when no full lock is\n * active at all. A lock can never be relaxed by a wider, gentler one.\n */\nexport function resolveLockdown(\n states: {\n platform?: LockdownState | null\n org?: LockdownState | null\n host?: LockdownState | null\n /**\n * AGL-1513. Narrower than `host`: it locks ONE attached name while the\n * same site keeps serving on its other addresses, so it must never\n * outrank a host takedown. It sits above `user` for the same reason\n * `host` does — it is a property of the site being addressed, not of\n * whoever is looking at it.\n */\n domain?: LockdownState | null\n user?: LockdownState | null\n },\n nowMs: number,\n): LockdownState | null {\n const active = [\n states.platform,\n states.org,\n states.host,\n states.domain,\n states.user,\n ].filter(\n (state): state is LockdownState => Boolean(state) && isLockdownActive(state, nowMs),\n )\n return active.find((state) => lockdownMode(state) === 'full') ?? active[0] ?? null\n}\n\n/**\n * Org carrier → state. `suspendedAt` alone (every pre-lockdown suspension)\n * normalizes to `manual` with no public message — the legacy free-text\n * `suspendedReason` was written for staff eyes and must not leak into the\n * visitor notice.\n */\nexport function normalizeOrgLockdown(\n org:\n | {\n suspendedAt?: unknown\n suspendedReasonCode?: unknown\n suspendedMessage?: unknown\n suspendedUntilMs?: unknown\n suspendedMode?: unknown\n suspendedEnforcement?: unknown\n }\n | null\n | undefined,\n): LockdownState | null {\n if (!org || org.suspendedAt == null) return null\n return {\n scope: 'org',\n // An unrecognised value normalizes to `full`, matching the absent case:\n // a strictness this build cannot interpret must not read as the softer\n // one (an older deploy meeting a mode it has never heard of).\n ...(org.suspendedMode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // Only the exact string opts into fail-closed; anything else — absent,\n // malformed, or a class a newer deploy invented — stays `standard` and\n // keeps failing open (AGL-1621).\n ...(org.suspendedEnforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: isLockdownReasonCode(org.suspendedReasonCode)\n ? org.suspendedReasonCode\n : 'manual',\n message:\n typeof org.suspendedMessage === 'string' && org.suspendedMessage\n ? org.suspendedMessage.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: toEpochMs(org.suspendedAt),\n untilMs:\n typeof org.suspendedUntilMs === 'number' &&\n Number.isFinite(org.suspendedUntilMs)\n ? org.suspendedUntilMs\n : undefined,\n }\n}\n\n/**\n * Host carrier → state. Same field family as the org, on the host doc.\n * `host.maintenance` is NOT consulted here — that is the customer's own\n * switch and keeps its shipped AGL-131 path.\n */\nexport function normalizeHostLockdown(\n host:\n | {\n suspendedAt?: unknown\n suspendedReasonCode?: unknown\n suspendedMessage?: unknown\n suspendedUntilMs?: unknown\n suspendedMode?: unknown\n suspendedEnforcement?: unknown\n }\n | null\n | undefined,\n): LockdownState | null {\n if (!host || host.suspendedAt == null) return null\n return {\n scope: 'host',\n ...(host.suspendedMode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // As the org carrier: exact string only, everything else fails open.\n ...(host.suspendedEnforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: isLockdownReasonCode(host.suspendedReasonCode)\n ? host.suspendedReasonCode\n : 'manual',\n message:\n typeof host.suspendedMessage === 'string' && host.suspendedMessage\n ? host.suspendedMessage.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: toEpochMs(host.suspendedAt),\n untilMs:\n typeof host.suspendedUntilMs === 'number' &&\n Number.isFinite(host.suspendedUntilMs)\n ? host.suspendedUntilMs\n : undefined,\n }\n}\n\n/**\n * The active org/host lock on one site, from documents the caller already\n * holds, or null (AGL-3356).\n *\n * For a sender deep in a plugin — the campaign core, the workflow engine,\n * the outreach runtime — that has the org and host documents in hand and\n * must refuse to mail for a suspended workspace. The server verdict\n * (`getSiteLockdown`) adds the platform scope and the takedown ledger on\n * top; this is the pure part both share, so neither restates what an\n * org/host lock is. Unlike the verdict it reads nothing and cannot fail, so\n * a caller that could not read the documents has already failed on its own.\n *\n * Returns the state whatever its mode; a caller that lets a read-only\n * maintenance window through asks `lockdownMode(state)`.\n */\nexport function siteLockdownFromDocs(\n docs: {\n org?: Parameters<typeof normalizeOrgLockdown>[0]\n host?: Parameters<typeof normalizeHostLockdown>[0]\n },\n nowMs: number,\n): LockdownState | null {\n const state = resolveLockdown(\n {\n org: normalizeOrgLockdown(docs.org),\n host: normalizeHostLockdown(docs.host),\n },\n nowMs,\n )\n return isLockdownActive(state, nowMs) ? state : null\n}\n\n/** `lockdowns/{id}` doc → state; refuses malformed docs rather than guess. */\nexport function normalizeLockdownDoc(\n doc: Partial<LockdownDoc> | null | undefined,\n scope: LockdownScope,\n): LockdownState | null {\n if (!doc) return null\n if (!isLockdownReasonCode(doc.reason)) return null\n // A feature doc whose key is not (or no longer) in the enum is refused\n // whole, matching the malformed-reason posture: the panic path does not\n // guess, and an unknown key has no chokepoint to enforce it anyway.\n if (scope === 'feature' && !isLockdownFeatureKey(doc.feature)) return null\n return {\n scope,\n ...(scope === 'feature' && isLockdownFeatureKey(doc.feature)\n ? { feature: doc.feature }\n : {}),\n ...(scope === 'feature' && typeof doc.orgId === 'string' && doc.orgId\n ? { orgId: doc.orgId }\n : {}),\n // Same posture as the org/host carriers: only the exact string relaxes\n // the lock. A malformed or unknown `mode` leaves it full.\n ...(doc.mode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // As the org/host carriers: exact string only. A doc whose enforcement\n // class this build cannot interpret is a STANDARD lock and fails open,\n // never the reverse (AGL-1621).\n ...(doc.enforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: doc.reason,\n message:\n typeof doc.message === 'string' && doc.message\n ? doc.message.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: typeof doc.atMs === 'number' ? doc.atMs : undefined,\n untilMs:\n typeof doc.untilMs === 'number' && Number.isFinite(doc.untilMs)\n ? doc.untilMs\n : undefined,\n actorUid: typeof doc.actorUid === 'string' ? doc.actorUid : undefined,\n }\n}\n\n/**\n * `Retry-After` seconds for a 503, when the lockdown has a known end.\n * Clamped to at least 60 so a window expiring mid-request cannot emit 0.\n */\nexport function lockdownRetryAfterSeconds(\n state: LockdownState,\n nowMs: number,\n): number | undefined {\n if (typeof state.untilMs !== 'number') return undefined\n return Math.max(60, Math.ceil((state.untilMs - nowMs) / 1000))\n}\n\nexport interface LockdownNotice {\n title: string\n body: string\n /** Shown as the action line; undefined = no contact line (maintenance). */\n contact?: string\n /**\n * The lock is a decision the account holder may appeal — a `security` or\n * `abuse` lock (AGL-3420) — so the contact line offers the appeal rather\n * than taking questions. Set by reason, never by staff's message, so a\n * custom message cannot remove the way to appeal.\n */\n appeal?: boolean\n}\n\n/**\n * The address on the visitor-facing 503, from operator configuration\n * (AGL-2016).\n *\n * Was a `'support@aglyn.com'` literal read by eleven call sites below. A\n * self-hosted site that went into lockdown therefore told *its* visitors to\n * write to **Aglyn** about an account Aglyn has no record of and cannot\n * restore. Unlike the abuse form this is not a legal misroute, but it is the\n * same class of wrong answer: the only party who can lift the lock is the\n * operator who set it.\n *\n * Returns `null` when unconfigured, which the notice treats as \"no contact\n * line\" — the same shape `maintenance` already uses. A lockdown notice with a\n * dangling \"contact \" and nothing after it would be worse than one that\n * simply does not offer a channel the deployment does not have.\n */\nexport function lockdownSupportEmail(): string | null {\n return operatorContactLine('support').address\n}\n\n/**\n * Per-reason visitor copy — plain and non-alarming. A staff-typed `message`\n * replaces the body; the title and contact line stay per-reason so staff\n * cannot accidentally strip the \"how do I get out of this\" affordance.\n */\nexport function lockdownNotice(state: LockdownState): LockdownNotice {\n const custom =\n typeof state.message === 'string' && state.message.trim()\n ? state.message.trim()\n : undefined\n // Feature locks carry feature-specific, honest copy (AGL-1510): what is\n // off, and — just as important — what is NOT affected. Same convention as\n // the per-reason copy below: a staff message replaces the body only.\n if (state.scope === 'feature' && state.feature) {\n return featureLockdownNotice(state.feature, custom, state.untilMs)\n }\n // A read-only lock refused a WRITE, and the full-lock copy would lie about\n // it: \"Access is temporarily disabled\" is false on a site the reader is\n // currently looking at (AGL-1511).\n if (isReadOnlyLockdown(state)) {\n return readOnlyLockdownNotice(state, custom)\n }\n // DOMAIN scope (AGL-1513). The site is fine and is still serving on its\n // other addresses; this NAME is the thing that is not, and the copy says\n // that much and no more.\n if (state.scope === 'domain') {\n return domainLockdownNotice(state, custom)\n }\n // A SITE lock closes one site, not the account that owns it: its default\n // copy says \"this site\", or the owners' notice tells them their account was\n // closed when their other sites are serving (AGL-3432).\n const subject = state.scope === 'host' ? 'site' : 'account'\n switch (state.reason) {\n case 'maintenance': {\n const until =\n typeof state.untilMs === 'number'\n ? new Date(state.untilMs).toUTCString()\n : undefined\n return {\n title: 'Down for maintenance',\n body:\n custom ??\n (until\n ? `Scheduled maintenance is in progress. Expected back by ${until}.`\n : 'Scheduled maintenance is in progress. Please check back shortly.'),\n }\n }\n case 'billing':\n return {\n title: 'Account on hold',\n body:\n custom ??\n `This ${subject} is on hold over an unresolved billing issue. ` +\n 'Updating the payment method in workspace billing settings ' +\n 'restores access.',\n contact: lockdownSupportEmail() ?? undefined,\n }\n case 'security':\n return {\n title: 'Temporarily unavailable',\n body:\n custom ??\n 'Access is temporarily disabled while we investigate a security ' +\n 'concern.',\n contact: lockdownSupportEmail() ?? undefined,\n // A platform-wide lock is ours, with nobody to appeal it.\n ...(state.scope !== 'platform' ? { appeal: true } : {}),\n }\n case 'abuse':\n return {\n title: 'Unavailable',\n body:\n custom ??\n `This ${subject} has been closed for a violation of our Terms of ` +\n 'Service.',\n contact: lockdownSupportEmail() ?? undefined,\n // A platform-wide lock is ours, with nobody to appeal it.\n ...(state.scope !== 'platform' ? { appeal: true } : {}),\n }\n case 'manual':\n default:\n return {\n title: 'Temporarily unavailable',\n body: custom ?? `Access to this ${subject} is currently disabled.`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n }\n}\n\n/**\n * DOMAIN copy (AGL-1513), for a visitor who typed one particular name.\n *\n * ## It must never name the address that still works\n *\n * Every other scope's copy can afford to be helpful, because the thing being\n * withheld is the thing the reader is entitled to. This scope exists for\n * ownership disputes and hijacked or expired domains now pointing at us — so\n * the person reading this notice may be precisely the party the site is being\n * withheld from, and \"try acme.aglyn.app instead\" would lift the lock in one\n * sentence. None of these bodies names the platform subdomain, and none\n * should acquire one; the `*.aglyn.app` address is deliberately absent rather\n * than accidentally missing.\n *\n * ## It also does not say whose the name is\n *\n * A dispute is exactly the case where we do not know, and a notice is a\n * publication. \"Not currently serving\" is the whole of what we can assert\n * without taking a side in something a registrar or a court settles.\n */\nfunction domainLockdownNotice(\n state: LockdownState,\n custom: string | undefined,\n): LockdownNotice {\n const title = 'This address is unavailable'\n const contact = lockdownSupportEmail() ?? undefined\n switch (state.reason) {\n case 'maintenance':\n return {\n title,\n body:\n custom ??\n 'This web address is temporarily not serving while maintenance ' +\n 'is in progress.',\n contact,\n }\n case 'billing':\n return {\n title,\n body:\n custom ??\n 'This web address is not currently serving over an unresolved ' +\n 'billing issue.',\n contact,\n }\n case 'security':\n return {\n title,\n body:\n custom ??\n 'This web address is not currently serving while we investigate ' +\n 'a report about it.',\n contact,\n }\n case 'abuse':\n return {\n title,\n body:\n custom ??\n 'This web address is not serving because of a violation of our ' +\n 'Terms of Service.',\n contact,\n }\n case 'manual':\n default:\n return {\n title,\n body: custom ?? 'This web address is not currently serving.',\n contact,\n }\n }\n}\n\n/**\n * READ-ONLY copy (AGL-1511), for the account holder whose save just refused.\n *\n * The whole point of this mode is that the reader is still looking at their\n * work while being told they cannot change it, so every sentence has to hold\n * both halves at once: nothing is down, nothing is lost, one verb is paused.\n * The full-lock titles (\"Temporarily unavailable\", \"Account on hold\") are\n * flatly untrue here and would send someone to support over a fifteen-minute\n * migration.\n *\n * The `until` sentence is built with the SAME `lockdownUntilSuffix` helper\n * the feature copy uses, so `parseLockdownRefusal` strips and re-renders it\n * in the reader's local time here too rather than showing a UTC stamp.\n */\nfunction readOnlyLockdownNotice(\n state: LockdownState,\n custom: string | undefined,\n): LockdownNotice {\n const window =\n typeof state.untilMs === 'number'\n ? ` ${lockdownUntilSuffix(state.untilMs)}`\n : ''\n const title = 'Changes are temporarily paused'\n switch (state.reason) {\n case 'maintenance':\n return {\n title,\n body:\n custom ??\n `Saving changes is paused while we complete scheduled maintenance. ` +\n `Your sites keep serving and nothing you have created is ` +\n `affected — changes will save again shortly.${window}`,\n }\n case 'billing':\n return {\n title,\n body:\n custom ??\n `Saving changes is paused over an unresolved billing issue. Your ` +\n `sites keep serving and nothing has been deleted — updating the ` +\n `payment method in workspace billing settings restores ` +\n `editing.${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n case 'security':\n case 'manual':\n default:\n return {\n title,\n body:\n custom ??\n `Saving changes is paused while we work on something. Your sites ` +\n `keep serving and nothing you have created is affected — ` +\n `changes will save again shortly.${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n }\n}\n\n/**\n * The surfaces on a customer's LIVE SITE that a read-only lock refuses\n * (AGL-1511). These are the ones whose reader is a visitor, not our\n * customer — someone who has never heard of a workspace, a lockdown or a\n * maintenance window and is simply trying to buy something or send a\n * message.\n */\nexport type LockdownPausedSurface = 'form' | 'checkout' | 'cart' | 'generic'\n\n/**\n * VISITOR copy for a read-only refusal on a tenant site (AGL-1511).\n *\n * The issue's central product call: a customer's site staying up and earning\n * is the entire reason this mode exists instead of full lockdown, so a\n * visitor-facing write gets a polite inline pause, never a page-level 503.\n * That decision only pays off if the words match it — a visitor shown\n * \"Temporarily unavailable\" on a page that plainly loaded concludes the shop\n * is broken and leaves, which costs the customer the sale the mode was\n * protecting.\n *\n * So: no mention of maintenance windows, workspaces or accounts (none of\n * which are the visitor's), no support address (support is the SITE\n * owner's, not ours, and pointing a stranger at aglyn.com is worse than\n * silent), an explicit \"nothing you typed is lost\", and — for checkout — the\n * same hard rule the feature copy carries: this must never read as a\n * declined card. A staff-typed `message` is deliberately NOT honoured here;\n * it is written for the account holder and would land in front of strangers.\n */\nexport function lockdownPausedNotice(\n surface: LockdownPausedSurface,\n): LockdownNotice {\n switch (surface) {\n case 'form':\n return {\n title: 'Temporarily paused',\n body:\n 'This form is not accepting submissions for a few minutes. ' +\n 'Nothing you typed has been lost — please try again shortly.',\n }\n case 'checkout':\n return {\n title: 'Checkout is temporarily paused',\n body:\n 'Checkout is paused for a few minutes — this is not a payment ' +\n 'problem and you have not been charged. Your basket is safe; ' +\n 'please try again shortly.',\n }\n case 'cart':\n return {\n title: 'Temporarily paused',\n body:\n 'Basket changes are paused for a few minutes. Browsing works as ' +\n 'normal — please try again shortly.',\n }\n case 'generic':\n default:\n return {\n title: 'Temporarily paused',\n body:\n 'This action is paused for a few minutes. Browsing works as ' +\n 'normal — please try again shortly.',\n }\n }\n}\n\n/**\n * Per-feature visitor copy (AGL-1510), read off the lever's declaration —\n * core or plugin — so a plugin's lever explains itself in its own words. A\n * key nothing declared (a document written before its plugin was removed)\n * gets the generic pause: the lock is still honored, it just cannot name\n * what it pauses.\n */\nfunction featureLockdownNotice(\n feature: LockdownFeatureKey,\n custom: string | undefined,\n untilMs?: number,\n): LockdownNotice {\n const window =\n typeof untilMs === 'number'\n ? ` Expected back by ${new Date(untilMs).toUTCString()}.`\n : ''\n const declared = lockdownFeatureDeclaration(feature)\n return {\n title: declared?.notice.title ?? 'Temporarily paused',\n body:\n custom ??\n `${\n declared?.notice.body ??\n 'This action is paused for a few minutes. Browsing works as normal — please try again shortly.'\n }${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n}\n\n/**\n * The wire shape of the 423 refusal body (`lockdownJsonResponse`). Declared\n * here, beside the copy that fills it, so the client parser below and the\n * server writer agree by construction rather than by comment.\n */\nexport interface LockdownRefusalBody {\n error?: unknown\n scope?: unknown\n feature?: unknown\n mode?: unknown\n reason?: unknown\n title?: unknown\n message?: unknown\n contact?: unknown\n appeal?: unknown\n untilMs?: unknown\n}\n\n/** A 423 body, parsed into something a client surface can render. */\nexport interface LockdownRefusalNotice {\n title: string\n message: string\n contact?: string\n /** The contact line offers an appeal — see {@link LockdownNotice.appeal}. */\n appeal?: boolean\n scope?: LockdownScope\n feature?: LockdownFeatureKey\n /**\n * `read-only` when the server refused a WRITE but is still serving reads\n * (AGL-1511). Absent on every refusal from a full lock, and on any older\n * deploy — a client must treat absence as \"no claim\", never as \"full\".\n */\n mode?: LockdownMode\n untilMs?: number\n /**\n * The expiry as a human, LOCAL-time line — `undefined` when the lock has\n * no expiry, which is most of them. Never a raw epoch number.\n */\n until?: string\n}\n\n/**\n * The generic-but-honest fallback: what a client says when the server said\n * Locked but the body told it nothing else. It must still be TRUE — \"this is\n * paused\", never \"something went wrong\" (the failure this whole affordance\n * exists to stop), and never the word `undefined`.\n */\nexport const LOCKDOWN_REFUSAL_FALLBACK_TITLE = 'Temporarily unavailable'\nexport const LOCKDOWN_REFUSAL_FALLBACK_MESSAGE =\n 'This is temporarily paused while we work on something. Nothing you have ' +\n 'created is affected — please try again shortly.'\n\n/**\n * The exact sentence the notice builders append to a DEFAULT body when a\n * lock has an expiry. Built here so the client parser can strip it by exact\n * match (same `untilMs`, same string) instead of sniffing a regex.\n */\nfunction lockdownUntilSuffix(untilMs: number): string {\n return `Expected back by ${new Date(untilMs).toUTCString()}.`\n}\n\n/**\n * The expiry as a client-side, LOCAL-time line (AGL-1532). A UTC string is\n * correct and unreadable; a customer wants to know when to come back on\n * their own clock.\n */\nexport function formatLockdownUntil(untilMs: number): string | undefined {\n if (!Number.isFinite(untilMs)) return undefined\n const when = new Date(untilMs)\n if (Number.isNaN(when.getTime())) return undefined\n const stamp = when.toLocaleString(undefined, {\n dateStyle: 'medium',\n timeStyle: 'short',\n })\n return `Expected back around ${stamp}.`\n}\n\n/**\n * Parse a fetch response's status + parsed JSON body into a renderable\n * lockdown notice, or `null` when this was not a lockdown refusal\n * (AGL-1532).\n *\n * ONE parser, used by every client call site a feature lock can refuse —\n * billing checkout, marketplace installs and purchases, the AI-assist\n * drawer. Three copies of this parsing is the second-implementation shape\n * that lets one surface drift back to \"checkout failed\" while the others\n * stay honest.\n *\n * Three rules, each of which a spec pins:\n *\n * 1. **Only 423.** A 500 — a real, unexplained failure — returns `null` so\n * the caller keeps its generic error toast. Dressing a genuine fault as\n * a deliberate pause is a worse lie than the one being fixed.\n * 2. **A 423 always yields a notice.** The server said Locked; that is the\n * honest thing to render even if the body is malformed, truncated by a\n * proxy, or from an older deploy. Missing fields degrade to the shared\n * per-feature copy when the body names a known feature, and to the\n * generic-but-honest fallback otherwise.\n * 3. **No duplicated expiry.** The default server copy already ends with a\n * UTC \"Expected back by …\" sentence; that exact suffix is stripped and\n * restated as `until` in the reader's local time. A staff-typed custom\n * message never carries the suffix, so nothing is stripped from it.\n */\nexport function parseLockdownRefusal(\n status: number,\n body: unknown,\n): LockdownRefusalNotice | null {\n if (status !== 423) return null\n const payload: LockdownRefusalBody =\n body && typeof body === 'object' ? (body as LockdownRefusalBody) : {}\n const feature = isLockdownFeatureKey(payload.feature)\n ? payload.feature\n : undefined\n const untilMs =\n typeof payload.untilMs === 'number' && Number.isFinite(payload.untilMs)\n ? payload.untilMs\n : undefined\n // A body that named a feature but lost its copy still gets the RIGHT\n // words — the same ones the server would have sent — because that copy\n // lives here too. Only a body naming nothing falls all the way back.\n const derived = feature\n ? featureLockdownNotice(feature, undefined, untilMs)\n : undefined\n const title =\n typeof payload.title === 'string' && payload.title.trim()\n ? payload.title.trim()\n : (derived?.title ?? LOCKDOWN_REFUSAL_FALLBACK_TITLE)\n let message =\n typeof payload.message === 'string' && payload.message.trim()\n ? payload.message.trim()\n : (derived?.body ?? LOCKDOWN_REFUSAL_FALLBACK_MESSAGE)\n if (typeof untilMs === 'number') {\n const suffix = lockdownUntilSuffix(untilMs)\n if (message.endsWith(suffix)) {\n message = message.slice(0, -suffix.length).trim()\n }\n }\n const contact =\n typeof payload.contact === 'string' && payload.contact.trim()\n ? payload.contact.trim()\n : undefined\n const until =\n typeof untilMs === 'number' ? formatLockdownUntil(untilMs) : undefined\n return {\n title,\n message,\n ...(contact ? { contact } : {}),\n ...(contact && payload.appeal === true ? { appeal: true } : {}),\n ...(typeof payload.scope === 'string'\n ? { scope: payload.scope as LockdownScope }\n : {}),\n ...(feature ? { feature } : {}),\n ...(isLockdownMode(payload.mode) ? { mode: payload.mode } : {}),\n ...(typeof untilMs === 'number' ? { untilMs } : {}),\n ...(until ? { until } : {}),\n }\n}\n\n/**\n * The notice as ONE line, for surfaces whose only affordance is a snackbar\n * (AGL-1532). The title is prefixed only when the message does not already\n * open with it — the checkout copy leads with its own title, and \"Checkout\n * is temporarily unavailable — Checkout is temporarily unavailable — this is\n * not a payment failure\" helps nobody.\n */\nexport function lockdownRefusalText(notice: LockdownRefusalNotice): string {\n const lower = notice.message.toLowerCase()\n const led = lower.startsWith(notice.title.toLowerCase())\n const head = led ? notice.message : `${notice.title} — ${notice.message}`\n return notice.until ? `${head} ${notice.until}` : head\n}\n\n/**\n * Which VISITOR surface a tenant plugin-API path belongs to, for read-only\n * pause copy (AGL-1511).\n *\n * The tenant dispatcher refuses every mutating method under a read-only lock\n * regardless of path — this only chooses the WORDS, and it exists because\n * the checkout sentence has a requirement no generic copy can carry: it must\n * never read as a declined card. Anything unrecognised gets the neutral\n * generic pause rather than a guess; being vague at a stranger is cheap,\n * being wrong about their money is not.\n */\nexport function lockdownPausedSurfaceForPluginApiPath(\n path: string,\n): LockdownPausedSurface {\n if (\n path === 'commerce/checkout' ||\n path === 'commerce/cart-checkout' ||\n path === 'commerce/pos-order' ||\n path === 'commerce/pos-payment' ||\n path === 'commerce/draft-order'\n ) {\n return 'checkout'\n }\n if (path === 'commerce/cart' || path.startsWith('commerce/cart/')) return 'cart'\n return 'generic'\n}\n\n/**\n * Which feature keys gate a plugin-API dispatcher path (AGL-1510, plural\n * since AGL-1545). Lives here (pure, beside the catalog) so the dispatcher's\n * wiring is one call and the mapping is unit-testable without a route\n * harness.\n *\n * Every lever declares the paths it gates: exact paths, and prefixes matched\n * on a SEGMENT boundary, so `ai/generate` gates `ai/generate/section` and\n * never `ai/generated-report`. A path several levers name is gated by all\n * of them, each keeping its own staff-bypass rule when composed —\n * `marketplace/checkout` carries both `checkout` and `marketplace-installs`\n * (AGL-1545): it creates NEW Stripe checkout sessions exactly like the\n * billing route, and it is also the front door of a paid install, so a\n * malicious-listing incident must stop buyers PAYING for the artifact under\n * investigation, not merely refuse the install after the money moved.\n *\n * A plugin's lever gates its paths by existing (AGL-2903): a door\n * registered under a declared prefix is gated before anyone remembers to\n * wire it, which is the reason the mapping is declared beside the lever\n * rather than beside the route.\n */\nexport function lockdownFeaturesForPluginApiPath(\n path: string,\n): LockdownFeatureKey[] {\n const keys: LockdownFeatureKey[] = []\n for (const feature of listLockdownFeatures()) {\n const exact = feature.apiPaths?.exact ?? []\n const prefixes = feature.apiPaths?.prefixes ?? []\n const hit =\n exact.includes(path) ||\n prefixes.some(\n (prefix) =>\n path === prefix ||\n path.startsWith(prefix.endsWith('/') || prefix.endsWith('-') ? prefix : `${prefix}/`),\n )\n if (hit && !keys.includes(feature.key)) keys.push(feature.key)\n }\n return keys\n}\n"],"names":["TENANT_APEX","operatorContactLine","PLATFORM_BRAND_NAME","listPluginLockdownFeatures","CORE_LOCKDOWN_FEATURES","key","label","customerName","staffBypass","notice","title","body","apiPaths","exact","prefixes","listLockdownFeatures","listLockdownFeatureKeys","map","feature","lockdownFeatureDeclaration","undefined","find","isLockdownFeatureKey","value","lockdownFeatureLabel","lockdownFeatureCustomerName","trim","lockdownFeaturesCustomerText","keys","names","Set","Intl","ListFormat","style","type","format","lockdownFeatureStaffBypass","LOCKDOWN_MODE_KEYS","full","LOCKDOWN_MODES","Object","isLockdownMode","lockdownMode","state","mode","isReadOnlyLockdown","LOCKDOWN_ENFORCEMENT_KEYS","standard","takedown","LOCKDOWN_ENFORCEMENTS","isLockdownEnforcement","LOCKDOWN_ENFORCEMENT_LABELS","lockdownEnforcement","enforcement","isTakedownLockdown","lockdownIntentForMethod","method","normalized","toUpperCase","lockdownBlocks","intent","LOCKDOWN_REASON_CODE_KEYS","security","abuse","billing","maintenance","manual","LOCKDOWN_REASON_CODES","isLockdownReasonCode","LOCKDOWN_REASON_LABELS","isSecurityClassLockdownReason","reason","isAccountBanLockdownReason","LOCKDOWNS_COLLECTION","PLATFORM_LOCKDOWN_DOC_ID","userLockdownDocId","uid","featureLockdownDocId","orgFeatureLockdownDocId","orgId","domainLockdownDocId","hostname","toLowerCase","LOCKABLE_DOMAIN_PATTERN","LOCKABLE_DOMAIN_MAX","isLockableDomain","domain","length","test","endsWith","LOCKDOWN_MESSAGE_MAX","toEpochMs","Number","isFinite","parsed","Date","parse","isNaN","record","toMillis","seconds","_seconds","isLockdownActive","nowMs","untilMs","SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS","lastReadSignupsLock","resetSignupsLockMemory","signupsLockEngaged","signupsCreationVerdict","readLock","timeoutMs","timer","Promise","race","_resolve","reject","setTimeout","Error","refused","cause","unreadable","clearTimeout","resolveLockdown","states","active","platform","org","host","user","filter","Boolean","normalizeOrgLockdown","suspendedAt","scope","suspendedMode","suspendedEnforcement","suspendedReasonCode","message","suspendedMessage","slice","atMs","suspendedUntilMs","normalizeHostLockdown","siteLockdownFromDocs","docs","normalizeLockdownDoc","doc","actorUid","lockdownRetryAfterSeconds","Math","max","ceil","lockdownSupportEmail","address","lockdownNotice","custom","featureLockdownNotice","readOnlyLockdownNotice","domainLockdownNotice","subject","until","toUTCString","contact","appeal","window","lockdownUntilSuffix","lockdownPausedNotice","surface","declared","LOCKDOWN_REFUSAL_FALLBACK_TITLE","LOCKDOWN_REFUSAL_FALLBACK_MESSAGE","formatLockdownUntil","when","getTime","stamp","toLocaleString","dateStyle","timeStyle","parseLockdownRefusal","status","payload","derived","suffix","lockdownRefusalText","lower","led","startsWith","head","lockdownPausedSurfaceForPluginApiPath","path","lockdownFeaturesForPluginApiPath","hit","includes","some","prefix","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/lockdown.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 * Lockdown (AGL-1501): the panic button. ONE state shape and ONE resolver\n * across four scopes — platform, org, host, user — with precedence\n * platform > org > host > user.\n *\n * This module is deliberately pure: it normalizes the storage carriers into\n * `LockdownState` and answers \"is access locked, and what does the visitor\n * see\". Where the state LIVES differs per scope, because two of the four\n * scopes already shipped and re-implementing them beside themselves is the\n * bug class this repo keeps re-learning:\n *\n * - **org** — `orgs/{orgId}.suspendedAt` (AGL-202/210), extended here with\n * `suspendedReasonCode` / `suspendedMessage` / `suspendedUntilMs`. Every\n * existing reader of `suspendedAt` keeps working unchanged.\n * - **host** — NEW staff-only `suspendedAt` (+ same extensions) on the host\n * doc. Deliberately NOT `host.maintenance`: that field is CLIENT-writable\n * (the customer's own maintenance switch, AGL-131) and must never double\n * as a staff security control the customer could simply switch off.\n * - **platform** — NEW `lockdowns/platform` doc (Admin-SDK read/write; the\n * collection is staff-only in rules).\n * - **user** — NEW `lockdowns/user--{uid}` doc carrying reason/notice,\n * alongside the existing Firebase Auth `disabled` flag + refresh-token\n * revocation which do the actual keying-out.\n *\n * The un-panic invariant lives in the SERVER verdict helper\n * (libs/tenant/data/admin lockdown.ts): a verified `staff` claim bypasses\n * every scope, always — a platform lockdown must never lock out the staff\n * who can lift it. Nothing in this module may be given a way to override\n * that.\n */\n\n// Leaf imports only. `host-naming` imports nothing at all, so the DOMAIN\n// scope's apex check cannot introduce a cycle here.\nimport { TENANT_APEX } from './host-naming'\nimport { operatorContactLine } from './operator-identity'\n// Also a leaf — `platform-brand` imports nothing — so the same no-cycle\n// argument as `host-naming` above covers it. Staff-surface copy must read\n// the CONFIGURED brand: a self-host operator cannot edit source to rename\n// the product (AGL-2153), and \"Aglyn cannot reach the database\" is a\n// sentence their operators would be shown about their own install.\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n// The plugin-declared levers (AGL-2940). `plugin-entitlements` reaches only\n// the plugin catalog and the permission registry, neither of which imports\n// anything, so the leaf argument above still holds.\nimport {\n listPluginLockdownFeatures,\n type PluginLockdownFeatureDeclaration,\n} from '../plugin-manager/plugin-entitlements'\n\nexport type LockdownScope =\n | 'platform'\n | 'org'\n | 'host'\n | 'domain'\n | 'user'\n | 'feature'\n\n/**\n * FEATURE scope (AGL-1510): kill one capability platform-wide while\n * everything else keeps serving. The launch set maps one-to-one onto the\n * incident shapes the issue names — bot wave → `signups`, malware report →\n * `uploads`, billing bug → `checkout`, malicious listing →\n * `marketplace-installs`. A plugin that can be switched off in an incident\n * declares its own lever through `registerPluginEntitlements` (AGL-2940):\n * the label, the staff-bypass rule, the visitor notice and the API paths it\n * gates all arrive with the declaration, and the staff checklist, the\n * writer and the dispatcher read the two sets as one.\n *\n * Precedence COMPOSES rather than ranks: a platform lock implies every\n * feature (the feature verdict helpers check platform first), while a\n * feature lock implies nothing about the platform/org/host/user scopes —\n * feature states never enter `resolveLockdown`.\n */\nexport type CoreLockdownFeatureKey =\n | 'signups'\n | 'uploads'\n | 'checkout'\n | 'marketplace-installs'\n\n/**\n * A lever's wire identity: one of the core four, or a key a plugin\n * declared. A string rather than a union because the set is only complete\n * once every declaring plugin has registered, which no type can know.\n */\nexport type LockdownFeatureKey = CoreLockdownFeatureKey | (string & {})\n\n/**\n * The un-panic invariant, per feature (AGL-1510). At the PLATFORM scope a\n * verified staff claim bypasses unconditionally — that is unchanged and not\n * negotiable. A FEATURE lock is narrower, so the bypass is granted only\n * where it aids incident response, and withheld where a staff action would\n * be the very thing the lock exists to stop:\n *\n * - `uploads: true` — the uploads lock answers a malware report, and the\n * staff member responding needs to upload a test asset to verify the fix\n * before lifting the lock for everyone.\n * - `marketplace-installs: true` — same shape: a malicious listing slipped\n * review, and reproducing the install is part of investigating it.\n * - `checkout: false` — a checkout lock answers a billing/Stripe bug, and a\n * staff-created checkout session is still a real charge against a real\n * card. There is no incident-response step that needs money to move;\n * verification belongs in Stripe test mode.\n * - `signups: false` — an account being created has no staff claim yet, so\n * a bypass here could never fire honestly; declaring `false` states that\n * rather than leaving a bypass that only a misattributed claim could use.\n *\n * A plugin's lever carries its own answer on its declaration.\n *\n * Each notice says what is paused AND what still works — a feature lock's\n * whole point is that everything else keeps serving, and the copy must not\n * let a narrow pause read as a wider outage. The checkout notice in\n * particular must NEVER read as a payment failure: \"your card was declined\"\n * and \"we turned checkout off\" are different sentences, and only one of them\n * sends a customer to their bank.\n */\nconst CORE_LOCKDOWN_FEATURES: readonly PluginLockdownFeatureDeclaration[] = [\n {\n key: 'signups',\n label: 'New signups',\n customerName: 'new signups',\n staffBypass: false,\n notice: {\n title: 'New signups are paused',\n body: 'New signups are temporarily paused. Existing accounts can sign in and work as usual.',\n },\n },\n {\n key: 'uploads',\n label: 'Media uploads',\n customerName: 'media uploads',\n staffBypass: true,\n notice: {\n title: 'Uploads are paused',\n body: 'Media uploads are temporarily disabled while we address an issue. Your existing media and published sites are unaffected.',\n },\n },\n {\n key: 'checkout',\n label: 'Checkout (new subscriptions)',\n // Both doors it closes start a purchase: a plan or add-on, and a paid\n // marketplace install.\n customerName: 'new purchases',\n staffBypass: false,\n notice: {\n title: 'Checkout is temporarily unavailable',\n body: 'Checkout is temporarily unavailable — this is not a payment failure, and your account, subscription, and sites are unaffected. Please try again shortly.',\n },\n // `marketplace/checkout` creates NEW Stripe checkout sessions exactly\n // like the billing route (AGL-1545); it is also the front door of a paid\n // install, so it carries BOTH keys and each keeps its own bypass rule.\n apiPaths: { exact: ['marketplace/checkout'] },\n },\n {\n key: 'marketplace-installs',\n label: 'Marketplace installs',\n customerName: 'marketplace installs',\n staffBypass: true,\n notice: {\n title: 'Marketplace installs are paused',\n body: 'Installing from the marketplace is temporarily disabled. Everything already installed keeps working.',\n },\n // Installs-as-a-class, every artifact kind. `marketplace/update-artifact`\n // re-copies a publisher's version into the org, which is an install by\n // another name and the same vector a malicious listing would ride.\n // Publish/review/report paths map to nothing: a marketplace incident\n // must not stop publishers reporting or staff reviewing.\n apiPaths: {\n exact: ['marketplace/checkout', 'marketplace/install', 'marketplace/update-artifact'],\n prefixes: ['marketplace/install-'],\n },\n },\n]\n\n/**\n * Every lever, core first and then the plugin declarations in catalog\n * order — the list the staff checklist renders and the writer validates\n * against. Read live rather than snapshotted: plugin declarations arrive\n * at module scope, which can run after any constant here was evaluated.\n */\nexport function listLockdownFeatures(): PluginLockdownFeatureDeclaration[] {\n return [...CORE_LOCKDOWN_FEATURES, ...listPluginLockdownFeatures()]\n}\n\n/** The keys of {@link listLockdownFeatures}, in the same order. */\nexport function listLockdownFeatureKeys(): LockdownFeatureKey[] {\n return listLockdownFeatures().map((feature) => feature.key)\n}\n\n/** One lever's declaration — core or plugin — or `undefined`. */\nexport function lockdownFeatureDeclaration(\n key: unknown,\n): PluginLockdownFeatureDeclaration | undefined {\n if (typeof key !== 'string') return undefined\n return listLockdownFeatures().find((feature) => feature.key === key)\n}\n\nexport function isLockdownFeatureKey(\n value: unknown,\n): value is LockdownFeatureKey {\n return lockdownFeatureDeclaration(value) !== undefined\n}\n\n/** Staff-surface label; the key stays the wire/API identity. */\nexport function lockdownFeatureLabel(key: LockdownFeatureKey): string {\n return lockdownFeatureDeclaration(key)?.label ?? key\n}\n\n/**\n * What a customer's mail calls a lever (`media uploads`), never the staff\n * label. A key nothing declares, or a declaration without a name, reads as\n * \"a feature\" rather than leak a key or a staff label.\n */\nexport function lockdownFeatureCustomerName(key: LockdownFeatureKey): string {\n return lockdownFeatureDeclaration(key)?.customerName?.trim() || 'a feature'\n}\n\n/**\n * Several levers named together in a customer's mail: `AI assist and AI\n * generation`. One pause of several levers is one notice.\n */\nexport function lockdownFeaturesCustomerText(keys: readonly LockdownFeatureKey[]): string {\n const names = [...new Set(keys.map(lockdownFeatureCustomerName))]\n return new Intl.ListFormat('en-US', { style: 'long', type: 'conjunction' }).format(names)\n}\n\n/**\n * Whether a verified staff claim passes this feature's lock. A key nothing\n * declared answers `false`: an undeclared lever has no bypass argument on\n * record, and the safe reading of \"no argument\" is \"no bypass\".\n */\nexport function lockdownFeatureStaffBypass(key: LockdownFeatureKey): boolean {\n return lockdownFeatureDeclaration(key)?.staffBypass === true\n}\n\n/**\n * HOW HARD the lock bites (AGL-1511).\n *\n * - `full` — the shipped AGL-1501 behaviour: nothing serves. Sites 503,\n * sessions refuse, every API call gets the 423.\n * - `read-only` — reads keep serving, WRITES refuse. The right shape for the\n * most common maintenance need (a schema migration, a data repair, a\n * suspected-corruption investigation): the customer's site stays up and\n * earning, visitors browse normally, and nothing races the repair.\n *\n * ABSENT MEANS `full`. Every lockdown written before this field existed is a\n * full lock, and a lock whose strictness cannot be read must be treated as\n * the stricter one — so `full` is both the default and the fail-safe. The\n * writer stores `mode` only for read-only locks, which keeps every existing\n * carrier document byte-identical and needs no migration.\n */\nexport type LockdownMode = 'full' | 'read-only'\n\nconst LOCKDOWN_MODE_KEYS: Record<LockdownMode, true> = {\n full: true,\n 'read-only': true,\n}\nexport const LOCKDOWN_MODES = Object.keys(LOCKDOWN_MODE_KEYS) as LockdownMode[]\n\nexport function isLockdownMode(value: unknown): value is LockdownMode {\n return typeof value === 'string' && value in LOCKDOWN_MODE_KEYS\n}\n\n/** The mode of a state, with the absent-means-`full` default applied. */\nexport function lockdownMode(\n state: { mode?: LockdownMode } | null | undefined,\n): LockdownMode {\n return state?.mode === 'read-only' ? 'read-only' : 'full'\n}\n\nexport function isReadOnlyLockdown(\n state: { mode?: LockdownMode } | null | undefined,\n): boolean {\n return lockdownMode(state) === 'read-only'\n}\n\n/**\n * WHAT HAPPENS WHEN WE CANNOT READ THE LOCK (AGL-1621).\n *\n * Lockdown fails OPEN: if the carrier read throws, the verdict is \"not\n * locked\". That is the right posture for availability — a Firestore blip\n * must not weld every customer site shut — and the wrong one for a legal or\n * abuse takedown, where the whole point is that the content stops being\n * served and STAYS stopped. A DMCA notice is not satisfied by \"we served it\n * because our database was down\".\n *\n * So the two needs are split on their own axis rather than either one being\n * bent to fit the other:\n *\n * - `standard` — today's behaviour, unchanged. An unreadable lock is not\n * enforced. Maintenance windows, billing suspensions, precautionary\n * holds, incident response: everything whose cost of over-refusing is\n * higher than its cost of under-refusing.\n * - `takedown` — holds through an infrastructure failure. Legal orders,\n * abuse/CSAM removals, domain hijack disputes: the cost of serving is\n * higher than the cost of an outage on that one subject.\n *\n * ## Why this is its own field and not read off `scope` or `reason`\n *\n * NOT `scope`: every one of the six scopes carries both classes. A `host`\n * lock is a maintenance window on Tuesday and a court-ordered removal on\n * Wednesday; the scope says WHAT is locked, never why it must hold.\n *\n * NOT `reason`: `reason` is already spoken for as the VISITOR-FACING\n * classifier — it selects the notice copy in `lockdownNotice`. Deriving\n * fail-closed from `reason === 'security'` would both couple what the\n * public is told to how the system behaves under failure, and be exactly\n * the inference this field exists to avoid: most `security` locks are\n * precautionary holds during an investigation, and those must keep failing\n * open. A lock that becomes fail-closed because of a heuristic is a lock\n * that takes a site down for an unrelated reason.\n *\n * ABSENT MEANS `standard`, and that direction is the safety property. Every\n * lock written before this field existed, every lock an operator did not\n * classify, and every doc whose value this build cannot interpret is\n * fail-OPEN — the behaviour shipped today. Getting this backwards during a\n * Firestore incident is the platform-wide outage the split exists to\n * prevent, so only the exact string `takedown` opts in. Same posture and\n * same storage discipline as `mode` above: the writer stores the field only\n * for takedowns, so every existing carrier document stays byte-identical\n * and no migration is needed.\n */\nexport type LockdownEnforcement = 'standard' | 'takedown'\n\nconst LOCKDOWN_ENFORCEMENT_KEYS: Record<LockdownEnforcement, true> = {\n standard: true,\n takedown: true,\n}\nexport const LOCKDOWN_ENFORCEMENTS = Object.keys(\n LOCKDOWN_ENFORCEMENT_KEYS,\n) as LockdownEnforcement[]\n\nexport function isLockdownEnforcement(\n value: unknown,\n): value is LockdownEnforcement {\n return typeof value === 'string' && value in LOCKDOWN_ENFORCEMENT_KEYS\n}\n\n/** Staff-surface labels; the key stays the wire/API identity. */\nexport const LOCKDOWN_ENFORCEMENT_LABELS: Record<LockdownEnforcement, string> = {\n standard: `Standard — releases if ${PLATFORM_BRAND_NAME} cannot reach the database`,\n takedown: `Takedown — keeps holding if ${PLATFORM_BRAND_NAME} cannot reach the database`,\n}\n\n/**\n * The enforcement class of a state, with the absent-means-`standard`\n * default applied. Read through this, never bare — an unrecognised value\n * must land on the fail-open default rather than on a truthiness test that\n * would make any junk string fail closed.\n */\nexport function lockdownEnforcement(\n state: { enforcement?: LockdownEnforcement } | null | undefined,\n): LockdownEnforcement {\n return state?.enforcement === 'takedown' ? 'takedown' : 'standard'\n}\n\n/**\n * Is this the class that must survive an unreadable carrier? The ONE\n * predicate the fail-closed path reduces to, so \"what is a takedown\" has a\n * single definition rather than one per reader.\n */\nexport function isTakedownLockdown(\n state: { enforcement?: LockdownEnforcement } | null | undefined,\n): boolean {\n return lockdownEnforcement(state) === 'takedown'\n}\n\n/**\n * What a request is trying to DO, which is the only thing a read-only lock\n * discriminates on.\n *\n * `write` is the default everywhere it is not stated, and that direction is\n * deliberate: an enforcement point that forgot to declare its intent refuses\n * during a migration rather than letting an unaudited write through. The\n * cost of the safe default is an over-refused read; the cost of the unsafe\n * one is the corruption the whole mode exists to prevent.\n */\nexport type LockdownIntent = 'read' | 'write'\n\n/**\n * HTTP method → intent. The safe-method set from RFC 9110 minus TRACE (which\n * nothing here serves): a method not on this list mutates until proven\n * otherwise.\n *\n * A route whose POST is really a query (`where-used`, `plugin-impact`,\n * validators) states `intent: 'read'` explicitly rather than being guessed at\n * from a path.\n */\nexport function lockdownIntentForMethod(\n method: string | null | undefined,\n): LockdownIntent {\n const normalized = typeof method === 'string' ? method.toUpperCase() : ''\n return normalized === 'GET' || normalized === 'HEAD' || normalized === 'OPTIONS'\n ? 'read'\n : 'write'\n}\n\n/**\n * Does this state refuse a request of this intent? The ONE predicate every\n * chokepoint's discrimination reduces to — a full lock refuses everything, a\n * read-only lock refuses writes only.\n *\n * Note what is NOT here: the staff bypass. That lives above every read in the\n * server verdict helper and must not acquire a second, mode-shaped copy\n * — \"staff bypass unless…\" is how an un-panic invariant stops being one.\n */\nexport function lockdownBlocks(\n state: { mode?: LockdownMode } | null | undefined,\n intent: LockdownIntent,\n): boolean {\n if (!state) return false\n return lockdownMode(state) === 'full' || intent === 'write'\n}\n\nexport type LockdownReasonCode =\n | 'security'\n /**\n * Phishing, fraud or malicious content (AGL-3420). On an ACCOUNT it is a\n * permanent ban: the account is kept only so its address can never sign up\n * again, and after the lock notice it is sent no mail at all — see\n * {@link isAccountBanLockdownReason}. Everywhere a lock's strictness is\n * read from its reason it is at least as strict as `security`.\n */\n | 'abuse'\n | 'billing'\n | 'maintenance'\n | 'manual'\n\nconst LOCKDOWN_REASON_CODE_KEYS: Record<LockdownReasonCode, true> = {\n security: true,\n abuse: true,\n billing: true,\n maintenance: true,\n manual: true,\n}\nexport const LOCKDOWN_REASON_CODES = Object.keys(\n LOCKDOWN_REASON_CODE_KEYS,\n) as LockdownReasonCode[]\n\nexport function isLockdownReasonCode(\n value: unknown,\n): value is LockdownReasonCode {\n return (\n typeof value === 'string' && value in LOCKDOWN_REASON_CODE_KEYS\n )\n}\n\n/** Staff-surface labels; the key stays the wire/API identity. */\nexport const LOCKDOWN_REASON_LABELS: Record<LockdownReasonCode, string> = {\n security: 'Security — investigating a concern',\n abuse: 'Abuse — phishing, fraud or malicious content (permanent ban)',\n billing: 'Billing — unresolved payment',\n maintenance: 'Maintenance',\n manual: 'Manual',\n}\n\n/**\n * The reasons that mean \"a threat, act now\": `security`, and `abuse`, which\n * is a confirmed one. Every rule that is stricter for a security lock — who\n * is signed out, which bytes stop serving, which tokens rotate — reads it\n * through here, so a ban is never the milder of the two.\n */\nexport function isSecurityClassLockdownReason(reason: unknown): boolean {\n return reason === 'security' || reason === 'abuse'\n}\n\n/**\n * A lock with this reason, on an account, is a permanent ban (AGL-3420):\n * after its notice the account is sent nothing, by any sender.\n */\nexport function isAccountBanLockdownReason(reason: unknown): boolean {\n return reason === 'abuse'\n}\n\n/** The one shape every enforcement point consumes. */\nexport interface LockdownState {\n scope: LockdownScope\n /** Set only when `scope === 'feature'` — which capability is locked. */\n feature?: LockdownFeatureKey\n /**\n * Set only on a feature lock placed for ONE workspace (AGL-2927): the\n * org it pauses the capability for. Absent on the platform-wide feature\n * lock, which implies every workspace.\n */\n orgId?: string\n /** Absent = `full` (AGL-1511). Read through `lockdownMode`, never bare. */\n mode?: LockdownMode\n /**\n * Absent = `standard`, i.e. fail-open (AGL-1621). Read through\n * `lockdownEnforcement`/`isTakedownLockdown`, never bare.\n */\n enforcement?: LockdownEnforcement\n reason: LockdownReasonCode\n /**\n * Visitor/user-facing notice text (bounded at write time). Anything staff\n * types here is SHOWN to locked-out users — internal rationale belongs in\n * the audit row, not this field.\n */\n message?: string\n atMs?: number\n /**\n * Optional expiry (maintenance windows end). Once `untilMs` passes the\n * lockdown is simply inactive — access restores with NO staff action and\n * no write.\n */\n untilMs?: number\n actorUid?: string\n}\n\n/**\n * `lockdowns/{id}` — the carrier for the two scopes that had none.\n * Doc ids are scope-encoded so a lookup is a single `get`:\n * `platform` and `user--{uid}`. Plain-number timestamps on purpose: the\n * collection is Admin-SDK-only and converter-free, so a partial write can\n * never run a converter that \"defaults\" a sibling field away (the\n * withConverter-on-partial-writes bug class).\n */\nexport const LOCKDOWNS_COLLECTION = 'lockdowns'\nexport const PLATFORM_LOCKDOWN_DOC_ID = 'platform'\nexport const userLockdownDocId = (uid: string): string => `user--${uid}`\n/** `feature--{key}` — same collection, same rules, same audited writer. */\nexport const featureLockdownDocId = (feature: LockdownFeatureKey): string =>\n `feature--${feature}`\n/**\n * `feature--{key}--org--{orgId}` — the same capability switched off for ONE\n * workspace (AGL-2927). The carrier the staff org page's AI pause writes:\n * a spend stop that touches neither the org's entitlements nor its plan, so\n * lifting it restores exactly what the customer bought. Read only by the\n * doors that carry that feature key and name the org, and never implied by\n * the platform-wide document's absence.\n */\nexport const orgFeatureLockdownDocId = (\n feature: LockdownFeatureKey,\n orgId: string,\n): string => `feature--${feature}--org--${orgId}`\n\n/**\n * `domain--{hostname}` — the DOMAIN scope's carrier (AGL-1513).\n *\n * ## Keyed on the hostname, deliberately not on the host id\n *\n * Every other narrow scope keys on the thing it locks: a user by uid, a host\n * by host id. A domain lock cannot, because the incidents it exists for are\n * exactly the ones where the domain moves. A hijacked or disputed domain gets\n * detached and re-attached — to another site, or to another org — and a lock\n * keyed on `hosts/{id}` would be left guarding whichever site the name has\n * since left. Keying on the NAME means the lock follows the name, survives\n * detach/re-attach, and can be placed on a domain that is not currently\n * attached to anything at all, which is the state a dispute is usually\n * resolved in.\n *\n * The hostname is lowercased and used verbatim; the caller is responsible for\n * having validated it as a hostname, and the writer does. Firestore document\n * ids may contain dots, so `example.com` needs no escaping — and the `domain--`\n * prefix keeps the key space disjoint from `user--`/`feature--` in the shared\n * collection.\n */\nexport const domainLockdownDocId = (hostname: string): string =>\n `domain--${hostname.trim().toLowerCase()}`\n\n/** A bare hostname: labels of a-z0-9/dash, at least two of them. */\nconst LOCKABLE_DOMAIN_PATTERN =\n /^(?!-)[a-z0-9-]{1,63}(\\.(?!-)[a-z0-9-]{1,63})+$/\n/** The DNS limit; also what keeps the document id bounded. */\nexport const LOCKABLE_DOMAIN_MAX = 253\n\n/**\n * Whether `value` is a name the DOMAIN scope will accept (AGL-1513).\n *\n * Shared by the writer and anything that builds the doc id, so \"what is a\n * lockable name\" has one definition. Two refusals matter:\n *\n * - **Not hostname-shaped.** The id is derived from this string, and it\n * arrives from a staff form; `isDocumentId`-style discipline starts by\n * not minting a document for junk in the first place.\n * - **Inside the platform apex.** `{sub}.aglyn.app` is OUR name, and the\n * tenant resolves that space by `subdomain` and never consults the domain\n * scope for it — so a lock placed there would write a document that no\n * reader ever looks at. Refusing is the difference between \"you cannot do\n * that\" and a control that silently does nothing, which is the worse of\n * the two by a distance. Taking a platform subdomain down is the HOST\n * scope's job.\n */\nexport function isLockableDomain(value: unknown): value is string {\n if (typeof value !== 'string') return false\n const domain = value.trim().toLowerCase()\n if (!domain || domain.length > LOCKABLE_DOMAIN_MAX) return false\n if (!LOCKABLE_DOMAIN_PATTERN.test(domain)) return false\n return domain !== TENANT_APEX && !domain.endsWith(`.${TENANT_APEX}`)\n}\n\nexport interface LockdownDoc {\n scope: LockdownScope\n /** Present on `feature--{key}` docs only. */\n feature?: LockdownFeatureKey\n /** Present on `feature--{key}--org--{orgId}` docs only (AGL-2927). */\n orgId?: string\n /** Written only for read-only locks; absent = `full`. */\n mode?: LockdownMode\n /** Written only for takedowns; absent = `standard` (fail open). */\n enforcement?: LockdownEnforcement\n reason: LockdownReasonCode\n message?: string\n atMs?: number\n untilMs?: number\n actorUid?: string\n}\n\n/** Staff-typed notice text is user-facing; keep it bounded and plain. */\nexport const LOCKDOWN_MESSAGE_MAX = 500\n\n/**\n * Tolerant epoch-ms reader: Firestore `Timestamp`, `{ seconds }` JSON, a\n * number of ms, or an ISO string — the org carrier's `suspendedAt` arrives\n * in all of these shapes depending on which cache serialized it.\n */\nexport function toEpochMs(value: unknown): number | undefined {\n if (value == null) return undefined\n if (typeof value === 'number') return Number.isFinite(value) ? value : undefined\n if (typeof value === 'string') {\n const parsed = Date.parse(value)\n return Number.isNaN(parsed) ? undefined : parsed\n }\n if (typeof value === 'object') {\n const record = value as {\n toMillis?: () => number\n seconds?: number\n _seconds?: number\n }\n if (typeof record.toMillis === 'function') {\n try {\n return record.toMillis()\n } catch {\n return undefined\n }\n }\n const seconds = record.seconds ?? record._seconds\n if (typeof seconds === 'number' && Number.isFinite(seconds)) {\n return seconds * 1000\n }\n }\n return undefined\n}\n\n/**\n * Active now? Expiry passing deactivates without any write.\n *\n * Typed on the only field it reads rather than on `LockdownState`, so the\n * sibling levers built on the same field family — asset quarantine\n * (AGL-1512) — share this definition of \"expired\" instead of restating it.\n * A second copy is how two panic levers end up disagreeing about whether a\n * window that closed one millisecond ago is still in force.\n */\nexport function isLockdownActive(\n state: { untilMs?: number } | null | undefined,\n nowMs: number,\n): boolean {\n if (!state) return false\n if (typeof state.untilMs === 'number' && state.untilMs <= nowMs) return false\n return true\n}\n\n/**\n * ACCOUNT CREATION ITSELF (AGL-1531) — the decision a Firebase Auth\n * `beforeUserCreated` blocking function makes.\n *\n * The signups feature lock already refuses the SESSION (the mint in\n * /api/auth/session), the acceptance recorder and the signup-page doors, so\n * a wave's accounts are unusable. They are still CREATED: account creation\n * is client -> Firebase Auth and no Aglyn server sits in front of it. The\n * only thing that does is a blocking function, which is why this decision\n * exists as its own function rather than as another `isLockdownActive` call\n * at a route.\n *\n * ==== EVERYTHING BELOW, TO THE #endregion MARKER, IS COPIED VERBATIM INTO\n * ==== cloud/functions/src/signups-lock.ts.\n *\n * `cloud/functions` is a plain npm package outside the nx workspace — it can\n * import firebase-admin and firebase-functions and nothing else, so it\n * cannot import this library. The copy is therefore byte-for-byte and\n * apps/console/specs/signups-creation-lock-wiring.spec.ts fails if the two\n * regions ever differ by a character. That makes the tests below tests of\n * the code that actually deploys, rather than of a lookalike.\n *\n * The region imports nothing, for that reason.\n */\n// #region signups-creation-lock\n/**\n * FAIL OPEN WHEN THE LOCK CANNOT BE READ; KEEP HOLDING ONE ALREADY SEEN.\n *\n * This repo's postures are deliberately not uniform — rate limiting fails\n * soft, CSRF fails closed, and `getFeatureLockdown` fails OPEN because an\n * unreachable Firestore is an outage rather than a feature lockdown. This\n * gate sits with the last of those, and the split below is what makes that\n * safe rather than merely convenient.\n *\n * ## Not knowing is answered by admitting\n *\n * An unreadable lock means the platform does not KNOW whether staff pulled\n * the lever. Refusing on \"do not know\" is not a cautious answer here, it is\n * a total one: this is the only thing standing in front of Firebase Auth\n * account creation, so one refusal turns away every stranger at once, with a\n * generic error they cannot act on and no reason to come back. And the\n * condition that produces it is ordinary rather than exceptional — where\n * signup traffic is light the instance is cold for nearly every attempt, so\n * \"the first Firestore read costs more than the budget\" is the common case,\n * not the rare one.\n *\n * The error in the other direction is bounded. An account created while\n * Firestore is unreadable cannot finish signing up anyway — the profile, the\n * legal-acceptance record and the workspace all live in Firestore — so\n * admitting costs some orphan Auth records for the length of the read\n * outage, and those are enumerable and removable afterwards. One side's\n * mistake is a handful of empty records; the other side's is the whole\n * funnel, silently, for as long as reads are slow.\n *\n * ## A lever actually seen is a fact, and it survives\n *\n * Every read that COMPLETES is recorded below, and a read that then fails is\n * answered from that record instead of from nothing. So the objection\n * fail-open usually earns — a bot wave makes reads fail and thereby releases\n * the brake aimed at the wave — needs the wave to land on an instance that\n * has never once read the lock; every instance that has keeps refusing for\n * the whole outage. Lifting the lever is itself a Firestore write, so\n * nothing can lift it during that outage either, and a lock's own `untilMs`\n * is still honored against the clock, so a dead-man expiry cannot become\n * un-liftable by being remembered.\n *\n * This is STRICTER than the tenant takedown ledger (AGL-1621), which\n * remembers only locks classified `takedown` and lets a `standard` one\n * release during an outage. That trade is right where it sits, because\n * holding a remembered lock there keeps every visitor off a customer's whole\n * site. Holding one here refuses new signups, which is the cheap direction,\n * so no `enforcement` field is consulted at all and any active lock is\n * remembered.\n *\n * ## What it does not do, stated rather than implied\n *\n * An instance that has never completed a read has nothing to remember, so a\n * lever pulled DURING a total Firestore outage does not reach a cold one.\n * Closing that gap needs a carrier more available than Firestore, which is a\n * different and much larger change; pretending otherwise here would be worse\n * than naming it. The escape hatch that needs no deploy is unchanged:\n * unregister the `beforeCreate` trigger in Identity Platform, which the\n * staff lockdown page reports the state of.\n */\nexport type SignupsCreationVerdict =\n /**\n * `unreadable` marks an admission made BLIND: the read did not complete\n * and nothing this instance remembers said the lever was pulled. Carried\n * so the caller can log it — the two admissions are the same outcome for\n * the person signing up and a very different one for an operator.\n */\n | { refused: false; unreadable?: true }\n /**\n * `locked` = a read completed and found the lever pulled. `held` = a read\n * failed and an earlier one had found it pulled.\n */\n | { refused: true; cause: 'locked' | 'held' }\n\n/**\n * How long the blocking function waits on the lock read before deciding\n * without it.\n *\n * Blocking functions sit on the account-creation critical path and Identity\n * Platform gives them a bounded window, so an unbounded `get()` would hand\n * the decision to the platform's own timeout — which refuses, after burning\n * the whole budget and with no log saying why. Bounding it keeps the verdict\n * here, where it can be reasoned about and recorded.\n */\nexport const SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS = 2_500\n\n/**\n * What the last COMPLETED read saw, for the life of this instance.\n * `undefined` means no read has completed here yet — the state a cold\n * instance starts in, and the only one that cannot hold a lock.\n *\n * Not a cache: it is never consulted in place of a read, only in place of\n * one that failed, so it can never make this gate slower to notice a lift\n * than the lift's own write.\n */\nlet lastReadSignupsLock: { untilMs?: number } | null | undefined\n\n/**\n * Forget it. Not part of any panic path — this exists for process\n * boundaries and for tests, where one case's lock would otherwise become the\n * next case's refusal.\n */\nexport function resetSignupsLockMemory(): void {\n lastReadSignupsLock = undefined\n}\n\n/**\n * Is this state an engaged lock at `nowMs`?\n *\n * Deliberately stricter than `normalizeLockdownDoc`, which refuses to\n * interpret a malformed doc and so reports NOT LOCKED. Here the document's\n * existence is the lever: the only writer is the audited staff route, so a\n * doc that exists at all means someone pulled it, and a field this build\n * cannot parse must never un-pull it. An expiry that has passed deactivates\n * with no write, matching `isLockdownActive` exactly (the sibling test pins\n * that equivalence).\n */\nfunction signupsLockEngaged(\n state: { untilMs?: number } | null | undefined,\n nowMs: number,\n): boolean {\n if (state === null || state === undefined) return false\n return !(typeof state.untilMs === 'number' && state.untilMs <= nowMs)\n}\n\n/**\n * Refuse this account creation?\n *\n * `readLock` returns the `lockdowns/feature--signups` document, or null when\n * it does not exist. It is injected rather than imported so this decision —\n * including its fail-open and timeout behavior — is testable without a\n * Firestore, and so the same characters run in the function and in the test.\n *\n * NOT parameterized by provider, by email, or by tenant. Email/password,\n * Google and SSO all reach Firebase Auth account creation, and a lock that\n * discriminated between them would be a lock on one gate of three. The\n * caller passes no identity at all, so no future edit can quietly add a\n * carve-out here.\n */\nexport async function signupsCreationVerdict(\n readLock: () => Promise<{ untilMs?: number } | null | undefined>,\n nowMs: number,\n timeoutMs: number = SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS,\n): Promise<SignupsCreationVerdict> {\n let timer: ReturnType<typeof setTimeout> | undefined\n let state: { untilMs?: number } | null | undefined\n try {\n state = await Promise.race([\n readLock(),\n new Promise<never>((_resolve, reject) => {\n timer = setTimeout(\n () => reject(new Error('signups lock read timed out')),\n timeoutMs,\n )\n }),\n ])\n } catch {\n // Nothing was learned, so the only thing that can refuse now is what an\n // earlier read established. A remembered lock whose window has closed is\n // dropped here rather than re-examined on every later failure.\n if (signupsLockEngaged(lastReadSignupsLock, nowMs)) {\n return { refused: true, cause: 'held' }\n }\n lastReadSignupsLock = undefined\n return { refused: false, unreadable: true }\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n }\n // A completed read is the whole truth, \"no document\" included: it decides\n // this account AND replaces whatever was remembered, which is how a lift\n // takes effect.\n lastReadSignupsLock = state ?? null\n return signupsLockEngaged(state, nowMs)\n ? { refused: true, cause: 'locked' }\n : { refused: false }\n}\n\n// #endregion signups-creation-lock\n\n/**\n * Precedence: platform > org > host > domain > user. The widest active scope wins so\n * the notice a visitor sees names the real cause (a platform maintenance\n * window should not read as \"this account is suspended\").\n *\n * STRICTNESS OUTRANKS WIDTH (AGL-1511). Once locks have a mode, \"widest\n * wins\" alone is a security hole: a platform-wide read-only maintenance\n * window would outrank — and therefore SOFTEN — a full security takedown on\n * one org, quietly readmitting every visitor to the site staff just took\n * down. So an active `full` lock is chosen first (in scope order among the\n * full ones), and the widest read-only lock only wins when no full lock is\n * active at all. A lock can never be relaxed by a wider, gentler one.\n */\nexport function resolveLockdown(\n states: {\n platform?: LockdownState | null\n org?: LockdownState | null\n host?: LockdownState | null\n /**\n * AGL-1513. Narrower than `host`: it locks ONE attached name while the\n * same site keeps serving on its other addresses, so it must never\n * outrank a host takedown. It sits above `user` for the same reason\n * `host` does — it is a property of the site being addressed, not of\n * whoever is looking at it.\n */\n domain?: LockdownState | null\n user?: LockdownState | null\n },\n nowMs: number,\n): LockdownState | null {\n const active = [\n states.platform,\n states.org,\n states.host,\n states.domain,\n states.user,\n ].filter(\n (state): state is LockdownState => Boolean(state) && isLockdownActive(state, nowMs),\n )\n return active.find((state) => lockdownMode(state) === 'full') ?? active[0] ?? null\n}\n\n/**\n * Org carrier → state. `suspendedAt` alone (every pre-lockdown suspension)\n * normalizes to `manual` with no public message — the legacy free-text\n * `suspendedReason` was written for staff eyes and must not leak into the\n * visitor notice.\n */\nexport function normalizeOrgLockdown(\n org:\n | {\n suspendedAt?: unknown\n suspendedReasonCode?: unknown\n suspendedMessage?: unknown\n suspendedUntilMs?: unknown\n suspendedMode?: unknown\n suspendedEnforcement?: unknown\n }\n | null\n | undefined,\n): LockdownState | null {\n if (!org || org.suspendedAt == null) return null\n return {\n scope: 'org',\n // An unrecognised value normalizes to `full`, matching the absent case:\n // a strictness this build cannot interpret must not read as the softer\n // one (an older deploy meeting a mode it has never heard of).\n ...(org.suspendedMode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // Only the exact string opts into fail-closed; anything else — absent,\n // malformed, or a class a newer deploy invented — stays `standard` and\n // keeps failing open (AGL-1621).\n ...(org.suspendedEnforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: isLockdownReasonCode(org.suspendedReasonCode)\n ? org.suspendedReasonCode\n : 'manual',\n message:\n typeof org.suspendedMessage === 'string' && org.suspendedMessage\n ? org.suspendedMessage.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: toEpochMs(org.suspendedAt),\n untilMs:\n typeof org.suspendedUntilMs === 'number' &&\n Number.isFinite(org.suspendedUntilMs)\n ? org.suspendedUntilMs\n : undefined,\n }\n}\n\n/**\n * Host carrier → state. Same field family as the org, on the host doc.\n * `host.maintenance` is NOT consulted here — that is the customer's own\n * switch and keeps its shipped AGL-131 path.\n */\nexport function normalizeHostLockdown(\n host:\n | {\n suspendedAt?: unknown\n suspendedReasonCode?: unknown\n suspendedMessage?: unknown\n suspendedUntilMs?: unknown\n suspendedMode?: unknown\n suspendedEnforcement?: unknown\n }\n | null\n | undefined,\n): LockdownState | null {\n if (!host || host.suspendedAt == null) return null\n return {\n scope: 'host',\n ...(host.suspendedMode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // As the org carrier: exact string only, everything else fails open.\n ...(host.suspendedEnforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: isLockdownReasonCode(host.suspendedReasonCode)\n ? host.suspendedReasonCode\n : 'manual',\n message:\n typeof host.suspendedMessage === 'string' && host.suspendedMessage\n ? host.suspendedMessage.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: toEpochMs(host.suspendedAt),\n untilMs:\n typeof host.suspendedUntilMs === 'number' &&\n Number.isFinite(host.suspendedUntilMs)\n ? host.suspendedUntilMs\n : undefined,\n }\n}\n\n/**\n * The active org/host lock on one site, from documents the caller already\n * holds, or null (AGL-3356).\n *\n * For a sender deep in a plugin — the campaign core, the workflow engine,\n * the outreach runtime — that has the org and host documents in hand and\n * must refuse to mail for a suspended workspace. The server verdict\n * (`getSiteLockdown`) adds the platform scope and the takedown ledger on\n * top; this is the pure part both share, so neither restates what an\n * org/host lock is. Unlike the verdict it reads nothing and cannot fail, so\n * a caller that could not read the documents has already failed on its own.\n *\n * Returns the state whatever its mode; a caller that lets a read-only\n * maintenance window through asks `lockdownMode(state)`.\n */\nexport function siteLockdownFromDocs(\n docs: {\n org?: Parameters<typeof normalizeOrgLockdown>[0]\n host?: Parameters<typeof normalizeHostLockdown>[0]\n },\n nowMs: number,\n): LockdownState | null {\n const state = resolveLockdown(\n {\n org: normalizeOrgLockdown(docs.org),\n host: normalizeHostLockdown(docs.host),\n },\n nowMs,\n )\n return isLockdownActive(state, nowMs) ? state : null\n}\n\n/** `lockdowns/{id}` doc → state; refuses malformed docs rather than guess. */\nexport function normalizeLockdownDoc(\n doc: Partial<LockdownDoc> | null | undefined,\n scope: LockdownScope,\n): LockdownState | null {\n if (!doc) return null\n if (!isLockdownReasonCode(doc.reason)) return null\n // A feature doc whose key is not (or no longer) in the enum is refused\n // whole, matching the malformed-reason posture: the panic path does not\n // guess, and an unknown key has no chokepoint to enforce it anyway.\n if (scope === 'feature' && !isLockdownFeatureKey(doc.feature)) return null\n return {\n scope,\n ...(scope === 'feature' && isLockdownFeatureKey(doc.feature)\n ? { feature: doc.feature }\n : {}),\n ...(scope === 'feature' && typeof doc.orgId === 'string' && doc.orgId\n ? { orgId: doc.orgId }\n : {}),\n // Same posture as the org/host carriers: only the exact string relaxes\n // the lock. A malformed or unknown `mode` leaves it full.\n ...(doc.mode === 'read-only' ? { mode: 'read-only' as const } : {}),\n // As the org/host carriers: exact string only. A doc whose enforcement\n // class this build cannot interpret is a STANDARD lock and fails open,\n // never the reverse (AGL-1621).\n ...(doc.enforcement === 'takedown'\n ? { enforcement: 'takedown' as const }\n : {}),\n reason: doc.reason,\n message:\n typeof doc.message === 'string' && doc.message\n ? doc.message.slice(0, LOCKDOWN_MESSAGE_MAX)\n : undefined,\n atMs: typeof doc.atMs === 'number' ? doc.atMs : undefined,\n untilMs:\n typeof doc.untilMs === 'number' && Number.isFinite(doc.untilMs)\n ? doc.untilMs\n : undefined,\n actorUid: typeof doc.actorUid === 'string' ? doc.actorUid : undefined,\n }\n}\n\n/**\n * `Retry-After` seconds for a 503, when the lockdown has a known end.\n * Clamped to at least 60 so a window expiring mid-request cannot emit 0.\n */\nexport function lockdownRetryAfterSeconds(\n state: LockdownState,\n nowMs: number,\n): number | undefined {\n if (typeof state.untilMs !== 'number') return undefined\n return Math.max(60, Math.ceil((state.untilMs - nowMs) / 1000))\n}\n\nexport interface LockdownNotice {\n title: string\n body: string\n /** Shown as the action line; undefined = no contact line (maintenance). */\n contact?: string\n /**\n * The lock is a decision the account holder may appeal — a `security` or\n * `abuse` lock (AGL-3420) — so the contact line offers the appeal rather\n * than taking questions. Set by reason, never by staff's message, so a\n * custom message cannot remove the way to appeal.\n */\n appeal?: boolean\n}\n\n/**\n * The address on the visitor-facing 503, from operator configuration\n * (AGL-2016).\n *\n * Was a `'support@aglyn.com'` literal read by eleven call sites below. A\n * self-hosted site that went into lockdown therefore told *its* visitors to\n * write to **Aglyn** about an account Aglyn has no record of and cannot\n * restore. Unlike the abuse form this is not a legal misroute, but it is the\n * same class of wrong answer: the only party who can lift the lock is the\n * operator who set it.\n *\n * Returns `null` when unconfigured, which the notice treats as \"no contact\n * line\" — the same shape `maintenance` already uses. A lockdown notice with a\n * dangling \"contact \" and nothing after it would be worse than one that\n * simply does not offer a channel the deployment does not have.\n */\nexport function lockdownSupportEmail(): string | null {\n return operatorContactLine('support').address\n}\n\n/**\n * Per-reason visitor copy — plain and non-alarming. A staff-typed `message`\n * replaces the body; the title and contact line stay per-reason so staff\n * cannot accidentally strip the \"how do I get out of this\" affordance.\n */\nexport function lockdownNotice(state: LockdownState): LockdownNotice {\n const custom =\n typeof state.message === 'string' && state.message.trim()\n ? state.message.trim()\n : undefined\n // Feature locks carry feature-specific, honest copy (AGL-1510): what is\n // off, and — just as important — what is NOT affected. Same convention as\n // the per-reason copy below: a staff message replaces the body only.\n if (state.scope === 'feature' && state.feature) {\n return featureLockdownNotice(state.feature, custom, state.untilMs)\n }\n // A read-only lock refused a WRITE, and the full-lock copy would lie about\n // it: \"Access is temporarily disabled\" is false on a site the reader is\n // currently looking at (AGL-1511).\n if (isReadOnlyLockdown(state)) {\n return readOnlyLockdownNotice(state, custom)\n }\n // DOMAIN scope (AGL-1513). The site is fine and is still serving on its\n // other addresses; this NAME is the thing that is not, and the copy says\n // that much and no more.\n if (state.scope === 'domain') {\n return domainLockdownNotice(state, custom)\n }\n // A SITE lock closes one site, not the account that owns it: its default\n // copy says \"this site\", or the owners' notice tells them their account was\n // closed when their other sites are serving (AGL-3432).\n const subject = state.scope === 'host' ? 'site' : 'account'\n switch (state.reason) {\n case 'maintenance': {\n const until =\n typeof state.untilMs === 'number'\n ? new Date(state.untilMs).toUTCString()\n : undefined\n return {\n title: 'Down for maintenance',\n body:\n custom ??\n (until\n ? `Scheduled maintenance is in progress. Expected back by ${until}.`\n : 'Scheduled maintenance is in progress. Please check back shortly.'),\n }\n }\n case 'billing':\n return {\n title: 'Account on hold',\n body:\n custom ??\n `This ${subject} is on hold over an unresolved billing issue. ` +\n 'Updating the payment method in workspace billing settings ' +\n 'restores access.',\n contact: lockdownSupportEmail() ?? undefined,\n }\n case 'security':\n return {\n title: 'Temporarily unavailable',\n body:\n custom ??\n 'Access is temporarily disabled while we investigate a security ' +\n 'concern.',\n contact: lockdownSupportEmail() ?? undefined,\n // A platform-wide lock is ours, with nobody to appeal it.\n ...(state.scope !== 'platform' ? { appeal: true } : {}),\n }\n case 'abuse':\n return {\n title: 'Unavailable',\n body:\n custom ??\n `This ${subject} has been closed for a violation of our Terms of ` +\n 'Service.',\n contact: lockdownSupportEmail() ?? undefined,\n // A platform-wide lock is ours, with nobody to appeal it.\n ...(state.scope !== 'platform' ? { appeal: true } : {}),\n }\n case 'manual':\n default:\n return {\n title: 'Temporarily unavailable',\n body: custom ?? `Access to this ${subject} is currently disabled.`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n }\n}\n\n/**\n * DOMAIN copy (AGL-1513), for a visitor who typed one particular name.\n *\n * ## It must never name the address that still works\n *\n * Every other scope's copy can afford to be helpful, because the thing being\n * withheld is the thing the reader is entitled to. This scope exists for\n * ownership disputes and hijacked or expired domains now pointing at us — so\n * the person reading this notice may be precisely the party the site is being\n * withheld from, and \"try acme.aglyn.app instead\" would lift the lock in one\n * sentence. None of these bodies names the platform subdomain, and none\n * should acquire one; the `*.aglyn.app` address is deliberately absent rather\n * than accidentally missing.\n *\n * ## It also does not say whose the name is\n *\n * A dispute is exactly the case where we do not know, and a notice is a\n * publication. \"Not currently serving\" is the whole of what we can assert\n * without taking a side in something a registrar or a court settles.\n */\nfunction domainLockdownNotice(\n state: LockdownState,\n custom: string | undefined,\n): LockdownNotice {\n const title = 'This address is unavailable'\n const contact = lockdownSupportEmail() ?? undefined\n switch (state.reason) {\n case 'maintenance':\n return {\n title,\n body:\n custom ??\n 'This web address is temporarily not serving while maintenance ' +\n 'is in progress.',\n contact,\n }\n case 'billing':\n return {\n title,\n body:\n custom ??\n 'This web address is not currently serving over an unresolved ' +\n 'billing issue.',\n contact,\n }\n case 'security':\n return {\n title,\n body:\n custom ??\n 'This web address is not currently serving while we investigate ' +\n 'a report about it.',\n contact,\n }\n case 'abuse':\n return {\n title,\n body:\n custom ??\n 'This web address is not serving because of a violation of our ' +\n 'Terms of Service.',\n contact,\n }\n case 'manual':\n default:\n return {\n title,\n body: custom ?? 'This web address is not currently serving.',\n contact,\n }\n }\n}\n\n/**\n * READ-ONLY copy (AGL-1511), for the account holder whose save just refused.\n *\n * The whole point of this mode is that the reader is still looking at their\n * work while being told they cannot change it, so every sentence has to hold\n * both halves at once: nothing is down, nothing is lost, one verb is paused.\n * The full-lock titles (\"Temporarily unavailable\", \"Account on hold\") are\n * flatly untrue here and would send someone to support over a fifteen-minute\n * migration.\n *\n * The `until` sentence is built with the SAME `lockdownUntilSuffix` helper\n * the feature copy uses, so `parseLockdownRefusal` strips and re-renders it\n * in the reader's local time here too rather than showing a UTC stamp.\n */\nfunction readOnlyLockdownNotice(\n state: LockdownState,\n custom: string | undefined,\n): LockdownNotice {\n const window =\n typeof state.untilMs === 'number'\n ? ` ${lockdownUntilSuffix(state.untilMs)}`\n : ''\n const title = 'Changes are temporarily paused'\n switch (state.reason) {\n case 'maintenance':\n return {\n title,\n body:\n custom ??\n `Saving changes is paused while we complete scheduled maintenance. ` +\n `Your sites keep serving and nothing you have created is ` +\n `affected — changes will save again shortly.${window}`,\n }\n case 'billing':\n return {\n title,\n body:\n custom ??\n `Saving changes is paused over an unresolved billing issue. Your ` +\n `sites keep serving and nothing has been deleted — updating the ` +\n `payment method in workspace billing settings restores ` +\n `editing.${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n case 'security':\n case 'manual':\n default:\n return {\n title,\n body:\n custom ??\n `Saving changes is paused while we work on something. Your sites ` +\n `keep serving and nothing you have created is affected — ` +\n `changes will save again shortly.${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n }\n}\n\n/**\n * The surfaces on a customer's LIVE SITE that a read-only lock refuses\n * (AGL-1511). These are the ones whose reader is a visitor, not our\n * customer — someone who has never heard of a workspace, a lockdown or a\n * maintenance window and is simply trying to buy something or send a\n * message.\n */\nexport type LockdownPausedSurface = 'form' | 'checkout' | 'cart' | 'generic'\n\n/**\n * VISITOR copy for a read-only refusal on a tenant site (AGL-1511).\n *\n * The issue's central product call: a customer's site staying up and earning\n * is the entire reason this mode exists instead of full lockdown, so a\n * visitor-facing write gets a polite inline pause, never a page-level 503.\n * That decision only pays off if the words match it — a visitor shown\n * \"Temporarily unavailable\" on a page that plainly loaded concludes the shop\n * is broken and leaves, which costs the customer the sale the mode was\n * protecting.\n *\n * So: no mention of maintenance windows, workspaces or accounts (none of\n * which are the visitor's), no support address (support is the SITE\n * owner's, not ours, and pointing a stranger at aglyn.com is worse than\n * silent), an explicit \"nothing you typed is lost\", and — for checkout — the\n * same hard rule the feature copy carries: this must never read as a\n * declined card. A staff-typed `message` is deliberately NOT honoured here;\n * it is written for the account holder and would land in front of strangers.\n */\nexport function lockdownPausedNotice(\n surface: LockdownPausedSurface,\n): LockdownNotice {\n switch (surface) {\n case 'form':\n return {\n title: 'Temporarily paused',\n body:\n 'This form is not accepting submissions for a few minutes. ' +\n 'Nothing you typed has been lost — please try again shortly.',\n }\n case 'checkout':\n return {\n title: 'Checkout is temporarily paused',\n body:\n 'Checkout is paused for a few minutes — this is not a payment ' +\n 'problem and you have not been charged. Your basket is safe; ' +\n 'please try again shortly.',\n }\n case 'cart':\n return {\n title: 'Temporarily paused',\n body:\n 'Basket changes are paused for a few minutes. Browsing works as ' +\n 'normal — please try again shortly.',\n }\n case 'generic':\n default:\n return {\n title: 'Temporarily paused',\n body:\n 'This action is paused for a few minutes. Browsing works as ' +\n 'normal — please try again shortly.',\n }\n }\n}\n\n/**\n * Per-feature visitor copy (AGL-1510), read off the lever's declaration —\n * core or plugin — so a plugin's lever explains itself in its own words. A\n * key nothing declared (a document written before its plugin was removed)\n * gets the generic pause: the lock is still honored, it just cannot name\n * what it pauses.\n */\nfunction featureLockdownNotice(\n feature: LockdownFeatureKey,\n custom: string | undefined,\n untilMs?: number,\n): LockdownNotice {\n const window =\n typeof untilMs === 'number'\n ? ` Expected back by ${new Date(untilMs).toUTCString()}.`\n : ''\n const declared = lockdownFeatureDeclaration(feature)\n return {\n title: declared?.notice.title ?? 'Temporarily paused',\n body:\n custom ??\n `${\n declared?.notice.body ??\n 'This action is paused for a few minutes. Browsing works as normal — please try again shortly.'\n }${window}`,\n contact: lockdownSupportEmail() ?? undefined,\n }\n}\n\n/**\n * The wire shape of the 423 refusal body (`lockdownJsonResponse`). Declared\n * here, beside the copy that fills it, so the client parser below and the\n * server writer agree by construction rather than by comment.\n */\nexport interface LockdownRefusalBody {\n error?: unknown\n scope?: unknown\n feature?: unknown\n mode?: unknown\n reason?: unknown\n title?: unknown\n message?: unknown\n contact?: unknown\n appeal?: unknown\n untilMs?: unknown\n}\n\n/** A 423 body, parsed into something a client surface can render. */\nexport interface LockdownRefusalNotice {\n title: string\n message: string\n contact?: string\n /** The contact line offers an appeal — see {@link LockdownNotice.appeal}. */\n appeal?: boolean\n scope?: LockdownScope\n feature?: LockdownFeatureKey\n /**\n * `read-only` when the server refused a WRITE but is still serving reads\n * (AGL-1511). Absent on every refusal from a full lock, and on any older\n * deploy — a client must treat absence as \"no claim\", never as \"full\".\n */\n mode?: LockdownMode\n untilMs?: number\n /**\n * The expiry as a human, LOCAL-time line — `undefined` when the lock has\n * no expiry, which is most of them. Never a raw epoch number.\n */\n until?: string\n}\n\n/**\n * The generic-but-honest fallback: what a client says when the server said\n * Locked but the body told it nothing else. It must still be TRUE — \"this is\n * paused\", never \"something went wrong\" (the failure this whole affordance\n * exists to stop), and never the word `undefined`.\n */\nexport const LOCKDOWN_REFUSAL_FALLBACK_TITLE = 'Temporarily unavailable'\nexport const LOCKDOWN_REFUSAL_FALLBACK_MESSAGE =\n 'This is temporarily paused while we work on something. Nothing you have ' +\n 'created is affected — please try again shortly.'\n\n/**\n * The exact sentence the notice builders append to a DEFAULT body when a\n * lock has an expiry. Built here so the client parser can strip it by exact\n * match (same `untilMs`, same string) instead of sniffing a regex.\n */\nfunction lockdownUntilSuffix(untilMs: number): string {\n return `Expected back by ${new Date(untilMs).toUTCString()}.`\n}\n\n/**\n * The expiry as a client-side, LOCAL-time line (AGL-1532). A UTC string is\n * correct and unreadable; a customer wants to know when to come back on\n * their own clock.\n */\nexport function formatLockdownUntil(untilMs: number): string | undefined {\n if (!Number.isFinite(untilMs)) return undefined\n const when = new Date(untilMs)\n if (Number.isNaN(when.getTime())) return undefined\n const stamp = when.toLocaleString(undefined, {\n dateStyle: 'medium',\n timeStyle: 'short',\n })\n return `Expected back around ${stamp}.`\n}\n\n/**\n * Parse a fetch response's status + parsed JSON body into a renderable\n * lockdown notice, or `null` when this was not a lockdown refusal\n * (AGL-1532).\n *\n * ONE parser, used by every client call site a feature lock can refuse —\n * billing checkout, marketplace installs and purchases, the AI-assist\n * drawer. Three copies of this parsing is the second-implementation shape\n * that lets one surface drift back to \"checkout failed\" while the others\n * stay honest.\n *\n * Three rules, each of which a spec pins:\n *\n * 1. **Only 423.** A 500 — a real, unexplained failure — returns `null` so\n * the caller keeps its generic error toast. Dressing a genuine fault as\n * a deliberate pause is a worse lie than the one being fixed.\n * 2. **A 423 always yields a notice.** The server said Locked; that is the\n * honest thing to render even if the body is malformed, truncated by a\n * proxy, or from an older deploy. Missing fields degrade to the shared\n * per-feature copy when the body names a known feature, and to the\n * generic-but-honest fallback otherwise.\n * 3. **No duplicated expiry.** The default server copy already ends with a\n * UTC \"Expected back by …\" sentence; that exact suffix is stripped and\n * restated as `until` in the reader's local time. A staff-typed custom\n * message never carries the suffix, so nothing is stripped from it.\n */\nexport function parseLockdownRefusal(\n status: number,\n body: unknown,\n): LockdownRefusalNotice | null {\n if (status !== 423) return null\n const payload: LockdownRefusalBody =\n body && typeof body === 'object' ? (body as LockdownRefusalBody) : {}\n const feature = isLockdownFeatureKey(payload.feature)\n ? payload.feature\n : undefined\n const untilMs =\n typeof payload.untilMs === 'number' && Number.isFinite(payload.untilMs)\n ? payload.untilMs\n : undefined\n // A body that named a feature but lost its copy still gets the RIGHT\n // words — the same ones the server would have sent — because that copy\n // lives here too. Only a body naming nothing falls all the way back.\n const derived = feature\n ? featureLockdownNotice(feature, undefined, untilMs)\n : undefined\n const title =\n typeof payload.title === 'string' && payload.title.trim()\n ? payload.title.trim()\n : (derived?.title ?? LOCKDOWN_REFUSAL_FALLBACK_TITLE)\n let message =\n typeof payload.message === 'string' && payload.message.trim()\n ? payload.message.trim()\n : (derived?.body ?? LOCKDOWN_REFUSAL_FALLBACK_MESSAGE)\n if (typeof untilMs === 'number') {\n const suffix = lockdownUntilSuffix(untilMs)\n if (message.endsWith(suffix)) {\n message = message.slice(0, -suffix.length).trim()\n }\n }\n const contact =\n typeof payload.contact === 'string' && payload.contact.trim()\n ? payload.contact.trim()\n : undefined\n const until =\n typeof untilMs === 'number' ? formatLockdownUntil(untilMs) : undefined\n return {\n title,\n message,\n ...(contact ? { contact } : {}),\n ...(contact && payload.appeal === true ? { appeal: true } : {}),\n ...(typeof payload.scope === 'string'\n ? { scope: payload.scope as LockdownScope }\n : {}),\n ...(feature ? { feature } : {}),\n ...(isLockdownMode(payload.mode) ? { mode: payload.mode } : {}),\n ...(typeof untilMs === 'number' ? { untilMs } : {}),\n ...(until ? { until } : {}),\n }\n}\n\n/**\n * The notice as ONE line, for surfaces whose only affordance is a snackbar\n * (AGL-1532). The title is prefixed only when the message does not already\n * open with it — the checkout copy leads with its own title, and \"Checkout\n * is temporarily unavailable — Checkout is temporarily unavailable — this is\n * not a payment failure\" helps nobody.\n */\nexport function lockdownRefusalText(notice: LockdownRefusalNotice): string {\n const lower = notice.message.toLowerCase()\n const led = lower.startsWith(notice.title.toLowerCase())\n const head = led ? notice.message : `${notice.title} — ${notice.message}`\n return notice.until ? `${head} ${notice.until}` : head\n}\n\n/**\n * Which VISITOR surface a tenant plugin-API path belongs to, for read-only\n * pause copy (AGL-1511).\n *\n * The tenant dispatcher refuses every mutating method under a read-only lock\n * regardless of path — this only chooses the WORDS, and it exists because\n * the checkout sentence has a requirement no generic copy can carry: it must\n * never read as a declined card. Anything unrecognised gets the neutral\n * generic pause rather than a guess; being vague at a stranger is cheap,\n * being wrong about their money is not.\n */\nexport function lockdownPausedSurfaceForPluginApiPath(\n path: string,\n): LockdownPausedSurface {\n if (\n path === 'commerce/checkout' ||\n path === 'commerce/cart-checkout' ||\n path === 'commerce/pos-order' ||\n path === 'commerce/pos-payment' ||\n path === 'commerce/pos-offline-sync' ||\n path === 'commerce/draft-order'\n ) {\n return 'checkout'\n }\n if (path === 'commerce/cart' || path.startsWith('commerce/cart/')) return 'cart'\n return 'generic'\n}\n\n/**\n * Which feature keys gate a plugin-API dispatcher path (AGL-1510, plural\n * since AGL-1545). Lives here (pure, beside the catalog) so the dispatcher's\n * wiring is one call and the mapping is unit-testable without a route\n * harness.\n *\n * Every lever declares the paths it gates: exact paths, and prefixes matched\n * on a SEGMENT boundary, so `ai/generate` gates `ai/generate/section` and\n * never `ai/generated-report`. A path several levers name is gated by all\n * of them, each keeping its own staff-bypass rule when composed —\n * `marketplace/checkout` carries both `checkout` and `marketplace-installs`\n * (AGL-1545): it creates NEW Stripe checkout sessions exactly like the\n * billing route, and it is also the front door of a paid install, so a\n * malicious-listing incident must stop buyers PAYING for the artifact under\n * investigation, not merely refuse the install after the money moved.\n *\n * A plugin's lever gates its paths by existing (AGL-2903): a door\n * registered under a declared prefix is gated before anyone remembers to\n * wire it, which is the reason the mapping is declared beside the lever\n * rather than beside the route.\n */\nexport function lockdownFeaturesForPluginApiPath(\n path: string,\n): LockdownFeatureKey[] {\n const keys: LockdownFeatureKey[] = []\n for (const feature of listLockdownFeatures()) {\n const exact = feature.apiPaths?.exact ?? []\n const prefixes = feature.apiPaths?.prefixes ?? []\n const hit =\n exact.includes(path) ||\n prefixes.some(\n (prefix) =>\n path === prefix ||\n path.startsWith(prefix.endsWith('/') || prefix.endsWith('-') ? prefix : `${prefix}/`),\n )\n if (hit && !keys.includes(feature.key)) keys.push(feature.key)\n }\n return keys\n}\n"],"names":["TENANT_APEX","operatorContactLine","PLATFORM_BRAND_NAME","listPluginLockdownFeatures","CORE_LOCKDOWN_FEATURES","key","label","customerName","staffBypass","notice","title","body","apiPaths","exact","prefixes","listLockdownFeatures","listLockdownFeatureKeys","map","feature","lockdownFeatureDeclaration","undefined","find","isLockdownFeatureKey","value","lockdownFeatureLabel","lockdownFeatureCustomerName","trim","lockdownFeaturesCustomerText","keys","names","Set","Intl","ListFormat","style","type","format","lockdownFeatureStaffBypass","LOCKDOWN_MODE_KEYS","full","LOCKDOWN_MODES","Object","isLockdownMode","lockdownMode","state","mode","isReadOnlyLockdown","LOCKDOWN_ENFORCEMENT_KEYS","standard","takedown","LOCKDOWN_ENFORCEMENTS","isLockdownEnforcement","LOCKDOWN_ENFORCEMENT_LABELS","lockdownEnforcement","enforcement","isTakedownLockdown","lockdownIntentForMethod","method","normalized","toUpperCase","lockdownBlocks","intent","LOCKDOWN_REASON_CODE_KEYS","security","abuse","billing","maintenance","manual","LOCKDOWN_REASON_CODES","isLockdownReasonCode","LOCKDOWN_REASON_LABELS","isSecurityClassLockdownReason","reason","isAccountBanLockdownReason","LOCKDOWNS_COLLECTION","PLATFORM_LOCKDOWN_DOC_ID","userLockdownDocId","uid","featureLockdownDocId","orgFeatureLockdownDocId","orgId","domainLockdownDocId","hostname","toLowerCase","LOCKABLE_DOMAIN_PATTERN","LOCKABLE_DOMAIN_MAX","isLockableDomain","domain","length","test","endsWith","LOCKDOWN_MESSAGE_MAX","toEpochMs","Number","isFinite","parsed","Date","parse","isNaN","record","toMillis","seconds","_seconds","isLockdownActive","nowMs","untilMs","SIGNUPS_CREATION_LOCK_READ_TIMEOUT_MS","lastReadSignupsLock","resetSignupsLockMemory","signupsLockEngaged","signupsCreationVerdict","readLock","timeoutMs","timer","Promise","race","_resolve","reject","setTimeout","Error","refused","cause","unreadable","clearTimeout","resolveLockdown","states","active","platform","org","host","user","filter","Boolean","normalizeOrgLockdown","suspendedAt","scope","suspendedMode","suspendedEnforcement","suspendedReasonCode","message","suspendedMessage","slice","atMs","suspendedUntilMs","normalizeHostLockdown","siteLockdownFromDocs","docs","normalizeLockdownDoc","doc","actorUid","lockdownRetryAfterSeconds","Math","max","ceil","lockdownSupportEmail","address","lockdownNotice","custom","featureLockdownNotice","readOnlyLockdownNotice","domainLockdownNotice","subject","until","toUTCString","contact","appeal","window","lockdownUntilSuffix","lockdownPausedNotice","surface","declared","LOCKDOWN_REFUSAL_FALLBACK_TITLE","LOCKDOWN_REFUSAL_FALLBACK_MESSAGE","formatLockdownUntil","when","getTime","stamp","toLocaleString","dateStyle","timeStyle","parseLockdownRefusal","status","payload","derived","suffix","lockdownRefusalText","lower","led","startsWith","head","lockdownPausedSurfaceForPluginApiPath","path","lockdownFeaturesForPluginApiPath","hit","includes","some","prefix","push"],"mappings":"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"}
@@ -124,10 +124,13 @@ const WORD_PARTS = /[@.+_-]+/;
124
124
  * becomes (`nameSearchToken`) is one of these.
125
125
  */ function addPrefixes(tokens, words, max) {
126
126
  for (const word of words){
127
- const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX);
127
+ // By codepoint, as `nameSearchTokens` cuts (a lone surrogate fails the write).
128
+ const capped = [
129
+ ...word
130
+ ].slice(0, NAME_TOKEN_MAX_PREFIX);
128
131
  for(let end = 1; end <= capped.length; end += 1){
129
132
  if (tokens.size >= max) return;
130
- tokens.add(capped.slice(0, end));
133
+ tokens.add(capped.slice(0, end).join(''));
131
134
  }
132
135
  }
133
136
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/message-search.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 { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from './name-search'\n\n/*\n * THE SEARCH KEYS OF A MESSAGE A VISITOR SENT (AGL-3321).\n *\n * A message is a free-form map of the fields its author declared — `name`,\n * `Email Address`, `message`, `Company size` — with no guaranteed shape. A\n * list of them is searched on its Firestore query, which cannot look inside\n * a string, so every writer stamps the words as word-prefix token arrays the\n * query asks with `array-contains`:\n *\n * senderTokens the sender's name and address — who wrote in. What the\n * list's From filter asks.\n * searchTokens the sender's tokens, then the words of every value the\n * message carries. What the list's search box asks.\n *\n * The words are the platform's search keys (`nameSearchKey`, prefixes up to\n * `NAME_TOKEN_MAX_PREFIX`), so a typed word becomes the token the query asks\n * through the same `nameSearchNormalizers` every list uses. A word that joins\n * parts with `@ . + _ -` — an address, a domain, a hyphenated name — is also\n * its parts, so `acme` finds `dana@acme.com` and `dana` does too.\n *\n * ## What is capped\n *\n * A message is free text, so the arrays are bounded rather than complete:\n * the first {@link MESSAGE_SEARCH_WORDS_MAX} distinct words of the values\n * contribute, and each array stops at its own ceiling\n * ({@link MESSAGE_SENDER_TOKENS_MAX}, {@link MESSAGE_SEARCH_TOKENS_MAX}) —\n * the sender first, so a long message loses the tail of its text before it\n * loses who sent it. A word past the cap is still in the message; the search\n * box just does not find it.\n *\n * ## Deterministic whatever the map's key order\n *\n * A map's keys come back in a different order from different readers (the\n * Admin SDK sorts them), and a cap makes the result order-sensitive. So the\n * values are read in SORTED key order, and a backfill that re-derives the\n * arrays from a stored message computes exactly what the writer stamped.\n *\n * `tools/scripts/backfill-form-submission-filters.mjs` restates this for the\n * rows written before it, held to `tools/scripts/lib/message-search.fixtures.json`\n * with the spec beside this file.\n */\n\n/** The most tokens the sender contributes. A name and an address are a few dozen. */\nexport const MESSAGE_SENDER_TOKENS_MAX = 60\n\n/** The most tokens one message's search array holds, the sender's included. */\nexport const MESSAGE_SEARCH_TOKENS_MAX = 200\n\n/** The most distinct words of the message's values that contribute prefixes. */\nexport const MESSAGE_SEARCH_WORDS_MAX = 40\n\n/**\n * Field names that mean \"this is who wrote in\", most specific first,\n * compared against a key REDUCED to lowercase letters and digits — the same\n * field arrives as `Full Name`, `full_name` and `fullname`.\n */\nconst SENDER_NAME_KEYS = ['name', 'fullname', 'yourname', 'firstname', 'contactname']\nconst SENDER_EMAIL_KEYS = ['email', 'emailaddress']\n\n/** The joins a word is also split on, so an address is also its parts. */\nconst WORD_JOINS = /[@.+_-]/\nconst WORD_PARTS = /[@.+_-]+/\n/** Punctuation around a word — `cake?`, `(june)` — is not part of it. */\nconst WORD_EDGES = /^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu\n\n/** A value as the text it reads as, or '' for anything that is not text. */\nconst textOf = (value: unknown): string => {\n if (typeof value === 'string') return value\n if (typeof value === 'number' && Number.isFinite(value)) return String(value)\n if (typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map(textOf).filter(Boolean).join(' ')\n return ''\n}\n\n/** Who wrote in, from the message's entries in the order given. */\nfunction senderOf(entries: ReadonlyArray<readonly [string, unknown]>): {\n name?: string\n email?: string\n} {\n const reduced = new Map<string, string>()\n for (const [key, value] of entries) {\n const text = textOf(value).trim()\n if (!text) continue\n const at = key.toLowerCase().replace(/[^a-z0-9]/g, '')\n if (!reduced.has(at)) reduced.set(at, text)\n }\n const name = SENDER_NAME_KEYS.map((key) => reduced.get(key)).find(Boolean)\n const email = SENDER_EMAIL_KEYS.map((key) => reduced.get(key)).find(Boolean)\n return { ...(name ? { name } : {}), ...(email ? { email } : {}) }\n}\n\n/**\n * Who wrote in, by the field-name convention: the first non-empty value of\n * a name-like field and of an address-like field. Reads the map in the\n * order it is handed, so the first spelling of a reduced key wins.\n */\nexport function messageSender(\n fields: Record<string, unknown> | null | undefined,\n): { name?: string; email?: string } {\n return senderOf(Object.entries(fields ?? {}))\n}\n\n/** The words of a text: its key's words without edge punctuation, and their parts. */\nfunction wordsOf(text: string): string[] {\n const words: string[] = []\n for (const raw of nameSearchKey(text).split(' ')) {\n const word = raw.replace(WORD_EDGES, '')\n if (!word) continue\n words.push(word)\n if (WORD_JOINS.test(word)) {\n for (const part of word.split(WORD_PARTS)) if (part && part !== word) words.push(part)\n }\n }\n return words\n}\n\n/**\n * Every prefix of each word, up to the prefix cap, into `tokens` until `max`\n * — cut exactly as `nameSearchTokens` cuts, so the one token a typed word\n * becomes (`nameSearchToken`) is one of these.\n */\nfunction addPrefixes(tokens: Set<string>, words: readonly string[], max: number): void {\n for (const word of words) {\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n if (tokens.size >= max) return\n tokens.add(capped.slice(0, end))\n }\n }\n}\n\n/**\n * The two search arrays a message is stamped with — see the block above.\n * Spread onto every write that creates one; a message's values never change\n * after it arrives, so no other write touches them.\n */\nexport function messageSearchFields(fields: Record<string, unknown> | null | undefined): {\n senderTokens: string[]\n searchTokens: string[]\n} {\n // Sorted entries, never an object rebuilt from them: an object puts\n // integer-like keys first whatever order they were added in.\n const sorted = Object.entries(fields ?? {}).sort(([left], [right]) =>\n left < right ? -1 : left > right ? 1 : 0,\n )\n const sender = senderOf(sorted)\n const senderSet = new Set<string>()\n addPrefixes(\n senderSet,\n [...wordsOf(sender.name ?? ''), ...wordsOf(sender.email ?? '')],\n MESSAGE_SENDER_TOKENS_MAX,\n )\n const words = new Set<string>()\n for (const word of sorted.flatMap(([, value]) => wordsOf(textOf(value)))) {\n if (words.size >= MESSAGE_SEARCH_WORDS_MAX) break\n words.add(word)\n }\n const search = new Set<string>(senderSet)\n addPrefixes(search, [...words], MESSAGE_SEARCH_TOKENS_MAX)\n return { senderTokens: [...senderSet], searchTokens: [...search] }\n}\n"],"names":["NAME_TOKEN_MAX_PREFIX","nameSearchKey","MESSAGE_SENDER_TOKENS_MAX","MESSAGE_SEARCH_TOKENS_MAX","MESSAGE_SEARCH_WORDS_MAX","SENDER_NAME_KEYS","SENDER_EMAIL_KEYS","WORD_JOINS","WORD_PARTS","WORD_EDGES","textOf","value","Number","isFinite","String","Array","isArray","map","filter","Boolean","join","senderOf","entries","reduced","Map","key","text","trim","at","toLowerCase","replace","has","set","name","get","find","email","messageSender","fields","Object","wordsOf","words","raw","split","word","push","test","part","addPrefixes","tokens","max","capped","slice","end","length","size","add","messageSearchFields","sender","sorted","sort","left","right","senderSet","Set","flatMap","search","senderTokens","searchTokens"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/message-search.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 { NAME_TOKEN_MAX_PREFIX, nameSearchKey } from './name-search'\n\n/*\n * THE SEARCH KEYS OF A MESSAGE A VISITOR SENT (AGL-3321).\n *\n * A message is a free-form map of the fields its author declared — `name`,\n * `Email Address`, `message`, `Company size` — with no guaranteed shape. A\n * list of them is searched on its Firestore query, which cannot look inside\n * a string, so every writer stamps the words as word-prefix token arrays the\n * query asks with `array-contains`:\n *\n * senderTokens the sender's name and address — who wrote in. What the\n * list's From filter asks.\n * searchTokens the sender's tokens, then the words of every value the\n * message carries. What the list's search box asks.\n *\n * The words are the platform's search keys (`nameSearchKey`, prefixes up to\n * `NAME_TOKEN_MAX_PREFIX`), so a typed word becomes the token the query asks\n * through the same `nameSearchNormalizers` every list uses. A word that joins\n * parts with `@ . + _ -` — an address, a domain, a hyphenated name — is also\n * its parts, so `acme` finds `dana@acme.com` and `dana` does too.\n *\n * ## What is capped\n *\n * A message is free text, so the arrays are bounded rather than complete:\n * the first {@link MESSAGE_SEARCH_WORDS_MAX} distinct words of the values\n * contribute, and each array stops at its own ceiling\n * ({@link MESSAGE_SENDER_TOKENS_MAX}, {@link MESSAGE_SEARCH_TOKENS_MAX}) —\n * the sender first, so a long message loses the tail of its text before it\n * loses who sent it. A word past the cap is still in the message; the search\n * box just does not find it.\n *\n * ## Deterministic whatever the map's key order\n *\n * A map's keys come back in a different order from different readers (the\n * Admin SDK sorts them), and a cap makes the result order-sensitive. So the\n * values are read in SORTED key order, and a backfill that re-derives the\n * arrays from a stored message computes exactly what the writer stamped.\n *\n * `tools/scripts/backfill-form-submission-filters.mjs` restates this for the\n * rows written before it, held to `tools/scripts/lib/message-search.fixtures.json`\n * with the spec beside this file.\n */\n\n/** The most tokens the sender contributes. A name and an address are a few dozen. */\nexport const MESSAGE_SENDER_TOKENS_MAX = 60\n\n/** The most tokens one message's search array holds, the sender's included. */\nexport const MESSAGE_SEARCH_TOKENS_MAX = 200\n\n/** The most distinct words of the message's values that contribute prefixes. */\nexport const MESSAGE_SEARCH_WORDS_MAX = 40\n\n/**\n * Field names that mean \"this is who wrote in\", most specific first,\n * compared against a key REDUCED to lowercase letters and digits — the same\n * field arrives as `Full Name`, `full_name` and `fullname`.\n */\nconst SENDER_NAME_KEYS = ['name', 'fullname', 'yourname', 'firstname', 'contactname']\nconst SENDER_EMAIL_KEYS = ['email', 'emailaddress']\n\n/** The joins a word is also split on, so an address is also its parts. */\nconst WORD_JOINS = /[@.+_-]/\nconst WORD_PARTS = /[@.+_-]+/\n/** Punctuation around a word — `cake?`, `(june)` — is not part of it. */\nconst WORD_EDGES = /^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu\n\n/** A value as the text it reads as, or '' for anything that is not text. */\nconst textOf = (value: unknown): string => {\n if (typeof value === 'string') return value\n if (typeof value === 'number' && Number.isFinite(value)) return String(value)\n if (typeof value === 'boolean') return String(value)\n if (Array.isArray(value)) return value.map(textOf).filter(Boolean).join(' ')\n return ''\n}\n\n/** Who wrote in, from the message's entries in the order given. */\nfunction senderOf(entries: ReadonlyArray<readonly [string, unknown]>): {\n name?: string\n email?: string\n} {\n const reduced = new Map<string, string>()\n for (const [key, value] of entries) {\n const text = textOf(value).trim()\n if (!text) continue\n const at = key.toLowerCase().replace(/[^a-z0-9]/g, '')\n if (!reduced.has(at)) reduced.set(at, text)\n }\n const name = SENDER_NAME_KEYS.map((key) => reduced.get(key)).find(Boolean)\n const email = SENDER_EMAIL_KEYS.map((key) => reduced.get(key)).find(Boolean)\n return { ...(name ? { name } : {}), ...(email ? { email } : {}) }\n}\n\n/**\n * Who wrote in, by the field-name convention: the first non-empty value of\n * a name-like field and of an address-like field. Reads the map in the\n * order it is handed, so the first spelling of a reduced key wins.\n */\nexport function messageSender(\n fields: Record<string, unknown> | null | undefined,\n): { name?: string; email?: string } {\n return senderOf(Object.entries(fields ?? {}))\n}\n\n/** The words of a text: its key's words without edge punctuation, and their parts. */\nfunction wordsOf(text: string): string[] {\n const words: string[] = []\n for (const raw of nameSearchKey(text).split(' ')) {\n const word = raw.replace(WORD_EDGES, '')\n if (!word) continue\n words.push(word)\n if (WORD_JOINS.test(word)) {\n for (const part of word.split(WORD_PARTS)) if (part && part !== word) words.push(part)\n }\n }\n return words\n}\n\n/**\n * Every prefix of each word, up to the prefix cap, into `tokens` until `max`\n * — cut exactly as `nameSearchTokens` cuts, so the one token a typed word\n * becomes (`nameSearchToken`) is one of these.\n */\nfunction addPrefixes(tokens: Set<string>, words: readonly string[], max: number): void {\n for (const word of words) {\n // By codepoint, as `nameSearchTokens` cuts (a lone surrogate fails the write).\n const capped = [...word].slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n if (tokens.size >= max) return\n tokens.add(capped.slice(0, end).join(''))\n }\n }\n}\n\n/**\n * The two search arrays a message is stamped with — see the block above.\n * Spread onto every write that creates one; a message's values never change\n * after it arrives, so no other write touches them.\n */\nexport function messageSearchFields(fields: Record<string, unknown> | null | undefined): {\n senderTokens: string[]\n searchTokens: string[]\n} {\n // Sorted entries, never an object rebuilt from them: an object puts\n // integer-like keys first whatever order they were added in.\n const sorted = Object.entries(fields ?? {}).sort(([left], [right]) =>\n left < right ? -1 : left > right ? 1 : 0,\n )\n const sender = senderOf(sorted)\n const senderSet = new Set<string>()\n addPrefixes(\n senderSet,\n [...wordsOf(sender.name ?? ''), ...wordsOf(sender.email ?? '')],\n MESSAGE_SENDER_TOKENS_MAX,\n )\n const words = new Set<string>()\n for (const word of sorted.flatMap(([, value]) => wordsOf(textOf(value)))) {\n if (words.size >= MESSAGE_SEARCH_WORDS_MAX) break\n words.add(word)\n }\n const search = new Set<string>(senderSet)\n addPrefixes(search, [...words], MESSAGE_SEARCH_TOKENS_MAX)\n return { senderTokens: [...senderSet], searchTokens: [...search] }\n}\n"],"names":["NAME_TOKEN_MAX_PREFIX","nameSearchKey","MESSAGE_SENDER_TOKENS_MAX","MESSAGE_SEARCH_TOKENS_MAX","MESSAGE_SEARCH_WORDS_MAX","SENDER_NAME_KEYS","SENDER_EMAIL_KEYS","WORD_JOINS","WORD_PARTS","WORD_EDGES","textOf","value","Number","isFinite","String","Array","isArray","map","filter","Boolean","join","senderOf","entries","reduced","Map","key","text","trim","at","toLowerCase","replace","has","set","name","get","find","email","messageSender","fields","Object","wordsOf","words","raw","split","word","push","test","part","addPrefixes","tokens","max","capped","slice","end","length","size","add","messageSearchFields","sender","sorted","sort","left","right","senderSet","Set","flatMap","search","senderTokens","searchTokens"],"mappings":"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"}
@@ -70,9 +70,17 @@
70
70
  const tokens = new Set();
71
71
  for (const word of key.split(' ')){
72
72
  if (!word) continue;
73
- const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX);
73
+ // Walked by CODEPOINT, never by UTF-16 unit (AGL-3689, 2026-10-08). The
74
+ // first unit of an emoji is a lone surrogate, which is not valid UTF-8,
75
+ // and Firestore refuses the whole write with `3 INVALID_ARGUMENT` — a
76
+ // site named "Nova Library. 📚" was created and then failed its member
77
+ // projections, so its owner got a 500 for a site that existed. Identical
78
+ // to the unit walk for every BMP name, so no stored token moves.
79
+ const capped = [
80
+ ...word
81
+ ].slice(0, NAME_TOKEN_MAX_PREFIX);
74
82
  for(let end = 1; end <= capped.length; end += 1){
75
- tokens.add(capped.slice(0, end));
83
+ tokens.add(capped.slice(0, end).join(''));
76
84
  if (tokens.size >= NAME_TOKEN_LIMIT) return [
77
85
  ...tokens
78
86
  ];
@@ -95,7 +103,11 @@
95
103
  var _key_split_;
96
104
  const key = nameSearchKey(query);
97
105
  if (!key) return '';
98
- return ((_key_split_ = key.split(' ')[0]) != null ? _key_split_ : '').slice(0, NAME_TOKEN_MAX_PREFIX);
106
+ // By codepoint, as the tokens were written: a query is capped where a
107
+ // stored token was, or it would ask for one that was never stored.
108
+ return [
109
+ ...(_key_split_ = key.split(' ')[0]) != null ? _key_split_ : ''
110
+ ].slice(0, NAME_TOKEN_MAX_PREFIX).join('');
99
111
  }
100
112
  /**
101
113
  * The normalized name, reversed, so "ends with" becomes a prefix range.