@aglyn/tenant-data-admin 1.0.0-beta.217 → 1.0.0-beta.219

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 (122) hide show
  1. package/package.json +9 -9
  2. package/src/lib/server/account-lock-mail.d.ts +74 -0
  3. package/src/lib/server/account-lock-mail.js +139 -0
  4. package/src/lib/server/account-lock-mail.js.map +1 -0
  5. package/src/lib/server/account-mail.d.ts +41 -0
  6. package/src/lib/server/account-mail.js +52 -0
  7. package/src/lib/server/account-mail.js.map +1 -0
  8. package/src/lib/server/api-v1-description.d.ts +112 -0
  9. package/src/lib/server/api-v1-description.js +126 -0
  10. package/src/lib/server/api-v1-description.js.map +1 -0
  11. package/src/lib/server/api-v1-kit.d.ts +156 -0
  12. package/src/lib/server/api-v1-kit.js +191 -0
  13. package/src/lib/server/api-v1-kit.js.map +1 -0
  14. package/src/lib/server/api-v1-resources.d.ts +144 -0
  15. package/src/lib/server/api-v1-resources.js +126 -0
  16. package/src/lib/server/api-v1-resources.js.map +1 -0
  17. package/src/lib/server/consent-group-change.d.ts +2 -1
  18. package/src/lib/server/consent-group-change.js +41 -16
  19. package/src/lib/server/consent-group-change.js.map +1 -1
  20. package/src/lib/server/contact-email-index.d.ts +22 -0
  21. package/src/lib/server/contact-email-index.js +108 -0
  22. package/src/lib/server/contact-email-index.js.map +1 -1
  23. package/src/lib/server/crm-email-activity.d.ts +1 -1
  24. package/src/lib/server/crm-email-activity.js +1 -1
  25. package/src/lib/server/crm-email-activity.js.map +1 -1
  26. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  27. package/src/lib/server/crm-inbound-email.js +1 -16
  28. package/src/lib/server/crm-inbound-email.js.map +1 -1
  29. package/src/lib/server/crm-records.d.ts +9 -1
  30. package/src/lib/server/crm-records.js +19 -2
  31. package/src/lib/server/crm-records.js.map +1 -1
  32. package/src/lib/server/cron-auth.d.ts +44 -0
  33. package/src/lib/server/cron-auth.js +76 -0
  34. package/src/lib/server/cron-auth.js.map +1 -0
  35. package/src/lib/server/dataset-live-pages.js +6 -6
  36. package/src/lib/server/dataset-live-pages.js.map +1 -1
  37. package/src/lib/server/duplicate-resource.d.ts +26 -0
  38. package/src/lib/server/duplicate-resource.js +47 -25
  39. package/src/lib/server/duplicate-resource.js.map +1 -1
  40. package/src/lib/server/dynamic-list-materialize.js +4 -1
  41. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  42. package/src/lib/server/email-deliverability.js +21 -6
  43. package/src/lib/server/email-deliverability.js.map +1 -1
  44. package/src/lib/server/email-suppression.d.ts +83 -3
  45. package/src/lib/server/email-suppression.js +229 -3
  46. package/src/lib/server/email-suppression.js.map +1 -1
  47. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  48. package/src/lib/server/email-unsubscribe-link.js +7 -7
  49. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  50. package/src/lib/server/erase.d.ts +19 -33
  51. package/src/lib/server/erase.js +37 -162
  52. package/src/lib/server/erase.js.map +1 -1
  53. package/src/lib/server/host-email-tokens.js +1 -1
  54. package/src/lib/server/host-email-tokens.js.map +1 -1
  55. package/src/lib/server/host-memberships.js +3 -3
  56. package/src/lib/server/host-memberships.js.map +1 -1
  57. package/src/lib/server/lockdown.js +2 -0
  58. package/src/lib/server/lockdown.js.map +1 -1
  59. package/src/lib/server/media-download-tokens.js +2 -1
  60. package/src/lib/server/media-download-tokens.js.map +1 -1
  61. package/src/lib/server/member-email-aliases.d.ts +1 -1
  62. package/src/lib/server/member-email-aliases.js +1 -1
  63. package/src/lib/server/member-email-aliases.js.map +1 -1
  64. package/src/lib/server/new-account-announcement.d.ts +62 -0
  65. package/src/lib/server/new-account-announcement.js +77 -0
  66. package/src/lib/server/new-account-announcement.js.map +1 -0
  67. package/src/lib/server/notifications.d.ts +8 -2
  68. package/src/lib/server/notifications.js +18 -4
  69. package/src/lib/server/notifications.js.map +1 -1
  70. package/src/lib/server/org-member-notice.js +5 -1
  71. package/src/lib/server/org-member-notice.js.map +1 -1
  72. package/src/lib/server/organizations.js +4 -0
  73. package/src/lib/server/organizations.js.map +1 -1
  74. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  75. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  76. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  77. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  78. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  79. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  80. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  81. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  82. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  83. package/src/lib/server/payment-provider.d.ts +173 -0
  84. package/src/lib/server/payment-provider.js +70 -0
  85. package/src/lib/server/payment-provider.js.map +1 -0
  86. package/src/lib/server/personal-data-export.js +1 -1
  87. package/src/lib/server/personal-data-export.js.map +1 -1
  88. package/src/lib/server/rate-limit-store.js +13 -3
  89. package/src/lib/server/rate-limit-store.js.map +1 -1
  90. package/src/lib/server/realm-plugins.js +8 -3
  91. package/src/lib/server/realm-plugins.js.map +1 -1
  92. package/src/lib/server/retained-refusals.d.ts +142 -0
  93. package/src/lib/server/retained-refusals.js +325 -0
  94. package/src/lib/server/retained-refusals.js.map +1 -0
  95. package/src/lib/server/risk-notice.js +21 -3
  96. package/src/lib/server/risk-notice.js.map +1 -1
  97. package/src/lib/server/serve-media-cdn.js +2 -2
  98. package/src/lib/server/serve-media-cdn.js.map +1 -1
  99. package/src/lib/server/serve-plugin-fetch.d.ts +17 -7
  100. package/src/lib/server/serve-plugin-fetch.js +19 -8
  101. package/src/lib/server/serve-plugin-fetch.js.map +1 -1
  102. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  103. package/src/lib/server/test-firestore-queries.js +70 -27
  104. package/src/lib/server/test-firestore-queries.js.map +1 -1
  105. package/src/lib/server/upsert-contact.d.ts +46 -1
  106. package/src/lib/server/upsert-contact.js +85 -18
  107. package/src/lib/server/upsert-contact.js.map +1 -1
  108. package/src/tenancy.d.ts +4 -5
  109. package/src/tenancy.js +11 -6
  110. package/src/tenancy.js.map +1 -1
  111. package/src/lib/server/connect-account-status.js.map +0 -1
  112. package/src/lib/server/connect-payout-failure.js.map +0 -1
  113. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  114. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  115. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  116. package/src/lib/server/contact-merge.d.ts +0 -55
  117. package/src/lib/server/contact-merge.js +0 -288
  118. package/src/lib/server/contact-merge.js.map +0 -1
  119. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  120. package/src/lib/server/form-dataset-binding-token.js +0 -107
  121. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  122. package/src/lib/server/stripe-account-mode.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/marketplace-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 {\n resolveSiteTheme,\n type ThemeHostDocument,\n} from '@aglyn/aglyn/app-utils/site-theme'\nimport { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport { buildSiteEmailChrome } from '@aglyn/aglyn/app-utils/site-email-chrome'\nimport {\n buildEmailPalette,\n emailPaletteBaseForHost,\n getTenantEmail,\n hostEmailOrigin,\n loadHostEmail,\n renderFramedTextEmail,\n renderHostEmail,\n renderLoadedHostEmail,\n type EmailChrome,\n type EmailTheme,\n type LoadedHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n const host = await readHostTokenSource(firestore, hostId)\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n //\n // And the site's header and footer (AGL-3370), which the built-in copy is\n // drawn inside; a design the owner published goes out as they built it.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n },\n )\n}\n\n/**\n * The site's host document as the token and chrome readers see it, or null.\n *\n * A failed read must not stop the send. Every token then resolves to its\n * empty behaviour, which is the same outcome as a site that set nothing —\n * an email with a gap where the address would be, rather than no email.\n */\nasync function readHostTokenSource(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n): Promise<SiteEmailHost | null> {\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n return snapshot.exists ? (snapshot.data() as SiteEmailHost) : null\n } catch {\n return null\n }\n}\n\n/** What a site email reads off the host document: its tokens and its theme. */\ntype SiteEmailHost = HostTokenSource & {\n theme?: {\n colorSchemes?: { light?: Record<string, unknown> | null }\n shape?: { borderRadius?: unknown }\n } | null\n themeOverride?: unknown\n}\n\n/**\n * The site's theme as its email draws it (AGL-3370): the palette the\n * published site renders with, light scheme, since an inbox has one, and its\n * corner radius for buttons. A site with no theme of its own resolves the\n * same base the site itself does.\n */\nfunction siteThemeFor(host: SiteEmailHost | null): EmailTheme {\n // Resolved, not read off `theme`: a site's edits are an override on the\n // theme it picked (AGL-3404), so the stored theme alone is the design\n // before anything the site changed.\n const theme = resolveSiteTheme(host as ThemeHostDocument | null)\n const radius = theme?.shape?.borderRadius\n return {\n palette: buildEmailPalette({\n base: emailPaletteBaseForHost(host),\n colors: (theme?.colorSchemes?.light ?? null) as Record<string, unknown> | null,\n }),\n ...(typeof radius === 'number' ? { buttonRadius: radius } : {}),\n }\n}\n\n/** The site's chrome for one kind of email, with that kind's footer reason. */\nfunction siteChromeFor(\n host: HostTokenSource | null,\n templateKey: string,\n): EmailChrome {\n return buildSiteEmailChrome({\n host,\n reason: getTenantEmail(templateKey)?.footerReason,\n })\n}\n\n/** A site email loaded once for a batch, with the site's tokens beside it. */\nexport interface LoadedHostEmailWithTokens {\n loaded: LoadedHostEmail\n hostMerge: Record<string, string>\n}\n\n/**\n * {@link loadHostEmail} for a batch (AGL-3370): the template, or the built-in\n * copy in the site's header and footer, read ONCE, with the site's `host.*`\n * tokens beside it for {@link renderLoadedHostEmailWithTokens}. `null` only\n * for a key with neither, so the batch keeps its own copy.\n */\nexport async function loadHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n options: { origin?: string } = {},\n): Promise<LoadedHostEmailWithTokens | null> {\n const host = await readHostTokenSource(firestore, hostId)\n const loaded = await loadHostEmail(firestore as never, hostId, templateKey, {\n ...options,\n compose: composeHostComponentNodes,\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n })\n return loaded ? { loaded, hostMerge: hostTokenMerge(host) } : null\n}\n\n/** One recipient's copy of a batch-loaded site email; no reads. */\nexport function renderLoadedHostEmailWithTokens(\n bundle: LoadedHostEmailWithTokens,\n merge: Record<string, string> = {},\n): RenderedHostEmail | null {\n return renderLoadedHostEmail(\n bundle.loaded,\n { ...bundle.hostMerge, ...merge },\n sanitizeAuthorHtml,\n )\n}\n\n/**\n * The site's header and footer for one kind of email, its theme, and the\n * origin and host a stored logo reference resolves against (AGL-3370): for a sender\n * that renders per recipient itself, such as a typed campaign.\n */\nexport async function siteEmailFrame(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n): Promise<{\n chrome: EmailChrome\n theme: EmailTheme\n mediaOrigin?: string\n mediaHostId: string\n}> {\n const host = await readHostTokenSource(firestore, hostId)\n const origin = host\n ? hostEmailOrigin({ cname: host.cname, subdomain: host.subdomain })\n : undefined\n return {\n chrome: siteChromeFor(host, templateKey),\n theme: siteThemeFor(host),\n ...(origin ? { mediaOrigin: origin } : {}),\n mediaHostId: hostId,\n }\n}\n\n/**\n * A text-only site email in the site's header and footer (AGL-3370): a\n * workflow's email step, or a message a site sends in words nobody designed.\n * The text arrives resolved; the footer's reason is the kind's, by key.\n */\nexport async function renderSiteTextEmail(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n message: { subject: string; text: string; preheader?: string },\n): Promise<{ subject: string; html: string; text: string }> {\n const frame = await siteEmailFrame(firestore, hostId, templateKey)\n const rendered = renderFramedTextEmail({ ...message, ...frame })\n return { subject: message.subject, html: rendered.html, text: rendered.text }\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["resolveSiteTheme","sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","buildSiteEmailChrome","buildEmailPalette","emailPaletteBaseForHost","getTenantEmail","hostEmailOrigin","loadHostEmail","renderFramedTextEmail","renderHostEmail","renderLoadedHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","readHostTokenSource","sanitize","compose","chrome","siteChromeFor","theme","siteThemeFor","snapshot","collection","doc","get","exists","data","radius","shape","borderRadius","palette","base","colors","colorSchemes","light","buttonRadius","reason","footerReason","loadHostEmailWithTokens","loaded","hostMerge","renderLoadedHostEmailWithTokens","bundle","siteEmailFrame","origin","cname","subdomain","undefined","mediaOrigin","mediaHostId","renderSiteTextEmail","message","frame","rendered","subject","html","text"],"mappings":"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"}
@@ -86,7 +86,7 @@ const readHostMeta = async (db, hostIds)=>{
86
86
  ];
87
87
  }));
