@aglyn/aglyn 1.0.0-beta.285 → 1.0.0-beta.288
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -11
- package/src/lib/app-utils/account-acquisition.d.ts +14 -0
- package/src/lib/app-utils/account-acquisition.js +19 -0
- package/src/lib/app-utils/account-acquisition.js.map +1 -1
- package/src/lib/app-utils/api-plugins.d.ts +25 -0
- package/src/lib/app-utils/api-plugins.js +13 -0
- package/src/lib/app-utils/api-plugins.js.map +1 -1
- package/src/lib/app-utils/business-profile-values.d.ts +15 -3
- package/src/lib/app-utils/business-profile-values.js +19 -3
- package/src/lib/app-utils/business-profile-values.js.map +1 -1
- package/src/lib/app-utils/business-profile.d.ts +5 -3
- package/src/lib/app-utils/business-profile.js +2 -1
- package/src/lib/app-utils/business-profile.js.map +1 -1
- package/src/lib/app-utils/contacts.d.ts +11 -0
- package/src/lib/app-utils/contacts.js +18 -0
- package/src/lib/app-utils/contacts.js.map +1 -1
- package/src/lib/app-utils/docs-deflection.d.ts +1 -1
- package/src/lib/app-utils/docs-deflection.js +17 -0
- package/src/lib/app-utils/docs-deflection.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +5 -2
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +9 -9
- package/src/lib/app-utils/docs-help.generated.js +20 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +168 -63
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/docs-retrieval.d.ts +17 -0
- package/src/lib/app-utils/docs-retrieval.js +28 -0
- package/src/lib/app-utils/docs-retrieval.js.map +1 -1
- package/src/lib/app-utils/host-naming.d.ts +2 -1
- package/src/lib/app-utils/host-naming.js +65 -2
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/host-permissions.d.ts +159 -3
- package/src/lib/app-utils/host-permissions.js +384 -12
- package/src/lib/app-utils/host-permissions.js.map +1 -1
- package/src/lib/app-utils/org-permission-editor.d.ts +128 -1
- package/src/lib/app-utils/org-permission-editor.js +176 -1
- package/src/lib/app-utils/org-permission-editor.js.map +1 -1
- package/src/lib/app-utils/org-permissions.d.ts +62 -1
- package/src/lib/app-utils/org-permissions.js +55 -5
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +28 -0
- package/src/lib/app-utils/org-role-grants.js +180 -2
- package/src/lib/app-utils/org-role-grants.js.map +1 -1
- package/src/lib/app-utils/org-roles.js +11 -2
- package/src/lib/app-utils/org-roles.js.map +1 -1
- package/src/lib/app-utils/redirect-query.d.ts +39 -0
- package/src/lib/app-utils/redirect-query.js +57 -0
- package/src/lib/app-utils/redirect-query.js.map +1 -0
- package/src/lib/app-utils/release-flags.js +2 -2
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/site-starter-links.d.ts +42 -0
- package/src/lib/app-utils/site-starter-links.js +79 -0
- package/src/lib/app-utils/site-starter-links.js.map +1 -0
- package/src/lib/app-utils/site-starter-nodes.d.ts +91 -0
- package/src/lib/app-utils/site-starter-nodes.js +210 -0
- package/src/lib/app-utils/site-starter-nodes.js.map +1 -0
- package/src/lib/app-utils/site-starter-theme.d.ts +90 -0
- package/src/lib/app-utils/site-starter-theme.js +241 -0
- package/src/lib/app-utils/site-starter-theme.js.map +1 -0
- package/src/lib/app-utils/webhook-delivery.js +6 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +12 -2
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +39 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-closure-notices.d.ts +88 -0
- package/src/lib/plugin-manager/plugin-closure-notices.js +123 -0
- package/src/lib/plugin-manager/plugin-closure-notices.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +15 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js +24 -1
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-customer-display-verifier.d.ts +77 -0
- package/src/lib/plugin-manager/plugin-customer-display-verifier.js +74 -0
- package/src/lib/plugin-manager/plugin-customer-display-verifier.js.map +1 -0
- package/src/lib/plugin-manager/plugin-customer-display.d.ts +135 -0
- package/src/lib/plugin-manager/plugin-customer-display.js +189 -0
- package/src/lib/plugin-manager/plugin-customer-display.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +59 -0
- package/src/lib/plugin-manager/plugin-entitlements.js +32 -3
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.d.ts +45 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-permissions.d.ts +74 -0
- package/src/lib/plugin-manager/plugin-permissions.js +91 -1
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-site-starters.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-site-starters.js.map +1 -1
- package/src/lib/plugin-manager/plugin-tax-return-sources.d.ts +87 -4
- package/src/lib/plugin-manager/plugin-tax-return-sources.js +155 -35
- package/src/lib/plugin-manager/plugin-tax-return-sources.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/org-roles.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 listPluginPermissionKeys,\n pluginPermissionDefaults,\n} from '../plugin-manager/plugin-permissions'\n\n/**\n * Org roles (AGL-120): named permission sets assigned to team members,\n * with per-user overrides an admin can apply on top. Shared between the\n * console UI and the server permission resolver so both read one table.\n */\n\n/** Keys are persisted in custom role docs — add new ones, never rename. */\nexport const ORG_ROLE_PERMISSION_KEYS = [\n 'createHosts',\n 'editHosts',\n 'editBilling',\n 'publishToMarketplace',\n 'installPlugins',\n 'manageMembers',\n] as const\n\nexport type OrgPermissionKey = (typeof ORG_ROLE_PERMISSION_KEYS)[number]\n\nexport type OrgPermissionSet = Record<OrgPermissionKey, boolean>\n\n/**\n * What `resolveRolePermissions` actually returns (AGL-2474): the legacy six\n * PLUS whatever plugin-declared keys are registered. The six stay named so\n * `permissions.createHosts` keeps its type; the index signature is what lets\n * a caller read `permissions.managePos` at all. Consumers that only care\n * about the built-in keys can keep using `OrgPermissionSet`.\n */\nexport type ResolvedOrgPermissionSet = OrgPermissionSet &\n Record<string, boolean>\n\nexport type OrgRoleTier = 'admin' | 'editor' | 'viewer'\n\n/** Owner-defined role at `orgs/{orgId}/roles/{id}` (AGL-133/243). */\nexport interface OrgCustomRole {\n name: string\n /**\n * Keyed by the legacy six OR any plugin-declared key (AGL-2474) — the\n * override loops below read both spaces out of this one map.\n */\n permissions?: Partial<Record<string, boolean>>\n}\n\n/**\n * Which built-in tier an ORG role resolves onto (AGL-2474).\n *\n * `owner` is NOT a tier, so `resolveRolePermissions('owner')` falls through to\n * the viewer default — correct for an unknown string, catastrophic for the one\n * role that owns the workspace. The server had its own copy of this table;\n * sharing it is what lets the console resolve the same plugin-key defaults the\n * API will enforce, instead of a second table that can drift from it.\n */\nexport const ORG_ROLE_TIER: Record<string, OrgRoleTier> = {\n owner: 'admin',\n admin: 'admin',\n editor: 'editor',\n viewer: 'viewer',\n}\n\n/** `ORG_ROLE_TIER` with least-privilege fallback for an unknown role. */\nexport function orgRoleTier(role: string | null | undefined): OrgRoleTier {\n return ORG_ROLE_TIER[role ?? ''] ?? 'viewer'\n}\n\nexport const ORG_ROLE_TIER_LABELS: Record<OrgRoleTier, string> = {\n admin: 'Admin',\n editor: 'Editor',\n viewer: 'Viewer',\n}\n\n/** Built-in role permission sets; overrides win key-by-key. */\nexport const ORG_ROLE_TIER_PERMISSIONS: Record<OrgRoleTier, OrgPermissionSet> =\n {\n admin: {\n createHosts: true,\n editHosts: true,\n editBilling: true,\n publishToMarketplace: true,\n installPlugins: true,\n manageMembers: true,\n },\n editor: {\n createHosts: false,\n editHosts: true,\n editBilling: false,\n publishToMarketplace: true,\n installPlugins: true,\n manageMembers: false,\n },\n viewer: {\n createHosts: false,\n editHosts: false,\n editBilling: false,\n publishToMarketplace: false,\n installPlugins: false,\n manageMembers: false,\n },\n }\n\n/**\n * Effective permissions: the role's defaults (unknown/missing roles resolve\n * as `viewer` — least privilege) with per-user overrides applied key-by-key.\n * Only boolean override values count; anything else keeps the role default.\n */\nexport function resolveRolePermissions(\n role: string | null | undefined,\n overrides?: Partial<Record<string, unknown>> | null,\n /**\n * Custom roles (AGL-133), keyed by role id (`orgs/{orgId}/roles`). A\n * non-built-in role id resolves against this map — viewer base with the\n * custom role's permissions applied; unknown ids stay plain viewer.\n */\n customRoles?: Record<string, OrgCustomRole | undefined> | null,\n): ResolvedOrgPermissionSet {\n const builtIn = ORG_ROLE_TIER_PERMISSIONS[(role ?? '') as OrgRoleTier]\n // Plugin-declared keys (AGL-435): tier defaults ride under the built-in\n // set; custom-role overrides below win key-by-key like any other key.\n const tier: OrgRoleTier = builtIn ? ((role ?? 'viewer') as OrgRoleTier) : 'viewer'\n let base: ResolvedOrgPermissionSet = {\n ...pluginPermissionDefaults(tier),\n ...(builtIn ?? ORG_ROLE_TIER_PERMISSIONS.viewer),\n }\n // EVERY GRANTABLE KEY, not just the legacy six (AGL-2474). Iterating\n // `ORG_ROLE_PERMISSION_KEYS` alone is what made every plugin-declared\n // permission unenforceable: the key materialised into the resolved map from\n // `pluginPermissionDefaults` above and then no override and no custom role\n // could move it, so `managePos` could only ever be its tier default. Read\n // per call — plugins register at module scope and a snapshot taken here\n // could be empty depending on import order.\n const grantableKeys: string[] = [\n ...ORG_ROLE_PERMISSION_KEYS,\n ...listPluginPermissionKeys(),\n ]\n if (!builtIn) {\n const custom = customRoles?.[role ?? '']\n if (custom) {\n // Plugin tier defaults are re-laid under the viewer base (AGL-2474).\n // Rebuilding from `ORG_ROLE_TIER_PERMISSIONS.viewer` alone dropped every\n // plugin key off a custom-role member's map entirely — `managePos` came\n // back `undefined`, not `false`, which reads as denied but is not the\n // same thing, and contradicted this function's own docblock.\n base = {\n ...pluginPermissionDefaults('viewer'),\n ...ORG_ROLE_TIER_PERMISSIONS.viewer,\n }\n for (const key of grantableKeys) {\n const value = custom.permissions?.[key]\n if (typeof value === 'boolean') base[key] = value\n }\n }\n }\n const permissions = { ...base }\n for (const key of grantableKeys) {\n const value = overrides?.[key]\n if (typeof value === 'boolean') permissions[key] = value\n }\n return permissions\n}\n\n/** True for the fixed admin/editor/viewer ids. */\nexport function isBuiltInRole(role: string | null | undefined): boolean {\n return (role ?? '') in ORG_ROLE_TIER_PERMISSIONS\n}\n"],"names":["listPluginPermissionKeys","pluginPermissionDefaults","ORG_ROLE_PERMISSION_KEYS","ORG_ROLE_TIER","owner","admin","editor","viewer","orgRoleTier","role","ORG_ROLE_TIER_LABELS","ORG_ROLE_TIER_PERMISSIONS","createHosts","editHosts","editBilling","publishToMarketplace","installPlugins","manageMembers","resolveRolePermissions","overrides","customRoles","builtIn","tier","base","grantableKeys","custom","
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/org-roles.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 listPluginPermissionKeys,\n pluginPermissionDefaults,\n pluginPermissionRefinements,\n refinePermissionMap,\n} from '../plugin-manager/plugin-permissions'\n\n/**\n * Org roles (AGL-120): named permission sets assigned to team members,\n * with per-user overrides an admin can apply on top. Shared between the\n * console UI and the server permission resolver so both read one table.\n */\n\n/** Keys are persisted in custom role docs — add new ones, never rename. */\nexport const ORG_ROLE_PERMISSION_KEYS = [\n 'createHosts',\n 'editHosts',\n 'editBilling',\n 'publishToMarketplace',\n 'installPlugins',\n 'manageMembers',\n] as const\n\nexport type OrgPermissionKey = (typeof ORG_ROLE_PERMISSION_KEYS)[number]\n\nexport type OrgPermissionSet = Record<OrgPermissionKey, boolean>\n\n/**\n * What `resolveRolePermissions` actually returns (AGL-2474): the legacy six\n * PLUS whatever plugin-declared keys are registered. The six stay named so\n * `permissions.createHosts` keeps its type; the index signature is what lets\n * a caller read `permissions.managePos` at all. Consumers that only care\n * about the built-in keys can keep using `OrgPermissionSet`.\n */\nexport type ResolvedOrgPermissionSet = OrgPermissionSet &\n Record<string, boolean>\n\nexport type OrgRoleTier = 'admin' | 'editor' | 'viewer'\n\n/** Owner-defined role at `orgs/{orgId}/roles/{id}` (AGL-133/243). */\nexport interface OrgCustomRole {\n name: string\n /**\n * Keyed by the legacy six OR any plugin-declared key (AGL-2474) — the\n * override loops below read both spaces out of this one map.\n */\n permissions?: Partial<Record<string, boolean>>\n}\n\n/**\n * Which built-in tier an ORG role resolves onto (AGL-2474).\n *\n * `owner` is NOT a tier, so `resolveRolePermissions('owner')` falls through to\n * the viewer default — correct for an unknown string, catastrophic for the one\n * role that owns the workspace. The server had its own copy of this table;\n * sharing it is what lets the console resolve the same plugin-key defaults the\n * API will enforce, instead of a second table that can drift from it.\n */\nexport const ORG_ROLE_TIER: Record<string, OrgRoleTier> = {\n owner: 'admin',\n admin: 'admin',\n editor: 'editor',\n viewer: 'viewer',\n}\n\n/** `ORG_ROLE_TIER` with least-privilege fallback for an unknown role. */\nexport function orgRoleTier(role: string | null | undefined): OrgRoleTier {\n return ORG_ROLE_TIER[role ?? ''] ?? 'viewer'\n}\n\nexport const ORG_ROLE_TIER_LABELS: Record<OrgRoleTier, string> = {\n admin: 'Admin',\n editor: 'Editor',\n viewer: 'Viewer',\n}\n\n/** Built-in role permission sets; overrides win key-by-key. */\nexport const ORG_ROLE_TIER_PERMISSIONS: Record<OrgRoleTier, OrgPermissionSet> =\n {\n admin: {\n createHosts: true,\n editHosts: true,\n editBilling: true,\n publishToMarketplace: true,\n installPlugins: true,\n manageMembers: true,\n },\n editor: {\n createHosts: false,\n editHosts: true,\n editBilling: false,\n publishToMarketplace: true,\n installPlugins: true,\n manageMembers: false,\n },\n viewer: {\n createHosts: false,\n editHosts: false,\n editBilling: false,\n publishToMarketplace: false,\n installPlugins: false,\n manageMembers: false,\n },\n }\n\n/**\n * Effective permissions: the role's defaults (unknown/missing roles resolve\n * as `viewer` — least privilege) with per-user overrides applied key-by-key.\n * Only boolean override values count; anything else keeps the role default.\n */\nexport function resolveRolePermissions(\n role: string | null | undefined,\n overrides?: Partial<Record<string, unknown>> | null,\n /**\n * Custom roles (AGL-133), keyed by role id (`orgs/{orgId}/roles`). A\n * non-built-in role id resolves against this map — viewer base with the\n * custom role's permissions applied; unknown ids stay plain viewer.\n */\n customRoles?: Record<string, OrgCustomRole | undefined> | null,\n): ResolvedOrgPermissionSet {\n const builtIn = ORG_ROLE_TIER_PERMISSIONS[(role ?? '') as OrgRoleTier]\n // Plugin-declared keys (AGL-435): tier defaults ride under the built-in\n // set; custom-role overrides below win key-by-key like any other key.\n const tier: OrgRoleTier = builtIn ? ((role ?? 'viewer') as OrgRoleTier) : 'viewer'\n let base: ResolvedOrgPermissionSet = {\n ...pluginPermissionDefaults(tier),\n ...(builtIn ?? ORG_ROLE_TIER_PERMISSIONS.viewer),\n }\n // EVERY GRANTABLE KEY, not just the legacy six (AGL-2474). Iterating\n // `ORG_ROLE_PERMISSION_KEYS` alone is what made every plugin-declared\n // permission unenforceable: the key materialised into the resolved map from\n // `pluginPermissionDefaults` above and then no override and no custom role\n // could move it, so `managePos` could only ever be its tier default. Read\n // per call — plugins register at module scope and a snapshot taken here\n // could be empty depending on import order.\n const grantableKeys: string[] = [\n ...ORG_ROLE_PERMISSION_KEYS,\n ...listPluginPermissionKeys(),\n ]\n // The custom role's map, when one decides the base: a stored layer for the\n // refinement pass below.\n let customLayer: Partial<Record<string, unknown>> | undefined\n if (!builtIn) {\n const custom = customRoles?.[role ?? '']\n if (custom) {\n customLayer = custom.permissions\n // Plugin tier defaults are re-laid under the viewer base (AGL-2474).\n // Rebuilding from `ORG_ROLE_TIER_PERMISSIONS.viewer` alone dropped every\n // plugin key off a custom-role member's map entirely — `managePos` came\n // back `undefined`, not `false`, which reads as denied but is not the\n // same thing, and contradicted this function's own docblock.\n base = {\n ...pluginPermissionDefaults('viewer'),\n ...ORG_ROLE_TIER_PERMISSIONS.viewer,\n }\n for (const key of grantableKeys) {\n const value = custom.permissions?.[key]\n if (typeof value === 'boolean') base[key] = value\n }\n }\n }\n const permissions = { ...base }\n for (const key of grantableKeys) {\n const value = overrides?.[key]\n if (typeof value === 'boolean') permissions[key] = value\n }\n // A key that refines a broader one (AGL-4047) follows it where nothing\n // set the key, and is never held without it.\n return refinePermissionMap(permissions, pluginPermissionRefinements(), [\n customLayer,\n overrides,\n ])\n}\n\n/** True for the fixed admin/editor/viewer ids. */\nexport function isBuiltInRole(role: string | null | undefined): boolean {\n return (role ?? '') in ORG_ROLE_TIER_PERMISSIONS\n}\n"],"names":["listPluginPermissionKeys","pluginPermissionDefaults","pluginPermissionRefinements","refinePermissionMap","ORG_ROLE_PERMISSION_KEYS","ORG_ROLE_TIER","owner","admin","editor","viewer","orgRoleTier","role","ORG_ROLE_TIER_LABELS","ORG_ROLE_TIER_PERMISSIONS","createHosts","editHosts","editBilling","publishToMarketplace","installPlugins","manageMembers","resolveRolePermissions","overrides","customRoles","builtIn","tier","base","grantableKeys","customLayer","custom","permissions","key","value","isBuiltInRole"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,wBAAwB,EACxBC,2BAA2B,EAC3BC,mBAAmB,QACd,0CAAsC;AAE7C;;;;CAIC,GAED,yEAAyE,GACzE,OAAO,MAAMC,2BAA2B;IACtC;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AA4BV;;;;;;;;CAQC,GACD,OAAO,MAAMC,gBAA6C;IACxDC,OAAO;IACPC,OAAO;IACPC,QAAQ;IACRC,QAAQ;AACV,EAAC;AAED,uEAAuE,GACvE,OAAO,SAASC,YAAYC,IAA+B;QAClDN;IAAP,QAAOA,kBAAAA,aAAa,CAACM,eAAAA,OAAQ,GAAG,YAAzBN,kBAA6B;AACtC;AAEA,OAAO,MAAMO,uBAAoD;IAC/DL,OAAO;IACPC,QAAQ;IACRC,QAAQ;AACV,EAAC;AAED,6DAA6D,GAC7D,OAAO,MAAMI,4BACX;IACEN,OAAO;QACLO,aAAa;QACbC,WAAW;QACXC,aAAa;QACbC,sBAAsB;QACtBC,gBAAgB;QAChBC,eAAe;IACjB;IACAX,QAAQ;QACNM,aAAa;QACbC,WAAW;QACXC,aAAa;QACbC,sBAAsB;QACtBC,gBAAgB;QAChBC,eAAe;IACjB;IACAV,QAAQ;QACNK,aAAa;QACbC,WAAW;QACXC,aAAa;QACbC,sBAAsB;QACtBC,gBAAgB;QAChBC,eAAe;IACjB;AACF,EAAC;AAEH;;;;CAIC,GACD,OAAO,SAASC,uBACdT,IAA+B,EAC/BU,SAAmD,EACnD;;;;GAIC,GACDC,WAA8D;IAE9D,MAAMC,UAAUV,yBAAyB,CAAEF,eAAAA,OAAQ,GAAmB;IACtE,wEAAwE;IACxE,sEAAsE;IACtE,MAAMa,OAAoBD,UAAYZ,eAAAA,OAAQ,WAA4B;IAC1E,IAAIc,OAAiC,aAChCxB,yBAAyBuB,OACxBD,kBAAAA,UAAWV,0BAA0BJ,MAAM;IAEjD,qEAAqE;IACrE,sEAAsE;IACtE,4EAA4E;IAC5E,2EAA2E;IAC3E,0EAA0E;IAC1E,wEAAwE;IACxE,4CAA4C;IAC5C,MAAMiB,gBAA0B;WAC3BtB;WACAJ;KACJ;IACD,2EAA2E;IAC3E,yBAAyB;IACzB,IAAI2B;IACJ,IAAI,CAACJ,SAAS;QACZ,MAAMK,SAASN,+BAAAA,WAAa,CAACX,eAAAA,OAAQ,GAAG;QACxC,IAAIiB,QAAQ;YACVD,cAAcC,OAAOC,WAAW;YAChC,qEAAqE;YACrE,yEAAyE;YACzE,wEAAwE;YACxE,sEAAsE;YACtE,6DAA6D;YAC7DJ,OAAO,aACFxB,yBAAyB,WACzBY,0BAA0BJ,MAAM;YAErC,KAAK,MAAMqB,OAAOJ,cAAe;oBACjBE;gBAAd,MAAMG,SAAQH,sBAAAA,OAAOC,WAAW,qBAAlBD,mBAAoB,CAACE,IAAI;gBACvC,IAAI,OAAOC,UAAU,WAAWN,IAAI,CAACK,IAAI,GAAGC;YAC9C;QACF;IACF;IACA,MAAMF,cAAc,aAAKJ;IACzB,KAAK,MAAMK,OAAOJ,cAAe;QAC/B,MAAMK,QAAQV,6BAAAA,SAAW,CAACS,IAAI;QAC9B,IAAI,OAAOC,UAAU,WAAWF,WAAW,CAACC,IAAI,GAAGC;IACrD;IACA,uEAAuE;IACvE,6CAA6C;IAC7C,OAAO5B,oBAAoB0B,aAAa3B,+BAA+B;QACrEyB;QACAN;KACD;AACH;AAEA,gDAAgD,GAChD,OAAO,SAASW,cAAcrB,IAA+B;IAC3D,OAAO,CAACA,eAAAA,OAAQ,OAAOE;AACzB"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A site redirect keeps the visitor's query string.
|
|
19
|
+
*
|
|
20
|
+
* A short link printed on a sign, `example.com/booth`, redirects to a long
|
|
21
|
+
* destination that carries its own parameters
|
|
22
|
+
* (`/signup?utm_source=event&utm_medium=qr`). Before this, a visitor who
|
|
23
|
+
* arrived with parameters of their own — `example.com/booth?ref=flyer` — lost
|
|
24
|
+
* them at the redirect, because the destination replaced the whole URL.
|
|
25
|
+
*
|
|
26
|
+
* The rule's own parameters WIN. They are what the owner set on purpose, and
|
|
27
|
+
* the printed link never carries any: a parameter the visitor brings with the
|
|
28
|
+
* same name is dropped rather than allowed to rewrite the rule's attribution.
|
|
29
|
+
* Every other parameter the visitor brought is appended, in the order it came,
|
|
30
|
+
* repeated keys and all.
|
|
31
|
+
*
|
|
32
|
+
* The destination's own query is kept byte for byte (no re-encoding: a rule an
|
|
33
|
+
* owner wrote with `%20` keeps `%20`), its fragment stays last, and a
|
|
34
|
+
* destination that is a path (`/pricing`) works the same as an absolute URL.
|
|
35
|
+
*
|
|
36
|
+
* Pure and import-free on purpose: the tenant middleware deep-imports it into
|
|
37
|
+
* the edge bundle.
|
|
38
|
+
*/
|
|
39
|
+
export declare function withVisitorQuery(destination: string, visitorSearch: string): string;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* A site redirect keeps the visitor's query string.
|
|
18
|
+
*
|
|
19
|
+
* A short link printed on a sign, `example.com/booth`, redirects to a long
|
|
20
|
+
* destination that carries its own parameters
|
|
21
|
+
* (`/signup?utm_source=event&utm_medium=qr`). Before this, a visitor who
|
|
22
|
+
* arrived with parameters of their own — `example.com/booth?ref=flyer` — lost
|
|
23
|
+
* them at the redirect, because the destination replaced the whole URL.
|
|
24
|
+
*
|
|
25
|
+
* The rule's own parameters WIN. They are what the owner set on purpose, and
|
|
26
|
+
* the printed link never carries any: a parameter the visitor brings with the
|
|
27
|
+
* same name is dropped rather than allowed to rewrite the rule's attribution.
|
|
28
|
+
* Every other parameter the visitor brought is appended, in the order it came,
|
|
29
|
+
* repeated keys and all.
|
|
30
|
+
*
|
|
31
|
+
* The destination's own query is kept byte for byte (no re-encoding: a rule an
|
|
32
|
+
* owner wrote with `%20` keeps `%20`), its fragment stays last, and a
|
|
33
|
+
* destination that is a path (`/pricing`) works the same as an absolute URL.
|
|
34
|
+
*
|
|
35
|
+
* Pure and import-free on purpose: the tenant middleware deep-imports it into
|
|
36
|
+
* the edge bundle.
|
|
37
|
+
*/ export function withVisitorQuery(destination, visitorSearch) {
|
|
38
|
+
const search = String(visitorSearch != null ? visitorSearch : '').replace(/^\?/, '');
|
|
39
|
+
if (!search) return destination;
|
|
40
|
+
const hashAt = destination.indexOf('#');
|
|
41
|
+
const beforeHash = hashAt === -1 ? destination : destination.slice(0, hashAt);
|
|
42
|
+
const hash = hashAt === -1 ? '' : destination.slice(hashAt);
|
|
43
|
+
const queryAt = beforeHash.indexOf('?');
|
|
44
|
+
const base = queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt);
|
|
45
|
+
const ownQuery = queryAt === -1 ? '' : beforeHash.slice(queryAt + 1);
|
|
46
|
+
const own = new URLSearchParams(ownQuery);
|
|
47
|
+
const added = new URLSearchParams();
|
|
48
|
+
for (const [key, value] of new URLSearchParams(search)){
|
|
49
|
+
if (!own.has(key)) added.append(key, value);
|
|
50
|
+
}
|
|
51
|
+
const extra = added.toString();
|
|
52
|
+
if (!extra) return destination;
|
|
53
|
+
const query = ownQuery ? `${ownQuery}&${extra}` : extra;
|
|
54
|
+
return `${base}?${query}${hash}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//# sourceMappingURL=redirect-query.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/redirect-query.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 * A site redirect keeps the visitor's query string.\n *\n * A short link printed on a sign, `example.com/booth`, redirects to a long\n * destination that carries its own parameters\n * (`/signup?utm_source=event&utm_medium=qr`). Before this, a visitor who\n * arrived with parameters of their own — `example.com/booth?ref=flyer` — lost\n * them at the redirect, because the destination replaced the whole URL.\n *\n * The rule's own parameters WIN. They are what the owner set on purpose, and\n * the printed link never carries any: a parameter the visitor brings with the\n * same name is dropped rather than allowed to rewrite the rule's attribution.\n * Every other parameter the visitor brought is appended, in the order it came,\n * repeated keys and all.\n *\n * The destination's own query is kept byte for byte (no re-encoding: a rule an\n * owner wrote with `%20` keeps `%20`), its fragment stays last, and a\n * destination that is a path (`/pricing`) works the same as an absolute URL.\n *\n * Pure and import-free on purpose: the tenant middleware deep-imports it into\n * the edge bundle.\n */\nexport function withVisitorQuery(\n destination: string,\n visitorSearch: string,\n): string {\n const search = String(visitorSearch ?? '').replace(/^\\?/, '')\n if (!search) return destination\n const hashAt = destination.indexOf('#')\n const beforeHash = hashAt === -1 ? destination : destination.slice(0, hashAt)\n const hash = hashAt === -1 ? '' : destination.slice(hashAt)\n const queryAt = beforeHash.indexOf('?')\n const base = queryAt === -1 ? beforeHash : beforeHash.slice(0, queryAt)\n const ownQuery = queryAt === -1 ? '' : beforeHash.slice(queryAt + 1)\n const own = new URLSearchParams(ownQuery)\n const added = new URLSearchParams()\n for (const [key, value] of new URLSearchParams(search)) {\n if (!own.has(key)) added.append(key, value)\n }\n const extra = added.toString()\n if (!extra) return destination\n const query = ownQuery ? `${ownQuery}&${extra}` : extra\n return `${base}?${query}${hash}`\n}\n"],"names":["withVisitorQuery","destination","visitorSearch","search","String","replace","hashAt","indexOf","beforeHash","slice","hash","queryAt","base","ownQuery","own","URLSearchParams","added","key","value","has","append","extra","toString","query"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,SAASA,iBACdC,WAAmB,EACnBC,aAAqB;IAErB,MAAMC,SAASC,OAAOF,wBAAAA,gBAAiB,IAAIG,OAAO,CAAC,OAAO;IAC1D,IAAI,CAACF,QAAQ,OAAOF;IACpB,MAAMK,SAASL,YAAYM,OAAO,CAAC;IACnC,MAAMC,aAAaF,WAAW,CAAC,IAAIL,cAAcA,YAAYQ,KAAK,CAAC,GAAGH;IACtE,MAAMI,OAAOJ,WAAW,CAAC,IAAI,KAAKL,YAAYQ,KAAK,CAACH;IACpD,MAAMK,UAAUH,WAAWD,OAAO,CAAC;IACnC,MAAMK,OAAOD,YAAY,CAAC,IAAIH,aAAaA,WAAWC,KAAK,CAAC,GAAGE;IAC/D,MAAME,WAAWF,YAAY,CAAC,IAAI,KAAKH,WAAWC,KAAK,CAACE,UAAU;IAClE,MAAMG,MAAM,IAAIC,gBAAgBF;IAChC,MAAMG,QAAQ,IAAID;IAClB,KAAK,MAAM,CAACE,KAAKC,MAAM,IAAI,IAAIH,gBAAgBZ,QAAS;QACtD,IAAI,CAACW,IAAIK,GAAG,CAACF,MAAMD,MAAMI,MAAM,CAACH,KAAKC;IACvC;IACA,MAAMG,QAAQL,MAAMM,QAAQ;IAC5B,IAAI,CAACD,OAAO,OAAOpB;IACnB,MAAMsB,QAAQV,WAAW,GAAGA,SAAS,CAAC,EAAEQ,OAAO,GAAGA;IAClD,OAAO,GAAGT,KAAK,CAAC,EAAEW,QAAQb,MAAM;AAClC"}
|
|
@@ -153,8 +153,8 @@ export function isReleaseFlagStage(value) {
|
|
|
153
153
|
{
|
|
154
154
|
key: 'release_ai_site_inspiration',
|
|
155
155
|
label: 'AI site start: sites you like',
|
|
156
|
-
description: 'The guided AI site start asks for sites the person likes (AGL-3975): ' + 'one on Free, three on a paid plan. The site job reads each home page ' + 'and two of its stylesheets through the SSRF-guarded fetch and keeps ' + 'signals only (section order, spacing, header, colors, type mood, ' + 'button and card shape, motion, and on a paid plan the features it ' + 'offers), never its words, images or logos.
|
|
157
|
-
defaultEnabled:
|
|
156
|
+
description: 'The guided AI site start asks for sites the person likes (AGL-3975): ' + 'one on Free, three on a paid plan. The site job reads each home page ' + 'and two of its stylesheets through the SSRF-guarded fetch and keeps ' + 'signals only (section order, spacing, header, colors, type mood, ' + 'button and card shape, motion, and on a paid plan the features it ' + 'offers), never its words, images or logos. ON since 2026-10-28, ' + 'after its live eval re-run passed; turned off, no field is shown ' + 'and no site is read.',
|
|
157
|
+
defaultEnabled: true
|
|
158
158
|
},
|
|
159
159
|
// Platform-wide, never per org: the nightly job reads only the platform
|
|
160
160
|
// value, because it is the operator's own marketing and no workspace's
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.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 * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_ai_site_import'\n | 'release_crm_assist_whole_record'\n | 'release_ai_site_inspiration'\n | 'release_ad_audience_sync'\n | 'release_site_starters'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n /**\n * Where a RELEASED feature stands, when it is still changing. Absent means\n * generally available, which is what every feature without a stage is.\n *\n * Data only. A stage never gates anything, and it says nothing while the\n * flag is off: an off feature is rolling out, whatever its stage reads. The\n * console labels an on, `beta`-staged feature Beta beside its nav tab and\n * its hub header, and the docs open its pages with a `:::note Beta`\n * admonition, which `docs-release-flags.spec.ts` holds to this field.\n */\n stage?: ReleaseFlagStage\n}\n\n/**\n * The stages a released feature can be in short of general availability.\n * One today. A stage is per feature, never platform-wide: features keep\n * arriving in beta for as long as the product grows.\n */\nexport const RELEASE_FLAG_STAGES = ['beta'] as const\n\nexport type ReleaseFlagStage = (typeof RELEASE_FLAG_STAGES)[number]\n\nexport function isReleaseFlagStage(value: unknown): value is ReleaseFlagStage {\n return (RELEASE_FLAG_STAGES as readonly unknown[]).includes(value)\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n // Released to every workspace and still changing: what it answers, what\n // it can build from a message and how it cites the docs.\n stage: 'beta',\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'ON in production, and gated on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: true,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n // Released to every workspace and still changing: the guided start, page\n // and section generation and the other generative doors.\n stage: 'beta',\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. ON for every workspace ' +\n 'since 2026-09-29; every generative route answers 404 while it is ' +\n 'off. On, it sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: true,\n },\n // \"Bring my site\" (AGL-3973): proving a workspace owns a website, and the\n // crawl that reads it once it has. OFF by default so a code change can\n // never be what releases it; the doors answer 404 while it is off, with a\n // staff preview. Its own flag rather than `release_ai_generative`, because\n // reading someone's website is a different thing to disclose than\n // writing from a brief.\n {\n key: 'release_ai_site_import',\n label: 'Bring my site',\n // Released on every paid plan and still changing: how pages are read,\n // rewritten in the new design and brought in wave by wave.\n stage: 'beta',\n description:\n 'Bring an existing website into a workspace on a paid plan (AGL-3973): ' +\n 'the workspace proves it owns the domain (a DNS record, a home page ' +\n 'meta tag, or a code emailed to an address at the domain), then ' +\n 'AglynBot reads the site within robots.txt, same-origin only, under ' +\n 'page, byte and time budgets. ON since 2026-10-25 (beta.282, after the ' +\n 'Legal v18 import clause and the blocked-page reason, AGL-4029); turned ' +\n 'off, the ownership and crawl doors answer 404.',\n defaultEnabled: true,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' +\n 'the Subprocessors row were republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: true,\n },\n // Per org, with no staff preview on the server: the site job reads the\n // sites a person names only where the org's verdict is on, and the guided\n // start asks for them by the same verdict. `model/ai-site-inspiration.ts`\n // in the AI plugin holds the key.\n {\n key: 'release_ai_site_inspiration',\n label: 'AI site start: sites you like',\n description:\n 'The guided AI site start asks for sites the person likes (AGL-3975): ' +\n 'one on Free, three on a paid plan. The site job reads each home page ' +\n 'and two of its stylesheets through the SSRF-guarded fetch and keeps ' +\n 'signals only (section order, spacing, header, colors, type mood, ' +\n 'button and card shape, motion, and on a paid plan the features it ' +\n 'offers), never its words, images or logos. OFF by default: no field ' +\n 'is shown and no site is read.',\n defaultEnabled: false,\n },\n // Platform-wide, never per org: the nightly job reads only the platform\n // value, because it is the operator's own marketing and no workspace's\n // feature. `apps/console/app/api/admin/ad-audience-sync/route.ts` holds the\n // gate; a dry run reports the counts whichever way it is set.\n {\n key: 'release_ad_audience_sync',\n label: 'Account-holder ad audiences',\n description:\n 'Nightly sync of United States account holders, as SHA-256 hashed ' +\n 'email addresses, to the operator\\u2019s own Meta, Google and LinkedIn ' +\n 'customer-list audiences (AGL-3780). OFF by default: nothing is sent ' +\n 'until the Privacy Policy that discloses it is published.',\n defaultEnabled: false,\n },\n // Per org (AGL-4049): \"Create site\" is to offer the starter picker only\n // where the org's verdict is on. Nothing reads the flag yet, and off it\n // creates the default site as it always has.\n {\n key: 'release_site_starters',\n label: 'Site starters from the marketplace',\n description:\n 'Create site asks the person to choose a starter (AGL-4049): every ' +\n `option, ${PLATFORM_BRAND_NAME}\\u2019s own included, is a marketplace listing any ` +\n 'publisher may list and price, for a base start or an AI start. OFF ' +\n 'by default: Create site makes the default site as it always has.',\n defaultEnabled: false,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The stage a flag's definition declares, or `undefined` for a generally\n * available feature (and for a key the registry does not know, so a retired\n * key can never label anything).\n */\nexport function releaseFlagStage(\n key: ReleaseFlagKey | null | undefined,\n): ReleaseFlagStage | undefined {\n if (!key) return undefined\n return RELEASE_FLAGS.find((entry) => entry.key === key)?.stage\n}\n\n/** Whether a flag's definition declares the `beta` stage. */\nexport function isBetaStaged(key: ReleaseFlagKey | null | undefined): boolean {\n return releaseFlagStage(key) === 'beta'\n}\n\n/**\n * Whether a surface wears the Beta label: its feature is RELEASED for this\n * viewer's workspace and its flag is staged `beta`.\n *\n * `released` is the rollout verdict without the staff bypass. A staff member\n * looking at a workspace whose flag is off is previewing a feature that is\n * still rolling out, which the staff preview marker already says; calling it\n * Beta there would describe a feature the workspace does not have.\n */\nexport function showsBetaStage(\n key: ReleaseFlagKey | null | undefined,\n released: boolean | null | undefined,\n): boolean {\n return released === true && isBetaStaged(key)\n}\n\n/**\n * The stage a console surface wears, from the release flags that govern it:\n * a hub's own flag and its open section's, either of which may be absent.\n * `beta` when any of them is released for this workspace and staged beta.\n *\n * `flags` is the console's verdict map, read for `released` (the rollout\n * verdict without the staff bypass) for the reason {@link showsBetaStage}\n * gives.\n */\nexport function releaseStageOfSurface(\n keys: readonly (ReleaseFlagKey | null | undefined)[],\n flags: Partial<Record<ReleaseFlagKey, { released?: boolean } | undefined>>,\n): ReleaseFlagStage | undefined {\n return keys.some((key) => key && showsBetaStage(key, flags[key]?.released))\n ? 'beta'\n : undefined\n}\n\n/** Every flag staged `beta`, in registry order. */\nexport const BETA_STAGED_RELEASE_FLAG_KEYS: readonly ReleaseFlagKey[] =\n RELEASE_FLAGS.filter((definition) => definition.stage === 'beta').map(\n (definition) => definition.key,\n )\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","RELEASE_FLAG_STAGES","isReleaseFlagStage","value","includes","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","stage","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","getReleaseFlagDefinition","find","entry","Error","releaseFlagStage","undefined","isBetaStaged","showsBetaStage","released","releaseStageOfSurface","keys","flags","some","BETA_STAGED_RELEASE_FLAG_KEYS","filter","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","named","Set","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/release-flags.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 * Release flags (AGL-227): platform-level \"is this feature launched\"\n * gating backed by Firebase Remote Config. A separate axis from plan\n * entitlements (`plan-entitlements.ts`) — entitlements ask whether the\n * tenant's PLAN includes a feature, release flags ask whether the feature\n * is released to the public at all (or partially, via percentage rollout).\n * Staff bypass release flags everywhere but see a flagged warning.\n */\n\n/** Remote Config parameter keys for release-gated features. */\nimport type { OrgPlan } from '../foundation'\nimport { PLAN_LABELS, SELF_SERVE_PLANS } from './plan-entitlements'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n PLUGIN_RELEASE_FLAGS,\n type PluginReleaseFlagKey,\n} from './plugin-release-flags.generated'\n\n/**\n * Every release flag: the ones plugins declare (compiled from\n * plugins.config.json, AGL-3080) and the platform's own below.\n */\nexport type ReleaseFlagKey =\n | PluginReleaseFlagKey\n | 'release_addon_store'\n | 'release_native_checkout'\n | 'release_edit_bar'\n | 'release_assist'\n | 'release_video_uploads'\n | 'release_video_delivery'\n | 'release_ai_generative'\n | 'release_ai_site_import'\n | 'release_crm_assist_whole_record'\n | 'release_ai_site_inspiration'\n | 'release_ad_audience_sync'\n | 'release_site_starters'\n\nexport interface ReleaseFlagDefinition {\n key: ReleaseFlagKey\n label: string\n description: string\n /**\n * Fallback verdict when Remote Config is unreachable (offline, blocked,\n * first paint before activate). MUST match the seeded value in\n * cloud/firebase-remoteconfig.template.json so environments without a\n * published template behave like the template intends.\n */\n defaultEnabled: boolean\n /** Host dashboard tab id (host-nav-tabs.ts) this flag hides, if any. */\n navTabId?: string\n /**\n * Where a RELEASED feature stands, when it is still changing. Absent means\n * generally available, which is what every feature without a stage is.\n *\n * Data only. A stage never gates anything, and it says nothing while the\n * flag is off: an off feature is rolling out, whatever its stage reads. The\n * console labels an on, `beta`-staged feature Beta beside its nav tab and\n * its hub header, and the docs open its pages with a `:::note Beta`\n * admonition, which `docs-release-flags.spec.ts` holds to this field.\n */\n stage?: ReleaseFlagStage\n}\n\n/**\n * The stages a released feature can be in short of general availability.\n * One today. A stage is per feature, never platform-wide: features keep\n * arriving in beta for as long as the product grows.\n */\nexport const RELEASE_FLAG_STAGES = ['beta'] as const\n\nexport type ReleaseFlagStage = (typeof RELEASE_FLAG_STAGES)[number]\n\nexport function isReleaseFlagStage(value: unknown): value is ReleaseFlagStage {\n return (RELEASE_FLAG_STAGES as readonly unknown[]).includes(value)\n}\n\n/** A release flag a plugin declares, as the generator compiles it. */\nexport type PluginReleaseFlagDefinition = ReleaseFlagDefinition & {\n key: PluginReleaseFlagKey\n}\n\n/**\n * The platform's own release flags: features no plugin owns.\n *\n * Every first-party plugin is release-flagged too (AGL-422) — the flag feeds\n * the plugin LOADER (console, published sites, API dispatch), not just nav\n * visibility, so staff can kill-switch a whole plugin platform-wide from the\n * Feature Flags page. Those flags are the plugins' own: each is defined by\n * its catalog row's `releaseFlagDefinition` in plugins.config.json and\n * compiled into `PLUGIN_RELEASE_FLAGS`, so adding a plugin edits nothing\n * here.\n */\nconst PLATFORM_RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n {\n key: 'release_addon_store',\n label: 'Add-on store',\n description:\n 'Self-serve add-on purchases on the Billing page: seats, datasets, ' +\n 'extra sites, POS registers, Event Calendar (AGL-524..531).',\n defaultEnabled: true,\n },\n // Released to every workspace 2026-10-06 (AGL-3606). Storefront-only since\n // the console's plan checkout stopped rendering Stripe Checkout at all. Still\n // the surface's kill switch, globally or per org: off, both storefront\n // checkout routes return the hosted redirect again.\n {\n key: 'release_native_checkout',\n label: 'In-page checkout',\n description:\n 'Storefront shoppers pay on the merchant\\u2019s own site: the Payment ' +\n 'Element, email, shipping address and method, and a live total with ' +\n 'tax open in place under the Buy or Checkout button, styled with the ' +\n 'site theme, instead of a redirect to checkout.stripe.com (AGL-1944). ' +\n 'Released to every workspace 2026-10-06 (AGL-3606). The webhook is ' +\n 'still the only thing that fulfils. Also gated on ' +\n 'NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, so a deployment without one keeps ' +\n 'the redirect; turning this off returns every storefront to the redirect.',\n defaultEnabled: true,\n },\n // Released to every site 2026-09-16 (AGL-3041). Still the whole surface's\n // kill switch, globally or for one org by override: off, the tenant slot\n // renders nothing, both token mints refuse, and `/api/edit-context` turns\n // away every token already issued.\n {\n key: 'release_edit_bar',\n label: 'Site admin bar',\n description:\n 'Admin bar on published sites: a signed-in editor jumps from a live ' +\n 'page straight into the Besigner to edit it. Released ' +\n '2026-09-16 (AGL-3041) for every site; turning it off hides the bar ' +\n 'and revokes every outstanding edit token at the verify site.',\n defaultEnabled: true,\n },\n {\n key: 'release_assist',\n label: `${PLATFORM_BRAND_NAME} Assist`,\n // Released to every workspace and still changing: what it answers, what\n // it can build from a message and how it cites the docs.\n stage: 'beta',\n description:\n 'The in-console AI chat helper on every page (AGL-1860): docs-' +\n 'grounded answers with deep links, page-context guidance on Pro+. ' +\n 'ON in production, and gated on TWO published legal artifacts ' +\n '(AGL-1909), neither of which is a repo file — both are live besigner ' +\n 'pages, so publication is what satisfies them: (1) the privacy-policy ' +\n 'disclosure for stored Q&A, because the data loop records every ' +\n 'exchange org-scoped under orgs/{orgId}/assistExchanges; and (2) the ' +\n 'Anthropic row on /legal/subprocessors, which was deliberately ' +\n 'REMOVED on 2026-08-13 (subprocessorsV2a-20260813) on the premise ' +\n 'that no production key existed — so the page is not merely ' +\n 'incomplete without it, it is affirmatively wrong. NOTE the real ' +\n 'trigger is ANTHROPIC_API_KEY, not this flag: /api/ai/assist (the ' +\n 'besigner copy assistant, AGL-89/130/169) carries no release flag at ' +\n 'all and sends customer site content to Anthropic on the key plus a ' +\n 'Pro entitlement alone. Setting the key in production therefore makes ' +\n 'Anthropic a subprocessor whether or not this flag is ever flipped.',\n defaultEnabled: true,\n },\n // Ingress only, and with no staff preview on the server: a video a staff\n // session uploads into a customer's library serves on that customer's pages\n // like any other, so a grant goes to the org through its override.\n // `apps/console/utils/server/video-uploads.ts` holds the gate.\n {\n key: 'release_video_uploads',\n label: 'Video uploads',\n description:\n 'New video in the media library, on every upload path (AGL-2830). ' +\n 'Off: refused with 403 video_uploads_paused, as DAM video delivery is ' +\n 'not yet metered or bounded per org (AGL-2810, AGL-2812). Images, ' +\n 'documents and stored videos are unaffected.',\n defaultEnabled: false,\n },\n // Delivery, not ingress: this flag decides whether a video already in the\n // library is COPIED to the configured delivery provider and served from it\n // through a short-lived signed redirect (AGL-2824). Per org, with no staff\n // preview on the server — a copy is customer data at the provider, so a\n // grant goes to the org through its override. Off, or with no provider\n // configured, every video serves from this platform exactly as before.\n // `media-delivery.ts` in `tenant-data-admin` holds the gate.\n {\n key: 'release_video_delivery',\n label: 'Video delivery',\n description:\n 'Serves library video from a configured delivery provider through ' +\n 'short-lived signed redirects, and copies each video to it (AGL-2824). ' +\n 'OFF by default: video serves from this platform as before. Also ' +\n \"needs the provider's settings.\",\n defaultEnabled: false,\n },\n // The generative doors share one runtime with Assist (AGL-2903) but not\n // its flag: `release_assist` answers questions, this builds things, and\n // the two are sold, metered and disclosed separately. The server-side\n // gate lives in `aiGateLadder` (404 when off, staff preview).\n // Labeled for what it switches — the generative doors — and not with the\n // add-on's own name, which is sold and live whether or not the doors are\n // released: the docs name the add-on, and a flag wearing the same words\n // reads as documented when it is not.\n {\n key: 'release_ai_generative',\n label: `${PLATFORM_BRAND_NAME} AI generation`,\n // Released to every workspace and still changing: the guided start, page\n // and section generation and the other generative doors.\n stage: 'beta',\n description:\n 'Generative building and automation behind the AI add-on ' +\n '(AGL-2903): sections, pages and workflows written by a model from ' +\n 'a brief, on the shared Anthropic runtime. ON for every workspace ' +\n 'since 2026-09-29; every generative route answers 404 while it is ' +\n 'off. On, it sends customer briefs and site content to Anthropic ' +\n 'on the same ANTHROPIC_API_KEY and under the same subprocessor ' +\n 'disclosure as Assist (AGL-1909).',\n defaultEnabled: true,\n },\n // \"Bring my site\" (AGL-3973): proving a workspace owns a website, and the\n // crawl that reads it once it has. OFF by default so a code change can\n // never be what releases it; the doors answer 404 while it is off, with a\n // staff preview. Its own flag rather than `release_ai_generative`, because\n // reading someone's website is a different thing to disclose than\n // writing from a brief.\n {\n key: 'release_ai_site_import',\n label: 'Bring my site',\n // Released on every paid plan and still changing: how pages are read,\n // rewritten in the new design and brought in wave by wave.\n stage: 'beta',\n description:\n 'Bring an existing website into a workspace on a paid plan (AGL-3973): ' +\n 'the workspace proves it owns the domain (a DNS record, a home page ' +\n 'meta tag, or a code emailed to an address at the domain), then ' +\n 'AglynBot reads the site within robots.txt, same-origin only, under ' +\n 'page, byte and time budgets. ON since 2026-10-25 (beta.282, after the ' +\n 'Legal v18 import clause and the blocked-page reason, AGL-4029); turned ' +\n 'off, the ownership and crawl doors answer 404.',\n defaultEnabled: true,\n },\n // What CRM assistance hands the model provider, not whether it runs:\n // `release_ai_generative` opens the doors, this widens what a record sends\n // through them. Per org, with no staff preview — the data is the customer's,\n // and a staff session reading a customer's record must send what the\n // published pages promise. The CRM's record-facts readers hold the gate.\n {\n key: 'release_crm_assist_whole_record',\n label: 'CRM assistance: whole record',\n description:\n 'What AI assistance in the CRM (a record summary, next step or ' +\n 'email draft) sends the model provider (AGL-3520). OFF: the ' +\n 'disclosed fields, with addresses and numbers in typed text ' +\n 'replaced. ON: the whole record, contact details and custom fields ' +\n 'included. ON since 2026-10-05, after Privacy Policy section 2 and ' +\n 'the Subprocessors row were republished; see ' +\n 'docs/drafts/agl-3520-crm-assistance-whole-record-disclosure.md.',\n defaultEnabled: true,\n },\n // Per org, with no staff preview on the server: the site job reads the\n // sites a person names only where the org's verdict is on, and the guided\n // start asks for them by the same verdict. `model/ai-site-inspiration.ts`\n // in the AI plugin holds the key.\n {\n key: 'release_ai_site_inspiration',\n label: 'AI site start: sites you like',\n description:\n 'The guided AI site start asks for sites the person likes (AGL-3975): ' +\n 'one on Free, three on a paid plan. The site job reads each home page ' +\n 'and two of its stylesheets through the SSRF-guarded fetch and keeps ' +\n 'signals only (section order, spacing, header, colors, type mood, ' +\n 'button and card shape, motion, and on a paid plan the features it ' +\n 'offers), never its words, images or logos. ON since 2026-10-28, ' +\n 'after its live eval re-run passed; turned off, no field is shown ' +\n 'and no site is read.',\n defaultEnabled: true,\n },\n // Platform-wide, never per org: the nightly job reads only the platform\n // value, because it is the operator's own marketing and no workspace's\n // feature. `apps/console/app/api/admin/ad-audience-sync/route.ts` holds the\n // gate; a dry run reports the counts whichever way it is set.\n {\n key: 'release_ad_audience_sync',\n label: 'Account-holder ad audiences',\n description:\n 'Nightly sync of United States account holders, as SHA-256 hashed ' +\n 'email addresses, to the operator\\u2019s own Meta, Google and LinkedIn ' +\n 'customer-list audiences (AGL-3780). OFF by default: nothing is sent ' +\n 'until the Privacy Policy that discloses it is published.',\n defaultEnabled: false,\n },\n // Per org (AGL-4049): \"Create site\" is to offer the starter picker only\n // where the org's verdict is on. Nothing reads the flag yet, and off it\n // creates the default site as it always has.\n {\n key: 'release_site_starters',\n label: 'Site starters from the marketplace',\n description:\n 'Create site asks the person to choose a starter (AGL-4049): every ' +\n `option, ${PLATFORM_BRAND_NAME}\\u2019s own included, is a marketplace listing any ` +\n 'publisher may list and price, for a base start or an AI start. OFF ' +\n 'by default: Create site makes the default site as it always has.',\n defaultEnabled: false,\n },\n]\n\n/**\n * The registry: one entry per gated feature, the plugins' first. Adding a\n * platform flag = add it above, seed it in the Remote Config template, and\n * (optionally) wrap the page in `<FeatureGate>` — the staff admin editor and\n * nav filtering pick it up from this list.\n */\nexport const RELEASE_FLAGS: readonly ReleaseFlagDefinition[] = [\n ...PLUGIN_RELEASE_FLAGS,\n ...PLATFORM_RELEASE_FLAGS,\n]\n\nexport const RELEASE_FLAG_KEYS = RELEASE_FLAGS.map(\n (definition) => definition.key,\n) as readonly ReleaseFlagKey[]\n\nexport function isReleaseFlagKey(value: string): value is ReleaseFlagKey {\n return (RELEASE_FLAG_KEYS as readonly string[]).includes(value)\n}\n\nexport function getReleaseFlagDefinition(\n key: ReleaseFlagKey,\n): ReleaseFlagDefinition {\n const definition = RELEASE_FLAGS.find((entry) => entry.key === key)\n if (!definition) throw new Error(`Unknown release flag: ${key}`)\n return definition\n}\n\n/**\n * The stage a flag's definition declares, or `undefined` for a generally\n * available feature (and for a key the registry does not know, so a retired\n * key can never label anything).\n */\nexport function releaseFlagStage(\n key: ReleaseFlagKey | null | undefined,\n): ReleaseFlagStage | undefined {\n if (!key) return undefined\n return RELEASE_FLAGS.find((entry) => entry.key === key)?.stage\n}\n\n/** Whether a flag's definition declares the `beta` stage. */\nexport function isBetaStaged(key: ReleaseFlagKey | null | undefined): boolean {\n return releaseFlagStage(key) === 'beta'\n}\n\n/**\n * Whether a surface wears the Beta label: its feature is RELEASED for this\n * viewer's workspace and its flag is staged `beta`.\n *\n * `released` is the rollout verdict without the staff bypass. A staff member\n * looking at a workspace whose flag is off is previewing a feature that is\n * still rolling out, which the staff preview marker already says; calling it\n * Beta there would describe a feature the workspace does not have.\n */\nexport function showsBetaStage(\n key: ReleaseFlagKey | null | undefined,\n released: boolean | null | undefined,\n): boolean {\n return released === true && isBetaStaged(key)\n}\n\n/**\n * The stage a console surface wears, from the release flags that govern it:\n * a hub's own flag and its open section's, either of which may be absent.\n * `beta` when any of them is released for this workspace and staged beta.\n *\n * `flags` is the console's verdict map, read for `released` (the rollout\n * verdict without the staff bypass) for the reason {@link showsBetaStage}\n * gives.\n */\nexport function releaseStageOfSurface(\n keys: readonly (ReleaseFlagKey | null | undefined)[],\n flags: Partial<Record<ReleaseFlagKey, { released?: boolean } | undefined>>,\n): ReleaseFlagStage | undefined {\n return keys.some((key) => key && showsBetaStage(key, flags[key]?.released))\n ? 'beta'\n : undefined\n}\n\n/** Every flag staged `beta`, in registry order. */\nexport const BETA_STAGED_RELEASE_FLAG_KEYS: readonly ReleaseFlagKey[] =\n RELEASE_FLAGS.filter((definition) => definition.stage === 'beta').map(\n (definition) => definition.key,\n )\n\n/**\n * The JSON payload stored in each Remote Config parameter. `enabled: true`\n * turns the feature on for everyone; `enabled: false` with a positive\n * `rolloutPercent` enables it for that percentage of subjects (stable\n * per-tenant bucketing, GrowthBook-style); `plans` narrows either of those\n * to a set of tiers (AGL-2486).\n */\nexport interface ReleaseFlagValue {\n enabled: boolean\n /** 0–100; only consulted while `enabled` is false. */\n rolloutPercent?: number\n /**\n * Plan/tier targeting (AGL-2486). The tiers this flag is being rolled out\n * to, or ABSENT for \"every tier\".\n *\n * ABSENT AND EMPTY BOTH MEAN EVERY TIER, and that is load-bearing: every\n * flag stored before this field existed parses to `undefined` here, and an\n * operator who unticks every box has plainly not asked for a flag that\n * reaches nobody. Reading an empty list as \"no tiers\" would dark-launch\n * nothing while the console still said the flag was on — the inverted\n * reading of this field is the whole hazard, so it is closed in the parser\n * rather than left to each call site.\n */\n plans?: OrgPlan[]\n /** Free-form staff note (\"waiting on AGL-199\", owner, etc.). */\n note?: string\n}\n\nconst clampPercent = (value: unknown): number => {\n const percent = typeof value === 'number' && Number.isFinite(value) ? value : 0\n return Math.min(100, Math.max(0, Math.round(percent)))\n}\n\n/**\n * Every plan a flag may target, cheapest first — `SELF_SERVE_PLANS` then\n * `enterprise`, the same ladder `planGrantingFeature` walks and the same\n * order the plan grid renders. Derived, never re-typed: pricing v3 inserted\n * `scale` mid-ladder and added `agency`, and a second hand-written tier list\n * here would have missed both.\n */\nexport const RELEASE_FLAG_PLAN_LADDER: readonly OrgPlan[] = [\n ...SELF_SERVE_PLANS,\n 'enterprise' as OrgPlan,\n]\n\nconst isOrgPlan = (value: unknown): value is OrgPlan =>\n typeof value === 'string' &&\n Object.prototype.hasOwnProperty.call(PLAN_LABELS, value)\n\n/**\n * The tiers on the ladder at or above `plan` — what the console's \"Pro and\n * above\" shortcut expands to before it is STORED as an explicit list.\n *\n * Deliberately a UI convenience and not a stored \"minimum plan\" mode: a\n * stored threshold would silently re-aim every live flag the day a tier is\n * inserted into the middle of the ladder (pricing v3 did exactly that with\n * `scale`). An explicit list means the audience of a published flag only\n * ever changes because a human changed it.\n */\nexport function releaseFlagPlansAtOrAbove(plan: OrgPlan): OrgPlan[] {\n const index = RELEASE_FLAG_PLAN_LADDER.indexOf(plan)\n return index < 0 ? [] : RELEASE_FLAG_PLAN_LADDER.slice(index)\n}\n\n/**\n * Sanitises whatever is stored at `plans`, in ladder order.\n *\n * Returns `undefined` — not `[]` — for \"no targeting declared\", so the\n * absence survives a round trip through the staff editor and cannot be\n * written back as a list that means something else. Unknown tier names are\n * dropped for the same reason `parseOrgReleaseFlagOverrides` drops unknown\n * keys: a renamed or retired tier must never gate anything. A list that\n * names ONLY unknown tiers collapses to `undefined` (every tier) rather than\n * to an empty list, because a typo must inherit, never silently target.\n */\nexport function parseReleaseFlagPlans(raw: unknown): OrgPlan[] | undefined {\n if (!Array.isArray(raw)) return undefined\n const named = new Set(raw.filter(isOrgPlan))\n if (named.size === 0) return undefined\n return RELEASE_FLAG_PLAN_LADDER.filter((plan) => named.has(plan))\n}\n\n/**\n * Parses a Remote Config parameter string into a `ReleaseFlagValue`.\n * Tolerant by design — the template is hand-editable in the Firebase\n * console, so plain \"true\"/\"false\" strings and malformed JSON must not\n * crash gating: anything unreadable falls back to the registry default.\n */\nexport function parseReleaseFlagValue(\n raw: string | null | undefined,\n fallbackEnabled: boolean,\n): ReleaseFlagValue {\n const text = raw?.trim()\n if (!text) return { enabled: fallbackEnabled }\n if (text === 'true') return { enabled: true }\n if (text === 'false') return { enabled: false }\n try {\n const parsed = JSON.parse(text)\n if (typeof parsed === 'boolean') return { enabled: parsed }\n if (parsed && typeof parsed === 'object') {\n const plans = parseReleaseFlagPlans(parsed.plans)\n return {\n enabled: parsed.enabled === true,\n rolloutPercent: clampPercent(parsed.rolloutPercent),\n // Spread, so a flag stored before AGL-2486 has NO `plans` key at all\n // rather than an explicit `undefined`. `JSON.stringify` drops both,\n // but the two are not the same to `'plans' in value`, which is what\n // the staff PUT uses to tell \"the operator cleared the targeting\"\n // apart from \"this client never sent the field\".\n ...(plans ? { plans } : {}),\n note: typeof parsed.note === 'string' ? parsed.note : undefined,\n }\n }\n } catch {\n // fall through to the registry default\n }\n return { enabled: fallbackEnabled }\n}\n\n/**\n * The longest parameter description Remote Config will publish (AGL-3048).\n *\n * One description past it refuses the WHOLE publish with\n * `DESCRIPTION_EXCEEDS_MAXIMUM_SIZE`, which the staff flags route answered as\n * a 500. Registry descriptions are written for the staff flags page, and\n * several run far past the limit, so what the page shows and what a\n * parameter carries cannot always be the same text.\n *\n * Counted in UTF-8 BYTES. Remote Config states the limit in characters\n * without saying how it counts them, and a UTF-8 byte count is never smaller\n * than the code-point or UTF-16 count, so text within 256 bytes is within the\n * limit on every reading. The cost is a few characters of headroom on text\n * with typographic punctuation.\n */\nexport const REMOTE_CONFIG_DESCRIPTION_MAX_BYTES = 256\n\nfunction utf8ByteLength(text: string): number {\n let bytes = 0\n for (const character of text) {\n const codePoint = character.codePointAt(0) ?? 0\n bytes +=\n codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4\n }\n return bytes\n}\n\n/** Whether Remote Config accepts `text` as a parameter description. */\nexport function fitsRemoteConfigDescription(text: string): boolean {\n return utf8ByteLength(text) <= REMOTE_CONFIG_DESCRIPTION_MAX_BYTES\n}\n\nconst DESCRIPTION_ELLIPSIS = '…'\n\n/**\n * `text` cut to a length Remote Config accepts, at a word boundary, with an\n * ellipsis marking the cut. Text that already fits comes back unchanged.\n *\n * The cut takes whole code points, so a multi-byte character is never split.\n * It backs up to the last space when it lands inside a word (only a single\n * word longer than the whole budget is cut hard), and punctuation left\n * dangling before the ellipsis is dropped.\n */\nexport function clampRemoteConfigDescription(text: string): string {\n if (fitsRemoteConfigDescription(text)) return text\n const budget =\n REMOTE_CONFIG_DESCRIPTION_MAX_BYTES - utf8ByteLength(DESCRIPTION_ELLIPSIS)\n let kept = ''\n let bytes = 0\n for (const character of text) {\n const size = utf8ByteLength(character)\n if (bytes + size > budget) break\n kept += character\n bytes += size\n }\n const next = text.charAt(kept.length)\n if (next && !/\\s/.test(next)) {\n const lastSpace = kept.search(/\\s\\S*$/)\n if (lastSpace > 0) kept = kept.slice(0, lastSpace)\n }\n return `${kept.replace(/[\\s.,;:(–—-]+$/, '')}${DESCRIPTION_ELLIPSIS}`\n}\n\n/**\n * The description to publish with a release flag's Remote Config parameter\n * (AGL-3048). Always one Remote Config accepts:\n *\n * 1. The registry description, when it fits, so the flags page and the\n * parameter say the same thing.\n * 2. Otherwise the LIVE parameter's description, when it has one that fits.\n * Remote Config has already accepted exactly that text, and someone chose\n * it for the Firebase console. The first 250 characters of a longer\n * paragraph would drop whatever the paragraph says last: for\n * `release_assist`, the `/legal/subprocessors` precondition that its\n * AGL-1909 suite wants in front of whoever reads the flag there. The full\n * registry text stays on the staff flags page either way.\n * 3. Otherwise, as on a first publish with nothing live to keep, the\n * registry description clamped at a word boundary.\n */\nexport function releaseFlagParameterDescription(\n registryDescription: string,\n liveDescription: string | null | undefined,\n): string {\n if (fitsRemoteConfigDescription(registryDescription)) {\n return registryDescription\n }\n if (\n typeof liveDescription === 'string' &&\n liveDescription.trim().length > 0 &&\n fitsRemoteConfigDescription(liveDescription)\n ) {\n return liveDescription\n }\n return clampRemoteConfigDescription(registryDescription)\n}\n\n/**\n * FNV-1a 32-bit hash → 0–99 bucket. Deterministic so a subject keeps the\n * same rollout verdict across sessions and surfaces, and seeded with the\n * flag key so a subject doesn't land in the same bucket for every flag.\n */\nexport function releaseFlagBucket(flagKey: string, subjectId: string): number {\n let hash = 0x811c9dc5\n const seed = `${flagKey}:${subjectId}`\n for (let index = 0; index < seed.length; index += 1) {\n hash ^= seed.charCodeAt(index)\n hash = Math.imul(hash, 0x01000193)\n }\n return (hash >>> 0) % 100\n}\n\n/**\n * The gating verdict for one subject.\n *\n * `subjectId` is the ORG ID, or nothing. A rollout is a cohort of\n * workspaces, so a whole workspace has to land on one side of the\n * percentage: every surface that asks about an org must ask with the same\n * string, or they answer differently about the same customer.\n *\n * This docstring used to sanction falling back to the uid, and the server\n * gates fell back to a hostId. Since a hostId, a uid and an orgId hash to\n * three different buckets, a mid-rollout flag could be on in the console\n * and off on the published site for one workspace — stable per subject, so\n * it never flickered, it just stayed wrong (AGL-1656).\n *\n * An empty subject only passes fully-enabled flags. That is the deliberate\n * cost: a request with no resolvable org never joins a partial rollout,\n * which is the conservative answer rather than a confidently wrong one.\n */\nexport function isReleaseFlagOn(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n /**\n * The subject org's plan, when the caller knows it (AGL-2486). Optional so\n * every pre-existing call site compiles and answers unchanged: a flag that\n * declares no tier targeting never reads this.\n */\n plan?: OrgPlan | null,\n): boolean {\n if (!releaseFlagTargetsPlan(value, plan)) return false\n if (value.enabled) return true\n const percent = clampPercent(value.rolloutPercent)\n if (percent <= 0 || !subjectId) return false\n if (percent >= 100) return true\n return releaseFlagBucket(flagKey, subjectId) < percent\n}\n\n/**\n * Does this flag's tier targeting admit `plan`? (AGL-2486)\n *\n * THE SEMANTICS, because an operator who cannot predict the audience will\n * not stage a rollout at all:\n *\n * - The tier list is a FILTER, applied before and independently of the\n * percentage. The percentage then picks a cohort WITHIN the admitted\n * tiers. \"Pro and above at 50%\" is therefore both \"half of the Pro+\n * workspaces\" and \"the global 50% cohort, restricted to Pro+\" — the two\n * readings name the same set, and they do so because\n * {@link releaseFlagBucket} hashes `flagKey:orgId` and NOTHING ELSE.\n * - Which is also why the bucket is stable. Adding, removing or reordering\n * tiers cannot reshuffle the bucket, so an org already inside a 50%\n * rollout keeps the feature when an unrelated tier joins the list. Had\n * the plan been mixed into the hash — the obvious way to write this — a\n * tier edit would have re-drawn the cohort under every customer already\n * in it, and a plan change would have re-rolled the dice for one.\n * - The filter binds the FULLY-ENABLED path too, not just the rollout.\n * \"On, Enterprise + Agency\" is how a launch to the top of the ladder is\n * expressed, and it is what was actually asked for. Untargeted flags are\n * unaffected: no list means every tier, so `enabled` still means everyone.\n * - An UNKNOWN plan fails a declared list. Same conservatism as a missing\n * subject on a percentage rollout: a caller that cannot say which\n * workspace it is asking about gets the safe answer rather than a\n * confidently wrong one. A per-org staff override still wins over all of\n * it — see {@link isReleaseFlagOnForOrg} — so a targeted flag can still be\n * handed to one org off-ladder.\n */\nexport function releaseFlagTargetsPlan(\n value: ReleaseFlagValue,\n plan: OrgPlan | null | undefined,\n): boolean {\n const plans = value.plans\n // `!plans` would be the idiom here, but `strictNullChecks` is off repo-wide\n // and an empty array is truthy — both \"absent\" and \"empty\" have to be named\n // for the every-tier reading to actually hold.\n if (plans == null || plans.length === 0) return true\n if (!plan) return false\n return plans.includes(plan)\n}\n\n/**\n * Per-org release-flag overrides (AGL-1635), stored on the org doc at\n * `releaseFlags`. A present key is a staff DECISION about one organization\n * and wins over both the Remote Config value and the rollout bucket; an\n * absent key inherits.\n *\n * A separate field from `entitlements.features` on purpose: those ask\n * whether the org's PLAN includes a feature, these ask whether a\n * not-yet-released feature is switched on for this one customer. Folding\n * them together would make \"granted by the deal\" and \"previewing an\n * unreleased build\" indistinguishable at the point a support question is\n * asked.\n *\n * Why the org doc and not a Remote Config condition: RC conditions are\n * template-global and would need one published condition per org, with a\n * template publish (a manual, separate deploy) for every grant.\n */\nexport type OrgReleaseFlagOverrides = Partial<Record<ReleaseFlagKey, boolean>>\n\n/**\n * Sanitises whatever is actually stored at `org.releaseFlags`.\n *\n * Tolerant for the same reason `parseReleaseFlagValue` is: this map is\n * hand-editable in the Firebase console and survives registry renames, so a\n * retired flag key or a non-boolean must be dropped rather than allowed to\n * gate anything. Unknown keys are discarded — a stale key can never grant a\n * flag that no longer exists, and a typo silently inherits instead of\n * silently forcing.\n */\nexport function parseOrgReleaseFlagOverrides(\n raw: unknown,\n): OrgReleaseFlagOverrides {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}\n const overrides: OrgReleaseFlagOverrides = {}\n for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {\n if (typeof value === 'boolean' && isReleaseFlagKey(key)) {\n overrides[key] = value\n }\n }\n return overrides\n}\n\n/**\n * The gating verdict with a per-org override applied on top of\n * `isReleaseFlagOn`.\n *\n * The override is checked FIRST and short-circuits, so a forced-off flag\n * stays off for an org even while the flag is globally enabled — the\n * per-org kill switch is half the point, not just the per-org grant. Every\n * release-flag gate resolves through here so the console, the tenant\n * runtime and the API dispatchers cannot disagree about one org.\n */\nexport function isReleaseFlagOnForOrg(\n flagKey: ReleaseFlagKey,\n value: ReleaseFlagValue,\n subjectId: string | null | undefined,\n overrides: OrgReleaseFlagOverrides | null | undefined,\n /**\n * The org's plan, for tier targeting (AGL-2486). Checked only AFTER the\n * override: a staff grant is a decision about one named customer and\n * outranks the tier filter exactly as it already outranks the rollout\n * bucket. That ordering is what lets a Free org preview a\n * Business-targeted flag without widening the flag for every Free org.\n *\n * REQUIRED, and `null` has to be typed out. It was optional for one\n * release and that cost a silent revenue bug: `report-usage` held the org\n * document, dropped the argument, and every `plans`-declaring flag read\n * OFF — so the contacts overage went uninvoiced for exactly the orgs\n * entitled to reach the feature, with nothing thrown and nothing logged.\n * An unknown tier still refuses, which is the correct conservatism; what\n * cannot be allowed is a caller MANUFACTURING an unknown out of a tier it\n * already has in hand. Spelling `null` makes that a statement rather than\n * an omission, and makes the next call site that forgets fail to compile\n * instead of failing to bill. `getOrgReleaseFlagTargeting` returns the\n * plan beside the overrides from ONE document read precisely so that\n * passing it costs nothing.\n */\n plan: OrgPlan | null,\n): boolean {\n const override = overrides?.[flagKey]\n if (typeof override === 'boolean') return override\n return isReleaseFlagOn(flagKey, value, subjectId, plan)\n}\n"],"names":["PLAN_LABELS","SELF_SERVE_PLANS","PLATFORM_BRAND_NAME","PLUGIN_RELEASE_FLAGS","RELEASE_FLAG_STAGES","isReleaseFlagStage","value","includes","PLATFORM_RELEASE_FLAGS","key","label","description","defaultEnabled","stage","RELEASE_FLAGS","RELEASE_FLAG_KEYS","map","definition","isReleaseFlagKey","getReleaseFlagDefinition","find","entry","Error","releaseFlagStage","undefined","isBetaStaged","showsBetaStage","released","releaseStageOfSurface","keys","flags","some","BETA_STAGED_RELEASE_FLAG_KEYS","filter","clampPercent","percent","Number","isFinite","Math","min","max","round","RELEASE_FLAG_PLAN_LADDER","isOrgPlan","Object","prototype","hasOwnProperty","call","releaseFlagPlansAtOrAbove","plan","index","indexOf","slice","parseReleaseFlagPlans","raw","Array","isArray","named","Set","size","has","parseReleaseFlagValue","fallbackEnabled","text","trim","enabled","parsed","JSON","parse","plans","rolloutPercent","note","REMOTE_CONFIG_DESCRIPTION_MAX_BYTES","utf8ByteLength","bytes","character","codePoint","codePointAt","fitsRemoteConfigDescription","DESCRIPTION_ELLIPSIS","clampRemoteConfigDescription","budget","kept","next","charAt","length","test","lastSpace","search","replace","releaseFlagParameterDescription","registryDescription","liveDescription","releaseFlagBucket","flagKey","subjectId","hash","seed","charCodeAt","imul","isReleaseFlagOn","releaseFlagTargetsPlan","parseOrgReleaseFlagOverrides","overrides","entries","isReleaseFlagOnForOrg","override"],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Page links across a site starter's publish and install (AGL-4049).
|
|
19
|
+
*
|
|
20
|
+
* A link to another page of the site names that page by id (`screenId`), so
|
|
21
|
+
* it survives a slug change. A starter's pages have no ids of their own until
|
|
22
|
+
* a site is created from it, so the published starter names each linked page
|
|
23
|
+
* by its routing slug instead (`screenSlug`), and the site it creates turns
|
|
24
|
+
* the slugs back into the ids its new pages were given. After install only
|
|
25
|
+
* `screenId` remains, so nothing that renders a page learns a new prop.
|
|
26
|
+
*
|
|
27
|
+
* Both directions walk each node's `props` one level deep and rewrite only a
|
|
28
|
+
* prop whose value is a string. Pure, no I/O; neither mutates its input.
|
|
29
|
+
*/
|
|
30
|
+
/** The prop a published starter names a linked page by: its routing slug. */
|
|
31
|
+
export declare const SITE_STARTER_LINK_SLUG_PROP = "screenSlug";
|
|
32
|
+
/**
|
|
33
|
+
* Publish side: every `screenId` prop naming a key of `routing` becomes
|
|
34
|
+
* `screenSlug: routing[id]`. A `screenId` naming a page `routing` does not
|
|
35
|
+
* route is left as it was.
|
|
36
|
+
*/
|
|
37
|
+
export declare function starterLinksBySlug(nodes: Record<string, any>, routing: Record<string, string>): Record<string, any>;
|
|
38
|
+
/**
|
|
39
|
+
* Install side: every `screenSlug` prop becomes `screenId: ids[slug]`; a slug
|
|
40
|
+
* no page has is dropped.
|
|
41
|
+
*/
|
|
42
|
+
export declare function starterLinksById(nodes: Record<string, any>, ids: Record<string, string>): Record<string, any>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ /**
|
|
18
|
+
* Page links across a site starter's publish and install (AGL-4049).
|
|
19
|
+
*
|
|
20
|
+
* A link to another page of the site names that page by id (`screenId`), so
|
|
21
|
+
* it survives a slug change. A starter's pages have no ids of their own until
|
|
22
|
+
* a site is created from it, so the published starter names each linked page
|
|
23
|
+
* by its routing slug instead (`screenSlug`), and the site it creates turns
|
|
24
|
+
* the slugs back into the ids its new pages were given. After install only
|
|
25
|
+
* `screenId` remains, so nothing that renders a page learns a new prop.
|
|
26
|
+
*
|
|
27
|
+
* Both directions walk each node's `props` one level deep and rewrite only a
|
|
28
|
+
* prop whose value is a string. Pure, no I/O; neither mutates its input.
|
|
29
|
+
*/ /** The prop a published starter names a linked page by: its routing slug. */ export const SITE_STARTER_LINK_SLUG_PROP = 'screenSlug';
|
|
30
|
+
/** The prop a site's own pages name a linked page by: its id. */ const SCREEN_ID_PROP = 'screenId';
|
|
31
|
+
const isRecord = (value)=>!!value && typeof value === 'object' && !Array.isArray(value);
|
|
32
|
+
/** `props` without `key`. */ const without = (props, key)=>{
|
|
33
|
+
const rest = _extends({}, props);
|
|
34
|
+
delete rest[key];
|
|
35
|
+
return rest;
|
|
36
|
+
};
|
|
37
|
+
const own = (map, key)=>Object.prototype.hasOwnProperty.call(map, key) && typeof map[key] === 'string' ? map[key] : undefined;
|
|
38
|
+
/** Each node, with its props rewritten by `rewrite` when it returns new props, else as it was. */ function mapNodeProps(nodes, rewrite) {
|
|
39
|
+
if (!isRecord(nodes)) return nodes;
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [key, node] of Object.entries(nodes)){
|
|
42
|
+
const next = isRecord(node) && isRecord(node['props']) ? rewrite(node['props']) : null;
|
|
43
|
+
out[key] = next ? _extends({}, node, {
|
|
44
|
+
props: next
|
|
45
|
+
}) : node;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Publish side: every `screenId` prop naming a key of `routing` becomes
|
|
51
|
+
* `screenSlug: routing[id]`. A `screenId` naming a page `routing` does not
|
|
52
|
+
* route is left as it was.
|
|
53
|
+
*/ export function starterLinksBySlug(nodes, routing) {
|
|
54
|
+
return mapNodeProps(nodes, (props)=>{
|
|
55
|
+
const id = props[SCREEN_ID_PROP];
|
|
56
|
+
if (typeof id !== 'string') return null;
|
|
57
|
+
const slug = own(routing, id);
|
|
58
|
+
if (slug === undefined) return null;
|
|
59
|
+
return _extends({}, without(props, SCREEN_ID_PROP), {
|
|
60
|
+
[SITE_STARTER_LINK_SLUG_PROP]: slug
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Install side: every `screenSlug` prop becomes `screenId: ids[slug]`; a slug
|
|
66
|
+
* no page has is dropped.
|
|
67
|
+
*/ export function starterLinksById(nodes, ids) {
|
|
68
|
+
return mapNodeProps(nodes, (props)=>{
|
|
69
|
+
const slug = props[SITE_STARTER_LINK_SLUG_PROP];
|
|
70
|
+
if (typeof slug !== 'string') return null;
|
|
71
|
+
const rest = without(props, SITE_STARTER_LINK_SLUG_PROP);
|
|
72
|
+
const id = own(ids, slug);
|
|
73
|
+
return id === undefined ? rest : _extends({}, rest, {
|
|
74
|
+
[SCREEN_ID_PROP]: id
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=site-starter-links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-starter-links.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 * Page links across a site starter's publish and install (AGL-4049).\n *\n * A link to another page of the site names that page by id (`screenId`), so\n * it survives a slug change. A starter's pages have no ids of their own until\n * a site is created from it, so the published starter names each linked page\n * by its routing slug instead (`screenSlug`), and the site it creates turns\n * the slugs back into the ids its new pages were given. After install only\n * `screenId` remains, so nothing that renders a page learns a new prop.\n *\n * Both directions walk each node's `props` one level deep and rewrite only a\n * prop whose value is a string. Pure, no I/O; neither mutates its input.\n */\n\n/** The prop a published starter names a linked page by: its routing slug. */\nexport const SITE_STARTER_LINK_SLUG_PROP = 'screenSlug'\n\n/** The prop a site's own pages name a linked page by: its id. */\nconst SCREEN_ID_PROP = 'screenId'\n\nconst isRecord = (value: unknown): value is Record<string, any> =>\n !!value && typeof value === 'object' && !Array.isArray(value)\n\n/** `props` without `key`. */\nconst without = (props: Record<string, any>, key: string): Record<string, any> => {\n const rest = { ...props }\n delete rest[key]\n return rest\n}\n\nconst own = (map: Record<string, string>, key: string): string | undefined =>\n Object.prototype.hasOwnProperty.call(map, key) && typeof map[key] === 'string' ? map[key] : undefined\n\n/** Each node, with its props rewritten by `rewrite` when it returns new props, else as it was. */\nfunction mapNodeProps(\n nodes: Record<string, any>,\n rewrite: (props: Record<string, any>) => Record<string, any> | null,\n): Record<string, any> {\n if (!isRecord(nodes)) return nodes\n const out: Record<string, any> = {}\n for (const [key, node] of Object.entries(nodes)) {\n const next = isRecord(node) && isRecord(node['props']) ? rewrite(node['props']) : null\n out[key] = next ? { ...node, props: next } : node\n }\n return out\n}\n\n/**\n * Publish side: every `screenId` prop naming a key of `routing` becomes\n * `screenSlug: routing[id]`. A `screenId` naming a page `routing` does not\n * route is left as it was.\n */\nexport function starterLinksBySlug(\n nodes: Record<string, any>,\n routing: Record<string, string>,\n): Record<string, any> {\n return mapNodeProps(nodes, (props) => {\n const id = props[SCREEN_ID_PROP]\n if (typeof id !== 'string') return null\n const slug = own(routing, id)\n if (slug === undefined) return null\n return { ...without(props, SCREEN_ID_PROP), [SITE_STARTER_LINK_SLUG_PROP]: slug }\n })\n}\n\n/**\n * Install side: every `screenSlug` prop becomes `screenId: ids[slug]`; a slug\n * no page has is dropped.\n */\nexport function starterLinksById(\n nodes: Record<string, any>,\n ids: Record<string, string>,\n): Record<string, any> {\n return mapNodeProps(nodes, (props) => {\n const slug = props[SITE_STARTER_LINK_SLUG_PROP]\n if (typeof slug !== 'string') return null\n const rest = without(props, SITE_STARTER_LINK_SLUG_PROP)\n const id = own(ids, slug)\n return id === undefined ? rest : { ...rest, [SCREEN_ID_PROP]: id }\n })\n}\n"],"names":["SITE_STARTER_LINK_SLUG_PROP","SCREEN_ID_PROP","isRecord","value","Array","isArray","without","props","key","rest","own","map","Object","prototype","hasOwnProperty","call","undefined","mapNodeProps","nodes","rewrite","out","node","entries","next","starterLinksBySlug","routing","id","slug","starterLinksById","ids"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GAED,2EAA2E,GAC3E,OAAO,MAAMA,8BAA8B,aAAY;AAEvD,+DAA+D,GAC/D,MAAMC,iBAAiB;AAEvB,MAAMC,WAAW,CAACC,QAChB,CAAC,CAACA,SAAS,OAAOA,UAAU,YAAY,CAACC,MAAMC,OAAO,CAACF;AAEzD,2BAA2B,GAC3B,MAAMG,UAAU,CAACC,OAA4BC;IAC3C,MAAMC,OAAO,aAAKF;IAClB,OAAOE,IAAI,CAACD,IAAI;IAChB,OAAOC;AACT;AAEA,MAAMC,MAAM,CAACC,KAA6BH,MACxCI,OAAOC,SAAS,CAACC,cAAc,CAACC,IAAI,CAACJ,KAAKH,QAAQ,OAAOG,GAAG,CAACH,IAAI,KAAK,WAAWG,GAAG,CAACH,IAAI,GAAGQ;AAE9F,gGAAgG,GAChG,SAASC,aACPC,KAA0B,EAC1BC,OAAmE;IAEnE,IAAI,CAACjB,SAASgB,QAAQ,OAAOA;IAC7B,MAAME,MAA2B,CAAC;IAClC,KAAK,MAAM,CAACZ,KAAKa,KAAK,IAAIT,OAAOU,OAAO,CAACJ,OAAQ;QAC/C,MAAMK,OAAOrB,SAASmB,SAASnB,SAASmB,IAAI,CAAC,QAAQ,IAAIF,QAAQE,IAAI,CAAC,QAAQ,IAAI;QAClFD,GAAG,CAACZ,IAAI,GAAGe,OAAO,aAAKF;YAAMd,OAAOgB;aAASF;IAC/C;IACA,OAAOD;AACT;AAEA;;;;CAIC,GACD,OAAO,SAASI,mBACdN,KAA0B,EAC1BO,OAA+B;IAE/B,OAAOR,aAAaC,OAAO,CAACX;QAC1B,MAAMmB,KAAKnB,KAAK,CAACN,eAAe;QAChC,IAAI,OAAOyB,OAAO,UAAU,OAAO;QACnC,MAAMC,OAAOjB,IAAIe,SAASC;QAC1B,IAAIC,SAASX,WAAW,OAAO;QAC/B,OAAO,aAAKV,QAAQC,OAAON;YAAiB,CAACD,4BAA4B,EAAE2B;;IAC7E;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,iBACdV,KAA0B,EAC1BW,GAA2B;IAE3B,OAAOZ,aAAaC,OAAO,CAACX;QAC1B,MAAMoB,OAAOpB,KAAK,CAACP,4BAA4B;QAC/C,IAAI,OAAO2B,SAAS,UAAU,OAAO;QACrC,MAAMlB,OAAOH,QAAQC,OAAOP;QAC5B,MAAM0B,KAAKhB,IAAImB,KAAKF;QACpB,OAAOD,OAAOV,YAAYP,OAAO,aAAKA;YAAM,CAACR,eAAe,EAAEyB;;IAChE;AACF"}
|