@aglyn/aglyn 1.0.0-beta.229 → 1.0.0-beta.231
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/analytics-events.d.ts +18 -0
- package/src/lib/app-utils/analytics-events.js +2 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +14 -1
- package/src/lib/app-utils/crm.js +19 -2
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +111 -9
- package/src/lib/app-utils/docs-help.generated.js +272 -3
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +810 -61
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/host-status.d.ts +85 -0
- package/src/lib/app-utils/host-status.js +115 -0
- package/src/lib/app-utils/host-status.js.map +1 -0
- package/src/lib/app-utils/lockdown.js +1 -1
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/media-filter.d.ts +136 -0
- package/src/lib/app-utils/media-filter.js +400 -0
- package/src/lib/app-utils/media-filter.js.map +1 -0
- package/src/lib/app-utils/mobile-push.d.ts +98 -0
- package/src/lib/app-utils/mobile-push.js +97 -0
- package/src/lib/app-utils/mobile-push.js.map +1 -0
- package/src/lib/app-utils/notification-push.d.ts +38 -0
- package/src/lib/app-utils/notification-push.js +54 -0
- package/src/lib/app-utils/notification-push.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +7 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/organizations.js +5 -2
- package/src/lib/app-utils/organizations.js.map +1 -1
- package/src/lib/app-utils/plan-entitlements.js +20 -0
- package/src/lib/app-utils/plan-entitlements.js.map +1 -1
- package/src/lib/app-utils/plugin-host-events.generated.d.ts +1 -1
- package/src/lib/app-utils/plugin-host-events.generated.js +164 -0
- package/src/lib/app-utils/plugin-host-events.generated.js.map +1 -1
- package/src/lib/app-utils/plugin-release-flags.generated.d.ts +1 -1
- package/src/lib/app-utils/plugin-release-flags.generated.js +7 -0
- package/src/lib/app-utils/plugin-release-flags.generated.js.map +1 -1
- package/src/lib/app-utils/realm-host-surface.generated.js +3 -0
- package/src/lib/app-utils/realm-host-surface.generated.js.map +1 -1
- package/src/lib/app-utils/release-flags.js +6 -2
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/scope-tokens.d.ts +16 -1
- package/src/lib/app-utils/scope-tokens.js +15 -1
- package/src/lib/app-utils/scope-tokens.js.map +1 -1
- package/src/lib/app-utils/site-journey.d.ts +143 -0
- package/src/lib/app-utils/site-journey.js +282 -0
- package/src/lib/app-utils/site-journey.js.map +1 -0
- package/src/lib/app-utils/site-list-query.d.ts +47 -0
- package/src/lib/app-utils/site-list-query.js +142 -0
- package/src/lib/app-utils/site-list-query.js.map +1 -0
- package/src/lib/app-utils/site-wide-outbox.d.ts +95 -0
- package/src/lib/app-utils/site-wide-outbox.js +117 -0
- package/src/lib/app-utils/site-wide-outbox.js.map +1 -0
- package/src/lib/app-utils/transfer-launcher-context.d.ts +6 -0
- package/src/lib/app-utils/transfer-launcher-context.js.map +1 -1
- package/src/lib/app-utils/upload-inspection.js +7 -0
- package/src/lib/app-utils/upload-inspection.js.map +1 -1
- package/src/lib/app-utils/webhook-delivery.js +4 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +24 -0
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +18 -7
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/definitions/write-deny-coverage.util.d.ts +4 -1
- package/src/lib/foundation/definitions/write-deny-coverage.util.js +12 -2
- package/src/lib/foundation/definitions/write-deny-coverage.util.js.map +1 -1
- package/src/lib/plugin-manager/enabled-plugins.js +4 -2
- package/src/lib/plugin-manager/enabled-plugins.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +173 -0
- package/src/lib/plugin-manager/feature-plugins.js +62 -1
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +251 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +192 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js +157 -0
- package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.d.ts +168 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.js +172 -0
- package/src/lib/plugin-manager/plugin-checkout-extras.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contributions.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-contributions.js +1 -1
- package/src/lib/plugin-manager/plugin-contributions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-domain-events.d.ts +138 -0
- package/src/lib/plugin-manager/plugin-domain-events.js +148 -0
- package/src/lib/plugin-manager/plugin-domain-events.js.map +1 -0
- package/src/lib/plugin-manager/plugin-events.d.ts +51 -0
- package/src/lib/plugin-manager/plugin-events.js +4 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-fulfillment-providers.d.ts +101 -0
- package/src/lib/plugin-manager/plugin-fulfillment-providers.js +83 -0
- package/src/lib/plugin-manager/plugin-fulfillment-providers.js.map +1 -0
- package/src/lib/plugin-manager/plugin-permissions.js +21 -5
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-person-records.d.ts +90 -0
- package/src/lib/plugin-manager/plugin-person-records.js +26 -0
- package/src/lib/plugin-manager/plugin-person-records.js.map +1 -1
- package/src/lib/plugin-manager/plugin-product-catalog.d.ts +204 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js +43 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipment-records.d.ts +210 -0
- package/src/lib/plugin-manager/plugin-shipment-records.js +63 -0
- package/src/lib/plugin-manager/plugin-shipment-records.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +151 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js +62 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.d.ts +103 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.js +39 -0
- package/src/lib/plugin-manager/plugin-sms-messaging.js.map +1 -0
- package/src/lib/plugin-manager/plugin-stock-levels.d.ts +81 -0
- package/src/lib/plugin-manager/plugin-stock-levels.js +32 -0
- package/src/lib/plugin-manager/plugin-stock-levels.js.map +1 -0
- package/src/lib/plugin-manager/plugin-tax-profile.d.ts +154 -0
- package/src/lib/plugin-manager/plugin-tax-profile.js +56 -0
- package/src/lib/plugin-manager/plugin-tax-profile.js.map +1 -1
- package/src/lib/plugin-manager/plugin-theme-font-catalog.d.ts +59 -0
- package/src/lib/plugin-manager/plugin-theme-font-catalog.js +40 -0
- package/src/lib/plugin-manager/plugin-theme-font-catalog.js.map +1 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.d.ts +55 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.js +76 -0
- package/src/lib/plugin-manager/plugin-tracking-pages.js.map +1 -0
- package/src/lib/plugin-manager/realm-host-aglyn.generated.js +3 -0
- package/src/lib/plugin-manager/realm-host-aglyn.generated.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n OrgUpgradeProposal,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n /**\n * Whether that suspension is in force, stored for the staff list's\n * Suspended filter (AGL-3416). See `AglynOrgBilling.suspended`.\n */\n suspended?: boolean\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets and media when nobody chooses\n * (AGL-1048). `'org'` — the default and today's behavior — shares them\n * with every site. `'host'` starts them private to the site they were\n * created in, which is what an agency running client sites wants: safe\n * by default rather than safe by discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Media or Data page there is no host to scope to, so those stay `'org'`\n * either way.\n */\n defaultResourceScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The plan staff asked the workspace to move to (AGL-3466). */\n upgradeProposal?: OrgUpgradeProposal\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n /**\n * The row belongs to platform staff and takes none of the customer's seats\n * (AGL-3466).\n *\n * Stamped by the server whenever it writes a row for an account that holds\n * the `staff` claim at that moment — `createOrganization`, `upsertOrgMember`,\n * `grantHostAccess` — and cleared on every row the account holds when the\n * claim is revoked. Every seat counter and every seat gate skips a stamped\n * row, so a staff member who builds a workspace for a prospect, or joins one\n * to help, never fills the seat the customer is paying for. The rules refuse\n * client writes to `members`, so nobody can stamp themselves.\n */\n staffSeat?: boolean\n}\n\n/**\n * What happens to the outgoing owner when an owner handoff is accepted\n * (AGL-3466): `stay` keeps them on as an admin, `leave` takes them off the\n * roster.\n */\nexport type OwnerHandoffPreviousOwner = 'stay' | 'leave'\n\n/** The handoff half of an owner-handoff invite (AGL-3466). */\nexport interface AglynOrgOwnerHandoff {\n previousOwner: OwnerHandoffPreviousOwner\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n /**\n * `owner` only on an owner-handoff invite, which always carries `handoff`\n * beside it (AGL-3466). Every other invite is admin, editor or viewer.\n */\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Accepting this invite moves the workspace to the invitee (AGL-3466). It\n * reserves no seat: the owner seat moves rather than being added, and the\n * send-time check refuses a `stay` that would need a seat the plan lacks.\n */\n handoff?: AglynOrgOwnerHandoff\n /**\n * The address belongs to an account holding the `staff` claim, checked when\n * the invite is sent, so it reserves no seat (AGL-3466). Checked again at\n * acceptance, against the account that actually accepts.\n */\n staffSeat?: boolean\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n OrgUpgradeProposal,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n /**\n * Whether that suspension is in force, stored for the staff list's\n * Suspended filter (AGL-3416). See `AglynOrgBilling.suspended`.\n */\n suspended?: boolean\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets when nobody chooses (AGL-1048). `'org'` — the default\n * and today's behavior — shares them with every site. `'host'` starts them\n * private to the site they were created in, which is what an agency\n * running client sites wants: safe by default rather than safe by\n * discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Data page there is no host to scope to, so those stay `'org'` either way.\n *\n * New media follows `defaultMediaScope` and new CRM records\n * `crm.defaultRecordScope` instead; each falls back to this only while it\n * is unset, because this one field decided all three before AGL-3662.\n */\n defaultResourceScope?: 'org' | 'host'\n /**\n * The same choice for new uploads and media folders (AGL-3662), set\n * separately from datasets: an org can keep its rate card on one site\n * while every site shares its photos. Unset reads `defaultResourceScope`\n * — see `defaultMediaScopeOf` — which is what every org stored before the\n * two were split.\n */\n defaultMediaScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The plan staff asked the workspace to move to (AGL-3466). */\n upgradeProposal?: OrgUpgradeProposal\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n /**\n * The row belongs to platform staff and takes none of the customer's seats\n * (AGL-3466).\n *\n * Stamped by the server whenever it writes a row for an account that holds\n * the `staff` claim at that moment — `createOrganization`, `upsertOrgMember`,\n * `grantHostAccess` — and cleared on every row the account holds when the\n * claim is revoked. Every seat counter and every seat gate skips a stamped\n * row, so a staff member who builds a workspace for a prospect, or joins one\n * to help, never fills the seat the customer is paying for. The rules refuse\n * client writes to `members`, so nobody can stamp themselves.\n */\n staffSeat?: boolean\n}\n\n/**\n * What happens to the outgoing owner when an owner handoff is accepted\n * (AGL-3466): `stay` keeps them on as an admin, `leave` takes them off the\n * roster.\n */\nexport type OwnerHandoffPreviousOwner = 'stay' | 'leave'\n\n/** The handoff half of an owner-handoff invite (AGL-3466). */\nexport interface AglynOrgOwnerHandoff {\n previousOwner: OwnerHandoffPreviousOwner\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n /**\n * `owner` only on an owner-handoff invite, which always carries `handoff`\n * beside it (AGL-3466). Every other invite is admin, editor or viewer.\n */\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Accepting this invite moves the workspace to the invitee (AGL-3466). It\n * reserves no seat: the owner seat moves rather than being added, and the\n * send-time check refuses a `stay` that would need a seat the plan lacks.\n */\n handoff?: AglynOrgOwnerHandoff\n /**\n * The address belongs to an account holding the `staff` claim, checked when\n * the invite is sent, so it reserves no seat (AGL-3466). Checked again at\n * acceptance, against the account that actually accepts.\n */\n staffSeat?: boolean\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
|
|
@@ -104,7 +104,10 @@ export interface ParsedSubcollectionRules {
|
|
|
104
104
|
};
|
|
105
105
|
/** Collections with a dedicated `match` block, which can RE-GRANT. */
|
|
106
106
|
dedicated: string[];
|
|
107
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Excluded from all three AND re-granted by nothing: denied outright. A
|
|
109
|
+
* dedicated block that allows only `read` re-grants no write.
|
|
110
|
+
*/
|
|
108
111
|
serverOnly: string[];
|
|
109
112
|
}
|
|
110
113
|
/**
|
|
@@ -197,7 +197,13 @@
|
|
|
197
197
|
...host.matchAll(/match\s+\/([A-Za-z][A-Za-z0-9]*)\//g)
|
|
198
198
|
].map((entry)=>entry[1]))
|
|
199
199
|
];
|
|
200
|
-
|
|
200
|
+
// A dedicated block re-grants only what it allows. One that allows nothing
|
|
201
|
+
// but `read` (a server-written collection opened to the people who read
|
|
202
|
+
// what it describes) writes nothing, so the exclusion lists still decide.
|
|
203
|
+
const grantsWrite = (name)=>[
|
|
204
|
+
...host.matchAll(new RegExp(`match\\s+\\/${name}\\/[^{]*\\{`, 'g'))
|
|
205
|
+
].some((header)=>/\ballow\b[^:;]*\b(write|create|update|delete)\b/.test(rawBlockBody(host.slice(header.index), header[0])));
|
|
206
|
+
const serverOnly = excluded.create.filter((name)=>excluded.update.includes(name) && excluded.delete.includes(name) && !(dedicated.includes(name) && grantsWrite(name)));
|
|
201
207
|
return {
|
|
202
208
|
excluded,
|
|
203
209
|
dedicated,
|
|
@@ -446,7 +452,11 @@
|
|
|
446
452
|
*/ export function readFieldsOf(sources, binding) {
|
|
447
453
|
const found = new Set();
|
|
448
454
|
for (const raw of sources){
|
|
449
|
-
|
|
455
|
+
// The sources are TypeScript, so a `//` inside a string is not a comment:
|
|
456
|
+
// the rules-file stripper would cut the line there, leave the string
|
|
457
|
+
// unterminated for `stripQuotedStrings`, and let prose such as
|
|
458
|
+
// `'… link host. Default https://…'` read as a field access.
|
|
459
|
+
const source = stripQuotedStrings(stripTypeScriptComments(raw));
|
|
450
460
|
// `host?.field` / `host.field` — the ordinary read.
|
|
451
461
|
for (const hit of source.matchAll(new RegExp(`\\b${binding}\\s*\\??\\.\\s*([A-Za-z_$][A-Za-z0-9_$]*)`, 'g'))){
|
|
452
462
|
// `host.toLowerCase()` is a method INVOCATION, not a field (AGL-1719).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/write-deny-coverage.util.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 * Shared parsing for the write-deny coverage guards (AGL-1355, AGL-1361).\n *\n * Extracted from `org-write-deny-coverage.spec.ts` when the same property was\n * extended to `hosts/{hostId}` and `marketplaceListings/{listingId}`. Three\n * documents, three different rule shapes, one parser — a second copy of this\n * would be a second thing to keep true, which is the disease these guards\n * exist to treat.\n *\n * Deliberately PURE: every function takes source text and returns data, with\n * no file I/O and no imports. That keeps the module free of `node:fs` (this\n * lib is bundled for the browser) and makes each helper testable on a string\n * literal. The specs own the reading.\n *\n * Not exported from the foundation barrel — nothing at runtime should reach\n * for it.\n */\n\n/**\n * Comments are stripped from every source before parsing. Prose in this repo\n * quotes rules fragments and field names constantly — the org block's own\n * comment names all four AGL-1354 keys, and the listing block's names half its\n * deny-list — so parsing with comments in place would read the explanation of\n * a hole as the fix for it.\n *\n * A single left-to-right scan, NOT two regex passes, and that distinction is\n * load-bearing (AGL-2004). The previous version removed block comments first\n * and line comments second, so a line comment that merely QUOTED a path opened\n * a block comment that was never meant to exist:\n *\n * // the name, so `hosts/<hostId>/datasets/*` stayed a client-writable\n *\n * The `/` before `*` reads as an opening delimiter, and the strip then ran to\n * the next closing delimiter 571 lines away, swallowing real rule text — and\n * with it two closing braces, which left the block walker unable to find the\n * end of the root `match` and threw `never closes`. Every deny-coverage guard\n * in this directory failed to RUN, which is the worst way for a guard to fail:\n * the suite is red, so nobody reads it as coverage, but the thing it protects\n * (the AGL-1775 `registers` deny-list) is unwatched until someone does.\n *\n * Scanning once fixes it in both directions: whichever delimiter appears first\n * wins, so `/*` inside a line comment is just text, and `//` inside a block\n * comment cannot eat the block's own terminator.\n */\nexport function stripComments(source: string): string {\n let out = ''\n let index = 0\n while (index < source.length) {\n const pair = source.slice(index, index + 2)\n if (pair === '/*') {\n const end = source.indexOf('*/', index + 2)\n index = end < 0 ? source.length : end + 2\n continue\n }\n if (pair === '//') {\n const end = source.indexOf('\\n', index + 2)\n if (end < 0) break\n // Keep the newline: line numbers and statement separation survive.\n out += '\\n'\n index = end + 1\n continue\n }\n out += source[index]\n index += 1\n }\n return out\n}\n\n/**\n * Quoted strings, blanked. `org-permissions.ts` declares permission KEYS\n * spelled `'org.settings'` and `'org.auditLog'`, which read exactly like a\n * field access — the first run of the org guard demanded the rules deny two\n * fields that do not exist. Template literals are left alone: a real read can\n * legitimately live inside `${…}`.\n */\nexport function stripQuotedStrings(source: string): string {\n return source\n .replace(/'(?:\\\\.|[^'\\\\\\n])*'/g, \"''\")\n .replace(/\"(?:\\\\.|[^\"\\\\\\n])*\"/g, '\"\"')\n}\n\n/**\n * Firestore path variables (`{orgId}`, `{document=**}`) carry braces that a\n * block-depth walker reads as nesting. Rewriting them to angle brackets makes\n * the rules parseable by brace counting without losing the path text — which\n * matters, because `{document=**}` IS the wildcard these guards have to see.\n */\nexport function normalizePathVariables(rules: string): string {\n return rules.replace(/\\{([A-Za-z_][A-Za-z0-9_]*(?:=\\*\\*)?)\\}/g, '<$1>')\n}\n\n/**\n * The text of a `{ … }` block, keeping only what sits at the block's own\n * depth. Nested blocks — sub-`match` rules, inline object types — are dropped,\n * so a nested `allow` or a nested property can never be mistaken for one\n * belonging to the block asked for.\n *\n * `header` must end with the opening brace.\n */\nexport function topLevelBody(source: string, header: string): string {\n const at = source.indexOf(header)\n if (at < 0) throw new Error(`Guard cannot parse: no \\`${header}\\` found.`)\n let depth = 1\n let out = ''\n for (let index = at + header.length; index < source.length; index += 1) {\n const character = source[index]\n if (character === '{') {\n depth += 1\n continue\n }\n if (character === '}') {\n depth -= 1\n if (depth === 0) return out\n continue\n }\n if (depth === 1) out += character\n }\n throw new Error(`Guard cannot parse: \\`${header}\\` never closes.`)\n}\n\n/**\n * The FULL text of a `{ … }` block, nested blocks INCLUDED.\n *\n * The opposite of `topLevelBody`, and needed for the opposite question. That\n * one answers \"what does THIS block say\", so it drops nested bodies — which is\n * exactly right for a deny-list on one document, and exactly wrong for\n * `hosts/{hostId}`, whose subcollection rules all live in nested `match`\n * blocks. AGL-1367 lived in one of them.\n *\n * `header` must end with the opening brace.\n */\nexport function rawBlockBody(source: string, header: string): string {\n const at = source.indexOf(header)\n if (at < 0) throw new Error(`Guard cannot parse: no \\`${header}\\` found.`)\n let depth = 1\n let out = ''\n for (let index = at + header.length; index < source.length; index += 1) {\n const character = source[index]\n if (character === '{') depth += 1\n else if (character === '}') {\n depth -= 1\n if (depth === 0) return out\n }\n out += character\n }\n throw new Error(`Guard cannot parse: \\`${header}\\` never closes.`)\n}\n\n/** The subcollection write rules of `hosts/{hostId}`, as the rules state them. */\nexport interface ParsedSubcollectionRules {\n /** The `subcollection in […]` exclusion list of each catch-all allow. */\n excluded: { create: string[]; update: string[]; delete: string[] }\n /** Collections with a dedicated `match` block, which can RE-GRANT. */\n dedicated: string[]\n /** Excluded from all three AND re-granted by nothing: denied outright. */\n serverOnly: string[]\n}\n\n/**\n * Parse the host catch-all's three exclusion lists (AGL-1367).\n *\n * Membership in ONE list is not denial and never was — `variables` is\n * create-excluded and freely updatable, `webhooks` is create-excluded and\n * freely updatable on purpose (the soft delete is how a capped site frees a\n * slot). And a dedicated `match` block RE-GRANTS, because Firestore ORs its\n * allows and the LOOSER one wins: `screens`, `layouts` and `collections` all\n * sit in three lists and stay editor-writable through the blocks above.\n *\n * So \"denied outright\" is the intersection of the three lists MINUS everything\n * with a block of its own. That subtraction is the reason a dedicated\n * `allow write: if false` block would not have closed AGL-1367 — a deny can\n * never win an OR.\n */\nexport function parseHostSubcollectionRules(\n rulesSource: string,\n): ParsedSubcollectionRules {\n const rules = normalizePathVariables(stripComments(rulesSource))\n const host = rawBlockBody(rules, 'match /hosts/<hostId> {')\n\n const catchAllHeader = 'match /<subcollection>/<document=**> {'\n const occurrences = host.split(catchAllHeader).length - 1\n if (occurrences !== 1) {\n throw new Error(\n `Expected exactly one \\`${catchAllHeader}\\` under match /hosts/{hostId}, ` +\n `found ${occurrences}. A second one would OR another set of allows ` +\n `onto every subcollection, so these lists would be half the answer.`,\n )\n }\n const catchAll = rawBlockBody(host, catchAllHeader)\n\n const listFor = (operation: 'create' | 'update' | 'delete'): string[] => {\n const statement = catchAll\n .split(';')\n .find((entry) =>\n new RegExp(`\\\\ballow\\\\b[^:]*\\\\b${operation}\\\\b`).test(entry),\n )\n if (!statement) {\n throw new Error(\n `No \\`allow … ${operation}\\` in the host catch-all. The block has been ` +\n `restructured; re-read it before trusting this guard.`,\n )\n }\n const list = statement.match(/subcollection\\s+in\\s+\\[([^\\]]*)\\]/)\n if (!list) {\n throw new Error(\n `The host catch-all's \\`allow ${operation}\\` has no ` +\n `\\`subcollection in […]\\` exclusion list. Every server-owned host ` +\n `subcollection is denied by NAME in that list and nowhere else, so ` +\n `this guard cannot see what is protected any more.`,\n )\n }\n return [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1])\n }\n\n const excluded = {\n create: listFor('create'),\n update: listFor('update'),\n delete: listFor('delete'),\n }\n // Named `match` blocks only. After `normalizePathVariables` a wildcard\n // segment starts with `<`, so a leading letter is what distinguishes a\n // collection block from `match /<subcollection>/…` or the nested\n // `match /<sub>/…` inside the screens/layouts/components blocks.\n const dedicated = [\n ...new Set(\n [...host.matchAll(/match\\s+\\/([A-Za-z][A-Za-z0-9]*)\\//g)].map(\n (entry) => entry[1],\n ),\n ),\n ]\n const serverOnly = excluded.create.filter(\n (name) =>\n excluded.update.includes(name) &&\n excluded.delete.includes(name) &&\n !dedicated.includes(name),\n )\n return { excluded, dedicated, serverOnly }\n}\n\n/**\n * Split an expression on `||` at parenthesis depth 0 only.\n *\n * A naive `split('||')` tears a branch apart at any nested alternation, and\n * both of the documents AGL-1361 added have one. The host rule's client branch\n * is `(… && !hasAny(L1) && (role == 'admin' || !hasAny(L2)))` — its deny-list\n * is TIERED, six keys no site member may touch plus `disabledPlugins` which\n * only a site admin may — and a naive split drops L2 into a branch of its own\n * where nothing looks for it. The listing block's `function listingManager()`\n * produces the mirror problem, matching two branches instead of one.\n */\nexport function splitTopLevelOr(expression: string): string[] {\n const parts: string[] = []\n let depth = 0\n let current = ''\n for (let index = 0; index < expression.length; index += 1) {\n const character = expression[index]\n if (character === '(') depth += 1\n if (character === ')') depth -= 1\n if (depth === 0 && character === '|' && expression[index + 1] === '|') {\n parts.push(current)\n current = ''\n index += 1\n continue\n }\n current += character\n }\n parts.push(current)\n return parts.map((part) => part.trim()).filter(Boolean)\n}\n\n/** The keys of the FIRST `hasAny([...])` literal in a rules branch. */\nexport function hasAnyKeys(branch: string): string[] {\n const list = branch.match(/hasAny\\(\\s*\\[([^\\]]*)\\]/)\n if (!list) return []\n return [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1])\n}\n\n/**\n * The keys of EVERY `hasAny([...])` literal in a branch, flattened.\n *\n * The org rule has one list per branch; the host rule has two in a single\n * branch, because its deny-list is TIERED — six keys no site member may\n * touch, and `disabledPlugins` which only a site admin may. `hasAnyKeys`\n * returns the first list alone, so a host guard built on it would have\n * silently believed `disabledPlugins` was unprotected.\n */\nexport function allHasAnyKeys(branch: string): string[] {\n return [...branch.matchAll(/hasAny\\(\\s*\\[([^\\]]*)\\]/g)].flatMap((list) =>\n [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]),\n )\n}\n\n/**\n * Top-level property names declared on an interface.\n *\n * `header` is the full declaration line up to and including the brace, because\n * the three documents do not share a shape: `AglynOrgBilling` and `AglynHost`\n * both `extends AglynDocument`, while `MarketplaceListing` extends nothing.\n */\nexport function declaredFields(source: string, header: string): string[] {\n const body = topLevelBody(stripComments(source), header)\n return [\n ...body.matchAll(/(?:^|\\n)\\s*(\\$?[A-Za-z_][A-Za-z0-9_$]*)\\s*\\??\\s*:/g),\n ].map((entry) => entry[1])\n}\n\n/**\n * The top-level recursive (`**`) matches that could reach a document in\n * `collection` — the ones that could OR a looser write onto it.\n *\n * A recursive wildcard matches only what its tail allows. A collection-group\n * match, `/{path=**}/formSubmissions/{submissionId}` (AGL-3303), reaches\n * documents in a collection named `formSubmissions` and nothing else; a\n * wildcard collection, or a recursive tail, reaches every document. So only\n * those, and a group match naming `collection` itself, are the hazard a\n * deny-list guard has to refuse.\n *\n * `topLevelMatches` are post-`normalizePathVariables`.\n */\nexport function recursiveMatchesReaching(\n topLevelMatches: readonly string[],\n collection: string,\n): string[] {\n return topLevelMatches.filter((path) => {\n if (!path.includes('**')) return false\n const segments = path.split('/').filter(Boolean)\n const last = segments[segments.length - 1] ?? ''\n const parent = segments[segments.length - 2] ?? ''\n return (\n last.includes('**') ||\n !parent ||\n parent.startsWith('<') ||\n parent === collection\n )\n })\n}\n\n/** A parsed `allow update` rule for one document. */\nexport interface ParsedUpdateRule {\n /** Every key denied to the client branch, across all its `hasAny` lists. */\n denied: string[]\n /** Every OR'd branch of the single `allow update` statement. */\n branches: string[]\n /** Depth-0 statements of the document's `match` block. */\n statements: string[]\n /** Depth-0 `match` headers under `/databases/{database}/documents`. */\n topLevelMatches: string[]\n}\n\n/**\n * Parse the single `allow update` of one top-level document block.\n *\n * `matchHeader` is post-`normalizePathVariables`, e.g.\n * `match /hosts/<hostId> {`. `clientPredicate` names the branch that a CLIENT\n * reaches — `canManageOrg()`, `canWriteHostContent(hostId)`, `listingManager()`\n * — as opposed to the staff branches, which are trusted by design.\n */\nexport function parseUpdateRule(\n rulesSource: string,\n matchHeader: string,\n clientPredicate: string,\n): ParsedUpdateRule {\n const rules = normalizePathVariables(stripComments(rulesSource))\n const root = topLevelBody(rules, 'match /databases/<database>/documents {')\n const topLevelMatches = [...root.matchAll(/match\\s+(\\/\\S*)/g)].map(\n (entry) => entry[1],\n )\n\n const block = topLevelBody(rules, matchHeader)\n const statements = block\n .split(';')\n .map((statement) => statement.trim())\n .filter(Boolean)\n\n // ONE `allow` statement may mention `update`. Firestore ORs every allow\n // across sibling statements AND sibling match blocks, and the LOOSER one\n // wins — so a second statement would mean this guard is reasoning about a\n // deny-list that some other statement quietly overrides.\n const updates = statements.filter((statement) =>\n /\\ballow\\b[^:]*\\bupdate\\b/.test(statement),\n )\n if (updates.length !== 1) {\n throw new Error(\n `Expected exactly one \\`allow … update\\` statement under ` +\n `${matchHeader}, found ${updates.length}. Firestore ORs them and the ` +\n `LOOSER wins, so the deny-list this guard reads would no longer be ` +\n `the whole answer. Fold the branches back into one statement, or ` +\n `teach this guard how they combine.`,\n )\n }\n\n // Start at the `allow` keyword. `topLevelBody` drops a nested block's BODY\n // but leaves its header text at depth 1, so the listing block's\n // `function listingManager()` declaration survives as an orphan with no\n // semicolon of its own — it merges into the following statement and makes\n // `listingManager()` appear in two branches instead of one. Slicing to\n // `allow` removes the remnant without pretending the function is not there.\n const statement = updates[0].slice(updates[0].indexOf('allow'))\n const branches = splitTopLevelOr(statement)\n const found = branches.filter((branch) => branch.includes(clientPredicate))\n if (found.length !== 1) {\n throw new Error(\n `Expected exactly one \\`allow update\\` branch guarded by ` +\n `${clientPredicate} under ${matchHeader}, found ${found.length}. The ` +\n `rule has been restructured; re-read it before trusting this guard.`,\n )\n }\n\n return {\n denied: allHasAnyKeys(found[0]),\n branches,\n statements,\n topLevelMatches,\n }\n}\n\n/**\n * Field names in an object literal a document is seeded with.\n *\n * `anchor` is a regex source locating the write; the literal's own braces are\n * matched by depth rather than `[^}]*`, because a seed can legitimately carry\n * a nested object (the host seed writes `screens: {}`) and a lazy class would\n * truncate the field list at the first inner brace — silently shrinking one of\n * the guard's sources rather than failing.\n */\nexport function seedFields(source: string, anchor: RegExp): string[] | null {\n const at = stripComments(source).search(anchor)\n if (at < 0) return null\n const text = stripComments(source)\n const open = text.indexOf('{', at)\n if (open < 0) return null\n let depth = 1\n let body = ''\n for (let index = open + 1; index < text.length; index += 1) {\n const character = text[index]\n if (character === '{') depth += 1\n else if (character === '}') {\n depth -= 1\n if (depth === 0) break\n }\n if (depth === 1) body += character\n }\n return [\n ...body.matchAll(/(?:^|,|\\n)\\s*([A-Za-z_$][A-Za-z0-9_$]*)\\s*[,:]/g),\n ].map((entry) => entry[1])\n}\n\n/**\n * Field names a document of `collection` is seeded with, in either shape a\n * seed write takes in this codebase (AGL-2100).\n *\n * The original parser understood only the CHAINED form —\n * `collection('hosts').doc(id).set({ … })` — because that is what every seed\n * route looked like when it was written. AGL-2063 rewrote the host create into\n * a transactional claim, which necessarily splits the chain: the ref has to be\n * built BEFORE `runTransaction` opens so the transaction body can both read\n * and write it, leaving `const hostRef = …doc(hostId)` in one statement and\n * `tx.set(hostRef, { … })` in another. The chained anchor then matched\n * nothing, `seedFields` returned null, and the guard threw — asserting nothing\n * about any of the four sources of the host field set until it was fixed.\n *\n * So resolve the REF BINDING rather than teaching the guard one more literal\n * spelling. Any `set`/`create` whose first argument is a ref bound to this\n * collection is a seed write, which covers `tx.`, `batch.` and a bare\n * `ref.set()` alike, and does not care what wraps them.\n *\n * Anchoring on the WRITE and not on the binding matters: the binding is\n * followed by the transaction callback's own `{`, so a binding-anchored parse\n * would happily return the arrow body's contents as the field list — a\n * silently wrong answer, which is worse than the throw.\n *\n * Returns null when neither shape is present, so the caller still throws.\n */\nexport function seedFieldsOfCollection(\n source: string,\n collection: string,\n): string[] | null {\n const name = collection.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const chained = seedFields(\n source,\n new RegExp(\n `collection\\\\('${name}'\\\\)\\\\s*\\\\.doc\\\\([^)]*\\\\)\\\\s*\\\\.\\\\s*(?:set|create)\\\\s*\\\\(`,\n ),\n )\n if (chained) return chained\n\n const text = stripComments(source)\n const bindings = [\n ...text.matchAll(\n new RegExp(\n `(?:const|let|var)\\\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\\\s*=\\\\s*[^;\\\\n]*collection\\\\('${name}'\\\\)\\\\s*\\\\.doc\\\\(`,\n 'g',\n ),\n ),\n ].map((entry) => entry[1])\n\n for (const binding of bindings) {\n const fields = seedFields(\n source,\n new RegExp(\n `\\\\b(?:set|create)\\\\s*\\\\(\\\\s*${binding}\\\\s*,\\\\s*\\\\{`,\n ),\n )\n if (fields) return fields\n }\n return null\n}\n\n/**\n * Whether the property access ending at `index` is being CALLED (AGL-1719).\n *\n * The guard reads `hosts/{hostId}` fields by scanning for `host.<name>` across\n * a whole directory, which means it matches on the identifier NAME and cannot\n * see the type. `media-ref.ts` has `isFirstPartyHost(host: string)` whose body\n * is `host.toLowerCase()` — a hostname string, not the host document — so\n * `toLowerCase` entered the field universe and the guard demanded that a\n * `String.prototype` method be classified as server-owned or client-writable.\n *\n * Dropping method calls removes that whole class rather than that one name,\n * and it is sound in the only direction that matters. Firestore document data\n * is JSON: string, number, boolean, null, array, map, timestamp, geopoint,\n * reference. **No field of a document can be callable**, so `binding.X(` is\n * provably never a field read and this can never introduce a false negative —\n * which is the single failure mode a coverage guard has (AGL-1420).\n *\n * A chained read is untouched: in `host.disabledPlugins.includes(x)` the\n * character after the captured name is `.`, so `disabledPlugins` is still\n * collected and only `includes` is dropped. Optional calls (`host.foo?.()`)\n * and generic ones (`host.foo<T>()`) are calls too.\n *\n * An exclusion list of known method names was rejected: it would silence the\n * symptom, need tending forever, and still say nothing about the next method\n * on the next same-named local.\n */\nfunction isMethodCallAt(source: string, index: number): boolean {\n const rest = source.slice(index)\n return /^\\s*(?:\\?\\.)?\\s*(?:<[^<>()]*>\\s*)?\\(/.test(rest)\n}\n\n/**\n * Top-level fields of `binding` that the modules in `sources` read.\n *\n * Directory-wide on purpose. A per-file list would be one more thing to keep\n * up to date, and a NEW resolver reading a NEW field is covered the moment it\n * is written — the only way this stays true without anyone tending it.\n *\n * The document is identified by IDENTIFIER NAME, not by type — this is a text\n * scan, and it has no way to know what a local called `host` actually holds.\n * So an unrelated binding of the same name contributes its property reads too.\n * That direction is safe (a spurious field fails loudly and gets classified by\n * a human, and can never HIDE a real one), and it is deliberately left alone,\n * because the filter that would suppress it is the filter that could suppress\n * a real read. See {@link isMethodCallAt} for the one case that is not safe to\n * leave — it produced a name no human could classify.\n */\nexport function readFieldsOf(\n sources: Array<string>,\n binding: string,\n): string[] {\n const found = new Set<string>()\n for (const raw of sources) {\n const source = stripQuotedStrings(stripComments(raw))\n // `host?.field` / `host.field` — the ordinary read.\n for (const hit of source.matchAll(\n new RegExp(`\\\\b${binding}\\\\s*\\\\??\\\\.\\\\s*([A-Za-z_$][A-Za-z0-9_$]*)`, 'g'),\n )) {\n // `host.toLowerCase()` is a method INVOCATION, not a field (AGL-1719).\n if (isMethodCallAt(source, hit.index + hit[0].length)) continue\n // A lone `$` is the head of a `${…}` interpolation, not a field: it\n // comes from token-name construction like `` `{{host.${key}}}` ``.\n // Template literals are left unstripped on purpose — a real read can\n // live inside one — so this is the price, and a field named `$` cannot\n // exist. Anything else, including `$id`, is kept and must be classified.\n if (hit[1] !== '$') found.add(hit[1])\n }\n // `(host as { cname?: unknown })?.cname` — the cast a field that is real\n // but was undeclared gets read through.\n for (const hit of source.matchAll(\n new RegExp(\n `\\\\b${binding}\\\\s+as\\\\s*\\\\{\\\\s*([A-Za-z_$][A-Za-z0-9_$]*)\\\\s*\\\\??\\\\s*:`,\n 'g',\n ),\n )) {\n found.add(hit[1])\n }\n }\n return [...found].sort()\n}\n\n/**\n * Comments stripped from a TYPESCRIPT source, with string literals respected.\n *\n * `stripComments` above scans rules files, where no string ever contains a\n * comment delimiter. TypeScript sources are not that: `apps/console/app/api/\n * media/upload-url/route.ts` builds a download URL from a template literal\n * containing `https://`, and a scan that does not know it is inside a string\n * reads that `//` as a line comment and deletes the rest of the line. On a\n * guard that parses an object literal out of a route, that is not a crash —\n * it is a SHORTER field list, silently, which is the failure mode AGL-2004\n * described one level down.\n *\n * A separate function rather than a widened `stripComments`, because the two\n * inputs have genuinely different grammars and three shipped guards depend on\n * the rules-file behaviour exactly as it is. Same single left-to-right scan,\n * with quotes as a third state.\n */\nexport function stripTypeScriptComments(source: string): string {\n let out = ''\n let index = 0\n while (index < source.length) {\n const pair = source.slice(index, index + 2)\n if (pair === '/*') {\n const end = source.indexOf('*/', index + 2)\n index = end < 0 ? source.length : end + 2\n continue\n }\n if (pair === '//') {\n const end = source.indexOf('\\n', index + 2)\n if (end < 0) break\n // Keep the newline so line-oriented reasoning downstream survives.\n out += '\\n'\n index = end + 1\n continue\n }\n const character = source[index]\n if (character === \"'\" || character === '\"' || character === '`') {\n let cursor = index + 1\n while (cursor < source.length && source[cursor] !== character) {\n cursor += source[cursor] === '\\\\' ? 2 : 1\n }\n out += source.slice(index, Math.min(cursor + 1, source.length))\n index = cursor + 1\n continue\n }\n out += character\n index += 1\n }\n return out\n}\n\n/**\n * The fields of a document's universe that no partition claims.\n *\n * Unclassified means CLIENT-WRITABLE in production — the rules deny only what\n * is named — so every coverage guard reports this list and fails on any entry.\n * Shared because the documents are not all core's: the marketplace listing's\n * guard lives in the marketplace plugin beside the list it partitions, and a\n * second copy of the partition test would be a second thing to keep true.\n */\nexport function unclassifiedFields(\n universe: Iterable<string>,\n ...partitions: Array<ReadonlySet<string>>\n): string[] {\n return [...universe].filter(\n (field) => !partitions.some((partition) => partition.has(field)),\n )\n}\n\n/**\n * The fields both denied by the rules and declared client-writable (or\n * unpersisted). The two partitions disagreeing means one of them is a lie, so\n * the guards expect this to be empty.\n */\nexport function deniedAndDeclaredWritable(\n denied: ReadonlySet<string>,\n ...declared: Array<Readonly<Record<string, string>>>\n): string[] {\n return declared\n .flatMap((record) => Object.keys(record))\n .filter((field) => denied.has(field))\n}\n"],"names":["stripComments","source","out","index","length","pair","slice","end","indexOf","stripQuotedStrings","replace","normalizePathVariables","rules","topLevelBody","header","at","Error","depth","character","rawBlockBody","parseHostSubcollectionRules","rulesSource","host","catchAllHeader","occurrences","split","catchAll","listFor","operation","statement","find","entry","RegExp","test","list","match","matchAll","map","excluded","create","update","delete","dedicated","Set","serverOnly","filter","name","includes","splitTopLevelOr","expression","parts","current","push","part","trim","Boolean","hasAnyKeys","branch","allHasAnyKeys","flatMap","declaredFields","body","recursiveMatchesReaching","topLevelMatches","collection","path","segments","last","parent","startsWith","parseUpdateRule","matchHeader","clientPredicate","root","block","statements","updates","branches","found","denied","seedFields","anchor","search","text","open","seedFieldsOfCollection","chained","bindings","binding","fields","isMethodCallAt","rest","readFieldsOf","sources","raw","hit","add","sort","stripTypeScriptComments","cursor","Math","min","unclassifiedFields","universe","partitions","field","some","partition","has","deniedAndDeclaredWritable","declared","record","Object","keys"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/write-deny-coverage.util.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 * Shared parsing for the write-deny coverage guards (AGL-1355, AGL-1361).\n *\n * Extracted from `org-write-deny-coverage.spec.ts` when the same property was\n * extended to `hosts/{hostId}` and `marketplaceListings/{listingId}`. Three\n * documents, three different rule shapes, one parser — a second copy of this\n * would be a second thing to keep true, which is the disease these guards\n * exist to treat.\n *\n * Deliberately PURE: every function takes source text and returns data, with\n * no file I/O and no imports. That keeps the module free of `node:fs` (this\n * lib is bundled for the browser) and makes each helper testable on a string\n * literal. The specs own the reading.\n *\n * Not exported from the foundation barrel — nothing at runtime should reach\n * for it.\n */\n\n/**\n * Comments are stripped from every source before parsing. Prose in this repo\n * quotes rules fragments and field names constantly — the org block's own\n * comment names all four AGL-1354 keys, and the listing block's names half its\n * deny-list — so parsing with comments in place would read the explanation of\n * a hole as the fix for it.\n *\n * A single left-to-right scan, NOT two regex passes, and that distinction is\n * load-bearing (AGL-2004). The previous version removed block comments first\n * and line comments second, so a line comment that merely QUOTED a path opened\n * a block comment that was never meant to exist:\n *\n * // the name, so `hosts/<hostId>/datasets/*` stayed a client-writable\n *\n * The `/` before `*` reads as an opening delimiter, and the strip then ran to\n * the next closing delimiter 571 lines away, swallowing real rule text — and\n * with it two closing braces, which left the block walker unable to find the\n * end of the root `match` and threw `never closes`. Every deny-coverage guard\n * in this directory failed to RUN, which is the worst way for a guard to fail:\n * the suite is red, so nobody reads it as coverage, but the thing it protects\n * (the AGL-1775 `registers` deny-list) is unwatched until someone does.\n *\n * Scanning once fixes it in both directions: whichever delimiter appears first\n * wins, so `/*` inside a line comment is just text, and `//` inside a block\n * comment cannot eat the block's own terminator.\n */\nexport function stripComments(source: string): string {\n let out = ''\n let index = 0\n while (index < source.length) {\n const pair = source.slice(index, index + 2)\n if (pair === '/*') {\n const end = source.indexOf('*/', index + 2)\n index = end < 0 ? source.length : end + 2\n continue\n }\n if (pair === '//') {\n const end = source.indexOf('\\n', index + 2)\n if (end < 0) break\n // Keep the newline: line numbers and statement separation survive.\n out += '\\n'\n index = end + 1\n continue\n }\n out += source[index]\n index += 1\n }\n return out\n}\n\n/**\n * Quoted strings, blanked. `org-permissions.ts` declares permission KEYS\n * spelled `'org.settings'` and `'org.auditLog'`, which read exactly like a\n * field access — the first run of the org guard demanded the rules deny two\n * fields that do not exist. Template literals are left alone: a real read can\n * legitimately live inside `${…}`.\n */\nexport function stripQuotedStrings(source: string): string {\n return source\n .replace(/'(?:\\\\.|[^'\\\\\\n])*'/g, \"''\")\n .replace(/\"(?:\\\\.|[^\"\\\\\\n])*\"/g, '\"\"')\n}\n\n/**\n * Firestore path variables (`{orgId}`, `{document=**}`) carry braces that a\n * block-depth walker reads as nesting. Rewriting them to angle brackets makes\n * the rules parseable by brace counting without losing the path text — which\n * matters, because `{document=**}` IS the wildcard these guards have to see.\n */\nexport function normalizePathVariables(rules: string): string {\n return rules.replace(/\\{([A-Za-z_][A-Za-z0-9_]*(?:=\\*\\*)?)\\}/g, '<$1>')\n}\n\n/**\n * The text of a `{ … }` block, keeping only what sits at the block's own\n * depth. Nested blocks — sub-`match` rules, inline object types — are dropped,\n * so a nested `allow` or a nested property can never be mistaken for one\n * belonging to the block asked for.\n *\n * `header` must end with the opening brace.\n */\nexport function topLevelBody(source: string, header: string): string {\n const at = source.indexOf(header)\n if (at < 0) throw new Error(`Guard cannot parse: no \\`${header}\\` found.`)\n let depth = 1\n let out = ''\n for (let index = at + header.length; index < source.length; index += 1) {\n const character = source[index]\n if (character === '{') {\n depth += 1\n continue\n }\n if (character === '}') {\n depth -= 1\n if (depth === 0) return out\n continue\n }\n if (depth === 1) out += character\n }\n throw new Error(`Guard cannot parse: \\`${header}\\` never closes.`)\n}\n\n/**\n * The FULL text of a `{ … }` block, nested blocks INCLUDED.\n *\n * The opposite of `topLevelBody`, and needed for the opposite question. That\n * one answers \"what does THIS block say\", so it drops nested bodies — which is\n * exactly right for a deny-list on one document, and exactly wrong for\n * `hosts/{hostId}`, whose subcollection rules all live in nested `match`\n * blocks. AGL-1367 lived in one of them.\n *\n * `header` must end with the opening brace.\n */\nexport function rawBlockBody(source: string, header: string): string {\n const at = source.indexOf(header)\n if (at < 0) throw new Error(`Guard cannot parse: no \\`${header}\\` found.`)\n let depth = 1\n let out = ''\n for (let index = at + header.length; index < source.length; index += 1) {\n const character = source[index]\n if (character === '{') depth += 1\n else if (character === '}') {\n depth -= 1\n if (depth === 0) return out\n }\n out += character\n }\n throw new Error(`Guard cannot parse: \\`${header}\\` never closes.`)\n}\n\n/** The subcollection write rules of `hosts/{hostId}`, as the rules state them. */\nexport interface ParsedSubcollectionRules {\n /** The `subcollection in […]` exclusion list of each catch-all allow. */\n excluded: { create: string[]; update: string[]; delete: string[] }\n /** Collections with a dedicated `match` block, which can RE-GRANT. */\n dedicated: string[]\n /**\n * Excluded from all three AND re-granted by nothing: denied outright. A\n * dedicated block that allows only `read` re-grants no write.\n */\n serverOnly: string[]\n}\n\n/**\n * Parse the host catch-all's three exclusion lists (AGL-1367).\n *\n * Membership in ONE list is not denial and never was — `variables` is\n * create-excluded and freely updatable, `webhooks` is create-excluded and\n * freely updatable on purpose (the soft delete is how a capped site frees a\n * slot). And a dedicated `match` block RE-GRANTS, because Firestore ORs its\n * allows and the LOOSER one wins: `screens`, `layouts` and `collections` all\n * sit in three lists and stay editor-writable through the blocks above.\n *\n * So \"denied outright\" is the intersection of the three lists MINUS everything\n * with a block of its own. That subtraction is the reason a dedicated\n * `allow write: if false` block would not have closed AGL-1367 — a deny can\n * never win an OR.\n */\nexport function parseHostSubcollectionRules(\n rulesSource: string,\n): ParsedSubcollectionRules {\n const rules = normalizePathVariables(stripComments(rulesSource))\n const host = rawBlockBody(rules, 'match /hosts/<hostId> {')\n\n const catchAllHeader = 'match /<subcollection>/<document=**> {'\n const occurrences = host.split(catchAllHeader).length - 1\n if (occurrences !== 1) {\n throw new Error(\n `Expected exactly one \\`${catchAllHeader}\\` under match /hosts/{hostId}, ` +\n `found ${occurrences}. A second one would OR another set of allows ` +\n `onto every subcollection, so these lists would be half the answer.`,\n )\n }\n const catchAll = rawBlockBody(host, catchAllHeader)\n\n const listFor = (operation: 'create' | 'update' | 'delete'): string[] => {\n const statement = catchAll\n .split(';')\n .find((entry) =>\n new RegExp(`\\\\ballow\\\\b[^:]*\\\\b${operation}\\\\b`).test(entry),\n )\n if (!statement) {\n throw new Error(\n `No \\`allow … ${operation}\\` in the host catch-all. The block has been ` +\n `restructured; re-read it before trusting this guard.`,\n )\n }\n const list = statement.match(/subcollection\\s+in\\s+\\[([^\\]]*)\\]/)\n if (!list) {\n throw new Error(\n `The host catch-all's \\`allow ${operation}\\` has no ` +\n `\\`subcollection in […]\\` exclusion list. Every server-owned host ` +\n `subcollection is denied by NAME in that list and nowhere else, so ` +\n `this guard cannot see what is protected any more.`,\n )\n }\n return [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1])\n }\n\n const excluded = {\n create: listFor('create'),\n update: listFor('update'),\n delete: listFor('delete'),\n }\n // Named `match` blocks only. After `normalizePathVariables` a wildcard\n // segment starts with `<`, so a leading letter is what distinguishes a\n // collection block from `match /<subcollection>/…` or the nested\n // `match /<sub>/…` inside the screens/layouts/components blocks.\n const dedicated = [\n ...new Set(\n [...host.matchAll(/match\\s+\\/([A-Za-z][A-Za-z0-9]*)\\//g)].map(\n (entry) => entry[1],\n ),\n ),\n ]\n // A dedicated block re-grants only what it allows. One that allows nothing\n // but `read` (a server-written collection opened to the people who read\n // what it describes) writes nothing, so the exclusion lists still decide.\n const grantsWrite = (name: string): boolean =>\n [...host.matchAll(new RegExp(`match\\\\s+\\\\/${name}\\\\/[^{]*\\\\{`, 'g'))].some(\n (header) =>\n /\\ballow\\b[^:;]*\\b(write|create|update|delete)\\b/.test(\n rawBlockBody(host.slice(header.index), header[0]),\n ),\n )\n const serverOnly = excluded.create.filter(\n (name) =>\n excluded.update.includes(name) &&\n excluded.delete.includes(name) &&\n !(dedicated.includes(name) && grantsWrite(name)),\n )\n return { excluded, dedicated, serverOnly }\n}\n\n/**\n * Split an expression on `||` at parenthesis depth 0 only.\n *\n * A naive `split('||')` tears a branch apart at any nested alternation, and\n * both of the documents AGL-1361 added have one. The host rule's client branch\n * is `(… && !hasAny(L1) && (role == 'admin' || !hasAny(L2)))` — its deny-list\n * is TIERED, six keys no site member may touch plus `disabledPlugins` which\n * only a site admin may — and a naive split drops L2 into a branch of its own\n * where nothing looks for it. The listing block's `function listingManager()`\n * produces the mirror problem, matching two branches instead of one.\n */\nexport function splitTopLevelOr(expression: string): string[] {\n const parts: string[] = []\n let depth = 0\n let current = ''\n for (let index = 0; index < expression.length; index += 1) {\n const character = expression[index]\n if (character === '(') depth += 1\n if (character === ')') depth -= 1\n if (depth === 0 && character === '|' && expression[index + 1] === '|') {\n parts.push(current)\n current = ''\n index += 1\n continue\n }\n current += character\n }\n parts.push(current)\n return parts.map((part) => part.trim()).filter(Boolean)\n}\n\n/** The keys of the FIRST `hasAny([...])` literal in a rules branch. */\nexport function hasAnyKeys(branch: string): string[] {\n const list = branch.match(/hasAny\\(\\s*\\[([^\\]]*)\\]/)\n if (!list) return []\n return [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1])\n}\n\n/**\n * The keys of EVERY `hasAny([...])` literal in a branch, flattened.\n *\n * The org rule has one list per branch; the host rule has two in a single\n * branch, because its deny-list is TIERED — six keys no site member may\n * touch, and `disabledPlugins` which only a site admin may. `hasAnyKeys`\n * returns the first list alone, so a host guard built on it would have\n * silently believed `disabledPlugins` was unprotected.\n */\nexport function allHasAnyKeys(branch: string): string[] {\n return [...branch.matchAll(/hasAny\\(\\s*\\[([^\\]]*)\\]/g)].flatMap((list) =>\n [...list[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]),\n )\n}\n\n/**\n * Top-level property names declared on an interface.\n *\n * `header` is the full declaration line up to and including the brace, because\n * the three documents do not share a shape: `AglynOrgBilling` and `AglynHost`\n * both `extends AglynDocument`, while `MarketplaceListing` extends nothing.\n */\nexport function declaredFields(source: string, header: string): string[] {\n const body = topLevelBody(stripComments(source), header)\n return [\n ...body.matchAll(/(?:^|\\n)\\s*(\\$?[A-Za-z_][A-Za-z0-9_$]*)\\s*\\??\\s*:/g),\n ].map((entry) => entry[1])\n}\n\n/**\n * The top-level recursive (`**`) matches that could reach a document in\n * `collection` — the ones that could OR a looser write onto it.\n *\n * A recursive wildcard matches only what its tail allows. A collection-group\n * match, `/{path=**}/formSubmissions/{submissionId}` (AGL-3303), reaches\n * documents in a collection named `formSubmissions` and nothing else; a\n * wildcard collection, or a recursive tail, reaches every document. So only\n * those, and a group match naming `collection` itself, are the hazard a\n * deny-list guard has to refuse.\n *\n * `topLevelMatches` are post-`normalizePathVariables`.\n */\nexport function recursiveMatchesReaching(\n topLevelMatches: readonly string[],\n collection: string,\n): string[] {\n return topLevelMatches.filter((path) => {\n if (!path.includes('**')) return false\n const segments = path.split('/').filter(Boolean)\n const last = segments[segments.length - 1] ?? ''\n const parent = segments[segments.length - 2] ?? ''\n return (\n last.includes('**') ||\n !parent ||\n parent.startsWith('<') ||\n parent === collection\n )\n })\n}\n\n/** A parsed `allow update` rule for one document. */\nexport interface ParsedUpdateRule {\n /** Every key denied to the client branch, across all its `hasAny` lists. */\n denied: string[]\n /** Every OR'd branch of the single `allow update` statement. */\n branches: string[]\n /** Depth-0 statements of the document's `match` block. */\n statements: string[]\n /** Depth-0 `match` headers under `/databases/{database}/documents`. */\n topLevelMatches: string[]\n}\n\n/**\n * Parse the single `allow update` of one top-level document block.\n *\n * `matchHeader` is post-`normalizePathVariables`, e.g.\n * `match /hosts/<hostId> {`. `clientPredicate` names the branch that a CLIENT\n * reaches — `canManageOrg()`, `canWriteHostContent(hostId)`, `listingManager()`\n * — as opposed to the staff branches, which are trusted by design.\n */\nexport function parseUpdateRule(\n rulesSource: string,\n matchHeader: string,\n clientPredicate: string,\n): ParsedUpdateRule {\n const rules = normalizePathVariables(stripComments(rulesSource))\n const root = topLevelBody(rules, 'match /databases/<database>/documents {')\n const topLevelMatches = [...root.matchAll(/match\\s+(\\/\\S*)/g)].map(\n (entry) => entry[1],\n )\n\n const block = topLevelBody(rules, matchHeader)\n const statements = block\n .split(';')\n .map((statement) => statement.trim())\n .filter(Boolean)\n\n // ONE `allow` statement may mention `update`. Firestore ORs every allow\n // across sibling statements AND sibling match blocks, and the LOOSER one\n // wins — so a second statement would mean this guard is reasoning about a\n // deny-list that some other statement quietly overrides.\n const updates = statements.filter((statement) =>\n /\\ballow\\b[^:]*\\bupdate\\b/.test(statement),\n )\n if (updates.length !== 1) {\n throw new Error(\n `Expected exactly one \\`allow … update\\` statement under ` +\n `${matchHeader}, found ${updates.length}. Firestore ORs them and the ` +\n `LOOSER wins, so the deny-list this guard reads would no longer be ` +\n `the whole answer. Fold the branches back into one statement, or ` +\n `teach this guard how they combine.`,\n )\n }\n\n // Start at the `allow` keyword. `topLevelBody` drops a nested block's BODY\n // but leaves its header text at depth 1, so the listing block's\n // `function listingManager()` declaration survives as an orphan with no\n // semicolon of its own — it merges into the following statement and makes\n // `listingManager()` appear in two branches instead of one. Slicing to\n // `allow` removes the remnant without pretending the function is not there.\n const statement = updates[0].slice(updates[0].indexOf('allow'))\n const branches = splitTopLevelOr(statement)\n const found = branches.filter((branch) => branch.includes(clientPredicate))\n if (found.length !== 1) {\n throw new Error(\n `Expected exactly one \\`allow update\\` branch guarded by ` +\n `${clientPredicate} under ${matchHeader}, found ${found.length}. The ` +\n `rule has been restructured; re-read it before trusting this guard.`,\n )\n }\n\n return {\n denied: allHasAnyKeys(found[0]),\n branches,\n statements,\n topLevelMatches,\n }\n}\n\n/**\n * Field names in an object literal a document is seeded with.\n *\n * `anchor` is a regex source locating the write; the literal's own braces are\n * matched by depth rather than `[^}]*`, because a seed can legitimately carry\n * a nested object (the host seed writes `screens: {}`) and a lazy class would\n * truncate the field list at the first inner brace — silently shrinking one of\n * the guard's sources rather than failing.\n */\nexport function seedFields(source: string, anchor: RegExp): string[] | null {\n const at = stripComments(source).search(anchor)\n if (at < 0) return null\n const text = stripComments(source)\n const open = text.indexOf('{', at)\n if (open < 0) return null\n let depth = 1\n let body = ''\n for (let index = open + 1; index < text.length; index += 1) {\n const character = text[index]\n if (character === '{') depth += 1\n else if (character === '}') {\n depth -= 1\n if (depth === 0) break\n }\n if (depth === 1) body += character\n }\n return [\n ...body.matchAll(/(?:^|,|\\n)\\s*([A-Za-z_$][A-Za-z0-9_$]*)\\s*[,:]/g),\n ].map((entry) => entry[1])\n}\n\n/**\n * Field names a document of `collection` is seeded with, in either shape a\n * seed write takes in this codebase (AGL-2100).\n *\n * The original parser understood only the CHAINED form —\n * `collection('hosts').doc(id).set({ … })` — because that is what every seed\n * route looked like when it was written. AGL-2063 rewrote the host create into\n * a transactional claim, which necessarily splits the chain: the ref has to be\n * built BEFORE `runTransaction` opens so the transaction body can both read\n * and write it, leaving `const hostRef = …doc(hostId)` in one statement and\n * `tx.set(hostRef, { … })` in another. The chained anchor then matched\n * nothing, `seedFields` returned null, and the guard threw — asserting nothing\n * about any of the four sources of the host field set until it was fixed.\n *\n * So resolve the REF BINDING rather than teaching the guard one more literal\n * spelling. Any `set`/`create` whose first argument is a ref bound to this\n * collection is a seed write, which covers `tx.`, `batch.` and a bare\n * `ref.set()` alike, and does not care what wraps them.\n *\n * Anchoring on the WRITE and not on the binding matters: the binding is\n * followed by the transaction callback's own `{`, so a binding-anchored parse\n * would happily return the arrow body's contents as the field list — a\n * silently wrong answer, which is worse than the throw.\n *\n * Returns null when neither shape is present, so the caller still throws.\n */\nexport function seedFieldsOfCollection(\n source: string,\n collection: string,\n): string[] | null {\n const name = collection.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n const chained = seedFields(\n source,\n new RegExp(\n `collection\\\\('${name}'\\\\)\\\\s*\\\\.doc\\\\([^)]*\\\\)\\\\s*\\\\.\\\\s*(?:set|create)\\\\s*\\\\(`,\n ),\n )\n if (chained) return chained\n\n const text = stripComments(source)\n const bindings = [\n ...text.matchAll(\n new RegExp(\n `(?:const|let|var)\\\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\\\s*=\\\\s*[^;\\\\n]*collection\\\\('${name}'\\\\)\\\\s*\\\\.doc\\\\(`,\n 'g',\n ),\n ),\n ].map((entry) => entry[1])\n\n for (const binding of bindings) {\n const fields = seedFields(\n source,\n new RegExp(\n `\\\\b(?:set|create)\\\\s*\\\\(\\\\s*${binding}\\\\s*,\\\\s*\\\\{`,\n ),\n )\n if (fields) return fields\n }\n return null\n}\n\n/**\n * Whether the property access ending at `index` is being CALLED (AGL-1719).\n *\n * The guard reads `hosts/{hostId}` fields by scanning for `host.<name>` across\n * a whole directory, which means it matches on the identifier NAME and cannot\n * see the type. `media-ref.ts` has `isFirstPartyHost(host: string)` whose body\n * is `host.toLowerCase()` — a hostname string, not the host document — so\n * `toLowerCase` entered the field universe and the guard demanded that a\n * `String.prototype` method be classified as server-owned or client-writable.\n *\n * Dropping method calls removes that whole class rather than that one name,\n * and it is sound in the only direction that matters. Firestore document data\n * is JSON: string, number, boolean, null, array, map, timestamp, geopoint,\n * reference. **No field of a document can be callable**, so `binding.X(` is\n * provably never a field read and this can never introduce a false negative —\n * which is the single failure mode a coverage guard has (AGL-1420).\n *\n * A chained read is untouched: in `host.disabledPlugins.includes(x)` the\n * character after the captured name is `.`, so `disabledPlugins` is still\n * collected and only `includes` is dropped. Optional calls (`host.foo?.()`)\n * and generic ones (`host.foo<T>()`) are calls too.\n *\n * An exclusion list of known method names was rejected: it would silence the\n * symptom, need tending forever, and still say nothing about the next method\n * on the next same-named local.\n */\nfunction isMethodCallAt(source: string, index: number): boolean {\n const rest = source.slice(index)\n return /^\\s*(?:\\?\\.)?\\s*(?:<[^<>()]*>\\s*)?\\(/.test(rest)\n}\n\n/**\n * Top-level fields of `binding` that the modules in `sources` read.\n *\n * Directory-wide on purpose. A per-file list would be one more thing to keep\n * up to date, and a NEW resolver reading a NEW field is covered the moment it\n * is written — the only way this stays true without anyone tending it.\n *\n * The document is identified by IDENTIFIER NAME, not by type — this is a text\n * scan, and it has no way to know what a local called `host` actually holds.\n * So an unrelated binding of the same name contributes its property reads too.\n * That direction is safe (a spurious field fails loudly and gets classified by\n * a human, and can never HIDE a real one), and it is deliberately left alone,\n * because the filter that would suppress it is the filter that could suppress\n * a real read. See {@link isMethodCallAt} for the one case that is not safe to\n * leave — it produced a name no human could classify.\n */\nexport function readFieldsOf(\n sources: Array<string>,\n binding: string,\n): string[] {\n const found = new Set<string>()\n for (const raw of sources) {\n // The sources are TypeScript, so a `//` inside a string is not a comment:\n // the rules-file stripper would cut the line there, leave the string\n // unterminated for `stripQuotedStrings`, and let prose such as\n // `'… link host. Default https://…'` read as a field access.\n const source = stripQuotedStrings(stripTypeScriptComments(raw))\n // `host?.field` / `host.field` — the ordinary read.\n for (const hit of source.matchAll(\n new RegExp(`\\\\b${binding}\\\\s*\\\\??\\\\.\\\\s*([A-Za-z_$][A-Za-z0-9_$]*)`, 'g'),\n )) {\n // `host.toLowerCase()` is a method INVOCATION, not a field (AGL-1719).\n if (isMethodCallAt(source, hit.index + hit[0].length)) continue\n // A lone `$` is the head of a `${…}` interpolation, not a field: it\n // comes from token-name construction like `` `{{host.${key}}}` ``.\n // Template literals are left unstripped on purpose — a real read can\n // live inside one — so this is the price, and a field named `$` cannot\n // exist. Anything else, including `$id`, is kept and must be classified.\n if (hit[1] !== '$') found.add(hit[1])\n }\n // `(host as { cname?: unknown })?.cname` — the cast a field that is real\n // but was undeclared gets read through.\n for (const hit of source.matchAll(\n new RegExp(\n `\\\\b${binding}\\\\s+as\\\\s*\\\\{\\\\s*([A-Za-z_$][A-Za-z0-9_$]*)\\\\s*\\\\??\\\\s*:`,\n 'g',\n ),\n )) {\n found.add(hit[1])\n }\n }\n return [...found].sort()\n}\n\n/**\n * Comments stripped from a TYPESCRIPT source, with string literals respected.\n *\n * `stripComments` above scans rules files, where no string ever contains a\n * comment delimiter. TypeScript sources are not that: `apps/console/app/api/\n * media/upload-url/route.ts` builds a download URL from a template literal\n * containing `https://`, and a scan that does not know it is inside a string\n * reads that `//` as a line comment and deletes the rest of the line. On a\n * guard that parses an object literal out of a route, that is not a crash —\n * it is a SHORTER field list, silently, which is the failure mode AGL-2004\n * described one level down.\n *\n * A separate function rather than a widened `stripComments`, because the two\n * inputs have genuinely different grammars and three shipped guards depend on\n * the rules-file behaviour exactly as it is. Same single left-to-right scan,\n * with quotes as a third state.\n */\nexport function stripTypeScriptComments(source: string): string {\n let out = ''\n let index = 0\n while (index < source.length) {\n const pair = source.slice(index, index + 2)\n if (pair === '/*') {\n const end = source.indexOf('*/', index + 2)\n index = end < 0 ? source.length : end + 2\n continue\n }\n if (pair === '//') {\n const end = source.indexOf('\\n', index + 2)\n if (end < 0) break\n // Keep the newline so line-oriented reasoning downstream survives.\n out += '\\n'\n index = end + 1\n continue\n }\n const character = source[index]\n if (character === \"'\" || character === '\"' || character === '`') {\n let cursor = index + 1\n while (cursor < source.length && source[cursor] !== character) {\n cursor += source[cursor] === '\\\\' ? 2 : 1\n }\n out += source.slice(index, Math.min(cursor + 1, source.length))\n index = cursor + 1\n continue\n }\n out += character\n index += 1\n }\n return out\n}\n\n/**\n * The fields of a document's universe that no partition claims.\n *\n * Unclassified means CLIENT-WRITABLE in production — the rules deny only what\n * is named — so every coverage guard reports this list and fails on any entry.\n * Shared because the documents are not all core's: the marketplace listing's\n * guard lives in the marketplace plugin beside the list it partitions, and a\n * second copy of the partition test would be a second thing to keep true.\n */\nexport function unclassifiedFields(\n universe: Iterable<string>,\n ...partitions: Array<ReadonlySet<string>>\n): string[] {\n return [...universe].filter(\n (field) => !partitions.some((partition) => partition.has(field)),\n )\n}\n\n/**\n * The fields both denied by the rules and declared client-writable (or\n * unpersisted). The two partitions disagreeing means one of them is a lie, so\n * the guards expect this to be empty.\n */\nexport function deniedAndDeclaredWritable(\n denied: ReadonlySet<string>,\n ...declared: Array<Readonly<Record<string, string>>>\n): string[] {\n return declared\n .flatMap((record) => Object.keys(record))\n .filter((field) => denied.has(field))\n}\n"],"names":["stripComments","source","out","index","length","pair","slice","end","indexOf","stripQuotedStrings","replace","normalizePathVariables","rules","topLevelBody","header","at","Error","depth","character","rawBlockBody","parseHostSubcollectionRules","rulesSource","host","catchAllHeader","occurrences","split","catchAll","listFor","operation","statement","find","entry","RegExp","test","list","match","matchAll","map","excluded","create","update","delete","dedicated","Set","grantsWrite","name","some","serverOnly","filter","includes","splitTopLevelOr","expression","parts","current","push","part","trim","Boolean","hasAnyKeys","branch","allHasAnyKeys","flatMap","declaredFields","body","recursiveMatchesReaching","topLevelMatches","collection","path","segments","last","parent","startsWith","parseUpdateRule","matchHeader","clientPredicate","root","block","statements","updates","branches","found","denied","seedFields","anchor","search","text","open","seedFieldsOfCollection","chained","bindings","binding","fields","isMethodCallAt","rest","readFieldsOf","sources","raw","stripTypeScriptComments","hit","add","sort","cursor","Math","min","unclassifiedFields","universe","partitions","field","partition","has","deniedAndDeclaredWritable","declared","record","Object","keys"],"mappings":"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"}
|
|
@@ -184,12 +184,14 @@ const FIRST_PARTY_IDS = new Set(FIRST_PARTY_PLUGINS.map((plugin)=>plugin.id));
|
|
|
184
184
|
* marketplace plugins (AGL-420) ride the same field.
|
|
185
185
|
*/ export function resolveEnabledPlugins(org) {
|
|
186
186
|
const configured = org == null ? void 0 : org.enabledPlugins;
|
|
187
|
-
|
|
187
|
+
// The default already holds every catalog id, the always-on ones included,
|
|
188
|
+
// in catalog order; only a stored list needs them unioned back in.
|
|
189
|
+
if (!Array.isArray(configured)) return [
|
|
188
190
|
...DEFAULT_ENABLED_PLUGINS
|
|
189
191
|
];
|
|
190
192
|
return Array.from(new Set([
|
|
191
193
|
...ALWAYS_ON_FOR_WORKSPACE,
|
|
192
|
-
...
|
|
194
|
+
...canonicalPluginIds(configured)
|
|
193
195
|
]));
|
|
194
196
|
}
|
|
195
197
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/enabled-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-org plugin enablement (AGL-416): `org.enabledPlugins` is the\n * switchboard that decides which plugins LOAD for a workspace — the loader\n * (AGL-417) dynamically imports only these. It composes with (not replaces)\n * the existing gates: a surface renders when its plugin is enabled AND its\n * `featureFlag` entitlement resolves; marketplace/marketplace listings keep\n * their per-host/org `installs` docs on top.\n *\n * The catalog's rows are declared by the plugins themselves, in\n * `plugins.config.json`, beside the package names the loader manifests are\n * generated from; this module holds the row's shape and the resolvers.\n */\n\nimport {\n FIRST_PARTY_PLUGINS,\n PLUGIN_EDIT_BAR_LINKS,\n PUBLISHED_SITE_IMPACT,\n} from './first-party-plugins.generated'\n\n/**\n * The first-party elements that run a site function (AGL-3393), compiled from\n * each plugin's `contributes.site.functionBindings`. Compose merges it with the\n * installed marketplace plugins' declarations via `mergeFunctionBindings`.\n */\nexport { FIRST_PARTY_FUNCTION_BINDINGS } from './first-party-plugins.generated'\n\n/**\n * One quick link on a live site's admin edit bar, declared by the plugin whose\n * console page it opens (AGL-3080).\n *\n * The bar is drawn by the TENANT server, which never loads a plugin's console\n * code, so this is DATA compiled from `plugins.config.json` rather than a\n * runtime registration — a registry that process had not filled would drop the\n * link silently instead of failing, which is the AGL-3025 shape. It is also\n * why the label is written here rather than taken from the plugin's nav item:\n * the nav item lives in the console bundle, and \"Orders\" is a section of\n * Commerce rather than the plugin's own name.\n *\n * The link is drawn only where the SITE runs the plugin. The bar asks the same\n * resolved plugin set the rest of the edit context is built from, so a site\n * with Commerce switched off is not offered its Orders page.\n */\nexport interface PluginEditBarLink {\n /** The plugin that owns the page — also the id the site's set is checked against. */\n pluginId: string\n /** Where it sits in the bar; every declared order is distinct. */\n order: number\n /** What the bar says. A SECTION's name where that is the honest one. */\n label: string\n /**\n * Console path beneath the site, beginning with `/` and carrying no query —\n * `/inbox`, `/products/orders`. Joined onto `/{orgSlug}/hosts/{host}`.\n */\n path: string\n}\n\nexport interface FirstPartyPlugin {\n /**\n * Stable plugin id — persisted in `org.enabledPlugins`,\n * `host.disabledPlugins` and `host.enabledPlugins`, and the document id\n * under every `pluginSettings` collection.\n *\n * Renaming one is therefore a data change, not a code change, and it is\n * done in two halves that must both ship: the old id is read through\n * {@link canonicalPluginId} so every stored list still resolves, and a\n * backfill rewrites the stored documents so the alias can be retired once\n * it reads nothing.\n */\n id: string\n /** Console-facing display name. */\n label: string\n /**\n * On for every workspace AND every site, with no switch anywhere: the base\n * component library the canvas cannot render without.\n */\n alwaysOn?: boolean\n /**\n * On for every WORKSPACE, and switchable for one SITE (AGL-3028, AGL-3029).\n *\n * The workspace half is what `alwaysOn` gives: the id is unioned into the\n * org's resolved set whatever `org.enabledPlugins` stores, so a list saved\n * before the plugin existed still runs it, and no workspace switch is\n * offered. The site half is ordinary: the id is subtracted by a site's\n * `disabledPlugins` deny-list like any other, so an absent host field means\n * ON and nothing needs migrating.\n *\n * It exists for capabilities whose workspace-level half must never stop.\n * AI carries the add-on, credits, allotments and overage billing, none of\n * which belongs to a site; Forms carries the catalog and the submissions\n * already stored. A workspace switch would take those down with the\n * site-facing half, so the only switch is the site's.\n */\n alwaysOnForWorkspace?: boolean\n /**\n * What switching this plugin off for ONE site stops, and what it leaves\n * running (AGL-3028, AGL-3029) — the copy the site's Admin › Plugins page\n * states beside the switch, so the decision is made knowing both halves.\n *\n * `confirm` makes the site switch ask before it applies, naming what it is\n * about to break on published pages.\n */\n siteOff?: {\n /** What stops on the site, stated as the consequence. */\n stops: string\n /** What keeps running, because it carries no site. */\n keeps: string\n /** Ask before applying. */\n confirm?: boolean\n /**\n * How the confirmation names the site's published pages the switch\n * reaches (AGL-3029): the sentence above the list, and the one that\n * stands in for an empty list when the scan read everything.\n */\n pages?: { heading: string; none: string }\n }\n /** One-line description for the org-settings toggle list. */\n description?: string\n /**\n * Release flag gating this plugin platform-wide (AGL-422). A flagged-off\n * plugin is subtracted from every workspace's effective set — console\n * loader, published sites, and API dispatch — unless the subject is\n * staff. Always-on plugins carry no flag.\n */\n releaseFlag?: string\n /**\n * OFF for a site until that site turns it on (AGL-2486) — the inversion of\n * this switchboard's default, and the only field here that changes what an\n * absent host doc means.\n *\n * The per-host field is a DENY-list: a site records what it switches off,\n * which makes \"absent means on\" the default for all twelve other bundles.\n * That is right for a capability whose worst case is an unused nav tab, and\n * wrong for one whose worst case is a PAGE — `/signin`, `/signup`,\n * `/recover` were served by every published site on the platform,\n * including marketing sites whose real sign-in is somewhere else entirely.\n * A sign-in-shaped page on a brand's own domain that is not that brand's\n * sign-in is a credential-confusion hazard, so this one defaults closed.\n *\n * A site un-defaults it by listing the id in `host.enabledPlugins`. That\n * list is scoped to default-off ids and cannot widen anything else, so the\n * AGL-1014 invariant survives intact: a site still can never reach past\n * what its org enables.\n */\n defaultOffPerSite?: boolean\n /**\n * Plugin ids this one cannot function without (AGL-2486) — DECLARED, never\n * inferred.\n *\n * The switchboard has always let a workspace turn off a plugin another one\n * is built on, and said nothing. `accounts` is the case that proves it: the\n * Members blocks and every `membership/*` API handler ship inside the\n * COMMERCE bundle, so switching Commerce off leaves a site advertising\n * `/signin` while nothing can answer the login POST.\n *\n * Declared rather than derived on purpose. The couplings that matter are\n * exactly the ones no static read can see — which bundle happens to\n * register whose components — so a graph inferred from imports or from the\n * registry would miss the real edges while looking authoritative. An\n * incomplete warning that presents itself as complete is worse than none,\n * so this list is the contract, and {@link resolveDisableCascade} is honest\n * about covering only what is on it.\n */\n requires?: readonly string[]\n}\n\n/**\n * What a DISABLE does to a site that is already published (AGL-2486).\n *\n * The cascade dialog has to state a consequence, and there are two very\n * different ones. \"These blocks will no longer be offered\" is a different\n * decision from \"parts of your live pages go blank\", and one generic sentence\n * for both would be a lie in one direction or the other.\n *\n * - `elements` — the plugin registers site components. The tenant loads only\n * the site's enabled bundles, so elements ALREADY PLACED on published pages\n * stop rendering. Pre-existing AGL-1014 behaviour.\n * - `routes` — the plugin registers no components, but a published site\n * stops serving something: `accounts` gates `/signin`, `/signup`,\n * `/recover`; `redirects` stops applying its rules; `workflows` stops\n * answering its hooks.\n * - `console-only` — nothing a visitor can reach changes; the plugin leaves\n * navigation and the editor.\n */\nexport type PublishedSiteImpact = 'elements' | 'routes' | 'console-only'\n\n/**\n * Every catalog id classified, from the `publishedSiteImpact` each plugin\n * declares on its own row. The generator refuses a row whose verdict\n * disagrees with its `register.site`, so a site-registering bundle cannot be\n * added without declaring its consequence.\n */\nexport { PUBLISHED_SITE_IMPACT }\n\n/**\n * The user-accounts capability (AGL-2486): visitor sign-in, sign-up and\n * password recovery on a published site.\n *\n * It carries no loader manifest entry, and that is deliberate rather than an\n * oversight. The member blocks and the `membership/*` API handlers already\n * ship inside the commerce bundle, so there is no separate package to load;\n * what this id contributes is the SWITCH — the thing the tenant route gate,\n * the console card and the sitemap all ask. Several catalog ids already have\n * no tenant bundle (`contacts`, `data`, `logic`), so a manifest-less entry is\n * the existing shape, not a new one.\n */\nexport const ACCOUNTS_PLUGIN_ID = 'accounts'\n\n/**\n * The Forms capability's id, named in core (AGL-3029).\n *\n * A form's server half is core — `/api/forms/submit`, the publish-time\n * contract check, the published page's render — and core may not import a\n * plugin, so each asks the site's plugin set about this id rather than asking\n * the Forms plugin anything.\n */\nexport const FORMS_PLUGIN_ID = 'forms'\n\n/**\n * The switchboard catalog. The core holds no row of it: each plugin declares\n * its own in `plugins.config.json` (`catalog`, and one per `capabilities`\n * entry), and the generator compiles them here in their declared order, so\n * adding a plugin edits no core file.\n *\n * Compiled in rather than registered at runtime, because every resolver below\n * is synchronous and runs in each bundle of both apps; a registry that one of\n * those bundles had not filled would read as every plugin switched OFF.\n */\nexport { FIRST_PARTY_PLUGINS }\n\n/**\n * The admin edit bar's quick links that THIS SITE runs, in the order the bar\n * draws them (AGL-3080).\n *\n * `enabledPluginIds` is the site's resolved plugin set — the same one the rest\n * of the edit context is built from, already narrowed by the org's switchboard,\n * the site's own deny-list and the release flags. A link whose plugin is not\n * in it is not offered, which is what stops the bar linking a site with\n * Commerce switched off to its Orders page.\n *\n * Compiled in rather than registered, for the reason the catalog above is:\n * the tenant server draws this bar and never loads a plugin's console code,\n * so a registry it had not filled would drop every link with nothing red.\n */\nexport function pluginEditBarLinks(\n enabledPluginIds: readonly string[] | undefined,\n): readonly PluginEditBarLink[] {\n const enabled = new Set(enabledPluginIds ?? [])\n return PLUGIN_EDIT_BAR_LINKS.filter((link) => enabled.has(link.pluginId))\n}\n\n/** Ids loaded for orgs that have never touched the switchboard. */\nexport const DEFAULT_ENABLED_PLUGINS: readonly string[] =\n FIRST_PARTY_PLUGINS.map((plugin) => plugin.id)\n\n/**\n * The id a stored plugin id means today — the seam a rename re-arms.\n *\n * A first-party id is written into every org's `enabledPlugins` and every\n * site's `disabledPlugins` the moment somebody touches the switchboard, so a\n * rename in this file alone would silently turn the plugin OFF for every\n * workspace that had listed it under the old name — the stored id would match\n * nothing in the catalog and fall through as a marketplace listing id. Every\n * reader of a stored list runs it through here first, so a document written\n * before a rename keeps meaning what it meant while the backfill runs.\n *\n * Nothing is aliased today. `contacts` read as `crm` from the CRM's rename\n * (AGL-2595) until its backfill reported zero documents carrying the old id,\n * and the alias was retired (AGL-2614) — an alias that outlives its backfill\n * is a second name for the plugin that nothing writes and every reader must\n * keep honoring. The next rename adds its pair here, ships a backfill over\n * `org.enabledPlugins`, `host.disabledPlugins`, `host.enabledPlugins` and the\n * `pluginSettings` document ids, and removes the pair the same way.\n */\nexport function canonicalPluginId(pluginId: string): string {\n return pluginId\n}\n\n/** A stored list, read through the alias seam and de-duplicated. */\nfunction canonicalPluginIds(pluginIds: readonly unknown[]): string[] {\n return Array.from(\n new Set(pluginIds.map((id) => canonicalPluginId(String(id)))),\n )\n}\n\n/** On everywhere, a site's deny-list included: the base component library. */\nconst ALWAYS_ON: readonly string[] = FIRST_PARTY_PLUGINS.filter(\n (plugin) => plugin.alwaysOn,\n).map((plugin) => plugin.id)\n\n/**\n * Unioned into every workspace's set whatever the org stored: {@link ALWAYS_ON}\n * plus the ids that are on for every workspace and switchable per site.\n */\nconst ALWAYS_ON_FOR_WORKSPACE: readonly string[] = FIRST_PARTY_PLUGINS.filter(\n (plugin) => plugin.alwaysOn || plugin.alwaysOnForWorkspace,\n).map((plugin) => plugin.id)\n\n/**\n * Whether the WORKSPACE switch for this plugin is locked on (AGL-3028): the\n * base library, and every plugin that is on for every workspace and\n * switchable only per site. The org switchboard renders these on and inert.\n */\nexport function isLockedOnForWorkspace(pluginId: string): boolean {\n return ALWAYS_ON_FOR_WORKSPACE.includes(pluginId)\n}\n\n/**\n * Whether the SITE switch for this plugin is locked on: the base library\n * alone. Every other plugin a workspace runs can be switched off for one site.\n */\nexport function isLockedOnForSite(pluginId: string): boolean {\n return ALWAYS_ON.includes(pluginId)\n}\n\nconst FIRST_PARTY_IDS: ReadonlySet<string> = new Set(\n FIRST_PARTY_PLUGINS.map((plugin) => plugin.id),\n)\n\n/**\n * Ids a SITE does not get until it asks (AGL-2486). See\n * {@link FirstPartyPlugin.defaultOffPerSite} for why this inversion exists.\n */\nexport const DEFAULT_OFF_PER_SITE_PLUGIN_IDS: ReadonlySet<string> = new Set(\n FIRST_PARTY_PLUGINS.filter((plugin) => plugin.defaultOffPerSite).map(\n (plugin) => plugin.id,\n ),\n)\n\n/** Whether a plugin id is off for a site until that site opts in. */\nexport function isDefaultOffPerSite(pluginId: string): boolean {\n return DEFAULT_OFF_PER_SITE_PLUGIN_IDS.has(pluginId)\n}\n\n/**\n * Applies a host's `enabledPlugins` OPT-IN list (AGL-2486): subtracts every\n * default-off id the host has not explicitly asked for.\n *\n * Narrow-only, like its deny-list sibling. The list can only ever REMOVE the\n * default-off subtraction for an id the org already enables — listing an\n * ordinary id buys nothing, and listing one the org switched off buys\n * nothing either, because this runs against the org's resolved set.\n */\nexport function applyDefaultOffOptIn(\n pluginIds: readonly string[],\n optedIn?: readonly string[] | null,\n): string[] {\n if (!DEFAULT_OFF_PER_SITE_PLUGIN_IDS.size) return [...pluginIds]\n const asked = new Set(\n Array.isArray(optedIn) ? canonicalPluginIds(optedIn) : [],\n )\n return pluginIds.filter(\n (id) => !DEFAULT_OFF_PER_SITE_PLUGIN_IDS.has(id) || asked.has(id),\n )\n}\n\n/**\n * Whether an `enabledPlugins` id is a first-party BUNDLE (vs a marketplace\n * listing id) — AGL-777. `enabledPlugins` is a flat mix of the two: bundle\n * ids are the short, stable names in {@link FIRST_PARTY_PLUGINS}; marketplace\n * installs ride the same field under their Firestore listing doc id. This is\n * the single classifier both writers and readers use so the two kinds never\n * get confused — e.g. an install sync must never add/remove a bundle id.\n */\nexport function isFirstPartyPlugin(pluginId: string): boolean {\n return FIRST_PARTY_IDS.has(pluginId)\n}\n\n/**\n * Splits a mixed `enabledPlugins` array into first-party bundle ids and\n * marketplace listing ids (AGL-777). The field stays a single flat list;\n * this just names the two kinds for callers that need to treat them apart.\n */\nexport function classifyEnabledPlugins(pluginIds: readonly string[]): {\n bundles: string[]\n listings: string[]\n} {\n const bundles: string[] = []\n const listings: string[] = []\n for (const id of pluginIds) {\n if (isFirstPartyPlugin(id)) bundles.push(id)\n else listings.push(id)\n }\n return { bundles, listings }\n}\n\n/**\n * The org's effective enabled-plugin set. Absent field → every first-party\n * plugin (existing orgs keep working untouched); always-on ids — and the ids\n * that are on for every workspace and switchable only per site — are unioned\n * in, so no stored list can switch them off for a workspace, including one\n * saved before the plugin existed. Unknown ids are kept — realm-trusted\n * marketplace plugins (AGL-420) ride the same field.\n */\nexport function resolveEnabledPlugins(\n org?: { enabledPlugins?: string[] } | null,\n): string[] {\n const configured = org?.enabledPlugins\n const base = Array.isArray(configured)\n ? canonicalPluginIds(configured)\n : [...DEFAULT_ENABLED_PLUGINS]\n return Array.from(new Set([...ALWAYS_ON_FOR_WORKSPACE, ...base]))\n}\n\n/**\n * Subtracts a host's per-site deny-list from an enabled set (AGL-1014).\n * Always-on ids survive — the base component library cannot be switched\n * off per site any more than per org. A plugin that is on for every\n * workspace (`alwaysOnForWorkspace`) does NOT survive: its site switch is\n * the one switch it has. Order of the surviving ids is kept.\n */\nexport function subtractDisabledPlugins(\n pluginIds: readonly string[],\n disabledPlugins?: readonly string[] | null,\n): string[] {\n if (!Array.isArray(disabledPlugins) || !disabledPlugins.length)\n return [...pluginIds]\n const disabled = new Set(canonicalPluginIds(disabledPlugins))\n return pluginIds.filter(\n (id) => ALWAYS_ON.includes(id) || !disabled.has(id),\n )\n}\n\n/**\n * A HOST's effective enabled-plugin set (AGL-1014): the org's resolved set\n * minus the host's `disabledPlugins` deny-list. This is the single source\n * of truth for per-site enablement — console navigation, the editor,\n * published sites, and API dispatch must all read it, or a \"disabled\"\n * plugin is merely hidden, not off.\n *\n * Semantics are narrow-only by construction: a host stores what it turns\n * OFF, so it can never widen beyond what the org enables, and an absent\n * field means every org-enabled plugin runs (newly installed plugins\n * default to enabled per site until a host admin disables them).\n *\n * ONE class of id reads the other way (AGL-2486): a `defaultOffPerSite`\n * plugin is subtracted unless the host names it in `enabledPlugins`. That\n * list un-defaults; it does not grant. Both fields are still bounded by the\n * org's set, and an explicit deny still beats an explicit opt-in — the two\n * are applied in that order below, so the safe reading wins whenever a\n * hand-edited or stale doc sets both.\n */\nexport function resolveHostEnabledPlugins(\n org?: { enabledPlugins?: string[] } | null,\n host?: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null,\n): string[] {\n return subtractDisabledPlugins(\n applyDefaultOffOptIn(resolveEnabledPlugins(org), host?.enabledPlugins),\n host?.disabledPlugins,\n )\n}\n\n/**\n * Whether ONE plugin runs on this site — the host-aware counterpart of\n * {@link isPluginEnabled}, and the form every route gate wants.\n */\nexport function isHostPluginEnabled(\n org: { enabledPlugins?: string[] } | null | undefined,\n host: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): boolean {\n return resolveHostEnabledPlugins(org, host).includes(pluginId)\n}\n\nexport function isPluginEnabled(\n org: { enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): boolean {\n return resolveEnabledPlugins(org).includes(pluginId)\n}\n\n/**\n * Where one plugin stands ON ONE SITE (AGL-1014, AGL-2486).\n *\n * Three surfaces need this answer and they must not each derive it: the site\n * plugin page's \"Where it runs\" card, that page's dependency list — where the\n * same question is asked about a NEIGHBOR, and where the answer is the whole\n * point, because a dependency the workspace enables and this site has switched\n * off is a broken plugin behind a healthy-looking workspace page — and the\n * per-site switchboard. A second derivation is a second chance to disagree\n * with `resolveHostEnabledPlugins`, which is what actually runs.\n *\n * - `always-on` — the base library; it cannot be switched off anywhere.\n * A plugin that is on for every workspace but\n * switchable per site is NOT this: it reads\n * `runs-here` or `off-for-site` like any other.\n * - `off-for-workspace` — the org has it off, so it runs on none of its sites\n * and this site cannot turn it on.\n * - `runs-here` — the effective host set contains it.\n * - `awaiting-opt-in` — a `defaultOffPerSite` plugin the org enables and\n * this site has not asked for. Off, but off by\n * DEFAULT rather than by a decision, which is a\n * different thing to tell an operator.\n * - `off-for-site` — the org enables it and this site switched it off.\n */\nexport type PluginSiteState =\n | 'always-on'\n | 'off-for-workspace'\n | 'runs-here'\n | 'awaiting-opt-in'\n | 'off-for-site'\n\nexport function resolvePluginSiteState(\n org: { enabledPlugins?: string[] } | null | undefined,\n host: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): PluginSiteState {\n if (ALWAYS_ON.includes(pluginId)) return 'always-on'\n if (!resolveEnabledPlugins(org).includes(pluginId)) return 'off-for-workspace'\n if (resolveHostEnabledPlugins(org, host).includes(pluginId)) return 'runs-here'\n // A default-off plugin this site has not named is off because nobody asked\n // for it; naming it in the deny-list is a decision, and an explicit deny\n // beats an opt-in, so the deny-list is checked first.\n const denied = Array.isArray(host?.disabledPlugins)\n ? host.disabledPlugins.map(String).includes(pluginId)\n : false\n return !denied && isDefaultOffPerSite(pluginId)\n ? 'awaiting-opt-in'\n : 'off-for-site'\n}\n\n/**\n * The declared dependency graph, both directions, built once (AGL-2486).\n *\n * `requires` on the catalog is the forward edge — what a plugin cannot run\n * without — and every consumer so far wanted the reverse one. Building both\n * here keeps the cascade, the console's dependency card and the server-side\n * refusal reading the SAME edges: a graph assembled a second time is a graph\n * that can disagree with itself about which plugins are coupled, and the\n * disagreement would show up as a warning shown on one surface and not on the\n * one that writes.\n *\n * `extraRequirements` extends the catalog and cannot shrink it, exactly as it\n * does for {@link resolveDisableCascade} — a third-party id can add edges of\n * its own but can never declare away a first-party one.\n */\nfunction dependencyEdges(\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): {\n /** Keyed by a required id; holds the ids that depend on it. */\n dependents: Map<string, string[]>\n /** Keyed by a dependent id; holds the ids it requires. */\n requires: Map<string, string[]>\n} {\n const dependents = new Map<string, string[]>()\n const requires = new Map<string, string[]>()\n const addEdge = (dependent: string, required: string) => {\n const reverse = dependents.get(required)\n if (reverse) reverse.push(dependent)\n else dependents.set(required, [dependent])\n const forward = requires.get(dependent)\n if (forward) forward.push(required)\n else requires.set(dependent, [required])\n }\n for (const plugin of FIRST_PARTY_PLUGINS)\n for (const required of plugin.requires ?? []) addEdge(plugin.id, required)\n for (const [dependent, required] of Object.entries(extraRequirements ?? {}))\n for (const one of required ?? []) addEdge(dependent, String(one))\n return { dependents, requires }\n}\n\n/** What `pluginId` declares it cannot run without — DIRECT edges only. */\nexport function pluginRequirements(\n pluginId: string,\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n return [...(dependencyEdges(extraRequirements).requires.get(pluginId) ?? [])]\n}\n\n/** What declares it cannot run without `pluginId` — DIRECT edges only. */\nexport function pluginDependents(\n pluginId: string,\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n return [...(dependencyEdges(extraRequirements).dependents.get(pluginId) ?? [])]\n}\n\n/**\n * Which of `enabledIds` would be left running with a requirement switched off\n * (AGL-2486) — the same question the cascade dialog asks, phrased so a WRITER\n * can refuse instead of a reader warning.\n *\n * The dialog is a courtesy: it stands in front of the console's own switches\n * and nothing else. A direct API call, a stale tab that posts an older set, or\n * a second console surface that forgot to ask can still store a set where\n * User Accounts is on and the Commerce bundle that answers its sign-in POST is\n * not. This is what a write path calls to reject that set outright, so the\n * boundary does not live in a dialog anyone can skip.\n *\n * Only DECLARED requirements are checked, with the same limit\n * {@link PLUGIN_CASCADE_IS_DECLARED_ONLY} states: an undeclared coupling is\n * invisible here too. A requirement naming an id outside the catalog is\n * ignored rather than treated as unmet — a stale edge must not lock a\n * workspace out of its own switchboard.\n */\nexport function strandedDependents(\n enabledIds: readonly string[],\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): Array<{ pluginId: string; missing: string[] }> {\n const enabled = new Set(enabledIds.map(String))\n const known = new Set<string>([\n ...FIRST_PARTY_PLUGINS.map((plugin) => plugin.id),\n ...enabled,\n ])\n const { requires } = dependencyEdges(extraRequirements)\n const stranded: Array<{ pluginId: string; missing: string[] }> = []\n for (const pluginId of enabled) {\n const missing = (requires.get(pluginId) ?? []).filter(\n (required) => known.has(required) && !enabled.has(required),\n )\n if (missing.length) stranded.push({ pluginId, missing })\n }\n return stranded\n}\n\n/**\n * Everything that must ALSO be switched off when `pluginId` is (AGL-2486) —\n * the transitive closure over reverse `requires` edges, restricted to what is\n * currently on.\n *\n * Pure and surface-agnostic: the org switchboard and the per-site card both\n * call it with their own \"currently enabled\" set, so the same graph answers\n * both, and the org's wider blast radius comes from the set it passes, not\n * from a second implementation.\n *\n * `extraRequirements` is how a marketplace listing joins the graph. Third-party\n * ids ride the same `enabledPlugins` field, so a listing whose manifest\n * declares `requires` is cascaded exactly like a bundle. It EXTENDS the\n * catalog graph and cannot shrink it — a listing cannot declare away a\n * first-party edge.\n *\n * ⚠️ The result is only ever as complete as what has been DECLARED. A\n * marketplace plugin that uses first-party components without saying so in its\n * manifest will not appear here, and callers must not present the list as\n * exhaustive. See `PLUGIN_CASCADE_IS_DECLARED_ONLY`.\n */\nexport function resolveDisableCascade(\n pluginId: string,\n enabledIds: readonly string[],\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n const enabled = new Set(enabledIds.map(String))\n const dependents = dependencyEdges(extraRequirements).dependents\n\n // Breadth-first over reverse edges. `seen` is seeded with the origin so a\n // cycle back to it terminates and the plugin being disabled is never listed\n // among its own dependents.\n const seen = new Set<string>([pluginId])\n const cascade: string[] = []\n const queue: string[] = [pluginId]\n while (queue.length) {\n const current = queue.shift() as string\n for (const dependent of dependents.get(current) ?? []) {\n if (seen.has(dependent)) continue\n seen.add(dependent)\n // Walk THROUGH an already-off dependent — something may depend on it in\n // turn — but do not claim it is being turned off.\n if (enabled.has(dependent)) cascade.push(dependent)\n queue.push(dependent)\n }\n }\n return cascade\n}\n\n/**\n * Why the cascade list must never be presented as exhaustive (AGL-2486).\n *\n * Exported as copy rather than left to each caller to paraphrase: the whole\n * value of the warning rests on it being honest about its own limits, and two\n * surfaces wording that differently is how one of them ends up overclaiming.\n *\n * It says built-in ONLY, and that is the current truth rather than a hedge.\n * `PluginManifest` carries no `requires` field, so a publisher cannot declare\n * a dependency even if they want to, and `extraRequirements` above — the seam\n * that would carry them — is supplied by nothing outside tests. An earlier\n * draft said \"a plugin that uses this one WITHOUT declaring it cannot be\n * detected\", which quietly implied declaring was possible and would have made\n * this the exact overclaiming warning it exists to avoid. Adding the manifest\n * field is a separate change: it needs a publish form, validation and a line\n * on the install screen, or it is a field written by nobody and read by\n * nothing. Update this sentence in the same change, not before.\n */\nexport const PLUGIN_CASCADE_IS_DECLARED_ONLY =\n 'This covers built-in plugins only. A marketplace plugin has no way to ' +\n 'declare that it depends on another one yet, so none are listed here — ' +\n 'check any third-party plugins you rely on before continuing.'\n\n/** Reverse lookup: which first-party plugin a release flag gates, if any. */\nexport function pluginForReleaseFlag(\n flagKey: string,\n): FirstPartyPlugin | undefined {\n return FIRST_PARTY_PLUGINS.find((plugin) => plugin.releaseFlag === flagKey)\n}\n\n/**\n * Subtracts release-flagged-off plugins from an effective set (AGL-422).\n * Pure — the caller supplies the verdict source (client: the activated\n * Remote Config hook state; server: the cached admin-SDK template read),\n * so the same policy runs identically on every surface:\n *\n * - unknown ids (marketplace/realm installs) and always-on ids pass;\n * - a first-party id with a `releaseFlag` passes only when the flag is on\n * for the subject, or `staffBypass` is set (staff preview keeps working\n * while a feature is dark).\n */\nexport function filterPluginsByReleaseFlags(\n pluginIds: readonly string[],\n isFlagOn: (flagKey: string) => boolean,\n options?: { staffBypass?: boolean },\n): string[] {\n if (options?.staffBypass) return [...pluginIds]\n const catalog = new Map(\n FIRST_PARTY_PLUGINS.map((plugin) => [plugin.id, plugin]),\n )\n return pluginIds.filter((pluginId) => {\n const plugin = catalog.get(pluginId)\n if (!plugin?.releaseFlag || plugin.alwaysOn) return true\n return isFlagOn(plugin.releaseFlag)\n })\n}\n"],"names":["FIRST_PARTY_PLUGINS","PLUGIN_EDIT_BAR_LINKS","PUBLISHED_SITE_IMPACT","FIRST_PARTY_FUNCTION_BINDINGS","ACCOUNTS_PLUGIN_ID","FORMS_PLUGIN_ID","pluginEditBarLinks","enabledPluginIds","enabled","Set","filter","link","has","pluginId","DEFAULT_ENABLED_PLUGINS","map","plugin","id","canonicalPluginId","canonicalPluginIds","pluginIds","Array","from","String","ALWAYS_ON","alwaysOn","ALWAYS_ON_FOR_WORKSPACE","alwaysOnForWorkspace","isLockedOnForWorkspace","includes","isLockedOnForSite","FIRST_PARTY_IDS","DEFAULT_OFF_PER_SITE_PLUGIN_IDS","defaultOffPerSite","isDefaultOffPerSite","applyDefaultOffOptIn","optedIn","size","asked","isArray","isFirstPartyPlugin","classifyEnabledPlugins","bundles","listings","push","resolveEnabledPlugins","org","configured","enabledPlugins","base","subtractDisabledPlugins","disabledPlugins","length","disabled","resolveHostEnabledPlugins","host","isHostPluginEnabled","isPluginEnabled","resolvePluginSiteState","denied","dependencyEdges","extraRequirements","dependents","Map","requires","addEdge","dependent","required","reverse","get","set","forward","Object","entries","one","pluginRequirements","pluginDependents","strandedDependents","enabledIds","known","stranded","missing","resolveDisableCascade","seen","cascade","queue","current","shift","add","PLUGIN_CASCADE_IS_DECLARED_ONLY","pluginForReleaseFlag","flagKey","find","releaseFlag","filterPluginsByReleaseFlags","isFlagOn","options","staffBypass","catalog"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/plugin-manager/enabled-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Per-org plugin enablement (AGL-416): `org.enabledPlugins` is the\n * switchboard that decides which plugins LOAD for a workspace — the loader\n * (AGL-417) dynamically imports only these. It composes with (not replaces)\n * the existing gates: a surface renders when its plugin is enabled AND its\n * `featureFlag` entitlement resolves; marketplace/marketplace listings keep\n * their per-host/org `installs` docs on top.\n *\n * The catalog's rows are declared by the plugins themselves, in\n * `plugins.config.json`, beside the package names the loader manifests are\n * generated from; this module holds the row's shape and the resolvers.\n */\n\nimport {\n FIRST_PARTY_PLUGINS,\n PLUGIN_EDIT_BAR_LINKS,\n PUBLISHED_SITE_IMPACT,\n} from './first-party-plugins.generated'\n\n/**\n * The first-party elements that run a site function (AGL-3393), compiled from\n * each plugin's `contributes.site.functionBindings`. Compose merges it with the\n * installed marketplace plugins' declarations via `mergeFunctionBindings`.\n */\nexport { FIRST_PARTY_FUNCTION_BINDINGS } from './first-party-plugins.generated'\n\n/**\n * One quick link on a live site's admin edit bar, declared by the plugin whose\n * console page it opens (AGL-3080).\n *\n * The bar is drawn by the TENANT server, which never loads a plugin's console\n * code, so this is DATA compiled from `plugins.config.json` rather than a\n * runtime registration — a registry that process had not filled would drop the\n * link silently instead of failing, which is the AGL-3025 shape. It is also\n * why the label is written here rather than taken from the plugin's nav item:\n * the nav item lives in the console bundle, and \"Orders\" is a section of\n * Commerce rather than the plugin's own name.\n *\n * The link is drawn only where the SITE runs the plugin. The bar asks the same\n * resolved plugin set the rest of the edit context is built from, so a site\n * with Commerce switched off is not offered its Orders page.\n */\nexport interface PluginEditBarLink {\n /** The plugin that owns the page — also the id the site's set is checked against. */\n pluginId: string\n /** Where it sits in the bar; every declared order is distinct. */\n order: number\n /** What the bar says. A SECTION's name where that is the honest one. */\n label: string\n /**\n * Console path beneath the site, beginning with `/` and carrying no query —\n * `/inbox`, `/products/orders`. Joined onto `/{orgSlug}/hosts/{host}`.\n */\n path: string\n}\n\nexport interface FirstPartyPlugin {\n /**\n * Stable plugin id — persisted in `org.enabledPlugins`,\n * `host.disabledPlugins` and `host.enabledPlugins`, and the document id\n * under every `pluginSettings` collection.\n *\n * Renaming one is therefore a data change, not a code change, and it is\n * done in two halves that must both ship: the old id is read through\n * {@link canonicalPluginId} so every stored list still resolves, and a\n * backfill rewrites the stored documents so the alias can be retired once\n * it reads nothing.\n */\n id: string\n /** Console-facing display name. */\n label: string\n /**\n * On for every workspace AND every site, with no switch anywhere: the base\n * component library the canvas cannot render without.\n */\n alwaysOn?: boolean\n /**\n * On for every WORKSPACE, and switchable for one SITE (AGL-3028, AGL-3029).\n *\n * The workspace half is what `alwaysOn` gives: the id is unioned into the\n * org's resolved set whatever `org.enabledPlugins` stores, so a list saved\n * before the plugin existed still runs it, and no workspace switch is\n * offered. The site half is ordinary: the id is subtracted by a site's\n * `disabledPlugins` deny-list like any other, so an absent host field means\n * ON and nothing needs migrating.\n *\n * It exists for capabilities whose workspace-level half must never stop.\n * AI carries the add-on, credits, allotments and overage billing, none of\n * which belongs to a site; Forms carries the catalog and the submissions\n * already stored. A workspace switch would take those down with the\n * site-facing half, so the only switch is the site's.\n */\n alwaysOnForWorkspace?: boolean\n /**\n * What switching this plugin off for ONE site stops, and what it leaves\n * running (AGL-3028, AGL-3029) — the copy the site's Admin › Plugins page\n * states beside the switch, so the decision is made knowing both halves.\n *\n * `confirm` makes the site switch ask before it applies, naming what it is\n * about to break on published pages.\n */\n siteOff?: {\n /** What stops on the site, stated as the consequence. */\n stops: string\n /** What keeps running, because it carries no site. */\n keeps: string\n /** Ask before applying. */\n confirm?: boolean\n /**\n * How the confirmation names the site's published pages the switch\n * reaches (AGL-3029): the sentence above the list, and the one that\n * stands in for an empty list when the scan read everything.\n */\n pages?: { heading: string; none: string }\n }\n /** One-line description for the org-settings toggle list. */\n description?: string\n /**\n * Release flag gating this plugin platform-wide (AGL-422). A flagged-off\n * plugin is subtracted from every workspace's effective set — console\n * loader, published sites, and API dispatch — unless the subject is\n * staff. Always-on plugins carry no flag.\n */\n releaseFlag?: string\n /**\n * OFF for a site until that site turns it on (AGL-2486) — the inversion of\n * this switchboard's default, and the only field here that changes what an\n * absent host doc means.\n *\n * The per-host field is a DENY-list: a site records what it switches off,\n * which makes \"absent means on\" the default for all twelve other bundles.\n * That is right for a capability whose worst case is an unused nav tab, and\n * wrong for one whose worst case is a PAGE — `/signin`, `/signup`,\n * `/recover` were served by every published site on the platform,\n * including marketing sites whose real sign-in is somewhere else entirely.\n * A sign-in-shaped page on a brand's own domain that is not that brand's\n * sign-in is a credential-confusion hazard, so this one defaults closed.\n *\n * A site un-defaults it by listing the id in `host.enabledPlugins`. That\n * list is scoped to default-off ids and cannot widen anything else, so the\n * AGL-1014 invariant survives intact: a site still can never reach past\n * what its org enables.\n */\n defaultOffPerSite?: boolean\n /**\n * Plugin ids this one cannot function without (AGL-2486) — DECLARED, never\n * inferred.\n *\n * The switchboard has always let a workspace turn off a plugin another one\n * is built on, and said nothing. `accounts` is the case that proves it: the\n * Members blocks and every `membership/*` API handler ship inside the\n * COMMERCE bundle, so switching Commerce off leaves a site advertising\n * `/signin` while nothing can answer the login POST.\n *\n * Declared rather than derived on purpose. The couplings that matter are\n * exactly the ones no static read can see — which bundle happens to\n * register whose components — so a graph inferred from imports or from the\n * registry would miss the real edges while looking authoritative. An\n * incomplete warning that presents itself as complete is worse than none,\n * so this list is the contract, and {@link resolveDisableCascade} is honest\n * about covering only what is on it.\n */\n requires?: readonly string[]\n}\n\n/**\n * What a DISABLE does to a site that is already published (AGL-2486).\n *\n * The cascade dialog has to state a consequence, and there are two very\n * different ones. \"These blocks will no longer be offered\" is a different\n * decision from \"parts of your live pages go blank\", and one generic sentence\n * for both would be a lie in one direction or the other.\n *\n * - `elements` — the plugin registers site components. The tenant loads only\n * the site's enabled bundles, so elements ALREADY PLACED on published pages\n * stop rendering. Pre-existing AGL-1014 behaviour.\n * - `routes` — the plugin registers no components, but a published site\n * stops serving something: `accounts` gates `/signin`, `/signup`,\n * `/recover`; `redirects` stops applying its rules; `workflows` stops\n * answering its hooks.\n * - `console-only` — nothing a visitor can reach changes; the plugin leaves\n * navigation and the editor.\n */\nexport type PublishedSiteImpact = 'elements' | 'routes' | 'console-only'\n\n/**\n * Every catalog id classified, from the `publishedSiteImpact` each plugin\n * declares on its own row. The generator refuses a row whose verdict\n * disagrees with its `register.site`, so a site-registering bundle cannot be\n * added without declaring its consequence.\n */\nexport { PUBLISHED_SITE_IMPACT }\n\n/**\n * The user-accounts capability (AGL-2486): visitor sign-in, sign-up and\n * password recovery on a published site.\n *\n * It carries no loader manifest entry, and that is deliberate rather than an\n * oversight. The member blocks and the `membership/*` API handlers already\n * ship inside the commerce bundle, so there is no separate package to load;\n * what this id contributes is the SWITCH — the thing the tenant route gate,\n * the console card and the sitemap all ask. Several catalog ids already have\n * no tenant bundle (`contacts`, `data`, `logic`), so a manifest-less entry is\n * the existing shape, not a new one.\n */\nexport const ACCOUNTS_PLUGIN_ID = 'accounts'\n\n/**\n * The Forms capability's id, named in core (AGL-3029).\n *\n * A form's server half is core — `/api/forms/submit`, the publish-time\n * contract check, the published page's render — and core may not import a\n * plugin, so each asks the site's plugin set about this id rather than asking\n * the Forms plugin anything.\n */\nexport const FORMS_PLUGIN_ID = 'forms'\n\n/**\n * The switchboard catalog. The core holds no row of it: each plugin declares\n * its own in `plugins.config.json` (`catalog`, and one per `capabilities`\n * entry), and the generator compiles them here in their declared order, so\n * adding a plugin edits no core file.\n *\n * Compiled in rather than registered at runtime, because every resolver below\n * is synchronous and runs in each bundle of both apps; a registry that one of\n * those bundles had not filled would read as every plugin switched OFF.\n */\nexport { FIRST_PARTY_PLUGINS }\n\n/**\n * The admin edit bar's quick links that THIS SITE runs, in the order the bar\n * draws them (AGL-3080).\n *\n * `enabledPluginIds` is the site's resolved plugin set — the same one the rest\n * of the edit context is built from, already narrowed by the org's switchboard,\n * the site's own deny-list and the release flags. A link whose plugin is not\n * in it is not offered, which is what stops the bar linking a site with\n * Commerce switched off to its Orders page.\n *\n * Compiled in rather than registered, for the reason the catalog above is:\n * the tenant server draws this bar and never loads a plugin's console code,\n * so a registry it had not filled would drop every link with nothing red.\n */\nexport function pluginEditBarLinks(\n enabledPluginIds: readonly string[] | undefined,\n): readonly PluginEditBarLink[] {\n const enabled = new Set(enabledPluginIds ?? [])\n return PLUGIN_EDIT_BAR_LINKS.filter((link) => enabled.has(link.pluginId))\n}\n\n/** Ids loaded for orgs that have never touched the switchboard. */\nexport const DEFAULT_ENABLED_PLUGINS: readonly string[] =\n FIRST_PARTY_PLUGINS.map((plugin) => plugin.id)\n\n/**\n * The id a stored plugin id means today — the seam a rename re-arms.\n *\n * A first-party id is written into every org's `enabledPlugins` and every\n * site's `disabledPlugins` the moment somebody touches the switchboard, so a\n * rename in this file alone would silently turn the plugin OFF for every\n * workspace that had listed it under the old name — the stored id would match\n * nothing in the catalog and fall through as a marketplace listing id. Every\n * reader of a stored list runs it through here first, so a document written\n * before a rename keeps meaning what it meant while the backfill runs.\n *\n * Nothing is aliased today. `contacts` read as `crm` from the CRM's rename\n * (AGL-2595) until its backfill reported zero documents carrying the old id,\n * and the alias was retired (AGL-2614) — an alias that outlives its backfill\n * is a second name for the plugin that nothing writes and every reader must\n * keep honoring. The next rename adds its pair here, ships a backfill over\n * `org.enabledPlugins`, `host.disabledPlugins`, `host.enabledPlugins` and the\n * `pluginSettings` document ids, and removes the pair the same way.\n */\nexport function canonicalPluginId(pluginId: string): string {\n return pluginId\n}\n\n/** A stored list, read through the alias seam and de-duplicated. */\nfunction canonicalPluginIds(pluginIds: readonly unknown[]): string[] {\n return Array.from(\n new Set(pluginIds.map((id) => canonicalPluginId(String(id)))),\n )\n}\n\n/** On everywhere, a site's deny-list included: the base component library. */\nconst ALWAYS_ON: readonly string[] = FIRST_PARTY_PLUGINS.filter(\n (plugin) => plugin.alwaysOn,\n).map((plugin) => plugin.id)\n\n/**\n * Unioned into every workspace's set whatever the org stored: {@link ALWAYS_ON}\n * plus the ids that are on for every workspace and switchable per site.\n */\nconst ALWAYS_ON_FOR_WORKSPACE: readonly string[] = FIRST_PARTY_PLUGINS.filter(\n (plugin) => plugin.alwaysOn || plugin.alwaysOnForWorkspace,\n).map((plugin) => plugin.id)\n\n/**\n * Whether the WORKSPACE switch for this plugin is locked on (AGL-3028): the\n * base library, and every plugin that is on for every workspace and\n * switchable only per site. The org switchboard renders these on and inert.\n */\nexport function isLockedOnForWorkspace(pluginId: string): boolean {\n return ALWAYS_ON_FOR_WORKSPACE.includes(pluginId)\n}\n\n/**\n * Whether the SITE switch for this plugin is locked on: the base library\n * alone. Every other plugin a workspace runs can be switched off for one site.\n */\nexport function isLockedOnForSite(pluginId: string): boolean {\n return ALWAYS_ON.includes(pluginId)\n}\n\nconst FIRST_PARTY_IDS: ReadonlySet<string> = new Set(\n FIRST_PARTY_PLUGINS.map((plugin) => plugin.id),\n)\n\n/**\n * Ids a SITE does not get until it asks (AGL-2486). See\n * {@link FirstPartyPlugin.defaultOffPerSite} for why this inversion exists.\n */\nexport const DEFAULT_OFF_PER_SITE_PLUGIN_IDS: ReadonlySet<string> = new Set(\n FIRST_PARTY_PLUGINS.filter((plugin) => plugin.defaultOffPerSite).map(\n (plugin) => plugin.id,\n ),\n)\n\n/** Whether a plugin id is off for a site until that site opts in. */\nexport function isDefaultOffPerSite(pluginId: string): boolean {\n return DEFAULT_OFF_PER_SITE_PLUGIN_IDS.has(pluginId)\n}\n\n/**\n * Applies a host's `enabledPlugins` OPT-IN list (AGL-2486): subtracts every\n * default-off id the host has not explicitly asked for.\n *\n * Narrow-only, like its deny-list sibling. The list can only ever REMOVE the\n * default-off subtraction for an id the org already enables — listing an\n * ordinary id buys nothing, and listing one the org switched off buys\n * nothing either, because this runs against the org's resolved set.\n */\nexport function applyDefaultOffOptIn(\n pluginIds: readonly string[],\n optedIn?: readonly string[] | null,\n): string[] {\n if (!DEFAULT_OFF_PER_SITE_PLUGIN_IDS.size) return [...pluginIds]\n const asked = new Set(\n Array.isArray(optedIn) ? canonicalPluginIds(optedIn) : [],\n )\n return pluginIds.filter(\n (id) => !DEFAULT_OFF_PER_SITE_PLUGIN_IDS.has(id) || asked.has(id),\n )\n}\n\n/**\n * Whether an `enabledPlugins` id is a first-party BUNDLE (vs a marketplace\n * listing id) — AGL-777. `enabledPlugins` is a flat mix of the two: bundle\n * ids are the short, stable names in {@link FIRST_PARTY_PLUGINS}; marketplace\n * installs ride the same field under their Firestore listing doc id. This is\n * the single classifier both writers and readers use so the two kinds never\n * get confused — e.g. an install sync must never add/remove a bundle id.\n */\nexport function isFirstPartyPlugin(pluginId: string): boolean {\n return FIRST_PARTY_IDS.has(pluginId)\n}\n\n/**\n * Splits a mixed `enabledPlugins` array into first-party bundle ids and\n * marketplace listing ids (AGL-777). The field stays a single flat list;\n * this just names the two kinds for callers that need to treat them apart.\n */\nexport function classifyEnabledPlugins(pluginIds: readonly string[]): {\n bundles: string[]\n listings: string[]\n} {\n const bundles: string[] = []\n const listings: string[] = []\n for (const id of pluginIds) {\n if (isFirstPartyPlugin(id)) bundles.push(id)\n else listings.push(id)\n }\n return { bundles, listings }\n}\n\n/**\n * The org's effective enabled-plugin set. Absent field → every first-party\n * plugin (existing orgs keep working untouched); always-on ids — and the ids\n * that are on for every workspace and switchable only per site — are unioned\n * in, so no stored list can switch them off for a workspace, including one\n * saved before the plugin existed. Unknown ids are kept — realm-trusted\n * marketplace plugins (AGL-420) ride the same field.\n */\nexport function resolveEnabledPlugins(\n org?: { enabledPlugins?: string[] } | null,\n): string[] {\n const configured = org?.enabledPlugins\n // The default already holds every catalog id, the always-on ones included,\n // in catalog order; only a stored list needs them unioned back in.\n if (!Array.isArray(configured)) return [...DEFAULT_ENABLED_PLUGINS]\n return Array.from(new Set([...ALWAYS_ON_FOR_WORKSPACE, ...canonicalPluginIds(configured)]))\n}\n\n/**\n * Subtracts a host's per-site deny-list from an enabled set (AGL-1014).\n * Always-on ids survive — the base component library cannot be switched\n * off per site any more than per org. A plugin that is on for every\n * workspace (`alwaysOnForWorkspace`) does NOT survive: its site switch is\n * the one switch it has. Order of the surviving ids is kept.\n */\nexport function subtractDisabledPlugins(\n pluginIds: readonly string[],\n disabledPlugins?: readonly string[] | null,\n): string[] {\n if (!Array.isArray(disabledPlugins) || !disabledPlugins.length)\n return [...pluginIds]\n const disabled = new Set(canonicalPluginIds(disabledPlugins))\n return pluginIds.filter(\n (id) => ALWAYS_ON.includes(id) || !disabled.has(id),\n )\n}\n\n/**\n * A HOST's effective enabled-plugin set (AGL-1014): the org's resolved set\n * minus the host's `disabledPlugins` deny-list. This is the single source\n * of truth for per-site enablement — console navigation, the editor,\n * published sites, and API dispatch must all read it, or a \"disabled\"\n * plugin is merely hidden, not off.\n *\n * Semantics are narrow-only by construction: a host stores what it turns\n * OFF, so it can never widen beyond what the org enables, and an absent\n * field means every org-enabled plugin runs (newly installed plugins\n * default to enabled per site until a host admin disables them).\n *\n * ONE class of id reads the other way (AGL-2486): a `defaultOffPerSite`\n * plugin is subtracted unless the host names it in `enabledPlugins`. That\n * list un-defaults; it does not grant. Both fields are still bounded by the\n * org's set, and an explicit deny still beats an explicit opt-in — the two\n * are applied in that order below, so the safe reading wins whenever a\n * hand-edited or stale doc sets both.\n */\nexport function resolveHostEnabledPlugins(\n org?: { enabledPlugins?: string[] } | null,\n host?: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null,\n): string[] {\n return subtractDisabledPlugins(\n applyDefaultOffOptIn(resolveEnabledPlugins(org), host?.enabledPlugins),\n host?.disabledPlugins,\n )\n}\n\n/**\n * Whether ONE plugin runs on this site — the host-aware counterpart of\n * {@link isPluginEnabled}, and the form every route gate wants.\n */\nexport function isHostPluginEnabled(\n org: { enabledPlugins?: string[] } | null | undefined,\n host: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): boolean {\n return resolveHostEnabledPlugins(org, host).includes(pluginId)\n}\n\nexport function isPluginEnabled(\n org: { enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): boolean {\n return resolveEnabledPlugins(org).includes(pluginId)\n}\n\n/**\n * Where one plugin stands ON ONE SITE (AGL-1014, AGL-2486).\n *\n * Three surfaces need this answer and they must not each derive it: the site\n * plugin page's \"Where it runs\" card, that page's dependency list — where the\n * same question is asked about a NEIGHBOR, and where the answer is the whole\n * point, because a dependency the workspace enables and this site has switched\n * off is a broken plugin behind a healthy-looking workspace page — and the\n * per-site switchboard. A second derivation is a second chance to disagree\n * with `resolveHostEnabledPlugins`, which is what actually runs.\n *\n * - `always-on` — the base library; it cannot be switched off anywhere.\n * A plugin that is on for every workspace but\n * switchable per site is NOT this: it reads\n * `runs-here` or `off-for-site` like any other.\n * - `off-for-workspace` — the org has it off, so it runs on none of its sites\n * and this site cannot turn it on.\n * - `runs-here` — the effective host set contains it.\n * - `awaiting-opt-in` — a `defaultOffPerSite` plugin the org enables and\n * this site has not asked for. Off, but off by\n * DEFAULT rather than by a decision, which is a\n * different thing to tell an operator.\n * - `off-for-site` — the org enables it and this site switched it off.\n */\nexport type PluginSiteState =\n | 'always-on'\n | 'off-for-workspace'\n | 'runs-here'\n | 'awaiting-opt-in'\n | 'off-for-site'\n\nexport function resolvePluginSiteState(\n org: { enabledPlugins?: string[] } | null | undefined,\n host: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null | undefined,\n pluginId: string,\n): PluginSiteState {\n if (ALWAYS_ON.includes(pluginId)) return 'always-on'\n if (!resolveEnabledPlugins(org).includes(pluginId)) return 'off-for-workspace'\n if (resolveHostEnabledPlugins(org, host).includes(pluginId)) return 'runs-here'\n // A default-off plugin this site has not named is off because nobody asked\n // for it; naming it in the deny-list is a decision, and an explicit deny\n // beats an opt-in, so the deny-list is checked first.\n const denied = Array.isArray(host?.disabledPlugins)\n ? host.disabledPlugins.map(String).includes(pluginId)\n : false\n return !denied && isDefaultOffPerSite(pluginId)\n ? 'awaiting-opt-in'\n : 'off-for-site'\n}\n\n/**\n * The declared dependency graph, both directions, built once (AGL-2486).\n *\n * `requires` on the catalog is the forward edge — what a plugin cannot run\n * without — and every consumer so far wanted the reverse one. Building both\n * here keeps the cascade, the console's dependency card and the server-side\n * refusal reading the SAME edges: a graph assembled a second time is a graph\n * that can disagree with itself about which plugins are coupled, and the\n * disagreement would show up as a warning shown on one surface and not on the\n * one that writes.\n *\n * `extraRequirements` extends the catalog and cannot shrink it, exactly as it\n * does for {@link resolveDisableCascade} — a third-party id can add edges of\n * its own but can never declare away a first-party one.\n */\nfunction dependencyEdges(\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): {\n /** Keyed by a required id; holds the ids that depend on it. */\n dependents: Map<string, string[]>\n /** Keyed by a dependent id; holds the ids it requires. */\n requires: Map<string, string[]>\n} {\n const dependents = new Map<string, string[]>()\n const requires = new Map<string, string[]>()\n const addEdge = (dependent: string, required: string) => {\n const reverse = dependents.get(required)\n if (reverse) reverse.push(dependent)\n else dependents.set(required, [dependent])\n const forward = requires.get(dependent)\n if (forward) forward.push(required)\n else requires.set(dependent, [required])\n }\n for (const plugin of FIRST_PARTY_PLUGINS)\n for (const required of plugin.requires ?? []) addEdge(plugin.id, required)\n for (const [dependent, required] of Object.entries(extraRequirements ?? {}))\n for (const one of required ?? []) addEdge(dependent, String(one))\n return { dependents, requires }\n}\n\n/** What `pluginId` declares it cannot run without — DIRECT edges only. */\nexport function pluginRequirements(\n pluginId: string,\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n return [...(dependencyEdges(extraRequirements).requires.get(pluginId) ?? [])]\n}\n\n/** What declares it cannot run without `pluginId` — DIRECT edges only. */\nexport function pluginDependents(\n pluginId: string,\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n return [...(dependencyEdges(extraRequirements).dependents.get(pluginId) ?? [])]\n}\n\n/**\n * Which of `enabledIds` would be left running with a requirement switched off\n * (AGL-2486) — the same question the cascade dialog asks, phrased so a WRITER\n * can refuse instead of a reader warning.\n *\n * The dialog is a courtesy: it stands in front of the console's own switches\n * and nothing else. A direct API call, a stale tab that posts an older set, or\n * a second console surface that forgot to ask can still store a set where\n * User Accounts is on and the Commerce bundle that answers its sign-in POST is\n * not. This is what a write path calls to reject that set outright, so the\n * boundary does not live in a dialog anyone can skip.\n *\n * Only DECLARED requirements are checked, with the same limit\n * {@link PLUGIN_CASCADE_IS_DECLARED_ONLY} states: an undeclared coupling is\n * invisible here too. A requirement naming an id outside the catalog is\n * ignored rather than treated as unmet — a stale edge must not lock a\n * workspace out of its own switchboard.\n */\nexport function strandedDependents(\n enabledIds: readonly string[],\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): Array<{ pluginId: string; missing: string[] }> {\n const enabled = new Set(enabledIds.map(String))\n const known = new Set<string>([\n ...FIRST_PARTY_PLUGINS.map((plugin) => plugin.id),\n ...enabled,\n ])\n const { requires } = dependencyEdges(extraRequirements)\n const stranded: Array<{ pluginId: string; missing: string[] }> = []\n for (const pluginId of enabled) {\n const missing = (requires.get(pluginId) ?? []).filter(\n (required) => known.has(required) && !enabled.has(required),\n )\n if (missing.length) stranded.push({ pluginId, missing })\n }\n return stranded\n}\n\n/**\n * Everything that must ALSO be switched off when `pluginId` is (AGL-2486) —\n * the transitive closure over reverse `requires` edges, restricted to what is\n * currently on.\n *\n * Pure and surface-agnostic: the org switchboard and the per-site card both\n * call it with their own \"currently enabled\" set, so the same graph answers\n * both, and the org's wider blast radius comes from the set it passes, not\n * from a second implementation.\n *\n * `extraRequirements` is how a marketplace listing joins the graph. Third-party\n * ids ride the same `enabledPlugins` field, so a listing whose manifest\n * declares `requires` is cascaded exactly like a bundle. It EXTENDS the\n * catalog graph and cannot shrink it — a listing cannot declare away a\n * first-party edge.\n *\n * ⚠️ The result is only ever as complete as what has been DECLARED. A\n * marketplace plugin that uses first-party components without saying so in its\n * manifest will not appear here, and callers must not present the list as\n * exhaustive. See `PLUGIN_CASCADE_IS_DECLARED_ONLY`.\n */\nexport function resolveDisableCascade(\n pluginId: string,\n enabledIds: readonly string[],\n extraRequirements?: Readonly<Record<string, readonly string[]>>,\n): string[] {\n const enabled = new Set(enabledIds.map(String))\n const dependents = dependencyEdges(extraRequirements).dependents\n\n // Breadth-first over reverse edges. `seen` is seeded with the origin so a\n // cycle back to it terminates and the plugin being disabled is never listed\n // among its own dependents.\n const seen = new Set<string>([pluginId])\n const cascade: string[] = []\n const queue: string[] = [pluginId]\n while (queue.length) {\n const current = queue.shift() as string\n for (const dependent of dependents.get(current) ?? []) {\n if (seen.has(dependent)) continue\n seen.add(dependent)\n // Walk THROUGH an already-off dependent — something may depend on it in\n // turn — but do not claim it is being turned off.\n if (enabled.has(dependent)) cascade.push(dependent)\n queue.push(dependent)\n }\n }\n return cascade\n}\n\n/**\n * Why the cascade list must never be presented as exhaustive (AGL-2486).\n *\n * Exported as copy rather than left to each caller to paraphrase: the whole\n * value of the warning rests on it being honest about its own limits, and two\n * surfaces wording that differently is how one of them ends up overclaiming.\n *\n * It says built-in ONLY, and that is the current truth rather than a hedge.\n * `PluginManifest` carries no `requires` field, so a publisher cannot declare\n * a dependency even if they want to, and `extraRequirements` above — the seam\n * that would carry them — is supplied by nothing outside tests. An earlier\n * draft said \"a plugin that uses this one WITHOUT declaring it cannot be\n * detected\", which quietly implied declaring was possible and would have made\n * this the exact overclaiming warning it exists to avoid. Adding the manifest\n * field is a separate change: it needs a publish form, validation and a line\n * on the install screen, or it is a field written by nobody and read by\n * nothing. Update this sentence in the same change, not before.\n */\nexport const PLUGIN_CASCADE_IS_DECLARED_ONLY =\n 'This covers built-in plugins only. A marketplace plugin has no way to ' +\n 'declare that it depends on another one yet, so none are listed here — ' +\n 'check any third-party plugins you rely on before continuing.'\n\n/** Reverse lookup: which first-party plugin a release flag gates, if any. */\nexport function pluginForReleaseFlag(\n flagKey: string,\n): FirstPartyPlugin | undefined {\n return FIRST_PARTY_PLUGINS.find((plugin) => plugin.releaseFlag === flagKey)\n}\n\n/**\n * Subtracts release-flagged-off plugins from an effective set (AGL-422).\n * Pure — the caller supplies the verdict source (client: the activated\n * Remote Config hook state; server: the cached admin-SDK template read),\n * so the same policy runs identically on every surface:\n *\n * - unknown ids (marketplace/realm installs) and always-on ids pass;\n * - a first-party id with a `releaseFlag` passes only when the flag is on\n * for the subject, or `staffBypass` is set (staff preview keeps working\n * while a feature is dark).\n */\nexport function filterPluginsByReleaseFlags(\n pluginIds: readonly string[],\n isFlagOn: (flagKey: string) => boolean,\n options?: { staffBypass?: boolean },\n): string[] {\n if (options?.staffBypass) return [...pluginIds]\n const catalog = new Map(\n FIRST_PARTY_PLUGINS.map((plugin) => [plugin.id, plugin]),\n )\n return pluginIds.filter((pluginId) => {\n const plugin = catalog.get(pluginId)\n if (!plugin?.releaseFlag || plugin.alwaysOn) return true\n return isFlagOn(plugin.releaseFlag)\n })\n}\n"],"names":["FIRST_PARTY_PLUGINS","PLUGIN_EDIT_BAR_LINKS","PUBLISHED_SITE_IMPACT","FIRST_PARTY_FUNCTION_BINDINGS","ACCOUNTS_PLUGIN_ID","FORMS_PLUGIN_ID","pluginEditBarLinks","enabledPluginIds","enabled","Set","filter","link","has","pluginId","DEFAULT_ENABLED_PLUGINS","map","plugin","id","canonicalPluginId","canonicalPluginIds","pluginIds","Array","from","String","ALWAYS_ON","alwaysOn","ALWAYS_ON_FOR_WORKSPACE","alwaysOnForWorkspace","isLockedOnForWorkspace","includes","isLockedOnForSite","FIRST_PARTY_IDS","DEFAULT_OFF_PER_SITE_PLUGIN_IDS","defaultOffPerSite","isDefaultOffPerSite","applyDefaultOffOptIn","optedIn","size","asked","isArray","isFirstPartyPlugin","classifyEnabledPlugins","bundles","listings","push","resolveEnabledPlugins","org","configured","enabledPlugins","subtractDisabledPlugins","disabledPlugins","length","disabled","resolveHostEnabledPlugins","host","isHostPluginEnabled","isPluginEnabled","resolvePluginSiteState","denied","dependencyEdges","extraRequirements","dependents","Map","requires","addEdge","dependent","required","reverse","get","set","forward","Object","entries","one","pluginRequirements","pluginDependents","strandedDependents","enabledIds","known","stranded","missing","resolveDisableCascade","seen","cascade","queue","current","shift","add","PLUGIN_CASCADE_IS_DECLARED_ONLY","pluginForReleaseFlag","flagKey","find","releaseFlag","filterPluginsByReleaseFlags","isFlagOn","options","staffBypass","catalog"],"mappings":"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"}
|