88
88
  };
89
- const listMembers = async (db, orgId)=>{
89
+ const readOrgMembers = async (db, orgId)=>{
90
90
  const snap = await db.collection('orgs').doc(orgId).collection('members').get();
91
91
  return snap.docs.map((d)=>_extends({
92
92
  $id: d.id
@@ -269,7 +269,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
269
269
  */ export async function syncHostProjectionForMembers(orgId, hostId) {
270
270
  const db = firestore();
271
271
  const [members, meta] = await Promise.all([
272
- listMembers(db, orgId),
272
+ readOrgMembers(db, orgId),
273
273
  readHostMeta(db, [
274
274
  hostId
275
275
  ])
@@ -301,7 +301,7 @@ const membershipRef = (db, uid, hostId)=>db.collection('users').doc(uid).collect
301
301
  }
302
302
  /** Delete every member's projection row for one host (host erased). */ export async function deleteHostProjectionForAllMembers(orgId, hostId) {
303
303
  const db = firestore();
304
- const members = await listMembers(db, orgId);
304
+ const members = await readOrgMembers(db, orgId);
305
305
  await commitChunked(db, members.map((member)=>(batch)=>{
306
306
  batch.delete(membershipRef(db, member.$id, hostId));
307
307
  }));
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst listMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n listMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await listMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","listMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-memberships.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 * Per-user host membership projection (AGL-844): a reverse index at\n * `users/{uid}/hostMemberships/{hostId}` mirroring `hosts/{hostId}.memberRoles`\n * — the hosts a user can reach in an org, denormalized with the name so the\n * site switcher and subdomain→id routing can query + order + name-prefix-search\n * a user's own sites without loading the whole `hosts` collection (which the\n * rules can only serve unordered under a per-user `memberRoles.{uid}` filter).\n *\n * Because it lives under the user's own doc it is owner-readable and already\n * membership-filtered, so `where(orgId==…) orderBy(nameLower|updatedAt)` is a\n * plain composite index (no dynamic map path). Admin-SDK-written only.\n *\n * It is a best-effort convenience index, NOT an authorization source: real\n * access stays gated by `hosts/{hostId}.memberRoles` in the rules, so a stale\n * row at worst shows a site that then 404s on read — never grants access.\n *\n * Writers are scoped, mirroring the `users/{uid}/orgs` reverse index: a member\n * change re-syncs THAT member across the org's hosts; a host change re-syncs\n * THAT host across members. Kept in sync at the same points as `memberRoles`\n * (see `syncHostMemberRoles` callers) plus the erase/displayName gaps (AGL-855).\n *\n * ## The Sites list is a query over these rows (AGL-3321)\n *\n * The organization's Sites list pages, filters and searches THIS collection —\n * `where(orgId ==)`, plus the reader's clauses, ordered by `nameLower` or\n * `createdAt` — and joins each row to the host document by id for display.\n * It cannot query `hosts` itself: the rules serve a non-staff LIST of `hosts`\n * only under a `memberRoles.{uid}` clause, a composite on that per-user map\n * path can exist for no order but the document id, and a predicated listen\n * over the mutable `memberRoles`/`orgId` is what tombstoned host documents\n * for every other reader (AGL-1190, `useOrgHosts`).\n *\n * So a row carries what that list asks of a site: `searchTokens` (the quick\n * search, over the name, the platform subdomain and the custom domain),\n * `hasCustomDomain` (the Custom domain filter) and the host's `createdAt`\n * (immutable, set once at create). Every field is derived from the host\n * document in `membershipRow`, so a re-sync from any writer re-derives all of\n * them; the writers of their source fields — create, the owner and staff\n * renames, the Setup name save, and the domain attach and detach routes (the\n * only writers of `cname`) — each re-sync. Rows written before AGL-3321 are\n * stamped by `tools/scripts/backfill-host-memberships-list-fields.mjs`.\n */\n\nimport {\n hostRoleFor,\n isOrgWideMember,\n nameSearchKey,\n type AglynOrganization,\n type AglynOrgMember,\n} from '@aglyn/aglyn/server'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport firebaseAdmin from './firebase-admin'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; commit in chunks below that. */\nconst CHUNK = 400\n\ninterface HostMeta {\n displayName?: string\n subdomain?: string\n favicon?: string\n /** The custom domain, which the Sites list's search finds a site by. */\n cname?: string\n /** When the site was created: set once, by `claimHostForOrg`, and never moved. */\n createdAt?: unknown\n /** Whether the site document was there to read. */\n exists?: boolean\n}\n\nconst orgHostIds = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> => {\n const org = (await db.collection('orgs').doc(orgId).get()).data() as\n | AglynOrganization\n | undefined\n return Object.keys(org?.hosts ?? {})\n}\n\nconst readHostMeta = async (\n db: FirebaseFirestore.Firestore,\n hostIds: string[],\n): Promise<Map<string, HostMeta>> => {\n if (hostIds.length === 0) return new Map()\n const snaps = await db.getAll(\n ...hostIds.map((id) => db.collection('hosts').doc(id)),\n )\n return new Map(\n snaps.map((snap) => [\n snap.id,\n {\n displayName: snap.get('displayName'),\n subdomain: snap.get('subdomain'),\n // The switcher renders from the projection, not the host doc, so the\n // favicon has to travel with it (AGL-1071).\n favicon: snap.get('seo')?.favicon,\n cname: snap.get('cname'),\n createdAt: snap.get('createdAt'),\n exists: snap.exists,\n },\n ]),\n )\n}\n\nconst readOrgMembers = async (\n db: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<AglynOrgMember[]> => {\n const snap = await db\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n .get()\n return snap.docs.map((d) => ({ $id: d.id, ...d.data() }) as AglynOrgMember)\n}\n\nconst membershipRef = (\n db: FirebaseFirestore.Firestore,\n uid: string,\n hostId: string,\n) => db.collection('users').doc(uid).collection('hostMemberships').doc(hostId)\n\n/** A tail of a word begins after any character that is not a letter or a digit. */\nconst WORD_SEPARATOR = /[^\\p{L}\\p{N}]/u\n\n/**\n * A word, and every tail of it that begins after a separator:\n * `shop.harbor-bakery.com` is itself, `harbor-bakery.com`, `bakery.com` and\n * `com`. Walked by codepoint, so a tail never starts inside a surrogate pair.\n */\nconst wordTails = (word: string): string[] => {\n const chars = [...word]\n const tails = [word]\n for (let at = 1; at < chars.length; at += 1) {\n if (WORD_SEPARATOR.test(chars[at - 1]) && !WORD_SEPARATOR.test(chars[at])) {\n tails.push(chars.slice(at).join(''))\n }\n }\n return tails\n}\n\n/**\n * The words a site is FOUND by in the Sites list's quick search (AGL-3321):\n * its name, its platform subdomain and its custom domain, as the word-prefix\n * tokens `nameSearchTokens` writes, which the list's query asks for with one\n * `array-contains` on the first typed word (`nameSearchNormalizers.token`).\n *\n * A subdomain or a domain is one \"word\" to `nameSearchTokens`, so the prefixes\n * of `harbor-bakery` alone would find it by `harbor` and never by `bakery`.\n * Every tail that begins after a separator is tokenized too, which is what\n * lets `bakery`, `bakery.com` and `shop.harbor` all find\n * `shop.harbor-bakery.com` — the words a reader remembers of an address.\n *\n * The platform apex is not a word here: it is the same on every site, so it\n * narrows nothing, and the subdomain in front of it is.\n *\n * Its script-side twin, for the backfill and the seeds that write these rows,\n * is `tools/scripts/lib/host-membership-search-tokens.mjs`; both answer\n * `tools/scripts/lib/host-membership-search-tokens.fixtures.json`, which this\n * library's spec asserts too.\n */\nexport function hostMembershipSearchTokens(\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n): string[] {\n const words = [meta?.displayName, meta?.subdomain, meta?.cname]\n .map((value) => nameSearchKey(typeof value === 'string' ? value : ''))\n .flatMap((key) => (key ? key.split(' ') : []))\n .flatMap(wordTails)\n return nameSearchTokens(words.join(' '))\n}\n\n/**\n * Whether a site has a custom domain connected, as the Sites card lists it:\n * a non-blank `cname`, which only the domain attach route writes and only the\n * detach route removes.\n */\nexport const hasCustomDomain = (meta: Pick<HostMeta, 'cname'> | undefined): boolean =>\n typeof meta?.cname === 'string' && meta.cname.trim() !== ''\n\n/**\n * The keys the staff Sites list (`/admin/sites`) queries on the SITE document\n * itself: the same name key, search tokens and custom-domain flag the\n * membership rows carry, so staff find a site by its name, subdomain or\n * domain across every organization without walking anyone's projection.\n *\n * Stamped by `syncHostProjectionForMembers` — the funnel every create,\n * rename, domain attach and domain release already calls — and by\n * `tools/scripts/backfill-host-search-fields.mjs` on the sites written before\n * it. `hasCustomDomain` is stored as `false`, never left out, for the reason\n * the membership row gives: a query cannot find a document by a field it\n * lacks.\n */\nexport const hostSearchFields = (\n meta: Pick<HostMeta, 'displayName' | 'subdomain' | 'cname'> | undefined,\n) => ({\n nameLower: nameSearchKey(meta?.displayName ?? ''),\n searchTokens: hostMembershipSearchTokens(meta),\n hasCustomDomain: hasCustomDomain(meta),\n})\n\n/** A Firestore timestamp, as opposed to a legacy number or string. */\nconst isTimestamp = (value: unknown): boolean =>\n typeof (value as { toMillis?: unknown } | null | undefined)?.toMillis === 'function'\n\n/**\n * Shape one projection row. Exported for tests: the favicon rule below is a\n * delete-vs-omit decision that is invisible in review and silent in\n * production when wrong (AGL-1071).\n */\nexport const membershipRow = (\n orgId: string,\n meta: HostMeta | undefined,\n role: string,\n) => {\n const displayName = meta?.displayName ?? ''\n return {\n orgId,\n ...(meta?.subdomain ? { subdomain: meta.subdomain } : {}),\n displayName,\n nameLower: nameSearchKey(displayName),\n // What the Sites list searches (AGL-3321). Re-derived on every sync, so a\n // rename, a domain attach or a domain release moves it with the host.\n searchTokens: hostMembershipSearchTokens(meta),\n // The Custom domain filter (AGL-3321): an equality, which every order the\n // list takes can carry. Stored as `false`, never left out, because a query\n // cannot find a document by a field it lacks.\n hasCustomDomain: hasCustomDomain(meta),\n // What the Sites list's Created filter and order read. NULL rather than\n // absent for a site that never recorded one: `orderBy('createdAt')` drops\n // a document MISSING the field, and a site must not vanish from the list\n // because it is sorted by a date it does not have.\n createdAt: isTimestamp(meta?.createdAt) ? meta?.createdAt : null,\n // DELETE rather than omit when the site has no favicon (AGL-1071). These\n // rows are written with `{ merge: true }`, so omitting the key leaves\n // whatever was there — clearing a favicon would keep showing the old one\n // in the switcher until something unrelated rewrote the row. The clear\n // path writes `seo.favicon: ''`, so test truthiness, not nullishness.\n favicon: meta?.favicon ? meta.favicon : FieldValue.delete(),\n role,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/** Runs a set of write operations in ≤CHUNK-sized batches. */\nconst commitChunked = async (\n db: FirebaseFirestore.Firestore,\n ops: Array<(batch: FirebaseFirestore.WriteBatch) => void>,\n): Promise<void> => {\n for (let i = 0; i < ops.length; i += CHUNK) {\n const batch = db.batch()\n for (const op of ops.slice(i, i + CHUNK)) op(batch)\n await batch.commit()\n }\n}\n\n/**\n * Mirror the member's org-wide reach onto their `users/{uid}/orgs/{orgId}`\n * row (AGL-1032), so the console can tell a site collaborator from an\n * org-wide viewer without a second read on every org route.\n *\n * Rides the projection pass rather than being a call the membership APIs\n * each have to remember: every path that changes a member's reach — add,\n * role change, grant, revoke, ownership transfer — already re-syncs their\n * host rows, so one funnel keeps the mirror true and self-heals a row an\n * earlier failure left stale.\n *\n * Only ever UPDATES an existing row. The reverse index is created with the\n * membership and deleted with it; a `set`/merge here on a removed member\n * would resurrect a half-row with no `orgName` or `slug`, and the console\n * renders that as a nameless workspace nobody can open.\n */\nasync function syncMemberOrgReach(\n db: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n member: AglynOrgMember | null,\n): Promise<void> {\n if (!member) return\n const ref = db.collection('users').doc(uid).collection('orgs').doc(orgId)\n const snap = await ref.get()\n if (!snap.exists) return\n const orgWide = isOrgWideMember(member)\n if (snap.get('orgWide') === orgWide) return\n await ref.set({ orgWide }, { merge: true })\n}\n\n/**\n * Re-sync ONE member's projection rows across all the org's hosts: set a row\n * for every host the member can reach, delete the row for every host they\n * cannot (a role downgrade silently drops hosts). Call after any change to a\n * single member (add / role change / grant / revoke / ownership step).\n */\nexport async function syncMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const [memberSnap, hostIds] = await Promise.all([\n db.collection('orgs').doc(orgId).collection('members').doc(uid).get(),\n orgHostIds(db, orgId),\n ])\n const member = memberSnap.exists\n ? ({ $id: uid, ...memberSnap.data() } as AglynOrgMember)\n : null\n await syncMemberOrgReach(db, orgId, uid, member)\n const meta = await readHostMeta(db, hostIds)\n const ops = hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, uid, hostId)\n if (role) batch.set(ref, membershipRow(orgId, meta.get(hostId), role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n}\n\n/**\n * Re-sync ONE host's projection rows across all org members: set a row for\n * every member who can reach it, delete for those who cannot. Call after a\n * host is created, renamed, or its displayName changes.\n */\nexport async function syncHostProjectionForMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const [members, meta] = await Promise.all([\n readOrgMembers(db, orgId),\n readHostMeta(db, [hostId]),\n ])\n const hostMeta = meta.get(hostId)\n const ops = members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n const role = hostRoleFor(member, hostId)\n const ref = membershipRef(db, member.$id, hostId)\n if (role) batch.set(ref, membershipRow(orgId, hostMeta, role), { merge: true })\n else batch.delete(ref)\n })\n await commitChunked(db, ops)\n // The staff Sites list's keys, on the site itself. `update`, never a\n // merge-set: a site erased since the read must not be minted back as a\n // phantom carrying only these three keys (the AGL-1765 shape). Outside the\n // batch so a site gone missing cannot fail its members' rows.\n if (hostMeta?.exists) {\n await db\n .collection('hosts')\n .doc(hostId)\n .update(hostSearchFields(hostMeta))\n .catch(() => undefined)\n }\n}\n\n/** Delete every projection row for a member across the org's hosts (member removed). */\nexport async function deleteMemberHostProjections(\n orgId: string,\n uid: string,\n): Promise<void> {\n const db = firestore()\n const hostIds = await orgHostIds(db, orgId)\n await commitChunked(\n db,\n hostIds.map((hostId) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, uid, hostId))\n }),\n )\n}\n\n/** Delete every member's projection row for one host (host erased). */\nexport async function deleteHostProjectionForAllMembers(\n orgId: string,\n hostId: string,\n): Promise<void> {\n const db = firestore()\n const members = await readOrgMembers(db, orgId)\n await commitChunked(\n db,\n members.map((member) => (batch: FirebaseFirestore.WriteBatch) => {\n batch.delete(membershipRef(db, member.$id, hostId))\n }),\n )\n}\n"],"names":["hostRoleFor","isOrgWideMember","nameSearchKey","nameSearchTokens","FieldValue","firebaseAdmin","firestore","app","CHUNK","orgHostIds","db","orgId","org","collection","doc","get","data","Object","keys","hosts","readHostMeta","hostIds","length","Map","snaps","getAll","map","id","snap","displayName","subdomain","favicon","cname","createdAt","exists","readOrgMembers","docs","d","$id","membershipRef","uid","hostId","WORD_SEPARATOR","wordTails","word","chars","tails","at","test","push","slice","join","hostMembershipSearchTokens","meta","words","value","flatMap","key","split","hasCustomDomain","trim","hostSearchFields","nameLower","searchTokens","isTimestamp","toMillis","membershipRow","role","delete","updatedAt","serverTimestamp","commitChunked","ops","i","batch","op","commit","syncMemberOrgReach","member","ref","orgWide","set","merge","syncMemberHostProjections","memberSnap","Promise","all","syncHostProjectionForMembers","members","hostMeta","update","catch","undefined","deleteMemberHostProjections","deleteHostProjectionForAllMembers"],"mappings":"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"}
@@ -564,6 +564,8 @@ export function lockdownJsonResponse(state, options) {
564
564
  message: notice.body
565
565
  }, notice.contact ? {
566
566
  contact: notice.contact
567
+ } : {}, notice.appeal ? {
568
+ appeal: true
567
569
  } : {}, typeof state.untilMs === 'number' ? {
568
570
  untilMs: state.untilMs
569
571
  } : {}), {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/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 * Server half of the lockdown system (AGL-1501): the verdict every\n * enforcement chokepoint asks for. The pure shape/precedence logic lives in\n * `@aglyn/aglyn` `app-utils/lockdown.ts`; this module adds the Admin-SDK\n * reads and the two properties only the server can hold:\n *\n * **The un-panic invariant.** A verified `staff` claim bypasses EVERY scope,\n * unconditionally, before any read happens. A platform-wide lockdown must\n * never lock out the staff who can lift it — a panic button that panics its\n * own operator is worse than none. Nothing may be added to\n * `getLockdownVerdict` above the staff check.\n *\n * **Fail-open on infrastructure error.** If the `lockdowns/platform` read\n * throws (Firestore outage, emulator without the collection), the verdict is\n * \"not locked\": an infrastructure blip must not weld the whole platform\n * shut. The same posture as the sanctions gate on an absent geo signal\n * (AGL-1492) and the release-flag reader on a missing template.\n *\n * **…except for takedowns (AGL-1621).** One class of lock inverts that cost\n * function: a legal or abuse takedown must keep holding when Firestore is\n * unreachable, because \"we served it, our database was down\" answers no\n * court order. The class is an EXPLICIT stored field (`enforcement`), never\n * inferred from scope or reason, and absent means `standard` — so every\n * lock that exists today, and every lock an operator does not classify,\n * keeps failing open exactly as before. See the takedown ledger below for\n * how a classification survives the read that loses it.\n */\n\nimport {\n featureLockdownDocId,\n orgFeatureLockdownDocId,\n isLockdownActive,\n isTakedownLockdown,\n lockdownFeatureStaffBypass,\n type LockdownFeatureKey,\n LOCKDOWNS_COLLECTION,\n lockdownBlocks,\n type LockdownDoc,\n type LockdownIntent,\n lockdownIntentForMethod,\n type LockdownNotice,\n lockdownNotice,\n lockdownRetryAfterSeconds,\n type LockdownState,\n domainLockdownDocId,\n normalizeHostLockdown,\n normalizeLockdownDoc,\n normalizeOrgLockdown,\n PLATFORM_LOCKDOWN_DOC_ID,\n resolveLockdown,\n userLockdownDocId,\n} from '@aglyn/aglyn/server'\nimport { getApp } from 'firebase-admin/app'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Platform-doc TTL. Short on purpose: this is the panic path, and 15s is the\n * worst-case lag between staff pressing the button and a warm server process\n * refusing its next request. Per-request reads would be ~1 Firestore read\n * per API call platform-wide, which AGL-1302 showed is real money at scale.\n */\nconst PLATFORM_TTL_MS = 15_000\n\n/**\n * ===================== THE TAKEDOWN LEDGER (AGL-1621) =====================\n *\n * Lockdown fails open, and that stays true for ordinary locks: an\n * unreachable Firestore is an outage, not a lockdown, and a blip must never\n * weld every customer site shut. But a legal or abuse TAKEDOWN has the\n * opposite cost function — \"we served the infringing content because our\n * database was down\" is not an answer to a court order. So takedown-class\n * locks (`enforcement: 'takedown'`) hold through a failed read, and\n * everything else keeps today's behaviour exactly.\n *\n * ## The ordering problem this exists to solve\n *\n * The classification is stored ON the lock document. When the read throws\n * there IS no document, so the class cannot be consulted at failure time —\n * the obvious implementation (\"if the lock is a takedown, fail closed\") is\n * unimplementable as written, because the failure is precisely the loss of\n * the thing it wants to branch on.\n *\n * The only honest source is what this process last SUCCESSFULLY read. So\n * every successful read records its verdict here: a takedown is remembered,\n * and anything else RETIRES the entry. A subsequent failed read consults\n * the ledger instead of falling to null.\n *\n * ## What this does and does not guarantee — stated, not implied\n *\n * HOLDS: a takedown that a process has already observed keeps being\n * enforced by that process for as long as Firestore is unreachable. That is\n * the real incident shape — a takedown placed hours or days ago, then a\n * Firestore blip — and it is what the decision asks for.\n *\n * DOES NOT HOLD: a COLD process that has never once read the takedown has\n * nothing to remember and fails open, like everything else. Closing that\n * gap needs a second carrier more available than Firestore (edge config or\n * similar), which is a different and much larger change; pretending\n * otherwise here would be worse than naming it. A takedown placed DURING a\n * total Firestore outage is likewise not enforceable — the write itself\n * cannot land.\n *\n * ## Two things it deliberately does not do\n *\n * It is NOT cleared by `invalidate*LockdownCache`. Those run after a staff\n * write, and clearing the ledger there would mean a lock-then-outage\n * sequence forgot the takedown at the exact moment it mattered. The next\n * successful read is what retires an entry, and a lift IS such a read.\n *\n * It does not outlive the lock's own expiry: a held takedown is still\n * checked against `isLockdownActive`, so a `untilMs` that has passed\n * releases on schedule even while Firestore is down. A dead-man expiry must\n * not become un-liftable by being classified.\n */\nconst takedownLedger = new Map<string, LockdownState>()\n\n/**\n * Bounded like the user and domain caches, and for the same reason: two of\n * the four keyed readers take their key from caller-controlled input.\n * Eviction is least-recently-USED.\n */\nexport const TAKEDOWN_LEDGER_MAX = 5000\n\n/**\n * Record what a SUCCESSFUL read saw. A takedown is remembered; anything\n * else — no lock, an ordinary lock, or a lock whose class this build cannot\n * interpret — retires the entry, which is how a lift takes effect.\n */\nfunction rememberTakedown(key: string, state: LockdownState | null): void {\n if (!state || !isTakedownLockdown(state)) {\n takedownLedger.delete(key)\n return\n }\n // Re-insert to refresh recency before the LRU trim below.\n takedownLedger.delete(key)\n takedownLedger.set(key, state)\n while (takedownLedger.size > TAKEDOWN_LEDGER_MAX) {\n const oldest = takedownLedger.keys().next().value\n if (oldest === undefined) break\n takedownLedger.delete(oldest)\n }\n}\n\n/**\n * What a FAILED read should serve: a remembered, still-active takedown, or\n * null — which is the shipped fail-open answer and therefore the default\n * for every lock that is not an observed takedown.\n */\nfunction heldTakedown(key: string, nowMs: number): LockdownState | null {\n const state = takedownLedger.get(key)\n if (!state) return null\n if (!isLockdownActive(state, nowMs)) {\n takedownLedger.delete(key)\n return null\n }\n return state\n}\n\n/**\n * Forget every remembered takedown. NOT part of the panic path and not\n * called by the admin route — this exists for process-boundary and test\n * setup, where a ledger surviving between cases would make one test's lock\n * another test's fail-closed verdict.\n */\nexport function resetTakedownLedger(): void {\n takedownLedger.clear()\n}\n\n/** Ledger keys. Disjoint by construction — the doc-id prefixes already are. */\nconst PLATFORM_LEDGER_KEY = 'platform'\nconst featureLedgerKey = (feature: LockdownFeatureKey): string =>\n featureLockdownDocId(feature)\nconst userLedgerKey = (uid: string): string => userLockdownDocId(uid)\nconst domainLedgerKey = (hostname: string): string =>\n domainLockdownDocId(hostname)\n/**\n * ORG and HOST scope (AGL-1881). These two locks have no document in\n * `lockdowns/*` — they live as the `suspended*` family ON the org and host\n * documents — so they never passed through a ledgered reader, and\n * `getSiteLockdown` answered `null` flat when its reads threw. The route\n * persists `suspendedEnforcement: 'takedown'` for both scopes all the same,\n * from a control that promises the lock \"keeps holding if Aglyn cannot reach\n * the database\". A court-ordered host takedown plus a partial Firestore\n * outage therefore served the infringing site for the duration of the\n * outage, which is the precise failure the mode exists to prevent.\n *\n * Keyed by HOST ID, both of them, including the org one. Every reader on\n * this path is host-addressed, and on a failed read the org id is exactly as\n * unreadable as the lock — `getOrgForHost` is the read that just threw. So\n * \"the org lock as seen through this host\" is the only thing a failed read\n * can look up, and it matches the ledger's existing warm-process semantics:\n * an org takedown holds for each site a process has already read once.\n *\n * Distinct prefixes keep them disjoint from the four doc-id keys above\n * (`platform`, `feature--`, `user--`, `domain--`).\n */\nconst orgForHostLedgerKey = (hostId: string): string =>\n `org--for-host--${hostId}`\nconst hostLedgerKey = (hostId: string): string => `host--${hostId}`\n\n/**\n * Record what a SUCCESSFUL site read saw, per scope. Same contract as the\n * four `lockdowns/*` readers: a takedown is remembered, and anything else —\n * no lock, an ordinary lock, a takedown DOWNGRADED to one — retires the\n * entry, which is how a lift takes effect.\n */\nexport function rememberSiteTakedown(\n hostId: string,\n states: { org: LockdownState | null; host: LockdownState | null },\n): void {\n rememberTakedown(orgForHostLedgerKey(hostId), states.org)\n rememberTakedown(hostLedgerKey(hostId), states.host)\n}\n\n/**\n * What a FAILED site read may still serve: the remembered, still-active\n * takedowns at org and host scope, or nulls — which is the shipped fail-open\n * answer and therefore the default for every lock that is not an observed\n * takedown.\n */\nexport function heldSiteTakedown(\n hostId: string,\n nowMs: number,\n): { org: LockdownState | null; host: LockdownState | null } {\n return {\n org: heldTakedown(orgForHostLedgerKey(hostId), nowMs),\n host: heldTakedown(hostLedgerKey(hostId), nowMs),\n }\n}\n\nlet platformCache: { at: number; state: LockdownState | null } | undefined\nlet platformPending: Promise<LockdownState | null> | undefined\n\n/**\n * Drop the in-process platform cache. Called by /api/admin/lockdown after a\n * platform write so the process that took the action serves fresh verdicts\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidatePlatformLockdownCache(): void {\n platformCache = undefined\n platformPending = undefined\n}\n\n/** `lockdowns/platform`, normalized; null = not locked (incl. on error). */\nexport async function getPlatformLockdown(): Promise<LockdownState | null> {\n if (platformCache && Date.now() - platformCache.at < PLATFORM_TTL_MS) {\n return platformCache.state\n }\n if (!platformPending) {\n platformPending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(PLATFORM_LOCKDOWN_DOC_ID)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'platform',\n )\n : null\n rememberTakedown(PLATFORM_LEDGER_KEY, state)\n } catch {\n // Fail open: an unreachable Firestore is an outage, not a lockdown\n // — UNLESS this process has already seen a takedown here, which is\n // the one class that must survive the read failing (AGL-1621).\n state = heldTakedown(PLATFORM_LEDGER_KEY, Date.now())\n }\n platformCache = { at: Date.now(), state }\n return state\n })().finally(() => {\n platformPending = undefined\n })\n }\n return platformPending\n}\n\n/**\n * Feature-doc cache (AGL-1510): same TTL and the same reasoning as the\n * platform doc — feature gates sit on hot chokepoints (the session mint,\n * every media ingress, the plugin dispatcher), and 15s is the worst-case\n * lag between staff flipping a feature switch and a warm process enforcing\n * it. One map for all keys; the admin route invalidates after every write.\n */\n/**\n * Keyed by DOCUMENT ID rather than by feature key since AGL-2927, so the\n * platform-wide `feature--{key}` entry and each workspace's\n * `feature--{key}--org--{orgId}` entry share one map, one TTL and one\n * invalidation — the admin route's write clears both kinds at once.\n */\nconst featureCache = new Map<string, { at: number; state: LockdownState | null }>()\nconst featurePending = new Map<string, Promise<LockdownState | null>>()\n\n/** Drop the in-process feature cache (all keys) after an admin write. */\nexport function invalidateFeatureLockdownCache(): void {\n featureCache.clear()\n featurePending.clear()\n}\n\n/** One feature-scope carrier, by document id — see `getFeatureLockdown`. */\nasync function readFeatureLockdownDoc(docId: string): Promise<LockdownState | null> {\n const cached = featureCache.get(docId)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) return cached.state\n let pending = featurePending.get(docId)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(docId)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'feature',\n )\n : null\n rememberTakedown(docId, state)\n } catch {\n // Fail open, matching the platform read: an unreachable Firestore is\n // an outage, not a feature lockdown. A takedown-class feature lock\n // this process has already observed still holds (AGL-1621).\n state = heldTakedown(docId, Date.now())\n }\n featureCache.set(docId, { at: Date.now(), state })\n return state\n })().finally(() => {\n featurePending.delete(docId)\n })\n featurePending.set(docId, pending)\n }\n return pending\n}\n\n/** `lockdowns/feature--{key}`, normalized; null = not locked (incl. error). */\nexport async function getFeatureLockdown(\n feature: LockdownFeatureKey,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(featureLedgerKey(feature))\n}\n\n/**\n * `lockdowns/feature--{key}--org--{orgId}` (AGL-2927): the same capability\n * paused for ONE workspace. Same cache, same TTL, same fail-open posture as\n * the platform-wide document; null = not paused for that org.\n */\nexport async function getOrgFeatureLockdown(\n feature: LockdownFeatureKey,\n orgId: string,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(orgFeatureLockdownDocId(feature, orgId))\n}\n\n/**\n * The feature verdict and refusal in one call (AGL-1510) — the one-line\n * wiring a feature chokepoint carries:\n *\n * const locked = await featureLockdownRefusal({ feature: 'checkout', staff })\n * if (locked) return locked\n *\n * Composition, not ranking: a PLATFORM lock implies every feature, so it is\n * checked first (TTL-cached — routes that already ran the scope verdict pay\n * no extra read), and the platform-scope staff bypass there is UNCHANGED\n * and unconditional. The feature doc is checked second, and its staff\n * bypass is per-feature (`lockdownFeatureStaffBypass`): granted where a\n * staff action aids incident response (uploads, installs, ai-assist),\n * withheld where it would BE the incident (checkout — a staff checkout\n * session is still a real charge). A feature lock implies nothing about the\n * org/host/user scopes — those stay with `lockdownRefusal` at the routes\n * that carry it.\n *\n * A door that names the org it serves passes `orgId` (AGL-2927), and the\n * workspace-scoped carrier for the same feature is checked third, under the\n * same per-feature staff bypass: the staff AI pause is a spend stop on one\n * customer, and a staff call made to verify the pause is ours to spend. A\n * door that names no org — the plugin dispatcher, which gates by path\n * before the body is read — sees the platform-wide document only, which is\n * what it saw before.\n */\nexport async function featureLockdownRefusal(options: {\n feature: LockdownFeatureKey\n /** Verified `staff` custom claim — from a VERIFIED token only. */\n staff?: boolean\n /** The workspace the request is metered against, where the door knows it. */\n orgId?: string | null\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const platform = await getPlatformLockdown()\n if (isLockdownActive(platform, nowMs)) {\n // The un-panic invariant at the platform scope, unchanged.\n if (options.staff === true) return null\n return lockdownJsonResponse(platform as LockdownState)\n }\n const bypass =\n options.staff === true && lockdownFeatureStaffBypass(options.feature)\n const state = await getFeatureLockdown(options.feature)\n if (isLockdownActive(state, nowMs)) {\n return bypass ? null : lockdownJsonResponse(state as LockdownState)\n }\n const orgId = options.orgId?.trim()\n if (!orgId) return null\n const paused = await getOrgFeatureLockdown(options.feature, orgId)\n if (!isLockdownActive(paused, nowMs)) return null\n return bypass ? null : lockdownJsonResponse(paused as LockdownState)\n}\n\n/**\n * User-doc cache (AGL-1522): the last per-call read on the verdict path.\n * AGL-1506 wired the verdict into ~36 routes plus the session mint/exchange,\n * and every call carrying a `uid` paid one Firestore get on\n * `lockdowns/user--{uid}` — an active console editor generated one read per\n * wired mutation, against the AGL-1302 read budget. Same TTL, same pending\n * dedupe, same fail-open posture as the platform and feature caches above —\n * ONE cache pattern in this module, not three implementations.\n *\n * The staleness tradeoff, stated: a user locked mid-window keeps passing\n * this check for up to PLATFORM_TTL_MS (15s) plus the caller's polling\n * cadence — on every process EXCEPT the one that took the action, which\n * refuses immediately because /api/admin/lockdown invalidates this uid's\n * entry after the write (the platform/feature invalidation hook, mirrored).\n * That bound is acceptable here for the same reason `media/sign` omits the\n * user scope entirely: the hard kill never rode this read. A user lock also\n * disables the Auth account and revokes refresh tokens, so the ID token\n * stops refreshing and the session cookie dies at its next\n * `verifySessionCookie(…, true)` — the cache can only delay the DISTINCT\n * 423 body, not access itself, and 15s matches the platform scope's stated\n * worst case and the drill's measured flip times.\n *\n * Bounded so a scan of uids cannot balloon a warm process: past\n * USER_LOCKDOWN_CACHE_MAX entries the least-recently-USED is evicted (a\n * cache hit refreshes recency, so active sessions survive a scan).\n */\nexport const USER_LOCKDOWN_CACHE_MAX = 5000\n\nconst userCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst userPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one uid's cached verdict (after a user lock/unlock write), or the\n * whole cache when called bare. The acting process serves a fresh verdict\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidateUserLockdownCache(uid?: string): void {\n if (uid === undefined) {\n userCache.clear()\n userPending.clear()\n return\n }\n userCache.delete(uid)\n userPending.delete(uid)\n}\n\n/** `lockdowns/user--{uid}`, normalized; null = not locked (incl. on error). */\nexport async function getUserLockdown(\n uid: string,\n): Promise<LockdownState | null> {\n const cached = userCache.get(uid)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency: eviction is by least-recently-used, and\n // an ACTIVE session must not be the one a uid scan pushes out.\n userCache.delete(uid)\n userCache.set(uid, cached)\n return cached.state\n }\n let pending = userPending.get(uid)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(userLockdownDocId(uid))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(snapshot.data() as Partial<LockdownDoc>, 'user')\n : null\n rememberTakedown(userLedgerKey(uid), state)\n } catch {\n // Fail open, matching the platform and feature reads: an unreachable\n // Firestore is an outage, not a lockdown. An observed takedown-class\n // user lock holds through it (AGL-1621).\n state = heldTakedown(userLedgerKey(uid), Date.now())\n }\n userCache.delete(uid)\n userCache.set(uid, { at: Date.now(), state })\n while (userCache.size > USER_LOCKDOWN_CACHE_MAX) {\n const oldest = userCache.keys().next().value\n if (oldest === undefined) break\n userCache.delete(oldest)\n }\n return state\n })().finally(() => {\n userPending.delete(uid)\n })\n userPending.set(uid, pending)\n }\n return pending\n}\n\n/**\n * DOMAIN-lock cache (AGL-1513), same shape and same TTL as the user cache.\n *\n * Bounded for the same reason and one more: the key is a HOSTNAME taken from\n * the request, so an unbounded map here would be attacker-keyed by anyone who\n * can point a DNS record at us — which, for the hijack incident this scope\n * exists for, is the adversary by definition.\n */\nexport const DOMAIN_LOCKDOWN_CACHE_MAX = 5000\n\nconst domainCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst domainPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one hostname's cached verdict (after a domain lock/unlock write), or\n * the whole cache when called bare.\n */\nexport function invalidateDomainLockdownCache(hostname?: string): void {\n if (hostname === undefined) {\n domainCache.clear()\n domainPending.clear()\n return\n }\n const key = hostname.trim().toLowerCase()\n domainCache.delete(key)\n domainPending.delete(key)\n}\n\n/**\n * `lockdowns/domain--{hostname}`, normalized; null = not locked (incl. on\n * error), matching every other reader here — an unreachable Firestore is an\n * outage, not a lockdown.\n */\nexport async function getDomainLockdown(\n hostname: string,\n): Promise<LockdownState | null> {\n const key = String(hostname ?? '')\n .trim()\n .toLowerCase()\n if (!key) return null\n const cached = domainCache.get(key)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency, like the user cache: eviction is\n // least-recently-USED, and a domain under active dispute is exactly the\n // one a scan must not push out.\n domainCache.delete(key)\n domainCache.set(key, cached)\n return cached.state\n }\n let pending = domainPending.get(key)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(domainLockdownDocId(key))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'domain',\n )\n : null\n rememberTakedown(domainLedgerKey(key), state)\n } catch {\n // Fail open, as above — and, as above, an observed takedown holds.\n // This is the scope where that matters most: AGL-1513's hijack and\n // dispute incidents are takedowns almost by definition.\n state = heldTakedown(domainLedgerKey(key), Date.now())\n }\n domainCache.delete(key)\n domainCache.set(key, { at: Date.now(), state })\n while (domainCache.size > DOMAIN_LOCKDOWN_CACHE_MAX) {\n const oldest = domainCache.keys().next().value\n if (oldest === undefined) break\n domainCache.delete(oldest)\n }\n return state\n })().finally(() => {\n domainPending.delete(key)\n })\n domainPending.set(key, pending)\n }\n return pending\n}\n\nexport interface LockdownVerdictOptions {\n /**\n * The verified `staff` custom claim of the caller — from a VERIFIED token\n * only, never from a header or body. True bypasses every scope: this is\n * the un-panic invariant.\n */\n staff?: boolean\n /** Verified caller uid; enables the user scope. */\n uid?: string | null\n /**\n * Already-loaded org doc (or its suspension fields), when the caller has\n * one in hand — org scope costs no extra read. Absent = scope not\n * evaluated.\n */\n org?: Parameters<typeof normalizeOrgLockdown>[0]\n /** Already-loaded host doc; same contract as `org`. */\n host?: Parameters<typeof normalizeHostLockdown>[0]\n /**\n * READ-ONLY discrimination (AGL-1511). A `read-only` lock refuses writes\n * and passes reads; a `full` lock refuses both, so this changes nothing\n * for every lock written before the mode existed.\n *\n * The request the chokepoint is answering — its METHOD decides. Passing\n * the route's own `request` is the wiring, because a single console\n * handler is usually exported as both GET and POST and the verdict runs\n * before the method branch: derive the intent, never restate it.\n */\n request?: { method?: string } | null\n /**\n * Explicit intent, overriding `request`. For the routes whose method lies\n * about what they do — a POST that is really a query (`where-used`,\n * `plugin-impact`), or a GET that mutates.\n *\n * Neither given = `write`, the fail-safe: a chokepoint that never declared\n * its intent refuses during a migration rather than letting an\n * unconsidered write race the repair.\n */\n intent?: LockdownIntent\n nowMs?: number\n}\n\n/** The declared or derived intent of a verdict request; `write` by default. */\nfunction verdictIntent(options: LockdownVerdictOptions): LockdownIntent {\n if (options.intent) return options.intent\n if (options.request) return lockdownIntentForMethod(options.request.method)\n return 'write'\n}\n\n/**\n * The one verdict: the lockdown that REFUSES this caller's request, or null.\n * Precedence platform > org > host > user, strictness before width (from\n * `resolveLockdown`).\n *\n * \"Refuses this request\" rather than \"covers this caller\" since AGL-1511: a\n * read-only lock is active but does not refuse a read, and returning it\n * anyway would make every wired chokepoint 423 a GET — a read-only mode that\n * behaves exactly like a full one. The active state is still what\n * `resolveLockdown` chose, so the staff probe and the notice copy see the\n * real lock; only the ANSWER is intent-aware.\n */\nexport async function getLockdownVerdict(\n options: LockdownVerdictOptions,\n): Promise<LockdownState | null> {\n // THE UN-PANIC INVARIANT. Staff are never locked out, by any scope, ever\n // — they are the only ones who can lift a lockdown. Keep this line first:\n // everything below it may read Firestore, and a staff verdict must not\n // depend on any read succeeding.\n //\n // This line is also the whole of AGL-1511's \"staff writes bypass\n // read-only\": staff bypass every scope AND every mode, unconditionally,\n // and read-only exists precisely so staff can work while the world reads.\n // A mode-aware staff rule below would be a second bypass to keep correct.\n if (options.staff === true) return null\n\n const nowMs = options.nowMs ?? Date.now()\n const [platform, user] = await Promise.all([\n getPlatformLockdown(),\n options.uid ? getUserLockdown(options.uid) : Promise.resolve(null),\n ])\n const state = resolveLockdown(\n {\n platform,\n org: normalizeOrgLockdown(options.org),\n host: normalizeHostLockdown(options.host),\n user,\n },\n nowMs,\n )\n return lockdownBlocks(state, verdictIntent(options)) ? state : null\n}\n\n/**\n * The refusal an API route returns for a locked caller: **423 Locked** with\n * a machine-readable body, so an API consumer sees \"suspended: billing\" and\n * not a mystery 403. Carries only the sanitized, user-facing subset — never\n * the actor uid or staff rationale.\n */\n/**\n * Verdict and refusal in one call — the two-line wiring every org-scoped\n * API route carries (AGL-1506):\n *\n * const locked = await lockdownRefusal({ staff, uid, org, host })\n * if (locked) return locked\n *\n * Null means \"not locked, proceed\". One mechanism: this delegates to\n * `getLockdownVerdict` (so the un-panic invariant and fail-open posture are\n * inherited, never re-implemented) and to `lockdownJsonResponse` for the\n * distinct 423 body. Routes with a richer flow (the session mint clears\n * cookies on refusal) keep calling the two halves directly.\n */\nexport async function lockdownRefusal(\n options: LockdownVerdictOptions & {\n /** Visitor-facing copy override; see `lockdownJsonResponse`. */\n notice?: LockdownNotice\n },\n): Promise<Response | null> {\n const state = await getLockdownVerdict(options)\n return state ? lockdownJsonResponse(state, { notice: options.notice }) : null\n}\n\nexport function lockdownJsonResponse(\n state: LockdownState,\n options?: {\n /**\n * Substitute copy for a surface whose reader is not the account holder\n * — the tenant runtime's visitor-facing pause (AGL-1511). ONE wire\n * shape and ONE writer either way: only the words change, so\n * `parseLockdownRefusal` reads a paused checkout exactly as it reads a\n * locked console.\n */\n notice?: LockdownNotice\n },\n): Response {\n const notice = options?.notice ?? lockdownNotice(state)\n const retryAfter = lockdownRetryAfterSeconds(state, Date.now())\n return Response.json(\n {\n error: 'locked',\n scope: state.scope,\n // The strictness, on the wire (AGL-1511): a client that can tell\n // \"paused\" from \"down\" renders the difference; one that cannot still\n // has the title and message, which already say it in words.\n ...(state.mode ? { mode: state.mode } : {}),\n // Distinct per-feature body (AGL-1510): an API consumer sees WHICH\n // capability is off (\"uploads\"), not a generic locked platform.\n ...(state.feature ? { feature: state.feature } : {}),\n reason: state.reason,\n title: notice.title,\n message: notice.body,\n ...(notice.contact ? { contact: notice.contact } : {}),\n ...(typeof state.untilMs === 'number' ? { untilMs: state.untilMs } : {}),\n },\n {\n status: 423,\n headers: {\n 'Cache-Control': 'no-store',\n ...(retryAfter ? { 'Retry-After': String(retryAfter) } : {}),\n },\n },\n )\n}\n\n/**\n * IS THE CREATION-LEVEL VALVE ACTUALLY ARMED? (AGL-1531)\n *\n * The signups feature lock refuses the session mint, the acceptance recorder\n * and the signup-page doors from code that ships with every deploy. It also\n * refuses account CREATION — but only through a Firebase Auth\n * `beforeUserCreated` blocking function in `cloud/functions`, which merging\n * does not deploy and deploying does not necessarily register. Identity\n * Platform holds that registration, not this repo.\n *\n * So the staff page had a way to be confidently wrong: the switch reads\n * LOCKED either way, and an operator during a bot wave would have no way to\n * tell \"creation is refused\" from \"creation is still wide open and only\n * sessions are being turned away\". This is the read that closes that gap —\n * the panic surface states which of the two it is instead of implying the\n * stronger one.\n *\n * `unknown` is a first-class answer and is NEVER rendered as armed. The\n * probe needs a credential with the identitytoolkit scope and an outbound\n * call; a deployment where either is missing must report that it does not\n * know, because \"we could not check\" and \"it is on\" are the two answers an\n * incident cannot afford to have confused.\n */\n/**\n * A STRING discriminant, not a `boolean | null`. `strictNullChecks` is off\n * repo-wide, so `null` is assignable to every member and a `armed: null` arm\n * cannot narrow — the compiler collapses the union and the \"unknown\" case\n * silently stops being distinguishable from the other two. Which is the one\n * distinction this type exists to hold.\n */\nexport type SignupsCreationTriggerStatus =\n | { status: 'armed'; functionUri: string | null; updateTime: string | null }\n | { status: 'absent' }\n | { status: 'unknown'; reason: string }\n\n/** Bounded: the panic page must render even when this call is the slow one. */\nconst IDENTITY_CONFIG_TIMEOUT_MS = 4_000\n\nexport async function readSignupsCreationTriggerStatus(): Promise<SignupsCreationTriggerStatus> {\n let token: string | undefined\n let projectId: string | undefined\n try {\n // `getApp()`, not `firebaseAdmin.app()`: the local shim narrows the app\n // to its four accessors and drops `options`, which is where the\n // credential and the project id live. Same acquisition path as the\n // error-beacon target (`client-error-report.ts`).\n const app = getApp()\n projectId = (app.options.projectId ??\n process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID']) as string | undefined\n token = (await app.options.credential?.getAccessToken())?.access_token\n } catch {\n token = undefined\n }\n if (!token || !projectId) {\n return {\n status: 'unknown',\n reason:\n 'No admin credential or project id on this deployment, so the ' +\n 'Identity Platform config could not be read.',\n }\n }\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), IDENTITY_CONFIG_TIMEOUT_MS)\n try {\n const response = await fetch(\n `https://identitytoolkit.googleapis.com/admin/v2/projects/${encodeURIComponent(projectId)}/config`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: controller.signal,\n },\n )\n if (!response.ok) {\n return {\n status: 'unknown',\n reason: `Identity Platform config read returned ${response.status}.`,\n }\n }\n const payload = (await response.json()) as {\n blockingFunctions?: {\n triggers?: Record<\n string,\n { functionUri?: string; updateTime?: string } | undefined\n >\n }\n }\n // The trigger key Identity Platform uses is `beforeCreate`; the Firebase\n // SDK's `beforeUserCreated` is what registers it. Absent means the valve\n // is not deployed OR was deployed and never registered — indistinguishable\n // from here, and both mean the same thing to an operator: creation is not\n // being refused.\n const trigger = payload.blockingFunctions?.triggers?.['beforeCreate']\n if (!trigger) return { status: 'absent' }\n return {\n status: 'armed',\n functionUri: trigger.functionUri ?? null,\n updateTime: trigger.updateTime ?? null,\n }\n } catch (error: any) {\n return {\n status: 'unknown',\n reason:\n error?.name === 'AbortError'\n ? 'Identity Platform config read timed out.'\n : 'Identity Platform config could not be read.',\n }\n } finally {\n clearTimeout(timer)\n }\n}\n"],"names":["featureLockdownDocId","orgFeatureLockdownDocId","isLockdownActive","isTakedownLockdown","lockdownFeatureStaffBypass","LOCKDOWNS_COLLECTION","lockdownBlocks","lockdownIntentForMethod","lockdownNotice","lockdownRetryAfterSeconds","domainLockdownDocId","normalizeHostLockdown","normalizeLockdownDoc","normalizeOrgLockdown","PLATFORM_LOCKDOWN_DOC_ID","resolveLockdown","userLockdownDocId","getApp","firebaseAdmin","PLATFORM_TTL_MS","takedownLedger","Map","TAKEDOWN_LEDGER_MAX","rememberTakedown","key","state","delete","set","size","oldest","keys","next","value","undefined","heldTakedown","nowMs","get","resetTakedownLedger","clear","PLATFORM_LEDGER_KEY","featureLedgerKey","feature","userLedgerKey","uid","domainLedgerKey","hostname","orgForHostLedgerKey","hostId","hostLedgerKey","rememberSiteTakedown","states","org","host","heldSiteTakedown","platformCache","platformPending","invalidatePlatformLockdownCache","getPlatformLockdown","Date","now","at","snapshot","app","firestore","collection","doc","exists","data","finally","featureCache","featurePending","invalidateFeatureLockdownCache","readFeatureLockdownDoc","docId","cached","pending","getFeatureLockdown","getOrgFeatureLockdown","orgId","featureLockdownRefusal","options","platform","staff","lockdownJsonResponse","bypass","trim","paused","USER_LOCKDOWN_CACHE_MAX","userCache","userPending","invalidateUserLockdownCache","getUserLockdown","DOMAIN_LOCKDOWN_CACHE_MAX","domainCache","domainPending","invalidateDomainLockdownCache","toLowerCase","getDomainLockdown","String","verdictIntent","intent","request","method","getLockdownVerdict","user","Promise","all","resolve","lockdownRefusal","notice","retryAfter","Response","json","error","scope","mode","reason","title","message","body","contact","untilMs","status","headers","IDENTITY_CONFIG_TIMEOUT_MS","readSignupsCreationTriggerStatus","token","projectId","process","env","credential","getAccessToken","access_token","controller","AbortController","timer","setTimeout","abort","trigger","payload","response","fetch","encodeURIComponent","Authorization","signal","ok","blockingFunctions","triggers","functionUri","updateTime","name","clearTimeout"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/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 * Server half of the lockdown system (AGL-1501): the verdict every\n * enforcement chokepoint asks for. The pure shape/precedence logic lives in\n * `@aglyn/aglyn` `app-utils/lockdown.ts`; this module adds the Admin-SDK\n * reads and the two properties only the server can hold:\n *\n * **The un-panic invariant.** A verified `staff` claim bypasses EVERY scope,\n * unconditionally, before any read happens. A platform-wide lockdown must\n * never lock out the staff who can lift it — a panic button that panics its\n * own operator is worse than none. Nothing may be added to\n * `getLockdownVerdict` above the staff check.\n *\n * **Fail-open on infrastructure error.** If the `lockdowns/platform` read\n * throws (Firestore outage, emulator without the collection), the verdict is\n * \"not locked\": an infrastructure blip must not weld the whole platform\n * shut. The same posture as the sanctions gate on an absent geo signal\n * (AGL-1492) and the release-flag reader on a missing template.\n *\n * **…except for takedowns (AGL-1621).** One class of lock inverts that cost\n * function: a legal or abuse takedown must keep holding when Firestore is\n * unreachable, because \"we served it, our database was down\" answers no\n * court order. The class is an EXPLICIT stored field (`enforcement`), never\n * inferred from scope or reason, and absent means `standard` — so every\n * lock that exists today, and every lock an operator does not classify,\n * keeps failing open exactly as before. See the takedown ledger below for\n * how a classification survives the read that loses it.\n */\n\nimport {\n featureLockdownDocId,\n orgFeatureLockdownDocId,\n isLockdownActive,\n isTakedownLockdown,\n lockdownFeatureStaffBypass,\n type LockdownFeatureKey,\n LOCKDOWNS_COLLECTION,\n lockdownBlocks,\n type LockdownDoc,\n type LockdownIntent,\n lockdownIntentForMethod,\n type LockdownNotice,\n lockdownNotice,\n lockdownRetryAfterSeconds,\n type LockdownState,\n domainLockdownDocId,\n normalizeHostLockdown,\n normalizeLockdownDoc,\n normalizeOrgLockdown,\n PLATFORM_LOCKDOWN_DOC_ID,\n resolveLockdown,\n userLockdownDocId,\n} from '@aglyn/aglyn/server'\nimport { getApp } from 'firebase-admin/app'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Platform-doc TTL. Short on purpose: this is the panic path, and 15s is the\n * worst-case lag between staff pressing the button and a warm server process\n * refusing its next request. Per-request reads would be ~1 Firestore read\n * per API call platform-wide, which AGL-1302 showed is real money at scale.\n */\nconst PLATFORM_TTL_MS = 15_000\n\n/**\n * ===================== THE TAKEDOWN LEDGER (AGL-1621) =====================\n *\n * Lockdown fails open, and that stays true for ordinary locks: an\n * unreachable Firestore is an outage, not a lockdown, and a blip must never\n * weld every customer site shut. But a legal or abuse TAKEDOWN has the\n * opposite cost function — \"we served the infringing content because our\n * database was down\" is not an answer to a court order. So takedown-class\n * locks (`enforcement: 'takedown'`) hold through a failed read, and\n * everything else keeps today's behaviour exactly.\n *\n * ## The ordering problem this exists to solve\n *\n * The classification is stored ON the lock document. When the read throws\n * there IS no document, so the class cannot be consulted at failure time —\n * the obvious implementation (\"if the lock is a takedown, fail closed\") is\n * unimplementable as written, because the failure is precisely the loss of\n * the thing it wants to branch on.\n *\n * The only honest source is what this process last SUCCESSFULLY read. So\n * every successful read records its verdict here: a takedown is remembered,\n * and anything else RETIRES the entry. A subsequent failed read consults\n * the ledger instead of falling to null.\n *\n * ## What this does and does not guarantee — stated, not implied\n *\n * HOLDS: a takedown that a process has already observed keeps being\n * enforced by that process for as long as Firestore is unreachable. That is\n * the real incident shape — a takedown placed hours or days ago, then a\n * Firestore blip — and it is what the decision asks for.\n *\n * DOES NOT HOLD: a COLD process that has never once read the takedown has\n * nothing to remember and fails open, like everything else. Closing that\n * gap needs a second carrier more available than Firestore (edge config or\n * similar), which is a different and much larger change; pretending\n * otherwise here would be worse than naming it. A takedown placed DURING a\n * total Firestore outage is likewise not enforceable — the write itself\n * cannot land.\n *\n * ## Two things it deliberately does not do\n *\n * It is NOT cleared by `invalidate*LockdownCache`. Those run after a staff\n * write, and clearing the ledger there would mean a lock-then-outage\n * sequence forgot the takedown at the exact moment it mattered. The next\n * successful read is what retires an entry, and a lift IS such a read.\n *\n * It does not outlive the lock's own expiry: a held takedown is still\n * checked against `isLockdownActive`, so a `untilMs` that has passed\n * releases on schedule even while Firestore is down. A dead-man expiry must\n * not become un-liftable by being classified.\n */\nconst takedownLedger = new Map<string, LockdownState>()\n\n/**\n * Bounded like the user and domain caches, and for the same reason: two of\n * the four keyed readers take their key from caller-controlled input.\n * Eviction is least-recently-USED.\n */\nexport const TAKEDOWN_LEDGER_MAX = 5000\n\n/**\n * Record what a SUCCESSFUL read saw. A takedown is remembered; anything\n * else — no lock, an ordinary lock, or a lock whose class this build cannot\n * interpret — retires the entry, which is how a lift takes effect.\n */\nfunction rememberTakedown(key: string, state: LockdownState | null): void {\n if (!state || !isTakedownLockdown(state)) {\n takedownLedger.delete(key)\n return\n }\n // Re-insert to refresh recency before the LRU trim below.\n takedownLedger.delete(key)\n takedownLedger.set(key, state)\n while (takedownLedger.size > TAKEDOWN_LEDGER_MAX) {\n const oldest = takedownLedger.keys().next().value\n if (oldest === undefined) break\n takedownLedger.delete(oldest)\n }\n}\n\n/**\n * What a FAILED read should serve: a remembered, still-active takedown, or\n * null — which is the shipped fail-open answer and therefore the default\n * for every lock that is not an observed takedown.\n */\nfunction heldTakedown(key: string, nowMs: number): LockdownState | null {\n const state = takedownLedger.get(key)\n if (!state) return null\n if (!isLockdownActive(state, nowMs)) {\n takedownLedger.delete(key)\n return null\n }\n return state\n}\n\n/**\n * Forget every remembered takedown. NOT part of the panic path and not\n * called by the admin route — this exists for process-boundary and test\n * setup, where a ledger surviving between cases would make one test's lock\n * another test's fail-closed verdict.\n */\nexport function resetTakedownLedger(): void {\n takedownLedger.clear()\n}\n\n/** Ledger keys. Disjoint by construction — the doc-id prefixes already are. */\nconst PLATFORM_LEDGER_KEY = 'platform'\nconst featureLedgerKey = (feature: LockdownFeatureKey): string =>\n featureLockdownDocId(feature)\nconst userLedgerKey = (uid: string): string => userLockdownDocId(uid)\nconst domainLedgerKey = (hostname: string): string =>\n domainLockdownDocId(hostname)\n/**\n * ORG and HOST scope (AGL-1881). These two locks have no document in\n * `lockdowns/*` — they live as the `suspended*` family ON the org and host\n * documents — so they never passed through a ledgered reader, and\n * `getSiteLockdown` answered `null` flat when its reads threw. The route\n * persists `suspendedEnforcement: 'takedown'` for both scopes all the same,\n * from a control that promises the lock \"keeps holding if Aglyn cannot reach\n * the database\". A court-ordered host takedown plus a partial Firestore\n * outage therefore served the infringing site for the duration of the\n * outage, which is the precise failure the mode exists to prevent.\n *\n * Keyed by HOST ID, both of them, including the org one. Every reader on\n * this path is host-addressed, and on a failed read the org id is exactly as\n * unreadable as the lock — `getOrgForHost` is the read that just threw. So\n * \"the org lock as seen through this host\" is the only thing a failed read\n * can look up, and it matches the ledger's existing warm-process semantics:\n * an org takedown holds for each site a process has already read once.\n *\n * Distinct prefixes keep them disjoint from the four doc-id keys above\n * (`platform`, `feature--`, `user--`, `domain--`).\n */\nconst orgForHostLedgerKey = (hostId: string): string =>\n `org--for-host--${hostId}`\nconst hostLedgerKey = (hostId: string): string => `host--${hostId}`\n\n/**\n * Record what a SUCCESSFUL site read saw, per scope. Same contract as the\n * four `lockdowns/*` readers: a takedown is remembered, and anything else —\n * no lock, an ordinary lock, a takedown DOWNGRADED to one — retires the\n * entry, which is how a lift takes effect.\n */\nexport function rememberSiteTakedown(\n hostId: string,\n states: { org: LockdownState | null; host: LockdownState | null },\n): void {\n rememberTakedown(orgForHostLedgerKey(hostId), states.org)\n rememberTakedown(hostLedgerKey(hostId), states.host)\n}\n\n/**\n * What a FAILED site read may still serve: the remembered, still-active\n * takedowns at org and host scope, or nulls — which is the shipped fail-open\n * answer and therefore the default for every lock that is not an observed\n * takedown.\n */\nexport function heldSiteTakedown(\n hostId: string,\n nowMs: number,\n): { org: LockdownState | null; host: LockdownState | null } {\n return {\n org: heldTakedown(orgForHostLedgerKey(hostId), nowMs),\n host: heldTakedown(hostLedgerKey(hostId), nowMs),\n }\n}\n\nlet platformCache: { at: number; state: LockdownState | null } | undefined\nlet platformPending: Promise<LockdownState | null> | undefined\n\n/**\n * Drop the in-process platform cache. Called by /api/admin/lockdown after a\n * platform write so the process that took the action serves fresh verdicts\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidatePlatformLockdownCache(): void {\n platformCache = undefined\n platformPending = undefined\n}\n\n/** `lockdowns/platform`, normalized; null = not locked (incl. on error). */\nexport async function getPlatformLockdown(): Promise<LockdownState | null> {\n if (platformCache && Date.now() - platformCache.at < PLATFORM_TTL_MS) {\n return platformCache.state\n }\n if (!platformPending) {\n platformPending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(PLATFORM_LOCKDOWN_DOC_ID)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'platform',\n )\n : null\n rememberTakedown(PLATFORM_LEDGER_KEY, state)\n } catch {\n // Fail open: an unreachable Firestore is an outage, not a lockdown\n // — UNLESS this process has already seen a takedown here, which is\n // the one class that must survive the read failing (AGL-1621).\n state = heldTakedown(PLATFORM_LEDGER_KEY, Date.now())\n }\n platformCache = { at: Date.now(), state }\n return state\n })().finally(() => {\n platformPending = undefined\n })\n }\n return platformPending\n}\n\n/**\n * Feature-doc cache (AGL-1510): same TTL and the same reasoning as the\n * platform doc — feature gates sit on hot chokepoints (the session mint,\n * every media ingress, the plugin dispatcher), and 15s is the worst-case\n * lag between staff flipping a feature switch and a warm process enforcing\n * it. One map for all keys; the admin route invalidates after every write.\n */\n/**\n * Keyed by DOCUMENT ID rather than by feature key since AGL-2927, so the\n * platform-wide `feature--{key}` entry and each workspace's\n * `feature--{key}--org--{orgId}` entry share one map, one TTL and one\n * invalidation — the admin route's write clears both kinds at once.\n */\nconst featureCache = new Map<string, { at: number; state: LockdownState | null }>()\nconst featurePending = new Map<string, Promise<LockdownState | null>>()\n\n/** Drop the in-process feature cache (all keys) after an admin write. */\nexport function invalidateFeatureLockdownCache(): void {\n featureCache.clear()\n featurePending.clear()\n}\n\n/** One feature-scope carrier, by document id — see `getFeatureLockdown`. */\nasync function readFeatureLockdownDoc(docId: string): Promise<LockdownState | null> {\n const cached = featureCache.get(docId)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) return cached.state\n let pending = featurePending.get(docId)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(docId)\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'feature',\n )\n : null\n rememberTakedown(docId, state)\n } catch {\n // Fail open, matching the platform read: an unreachable Firestore is\n // an outage, not a feature lockdown. A takedown-class feature lock\n // this process has already observed still holds (AGL-1621).\n state = heldTakedown(docId, Date.now())\n }\n featureCache.set(docId, { at: Date.now(), state })\n return state\n })().finally(() => {\n featurePending.delete(docId)\n })\n featurePending.set(docId, pending)\n }\n return pending\n}\n\n/** `lockdowns/feature--{key}`, normalized; null = not locked (incl. error). */\nexport async function getFeatureLockdown(\n feature: LockdownFeatureKey,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(featureLedgerKey(feature))\n}\n\n/**\n * `lockdowns/feature--{key}--org--{orgId}` (AGL-2927): the same capability\n * paused for ONE workspace. Same cache, same TTL, same fail-open posture as\n * the platform-wide document; null = not paused for that org.\n */\nexport async function getOrgFeatureLockdown(\n feature: LockdownFeatureKey,\n orgId: string,\n): Promise<LockdownState | null> {\n return readFeatureLockdownDoc(orgFeatureLockdownDocId(feature, orgId))\n}\n\n/**\n * The feature verdict and refusal in one call (AGL-1510) — the one-line\n * wiring a feature chokepoint carries:\n *\n * const locked = await featureLockdownRefusal({ feature: 'checkout', staff })\n * if (locked) return locked\n *\n * Composition, not ranking: a PLATFORM lock implies every feature, so it is\n * checked first (TTL-cached — routes that already ran the scope verdict pay\n * no extra read), and the platform-scope staff bypass there is UNCHANGED\n * and unconditional. The feature doc is checked second, and its staff\n * bypass is per-feature (`lockdownFeatureStaffBypass`): granted where a\n * staff action aids incident response (uploads, installs, ai-assist),\n * withheld where it would BE the incident (checkout — a staff checkout\n * session is still a real charge). A feature lock implies nothing about the\n * org/host/user scopes — those stay with `lockdownRefusal` at the routes\n * that carry it.\n *\n * A door that names the org it serves passes `orgId` (AGL-2927), and the\n * workspace-scoped carrier for the same feature is checked third, under the\n * same per-feature staff bypass: the staff AI pause is a spend stop on one\n * customer, and a staff call made to verify the pause is ours to spend. A\n * door that names no org — the plugin dispatcher, which gates by path\n * before the body is read — sees the platform-wide document only, which is\n * what it saw before.\n */\nexport async function featureLockdownRefusal(options: {\n feature: LockdownFeatureKey\n /** Verified `staff` custom claim — from a VERIFIED token only. */\n staff?: boolean\n /** The workspace the request is metered against, where the door knows it. */\n orgId?: string | null\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const platform = await getPlatformLockdown()\n if (isLockdownActive(platform, nowMs)) {\n // The un-panic invariant at the platform scope, unchanged.\n if (options.staff === true) return null\n return lockdownJsonResponse(platform as LockdownState)\n }\n const bypass =\n options.staff === true && lockdownFeatureStaffBypass(options.feature)\n const state = await getFeatureLockdown(options.feature)\n if (isLockdownActive(state, nowMs)) {\n return bypass ? null : lockdownJsonResponse(state as LockdownState)\n }\n const orgId = options.orgId?.trim()\n if (!orgId) return null\n const paused = await getOrgFeatureLockdown(options.feature, orgId)\n if (!isLockdownActive(paused, nowMs)) return null\n return bypass ? null : lockdownJsonResponse(paused as LockdownState)\n}\n\n/**\n * User-doc cache (AGL-1522): the last per-call read on the verdict path.\n * AGL-1506 wired the verdict into ~36 routes plus the session mint/exchange,\n * and every call carrying a `uid` paid one Firestore get on\n * `lockdowns/user--{uid}` — an active console editor generated one read per\n * wired mutation, against the AGL-1302 read budget. Same TTL, same pending\n * dedupe, same fail-open posture as the platform and feature caches above —\n * ONE cache pattern in this module, not three implementations.\n *\n * The staleness tradeoff, stated: a user locked mid-window keeps passing\n * this check for up to PLATFORM_TTL_MS (15s) plus the caller's polling\n * cadence — on every process EXCEPT the one that took the action, which\n * refuses immediately because /api/admin/lockdown invalidates this uid's\n * entry after the write (the platform/feature invalidation hook, mirrored).\n * That bound is acceptable here for the same reason `media/sign` omits the\n * user scope entirely: the hard kill never rode this read. A user lock also\n * disables the Auth account and revokes refresh tokens, so the ID token\n * stops refreshing and the session cookie dies at its next\n * `verifySessionCookie(…, true)` — the cache can only delay the DISTINCT\n * 423 body, not access itself, and 15s matches the platform scope's stated\n * worst case and the drill's measured flip times.\n *\n * Bounded so a scan of uids cannot balloon a warm process: past\n * USER_LOCKDOWN_CACHE_MAX entries the least-recently-USED is evicted (a\n * cache hit refreshes recency, so active sessions survive a scan).\n */\nexport const USER_LOCKDOWN_CACHE_MAX = 5000\n\nconst userCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst userPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one uid's cached verdict (after a user lock/unlock write), or the\n * whole cache when called bare. The acting process serves a fresh verdict\n * immediately; other processes converge within PLATFORM_TTL_MS.\n */\nexport function invalidateUserLockdownCache(uid?: string): void {\n if (uid === undefined) {\n userCache.clear()\n userPending.clear()\n return\n }\n userCache.delete(uid)\n userPending.delete(uid)\n}\n\n/** `lockdowns/user--{uid}`, normalized; null = not locked (incl. on error). */\nexport async function getUserLockdown(\n uid: string,\n): Promise<LockdownState | null> {\n const cached = userCache.get(uid)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency: eviction is by least-recently-used, and\n // an ACTIVE session must not be the one a uid scan pushes out.\n userCache.delete(uid)\n userCache.set(uid, cached)\n return cached.state\n }\n let pending = userPending.get(uid)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(userLockdownDocId(uid))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(snapshot.data() as Partial<LockdownDoc>, 'user')\n : null\n rememberTakedown(userLedgerKey(uid), state)\n } catch {\n // Fail open, matching the platform and feature reads: an unreachable\n // Firestore is an outage, not a lockdown. An observed takedown-class\n // user lock holds through it (AGL-1621).\n state = heldTakedown(userLedgerKey(uid), Date.now())\n }\n userCache.delete(uid)\n userCache.set(uid, { at: Date.now(), state })\n while (userCache.size > USER_LOCKDOWN_CACHE_MAX) {\n const oldest = userCache.keys().next().value\n if (oldest === undefined) break\n userCache.delete(oldest)\n }\n return state\n })().finally(() => {\n userPending.delete(uid)\n })\n userPending.set(uid, pending)\n }\n return pending\n}\n\n/**\n * DOMAIN-lock cache (AGL-1513), same shape and same TTL as the user cache.\n *\n * Bounded for the same reason and one more: the key is a HOSTNAME taken from\n * the request, so an unbounded map here would be attacker-keyed by anyone who\n * can point a DNS record at us — which, for the hijack incident this scope\n * exists for, is the adversary by definition.\n */\nexport const DOMAIN_LOCKDOWN_CACHE_MAX = 5000\n\nconst domainCache = new Map<\n string,\n { at: number; state: LockdownState | null }\n>()\nconst domainPending = new Map<string, Promise<LockdownState | null>>()\n\n/**\n * Drop one hostname's cached verdict (after a domain lock/unlock write), or\n * the whole cache when called bare.\n */\nexport function invalidateDomainLockdownCache(hostname?: string): void {\n if (hostname === undefined) {\n domainCache.clear()\n domainPending.clear()\n return\n }\n const key = hostname.trim().toLowerCase()\n domainCache.delete(key)\n domainPending.delete(key)\n}\n\n/**\n * `lockdowns/domain--{hostname}`, normalized; null = not locked (incl. on\n * error), matching every other reader here — an unreachable Firestore is an\n * outage, not a lockdown.\n */\nexport async function getDomainLockdown(\n hostname: string,\n): Promise<LockdownState | null> {\n const key = String(hostname ?? '')\n .trim()\n .toLowerCase()\n if (!key) return null\n const cached = domainCache.get(key)\n if (cached && Date.now() - cached.at < PLATFORM_TTL_MS) {\n // Re-insert to refresh recency, like the user cache: eviction is\n // least-recently-USED, and a domain under active dispute is exactly the\n // one a scan must not push out.\n domainCache.delete(key)\n domainCache.set(key, cached)\n return cached.state\n }\n let pending = domainPending.get(key)\n if (!pending) {\n pending = (async () => {\n let state: LockdownState | null\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection(LOCKDOWNS_COLLECTION)\n .doc(domainLockdownDocId(key))\n .get()\n state = snapshot.exists\n ? normalizeLockdownDoc(\n snapshot.data() as Partial<LockdownDoc>,\n 'domain',\n )\n : null\n rememberTakedown(domainLedgerKey(key), state)\n } catch {\n // Fail open, as above — and, as above, an observed takedown holds.\n // This is the scope where that matters most: AGL-1513's hijack and\n // dispute incidents are takedowns almost by definition.\n state = heldTakedown(domainLedgerKey(key), Date.now())\n }\n domainCache.delete(key)\n domainCache.set(key, { at: Date.now(), state })\n while (domainCache.size > DOMAIN_LOCKDOWN_CACHE_MAX) {\n const oldest = domainCache.keys().next().value\n if (oldest === undefined) break\n domainCache.delete(oldest)\n }\n return state\n })().finally(() => {\n domainPending.delete(key)\n })\n domainPending.set(key, pending)\n }\n return pending\n}\n\nexport interface LockdownVerdictOptions {\n /**\n * The verified `staff` custom claim of the caller — from a VERIFIED token\n * only, never from a header or body. True bypasses every scope: this is\n * the un-panic invariant.\n */\n staff?: boolean\n /** Verified caller uid; enables the user scope. */\n uid?: string | null\n /**\n * Already-loaded org doc (or its suspension fields), when the caller has\n * one in hand — org scope costs no extra read. Absent = scope not\n * evaluated.\n */\n org?: Parameters<typeof normalizeOrgLockdown>[0]\n /** Already-loaded host doc; same contract as `org`. */\n host?: Parameters<typeof normalizeHostLockdown>[0]\n /**\n * READ-ONLY discrimination (AGL-1511). A `read-only` lock refuses writes\n * and passes reads; a `full` lock refuses both, so this changes nothing\n * for every lock written before the mode existed.\n *\n * The request the chokepoint is answering — its METHOD decides. Passing\n * the route's own `request` is the wiring, because a single console\n * handler is usually exported as both GET and POST and the verdict runs\n * before the method branch: derive the intent, never restate it.\n */\n request?: { method?: string } | null\n /**\n * Explicit intent, overriding `request`. For the routes whose method lies\n * about what they do — a POST that is really a query (`where-used`,\n * `plugin-impact`), or a GET that mutates.\n *\n * Neither given = `write`, the fail-safe: a chokepoint that never declared\n * its intent refuses during a migration rather than letting an\n * unconsidered write race the repair.\n */\n intent?: LockdownIntent\n nowMs?: number\n}\n\n/** The declared or derived intent of a verdict request; `write` by default. */\nfunction verdictIntent(options: LockdownVerdictOptions): LockdownIntent {\n if (options.intent) return options.intent\n if (options.request) return lockdownIntentForMethod(options.request.method)\n return 'write'\n}\n\n/**\n * The one verdict: the lockdown that REFUSES this caller's request, or null.\n * Precedence platform > org > host > user, strictness before width (from\n * `resolveLockdown`).\n *\n * \"Refuses this request\" rather than \"covers this caller\" since AGL-1511: a\n * read-only lock is active but does not refuse a read, and returning it\n * anyway would make every wired chokepoint 423 a GET — a read-only mode that\n * behaves exactly like a full one. The active state is still what\n * `resolveLockdown` chose, so the staff probe and the notice copy see the\n * real lock; only the ANSWER is intent-aware.\n */\nexport async function getLockdownVerdict(\n options: LockdownVerdictOptions,\n): Promise<LockdownState | null> {\n // THE UN-PANIC INVARIANT. Staff are never locked out, by any scope, ever\n // — they are the only ones who can lift a lockdown. Keep this line first:\n // everything below it may read Firestore, and a staff verdict must not\n // depend on any read succeeding.\n //\n // This line is also the whole of AGL-1511's \"staff writes bypass\n // read-only\": staff bypass every scope AND every mode, unconditionally,\n // and read-only exists precisely so staff can work while the world reads.\n // A mode-aware staff rule below would be a second bypass to keep correct.\n if (options.staff === true) return null\n\n const nowMs = options.nowMs ?? Date.now()\n const [platform, user] = await Promise.all([\n getPlatformLockdown(),\n options.uid ? getUserLockdown(options.uid) : Promise.resolve(null),\n ])\n const state = resolveLockdown(\n {\n platform,\n org: normalizeOrgLockdown(options.org),\n host: normalizeHostLockdown(options.host),\n user,\n },\n nowMs,\n )\n return lockdownBlocks(state, verdictIntent(options)) ? state : null\n}\n\n/**\n * The refusal an API route returns for a locked caller: **423 Locked** with\n * a machine-readable body, so an API consumer sees \"suspended: billing\" and\n * not a mystery 403. Carries only the sanitized, user-facing subset — never\n * the actor uid or staff rationale.\n */\n/**\n * Verdict and refusal in one call — the two-line wiring every org-scoped\n * API route carries (AGL-1506):\n *\n * const locked = await lockdownRefusal({ staff, uid, org, host })\n * if (locked) return locked\n *\n * Null means \"not locked, proceed\". One mechanism: this delegates to\n * `getLockdownVerdict` (so the un-panic invariant and fail-open posture are\n * inherited, never re-implemented) and to `lockdownJsonResponse` for the\n * distinct 423 body. Routes with a richer flow (the session mint clears\n * cookies on refusal) keep calling the two halves directly.\n */\nexport async function lockdownRefusal(\n options: LockdownVerdictOptions & {\n /** Visitor-facing copy override; see `lockdownJsonResponse`. */\n notice?: LockdownNotice\n },\n): Promise<Response | null> {\n const state = await getLockdownVerdict(options)\n return state ? lockdownJsonResponse(state, { notice: options.notice }) : null\n}\n\nexport function lockdownJsonResponse(\n state: LockdownState,\n options?: {\n /**\n * Substitute copy for a surface whose reader is not the account holder\n * — the tenant runtime's visitor-facing pause (AGL-1511). ONE wire\n * shape and ONE writer either way: only the words change, so\n * `parseLockdownRefusal` reads a paused checkout exactly as it reads a\n * locked console.\n */\n notice?: LockdownNotice\n },\n): Response {\n const notice = options?.notice ?? lockdownNotice(state)\n const retryAfter = lockdownRetryAfterSeconds(state, Date.now())\n return Response.json(\n {\n error: 'locked',\n scope: state.scope,\n // The strictness, on the wire (AGL-1511): a client that can tell\n // \"paused\" from \"down\" renders the difference; one that cannot still\n // has the title and message, which already say it in words.\n ...(state.mode ? { mode: state.mode } : {}),\n // Distinct per-feature body (AGL-1510): an API consumer sees WHICH\n // capability is off (\"uploads\"), not a generic locked platform.\n ...(state.feature ? { feature: state.feature } : {}),\n reason: state.reason,\n title: notice.title,\n message: notice.body,\n ...(notice.contact ? { contact: notice.contact } : {}),\n ...(notice.appeal ? { appeal: true } : {}),\n ...(typeof state.untilMs === 'number' ? { untilMs: state.untilMs } : {}),\n },\n {\n status: 423,\n headers: {\n 'Cache-Control': 'no-store',\n ...(retryAfter ? { 'Retry-After': String(retryAfter) } : {}),\n },\n },\n )\n}\n\n/**\n * IS THE CREATION-LEVEL VALVE ACTUALLY ARMED? (AGL-1531)\n *\n * The signups feature lock refuses the session mint, the acceptance recorder\n * and the signup-page doors from code that ships with every deploy. It also\n * refuses account CREATION — but only through a Firebase Auth\n * `beforeUserCreated` blocking function in `cloud/functions`, which merging\n * does not deploy and deploying does not necessarily register. Identity\n * Platform holds that registration, not this repo.\n *\n * So the staff page had a way to be confidently wrong: the switch reads\n * LOCKED either way, and an operator during a bot wave would have no way to\n * tell \"creation is refused\" from \"creation is still wide open and only\n * sessions are being turned away\". This is the read that closes that gap —\n * the panic surface states which of the two it is instead of implying the\n * stronger one.\n *\n * `unknown` is a first-class answer and is NEVER rendered as armed. The\n * probe needs a credential with the identitytoolkit scope and an outbound\n * call; a deployment where either is missing must report that it does not\n * know, because \"we could not check\" and \"it is on\" are the two answers an\n * incident cannot afford to have confused.\n */\n/**\n * A STRING discriminant, not a `boolean | null`. `strictNullChecks` is off\n * repo-wide, so `null` is assignable to every member and a `armed: null` arm\n * cannot narrow — the compiler collapses the union and the \"unknown\" case\n * silently stops being distinguishable from the other two. Which is the one\n * distinction this type exists to hold.\n */\nexport type SignupsCreationTriggerStatus =\n | { status: 'armed'; functionUri: string | null; updateTime: string | null }\n | { status: 'absent' }\n | { status: 'unknown'; reason: string }\n\n/** Bounded: the panic page must render even when this call is the slow one. */\nconst IDENTITY_CONFIG_TIMEOUT_MS = 4_000\n\nexport async function readSignupsCreationTriggerStatus(): Promise<SignupsCreationTriggerStatus> {\n let token: string | undefined\n let projectId: string | undefined\n try {\n // `getApp()`, not `firebaseAdmin.app()`: the local shim narrows the app\n // to its four accessors and drops `options`, which is where the\n // credential and the project id live. Same acquisition path as the\n // error-beacon target (`client-error-report.ts`).\n const app = getApp()\n projectId = (app.options.projectId ??\n process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID']) as string | undefined\n token = (await app.options.credential?.getAccessToken())?.access_token\n } catch {\n token = undefined\n }\n if (!token || !projectId) {\n return {\n status: 'unknown',\n reason:\n 'No admin credential or project id on this deployment, so the ' +\n 'Identity Platform config could not be read.',\n }\n }\n const controller = new AbortController()\n const timer = setTimeout(() => controller.abort(), IDENTITY_CONFIG_TIMEOUT_MS)\n try {\n const response = await fetch(\n `https://identitytoolkit.googleapis.com/admin/v2/projects/${encodeURIComponent(projectId)}/config`,\n {\n headers: { Authorization: `Bearer ${token}` },\n signal: controller.signal,\n },\n )\n if (!response.ok) {\n return {\n status: 'unknown',\n reason: `Identity Platform config read returned ${response.status}.`,\n }\n }\n const payload = (await response.json()) as {\n blockingFunctions?: {\n triggers?: Record<\n string,\n { functionUri?: string; updateTime?: string } | undefined\n >\n }\n }\n // The trigger key Identity Platform uses is `beforeCreate`; the Firebase\n // SDK's `beforeUserCreated` is what registers it. Absent means the valve\n // is not deployed OR was deployed and never registered — indistinguishable\n // from here, and both mean the same thing to an operator: creation is not\n // being refused.\n const trigger = payload.blockingFunctions?.triggers?.['beforeCreate']\n if (!trigger) return { status: 'absent' }\n return {\n status: 'armed',\n functionUri: trigger.functionUri ?? null,\n updateTime: trigger.updateTime ?? null,\n }\n } catch (error: any) {\n return {\n status: 'unknown',\n reason:\n error?.name === 'AbortError'\n ? 'Identity Platform config read timed out.'\n : 'Identity Platform config could not be read.',\n }\n } finally {\n clearTimeout(timer)\n }\n}\n"],"names":["featureLockdownDocId","orgFeatureLockdownDocId","isLockdownActive","isTakedownLockdown","lockdownFeatureStaffBypass","LOCKDOWNS_COLLECTION","lockdownBlocks","lockdownIntentForMethod","lockdownNotice","lockdownRetryAfterSeconds","domainLockdownDocId","normalizeHostLockdown","normalizeLockdownDoc","normalizeOrgLockdown","PLATFORM_LOCKDOWN_DOC_ID","resolveLockdown","userLockdownDocId","getApp","firebaseAdmin","PLATFORM_TTL_MS","takedownLedger","Map","TAKEDOWN_LEDGER_MAX","rememberTakedown","key","state","delete","set","size","oldest","keys","next","value","undefined","heldTakedown","nowMs","get","resetTakedownLedger","clear","PLATFORM_LEDGER_KEY","featureLedgerKey","feature","userLedgerKey","uid","domainLedgerKey","hostname","orgForHostLedgerKey","hostId","hostLedgerKey","rememberSiteTakedown","states","org","host","heldSiteTakedown","platformCache","platformPending","invalidatePlatformLockdownCache","getPlatformLockdown","Date","now","at","snapshot","app","firestore","collection","doc","exists","data","finally","featureCache","featurePending","invalidateFeatureLockdownCache","readFeatureLockdownDoc","docId","cached","pending","getFeatureLockdown","getOrgFeatureLockdown","orgId","featureLockdownRefusal","options","platform","staff","lockdownJsonResponse","bypass","trim","paused","USER_LOCKDOWN_CACHE_MAX","userCache","userPending","invalidateUserLockdownCache","getUserLockdown","DOMAIN_LOCKDOWN_CACHE_MAX","domainCache","domainPending","invalidateDomainLockdownCache","toLowerCase","getDomainLockdown","String","verdictIntent","intent","request","method","getLockdownVerdict","user","Promise","all","resolve","lockdownRefusal","notice","retryAfter","Response","json","error","scope","mode","reason","title","message","body","contact","appeal","untilMs","status","headers","IDENTITY_CONFIG_TIMEOUT_MS","readSignupsCreationTriggerStatus","token","projectId","process","env","credential","getAccessToken","access_token","controller","AbortController","timer","setTimeout","abort","trigger","payload","response","fetch","encodeURIComponent","Authorization","signal","ok","blockingFunctions","triggers","functionUri","updateTime","name","clearTimeout"],"mappings":"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"}
@@ -84,6 +84,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
84
84
  * `truncated` so the audit row records an incomplete revocation rather
85
85
  * than implying a clean one.
86
86
  */ import { randomUUID } from "node:crypto";
87
+ import { isSecurityClassLockdownReason } from "@aglyn/aglyn/app-utils/lockdown";
87
88
  import { firebaseAdmin } from "./firebase-admin.js";
88
89
  /** The custom-metadata key Firebase reads the raw download token from. */ export const DOWNLOAD_TOKEN_METADATA_KEY = 'firebaseStorageDownloadTokens';
89
90
  /**
@@ -111,7 +112,7 @@ import { firebaseAdmin } from "./firebase-admin.js";
111
112
  */ export function lockRotatesDownloadTokens(lock) {
112
113
  if (!lock) return false;
113
114
  if (lock.mode === 'read-only') return false;
114
- return lock.reason === 'security';
115
+ return isSecurityClassLockdownReason(lock.reason);
115
116
  }
116
117
  /**
117
118
  * Rewrite the download token on every already-tokened object under one